# Brentwood WordPress migration — decisions & deferred items

Durable record of choices made during the content import so nothing is silently lost.
Newest context at top. See `HANDOFF.md` diary for the running narrative.

## Migration target
- **Target site = `brentwooddev.demoing.info`** (Docker container `brentwooddev-wordpress`).
- `brentwoodwp.demoing.info` (the co-located `./wp`) is a **separate build** and is NOT the target.
  A ~80-page build was done there on 2026-06-03 that is unrelated — do not confuse the two.

## Schema is in code
- `wp-content/plugins/brentwood-migration/` registers all CPTs, taxonomies, and ACF field groups.
  Edit there (version-controlled), not via the ACF/CPT UI.
- Importer scripts: `/srv/apps/brentwood/migration/importers/` (kept out of web root).
  They read JSON regenerated from `brentwood-pages.xlsx`; each is idempotent (keyed on `_bw_src_*` meta,
  or a guard for inquiries/redirects).

## DEFERRED — confirm with client, data is retained, can add later

### Livestream fields not imported (CONFIRM WITH CLIENT)
- **"Will be recorded"** and **"Allowed Groups"** were intentionally NOT migrated.
  - Reason: per Rian, these features look unused since 2024 and may be dropped.
  - The data still exists in the `Livestream Migration` tab of `brentwood-pages.xlsx`.
  - If the client wants them: add ACF fields to the `livestream` group in `brentwood-migration` plugin
    and extend `import-livestream.php` to populate them. Allowed Groups = access control (would also need
    enforcement logic); Will-be-recorded = a simple boolean.

### Page fields intentionally skipped for now (data retained in the sheet)
When we build Pages, these `Migration Map` columns are being deferred until we decide how to handle them:
- **Title Area Type** (image/video/slider/other), **Footer Image URL**, **Footer Color**,
  **Locked**, **Exclude from Site Search**, **Special Permissions**.
- All values are still in `brentwood-pages.xlsx` → populate once the mechanism is decided. For H1 we plan
  to use the **bw-dev title override** custom field where H1 ≠ Page Name.

### Course PhotoBlocks (8 courses)
- 8 Laravel course pages have a `PhotoBlock` in addition to the description; only the description `body`
  was imported. If those images matter, fetch from `photo_blocks`/`photos` and append to the course body.
- 14 courses have no description body at all in Laravel (legitimately empty).

### Redirects needing a target (21)
- 21 Laravel redirects point to page-id + content-anchor targets (`/58#c-UUID`) with no WP equivalent yet.
- Imported into the Redirection plugin as **disabled**, titled `[NEEDS TARGET]`. List:
  `notes/redirects-needs-target.txt`. Map each to the correct WP page URL, then enable.

## Pages built (2026-06-03)
- 213 internal pages built with full parent/child hierarchy (URLs preserved), 12 landing pages at `/l/*`
  (`landing` CPT), `Home` set as the static front page. 11 external-link rows skipped (they're nav links,
  not pages) — listed in `notes/pages-external-links.txt`.
- **bw-dev title override** (`_bw_dev_title_override`) set on 131 pages where H1 ≠ Page Name. Confirmed
  rendering on the front end (Kadence child theme uses `the_title()` for the H1).
- **Deferred page fields** (still in the sheet, not yet applied): Title Area Type, Footer Image/Color,
  Locked, Exclude-from-search, Special Permissions. No page **body content** yet (later, likely manual).

## Redirect ↔ page conflict (resolved 2026-06-03)
- 9 Laravel section catch-all redirects (`^/arts/?(.*)$ → /arts`, `^/athletics/?(.*)$ → /athletics`, etc.,
  plus 2 self-referential loops `#45`, `#16`) were shadowing ~72 real new pages (Redirection fires before
  WP serves a page). **Disabled all 9** (titled `[DISABLED: shadowed real pages]`). Real pages now 200.
- **TODO when more content lands:** re-scan ALL active redirects against real URLs (pages done; re-check
  after blogs/menus) to catch any further shadowing.

## Blog pilot (2026-06-03) — 10 posts, awaiting review before full run
- **Decision:** blogs = NATIVE WP posts; permalink base changed to `/blogs/%postname%/`. Author = ACF
  `blog_author` (free-text; Laravel authors aren't WP users). Full 126-term category hierarchy built in
  the native `category` taxonomy.
- **Body assembly:** each blog's Laravel content elements (TextBlock + PhotoBlock) emitted in order —
  TextBlock HTML as a `wp:html` block, PhotoBlock photos as `wp:image` blocks with the image imported &
  attached to the post. Featured image from the sheet's per-post URL.
- **Review URLs (10):**
  - /blogs/halloweek/ · /blogs/hitting-the-ice-hitting-your-couch/ · /blogs/the-girls-are-rising/
  - /blogs/leading-with-his-heart/ · /blogs/einstein-and-picasso-go-into-a-bar/ · /blogs/fire-me-up-brentwood/
  - /blogs/development-of-the-brentwood-regatta/ · /blogs/fitfam-takes-on-third-term/ · /blogs/fridaysforfuture/
  - /blogs/island-champions-in-u18-club-volleyball-championships/  (text-only — no images resolved)
- Builder: `migration/build_blog_pilot.py`; importer: `migration/importers/import-blog-pilot.php`.

### Slug fidelity (leading/trailing/double dashes) — investigated 2026-06-03
- Live Laravel slug algo (`SlugTrait::getSlug`): `lower(name)` → spaces to `-` → strip everything except
  `[a-z0-9-]`. **No trimming, no collapsing.** So ` "Leading…"` (leading space + curly quote) → `-leading-…`.
- **WordPress cannot store** leading/trailing/double dashes — `sanitize_title` strips/collapses them on BOTH
  storage and incoming-request parsing. Forcing the dirty slug via DB → 404 (request gets normalized).
- **BUT the dirty live URLs still resolve (HTTP 200) to the clean-slug post** via WP's loose matching —
  verified for leading-dash, double-dash, trailing-dash. So old/indexed dirty URLs do NOT break.
- **Prevalence:** dash-dirty = 888 / 5,941 (14%): 473 trailing, 469 double, 19 leading. Plus ~208 (3%)
  with non-ASCII letters where Laravel STRIPS (café→caf) but WP/our-sheet TRANSLITERATE (café→cafe) — these
  can mismatch in letters (not just dashes), which loose-matching does NOT fix.
- **PLAN for full run:** generate `post_name` from Laravel's EXACT algorithm (not the sheet's pre-cleaned
  slug, not a transliterating slugify) so the stored slug == `sanitize_title(laravel_exact)` and the live
  URL resolves as closely as the platform allows. Then run a **verification sweep**: hit every blog's
  live-exact URL on the new site; any that don't 200 get a targeted 301. Canonical (Yoast) = clean URL.

### Gotchas to resolve before the FULL ~6,031-blog run
1. **Join key = slugified name** (Laravel blog version slugs are NULL). ~220 of 6,031 names are duplicates
   → ambiguous; pilot used a publish-date tiebreaker. Need a robust rule (or a name+date composite key).
2. **15 blogs have degenerate slugs** (`blogs/1`, empty) from blank/numeric Laravel titles — decide skip
   vs. manual.
3. **Scale:** ~6,000 posts + thousands of PhotoBlock images. Must batch (the pilot importer re-imports
   images each run); add resume/idempotency that doesn't duplicate attachments at scale.
4. **Featured vs. first photo:** featured image (sheet) is sometimes the same photo as the first
   in-body PhotoBlock → possible visual duplication; decide whether to de-dupe.
5. **TextBlock HTML** is wrapped as a `wp:html` block (renders fine, but it's one HTML block in the editor,
   not native paragraph blocks). Convert to native blocks later if the team wants cleaner editing.
6. **4 text blocks** have inline `<img>` pointing to dead legacy `brentwood.bc.ca/fileadmin` URLs — rare;
   handle/strip during full run.

## Behavioural notes
- Future-dated livestreams are force-published (WP otherwise sets them to `future`/scheduled and hides
  them). The event datetime lives in ACF `ls_start` and in `post_date`.
- Staff are `noindex` (Yoast) + excluded from native search — they're modal popups, no standalone pages.
- Inquiries: archived flag preserved as a GF field + `is_read`; entries kept active (NOT trashed, which GF
  would auto-purge after 30 days).

## Blog layout findings — recent-posts visual compare (2026-06-03)
Compared 10 most-recent posts (new WP vs live mirror) in the browser. Recent-post design pattern:
- **Lead PhotoBlock gallery is the HERO** — full-width, N columns (= `photo_blocks.columns`, seen 1/2/3),
  rendered **ABOVE the title**. Then an **offset content column** (red vertical accent on the left):
  title → date → **author** → body.
- Our migration emits `wp:gallery` blocks with the correct column count — **renders correctly (2-col & 3-col
  verified)**. Content fidelity is good; differences are PRESENTATION (theme), not data.

### Template/theme work needed (NOT migration-script work) — add to theme build
1. **Custom single-post (blog) template**: render the lead gallery (or featured image) as a full-width hero
   ABOVE the title; title/date/author/body in an offset column with the red accent line.
2. **Author byline**: theme must output ACF `blog_author` (live shows e.g. "Liza D, Mack '26"); default
   Kadence byline shows the WP user → currently blank ("By •"). Hide WP author, show ACF author.
3. **Offset content column + red accent line**: Brentwood blog styling (CSS).

### Migration-script items for the full run
- **Featured image vs lead gallery**: WP currently shows a separate featured-image hero AND the in-body
  gallery → possible double hero / duplicate image. Decide: (a) don't set a separate featured image and let
  the template promote the lead gallery, or (b) set featured = first gallery photo and have the template
  render featured-as-hero WITHOUT also repeating it in body. (Featured still useful for archive thumbnails.)
- **Category**: some recent posts have empty sheet Categories → show as "Uncategorized" (data, not layout).
- Gallery approach (columns from data) is validated → keep for the full run.

## "Above the title" / hero logic — AUDITED across all blogs (2026-06-03)
**Exact rule** (`resources/views/content.blade.php`): the blog title/author is injected right AFTER the
FIRST content element — but only if that first element is NOT a text-block. So:
- First element is a **media block** → that ONE element renders ABOVE the title (the hero).
- First element is a **text-block** → title is at the very top (no hero).
- Only the first element can be the hero (a "multi-column gallery above the title" = ONE PhotoBlock with N photos).

**Full audit of every blog's hero (first element):**
- PhotoBlock hero: 5,603 (94%) — of these: **single image 5,469 (97.6%)**, 2-col gallery ~56, 3-col ~68, empty 10.
- TextBlock first (no hero): 391 (6.6%). Empty blog: 37.
- **Element types used ANYWHERE in blogs: ONLY PhotoBlock + TextBlock.** No video/carousel/slideshow/embed
  in any blog (those exist in the system but are used in PAGES, not blogs).

**Design decision for blog hero ("featured media"):**
- 97.6% single image → use WordPress's **native Featured Image**; the blog single-template renders the
  featured image as the hero ABOVE the title.
- ~2.2% multi-image → an **ACF "Featured Gallery"** field (images + column count 2/3); template renders it
  above the title when present (else falls back to featured image).
- The hero is captured as featured media and is NOT duplicated in the body (fixes the double-hero).
- Carousel/Video featured types are NOT needed for blogs — defer them to the PAGES migration (pages do use
  embed-video / youtube-video / slideshow). Build Image + Gallery featured types now.

**Migration encoding:** detect the first content element; if PhotoBlock → set as featured (image or gallery
w/ columns), drop from body; if text-first → no featured. Remaining elements → body (text + photos).

## Body content -> proper Gutenberg blocks (2026-06-03)
Text bodies are now converted to NATIVE Gutenberg blocks (not a single `wp:html` blob):
- **Plain text (~91% of blog text-blocks):** paragraphs split on `<br><br>` (the real delimiter — there
  are NO double-newlines; `\r\n` collapse to spaces; single `<br>` kept as a line break) → `wp:paragraph`.
- **Structured (~9%, has `<p>`/list/table):** DOM-parsed → `wp:paragraph`, `wp:heading` (h1→h2, title owns
  h1), `wp:list` + `wp:list-item`, `wp:quote`, `wp:table`. Inline tags (`<a>/<strong>/<em>/<br>`) preserved;
  wrapper classes (Google-Docs `CDt4Ke` etc.) dropped for clean blocks. Dead legacy inline `<img>` → `wp:html`.
- Verified: `parse_blocks()` returns clean `core/*` blocks with no loose/invalid HTML on all 10; renders as
  separated paragraphs. Converter is in `migration/importers/import-blog-recent.php` (`bw_text_to_blocks`).
- **Encoding non-issue:** text is valid utf8mb4 (curly quotes = `E2 80 9C`); the `�` seen earlier was only
  the mysql CLI display charset. The base64 pipeline preserves correct UTF-8.

## Definitive blog content inventory + two pockets handled (2026-06-03)
Scanned ALL blogs before the big run (user asked to confirm we cover every block type):
- **Content-element types in blogs: ONLY PhotoBlock + TextBlock.** No quote / embed-video / youtube-video /
  slideshow / calendar / etc. element is used in any blog (those exist only in PAGES).
- **HTML inside text blocks:** paragraphs + inline links (571); **8 lists, 1 table**; ZERO blockquotes,
  iframes/videos/audio/embeds, headings, code, hr.
- **Pocket #1 — photo-block TEXT** (~8-12 blogs, incl. the monthly CAMPUS LIFE / ATHLETICS / ARTS roundups):
  article text lives in `photo_blocks.body`/`header` (the "photo-block-text" layout). Now CAPTURED → emitted
  as body blocks (header→`wp:heading`, body→paragraphs). Verified on 6072 (30 paragraphs that were being lost).
- **Pocket #2 — photo DESCRIPTIONS** (110 photos): now imported as image **captions** (`figcaption` +
  attachment `post_excerpt`). Verified on 5779 (2 captions).
- Converter (`migration/importers/import-blog-recent.php`) handles every type present PLUS defensively:
  heading, quote, list, table, image. `parse_blocks()` clean on all test posts.
- Builder `migration/build_blog_recent.py` accepts blog-ids as args to force-include specific posts for testing.

## EXHAUSTIVE tag/element verification (2026-06-03) — not a sample
- Content-element types: full GROUP BY over all blogs → only PhotoBlock + TextBlock.
- HTML tags: open-ended extraction over ALL 5,978 non-empty blog text bodies (no predefined list).
  COMPLETE tag set found: br(28948), p(4325), a(944), em(201), strong(195), li(43), ul(8), ol(1),
  s(1), table+colgroup/col/tbody/tr/td(1), sup(1). Nothing else exists.
  → Every tag is handled (paragraph/list/table/inline). Only 1 table in the whole archive.
- Not done: visual render of all ~6,000 (only ~13). Data-shape is fully verified; visual is "format later".

## Page hero reassessment (2026-06-03) — honest result
Re-derived each page's hero from its FIRST content element (objective). Distribution:
- image single 74 · no-hero/text 44 · empty 35 · **slider(Slideshow) 32** · image gallery 12 ·
  **video mp4(EmbedVideo) 7** · custom-template 6 · video YouTube 3 · 2-up 2 · plus Quote/StaffList/
  Calendar/CourseList/etc. Full list: notes/page-hero-derived.txt.
- **Cannot cleanly auto-compare to the human's "Title Area Type" labels:** page version slugs are NULL,
  the Migration Map uses FINAL/remapped slugs (e.g. /why-brentwood) vs Laravel's ORIGINAL paths
  (/about-brentwood), there are duplicate page names, and ~8 custom-template pages. Naive joins gave
  31% (by name) / 0% (by path) — both JOIN ARTIFACTS, not real accuracy.
- Spot-checks where they differ show the DB is often MORE correct: the human labeled Slideshows as
  "image-simple" (under-detected sliders). DB finds 32 sliders + 10 videos vs the human's firm 8 + 3.
- **Conclusion:** the DB first-element type is the best objective basis for page heroes (use it when we
  build pages), with manual review of the 6-8 custom-template pages + 35 "empty" pages. It is a better
  signal than the human's visual labels, not a validation of them.

## CORRECTION — page hero derivation is NOT reliable (2026-06-03)
Earlier entry overclaimed "32 sliders / verified". WRONG. After Rian flagged /academics:
- /academics first element IS a "Slideshow" element-type BUT has only 1 slide → renders as a SIMPLE IMAGE.
  My classifier mapped any Slideshow→slider without counting slides. Human label ("image-simple") was RIGHT.
- Fixed (Slideshow needs 2+ slides → slider): sliders 32→11. But accuracy vs human still only ~54%.
- SYSTEMATIC problems making DB page-hero derivation unreliable: (a) slideshow slide-count, (b) sort_order
  GAPS — many pages' first VISIBLE element is at sort 2/8 (earlier elements deleted), so "first element"
  isn't the hero; (c) empty pages (Clubs, Policies) that still render something live; (d) 8 custom templates.
- Where DB is clearly right: Strength & Conditioning = EmbedVideo = video (human said image — human wrong).
- Where human is right: /academics 1-slide slideshow = image (DB said slider — DB wrong).
- **CONCLUSION: neither source is fully reliable for page heroes.** When we build pages, determine heroes by
  VISUAL check of each rendered page (the human's method), using the DB only as a HINT for unambiguous cases
  (multi-slide slideshow, EmbedVideo/YouTube, multi-photo gallery) — e.g. to catch videos/sliders the human
  under-counted. Do NOT auto-trust the DB first-element type.

## DEFINITIVE page hero call (2026-06-03) — rendered-HTML sweep, visually validated
Method: fetch each page's REAL rendered HTML from the mirror app (172.17.0.1:3082, bypassing the gate),
read the first `content-element-{type}` (the hero), and disambiguate by render-truth: slideshow with a
`<slideshow>` Vue tag = real multi-slide SLIDER vs 1-slide = image; embed-video/youtube = video; photo-block
img-count = image/2-up/gallery. Reflects what ACTUALLY renders (immune to the deleted-element/sort-order bug).
Validated visually: academics=image(1-slide), athletics=video(spinner), philosophy=slider(13 dots),
strength-and-conditioning=video. Result for all 236 pages (notes/page-heroes-final.csv):
- image 86 · **video mp4 31 · video youtube 8 (=39 videos!)** · **slider 23** · text 12 · 2-up 1 · misc 5
- redirect 33 (no own hero) · 404 8 (anchors) · no-content/special 14 (login/forms) · **ERROR-500 14 (manual)**
Key: the human FIRMLY labeled only 3 videos + 8 sliders; the real counts are **39 videos + 23 sliders**.
Both the human's visual pass AND the naive DB query badly under-counted videos/sliders — a static screenshot
can't tell a buffering video or a 1st slide from a plain image. Use page-heroes-final.csv for the page build;
manually review the 14 ERROR-500 pages (they error on the mirror — check on live).

## FULL BLOG MIGRATION COMPLETE (2026-06-03)
- **6,016 blogs imported** (every published Laravel blog; 15 degenerate-title ones skipped), 0 failures,
  8,301 images. Laravel-DRIVEN by unique blog id (no sheet-match loss; the earlier 286 "unmatched" were
  HTML-entity sheet-name artifacts — gone).
- Exact-Laravel slugs (WP sanitizes; dirty live URLs like `/blogs/-leading-with-his-heart` still resolve
  200 via WP loose matching — verified, + 20/20 random URL sample all 200).
- Hero: 5,893 single-image (WP featured image) / 123 featured-gallery (ACF). Body = native Gutenberg blocks.
  Photo-block text captured, photo captions captured. Categories from Laravel tags (+parents), authors,
  dates (published_at w/ epoch fallback), Yoast meta from sheet.
- Builder `migration/build_blog_full.py`; importer `migration/importers/import-blog-full.php` (resumable).
  Staging dir removed after import.

## FINAL migrated content totals (brentwooddev.demoing.info)
- Blogs 6,016 · Staff 170 · Livestreams 289 · Courses 136 · Pages 205 · Landing 12 · Inquiry GF entries 1,155
- Page hero types: master Migration Map's `Title Area Type` filled with the rendered-HTML assessment
  (40 videos + 24 sliders found; 21 left "Needs Review" = mirror-500 pages, check on live).
