diff --git a/.agents/skills/migrate-strapi-content/SKILL.md b/.agents/skills/migrate-strapi-content/SKILL.md index 7e550cb..4a86f59 100644 --- a/.agents/skills/migrate-strapi-content/SKILL.md +++ b/.agents/skills/migrate-strapi-content/SKILL.md @@ -74,15 +74,19 @@ Everything else (target documentId, category documentIds, schema mapping) is dis Detect content type from the URL **path** prefix. The host doesn't matter — `strapi.io`, `website-ui-omega.vercel.app`, `localhost:3000` all resolve the same. -| URL path pattern | Old endpoint | New endpoint | Target content type | Match by | -| -------------------------------------------- | --------------------- | --------------------- | --------------------- | -------------------------------- | -| `/user-stories/` | `api/case-studies` | `api/case-studies` | case-study | slug | -| `/blog/` | `api/blog-posts` | `api/blog-posts` | blog-post | slug | -| `/news/` | `api/news-items` | `api/news-items` | news-item | slug | -| `/jobs/` | `api/internal-jobs` | `api/internal-jobs` | internal-job | slug | -| `/comparators/` or `/-vs-` | `api/cms-comparisons` | `api/cms-comparisons` | cms-comparison | slug | -| `/solutions/` | `api/use-cases` | `api/pages` | **page** (cross-type) | `fullPath` = `/solutions/` | -| `/` (top-level, no prefix above) | `api/universals` | `api/pages` | **page** (cross-type) | `fullPath` = `/` | +| URL path pattern | Old endpoint | New endpoint | Target content type | Match by | +| ----------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------- | --------------------- | -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/user-stories/` | `api/case-studies` | `api/case-studies` | case-study | slug | +| `/blog/` | `api/blog-posts` | `api/blog-posts` | blog-post | slug | +| `/news/` | `api/news-items` | `api/news-items` | news-item | slug | +| `/jobs/` | `api/internal-jobs` | `api/internal-jobs` | internal-job | slug | +| `/comparators/` or `/-vs-` or `/headless-cms/comparison/` | `api/comparators` | `api/cms-comparisons` | cms-comparison | slug | (source v4 collection is **`api/comparators`** — NOT `api/cms-comparisons`, which is the v5 target name. The comparison index `/headless-cms/comparison` itself is a `page`, see below.) | +| `/solutions/` | `api/use-cases` | `api/pages` | **page** (cross-type) | `fullPath` = `/solutions/` | +| `/features` and `/features/` | (CONFIRMED no per-slug v4 source — `api/feature` is a single-type covering only the `/features` index; `features`/`single-features`/`product-features`/`capabilities`/`feature-pages` all 404; `universals` has no fullPath and matches only slug `content-types-builder`, an unrelated legacy page) | `api/pages` | **page** (cross-type) | `fullPath` | Feature pages appear **v5-native**: the target `page` already carries draft content and there is no v4 source to read. → treat as **validate-only** (do NOT replace-overwrite); only migrate if a real v4 source record is located at runtime. | +| any other multi-segment path with no prefix above (e.g. `/headless-cms`, `/headless-cms/comparison`, list roots `/blog`, `/user-stories`) | `api/universals` (if a matching universal exists) | `api/pages` | **page** (cross-type) | `fullPath` = the exact path | List/index pages are `page` records (hero + frontend-auto-fetched lists). If no v4 universal matches the fullPath, validate-only. | +| `/` (top-level, no prefix above) | `api/universals` — **FALLBACK: if no universal matches the slug, probe a same-named single-type `api/`** (confirmed: `careers`→`api/career`, `community`→`api/community`; these hold `slices[]` directly, sometimes without a universal-style root hero) | `api/pages` | **page** (cross-type) | `fullPath` = `/` | + +**Before replace-migrating ANY page, check whether the target already has curated/v5-native content AND whether a real v4 source record exists.** If there is no locatable v4 source (e.g. feature pages, some list/index pages), do NOT wipe the target — switch to **validate-only** (Playwright-check the existing render, report, leave content intact). `replace` is only safe when a canonical v4 source exists to rebuild from. Cross-type rules: old `use-case` and `universal` records are collapsed into the new `page` collection. Lookup the target by `fullPath` (not `slug`), because multiple pages can share a slug across different parent paths. @@ -152,52 +156,175 @@ For each row: Do NOT query `populate: { content: { fields: [...] } }` — 500 error on Strapi Cloud. Use `populate: { content: true }` or omit populate. +### 5.5 Visual mapping pass — for new or ambiguous slice types + +Step 6 rules are schema-aware but **not visual-aware**. Two slices with the same name can render very differently per page; same target component can look right or wrong depending on variant/layout flags that no rule can pick without seeing the source. This step grounds the mapping decision in actual rendered visuals before any write. + +**Why this exists**: rule-driven migrations consistently produce data-correct but visually-divergent output. The fix isn't more rules — it's letting the agent SEE the source section and the candidate v5 component side-by-side, then choose. Skip this step only when the cache already has a decision for the exact slice type + shape. + +#### When to run + +For each batch, identify the set of `(source_uid, shape_hash)` pairs that appear. The `shape_hash` captures polymorphic variants — compute as the sorted list of populated top-level keys on the slice, joined with `+` (e.g. `description+features+title+upperTitle` vs `image+logos+upperTitle+whiteCards`). + +- **Run Step 5.5 for any (uid, shape) not in `visual-cache.json`** (sibling of this skill). +- **Skip for cached entries** — Step 6 reads the cached decision directly. + +#### Protocol — per uncached (uid, shape) + +1. **Capture source render**: + - Find a URL in the batch that has this slice + shape. Navigate via `mcp__plugin_playwright_playwright__browser_navigate`. + - Run `mcp__plugin_playwright_playwright__browser_evaluate` to locate the section. Match by the slice's resolved TITLE (per 6.0) or by the position of the slice in `slices[]` against H2 headings in the rendered DOM: + ```js + // Find the h2 / h3 / section by text from the slice's resolved TITLE + const titles = Array.from(document.querySelectorAll("h1, h2, h3")) + const match = titles.find((h) => h.textContent.includes("")) + match + ?.closest('section, [class*="Section"], [class*="section"]') + ?.getBoundingClientRect() + ``` + - Take a tight screenshot (`browser_take_screenshot` with `element` reference from the snapshot). Save to `<workspace>/visual-pass/source-<uid>-<shape>.png`. + +2. **Resolve default candidate**: + - Run the Step 6 rule for this `(uid, shape)`. Get the candidate v5 UID + variant/layout/imagePosition flags. + +3. **Capture candidate v5 render**: + - Navigate to `https://website-ui-omega.vercel.app/dev/component-library`. + - Find the section labeled with the candidate component's display name (read `info.displayName` from `apps/strapi/src/components/<group>/<name>.json` — e.g. `sections.feature-card-grid` → "FeatureCardGrid"; `sections.three-column-grid` → "ThreeColumnGrid"; `sections.cta-banner` → "CTA Banner"). + - The page has H2 headings per top-level component and H3 headings per variant. Use `browser_evaluate` to locate the candidate's H2 and the nearest H3 sub-variant that matches the planned flags (e.g. variant=bordered → "Bordered Stacked Card"; size=sm → "Content size: sm"). + - Take a tight screenshot of that variant. Save to `<workspace>/visual-pass/candidate-<uid>.png`. + +4. **Decide**: + - Compare the two screenshots. Three outcomes: + - **Close match**: accept the Step 6 default. Cache it. + - **Wrong variant**: same component family but different variant/layout. Browse other H3 variants of the same H2 component. Cache the corrected flags. + - **Wrong component**: the candidate is the wrong v5 component entirely. Scan the page's H2 list (use `browser_evaluate` to list all H2s and their y-positions) for a component whose visual matches better. Common second-guesses: + - `cards.feature-card` (no image) → `sections.section-header` or `cards.content-card` + - N×`cards.content-card` standalone → `sections.feature-card-grid` (with feature-card items mapped from content-card shape) + - `sections.feature-card-grid` plain → bordered variant or `sections.three-column-grid` + - **No good match**: emit `migration.data-sink` if allowed; else SKIP. Cache with `v5_component: "SKIP"` + reason. + +5. **Cache the decision** in `visual-cache.json`: + ```json + { + "<source_uid>:<shape_hash>": { + "v5_component": "sections.three-column-grid", + "variant": "bordered", + "layout": "third", + "image_position": null, + "decided_at": "2026-05-24", + "source_screenshot": "<workspace>/visual-pass/source-...", + "candidate_screenshot": "<workspace>/visual-pass/candidate-...", + "override_reason": "Step 6 default was feature-card-grid plain; visual shows bordered tiles with prominent layout — three-column-grid bordered matches the source much better." + } + } + ``` + +#### Cache invalidation + +The cache is a permanent record across batches. Reset by deleting `visual-cache.json` when: + +- The component library page changes substantially (new components, redesigned variants). +- A frontend redesign changes how a v5 component renders. +- A misclassification is discovered on a later page. + +Pass `--reset-visual-cache` (or just delete the file) before re-running a batch to re-evaluate everything. + +#### Budget + +~30s of Playwright work per uncached `(uid, shape)`. For a typical 50-URL batch with 10-15 unique slice shapes, ~5-8 minutes upfront. Per-URL overhead afterward = 0 (cache hits). Worth it: eliminates the entire class of visual-mismatch bugs at migration time. + ### 6. Slice → component mapping -The rules below expand `components-cheatsheet.csv` into runnable code-form. Prefer the real target component over `migration.data-sink` — only fall back to data-sink if **no real v5 component fits** and the target schema allows `migration.data-sink`. +The rules below expand `components-cheatsheet.csv` into runnable code-form. **Consult `visual-cache.json` first** — if there's a cached decision for the slice's `(uid, shape)`, use it verbatim and skip the rule. Step 6 is the fallback default when no cache exists. Prefer the real target component over `migration.data-sink` — only fall back to data-sink if **no real v5 component fits** and the target schema allows `migration.data-sink`. + +**Why these rules are forgiving**: v4 slice authors used inconsistent field names. Eyebrow text is `upperTitle` on most slices but `label` inside `intro` blocks. Repeatable items live under `features[]` on issues-header, `capabilityCards[]` on capability-cards, `whiteCards[]` on side-hero-with-image, `cards[]` on stacking-cards, `integrations.data[]` on integration-cards-grid. A rule that hardcodes one field name produces empty output when the slice uses a synonym. **Every rule below resolves fields through aliases first, then dispatches by shape.** When in doubt, look at the actual populated keys on a sample of the source data before mapping — see Step 8 populate for the shapes you need to fetch. + +#### 6.0 Universal field-resolution + +For every slice, resolve these concepts via ordered alias lookup (first non-empty wins): + +| Concept | Aliases (try in order) | +| ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| **LABEL** (eyebrow / kicker above title) | `upperTitle` → `label` → `eyebrow` → `kicker` → `intro.label` → `intro.upperTitle` | +| **TITLE** (main heading) | `title` → `intro.title` → `heading` → `headline` | +| **DESCRIPTION** (body / lead text) | `description` → `text` → `intro.text` → `intro.description` → `content` → `body` | +| **ITEMS** (repeatable cards/tiles) | `features` → `items` → `cards` → `capabilityCards` → `whiteCards` → `stats` → `companyStats` → `integrations.data` → `reviews.data` | +| **CTA** (single primary CTA) | `button` → `intro.button[0]` → `cta` → `ctaLink` → `link` | +| **CTAs** (multiple CTAs) | `buttons` → `ctaLinks` → `links` → `intro.button` | +| **IMAGE** | `image` → `cover` → `coverImage` → `media` → `picture` | +| **ICON** | `icon` → `iconImage` | +| **HERO_INTRO** (nested hero block) | `hero.intro` → `hero` → `intro` → root | + +When a rule says "from LABEL" it means run the LABEL resolver. When it says "items from ITEMS" it means run the ITEMS resolver. The rule body specifies which **shape** of v5 component to emit; the resolver picks the actual field name at runtime per source data. + +If all aliases for a required concept resolve to empty/null, the rule SKIPs the slice (or that fragment of a composite) and reports the reason. + +#### 6.1 Polymorphic shape dispatch + +Several v4 slice UIDs cover several different visual shapes (same UID, different populated fields). For these slices, detect what's actually present and pick the v5 component accordingly. This is more important than the per-slice tables below — when in doubt, treat the table as the default and 6.1 as the override. + +| Slice | Shape (detect by) | → v5 | +| ----------------------------- | ---------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `slices.side-hero-with-image` | `isHero === true` (it's the PAGE hero, not a body section) | `sections.hero` `{ label: upperTitle, title, description, image: <basic-image via find-before-upload>, ctas: button?[0]?.link ? [resolveLink] : [] }`. PREPEND. This branch wins over the body-section shapes below when `isHero` is set. | +| `slices.side-hero-with-image` | `whiteCards.length > 0` (stats grid) | `sections.three-column-grid` (itemStyle=default; items=`elements.how-it-works-item` each `{ title: card.title, description: card.text, icon: <upload card.icon> }`). Section from LABEL/TITLE/DESCRIPTION. | +| `slices.side-hero-with-image` | `logos.length > 0` (brand wall) | composite `sections.section-header` (LABEL/TITLE/DESCRIPTION) + `media.brand-logo-grid` (logos uploaded via routine) | +| `slices.side-hero-with-image` | `image` populated AND no whiteCards/logos | composite `sections.section-header` + `cards.feature-card` (variant=bordered, layout=full, imagePosition=right with the image, ctaLinks from CTAs) | +| `slices.side-hero-with-image` | `features.length > 0` only | composite `sections.section-header` + `sections.two-column-grid` (items=how-it-works-item from features) | +| `slices.side-hero-with-image` | none populated | `sections.section-header` only | +| `slices.text-slice` | has CTA (button on slice or `content.button`) | `sections.cta-banner` | +| `slices.text-slice` | `theme === "purple"` | `sections.section-header` (inner utilities.section-header.variant=purple) | +| `slices.text-slice` | otherwise | `sections.richtext` (markdown from LABEL/TITLE/DESCRIPTION) | +| `slices.intro` | first slice on page AND no root-level hero with a title | `sections.hero` | +| `slices.intro` | otherwise | `sections.section-header` | + +For each row in 6.1, the rule for that slice in the per-slice tables below points back here. **Rich text / text** -| Old | Target | Rule | -| ------------------------------ | ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `slices.universal-rich-text` | `sections.richtext` | `{ content: richText }`. SKIP if empty. | -| `slices.text-slice` | conditional (3-way) | Dispatch on slice fields: (a) if `content.button` (or top-level `button`) is present → `sections.cta-banner` with `section: utilities.section-header` from `{label, title, description: text, ctaLinks: [resolveLink(button)]}` and `background: "dark-inverse"`; (b) if `theme === "purple"` or equivalent → `sections.section-header` with the inner `utilities.section-header` carrying `variant: "purple"`; (c) otherwise → `sections.richtext` with markdown from `label` (bold), `title` (H2), `text`. SKIP if all of label/title/text/button are empty. | -| `slices.text-with-key-numbers` | SKIP | No v5 component currently fits the `{number, text}[]` shape cleanly. Report in `skipped` with reason `"awaiting key-numbers component"` so the user can build it and revisit. (Avoid the old richtext-bullets workaround — the user is intentionally retiring it.) | +| Old | Target | Rule | +| ------------------------------ | ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `slices.universal-rich-text` | `sections.richtext` | `{ content: richText }`. SKIP if empty. | +| `slices.text-slice` | conditional (see 6.1) | Shape dispatch from 6.1. **Field note:** title/text often nest under a `content` _component_ sub-field — resolve TITLE via `title → content.title → intro.title` and DESCRIPTION via `text → content.text → description → intro.text`, and deep-populate `content` per the Step 8 special case (else the slice reads empty and gets skipped). For the cta-banner branch: `section: utilities.section-header` from `{label: LABEL, title: TITLE, description: DESCRIPTION, ctaLinks: CTAs}`, background "dark-inverse". For the purple-theme branch: `sections.section-header` with inner `utilities.section-header.variant = "purple"`. **For a plain heading+lead block (TITLE+DESCRIPTION, `alignCenter`, no prose markdown): prefer `sections.section-header` (layout=center) over `sections.richtext`** — richtext is for prose paragraphs. Only use the richtext branch for actual markdown body. **CTA on a LIGHT block (recurs on case-studies — the "Enterprise Edition / Scale your Strapi project / Discover our plans" footer block):** when the text-slice carries a CTA (`content.button[0].link`) and renders as a light promo block, emit `sections.section-header` (background=light, layout=center) WITH `ctaLinks` from `content.button[0].link` — do NOT drop the CTA and do NOT use cta-banner (it only offers dark backgrounds). SKIP if LABEL/TITLE/DESCRIPTION/CTA all empty. | +| `slices.text-with-key-numbers` | `sections.three-column-grid` | INTERIM mapping (renders cleanly as a stats block, same as `company-stat-list`): `section` from intro LABEL/TITLE/DESCRIPTION; `items` = `keyNumber[]` each `{ title: n.number, description: n.text }`; `itemStyle: "default"`. Deep-populate `populate[slices][on][slices.text-with-key-numbers][populate][keyNumber][populate]=*`. SKIP only if both intro and keyNumber are empty. (Supersedes the old SKIP/"awaiting key-numbers component": three-column-grid keeps the stats VISIBLE and is NOT the retired richtext-bullets hack. Upgrade to a dedicated key-numbers component if one is later built — flagged to user.) | **Hero / intro** `slices.intro` is position-dependent because v4 conflated "page hero" and "section heading" into one slice. Use the position in `slices[]` plus the presence of a root-field hero to decide: -| Old | Target | Rule | -| ------------------ | -------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `slices.intro` | `sections.hero` OR `sections.section-header` | If this is the FIRST entry of `slices[]` AND the record has no root-field hero (no `useCaseHero` / `homeHero` / `whiteHero` / etc. with a title) → emit `sections.hero` from `content.{label, title, text, button}`. Otherwise → emit `sections.section-header` with `background: "none", boxed: false` and inner `utilities.section-header` from the same fields (layout=center). SKIP if no title in either case. (v4 `slices.intro` wraps fields inside `content`, not at the top level.) | -| `slices.new-intro` | `sections.section-header` | Heading-only block. `utilities.section-header` from `{label, title, description: text, ctaLinks: button ? [resolveLink(button)] : []}`. SKIP if no title. | +| Old | Target | Rule | +| ------------------ | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `slices.intro` | conditional (see 6.1) | Shape dispatch from 6.1. v4 wraps fields under `content.*`, so the resolver looks up TITLE/DESCRIPTION/CTA via `intro.*`/`content.*` automatically. For the hero branch: `sections.hero` with all fields. For the section-header branch: `sections.section-header` with inner `utilities.section-header` (layout=center). SKIP if no TITLE in either branch. | +| `slices.new-intro` | `sections.section-header` | Heading-only block. `utilities.section-header` from `{label: LABEL, title: TITLE, description: DESCRIPTION, ctaLinks: CTAs}`. SKIP if no TITLE. | **Root-level hero fields** (handled outside `slices[]`, always PREPENDED to newContent so the hero sits at the top of the page): -| Field | Target | Rule | -| ------------------------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `useCaseHero` (use-case records) | `sections.hero` | Build from `useCaseHero.hero.intro.{label, title, text, button}`. SKIP if no `useCaseHero.hero.intro.title`. **Unchanged.** | -| `homeHero` (home universal) | `sections.hero` | Build from `homeHero.{label, title, text, button}` (or nested `.hero.intro.*` — verify per record before mapping). | -| `whiteHero` (relevant universals) | `sections.hero` | Same as `homeHero`. Use `background: "light"` if the hero schema supports it; otherwise rely on default. | -| `careersHero` (careers universal) | `sections.hero` | Same fields. If `careersHero.image` (or `.coverImage`) is present, upload it via the media policy (`reuse-existing` by default) and attach. | -| `featuresHero` (relevant universals) | `sections.hero` | Same as `homeHero`. | -| `communityHero` (community universal) | `sections.hero` + `media.brand-logo-grid` | PREPEND `sections.hero` from `communityHero.hero.intro.{label, title, text, button}`. Then, if `communityHero.brandsWithIntro` is present, APPEND a `media.brand-logo-grid` built from its logos (see Brand logos below). | +| Field | Target | Rule | +| ------------------------------------- | ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `useCaseHero` (use-case records) | `sections.hero` | Build from `useCaseHero.hero.intro.{label, title, text, button, cliContent?, smallTextWithLink?, newsWithLink?}`. SKIP if no `useCaseHero.hero.intro.title`. | +| `homeHero` (home universal) | `sections.hero-home` OR PRESERVE existing | Path is **`homeHero.hero.intro.{theme, title, text, button, cliContent, smallTextWithLink, newsWithLink}`** (same nesting as useCaseHero). **When the target record already has a `sections.hero-home` at `content[0]`, preserve that component verbatim and DO NOT emit a new hero from this field** — the v5 hero-home schema is custom and has been hand-tuned. See Step 9 hero-preservation rule. When the target has no existing hero, build a `sections.hero` from `homeHero.hero.intro.*` as the fallback. | +| `whiteHero` (relevant universals) | `sections.hero` | Same nesting as homeHero (`.hero.intro.*`). | +| `careersHero` (careers universal) | `sections.hero` | Same nesting. If `careersHero.image` (or `.coverImage`) is present, upload it via the media policy and attach. | +| `featuresHero` (relevant universals) | `sections.hero` | Same nesting. | +| `communityHero` (community universal) | `sections.hero` + `media.brand-logo-grid` | PREPEND `sections.hero` from `communityHero.hero.intro.{label, title, text, button}`. Then, if `communityHero.brandsWithIntro` is present, APPEND a `media.brand-logo-grid` built from its logos. | Always populate this set in the explicit populate spec (Step 8) for the relevant content types — `populate=*` won't reach into `useCaseHero.hero.intro` etc. +**CTA field name gotcha:** all of these map to `sections.hero` (or `sections.hero-home`), whose repeatable CTA field is **`ctas`** (`utilities.link`) — NOT `ctaLinks` like `section-header`/`feature-card`/`cta-banner`. Map source `intro.button[0].link` → `ctas: [resolveLink(button[0].link)]`. Hero scalar mapping: `label→label`, `title→title`, `text→description` (richtext). Using `ctaLinks` on the hero silently drops the CTA (Strapi ignores unknown keys — no error). + +**`resolveLink()` output shape (applies to EVERY `ctas`/`ctaLinks` entry skill-wide, not just heroes):** the v5 `utilities.link` schema is `{ type: "external"|"page", label: string, href: string, newTab: boolean }` — there is **NO `text` and NO `target` field**. Map the v4 `link` component: `link.text → label`, `link.target` (`"_self"→false`, `"_blank"→true`) `→ newTab`, `link.href → href`, and set `type: "external"` for absolute URLs and internal paths. Sending `text`/`target` fails the PUT with `400 Invalid key target`. + **Cards (single tile, no grid wrapper)** The v5 `cards.feature-card` `layout` enum is `full | half | third`. CSV terminology "split image right/left" means the visual style where the card has text on one side and image on the other — that's `layout: "full"` with `imagePosition` set. There is no `split` layout value. -| Old | Target | Rule | -| ------------------------------------- | ----------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `slices.section-with-image` | `cards.feature-card` | `{ title, description: text, imagePosition: textPosition==="left"?"right":"left", variant: "bordered", size: "default", layout: "full", ctaLinks: button?.link ? [resolveLink(button)] : [] }`. **Note the inversion**: v4 `textPosition` = where text is; v5 `imagePosition` = where image is. Flip it. SKIP if no title. | -| `slices.text-next-to-image` | `cards.feature-card` | Same as section-with-image. `{ title: title \|\| content?.title, description: text \|\| content?.text, imagePosition: textPosition==="left"?"right":"left", variant: "bordered", layout: "full", ctaLinks: (content?.button \|\| []).map(resolveLink) }`. SKIP if no title. | -| `slices.text-next-to-big-image` | `cards.feature-card` | `{ title, description: text, imagePosition: "right", variant: "bordered", layout: "full", ctaLinks: [...] }`. Single card, image right. SKIP if no title. | -| `slices.simple-text-next-to-image` | `cards.feature-card` | `{ title, description: text, imagePosition: "left", variant: "bordered", layout: "full", ctaLinks: [...] }`. Single card, image LEFT. SKIP if no title. | -| `slices.text-with-image-and-gradient` | `cards.feature-card` | `{ title, description: text, imagePosition: "right", variant: "bordered", layout: "full", ctaLinks: [...] }`. The v4 `DownloadLink` (or `button.download`) becomes a ctaLink — preserve its `href` and `label`. SKIP if no title. | -| `slices.side-hero-with-image` | composite: `sections.section-header` + `cards.feature-card` | Emit `sections.section-header` first (from intro/label/title/text), then `cards.feature-card` (variant=bordered, layout=full, imagePosition=right) carrying the main content. SKIP whichever fragment is empty; if both are empty, SKIP the whole slice. | +| Old | Target | Rule | +| ------------------------------------- | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `slices.section-with-image` | `cards.feature-card` | `{ title, description: text, imagePosition: textPosition==="left"?"right":"left", variant: "bordered", size: "default", layout: "full", ctaLinks: button?.link ? [resolveLink(button)] : [] }`. **Note the inversion**: v4 `textPosition` = where text is; v5 `imagePosition` = where image is. Flip it. SKIP if no title. | +| `slices.text-next-to-image` | `cards.feature-card` | Same as section-with-image. `{ title: title \|\| content?.title, description: text \|\| content?.text, imagePosition: textPosition==="left"?"right":"left", variant: "bordered", layout: "full", ctaLinks: (content?.button \|\| []).map(resolveLink) }`. SKIP if no title. | +| `slices.text-next-to-big-image` | `cards.feature-card` | `{ title, description: text, imagePosition: "right", variant: "bordered", layout: "full", ctaLinks: [...] }`. Single card, image right. SKIP if no title. | +| `slices.simple-text-next-to-image` | `cards.feature-card` | `{ title, description: text, imagePosition: "left", variant: "bordered", layout: "full", ctaLinks: [...] }`. Single card, image LEFT. SKIP if no title. | +| `slices.text-with-image-and-gradient` | `cards.feature-card` | `{ title, description: text, imagePosition: "right", variant: "bordered", layout: "full", ctaLinks: [...] }`. The v4 `DownloadLink` (or `button.download`) becomes a ctaLink — preserve its `href` and `label`. SKIP if no title. | +| `slices.side-hero-with-image` | polymorphic (see 6.1) | Shape dispatch by populated arrays: `whiteCards` → three-column-grid of stats; `logos` → section-header + brand-logo-grid; `image` only → section-header + feature-card; `features` only → section-header + two-column-grid; otherwise → section-header only. See 6.1 for the exact mapping per shape. SKIP if no shape fires AND LABEL/TITLE/DESCRIPTION all empty. | **Grids of features / cards** @@ -208,26 +335,28 @@ Two patterns here: `sections.feature-card-grid.items` is required and must be `cards.feature-card` — content-cards cannot be grid items. If the design calls for content-cards, emit them as N standalone `cards.content-card` entries in the dynamic zone (allowed by the page schema). +**⚠️ GRID ITEM FLAGS (recurring mistake — get this right):** every `cards.feature-card` INSIDE a `feature-card-grid` uses `layout: "third"` (3-column) or `"half"` (2-column) — **NEVER `layout: "full"`** and **NEVER `imagePosition`**. `full`/`imagePosition` are ONLY for a SINGLE standalone `cards.feature-card` (the `section-with-image` image-split family), not grid items — using them makes each tile a full-width bordered box (wrong). For **plain icon/title/text tile grids** (`slices.features-slice`, `slices.features-grid`, multi-column feature lists like financial-services' "Any channel, any device") use **`variant: "plain"`** and put the source per-card `icon` in the feature-card `icon` field (not `image`). Reserve `variant: "bordered"` for grids that are genuinely bordered by design (`integration-cards-grid`, `getting-started-grid`, `features-card`). When unsure, a multi-column tile grid defaults to `variant: plain, layout: third, no imagePosition`. **BENTO EXCEPTION:** a curated **bento** feature-card-grid may DELIBERATELY mix `full`/`half`/`third` for visual variety (wide hero card + smaller cards) and render well — if the target already has a deliberate mix that renders as a bento, PRESERVE it. Only "fix" `full` when it's applied UNIFORMLY to what should be a plain equal-tile grid (the actual error). Verify visually before flattening. **`imagePosition` is INERT on image-less tiles — don't chase it:** the enum is `left|right` with `default:"right"` and NO null, so Strapi re-applies `"right"` on every write even if omitted. The frontend gates image rendering on `hasImage` (`image != null`), so on a `variant:plain` tile with `image:null` the residual `imagePosition:"right"` has zero visual effect. The load-bearing flags are `variant` + `layout` + presence-of-image; a persisting `imagePosition:"right"` on a plain image-less tile is NOT a defect. + `sections.two-column-grid.section` is REQUIRED — always populate it. Items are `elements.how-it-works-item` (icon + title + description); the `icon` field is optional and matches v4 `feature.icon` when present. -| Old | Target | Rule | -| ------------------------------- | --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `slices.top-features` | `sections.two-column-grid` | Single component. `section: utilities.section-header` from `intro` (or `{ title: "Features" }` as fallback — REQUIRED). `items` = `features[]` filtered by `title`, each `{ title: feature.title, description: feature.description, icon: feature.icon ? { media: <uploaded>, alt: feature.icon.alt } : undefined }`. SKIP entire slice if no items have title. (Feature `.links` are dropped — how-it-works-item has no ctaLinks. If preserving links is critical for a given migration, the caller can explicitly opt into `sections.feature-card-grid` instead.) | -| `slices.large-features-slice` | `sections.two-column-grid` | Same shape as top-features. Single component. | -| `slices.features-slice` | `sections.feature-card-grid` | `section: s.title ? { title: s.title, layout: "center" } : undefined`; `items` = `cards[]` filtered by `title`, `layout: s.layout==="two"?"half":"third"`, `variant: "plain"`. SKIP if empty. | -| `slices.stacking-cards` | composite: `sections.section-header` + N × `cards.content-card` | Emit `sections.section-header` from `s.title/label/intro` (skip if empty). Then for each `card` in `cards[]` filtered by title, emit one `cards.content-card` directly into the dynamic zone with `{ label: card.label, title: card.title, content: card.description \|\| card.text }` (content is required — SKIP cards with no body). | -| `slices.text-with-cards` | `sections.feature-card-grid` | Single component. `section` from `title/intro`. `items` = `cards[]` filtered by title, each `{ title, description, icon: card.icon ? <upload> : undefined, variant: "plain", size: "sm", layout: "third" }`. SKIP if empty. | -| `slices.features-card` | `sections.feature-card-grid` | Single component on light background. `section` from intro if present. `items` = `cards[]` filtered by title, each `{ title, description, imagePosition: "right", variant: "bordered", layout: "third" }`. SKIP if empty. | -| `slices.getting-started-grid` | `sections.feature-card-grid` | `section` from intro/title if present. `items` = `cards[]` (or `features[]` — whichever the slice uses) filtered by title, each `{ title, description, variant: "bordered", layout: "third" }`. SKIP if empty. | -| `slices.integration-cards-grid` | `sections.feature-card-grid` | `section` from `intro` if present; `items` = `integrations.data[]` filtered by `attributes.title`, cap at 12, each `{ title: attributes.title, description: attributes.description, ctaLinks: [{ type:"external", label:"Learn more", href:"/integrations/"+attributes.slug, newTab:false }] }`. SKIP if empty. **Unchanged.** | -| `slices.company-stat-list` | `sections.three-column-grid` | Single component. `section: utilities.section-header` REQUIRED — fall back to `{ title: "Company Stats" }` if slice intro is empty. `items` = `stats[]` (or `companyStats[]` — whichever the slice uses) filtered by having any text, each `{ title: stat.value \|\| stat.number, description: stat.label \|\| stat.text }`. `itemStyle: "default"`. SKIP if no items survive. **Note**: on the Careers content type this may also appear as a root field — populate accordingly in Step 8 if so. | -| `slices.issues-header` | `sections.three-column-grid` | Single component, `itemStyle: "bordered"`. `section` REQUIRED — fall back to slice intro/title, then `{ title: "Issues" }`. `items` = `issues[]` (or `items[]`) filtered by title, each `{ title: issue.title, description: issue.description }`. SKIP if no items survive. | +| Old | Target | Rule | +| ------------------------------- | --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `slices.top-features` | `sections.two-column-grid` | Single component. `section: utilities.section-header` REQUIRED — `{label: LABEL, title: TITLE, description: DESCRIPTION}` with fallback `{ title: "Features" }`. `items` from ITEMS (typically `features[]`) filtered by title, each `{ title: f.title, description: f.description ?? f.text, icon: f.icon ? <upload> : undefined }`. SKIP if no items. Feature `.links` are dropped — how-it-works-item has no ctaLinks. | +| `slices.large-features-slice` | `sections.two-column-grid` | Same shape as top-features. | +| `slices.features-slice` | `sections.feature-card-grid` | `section` from LABEL/TITLE/DESCRIPTION. `items` from ITEMS (typically `cards[]`) filtered by title, `layout: s.layout==="two"?"half":"third"`, `variant: "plain"`. SKIP if empty. | +| `slices.stacking-cards` | composite: `sections.section-header` + N × `cards.content-card` | Emit `sections.section-header` from LABEL/TITLE/DESCRIPTION (skip header if empty). Then for each `card` in ITEMS (try `cards[]`, `items[]`) filtered by title, emit one `cards.content-card` with `{ label: card.label, title: card.title, content: card.description ?? card.text }` (content REQUIRED — SKIP cards with no body). | +| `slices.text-with-cards` | `sections.feature-card-grid` | Single component. `section` from LABEL/TITLE/DESCRIPTION. `items` from ITEMS filtered by title, each `{ title, description, icon: c.icon ? <upload> : undefined, variant: "plain", size: "sm", layout: "third" }`. SKIP if empty. | +| `slices.features-card` | `sections.feature-card-grid` | Single component on light background. `section` from LABEL/TITLE/DESCRIPTION. `items` from ITEMS filtered by title, each `{ title, description, imagePosition: "right", variant: "bordered", layout: "third" }`. SKIP if empty. | +| `slices.getting-started-grid` | `sections.feature-card-grid` | `section` from LABEL/TITLE/DESCRIPTION. `items` from ITEMS (try `cards[]`, `features[]`) filtered by title, each `{ title, description, variant: "bordered", layout: "third" }`. SKIP if empty. | +| `slices.integration-cards-grid` | `sections.feature-card-grid` | `section` from `intro.{label, title, text}` (LABEL/TITLE/DESCRIPTION will resolve to intro.\* per the alias list). `items` = `integrations.data[]` filtered by `attributes.title`, cap at 12, each `{ title: attributes.title, description: attributes.description, ctaLinks: [{ type:"external", label: slice.buttonText ?? "Learn more", href:"/integrations/"+attributes.slug, newTab:false }] }`. SKIP if empty. | +| `slices.company-stat-list` | `sections.three-column-grid` | Single component. `section` REQUIRED — from LABEL/TITLE/DESCRIPTION; fall back to `{ title: "Company Stats" }`. `items` from ITEMS (try `stats`, `companyStats`, `features`), each `{ title: stat.value ?? stat.number ?? stat.title, description: stat.label ?? stat.text }`. `itemStyle: "default"`. SKIP if no items. May also appear as root field on careers content type. | +| `slices.issues-header` | `sections.three-column-grid` | Single component, `itemStyle: "bordered"`. `section` REQUIRED — from LABEL/TITLE/DESCRIPTION; fall back to `{ title: "Issues" }`. `items` from ITEMS (on this slice typically `features[]`) filtered by title, each `{ title: f.title, description: f.text ?? f.description }`. SKIP if no items. | **Tabbed content** -| Old | Target | Rule | -| ----------------------------------- | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `slices.capabilities-dynamic-cards` | `sections.tabbed-feature-overview` | `section: utilities.section-header` REQUIRED — from slice intro/title (fall back to `{ title: "Capabilities" }`). `tabs[]` = `capabilities[]` (or `cards[]`) mapped to `elements.tabbed-feature` each: `{ tabLabel: capability.label \|\| capability.title (REQUIRED — SKIP tab if absent), tabIcon: capability.icon ? <upload> : undefined, content: sections.feature-overview { label: capability.label, title: capability.title (REQUIRED), description: capability.description, ctaLinks: (capability.button \|\| []).map(resolveLink), image: <upload — REQUIRED, SKIP tab if no image survives>, items: (capability.features \|\| []).map(f => ({ title: f.title, description: f.description, icon: f.icon ? <upload> : undefined })) } }`. SKIP whole slice if no tabs survive. Image uploads use the same find-before-upload routine from Step 6 brand logos. | +| Old | Target | Rule | +| ------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `slices.capability-cards` | `sections.tabbed-feature-overview` | `section: utilities.section-header` REQUIRED — from LABEL/TITLE/DESCRIPTION on the slice (fall back to `{ title: "Capabilities" }`). `tabs[]` = ITEMS (on this slice typically `capabilityCards[]`) mapped to `elements.tabbed-feature` each: `{ tabLabel: c.upperTitle ?? c.label ?? c.title (REQUIRED — SKIP tab if absent), tabIcon: c.icon ? <upload> : undefined, content: sections.feature-overview { label: c.upperTitle ?? c.label, title: c.title (REQUIRED), description: c.text ?? c.description, ctaLinks: c.button ? [resolveLink(c.button)] : [], image: <upload c.image — REQUIRED, SKIP tab if no image survives>, items: (c.features ?? []).map(f => ({ title: f.title, description: f.text ?? f.description, icon: f.icon ? <upload> : undefined })) } }`. SKIP whole slice if no tabs survive. Image uploads use the find-before-upload routine. | **Auto-fetched lists** @@ -239,9 +368,9 @@ These components have empty schemas — the frontend auto-fetches data. Migratio **Reviews** -| Old | Target | Rule | -| ----------------------- | ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `slices.reviews-slider` | `sections.reviews` | `{ title: slice.title \|\| "What customers say" (REQUIRED — must be non-empty), subTitle: slice.label \|\| slice.subTitle, description: slice.text \|\| slice.description, reviews: <resolved relation ids> }`. To resolve the relation: take old `slice.reviews.data[]`, look up each on the target server: `api/reviews?filters[author][$eq]=<author>&fields=id,author`. Collect matching documentIds. If zero matches, leave `reviews` empty and add a `manual_followup` entry so reviews can be linked by hand. Component still emits as long as title resolves. | +| Old | Target | Rule | +| ----------------------- | --------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `slices.reviews-slider` | `sections.reviews` (preferred) OR N × `testimonials.quote` (fallback) | The v4 slice has reviews INLINE: each `reviews.data[*].attributes` has `author.{name, description, image}` and `text`. **First try the new component**: emit `sections.reviews` with `{ title: TITLE ?? "What customers say" (REQUIRED), subTitle: LABEL, description: DESCRIPTION, reviews: <resolved relation ids> }`. Resolve `reviews` relation by looking up each `author.name` against `api/reviews?filters[author][$eq]=<name>&fields=id,author` on the target server. If at least one resolves, emit `sections.reviews`. **If NO author names resolve to existing target review records** (the typical case when the target review collection isn't seeded), emit nothing for `sections.reviews` and instead **fall back to emitting N × `testimonials.quote` directly into the dynamic zone** using the inline data: `{ quote: r.text, authorName: r.author.name, authorRole: r.author.description ?? r.role ?? "", variant: "boxed" }`. Either branch preserves the content. Report the choice in the per-page log. | **Section header / heading-only blocks** @@ -287,13 +416,18 @@ Brand-logo slices used to be SKIPPED to avoid media uploads. That non-goal has b ``` for each logo in source.logos[]: url = logo.image.data.attributes.url (absolute on old CDN if it starts with http; otherwise prefix with old CDN host) - name = derive filename from url (e.g. "rocket.svg" → "rocket") + stem = FULL filename without extension, hash included (e.g. "rocket_5210f84d20.svg" → "rocket_5210f84d20") altText = logo.image.data.attributes.alternativeText ?? logo.name ?? "" # find-before-upload (dedupe by filename) - existing = GET /api/upload/files?filters[name][$containsi]=<name> on target_server - if existing.length: - mediaId = existing[0].id + # Use the FULL stem (with hash), NOT a truncated prefix — a short stem like + # "Content_bloc" $containsi-matches the wrong file (e.g. "Content_Blocks_Blog.gif"). + existing = GET /api/upload/files?filters[name][$containsi]=<stem> on target_server + exact = existing.filter(f => f.name without extension === stem) # prefer exact stem match + if exact.length: + mediaId = max(exact, by id).id + elif existing.length: + mediaId = max(existing, by id).id # last-resort: most recent contains-match else: downloaded = curl -L <url> → temp file mediaId = mcp__strapi-local__strapi_upload_media({ @@ -322,9 +456,9 @@ If a single logo fails to upload (network/permission), keep the item out of the **Quotes** | Old | Target | Rule | -| ------------------------- | -------------------- | --------------------------------------------------------------------------------------------------------------------------------- | +| ------------------------- | -------------------- | --------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `slices.quote` | `testimonials.quote` | `{ quote, authorName: author?.name \|\| "Strapi", authorRole: author?.description \|\| "", variant: "boxed" }`. SKIP if no quote. | -| `slices.full-width-quote` | `testimonials.quote` | Same fields, `variant: "fullwidth"`. SKIP if no quote. | +| `slices.full-width-quote` | `testimonials.quote` | Same fields. \*\*`testimonials.quote.variant` enum is `boxed | image`ONLY — there is NO`fullwidth`** (sending it fails validation). Use `variant: "boxed"`(or`"image"` if the source has an author image to feature). SKIP if no quote. | **FAQ / interview** @@ -336,9 +470,9 @@ The interview slice now gets a dark/boxed wrapper around the Q&A list. We keep ` **Case study reference** -| Old | Target | Rule | -| ------------------------ | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `slices.case-study-card` | `cards.case-study-card` | `slug = card?.data?.attributes?.slug`. Look up on **target** server: `api/case-studies?filters[slug][$eq]=<slug>&fields=companyName,title,slug`. If found: `{ companyName, title, ctaLink: { type:"external", label: buttonText \|\| "Read story", href: "/user-stories/"+slug, newTab: false } }`. SKIP if slug missing or target not found. **Unchanged.** | +| Old | Target | Rule | +| ------------------------ | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `slices.case-study-card` | `cards.case-study-card` | The v5 component is a **relation-only** wrapper — schema is just `{ caseStudy: relation to api::case-study.case-study }` (companyName/title/cta come from the related record itself when the frontend renders). `slug = card?.data?.attributes?.slug`. Look up on **target** server: `api/case-studies?filters[slug][$eq]=<slug>&fields=slug`. If found, emit `{ caseStudy: <documentId> }` (pass the documentId string, not an object). SKIP if slug missing or target case study not found. **Do NOT send `companyName`, `title`, or `ctaLink` — those fields don't exist on the v5 schema and Strapi will reject the PUT.** | **Algorithmic / always-skip** @@ -361,7 +495,7 @@ Required-field validation: ### 7. Content-length validation - Trim `seo.metaTitle` to max 60 chars (word-boundary). -- Trim `seo.metaDescription` to max 160 chars. +- Trim `seo.metaDescription` to max 160 chars. **It also has a MIN length of 50 chars on the v5 `shared.seo` schema** — a too-short value passes a draft PUT but FAILS the publish PUT (publish validation is stricter). If the source/target metaDescription is <50, extend it faithfully to ≥50 so the record can publish. (`metaTitle` max 60.) - Check other string/text fields against `maxLength` if present in schema. - Omit `metaImage` and `metaSocial` (v4 shapes that don't map cleanly). @@ -373,9 +507,12 @@ Strapi v4 `populate=*` only goes one level deep. `populate=deep,N` sometimes wor ``` populate: { - slices: { populate: "*" }, + // "*" alone is NOT enough — it stops at the slice's own fields, so component + // sub-fields (image→media, button→link) come back as empty shells {} / {theme}. + // __all__:"*" keeps every slice field, then we deep-populate the common nested ones. + slices: { populate: { __all__: "*", image: { populate: "*" }, button: { populate: "*" }, intro: { populate: "*" } } }, // include only the root-hero fields that exist on the content type being fetched - useCaseHero: { populate: { hero: { populate: { intro: { populate: "*" } } } } }, + useCaseHero: { populate: { hero: { populate: { intro: { populate: { __all__: "*", button: { populate: "*" }, smallTextWithLink: { populate: "*" }, newsWithLink: { populate: "*" } } } } } } }, // intro:{populate:"*"} stops at button as [{theme}] WITHOUT link → deep-populate button/smallTextWithLink/newsWithLink to reach .link.{href,text,target}. homeHero/whiteHero/featuresHero share this and need the same when those types are migrated. homeHero: { populate: "*" }, whiteHero: { populate: "*" }, careersHero: { populate: { image: true, button: { populate: "*" } } }, @@ -385,17 +522,37 @@ populate: { } ``` -Verify by spot-checking one record in the batch: `slices[].cards[].title`, `slices[].features[].title`, `slices[].integrations.data[].attributes.title`, `useCaseHero.hero.intro.title`, and the relevant `<x>Hero.title` (or `.hero.intro.title`) must all be present when the source has them. If they come back empty or missing, the populate spec is wrong — fix before launching agents. +Verify by spot-checking one record in the batch: `slices[].cards[].title`, `slices[].features[].title`, `slices[].integrations.data[].attributes.title`, `useCaseHero.hero.intro.title`, `useCaseHero.hero.intro.button[0].link.href` (hero CTA), and the relevant `<x>Hero.title` (or `.hero.intro.title`) must all be present when the source has them. **Also confirm nested component sub-fields resolve, not just top-level keys**: on any card slice, `image.media.data.attributes.url` and `button.link.href` must be present when the source renders an image/CTA. A slice that shows `image: {}` or `button: {"theme":"link"}` is a SHALLOW-POPULATE symptom — the data exists, your spec just didn't reach it. If they come back empty or missing, the populate spec is wrong — fix before launching agents. (This was a real bug: `section-with-image` cards migrated image-less and CTA-less because `populate:"*"` never reached `image.media`/`button.link`. SAME BUG on the use-case hero: `useCaseHero` used `intro:{populate:"*"}`, which returned `button:[{theme}]` WITHOUT `link`, so the hero "Get Started" CTA was silently dropped on every use-case page; the spec now deep-populates `intro.button`/`smallTextWithLink`/`newsWithLink`. The hero `button` is an ARRAY — resolve the CTA from `button[0].link`.) -**Special case — `slices.capabilities-dynamic-cards`**: each capability nests `image`, `icon`, `features[]`, and `button[]` underneath. `slices: { populate: "*" }` reaches the capabilities but stops there. For batches that include capabilities-dynamic-cards, swap to: +**Special case — image+button card slices** (`section-with-image`, `text-next-to-image`, `simple-text-next-to-image`, `text-next-to-big-image`, `text-with-image-and-gradient`, `side-hero-with-image`): each wraps its picture in an `image` _component_ (`image.media` is the actual media relation) and its CTA in a `button` _component_ (`button.link.{href,text,target}`). The default spec above deep-populates `image` and `button`, but verify `slices[].image.media.data.attributes.url` and `slices[].button.link.href` resolve on a sample. When mapping → `cards.feature-card`, emit BOTH: `image` = `utilities.basic-image` `{media: <deduped-id>, alt, width, height}` (via find-before-upload) and `ctaLinks` = `[utilities.link from button.link]`. Dropping either is the most visible fidelity loss on use-case pages. + +**Special case — `slices.capability-cards`**: each card nests `image`, `icon`, `features[]`, and `button` underneath. `slices: { populate: "*" }` reaches the `capabilityCards` array but stops there. For batches that include capability-cards, deep-populate the card contents: ``` -slices: { populate: { __all__: "*", capabilities: { populate: { image: true, icon: true, features: { populate: "*" }, button: { populate: "*" } } } } } +slices: { populate: { __all__: "*", capabilityCards: { populate: { image: true, icon: true, features: { populate: "*" }, button: { populate: "*" } } } } } ``` -Or pre-fetch one record with that explicit populate to confirm `capabilities[].image.data.attributes.url` resolves. +Verify by spot-checking that `capabilityCards[].image.data.attributes.url` resolves on a sample record. + +**Special case — `slices.reviews-slider`**: needs `slices.<reviews-slider>.reviews: { populate: { author: { populate: { image: true } } } }` to reach author name/description/image and the review text on each related review (otherwise you only get review ids and can't match them against target, nor produce the fallback testimonials). -**Special case — `slices.reviews-slider`**: needs `slices.<reviews-slider>.reviews: { populate: "*" }` to reach author/text on each related review (otherwise you only get review ids and can't match them against target). +**Special case — `slices.side-hero-with-image`**: polymorphic — could populate any of `whiteCards`, `logos`, `features`, `image`. Populate ALL of them with their nested icon/media so shape detection at mapping time has full data: + +``` +slices: { populate: { __all__: "*", whiteCards: { populate: { icon: true, links: true } }, logos: { populate: { image: true, link: true } }, features: { populate: "*" }, image: true } } +``` + +**Special case — `slices.issues-header`**: populate `features: { populate: { links: true } }` so feature links survive. + +**Special case — `slices.text-slice`**: stores its title/text under a nested `content` _component_ sub-field (`content.title`, `content.text`), NOT at the slice top level. The generic `slices: { populate: "*" }` / `__all__:"*"` does NOT reach it — the slice comes back as just `{layout, alignCenter, withBackground}` with `content: null`, and the resolvers find nothing → the section is silently skipped. Reach it with the per-component dynamic-zone populate: `populate[slices][on][slices.text-slice][populate]=*` (equivalently add `content: { populate: "*" }` to the slice populate). Verify `content.title`/`content.text` resolve on a sample before mapping. + +**Special case — `api/comparators` (cms-comparison source) slices come back EMPTY under `__all__:"*"`.** On the comparators collection, `populate[slices][populate][__all__]="*"` (and plain `populate[slices][populate]=*`) returns `slices: []` — a populate quirk, NOT an empty source. ALWAYS re-fetch comparator slices with per-component `on` populate before concluding "nothing to migrate": `populate[slices][on][slices.content-cards-list][populate][cards][populate]=*` and `populate[slices][on][slices.faq][populate][categories][populate]=*` (`categories` alone returns `{name}` without its `questions`). General rule: for ANY dynamic zone, if `__all__:"*"`/`populate=*` yields an empty or shallow array, switch to per-component `populate[<dz>][on][<uid>][populate]=*` — never declare a source empty from the `__all__` result alone. + +**Special case — `slices.brands` / `slices.brands-with-intro` logo nesting.** On (at least use-case) records, each brand item nests its media at **`brands[].logo.media`** (a `logo` component wrapping `media`), NOT the routine's documented `logos[].image.data.attributes.url`. The generic spec returns the brand items as empty `{}` (shallow-populate). Reach them with: `populate[slices][on][slices.brands][populate][brands][populate][logo][populate]=*` (and same for `slices.brands-with-intro`). In the logo-upload routine, treat `brands[].logo.media` as an alias for the logo media source. + +**Verification-GET uses v5 (flat) media shape, not v4.** When confirming a write persisted, v5 returns media flat as `image.media.{id,url}` — there is NO `.data.attributes` wrapper. Verify with `populate[content][on][<uid>][populate][image][populate][media]=true` and read `image.media.url`. Using the v4 `image.media.data.attributes.url` path on a v5 GET returns `null` and triggers a FALSE shallow-populate alarm (the data is actually present). + +**Special case — `homeHero` (and other root-field heroes)**: deep nesting at `.hero.intro.*` + `.hero.brands` + `.hero.animations[]` + `.hero.topRightBackgroundImage` + `.hero.bottomLeftBackgroundImage` + `.hero.features[]`. The home universal record can have all of these. ### 9. Launch one parallel agent per URL @@ -403,7 +560,7 @@ Use the Agent tool with `general-purpose` subagent type, `run_in_background: tru Each agent's prompt must contain: -1. The slice → component mapping from Step 6. +1. The slice → component mapping from Step 6 (universal resolvers + shape dispatch + per-slice rules). 2. The `{ name → documentId }` map for each relation field from Step 4. 3. The dynamic-zone allowlist from Step 3. 4. The explicit populate spec from Step 8. @@ -412,7 +569,11 @@ Each agent's prompt must contain: 7. The rule "never publish from within the agent — only update the draft." 8. The rule "never emit `migration.data-sink` if a real component fits from the mapping; only use it as a LAST resort when `migration.data-sink` is explicitly allowed and the slice has no mapping." 9. The rule "DO NOT overwrite target `title`, `slug`, `fullPath`, `breadcrumbTitle`, `parent`, `children`, `companyName`, `coverImage`, `logoImage` — only send `content` and (optionally) `seo`." -10. A strict JSON report format (see Step 11). +10. **Hero preservation rule** (homepage-class records): when migrating a record whose source has a `homeHero` (or similar root hero field) AND the target's current `content[0]` is a `sections.hero-home` component, **read content[0] verbatim and prepend it unchanged to the new content[] array**. Do NOT regenerate the hero from the source; the v5 hero-home has been hand-tuned and the source schema doesn't carry equivalent fields. Same rule applies if the target's existing hero is any `sections.hero-*` custom variant. +11. A strict JSON report format (see Step 11). +12. **PUT payload hygiene (Strapi v5 rejects unknown/extra keys):** recursively STRIP every nested component `id` from the body (including inside the preserved hero from rule 10 — "prepend unchanged" means preserve the content + structure, but ids must still be removed), AND STRIP `__component` from every NESTED sub-component entry (`ctas[]`, `ctaLinks[]`, `section{}`, `items[]`, `image{}`, etc.) — `__component` is valid ONLY on the top-level dynamic-zone entries. Both fail the PUT otherwise: `Invalid key id` / `Invalid key __component`. **Don't strip the dynamic-zone ENTRY's own `__component` while stripping the nested ones.** A naive recursive `walk(del(.__component))` removes `__component` from EVERY object — including each top-level entry — and the PUT then fails (`400 Invalid key __component` / component-not-found). Strip `__component` only from NESTED sub-components; each top-level entry must KEEP its own. Safe jq, per entry: `{"__component": $entry.__component} + ($entry | <strip nested ids + nested __component>)`. (Avoid `del(.__component) | … | .__component = …` reassignment chains that can drop it.) **KEY ORDER MATTERS (confirmed by controlled experiment 2026-05-25): `__component` MUST be the FIRST key of every top-level dynamic-zone entry.** Strapi Cloud's validator is key-order sensitive — a byte-identical payload with `__component` placed LAST fails `400 Invalid key __component`; placed FIRST it succeeds (proven on `/cloud` with otherwise-identical content). Build each entry as `{"__component": uid, ...rest}` (jq `{"__component": $e.__component} + (rest)`) and preserve that order through serialization (don't let a re-sort move it). (This corrects an earlier wrong note that claimed order was insignificant — it is NOT.) + +13. **Drop empty/media-less `utilities.basic-image` (and icon) shells before PUT.** When a target GET returns an OPTIONAL `image`/`icon` (basic-image) as a media-less shell `{id, alt, width, height}` with no `media`, echoing it back also triggers a (misleading) `Invalid key __component` / validation error. The `image`/`icon` field is optional on `elements.how-it-works-item` / `cards.feature-card` — so drop any such object that lacks a usable `media` id rather than sending the empty shell. **Likewise drop a `sections.section-header` whose `section` sub-component is null/empty** — it renders nothing AND fails the publish PUT (`content[N].section must be a object type ... null`). Drop it (or populate a minimal `section`) before publishing. (Common on terms/legal pages alongside the content-card-empty-title→richtext fix.) The agent should: diff --git a/.agents/skills/migrate-strapi-content/components-cheatsheet.csv b/.agents/skills/migrate-strapi-content/components-cheatsheet.csv index a4d05da..4f1edac 100644 --- a/.agents/skills/migrate-strapi-content/components-cheatsheet.csv +++ b/.agents/skills/migrate-strapi-content/components-cheatsheet.csv @@ -1,8 +1,8 @@ source_kind,source_name,target,variant,layout,image_position,notes slice,slices.brands,media.brand-logo-grid,,,,Logo media uploaded from old CDN; deduped by filename. SKIP if items[] empty after upload. slice,slices.brands-with-intro,composite,,,,utilities.section-header (from intro) wrapped in sections.section-header + media.brand-logo-grid (logos uploaded). Header alone if logos empty. -slice,slices.capabilities-dynamic-cards,sections.tabbed-feature-overview,,,,"Each capability → elements.tabbed-feature (tabLabel from capability.label/title, optional tabIcon uploaded from CDN, content = sections.feature-overview). feature-overview.image is REQUIRED — upload via the brand-logo routine; SKIP that tab if no image survives upload. SKIP whole slice if no tabs survive." -slice,slices.case-study-card,cards.case-study-card,,,,Look up target by `card.data.attributes.slug` on target server; build ctaLink to `/user-stories/<slug>`. +slice,slices.capability-cards,sections.tabbed-feature-overview,,,,"Each card in capabilityCards[] → elements.tabbed-feature (tabLabel from c.upperTitle/label/title, optional tabIcon uploaded from CDN, content = sections.feature-overview). feature-overview.image is REQUIRED — upload via the brand-logo routine; SKIP that tab if no image survives upload. SKIP whole slice if no tabs survive." +slice,slices.case-study-card,cards.case-study-card,,,,"v5 component is a relation-only wrapper. Look up target by `card.data.attributes.slug`; emit `{ caseStudy: <documentId> }` only. DO NOT send companyName/title/ctaLink — schema rejects them." slice,slices.chili-piper,sections.section-header,,,,Heading-only block. Title/label/text from slice. slice,slices.company-stat-list,sections.three-column-grid,,,,"Each stat → elements.how-it-works-item (title=stat.value/number, description=stat.label/text). section field is REQUIRED — fall back to {title: 'Company Stats'} if slice intro is empty. itemStyle=default. May also appear as a root field on careers content type — verify before mapping." slice,slices.content-videos-list,sections.section-header,,,,Heading-only block. Video items themselves are not migrated. @@ -11,12 +11,12 @@ slice,slices.disclaimer,sections.disclaimer,,,,"Purpose-built component (title + slice,slices.embed-form,SKIP,,,,No mapping unless caller provides a `hubspot-form` relation map. slice,slices.features-card,sections.feature-card-grid,bordered,third,right,"Grid of split feature-cards on light background. Items = cards.feature-card variant=bordered layout=third imagePosition=right. SKIP if cards[] empty." slice,slices.features-slice,sections.feature-card-grid,plain,half|third,,"layout = s.layout==='two' ? 'half' : 'third'; items = cards.feature-card variant=plain. SKIP if items have no title." -slice,slices.full-width-quote,testimonials.quote,fullwidth,,,Quote with full-width visual treatment. +slice,slices.full-width-quote,testimonials.quote,boxed,,,"testimonials.quote.variant enum is boxed|image ONLY (NO fullwidth — fails validation). Use boxed (or image if author image featured)." slice,slices.getting-started-grid,sections.feature-card-grid,bordered,third,,"Items = cards.feature-card variant=bordered layout=third. Intro becomes the grid's built-in section header." slice,slices.image-slider,media.image-gallery,contained,,,"Images uploaded from old CDN (dedupe by filename). SKIP if images[] empty after upload." slice,slices.integration-cards-grid,sections.feature-card-grid,plain,,,"Items from integrations.data[] (cap 12). Each item: title, description, ctaLink to /integrations/<slug>." slice,slices.interview,composite,dark+boxed,,,"sections.section-header (background=dark, boxed=true) for header/image + N standalone cards.content-card per Q&A pair (title=question, content=answer). content-card is allowed directly in page dynamic zone." -slice,slices.issues-header,sections.three-column-grid,bordered,,,"itemStyle=bordered. Each issue → elements.how-it-works-item (title=issue.title, description=issue.description). section field REQUIRED — fall back to slice intro/title, then default {title: 'Issues'} if empty." +slice,slices.issues-header,sections.three-column-grid,bordered,,,"itemStyle=bordered. Each item in features[] → elements.how-it-works-item (title=f.title, description=f.text/description). section field REQUIRED — from LABEL/TITLE/DESCRIPTION on slice (upperTitle/title/description), fall back to {title: 'Issues'} if all empty. Note: items live under `features`, not `items`/`issues`." slice,slices.intro,conditional,,,,"Position-dependent. If first slices[] entry AND no useCaseHero on the record → sections.hero. Otherwise → sections.section-header (no background, layout=center)." slice,slices.large-features-slice,sections.two-column-grid,,,,"Single component. Grid's required section field carries the intro/title. Items = elements.how-it-works-item per feature (title + description). Same pattern as top-features." slice,slices.large-video,media.video,,center,,"alignment=center. SKIP if url missing." @@ -24,10 +24,10 @@ slice,slices.new-intro,sections.section-header,,,,Heading-only block. slice,slices.news-list,sections.news-list,,,,"Auto-fetched chronological list — target component has NO attributes. Emit `{ __component: 'sections.news-list' }` with no fields. Always succeeds; never SKIP." slice,slices.newsletter-banner,forms.newsletter,,,,"Limited migration: only the hubspotForm relation. Frontend renders fixed copy. SKIP if no resolvable hubspot-form on target." slice,slices.quote,testimonials.quote,boxed,,,Standard quote treatment. -slice,slices.reviews-slider,sections.reviews,,,,"title REQUIRED (fall back to slice.title or 'What customers say'). subTitle from slice.label. description from slice.text/description. reviews relation: attempt to resolve old `reviews.data[]` against target `api::review.review` by author name match; on no match, leave relation empty and report under `manual_followup` so reviews can be hand-linked later." +slice,slices.reviews-slider,sections.reviews OR N×testimonials.quote,,,,"v4 has reviews INLINE (each .reviews.data[*].attributes has author{name,description,image} + text). Primary: emit sections.reviews with title (from TITLE/upperTitle, fall back to 'What customers say' — REQUIRED), subTitle from LABEL, description from DESCRIPTION; resolve reviews relation by author name match on target. FALLBACK when no authors resolve: emit N×testimonials.quote directly into dynamic zone (quote=r.text, authorName=r.author.name, authorRole=r.author.description, variant=boxed). Either branch preserves content." slice,slices.related-case-studies,SKIP,,,,"Frontend renders related items algorithmically. Migrating creates duplicate content. Kept as SKIP." -slice,slices.section-with-image,cards.feature-card,bordered,full,flip_textPosition,"imagePosition = textPosition==='left' ? 'right' : 'left' (v4 textPosition = where text is; v5 imagePosition = where image is)." -slice,slices.side-hero-with-image,composite,bordered,full,right,sections.section-header (from intro) + cards.feature-card (variant=bordered layout=full imagePosition=right) for the hero content. +slice,slices.section-with-image,cards.feature-card,bordered,full,flip_textPosition,"imagePosition = textPosition==='left' ? 'right' : 'left' (v4 textPosition = where text is; v5 imagePosition = where image is). MUST emit image AND ctaLinks: image is a component (image.media is the real media — deep-populate, then find-before-upload → utilities.basic-image {media,alt,width,height}); button is a component (button.link.{href,text} → utilities.link). populate:'*' returns image:{} / button:{theme} — that's a shallow-populate bug, not a data gap." +slice,slices.side-hero-with-image,polymorphic,,,,"Shape-dispatched (see Step 6.1). whiteCards.length>0 → sections.three-column-grid (stats). logos.length>0 → composite section-header + media.brand-logo-grid (brand wall). image only → composite section-header + cards.feature-card. features only → composite section-header + two-column-grid. None populated → section-header only." slice,slices.simple-text-next-to-image,cards.feature-card,bordered,full,left,"Single card. imagePosition=left. SKIP if no title." slice,slices.stacking-cards,composite,,,,"sections.section-header (from title) + N standalone cards.content-card per card in cards[] (title=card.title, content=card.description as richtext). Schema constraint: feature-card-grid.items requires cards.feature-card, so we can't put content-cards in a grid wrapper; emitting them directly is allowed by the page dynamic zone." slice,slices.text-next-to-big-image,cards.feature-card,bordered,full,right,"Single card. imagePosition=right." @@ -35,14 +35,25 @@ slice,slices.text-next-to-image,cards.feature-card,bordered,full,flip_textPositi slice,slices.text-slice,conditional,,,,"Three-way dispatch: has button → sections.cta-banner (section from label/title/text, ctaLinks from button); purple theme → sections.section-header (utilities.section-header.variant=purple inside); else → sections.richtext (markdown from label/title/text)." slice,slices.text-with-cards,sections.feature-card-grid,plain,third,,"Grid with built-in section header from title/intro. Items = cards.feature-card size=sm with icon inline. SKIP if cards[] empty." slice,slices.text-with-image-and-gradient,cards.feature-card,bordered,full,right,"Single card. ctaLinks from DownloadLink (button.link or button.download)." -slice,slices.text-with-key-numbers,SKIP,,,,"Awaiting new v5 component (no current grid can represent the {number, text} pairs cleanly). Reported in `skipped` with reason 'awaiting key-numbers component'." +slice,slices.text-with-key-numbers,sections.three-column-grid,,,,"INTERIM: keyNumber[] {number,text} -> three-column-grid items=how-it-works-item {title:number, description:text}, itemStyle=default; section from intro. Deep-populate keyNumber via populate[slices][on][slices.text-with-key-numbers][populate][keyNumber][populate]=*. Renders as a stats block (like company-stat-list); keeps stats visible. SKIP only if intro+keyNumber both empty. Upgrade to a dedicated key-numbers component if built. **CASE-STUDY targets:** their content dynamic-zone does NOT allow sections.three-column-grid — but it DOES allow nearly everything else (hero, hero-home, video, section-header, two-column-grid, two-columns-benefits, how-it-works, feature-card, feature-card-grid, content-card, case-study-card, image, image-gallery, brand-logo-grid, quote, testimonies, meet-the-team, cta-banner, community-banner, comparator-grid, disclaimer, richtext, faq-section, forms.*, embed, data-sink). Disallowed on case-study: three-column-grid, tabbed-feature-overview, reviews, news-list. So map keyNumber[] to sections.feature-card-grid (variant=plain, layout=third, items {title:number, description:text}); use NORMAL Step 6 mapping for everything else (hero->hero, video->video, etc.). NEVER downgrade existing hero/video/section-header entries to richtext." slice,slices.top-features,sections.two-column-grid,,,,"Single component. Grid's required section field carries the intro (or default {title: 'Features'}). Items = elements.how-it-works-item per feature (title=feature.title, description=feature.description). feature.links/icon dropped (how-it-works-item has only icon + title + description)." -slice,slices.universal-rich-text,sections.richtext,,,,"Map richText directly. SKIP if empty." +slice,slices.universal-rich-text,sections.richtext,,,,"Map richText directly. SKIP if empty. EXCEPTION: if the body is ONLY a raw <iframe>/embed (lu.ma calendar, hubspot/embedded forms, video), emit media.embed {url,width,height} parsed from the iframe — the frontend Markdown (rehypeSanitize/defaultSchema) STRIPS raw iframe tags so they vanish in sections.richtext, and a title-less cards.content-card null-guards out entirely." root_field,careersHero,sections.hero,,,,"PREPEND to newContent. From careersHero.{label, title, text, button}. Include image when present (image field maps to hero's cover via existing media-upload pipeline)." root_field,communityHero,composite,,,,"PREPEND sections.hero from communityHero.hero.{label, title, text, button}; then if communityHero.brandsWithIntro is present, also append a media.brand-logo-grid (logos uploaded from old CDN)." root_field,featuresHero,sections.hero,,,,PREPEND. From featuresHero.{label/title/text/button} (or .hero.intro nested same as useCaseHero — verify per record)." -root_field,homeHero,sections.hero,,,,PREPEND on home universal. From homeHero.{label/title/text/button}. -root_field,useCaseHero,sections.hero,,,,"PREPEND on use-case records. From useCaseHero.hero.intro.{label, title, text, button}. Existing behavior — unchanged." +root_field,homeHero,sections.hero-home (preserve existing) OR sections.hero,,,,"Path is homeHero.hero.intro.{theme,title,text,button,cliContent,smallTextWithLink,newsWithLink} (same nesting as useCaseHero). When target's content[0] is already sections.hero-home, PRESERVE that component verbatim and don't emit a new hero. Only fall back to sections.hero when no existing hero is present." +root_field,useCaseHero,sections.hero,,,,"PREPEND on use-case records. From useCaseHero.hero.intro.{label, title, text, button[0]}. button is an ARRAY and intro:{populate:'*'} returns it WITHOUT .link → deep-populate intro.button (Step 8) so button[0].link.{href,text} resolves; map button[0].link → hero ctaLinks. Dropping it loses the hero CTA on every use-case page." root_field,whiteHero,sections.hero,,,,PREPEND. White visual variant (background=light on hero if supported). content_type,cms-comparison-comparator-grid,sections.comparator-grid,,,,"Comparator grid is a root-level field on cms-comparison records — handled by the cms-comparison content type's own mapping, not a slice rule." +slice,slices.content-cards-list,cards.content-card,,,,"Each card in cards[] {label,title,content} -> one standalone cards.content-card (label->label, title->title, content->content REQUIRED richtext). Markdown tables survive; raw <iframe> embeds are STRIPPED by the frontend Markdown sanitize (rehypeSanitize) — embed-only content needs media.embed, not markdown. SKIP cards with empty content. Reach cards via populate[slices][on][slices.content-cards-list][populate][cards][populate]=*. (Common on cms-comparison/comparator sources.) FRONTEND GUARD: StrapiContentCard returns null when title is EMPTY -> a cards.content-card MUST have a non-empty title to render at all; for a title-less richtext body (e.g. a mis-mapped slices.universal-rich-text) emit sections.richtext instead (no title guard)." +slice,slices.faq,sections.faq-section,,,,"categories[].questions[] {question,answer} -> single sections.faq-section, items[] = questions FLATTENED across all categories (utilities.accordions {question,answer}, both REQUIRED). Category names dropped (no grouping in faq-section). Reach via populate[slices][on][slices.faq][populate][categories][populate]=*." +slice,slices.image-gallery,media.image-gallery,contained,,,"image[] mosaic -> media.image-gallery variant=contained; upload+dedupe images from old CDN (image-slider routine). Reach via populate[slices][on][slices.image-gallery][populate][image][populate]=*. SKIP if no images survive. (New UID alongside slices.image-slider.)" +slice,slices.team-slice,SKIP (sections.meet-the-team only if members[] present),,,,"Source team-slice is intro-only in CMS; members are frontend-auto-fetched. v5 sections.meet-the-team REQUIRES items[] (elements.team-member-item, role REQUIRED) and does NOT auto-fetch. SKIP when source has no members[]; else map intro->section + members->items." +slice,slices.features-grid,sections.feature-card-grid,,,,"cards[] {title, description=text, icon, button} -> feature-card-grid, items=cards.feature-card. NOTE: card.button here is a FLAT link object {href,text,target} (NOT wrapped under button.link) -> resolveLink must accept both the flat shape and the wrapped button.link shape. Distinct UID from features-slice/features-card. section from LABEL/TITLE/DESCRIPTION. on-populate cards (image/icon/button)." +slice,slices.two-columns-benefits,sections.two-columns-benefits,,,,"v5-native same-named component. benefits[] {title, description=text, icon} -> items=elements.how-it-works-item. 1:1. on-populate benefits (icon)." +slice,slices.capabilities-dynamic-cards,sections.feature-card-grid,,,,"cards[] with per-card images -> feature-card-grid (NOT tabbed-feature-overview; differs from slices.capability-cards). Reach images via populate[slices][on][slices.capabilities-dynamic-cards][populate][cards][populate][image][populate][media]=true (cards.image populate=* returns image:{} shallow = false data gap). EACH CARD ALSO carries a button (FLAT link {href,text,target}) -> emit ctaLinks from card.button; do NOT drop (multiple prior migrations dropped these 'Learn more' CTAs)." +slice,slices.get-demo-layout,forms.hubspot-form,,,,"leftSide+rightSide demo/form layout -> forms.hubspot-form; HubSpot relation usually unresolved (form:null) -> renders empty. HubSpot mapping is a non-goal; emit the component but expect empty unless a hubspot-form relation map is provided." +slice,slices.icon-cards,sections.feature-card-grid,plain,half,,"cards[] {icon, card.{title,text}, link} -> feature-card-grid items=cards.feature-card variant=plain layout=half, title/description from card.card, icon→feature-card icon, ctaLinks from card.link. Do NOT map to two-column-grid (drops per-card CTAs). Seen on /pricing." +slice,slices.plan-cards,plans.plan-pricing-cards,,,,"Dedicated v5 pricing component (plan switcher cards). Curated on target; preserve. Plans are a relation / frontend-rendered." +slice,slices.plans-grid,plans.plan-comparison-table,,,,"Dedicated v5 pricing comparison table. `plans` relation to api::plan documentIds (populate source plan slugs/ids; empty plans[] renders nothing + an all-empty entry is DROPPED on re-PUT, so populate the relation). Preserve curated; restore plans if dropped." fallback,*,migration.data-sink,,,,"If allowed in target dynamic zone; else SKIP and report." diff --git a/.agents/skills/migrate-strapi-content/iteration-log.md b/.agents/skills/migrate-strapi-content/iteration-log.md new file mode 100644 index 0000000..8179bb1 --- /dev/null +++ b/.agents/skills/migrate-strapi-content/iteration-log.md @@ -0,0 +1,333 @@ +# Perfection Loop — Iteration Log + +State for the `migrate-strapi-content` perfection loop. Read this first each tick; update it last. Protocol: `perfection-loop.md` (sibling). + +**Started:** 2026-05-25 +**Workspace:** `.claude/skills/migrate-strapi-content-workspace/` (next dir: `iteration-9`; `iteration-1..3` prior runs, `iteration-4/5` = page 1, `iteration-6` = page 2, `iteration-7` = page 3, `iteration-8` = page 4) +**Pass cap:** 4 per page → then `parked(pass-cap-reached)` +**States:** `queue` → `in-progress` → (`perfected` | `parked`) + +## Queue + +Worked top-to-bottom. Resume any `in-progress` page before pulling a new `queue` page. + +| # | Page (fullPath) | State | Passes | Workspace | Last outcome / notes | +| --- | ------------------------------------------ | ------------- | ------ | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| 1 | `/solutions/app-builder-backend-framework` | **perfected** | 2 | iteration-4,5 | pass 2: hero CTA verified in data + render; 3 feature-card "Learn More" CTAs also recovered; no regressions (7 cmp). 2 further skill edits (hero `ctas` field, PUT hygiene). | +| 2 | `/solutions/corporate-website-cms` | **perfected** | 1 | iteration-6 | pass 1 clean: 9→9 cmp; hero CTA + 5 card images/CTAs + case-study relation (`1minus1`) all written; published; no skips. 1 skill edit (utilities.link field shape). | +| 3 | `/solutions/ecommerce-cms` | **perfected** | 1 | iteration-7 | pass 1 clean: 7→8 cmp; hero CTA + 4 card images/CTAs + case-study (`Moustache Bikes`) + 2 grids; published; no skips. 1 skill edit (\_\_component presence; corrected agent's key-order misdiagnosis). | +| 4 | `/solutions/enterprise-intranet-cms` | **perfected** | 1 | iteration-8 | pass 1: 12→11 cmp; hero 2 CTAs; published. 2 legit skips (brands=0 logos in src, embed-form=no hubspot map). 1 skill edit (text-slice `content` sub-field populate). +2 visual-cache entries. | +| 5 | `/solutions/mobile-cms` | **perfected** | 1 | iteration-9 | pass 1 clean: 7→8 cmp; hero CTA + 4 card images/CTAs + case-study (`kyivstar`) + 2 grids; published; no skips. **0 skill edits — skill converged.** +2 cache entries. | + +## Skill edits applied across the loop + +(Appended as the loop finds and fixes skill-side gaps. The durable output of the loop.) + +1. **[tick 1] Hero-CTA shallow-populate fix** (`SKILL.md` Step 8 + spot-check note + lesson; `components-cheatsheet.csv` useCaseHero row). `useCaseHero` populate was `intro:{populate:"*"}`, which returns `button:[{theme}]` WITHOUT `link` → the hero "Get Started" CTA was silently dropped on **every use-case page**. Now deep-populates `intro.button`/`smallTextWithLink`/`newsWithLink` and resolves the CTA from `button[0].link`. Benefits all 5 queued pages (all use-cases). Verified the source link exists: `https://docs.strapi.io/cms/quick-start`. Sibling heroes (homeHero/whiteHero/featuresHero) flagged in a code comment for the same fix when those types are migrated. + +2. **[tick 2] Hero CTA field name documented** (`SKILL.md` Step 6 root-hero section). `sections.hero`'s repeatable CTA field is **`ctas`** (verified against `apps/strapi/src/components/sections/hero.json`), NOT `ctaLinks` like section-header/feature-card/cta-banner. Using `ctaLinks` on the hero silently drops the CTA (Strapi ignores unknown keys, no error). Documented the field name + scalar mapping (text→description). Output was already correct (pass-2 agent discovered it via schema); this prevents a future naive agent from regressing. +3. **[tick 2] PUT payload hygiene rule** (`SKILL.md` Step 9 rule 12). Strapi v5 rejects unknown keys: must recursively strip nested component `id` (incl. inside the preserved hero — "prepend unchanged" now caveated) AND strip `__component` from nested sub-component entries (`ctas[]`/`ctaLinks[]`/`section{}`/`items[]`/`image{}`) — `__component` is valid only on top-level dynamic-zone entries. Both hit empirically (`Invalid key id` / `Invalid key __component`). +4. **[tick 3] `resolveLink()` / `utilities.link` field shape documented** (`SKILL.md` Step 6 CTA paragraph). The v5 `utilities.link` is `{type, label, href, newTab}` (verified against `apps/strapi/src/components/utilities/link.json`) — NO `text`/`target`. Must map v4 `link.text→label`, `link.target ("_self"→false/"_blank"→true)→newTab`, set `type:"external"`. Page-2 agent hit `400 Invalid key target` and fixed in-flight; page-1 agent happened to guess right — so the mapping was undocumented and inconsistently applied. Now defined once, applies to every `ctas`/`ctaLinks` skill-wide. +5. **[tick 4] `__component` stripping clarified** (`SKILL.md` Step 9 rule 12). A naive recursive `walk(del(.__component))` strips `__component` from the dynamic-zone ENTRIES too (not just nested), failing the PUT. Rule now says: strip only NESTED `__component`; rebuild each entry as `{"__component": $entry.__component} + (entry|strip nested)`. **Orchestrator correction:** the page-3 agent self-edited SKILL.md claiming Strapi rejects based on key _position_ ("**component must be FIRST key"). That's a misdiagnosis — JSON member order is insignificant and Strapi reads `**component` by name. Reworded to the real cause (PRESENCE, not order); kept the agent's safe jq pattern (works under either theory). +6. **[tick 5] `slices.text-slice` `content` sub-field populate + mapping** (`SKILL.md` Step 8 new special case + Step 6 text-slice rule). text-slice nests title/text under a `content` component; generic `slices:{populate:"*"}` returns `content:null` (verified in iteration-8 source-deep) → section silently skipped. Documented per-component populate `populate[slices][on][slices.text-slice][populate]=*`, the `content.title`/`content.text` resolver aliases, and that a heading+lead block (alignCenter, no markdown) maps to `sections.section-header` not `sections.richtext`. Agent added 2 visual-cache entries (text-slice→section-header; section-with-image no-CTA variant→feature-card). +7. **[tick 5] Feature-row evidence tightened** (`SKILL.md` line 85) — recorded the confirmed "no v4 source" evidence for feature pages. +8. **[tick 6] Brand-logo nesting + v5 verify shape** (`SKILL.md` Step 8). (a) `slices.brands`/`brands-with-intro` nest media at `brands[].logo.media` (not `logos[].image.data...`) → per-component `on` populate `populate[slices][on][slices.brands][populate][brands][populate][logo][populate]=*`. (b) Verification GETs must use v5 FLAT media shape `image.media.{id,url}` (no `.data.attributes`) — the v4 path returns null → false shallow-populate alarm. +9. **[tick 8] image-gallery + team-slice rules** (`components-cheatsheet.csv` + `visual-cache.json`). `slices.image-gallery`→`media.image-gallery` (contained; new UID alongside image-slider; `on`-populate the `image[]`). `slices.team-slice`→**SKIP** (source members are frontend-auto-fetched; v5 `sections.meet-the-team` requires items[] and doesn't auto-fetch). Orchestrator-applied 2 visual-cache entries. +10. **[tick 9] 4 universal slice types** (cheatsheet + visual-cache): `features-grid`→feature-card-grid; `two-columns-benefits`→v5-native same-named; `capabilities-dynamic-cards`→feature-card-grid (per-card image `on`-populate, NOT tabbed); `get-demo-layout`→forms.hubspot-form (usually empty/non-goal). **Policy:** universals = **full-replace from source** going forward (deterministic; skill converged), preserve only custom hero-home — not per-page surgical diffs (tick 9 was surgical because target had a good prior migration). +11. **[tick 10] isHero hero branch + features-grid flat button** (`SKILL.md` 6.1 + cheatsheet). `slices.side-hero-with-image` with `isHero===true` → `sections.hero` (was only covered as body-section shapes). `slices.features-grid` card `button` is a FLAT link `{href,text,target}` (not `button.link`) — resolveLink must accept both. +12. **[tick 11] content-card empty-title frontend guard** (cheatsheet): `cards.content-card` with empty title is dropped by frontend → use `sections.richtext` for title-less bodies. +13. **[tick 14] KEY-ORDER CORRECTION** (`SKILL.md` rule 12) — `__component` MUST be FIRST key of each top-level dynamic-zone entry (Strapi Cloud key-order sensitive; PROVEN by controlled experiment: identical content, last→400, first→200). REVERTS the earlier wrong "presence not order" note. + rule 13: drop empty media-less basic-image/icon shells before PUT. + +## POLICY (resolved tick 10): PRESERVE-AND-FIX for already-populated pages + +User chose **preserve-and-fix**: for pages that already have v5 content, KEEP existing content + manual additions (extra CTAs/links/components not in source), only ADD what's missing from source and FIX migration defects (dropped CTAs, wrong fields, missing images). Do NOT full-replace/drop manual additions. (Supersedes the "full-replace" note in skill edit 7.) Empty/stale targets → still rebuild from source. `/ai`'s dropped "Create a project" hero CTA was **restored + republished** (recovered from iteration-19 pre-migration target). Agent recommended (didn't self-edit); orchestrator verified the null-content claim + cache validity before applying. + +## Parked blockers + +(Out-of-scope gaps, grouped by owner: frontend / missing-component / schema / missing-record. May attach to a _perfected_ page — they are follow-ups, not migration blockers.) + +**Page 1 `/solutions/app-builder-backend-framework` (perfected) — out-of-scope follow-ups:** + +- **missing-record:** case-study card (Tesco) skipped — source `card` relation is null (only `buttonText`). Needs the relation set on the source, or manual assignment in target. (Correct per skill: case-study-card SKIPs when slug absent.) +- **frontend:** integration grid cards render without icons; `/integrations/<slug>` destination pages 404 (not yet created in v5 site). Data is correct. +- **frontend:** source hero is a purple gradient; `sections.hero` has no purple-gradient variant in v5. Text + CTA migrate correctly; the gradient styling is a frontend concern. + +**Page 3 `/solutions/ecommerce-cms` (perfected) — out-of-scope follow-ups (same themes as page 1):** + +- **frontend:** hero background purple→navy (no bg-color CMS field on `sections.hero`). +- **content:** source `useCaseHero` carries no hero image; the v4 hero mockup screenshot isn't in source data — needs manual media assignment if wanted. +- **frontend:** stacking-cards scroll animation → static `feature-card-grid` (intentional per visual-cache; no v5 animation component). + +**Page 4 `/solutions/enterprise-intranet-cms` (perfected) — out-of-scope follow-ups:** + +- **missing-record/config:** `slices.embed-form` ("Try Strapi out for free") skipped — needs a hubspot-form documentId mapping. Owner: content team. +- **content:** `slices.brands` had 0 logos in the source API (genuinely empty, verified with slot-targeted populate) — nothing to migrate. + +## Per-tick log + +(One entry per tick: iteration dir, page, pass#, outcome, findings link, edits.) + +### Tick 1 — iteration-4 — `/solutions/app-builder-backend-framework` — pass 1 + +- **Pre-check:** source `production-old api/use-cases` slug=`app-builder-backend-framework` exists (7 use-cases total). Target `production api/pages` fullPath match → documentId `v6ny5peg404vq9vqk2im554m`, title "App Builder Backend Framework". +- **Gotcha found (loop infra):** strapi MCP `strapi_rest` mis-serializes `params` → use curl + token from `~/.mcp/strapi-mcp-server.config.json`. Old `use-cases`/`universals` reject `fields=title`/`fullPath` ("Invalid parameter"). Source responds in v4 shape (`data.attributes.slices[]`). +- **Migration:** success, published. oldSliceCounts 8 (useCaseHero + 7 slices) → newContentCounts 7 (hero + 4×feature-card + 2×feature-card-grid). Media: 0 uploaded (4 reused). Skipped: `slices.case-study-card` (source `card` relation null — Tesco). +- **Subagent recommended** `parked`/no-edits. **Orchestrator override:** independently verified two excused items — (a) stacking-cards→feature-card-grid is a legit user-backed cache decision (kept); (b) hero "no CTA" was WRONG — a shallow-populate symptom. Deep re-fetch proved the CTA exists. → skill gap found + fixed. +- **Outcome:** `needs-another-pass` (re-run pass 2 to confirm hero CTA now emits). +- **Out-of-scope (provisional, finalize on page terminal):** case-study Tesco relation null in source (missing-record); integration card icons + `/integrations/*` 404s (frontend); hero purple-gradient variant (frontend; no v5 variant). Findings: `iteration-4/findings.md`. + +### Tick 2 — iteration-5 — `/solutions/app-builder-backend-framework` — pass 2 + +- **Verified the tick-1 fix end-to-end:** source-deep now resolves `useCaseHero.hero.intro.button[0].link` = `{href: docs.strapi.io/cms/quick-start, text: "Get Started"}`; re-migrate wrote `content[0].ctas = [{label:"Get Started", href:.../quick-start}]`; Playwright confirms the button RENDERS in the hero. Screenshot: `iteration-5/screenshots/target-hero-pass2.png`. +- **Bonus recovery:** the 3 feature-card "Learn More" CTAs (create-apis / enterprise / docs customization) were also absent in pass 1 and now write correctly. +- **No regressions:** 7 components intact (hero + 4 feature-card + 2 feature-card-grid). +- **Orchestrator verification of agent's 2 new gaps:** (1) confirmed `sections.hero` field is `ctas` not `ctaLinks` by reading the component schema — REAL gap, documented. (2) `__component` invalid on nested sub-components — matches Strapi v5 + empirically hit — documented with the `id`-strip rule. Both are doc hardening; page-1 output already correct so no extra pass. +- **Outcome:** `perfected`. Findings: `iteration-5/findings.md`. + +### Tick 3 — iteration-6 — `/solutions/corporate-website-cms` — pass 1 + +- **First migration under the improved skill.** 9 source items (useCaseHero + 8 slices) → 9 components. Hero CTA correct (`content[0].ctas[0]` = "Get Started" → quick-start). 5×`section-with-image`→feature-card (all 5 images reused, all CTAs set). `case-study-card` relation RESOLVED (source slug `1minus1` → target documentId `qnv81wa3dhfk3wiejz5r4ylu`) — unlike page 1's null Tesco relation. integration grid (7) + stacking grid (6, via visual-cache). No skips, published. +- **New gap found + fixed:** `utilities.link` field shape (`label`/`newTab`, not `text`/`target`) — see skill edit 4. Agent hit `400 Invalid key target`, fixed in-flight; output correct. +- **Orchestrator verification:** confirmed `utilities/link.json` has no `text`/`target` fields (real gap). Hero CTA + 5 card images/CTAs verified present in read-back (no shallow-populate false-negative). +- **Outcome:** `perfected`. Findings: `iteration-6/findings.md`. + +### Tick 4 — iteration-7 — `/solutions/ecommerce-cms` — pass 1 + +- **Re-migration under improved skill.** 7 source slices → 8 components. Hero CTA correct (`content[0].ctas[0]` "Get Started"). 4×section-with-image→feature-card (images reused 1926–1929, CTAs set, imagePosition flips correct). case-study relation resolved (`Moustache Bikes` → `jnvduioepn7jyhm729pvxkd4`). integration grid (10 items) + stacking grid (6, visual-cache). No skips, published. +- **Agent self-edited SKILL.md** with a `__component` "must be FIRST key" rule. **Orchestrator verified & corrected:** key _order_ is insignificant per JSON spec / Strapi reads by name — the real bug is an over-broad recursive strip removing the entry's own `__component` (PRESENCE). Reworded rule 12; kept the safe jq pattern (correct under either theory). See skill edit 5. +- **Outcome:** `perfected` (agent suggested needs-another-pass only to confirm the doc fix; output already correct + published, pages 4–5 will exercise the rule). Findings: `iteration-7/findings.md`. + +### Tick 5 — iteration-8 — `/solutions/enterprise-intranet-cms` — pass 1 + +- **First-ever migration of this page** (no prior run) — generalization test. 12 source items → 11 components. Hero 2 CTAs ("Try Live Demo", "Read case study"). Images reused (1824/1917/1743). Published. Skips: `brands` (0 logos in src), `embed-form` (no hubspot map) — both legit. +- **New gap (real):** `slices.text-slice` nests title/text under a `content` component the generic populate doesn't reach → section silently skipped until per-component `on` populate used. See skill edit 6. +- **Orchestrator verification:** confirmed `content:null` in source-deep under generic populate (real gap, not agent error); validated the 2 visual-cache additions are well-formed + sensible. Agent recommended edits (did NOT self-edit, per instruction); orchestrator applied the reliable `on`-populate framing rather than the agent's looser inline-only suggestion. +- **Outcome:** `perfected`. Findings: `iteration-8/findings.md`. + +### Tick 6 — iteration-9 — `/solutions/mobile-cms` — pass 1 (FINAL page) + +- **First-ever migration; skill fully converged.** 7 source slices → 8 components. Hero CTA correct ("Get Started"). 4 card images reused + CTAs (card 3 newTab=true from `_blank`). case-study resolved (`kyivstar` → `cgtzgktz7wint69c690t2bbg`). integration grid (6) + stacking grid (6). Published. **0 skill-side edits** — first page needing none. +2 visual-cache entries (section-with-image shape variants, same mapping). +- **Orchestrator VISUAL sweep (prompted by user: visual validation is most important):** viewed target full-page screenshots for pages 2–5 + page-1 stacking earlier. All render coherently (hero + feature cards + case-study + integration grid + solutions grid + CTA + footer). Confirmed page-4's `text-slice` "Break free from inflexible intranet platforms" now renders as section-header (the tick-5 fix, visually verified). Only divergences are the known out-of-scope items (dark vs purple hero gradient; stacking animation→static grid). Added a Guardrail to perfection-loop.md requiring the orchestrator to view target screenshots every tick. +- **Outcome:** `perfected`. Findings: `iteration-9/findings.md`. + +--- + +## LOOP COMPLETE — 2026-05-25 + +Queue empty: **5/5 pages perfected, 0 parked.** Loop stopped. 6 durable skill edits (see above) + 4 visual-cache entries added. All 5 `/solutions/*` pages migrated + published to production and visually validated against source. Remaining items are all out-of-scope follow-ups (frontend `apps/ui` + content) logged under "Parked blockers / out-of-scope follow-ups". + +--- + +# BATCH 2 — full-site migration (started 2026-05-25) + +## ⚠️ PAUSED AGAIN 2026-05-25 — SESSION USAGE LIMIT (resets ~22:40 Europe/Prague) + +**Current pause (2nd):** after completing all 43 universals + groups A/D, tick 55 `/user-stories/1minus1` was throttled (11 tool calls, no work → **NOT migrated, still TODO**). RESUME after ~22:40 reset, ONE agent at a time, from group E (31 user-stories, first = 1minus1), then group F (6 re-validations). The Meilisearch case-study index is unseeded (user-stories grid empty) — flag for a reindex op, separate from migration. All completed pages published; nothing lost. The first pause's notes (resolved) below: + +## ⚠️ PAUSED 2026-05-25 — SESSION USAGE LIMIT (resets ~17:40 Europe/Prague) [RESOLVED — resumed] + +Hit the Claude session usage limit. The two in-flight agents were throttled, not stuck: tick 31 `/headless-cms-for-executives` returned the limit message (13 tool calls, no work done → **NOT migrated, still TODO**); the FCG flag-fix sweep (iteration-40) **COMPLETED after reset: 9 pages fixed (42 grid items full→third/plain), counts verified vs source, all published; orchestrator viewed content-management render = correct 3-col plain.** (Pages: agency-playbook, ai, collaboration, content-management, create-apis, customization, for-business-teams, for-developers, headless-cms. Skipped no-change: about-us, cloud, headless-cms-guide.) **RESUME after the limit resets**, from the queue below, **ONE agent at a time** (parallel spawns burn the limit faster + caused this). All completed pages are published; nothing lost — state is this file. +**To resume:** continue with the first `[ ]` queue item (`/headless-cms-for-executives`), then re-run the FCG sweep over completed pages, then continue. + +**Approach (user-chosen):** ONE page per tick; Playwright-validate every page; iterate/fix skill as needed. **Revalidate-only** for the 6 already-perfected (home + 5 solutions). **Validate-only (NEVER replace-overwrite)** for any page with no locatable v4 source (feature pages look v5-native; some list/index pages). `replace` only when a canonical v4 source exists. + +**Mapping (verified in triage):** user-stories→`case-studies`(slug); top-level + nested/list paths→`pages`(fullPath, src `universals`); solutions→`pages`(src `use-cases`); comparisons `/headless-cms/comparison/<slug>`→`cms-comparisons`(slug, src **`api/comparators`**); features→`pages`(fullPath, **no v4 src → validate-only**). Skill URL table extended accordingly (batch-2 skill edit 1). `agilitycms-vs-contentsack` dropped (typo; real = contentstack). + +**Workspace dirs:** continue `iteration-10`+ (one per tick). + +## Batch-2 queue (process top-to-bottom; `[ ]`=todo `[x]`=done `[~]`=parked/validate-only) + +### A. New path types — do FIRST to de-risk the skill extension + +- [x] `/headless-cms/comparison/strapi-vs-sanity` → **perfected** (iteration-10): 16 cards-list→content-card + faq→faq-section; published; scalars/cms/table untouched. Table out-of-scope (cms relation unset). Orchestrator viewed target render ✓. +- [x] `/headless-cms/comparison/strapi-vs-prismic` → **perfected** (iteration-11): 14 content-card + faq; published; 0 skill edits (converged). Render viewed ✓. Table out-of-scope. +- [x] `/headless-cms/comparison/datocms-vs-sanity` → **perfected** (iteration-12): upperContent→richtext + 15 content-card + faq (17 entries); published; 0 skill edits. Render viewed ✓ (intro richtext shows link URLs — minor, faithful-ish). Table out-of-scope. [artifacts were mis-pathed by agent → moved into workspace] +- [x] `/headless-cms/comparison/agilitycms-vs-contentstack` → **perfected (no-op)** (iteration-13): source comparator has NO own slices + no upperContent (verified, not a populate artifact). Target content stays empty; page renders from frontend table/grid. Render viewed ✓. 0 skill edits. + +**✅ COMPARISON GROUP COMPLETE (4/4).** All published/verified. Shared out-of-scope: feature-comparison TABLE needs `cms` relation seeded + `showTable=true` (separate data task, not content migration). + +- [x] `/features` + `/features/audit-logs` → **validated-only** (iteration-14): see feature group below. +- [x] `/solutions/learning-management-systems-cms` → **perfected** (iteration-15): 12 slices→13 comp; hero 2 CTAs; 2×9-logo brand walls; case-study; published; render viewed ✓. 1 skill edit (brands logo nesting + v5 verify shape). +- [x] `/solutions/product-information-management-pim-cms` → **perfected** (iteration-16): 12 slices→13 comp; hero 2 CTAs; brands via on-populate; case-study BASH; published; 0 skill edits (converged); render viewed ✓. + +**✅ GROUP A COMPLETE:** 4 comparisons + 16 feature pages (published) + 2 new solutions. Skill converged for comparisons & solutions. + +### B. Feature pages (remaining — validate-only unless v4 src found at tick A) + +- [x] all 14 remaining `/features/*` → **validated-only** (iteration-14): see feature group below. + +**✅ FEATURE GROUP COMPLETE (16/16) — validate-only.** Verdict: **v5-native, NO v4 source** (confirmed: `api/feature` is a single-type for the index only; all plural feature collections 404; `universals` has no fullPath + matches only `content-types-builder`, an unrelated legacy page). All 16 already have coherent hand-built v5 content (3–9 comps, incl. v5-only components `two-columns-benefits`/`dynamic-features-grid`/`media.embed`) and render correctly — orchestrator spot-viewed index + custom-roles-and-permissions + relations ✓. **NOT migrated, nothing overwritten.** ✅ **All 16 PUBLISHED** 2026-05-25 (user approved — empty-body PUT `?status=published`, 16/16 OK, content unchanged). + +### C. Top-level universals → pages (43) + +- [x] /about-us → **perfected** (iteration-17): universal 7 slices→8 comp (gallery + 2 logo grids + 3 section-headers + getting-started-grid); team-slice SKIPPED (auto-fetched); published; render viewed ✓. Skill edit 6 (image-gallery + team-slice rules). +- [x] /agency-playbook → **perfected** (iteration-18): universal 10 slices→11 comp; fixed dropped dark-cta-banner CTA ("Become a partner"); published; render viewed ✓. 4 new slice types cached (skill edit 7). +- [x] /ai → **perfected** (iteration-19): universal full-replace, 3 slices→3 comp (hero + features-grid 3 cards + faq 11q); published; render viewed ✓. ⚠️ full-replace DROPPED a hand-added "Create a project" hero CTA (source has none). 1 skill edit (isHero hero branch + features-grid flat button). +- [x] /ai-terms → **perfected** (iteration-20, preserve-and-fix): fixed 2 prior-migration defects — empty hero title (filled from source) + a content-card holding the full legal text but with empty title (frontend dropped it → body was INVISIBLE live) converted to sections.richtext. Published; render viewed ✓ (legal text now visible). Skill edit 9 (content-card empty-title guard). +- [x] /careers → **perfected** (iteration-21, preserve-and-fix): restored dropped perks-grid heading ("Candidly caring for Strapiers"); preserved a manual CTA addition; 12→12; published; render viewed ✓. One-off findings (logged, not added to skill — careers is the only careers page): source is single-type `api/career` (not universals); `careersHero.intro.content.*` graph-node hero; `tech-stack-icon-list`→brand-logo-grid; `perk-lists`→two-column-grid. ⚠️ Vercel-preview shows grey image placeholders (URLs resolve via API — preview-env quirk; spot-check live). +- [x] /cloud → **perfected** (iteration-22, preserve-and-fix): fixed empty caseStudy relation (→PostHog) + flattened imagePosition (restored alternating); preserved hero CTA + brand grid; 8→8; published; render viewed ✓. 0 new types. +- [x] /collaboration → **perfected** (iteration-23, preserve-and-fix): added 5 dropped capability CTAs + 1 section label; preserved hero CTA + all components; 6→6; published; render viewed ✓. **Triggered the key-order correction** (skill edit 10). +- [x] /community → **NO-OP/validated** (iteration-24, preserve-and-fix): target already faithful (hero+JoinDiscord CTA + benefits + 5-card Resources); preserved manual additions; no defects, no write. Render viewed ✓. Source = single-type `api/community`. Skill edit 11 (single-type fallback note). +- [x] /contact → **perfected** (iteration-25, preserve-and-fix): added 3 missing benefit/routing cards (feature-card-grid w/ icons+CTAs) to existing header; 1→2; published; render viewed ✓. `slices.contact-header` (one-off, logged not added). +- [x] /contact-sales → **perfected/no-op** (iteration-26, preserve-and-fix): target `forms.conversion` already faithful (section + infoBlocks + features + form) + manual additions preserved; HubSpot embed = non-goal. No write. Render viewed ✓. ⚠️ FRONTEND follow-up: section.title "Talk to our Sales team" IS in CMS but renders BLANK (forms.conversion component doesn't render its section heading) — frontend bug, not content. `slices.contact-sales-layout` one-off (logged). +- [x] /content-management → **perfected** (iteration-27, preserve-and-fix): restored 5 dropped capability-card CTAs; preserved rest; 6→6; published; render viewed ✓. 0 new types. +- [x] /create-apis → **perfected** (iteration-28, preserve-and-fix): restored 5 dropped capability-card CTAs; preserved hero CTA + rest; 5→5; published; render viewed ✓. Skill edit 12 (capabilities-dynamic-cards emit ctaLinks — recurring drop). +- [~] /culture → **PARKED** (iteration-29, missing-record): NO target page at `/culture` (confirmed draft+published). Source exists but `strapi.io/culture` 301-redirects to the Strapi handbook → page intentionally RETIRED in v5; target FE shows error. Nothing to migrate into. (Content+seo payload saved in iteration-29/dry-run/ if a page is ever created.) +- [x] /culture-code → **perfected** (iteration-30, preserve-and-fix): fixed content-card-empty-title→richtext (values body now renders) + too-short seo.metaDescription (36→122, blocked publish); preserved 2 heroes; 3→3; published; render viewed ✓. Skill edit 13 (seo metaDescription MIN 50). +- [x] /customization → **perfected** (iteration-31, preserve-and-fix): fixed 5 dropped capability CTAs; preserved rest; 6→6; published; render viewed ✓. (1 source-data href typo `/content-mangement` migrated faithfully — out of scope to fix.) 0 new types. +- [x] /demo → **perfected/no-op** (iteration-32, validate-only): target v5-native `forms.demo-conversion` faithfully reproduces source (HubSpot embed → native form); no migratable change. Render viewed ✓ (demo tiles + tech stack render). ⚠️ FRONTEND: native demo form shows "Form unavailable" (runtime/preview failure, CMS data correct) — 2nd `forms.*` frontend gap. Target still a DRAFT (unpublished; left as-is, no content change). +- [x] /enterprise → **perfected/no-op** (iteration-33, validate-only): NO source universal (v5-native, 11 curated comps); preserved intact incl. an empty case-study-card placeholder (no source to fill). Render viewed ✓ (clean). Target is a DRAFT (unpublished, left as-is). Note: source live page richer (quote+logo wall) but not CMS-backed → not migratable. +- [x] /enterprise-terms → **perfected** (iteration-34, preserve-and-fix): fixed content-card-empty-title→richtext (legal body was invisible) + migrated newer source revision (eff 18 Mar 2026); dropped empty section-header shell; 2→1; published; render viewed ✓. (3rd terms-page content-card fix.) +- [x] /events → **perfected** (iteration-35, preserve-and-fix): fixed invisible lu.ma calendar (content-card-empty-title → **media.embed**, NOT richtext — frontend Markdown strips raw iframes, verified in apps/ui Markdown.tsx); dropped empty data-sink; preserved hero + Discord CTA; 4→3; published; render viewed ✓ (calendar embeds live). Skill edit 14 (iframe-only richtext→media.embed + corrected content-cards-list "iframes survive" claim). +- [x] /faq → **perfected** (iteration-36, preserve-and-fix): content-card-empty-title→richtext (AI-Info body was invisible, now renders); 1→1; published; render viewed ✓. (4th content-card-empty-title fix.) +- [x] /financial-services → **perfected** (iteration-37, preserve-and-fix): linked 3 dropped case-study relations (SocGen/Continuum/Finary); preserved rest; 11→11; published; render viewed ✓. 0 new types. +- [x] /for-business-teams → **perfected** (iteration-38, preserve-and-fix): linked dropped case-study (Google×WallDecaux); preserved 14 incl. manual CTAs; 14→14; published; render viewed ✓. +- [x] /for-developers → **perfected** (iteration-39, preserve-and-fix): dropped 1 empty card shell; preserved 8 incl. manual hero CTA; integration grid intentionally frontend-auto-rendered; 8→8; published; render viewed by agent. ⚠️ FCG item flags pending flag-sweep (spawned before rule edit 15). +- [x] /headless-cms-for-executives → **perfected** (iteration-42, preserve-and-fix): fixed 6 — 3 data-sinks→real (incl. text-with-key-numbers→three-column-grid restoring 3 stats), grid flags full→third, restored integration grid, linked Tesco case-study; preserved 2 brand grids + hubspot + manual; 15→15; published; render viewed ✓. Skill edit 16 (text-with-key-numbers SKIP→three-column-grid, INTERIM — **flagged to user**). +- [x] /headless-cms-for-web-agencies → **perfected** (iteration-43, preserve-and-fix): fixed 5 (2 grids full→third/plain, case-study 1minus1, restored Partner-Program header + 7-card integration grid); 10→12; published; render viewed ✓. +- [x] /headless-cms-guide → **perfected/no-op** (iteration-44, validate-only): target faithful (section-header hero + feature-card + hubspot-form); no defects, no write. Render viewed ✓. +- [x] /hosting → **perfected** (iteration-45, preserve-and-fix): trust grid full→third/plain; restored 5 capability CTAs; PRESERVED features bento (deliberate full/half/third mix — bento exception added to rule); 6→6; published; render viewed ✓. +- [x] /market-guidelines → **perfected** (iteration-46, preserve-and-fix): restored dropped image (illo, media 1664) + CTA (Submit your plugin) on trailing feature-card; preserved hero + 6 richtext; 8→8; published; render viewed ✓. +- [x] /newsletter → **perfected/no-op** (iteration-47, validate-only): hero + hubspot-form faithful (embed-form relation resolved); no defects, no write. Render viewed ✓. +- [x] /newsroom → **perfected** (iteration-48, preserve-and-fix): added dropped sections.news-list (now renders 22 news items); preserved hero + 2 headers; 3→4; published; render viewed ✓. +- [x] /partner-form → **perfected/no-op** (iteration-49, validate-only): section-header + HubSpot form faithful (renders fine); no defects, no write. Render viewed ✓. +- [x] /partner-program → **perfected** (iteration-50, preserve-and-fix; src single-type api/partner-program): restored hero image (6476) + 2 partner-card images (6477/6478, uploaded) + dropped FAQ link; 4→4; published; render viewed ✓. +- [x] /pricing → **perfected** (iteration-51, preserve-and-fix): restored empty plan-comparison-table (5 plans) + icon-card CTAs (→feature-card-grid not two-column-grid) + dropped reviews (→sections.reviews, 3 relations resolved — first working reviews branch); 7→7; published; render viewed ✓. Skill edit 17 (icon-cards/plan-cards/plans-grid cheatsheet rows + agent's 2 cache entries). +- [x] /pricing-cloud → **perfected** (iteration-52, preserve-and-fix): icon-cards CTAs→feature-card-grid + reviews→sections.reviews (3 resolved); preserved plan cards/table/brands/faq; 6→6; published; render viewed ✓. +- [x] /pricing-cms → **perfected** (iteration-53, preserve-and-fix): reviews→sections.reviews (3 resolved); preserved plan cards/table/brands/faq/CTA; 6→6; published; render viewed ✓. +- [x] /privacy → **perfected** (iteration-54, preserve-and-fix): content-card-empty-title→richtext (17k-char policy was invisible) + dropped empty section-header (was blocking publish); 2→1; published; render viewed ✓. Skill edit 18 (rule 13: also drop empty section-header). +- [x] /retail → **perfected** (iteration-55, preserve-and-fix): feature grid full→third/plain; linked 2 case-studies (Sonos, Mug&Snug); dropped icon shells; 6→6; published; render viewed ✓. +- [x] /security → **perfected** (iteration-56, preserve-and-fix): trust badges full→third/plain; 5 capability CTAs restored; PRESERVED features-grid bento (bento exception applied correctly); 6→6; published; render viewed ✓. +- [x] /strapi-marketing-advertising-cms-campaign-management → **perfected** (iteration-57, preserve-and-fix): re-added dropped 9-logo brand grid + 2 section headings + case-study (google-walldecaux) + grid flags; 6→7; published; render viewed ✓. +- [x] /support → **perfected** (iteration-58, preserve-and-fix): filled empty section-header ("SLAs – Gold plan", purple — resolved publish blocker + dropped heading); FAQ preserved; 2→2; published; render viewed ✓. +- [x] /tech-business-services → **perfected** (iteration-59, preserve-and-fix): linked 4 case-studies (ae-studio/delivery-hero/openforge/paradigma); feature grid full→third/plain; 11→11; published; render viewed ✓. +- [x] /telco-media-gaming → **perfected** (iteration-60, preserve-and-fix): linked 4 case-studies (palmabit/kyivstar/l-equipe/1minus1); feature grid full→third/plain; 11→11; published; render viewed ✓. +- [x] /terms-of-use → **perfected** (iteration-61, preserve-and-fix): content-card-empty-title→richtext (6.7k-char terms body was invisible); 2→2; published; render viewed ✓. + +**✅ GROUP C COMPLETE — 43/43 top-level universals migrated/validated + published.** (1 parked: /culture.) Dominant defect classes fixed across the group: empty case-study relations, feature-card-grid full→third/plain, dropped headings/CTAs/images/logos, content-card-empty-title→richtext on terms pages, reviews→sections.reviews. + +### D. Nested / list-root pages → pages + +- [x] /headless-cms → **perfected** (iteration-62, preserve-and-fix): content-card-empty-title→richtext (3.7k-char guide body was invisible); 5→5; published; render viewed ✓. Skill edit 19 (imagePosition inert on image-less plain tiles — not a defect). +- [x] /headless-cms/comparison → **perfected/no-op** (iteration-63, validate-only): frontend-only comparator index (sections.comparator-grid auto-fetches 126 comparators); target faithful, no defects, no write. Render viewed ✓. +- [x] /blog → **perfected/no-op** (iteration-64, validate-only): frontend-only blog index (UI reads api::blog single-type, not page content[]/seo); renders full index; no defects, no write. Render viewed ✓. +- [x] /user-stories → **perfected** (iteration-65, preserve-and-fix): dropped empty section-header; preserved hero + dynamic-case-studies-grid (auto-fetch) + 5-logo brand grid; 4→3; published; render viewed ✓. ⚠️ FOLLOW-UP: the case-studies grid renders only a search box — Meilisearch case-study index unseeded (indexing op, not migration). +- [~] /headless-cms/benefits-of-a-headless-cms-development → **PARKED** (missing-record): source `api/resources` exists but NO v5 target page (draft+published both 0; not a blog-post) → no home to migrate into. +- [~] /headless-cms/headless-cms-vs-traditional-cms-understanding-the-difference → **PARKED** (missing-record): same — `api/resources` source, no v5 target page. + +**✅ GROUP D COMPLETE:** /headless-cms (fixed), /headless-cms/comparison (no-op), /blog (no-op), /user-stories (fixed) migrated/validated; 2 guide pages parked (no v5 target). + +### E. User-stories → case-studies (31, slug match) + +- [x] 1minus1 → **perfected** (iteration-67, preserve-and-fix): filled empty faq-section (interview→4 accordions); preserved 2 richtext; 3→3; published; render viewed ✓. +- [x] ae-studio → **perfected** (iteration-68, preserve-and-fix): restored dropped "Discover our plans" CTA on Enterprise-Edition text-slice (→section-header light + ctaLinks); preserved faq; 2→2; published; render viewed ✓. Skill edit 20 (text-slice light-CTA fallback). +- [x] ae-studio-prosperity → **perfected** (iteration-69, preserve-and-fix): text-slice light-CTA fix (Discover our plans); preserved key-numbers/faq(7)/quote; 4→4; published; render viewed ✓. +- [x] airbus → **perfected** (iteration-70, preserve-and-fix): restored 3 quote authors + added key-numbers block (feature-card-grid, since three-column-grid not in case-study allowlist); 11→12; published; render viewed ✓. Skill edit 21 (quote variant enum boxed|image not fullwidth; case-study key-numbers→feature-card-grid). +- [x] banco-bhd → **perfected** (iteration-71, preserve-and-fix): quote author Strapi→Azher Aleem; preserved richtext; 2→2; published; render viewed ✓. +- [x] bash → **perfected** (iteration-72 then CORRECTED iteration-73): tick-72 wrongly downgraded media.video + 3× sections.hero to richtext (followed a too-narrow case-study allowlist I'd recorded). ROOT CAUSE: case-study content DZ is BROAD (hero/video/section-header allowed; only three-column-grid/tabbed/reviews/news-list disallowed). Skill edit 22: corrected cheatsheet line 38 + memory case-study-content-allowlist. iteration-73 restored video+heroes from target-before.json, kept key-numbers→feature-card-grid; 8 entries published; render viewed ✓. +- [x] continuum-banco-internacional → **perfected** (iteration-74, preserve-and-fix): 4→5; added dropped intro richtext; key-numbers bullets→feature-card-grid (plain/third); filled EMPTY faq-section with 5 Q&A from interview; preserved quote+enterprise; no downgrades; published. Agent screenshot was STALE ISR (showed old bullets) — I re-fetched live: numbers 30/200000/15 render as 3-up grid ✓ (skill lesson: memory stale-isr-render). +- [x] delivery-hero → **perfected** (iteration-75, preserve-and-fix): 5→4; key-numbers bullets→feature-card-grid (plain/third, 42/22000/500000); Enterprise promo richtext→section-header (light/center); preserved faq (4 Q&A — "empty" was populate symptom) + quote; no downgrades; fresh screenshot; published; render viewed ✓. +- [x] erlkoenig-toyota → **perfected** (iteration-76, preserve-and-fix): 5→4; key-numbers bullets→feature-card-grid (plain/third, 6wk/2.2s/30); Enterprise promo→section-header (light/center); preserved faq (4 Q&A, deep-confirmed) + quote (Dominic Land); no downgrades; fresh screenshot; published; render viewed ✓. +- [ ] finary · glean · google-walldecaux · kyivstar · l-equipe-amp-story-20th-anniversary · mind-gym · moustache-bikes-replaced-wordPress-with-strapi · mug-snug-e-commerce · openforge-mobile-agency · palmabit-intred · paradigma-digital-brand · pixeldust-agency · posthog · shelt-in-iot-health-monitoring · smartshore-ability-rd-nl · societe-generale-e-training-platform · sonos-pixel-alliance · successive-technologies · tesco · yatra-scaled-10m-users-4x-faster-campaigns-with-strapi · yuka-moves-fast-with-strapi-cloud · zero-molecule + +### F. Revalidate-only (already perfected this session — Playwright re-check, NO re-migrate) + +- [ ] `/` · `/solutions/app-builder-backend-framework` · `/solutions/corporate-website-cms` · `/solutions/ecommerce-cms` · `/solutions/enterprise-intranet-cms` · `/solutions/mobile-cms` + +## Batch-2 skill edits + +**⚠️ [tick 30, USER FEEDBACK] skill edit 15 — feature-card-grid item flags (RECURRING):** multi-column tile grids (`features-slice`/`features-grid`, plain icon/text grids like financial-services "Any channel, any device") were repeatedly emitted with items `layout:full`+`variant:bordered`+`imagePosition` (full-width bordered boxes) = WRONG. Grid items must be `layout:third`/`half` (NEVER `full`); plain tile grids `variant:plain`, no `imagePosition`, source icon→feature-card `icon`. `full`/`imagePosition` only for single image-split cards; `bordered` only for genuinely-bordered grids (integration/getting-started/features-card). Fixed in SKILL.md Step 6 grids note + visual-cache (features-grid + features-slice → plain/third) + memory [[feature-card-grid-item-flags]]. `/financial-services` corrected+republished. **Sweep DONE (iteration-40): 9 pages fixed (42 items full→third/plain), verified + published; orchestrator viewed content-management = correct 3-col plain.** + +1. **[setup] URL table extended** — fixed comparator source endpoint (`api/comparators`, was wrongly `api/cms-comparisons`); added `/headless-cms/comparison/<slug>` path; added `/features`+`/features/<slug>`→page (validate-only, no v4 src); added nested/list-root → page; added the "no v4 source ⇒ validate-only, never wipe" guard. + +## Batch-2 skill edits (cont.) + +2. **[tick 1] Comparator populate quirk** (`SKILL.md` Step 8). `api/comparators` returns `slices:[]` under `__all__:"*"` — a populate quirk, not empty source. Documented per-component `on` populate (`populate[slices][on][<uid>][populate]=*`) as mandatory for comparators + general rule: empty/shallow `__all__` result ⇒ switch to per-component `on`, never declare source empty. (Cost a mid-pass on tick 1.) +3. **[tick 1] Two new slice rules** (`components-cheatsheet.csv`): `slices.content-cards-list`→N×`cards.content-card`; `slices.faq`→`sections.faq-section` (flatten categories' questions). Verified by render. +2 visual-cache entries (agent-applied). + +## Batch-2 per-tick log + +### Tick 1 — iteration-10 — `/headless-cms/comparison/strapi-vs-sanity` — pass 1 + +- First `cms-comparison`. Source `api/comparators` slug=strapi-vs-sanity → 2 own slices (content-cards-list 16, faq 5q) → 16 `cards.content-card` + 1 `sections.faq-section`. Published. Did NOT touch title/slug/label/description/showTable/seo/cms. +- **Orchestrator viewed** target+source full-page renders: target mirrors source (hero→content cards→FAQ→Compare grid→CTA). Only the top feature-comparison TABLE absent — out-of-scope (frontend, needs `cms` relation seeded + `showTable=true`). +- Mid-pass recovery: `__all__:"*"` returned empty slices; per-component `on` populate revealed the real 2 slices. → skill edits 2+3. +- **Outcome:** `perfected`. Findings: `iteration-10/findings.md`. + +### Tick 2 — iteration-11 — `/headless-cms/comparison/strapi-vs-prismic` — pass 1 + +- 14 content-cards + 1 faq-section; published; no upperContent. **0 skill edits** (comparator mapping converged after tick 1). Per-component `on` populate worked; faq items:0 shallow-artifact handled. Orchestrator viewed target render ✓ (faithful; table out-of-scope). Findings: `iteration-11/findings.md`. + +### Tick 3 — iteration-12 — `/headless-cms/comparison/datocms-vs-sanity` — pass 1 + +- upperContent→`sections.richtext` + 15 content-cards + faq-section (17 entries); published; **0 skill edits**. Orchestrator viewed render ✓ (faithful; intro richtext renders markdown links with visible URLs — minor watch-item, in-scope content correct). Table out-of-scope. +- **Process note:** tick-3 prompt abbreviated workspace path → agent wrote artifacts to repo-parent; moved into real workspace. FIX: pass FULL absolute workspace path in agent prompts (done tick 4+). + +### Tick 4 — iteration-13 — `/headless-cms/comparison/agilitycms-vs-contentstack` — pass 1 + +- **No-op (perfected):** source comparator `slices:[]` + `upperContent:null` — genuinely empty (verified via `on` populate + `populate=*` + `populate=deep` + by-id + control test vs strapi-vs-sanity). Target content stays empty; frontend renders hero + Compare grid + disclaimer + CTA. No write/publish. Orchestrator viewed render ✓. 0 skill edits. + +### Tick 5 — iteration-14 — FEATURE GROUP (16 pages) — validate-only + +- Discovery: ran content-type probing on production-old → **no v4 source** for feature pages (evidence in feature-group checkpoint above). Feature pages are v5-native drafts. +- Validated all 16: each has real v5 content (3–9 comps), renders correctly, none empty/broken. NO writes. All unpublished. +- 1 skill edit: tightened SKILL.md line 85 feature-row evidence (batch-2 edit 4). Orchestrator spot-viewed 3 renders ✓. +- **Resolved:** user said publish → all 16 feature drafts published (16/16 OK, content untouched, publish-only PUT). + +### Tick 6 — iteration-15 — `/solutions/learning-management-systems-cms` — pass 1 + +- Use-case→page, replace. 12 slices → 13 components (hero + 2 brand-grids + 2 section-headers + 4 feature-cards + case-study + two-column-grid + 2 feature-card-grids). Hero 2 CTAs persisted. 0 media uploaded (9 logos + 4 images reused). Skipped: embed-form (no hubspot map). Published. Orchestrator viewed render ✓ (faithful). +- New: brand-logo nesting `brands[].logo.media` (shallow under generic spec) → skill edit 5. No new slice shapes (section-with-image `shape` variant = cache match). +- **Outcome:** `perfected`. Findings: `iteration-15/findings.md`. + +### Tick 7 — iteration-16 — `/solutions/product-information-management-pim-cms` — pass 1 + +- Use-case→page, replace. 12 slices→13 components; hero 2 CTAs persisted; brands via on-populate (9 logos×2); case-study→BASH; published. 0 media uploaded. Skipped embed-form. **0 skill edits** (converged). Verify used v5 flat media path. Orchestrator viewed render ✓. +- **Outcome:** `perfected`. Findings: `iteration-16/findings.md`. + +### Tick 8 — iteration-17 — `/about-us` (universal→page) — pass 1 + +- First universal-sourced migration. Source universal had 7 slices (no root hero). → 8 components (3 section-headers incl. 2 purple text-slice, image-gallery, 2 brand-grids, feature-card-grid). team-slice SKIPPED (members auto-fetched, v5 needs items[]). 0 media uploaded (16 reused). Published. Orchestrator viewed render ✓. +- 2 NEW slice types → skill edit 6 (image-gallery→media.image-gallery; team-slice→SKIP). +2 visual-cache entries (orchestrator-applied this time). +- **Outcome:** `perfected`. Findings: `iteration-17/findings.md`. + +### Tick 9 — iteration-18 — `/agency-playbook` (universal→page) — pass 1 + +- Target had a good prior 11-comp migration; agent surgically fixed the one defect (dark-cta-banner → cta-banner with "Become a partner" CTA), preserved rest, published. 10 source slices, 4 new types (→ skill edit 7). Orchestrator viewed render ✓ (fixed CTA banner present). +- **Outcome:** `perfected`. Findings: `iteration-18/findings.md`. + +### Tick 10 — iteration-19 — `/ai` (universal) — pass 1 + +- Full-replace (before policy change) → dropped hand-added "Create a project" hero CTA; **CTA later restored + republished**. 3 slices→3 comp. Skill edit 8 (isHero hero + features-grid flat button). Triggered the preserve-and-fix policy decision. + +### Tick 11 — iteration-20 — `/ai-terms` (universal, PRESERVE-AND-FIX) — pass 1 + +- First preserve-and-fix pass. Found 2 defective prior-migration components: empty-title section-header (no heading) + content-card with full legal body but empty title (frontend `StrapiContentCard` drops `!title` → entire legal text INVISIBLE live). Fixed: filled hero title (variant=purple) + converted body content-card→sections.richtext. Published; orchestrator viewed render ✓ (legal text now renders). +- Skill edit 9: cheatsheet content-card FRONTEND GUARD (empty title → dropped; use richtext for title-less bodies). +- **Outcome:** `perfected`. Findings: `iteration-20/findings.md`. + +### Tick 12 — iteration-21 — `/careers` (universal, PRESERVE-AND-FIX) — pass 1 + +- Restored dropped perks heading; preserved manual CTA; 12→12; published; render viewed ✓ (restored heading visible; image placeholders = Vercel preview quirk, URLs resolve). careers source = single-type `api/career` + careersHero graph-node — one-offs, logged only (no more careers pages). 0 skill edits applied (findings noted). +- **Outcome:** `perfected`. Findings: `iteration-21/findings.md`. + +### Tick 13 — iteration-22 — `/cloud` (universal, PRESERVE-AND-FIX) — pass 1 + +- Fixed empty caseStudy relation (→PostHog `xa23nkj3ccc2x80l5jsz7rv4`) + 2 flattened imagePosition (restored alternating left/right); preserved hero/CTA/brand-grid/quote/seo; 8→8; published; render viewed ✓ (both fixes visible). 0 new types, 0 skill edits. +- **Outcome:** `perfected`. Findings: `iteration-22/findings.md`. + +### Tick 14 — iteration-23 — `/collaboration` (universal, PRESERVE-AND-FIX) — pass 1 + +- Added 5 dropped capability CTAs + 1 missing section label; preserved manual hero CTA + all 6 components; published; render viewed ✓. +- **KEY-ORDER CORRECTION (skill edit 10):** agent's controlled experiment + my own re-test on `/cloud` (identical content, `__component` last → 400, first → 200) PROVED Strapi Cloud is key-order sensitive: `__component` MUST be first key of each top-level entry. This REVERTS my batch-1 "presence not order" reword (which was wrong + cost agents failed attempts). Also added rule 13 (drop empty media-less basic-image shells). Saved memory [[empirical-beats-apriori]]. +- **Outcome:** `perfected`. Findings: `iteration-23/findings.md`. + +### Tick 15 — iteration-24 — `/community` (single-type api/community → page, PRESERVE-AND-FIX) — pass 1 + +- NO-OP/validated: target already faithful (3 comps: hero+JoinDiscord, two-columns-benefits, features-grid 5 cards) + manual additions preserved; no defects, no write. Render viewed ✓. Source = `api/community` single-type → skill edit 11 (single-type fallback note on URL table). +- **Outcome:** `perfected` (no-op). + +### Tick 16 — iteration-25 — `/contact` (universal, PRESERVE-AND-FIX) — pass 1 + +- Added 3 missing benefit/routing cards (feature-card-grid, feature-card items w/ icons+CTAs) to the existing section-header; 1→2; published; render viewed ✓. `slices.contact-header` new but contact-specific → logged only. 0 skill edits. +- **Outcome:** `perfected`. diff --git a/.agents/skills/migrate-strapi-content/perfection-loop.md b/.agents/skills/migrate-strapi-content/perfection-loop.md new file mode 100644 index 0000000..df2a296 --- /dev/null +++ b/.agents/skills/migrate-strapi-content/perfection-loop.md @@ -0,0 +1,82 @@ +# Perfection Loop — migrate-strapi-content + +A meta-process that **iteratively improves the `migrate-strapi-content` skill** by migrating real pages, comparing the rendered result against the live v4 source, and editing the skill's own files to close every _skill-side_ gap. It runs one page per tick under fresh context; the queue and state live in `iteration-log.md` (sibling of this file). + +This doc is the contract the `/loop` runner follows. The migration mechanics themselves live in `SKILL.md` — this doc never re-specifies them, it orchestrates runs of them. + +## Loop vs. skill — who does what + +- **The skill (`SKILL.md`)** performs one autonomous migration: parse URL → map slices → write draft → publish. It never asks the user and never edits itself. +- **The loop (this doc)** runs the skill on one page, judges the result against the source, and — when the gap is the skill's fault — **edits the skill's own files** (`SKILL.md`, `components-cheatsheet.csv`, `visual-cache.json`) so the next page benefits. The loop is also fully autonomous: accumulate findings, edit the skill, report at the end. Do **not** ask the user mid-loop. + +### In scope for loop edits + +`SKILL.md`, `components-cheatsheet.csv`, `visual-cache.json` — the skill's own behavior. + +### Out of scope → **park** the page (never edit these) + +- Frontend render bugs in `apps/ui/` (data is correct in Strapi but the Next.js component doesn't render it). +- Missing v5 component (needs `/create-content-component`). +- Strapi content-type schema changes in `apps/strapi/`. +- Missing/unlocatable source or target record (nothing to migrate). + +Parking records the blocker and reason; it does not attempt a fix outside the skill. + +## Page state machine + +Each page in `iteration-log.md` is in exactly one state: + +| State | Meaning | +| ------------- | ------------------------------------------------------------------------------------------------------------------------ | +| `queue` | Not started. | +| `in-progress` | Has had ≥1 pass; the last pass found a fixable skill-side gap, a skill edit was applied, needs another pass to verify. | +| `perfected` | Migrated + published; rendered target matches source within skill scope; **no outstanding skill-side gap**. Terminal. | +| `parked` | Blocked on something out of scope (see above) **or** hit the pass cap. Records blocker + reason. Terminal for this loop. | + +## Per-tick algorithm + +One tick = one page pass. Fresh context each tick (so the latest `SKILL.md` is always re-read). + +1. **Read `iteration-log.md`.** Pick the work item: + - Resume the single `in-progress` page if one exists; else + - Take the first `queue` page (top to bottom). + - If none of either → **stop** (see Stop conditions). +2. **Mark it `in-progress`** and allocate the next workspace dir `…-workspace/iteration-<N>/` (N = next integer after the highest existing). Record `iteration-<N> → <slug>, pass <p>` in the log. +3. **Invoke the skill** via the `Skill` tool (`migrate-strapi-content`) on this one URL. This re-reads `SKILL.md` fresh, so any edit from a prior tick is in effect. +4. **Run the per-page perfection protocol** (below). +5. **Decide the outcome** for this pass: `perfected` | `needs-another-pass` | `parked`. + - `needs-another-pass`: a skill-side gap was found AND is fixable in `SKILL.md`/CSV/`visual-cache.json`. Apply the edit now, keep state `in-progress`, increment pass count. Next tick re-runs the same page. + - `parked`: the only remaining gap is out of scope, or the pass cap is hit. + - `perfected`: no skill-side gap remains. +6. **Update `iteration-log.md`**: new state, pass count, link to the tick's `findings.md`, and a one-line list of any skill edits applied this pass. +7. **End the tick.** The `/loop` runner schedules the next one. + +## Per-page perfection protocol (steps inside one pass) + +Reconstructed from iteration-1/2/3. Each pass produces a `findings.md` in its workspace dir. + +1. **Dry-run / inspect source.** Deep-populate the source record (per `SKILL.md` Step 8) and save the raw + deep JSON to `iteration-<N>/dry-run/`. Confirm the slice shapes and that nested component sub-fields resolve (the shallow-populate gotcha). +2. **Migrate.** Run the skill end-to-end: map → PUT draft → publish. Save the built payload to `iteration-<N>/dry-run/put-payload.json`. +3. **Visual diff, section by section.** Screenshot the v4 source (`strapi.io/...`) and the v5 target (`website-ui-omega.vercel.app/...`) and compare per section. Save to `iteration-<N>/screenshots/`. Use the live component library (`/dev/component-library`) when a candidate component's correct variant is unclear. +4. **Write `findings.md`.** For each section: source vs. target verdict. Classify every gap as **skill-side** (wrong UID, wrong field alias, wrong variant, bad populate spec, stale `visual-cache.json` entry) or **frontend-side / out-of-scope**. +5. **Close skill-side gaps.** Edit `SKILL.md` / `components-cheatsheet.csv` / `visual-cache.json` to fix each skill-side gap. (CSV is source of truth; keep Step 6 in `SKILL.md` in sync — see the skill's "Mapping references" note.) Record edits in the log. +6. **Judge:** if any skill-side gap was fixed this pass → `needs-another-pass` (re-verify next tick). If the only gaps left are out-of-scope → `parked` with the blocker. If clean → `perfected`. + +## Guardrails + +- **Visual validation is the PRIMARY signal (most important).** Each pass MUST use Playwright to render and screenshot both the v4 source and the v5 target, section by section. And the orchestrator MUST independently **view** (Read) the target full-page screenshot itself each tick — plus the source for any section the pass flags as a gap — rather than accepting the pass's prose verdict alone. Data checks (JSON/schema) catch dropped fields; only looking at the rendered page catches layout/variant/visual-fidelity regressions. If the target screenshot can't be produced, that's a blocker to resolve, not a step to skip. +- **Pass cap: 4 passes per page.** On the 5th would-be pass, force `parked` with reason `pass-cap-reached` plus the unresolved gap. Prevents an infinite polish loop. (Iteration-1 needed a large rewrite; later pages should converge in 1–2 passes.) +- **Never overwrite curated top-level fields** — that rule lives in the skill; the loop doesn't relax it. +- **Don't re-screenshot from cache blindly** — if a prior pass cached a visual decision that the current source contradicts, invalidate that `visual-cache.json` entry (it's a skill-side gap). +- **Autonomous** — no `AskUserQuestion` during the loop. Surface everything in the per-page `findings.md` and the final summary. + +## Stop conditions + +Stop the loop and post a final summary when **either**: + +- The queue is empty (no `queue` and no `in-progress` pages remain), or +- Every remaining page is `parked`. + +## Final summary (on stop) + +One compact table: per page → final state, pass count, workspace dir, one-line outcome. Plus an aggregated list of **skill edits applied across the whole loop** (the durable value) and a list of **parked blockers** grouped by type (frontend / missing-component / schema / missing-record) so the user knows exactly what's left and who owns it. diff --git a/.agents/skills/migrate-strapi-content/visual-cache.json b/.agents/skills/migrate-strapi-content/visual-cache.json new file mode 100644 index 0000000..4f58084 --- /dev/null +++ b/.agents/skills/migrate-strapi-content/visual-cache.json @@ -0,0 +1,200 @@ +{ + "slices.section-with-image:image+line+text+textPosition+title": { + "v5_component": "cards.feature-card", + "variant": "bordered", + "layout": "full", + "image_position": "flip_from_textPosition", + "emit_image": true, + "emit_ctaLinks": false, + "decided_at": "2026-05-25", + "evidence": { + "source": "iteration-8/screenshots/source-full.png", + "target": "iteration-8/screenshots/target-final-full.png" + }, + "override_reason": "Same card shape as the fuller version but without button/features/description fields. Cards on enterprise-intranet-cms have only title+text+image (no CTA). Still maps to cards.feature-card bordered/full with imagePosition flipped from textPosition. ctaLinks emitted as empty array. Confirmed rendering correctly on target." + }, + "slices.text-slice:content.title+content.text": { + "v5_component": "sections.section-header", + "variant": null, + "layout": "center", + "image_position": null, + "decided_at": "2026-05-25", + "evidence": { + "source": "iteration-8/screenshots/source-full.png", + "target": "iteration-8/screenshots/target-final-full.png" + }, + "override_reason": "slices.text-slice with content nested under a `content` sub-object (not top-level title/text) and no button/theme=purple. SKILL.md Step 6.1 says 'otherwise → sections.richtext' but this is a clean title+description heading block (alignCenter: true), not prose markdown — sections.section-header matches visually better. IMPORTANT populate note: `slices.text-slice` content lives under a `content` sub-field; populate[slices][populate]=* does NOT reach it. Must use populate[slices][on][slices.text-slice][populate]=* to reach content.title/content.text. Missed in initial populate spec — added to findings." + }, + "slices.stacking-cards:cards+title": { + "v5_component": "sections.feature-card-grid", + "variant": "plain", + "layout": "third", + "image_position": null, + "background": "light", + "decided_at": "2026-05-24", + "evidence": { + "source": "iteration-2/visual-pass/source-stacking-cards.png", + "candidate": "iteration-2/visual-pass/candidate-featurecardgrid.png" + }, + "override_reason": "Source 'Extend Strapi' renders as a sticky-left header + vertical stacking-card animation. User feedback: prefers v5 to use grid layout, not standalone cards. The dev-page 'FeatureCardGrid 3-column grid on light background' variant matches the user's intent. Old default emitted sections.section-header + N×standalone cards.content-card; this override wraps all cards in a single feature-card-grid with feature-card items (title + description) so the section is one cohesive component, not 7 loose ones." + }, + "slices.section-with-image:button+image+line+text+textPosition+title": { + "v5_component": "cards.feature-card", + "variant": "bordered", + "layout": "full", + "image_position": "flip_from_textPosition", + "emit_image": true, + "emit_ctaLinks": true, + "decided_at": "2026-05-25", + "evidence": { + "source": "iteration-9/screenshots/source-full.png", + "target": "iteration-9/screenshots/target-full.png" + }, + "override_reason": "mobile-cms section-with-image cards: title+text+image+button+line (no label/description/features). Same mapping as fuller shapes — cards.feature-card bordered/full, imagePosition flipped from textPosition, ctaLinks from button.link. Confirmed visually: all 4 feature cards render with image and CTA on target." + }, + "slices.section-with-image:button+image+label+line+text+textPosition+title": { + "v5_component": "cards.feature-card", + "variant": "bordered", + "layout": "full", + "image_position": "flip_from_textPosition", + "emit_image": true, + "emit_ctaLinks": true, + "decided_at": "2026-05-25", + "evidence": { + "source": "iteration-9/screenshots/source-full.png", + "target": "iteration-9/screenshots/target-full.png" + }, + "override_reason": "Same as button+image+line shape but with an empty label field present. Identical mapping: cards.feature-card bordered/full, imagePosition flip, ctaLinks from button.link. Empty label field is ignored (not sent to target)." + }, + "slices.section-with-image:button+description+features+image+text+textPosition+title": { + "v5_component": "cards.feature-card", + "variant": "bordered", + "layout": "full", + "image_position": "flip_from_textPosition", + "emit_image": true, + "emit_ctaLinks": true, + "decided_at": "2026-05-25", + "evidence": { + "source": "iteration-2/visual-pass/source-section-with-image.png", + "candidate": "iteration-2/visual-pass/candidate-featurecard-variants.png", + "after": "iteration-2/screenshots/04-target-AFTER-v3-images-ctas.png" + }, + "override_reason": "CORRECTED 2026-05-25. The earlier 'data gap' diagnosis was WRONG — it was a shallow-populate bug. The source DOES carry image (image.media) and CTA (button.link) per card; populate:'*' just returned image:{} and button:{theme:link} as empty shells. Fix: deep-populate image.media + button.link (now the Step 8 default), find-before-upload the media → utilities.basic-image {media,alt,width,height}, and emit ctaLinks from button.link. All 4 ecommerce-cms cards now render bordered with real images + 'Learn More' CTAs, alternating left/right. Lesson: an empty nested object is a populate symptom, never proof of missing data." + }, + "slices.content-cards-list:cards": { + "v5_component": "cards.content-card", + "variant": null, + "layout": null, + "image_position": null, + "decided_at": "2026-05-25", + "evidence": { + "source": "iteration-10/screenshots/source-full.png", + "candidate": "iteration-10/screenshots/candidate-content-card.png", + "target": "iteration-10/screenshots/target-full.png" + }, + "override_reason": "NEW slice type, first seen on cms-comparison strapi-vs-sanity. slices.content-cards-list has a cards[] array, each card {label, title, content} where content is rich markdown (incl. markdown tables and embedded iframes). Maps 1:1 to N standalone cards.content-card entries in the dynamic zone — exact field match (label→label, title→title, content→content REQUIRED richtext). Same precedent as slices.stacking-cards' content-card branch. Source renders each card as a titled prose/table block (H3 + body); component-library ContentCard variant renders identically incl. markdown tables. SKIP any card with empty content (content is required). POPULATE NOTE: reach cards via populate[slices][on][slices.content-cards-list][populate][cards][populate]=* — the generic populate[slices][populate][__all__]=* returned slices:[] (empty) on the comparators collection, a populate quirk; per-component 'on' populate is required." + }, + "slices.faq:categories": { + "v5_component": "sections.faq-section", + "variant": null, + "layout": null, + "image_position": null, + "decided_at": "2026-05-25", + "evidence": { + "source": "iteration-10/screenshots/source-full.png", + "target": "iteration-10/screenshots/target-full.png" + }, + "override_reason": "NEW slice type, first seen on cms-comparison strapi-vs-sanity. slices.faq nests questions under categories[]: each category has {name, questions[]} and each question is {question, answer, openByDefault}. Maps to a single sections.faq-section with items[] = FLATTENED questions across all categories, each → utilities.accordions {question, answer} (both REQUIRED). Category names are dropped (faq-section has no category grouping — flat accordion list). SKIP the slice if no question survives the filter. POPULATE NOTE: reach the questions via populate[slices][on][slices.faq][populate][categories][populate]=* — categories alone returns just {name} without questions." + }, + "slices.image-gallery:image": { + "v5_component": "media.image-gallery", + "variant": "contained", + "layout": null, + "image_position": null, + "decided_at": "2026-05-25", + "evidence": { + "source": "iteration-17/screenshots/source-full.png", + "candidate": "iteration-17/screenshots/candidate-image-gallery.png", + "target": "iteration-17/screenshots/target-full.png" + }, + "override_reason": "NEW slice UID (cheatsheet previously only had slices.image-slider). slices.image-gallery has image[] (mosaic, ~8 imgs) → media.image-gallery variant=contained; images uploaded+deduped from old CDN via the brand-logo/image-slider find-before-upload routine. Reach via populate[slices][on][slices.image-gallery][populate][image][populate]=*. SKIP if no images survive. Confirmed render matches on /about-us." + }, + "slices.team-slice:intro": { + "v5_component": "SKIP", + "variant": null, + "layout": null, + "image_position": null, + "decided_at": "2026-05-25", + "evidence": { + "source": "iteration-17/screenshots/source-full.png", + "candidate": "iteration-17/screenshots/candidate-meet-the-team.png" + }, + "override_reason": "NEW slice UID. On the old site team MEMBERS are frontend-auto-fetched (not stored on the slice — source has intro only, no members[]). The v5 sections.meet-the-team REQUIRES items[] (elements.team-member-item, role required) and does NOT auto-fetch. So with no source members there is nothing to fill → SKIP. Only map (intro→section + members→items) if a future team-slice actually carries members[]." + }, + "slices.features-grid:cards": { + "v5_component": "sections.feature-card-grid", + "variant": "plain", + "layout": "third", + "image_position": null, + "decided_at": "2026-05-25", + "evidence": { "source": "iteration-18/screenshots/source-full.png", "target": "iteration-18/screenshots/target-full.png" }, + "override_reason": "NEW UID (distinct from features-slice/features-card). cards[] {title, description=text, icon, button} -> sections.feature-card-grid, items=cards.feature-card with variant=PLAIN + layout=THIRD (or half for 2-col) — NEVER full/bordered/imagePosition (those are for single image-split cards; full makes full-width boxes = wrong). Source per-card icon -> feature-card icon field. ctaLinks from button.link. section from LABEL/TITLE/DESCRIPTION." + }, + "slices.features-slice:cards": { + "v5_component": "sections.feature-card-grid", + "variant": "plain", + "layout": "third", + "image_position": null, + "decided_at": "2026-05-25", + "evidence": { "source": "https://strapi.io/financial-services", "target": "iteration-37 (fixed)" }, + "override_reason": "Plain multi-column tile grid (icon/title/text). items=cards.feature-card variant=PLAIN, layout = (source layout 'two'->half else 'three'/default->third), NO imagePosition, source card.icon -> feature-card.icon. The financial-services 'Any channel, any device' 3-col section. (Was wrongly emitted full/bordered/imagePosition — corrected per user feedback.)" + }, + "slices.two-columns-benefits:benefits": { + "v5_component": "sections.two-columns-benefits", + "variant": null, + "layout": null, + "image_position": null, + "decided_at": "2026-05-25", + "evidence": { "source": "iteration-18/screenshots/source-full.png", "target": "iteration-18/screenshots/target-full.png" }, + "override_reason": "v5-NATIVE same-named component. benefits[] {title, description=text, icon} -> items=elements.how-it-works-item, 1:1. Confirmed render." + }, + "slices.capabilities-dynamic-cards:cards": { + "v5_component": "sections.feature-card-grid", + "variant": null, + "layout": null, + "image_position": null, + "decided_at": "2026-05-25", + "evidence": { "source": "iteration-18/screenshots/source-full.png", "target": "iteration-18/screenshots/target-full.png" }, + "emit_ctaLinks": true, + "override_reason": "cards[] with per-card images -> sections.feature-card-grid (NOT tabbed-feature-overview; differs from slices.capability-cards). Per-card image via populate[slices][on][slices.capabilities-dynamic-cards][populate][cards][populate][image][populate][media]=true (generic cards.image populate=* returns image:{} shallow). EACH CARD carries a FLAT button {href,text,target} -> emit ctaLinks; prior migrations dropped these 'Learn more' CTAs (fixed on /content-management + /create-apis). Confirmed render." + }, + "slices.icon-cards:cards": { + "v5_component": "sections.feature-card-grid", + "variant": "plain", + "layout": "half", + "image_position": null, + "emit_ctaLinks": true, + "decided_at": "2026-05-25", + "evidence": { "source": "iteration-51/screenshots/source-icon-cards.png", "candidate": "iteration-51/screenshots/candidate-feature-card-grid.png" }, + "override_reason": "NEW UID, first seen on /pricing universal. slices.icon-cards has cards[] each {theme, link:{href,text,target}, card:{title,text}} — NO icon/image media. Each card carries a CTA link ('Read More', 'Become a partner'). The target had mis-mapped this to sections.two-column-grid (how-it-works-item) which DROPS the CTAs (how-it-works-item has only icon/title/description). Correct mapping: sections.feature-card-grid, items=cards.feature-card variant=plain, layout=half (2 cards / 2-col), NO imagePosition; title=card.card.title, description=card.card.text, ctaLinks=[resolveLink(card.link)]. Restores the dropped CTAs while keeping title+description. section from LABEL/TITLE/DESCRIPTION (icon-cards has none -> omit/empty section)." + }, + "slices.reviews-slider:intro+reviews": { + "v5_component": "sections.reviews", + "variant": null, + "layout": null, + "image_position": null, + "decided_at": "2026-05-25", + "evidence": { "source": "https://strapi.io/pricing-cloud", "candidate": "iteration-51/screenshots/candidate-reviews.png" }, + "override_reason": "Confirms cheatsheet preferred mapping. slices.reviews-slider with inline reviews.data[].attributes.{quote, author:{name,description}} -> sections.reviews {title (required, from intro.title), subTitle from LABEL, description from DESCRIPTION, reviews=<resolved api::review.review documentIds matched by authorName>}. Target api/reviews collection IS seeded (13 records) — all 3 pricing authors (Guillermo Rauch, Francois Duprat, Jerome Chauveau) resolve, so use the RELATION branch (not the testimonials.quote fallback). The target had mis-mapped this to a bare sections.section-header that kept only the heading and DROPPED all 3 review quotes — fixed to sections.reviews which renders heading + 3 review cards with quote/author/role/avatar." + }, + "slices.text-with-key-numbers:keyNumber@case-study": { + "v5_component": "sections.feature-card-grid", + "variant": "plain", + "layout": "third", + "image_position": null, + "background": "none", + "decided_at": "2026-05-25", + "evidence": { "source": "iteration-70/screenshots/source-full.png", "target": "iteration-70/screenshots/target-full.png" }, + "override_reason": "On a CASE-STUDY target the cheatsheet default sections.three-column-grid is NOT in the case-study content dynamic-zone allowlist (only two-column-grid/feature-card-grid/richtext etc. are). Closest allowed 3-column stat renderer is sections.feature-card-grid variant=plain layout=third (3 stats -> 3 columns), items=cards.feature-card {title:keyNumber.number, description:keyNumber.text}. section header OMITTED (the key-numbers slice carries no intro; section is optional on feature-card-grid, required on two-column-grid so feature-card-grid is the better fit). Confirmed render on /user-stories/airbus: +57% / 3 frontends / +67% as a clean 3-up stat row. NOTE: scope-qualified key (@case-study) — on pages where three-column-grid IS allowed, keep the cheatsheet default." + } +} diff --git a/apps/ui/src/app/[locale]/blog/[slug]/page.tsx b/apps/ui/src/app/[locale]/blog/[slug]/page.tsx index c9b74b2..a45ed8f 100644 --- a/apps/ui/src/app/[locale]/blog/[slug]/page.tsx +++ b/apps/ui/src/app/[locale]/blog/[slug]/page.tsx @@ -4,7 +4,7 @@ import { use } from "react" import { StrapiBlogPostView } from "@/components/layouts/StrapiBlogPostView" import { createFallbackPath, debugStaticParams } from "@/lib/build" import { isDevelopment } from "@/lib/general-helpers" -import { getBlogPostMetadata } from "@/lib/metadata/blog" +import { getBlogPostMetadata } from "@/lib/metadata" import { fetchAllBlogPosts } from "@/lib/strapi-api/content/server" export const dynamic = "force-static" diff --git a/apps/ui/src/app/[locale]/blog/categories/[slug]/page.tsx b/apps/ui/src/app/[locale]/blog/categories/[slug]/page.tsx index 3177a43..6ebbb1d 100644 --- a/apps/ui/src/app/[locale]/blog/categories/[slug]/page.tsx +++ b/apps/ui/src/app/[locale]/blog/categories/[slug]/page.tsx @@ -13,7 +13,10 @@ import { } from "@/components/elementary/HeroContainer" import { InlineMarkdown } from "@/components/elementary/markdown/InlineMarkdown" import { NewsletterSignup } from "@/components/newsletter/NewsletterSignup" +import { StrapiSeoStructuredDataFromSeo } from "@/components/page-builder/components/seo-utilities/StrapiSeoStructuredData" import { getBlogNewsletterHubspot, type BlogPost } from "@/lib/blog-utils" +import { getPostCategoryMetadata } from "@/lib/metadata" +import type { SeoComponent } from "@/lib/metadata/build-from-seo" import { fetchBlog, fetchBlogPostsList, @@ -24,11 +27,7 @@ type CategoryWithExtras = { name?: string | null slug?: string | null description?: string | null - seo?: { - metaTitle?: string | null - metaDescription?: string | null - keywords?: string | null - } | null + seo?: SeoComponent | null children?: ({ slug?: string | null } | null)[] | null } @@ -58,20 +57,13 @@ export async function generateMetadata(props: { params: Promise<{ locale: string; slug: string }> }): Promise<Metadata> { const { slug, locale } = await props.params - const res = await fetchPostCategory(slug, locale as Locale) - const category = res?.data as CategoryWithExtras | undefined - const seo = category?.seo - - const fallbackName = slug - .replaceAll("-", " ") - .replaceAll(/\b\w/g, (c) => c.toUpperCase()) - const name = category?.name ?? fallbackName - - return { - title: seo?.metaTitle || `${name} — Blog`, - description: seo?.metaDescription ?? undefined, - keywords: seo?.keywords ?? undefined, - } + + return ( + (await getPostCategoryMetadata({ + slug, + locale: locale as Locale, + })) ?? {} + ) } export default function BlogCategoryPage( @@ -105,32 +97,35 @@ export default function BlogCategoryPage( const categoryName = category?.name ?? featuredPost?.category?.name ?? slug return ( - <HeroContainer affectsNavbarTheme className="gap-0"> - <BlogNavbar locale={locale} /> - - <HeroContainerContent className="animate-reveal-cascade border-strapi-gray-700/50 flex flex-col gap-10 border-b"> - <div className="flex flex-col gap-6"> - <BlogBreadcrumbs category={{ name: categoryName, slug }} /> - - <h1 className="text-3xl font-semibold tracking-tight text-white sm:text-4xl"> - {categoryName} - </h1> - - {category?.description && ( - <div className="text-background/60 max-w-full lg:max-w-1/2 [&_p:last-child]:mb-0"> - <InlineMarkdown>{category.description}</InlineMarkdown> - </div> - )} - </div> - - {featuredPost && <FeaturedBlogPost post={featuredPost} />} - - <BlogPostsList posts={remainingPosts} loadMoreLabel={t("loadMore")} /> - </HeroContainerContent> - - <HeroContainerContent className="animate-reveal-cascade flex flex-col gap-10 [--reveal-delay:680ms]"> - <NewsletterSignup presentation="banner" hubspotForm={hubspotForm} /> - </HeroContainerContent> - </HeroContainer> + <> + <StrapiSeoStructuredDataFromSeo seo={category?.seo} /> + <HeroContainer affectsNavbarTheme className="gap-0"> + <BlogNavbar locale={locale} /> + + <HeroContainerContent className="animate-reveal-cascade border-strapi-gray-700/50 flex flex-col gap-10 border-b"> + <div className="flex flex-col gap-6"> + <BlogBreadcrumbs category={{ name: categoryName, slug }} /> + + <h1 className="text-3xl font-semibold tracking-tight text-white sm:text-4xl"> + {categoryName} + </h1> + + {category?.description && ( + <div className="text-background/60 max-w-full lg:max-w-1/2 [&_p:last-child]:mb-0"> + <InlineMarkdown>{category.description}</InlineMarkdown> + </div> + )} + </div> + + {featuredPost && <FeaturedBlogPost post={featuredPost} />} + + <BlogPostsList posts={remainingPosts} loadMoreLabel={t("loadMore")} /> + </HeroContainerContent> + + <HeroContainerContent className="animate-reveal-cascade flex flex-col gap-10 [--reveal-delay:680ms]"> + <NewsletterSignup presentation="banner" hubspotForm={hubspotForm} /> + </HeroContainerContent> + </HeroContainer> + </> ) } diff --git a/apps/ui/src/app/[locale]/blog/page.tsx b/apps/ui/src/app/[locale]/blog/page.tsx index 6f29a07..57ea76c 100644 --- a/apps/ui/src/app/[locale]/blog/page.tsx +++ b/apps/ui/src/app/[locale]/blog/page.tsx @@ -11,8 +11,9 @@ import { HeroContainerContent, } from "@/components/elementary/HeroContainer" import { NewsletterSignup } from "@/components/newsletter/NewsletterSignup" +import { StrapiSeoStructuredDataByFullPath } from "@/components/page-builder/components/seo-utilities/StrapiSeoStructuredData" import { getBlogNewsletterHubspot, type BlogPost } from "@/lib/blog-utils" -import { routing } from "@/lib/navigation" +import { getBlogIndexMetadata } from "@/lib/metadata" import { fetchBlog, fetchBlogPostsList } from "@/lib/strapi-api/content/server" export const dynamic = "force-static" @@ -21,22 +22,8 @@ export async function generateMetadata(props: { params: Promise<{ locale: string }> }): Promise<Metadata> { const { locale } = await props.params - const t = await getTranslations({ - locale: locale as "en", - namespace: "blog", - }) - const localePath = routing.defaultLocale !== locale ? `/${locale}` : "" - - return { - title: t("title"), - description: t("description"), - alternates: { - types: { - "application/rss+xml": `${localePath}/blog/rss.xml`, - }, - }, - } + return (await getBlogIndexMetadata({ locale: locale as Locale })) ?? {} } export default function BlogIndexPage(props: PageProps<"/[locale]/blog">) { @@ -58,18 +45,21 @@ export default function BlogIndexPage(props: PageProps<"/[locale]/blog">) { const remainingPosts: BlogPost[] = allPosts?.data.slice(1) ?? [] return ( - <HeroContainer affectsNavbarTheme className="gap-0"> - <BlogNavbar locale={locale} /> + <> + <StrapiSeoStructuredDataByFullPath fullPath="/blog" locale={locale} /> + <HeroContainer affectsNavbarTheme className="gap-0"> + <BlogNavbar locale={locale} /> - <HeroContainerContent className="animate-reveal-cascade border-strapi-gray-700/50 flex flex-col gap-10 border-b"> - {featuredPost && <FeaturedBlogPost post={featuredPost} />} + <HeroContainerContent className="animate-reveal-cascade border-strapi-gray-700/50 flex flex-col gap-10 border-b"> + {featuredPost && <FeaturedBlogPost post={featuredPost} />} - <BlogPostsList posts={remainingPosts} loadMoreLabel={t("loadMore")} /> - </HeroContainerContent> + <BlogPostsList posts={remainingPosts} loadMoreLabel={t("loadMore")} /> + </HeroContainerContent> - <HeroContainerContent className="animate-reveal-cascade flex flex-col gap-10 [--reveal-delay:680ms]"> - <NewsletterSignup presentation="banner" hubspotForm={hubspotForm} /> - </HeroContainerContent> - </HeroContainer> + <HeroContainerContent className="animate-reveal-cascade flex flex-col gap-10 [--reveal-delay:680ms]"> + <NewsletterSignup presentation="banner" hubspotForm={hubspotForm} /> + </HeroContainerContent> + </HeroContainer> + </> ) } diff --git a/apps/ui/src/app/[locale]/blog/tags/[slug]/page.tsx b/apps/ui/src/app/[locale]/blog/tags/[slug]/page.tsx index 191cbc6..99b7c0a 100644 --- a/apps/ui/src/app/[locale]/blog/tags/[slug]/page.tsx +++ b/apps/ui/src/app/[locale]/blog/tags/[slug]/page.tsx @@ -13,7 +13,10 @@ import { } from "@/components/elementary/HeroContainer" import { InlineMarkdown } from "@/components/elementary/markdown/InlineMarkdown" import { NewsletterSignup } from "@/components/newsletter/NewsletterSignup" +import { StrapiSeoStructuredDataFromSeo } from "@/components/page-builder/components/seo-utilities/StrapiSeoStructuredData" import { getBlogNewsletterHubspot, type BlogPost } from "@/lib/blog-utils" +import { getPostTagMetadata } from "@/lib/metadata" +import type { SeoComponent } from "@/lib/metadata/build-from-seo" import { fetchAllPostTags, fetchBlog, @@ -25,11 +28,7 @@ type TagWithExtras = { name?: string | null slug?: string | null description?: string | null - seo?: { - metaTitle?: string | null - metaDescription?: string | null - keywords?: string | null - } | null + seo?: SeoComponent | null } export const dynamic = "force-static" @@ -53,20 +52,8 @@ export async function generateMetadata(props: { params: Promise<{ locale: string; slug: string }> }): Promise<Metadata> { const { slug, locale } = await props.params - const res = await fetchPostTag(slug, locale as Locale) - const tag = res?.data as TagWithExtras | undefined - const seo = tag?.seo - - const fallbackName = slug - .replaceAll("-", " ") - .replaceAll(/\b\w/g, (c) => c.toUpperCase()) - const name = tag?.name ?? fallbackName - - return { - title: seo?.metaTitle || `${name} — Blog`, - description: seo?.metaDescription ?? undefined, - keywords: seo?.keywords ?? undefined, - } + + return (await getPostTagMetadata({ slug, locale: locale as Locale })) ?? {} } export default function BlogTagPage( @@ -96,32 +83,35 @@ export default function BlogTagPage( const tagName = tag?.name ?? slug return ( - <HeroContainer affectsNavbarTheme className="gap-0"> - <BlogNavbar locale={locale} /> - - <HeroContainerContent className="animate-reveal-cascade border-strapi-gray-700/50 flex flex-col gap-10 border-b"> - <div className="flex flex-col gap-6"> - <BlogBreadcrumbs tag={{ name: tagName, slug }} /> - - <h1 className="text-3xl font-semibold tracking-tight text-white sm:text-4xl"> - {tagName} - </h1> - - {tag?.description && ( - <div className="text-strapi-gray-300 max-w-3xl [&_p]:text-base [&_p:last-child]:mb-0"> - <InlineMarkdown>{tag.description}</InlineMarkdown> - </div> - )} - </div> - - {featuredPost && <FeaturedBlogPost post={featuredPost} />} - - <BlogPostsList posts={remainingPosts} loadMoreLabel={t("loadMore")} /> - </HeroContainerContent> - - <HeroContainerContent className="animate-reveal-cascade flex flex-col gap-10 [--reveal-delay:680ms]"> - <NewsletterSignup presentation="banner" hubspotForm={hubspotForm} /> - </HeroContainerContent> - </HeroContainer> + <> + <StrapiSeoStructuredDataFromSeo seo={tag?.seo} /> + <HeroContainer affectsNavbarTheme className="gap-0"> + <BlogNavbar locale={locale} /> + + <HeroContainerContent className="animate-reveal-cascade border-strapi-gray-700/50 flex flex-col gap-10 border-b"> + <div className="flex flex-col gap-6"> + <BlogBreadcrumbs tag={{ name: tagName, slug }} /> + + <h1 className="text-3xl font-semibold tracking-tight text-white sm:text-4xl"> + {tagName} + </h1> + + {tag?.description && ( + <div className="text-strapi-gray-300 max-w-3xl [&_p]:text-base [&_p:last-child]:mb-0"> + <InlineMarkdown>{tag.description}</InlineMarkdown> + </div> + )} + </div> + + {featuredPost && <FeaturedBlogPost post={featuredPost} />} + + <BlogPostsList posts={remainingPosts} loadMoreLabel={t("loadMore")} /> + </HeroContainerContent> + + <HeroContainerContent className="animate-reveal-cascade flex flex-col gap-10 [--reveal-delay:680ms]"> + <NewsletterSignup presentation="banner" hubspotForm={hubspotForm} /> + </HeroContainerContent> + </HeroContainer> + </> ) } diff --git a/apps/ui/src/app/[locale]/dev/layout.tsx b/apps/ui/src/app/[locale]/dev/layout.tsx index 7d8e3ee..9c2c859 100644 --- a/apps/ui/src/app/[locale]/dev/layout.tsx +++ b/apps/ui/src/app/[locale]/dev/layout.tsx @@ -1,8 +1,24 @@ +import type { Metadata } from "next" import { notFound } from "next/navigation" +import type { Locale } from "next-intl" import { DevNavbar } from "@/app/[locale]/dev/components/DevNavbar" import { Container } from "@/components/elementary/Container" import { isProduction } from "@/lib/general-helpers" +import { getMetadataFromStrapi } from "@/lib/metadata" + +export async function generateMetadata( + props: LayoutProps<"/[locale]/dev"> +): Promise<Metadata> { + const { locale } = await props.params + + return ( + (await getMetadataFromStrapi({ + locale: locale as Locale, + customMetadata: { title: "Developer tools" }, + })) ?? {} + ) +} export default async function Layout({ children, diff --git a/apps/ui/src/app/[locale]/headless-cms/comparison/[slug]/page.tsx b/apps/ui/src/app/[locale]/headless-cms/comparison/[slug]/page.tsx index 1a33c02..8b690f3 100644 --- a/apps/ui/src/app/[locale]/headless-cms/comparison/[slug]/page.tsx +++ b/apps/ui/src/app/[locale]/headless-cms/comparison/[slug]/page.tsx @@ -4,7 +4,7 @@ import { use } from "react" import { CmsComparisonView } from "@/components/cms-comparison/CmsComparisonView" import { createFallbackPath, debugStaticParams } from "@/lib/build" import { isDevelopment } from "@/lib/general-helpers" -import { getCmsComparisonMetadata } from "@/lib/metadata/cms-comparison" +import { getCmsComparisonMetadata } from "@/lib/metadata" import { fetchAllCmsComparisons } from "@/lib/strapi-api/content/server" export const dynamic = "force-static" diff --git a/apps/ui/src/app/[locale]/not-found.tsx b/apps/ui/src/app/[locale]/not-found.tsx index 228c7ee..c845153 100644 --- a/apps/ui/src/app/[locale]/not-found.tsx +++ b/apps/ui/src/app/[locale]/not-found.tsx @@ -1,3 +1,4 @@ +import type { Metadata } from "next" import type { Locale } from "next-intl" import { getLocale } from "next-intl/server" @@ -9,10 +10,17 @@ import { import { StrapiBasicImage } from "@/components/page-builder/components/utilities/StrapiBasicImage" import { DynamicZoneRenderer } from "@/components/page-builder/DynamicZoneRenderer" import { buttonVariants } from "@/components/ui/button" +import { getMetadataFromStrapi } from "@/lib/metadata" import { Link } from "@/lib/navigation" import { fetchNotFound } from "@/lib/strapi-api/content/server" import { cn } from "@/lib/styles" +export async function generateMetadata(): Promise<Metadata> { + const locale = (await getLocale()) as Locale + + return (await getMetadataFromStrapi({ locale })) ?? {} +} + export default async function NotFound() { const locale = (await getLocale()) as Locale const notFound = (await fetchNotFound(locale))?.data diff --git a/apps/ui/src/app/[locale]/user-stories/[slug]/page.tsx b/apps/ui/src/app/[locale]/user-stories/[slug]/page.tsx index 110f395..8d10a4f 100644 --- a/apps/ui/src/app/[locale]/user-stories/[slug]/page.tsx +++ b/apps/ui/src/app/[locale]/user-stories/[slug]/page.tsx @@ -3,7 +3,7 @@ import type { Locale } from "next-intl" import { CaseStudyView } from "@/components/case-study/CaseStudyView" import { createFallbackPath, debugStaticParams } from "@/lib/build" import { isDevelopment } from "@/lib/general-helpers" -import { getCaseStudyMetadata } from "@/lib/metadata/case-study" +import { getCaseStudyMetadata } from "@/lib/metadata" import { fetchAllCaseStudies } from "@/lib/strapi-api/content/server" export const dynamic = "force-static" diff --git a/apps/ui/src/components/case-study/CaseStudyView.tsx b/apps/ui/src/components/case-study/CaseStudyView.tsx index 1f38a05..eb05a51 100644 --- a/apps/ui/src/components/case-study/CaseStudyView.tsx +++ b/apps/ui/src/components/case-study/CaseStudyView.tsx @@ -17,6 +17,7 @@ import { SectionLabel, SectionTitle, } from "@/components/elementary/section-header" +import { StrapiSeoStructuredDataFromSeo } from "@/components/page-builder/components/seo-utilities/StrapiSeoStructuredData" import { StrapiBasicImage } from "@/components/page-builder/components/utilities/StrapiBasicImage" import { DynamicZoneRenderer } from "@/components/page-builder/DynamicZoneRenderer" import { fetchCaseStudy } from "@/lib/strapi-api/content/server" @@ -47,6 +48,7 @@ export async function CaseStudyView({ params }: CaseStudyViewProps) { return ( <> + <StrapiSeoStructuredDataFromSeo seo={caseStudy.seo} /> <HeroContainer affectsNavbarTheme> <HeroContainerContent> <HeroContainerBorder> diff --git a/apps/ui/src/components/cms-comparison/CmsComparisonView.tsx b/apps/ui/src/components/cms-comparison/CmsComparisonView.tsx index 05f2ef0..e111e43 100644 --- a/apps/ui/src/components/cms-comparison/CmsComparisonView.tsx +++ b/apps/ui/src/components/cms-comparison/CmsComparisonView.tsx @@ -1,3 +1,4 @@ +import type { Data } from "@repo/strapi-types" import { notFound } from "next/navigation" import type { Locale } from "next-intl" import { getTranslations, setRequestLocale } from "next-intl/server" @@ -6,6 +7,7 @@ import { use } from "react" import { Container } from "@/components/elementary/Container" import { Disclaimer } from "@/components/elementary/disclaimer/Disclaimer" import { StrapiComparatorGrid } from "@/components/page-builder/components/sections/StrapiComparatorGrid" +import { StrapiSeoStructuredDataFromSeo } from "@/components/page-builder/components/seo-utilities/StrapiSeoStructuredData" import { DynamicZoneRenderer } from "@/components/page-builder/DynamicZoneRenderer" import { buildComparatorWithCMS, @@ -52,7 +54,9 @@ export function CmsComparisonView({ params }: CmsComparisonViewProps) { ]) ) - const comparison = comparisonRes?.data + const comparison = comparisonRes?.data as + | Data.ContentType<"api::cms-comparison.cms-comparison"> + | undefined if (!comparison) { notFound() @@ -66,6 +70,7 @@ export function CmsComparisonView({ params }: CmsComparisonViewProps) { return ( <> + <StrapiSeoStructuredDataFromSeo seo={comparison.seo} /> <HeroContainer affectsNavbarTheme> <HeroContainerContent> <HeroContainerBorder> diff --git a/apps/ui/src/components/elementary/Breadcrumbs.tsx b/apps/ui/src/components/elementary/Breadcrumbs.tsx index b142a35..6f378ee 100644 --- a/apps/ui/src/components/elementary/Breadcrumbs.tsx +++ b/apps/ui/src/components/elementary/Breadcrumbs.tsx @@ -16,5 +16,10 @@ export function Breadcrumbs({ breadcrumbs, locale }: Props) { const breadcrumbListSchema = generateBreadcrumbListSchema(breadcrumbs, locale) - return <StrapiStructuredData structuredData={breadcrumbListSchema} /> + return ( + <StrapiStructuredData + structuredData={breadcrumbListSchema} + id="breadcrumbListStructuredData" + /> + ) } diff --git a/apps/ui/src/components/layouts/StrapiBlogPostView.tsx b/apps/ui/src/components/layouts/StrapiBlogPostView.tsx index 2efb678..fe21397 100644 --- a/apps/ui/src/components/layouts/StrapiBlogPostView.tsx +++ b/apps/ui/src/components/layouts/StrapiBlogPostView.tsx @@ -11,6 +11,7 @@ import { BlogAutoRelatedPosts } from "@/components/blog/BlogAutoRelatedPosts" import { BlogContent } from "@/components/blog/BlogContent" import { BlogSocialShare } from "@/components/blog/BlogSocialShare" import { Container } from "@/components/elementary/Container" +import { StrapiSeoStructuredDataFromSeo } from "@/components/page-builder/components/seo-utilities/StrapiSeoStructuredData" import { DynamicZoneRenderer } from "@/components/page-builder/DynamicZoneRenderer" import { extractHeadings, type BlogPost } from "@/lib/blog-utils" import { getEnvVar } from "@/lib/env-vars" @@ -63,18 +64,25 @@ export function StrapiBlogPostView({ params }: Props) { ? new URL(`${localePath}/blog/${slug}`, siteUrl).toString() : `${localePath}/blog/${slug}` - const jsonLd = buildBlogPostingJsonLd({ - post, - url: postUrl, - siteUrl: siteUrl || undefined, - }) + const hasStrapiStructuredData = Boolean(post.seo?.structuredData) + const jsonLd = hasStrapiStructuredData + ? null + : buildBlogPostingJsonLd({ + post, + url: postUrl, + siteUrl: siteUrl || undefined, + }) return ( <> - <script - type="application/ld+json" - dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} - /> + <StrapiSeoStructuredDataFromSeo seo={post.seo} /> + {jsonLd && ( + <script + id="blogPostingStructuredData" + type="application/ld+json" + dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} + /> + )} {content && <BlogReadingProgress />} diff --git a/apps/ui/src/components/layouts/StrapiPageView.tsx b/apps/ui/src/components/layouts/StrapiPageView.tsx index 82fd2aa..0140969 100644 --- a/apps/ui/src/components/layouts/StrapiPageView.tsx +++ b/apps/ui/src/components/layouts/StrapiPageView.tsx @@ -7,7 +7,7 @@ import { use } from "react" import { Breadcrumbs } from "@/components/elementary/Breadcrumbs" import { Container } from "@/components/elementary/Container" import { MinimalHeader } from "@/components/layouts/MinimalHeader" -import { StrapiStructuredData } from "@/components/page-builder/components/seo-utilities/StrapiStructuredData" +import { StrapiSeoStructuredDataFromSeo } from "@/components/page-builder/components/seo-utilities/StrapiSeoStructuredData" import { DynamicZoneRenderer } from "@/components/page-builder/DynamicZoneRenderer" import { fetchPage } from "@/lib/strapi-api/content/server" import { cn } from "@/lib/styles" @@ -60,7 +60,7 @@ export function StrapiPageView({ params, searchParams }: Props) { <div className="flex w-full flex-col"> {minimalLayout && <div data-minimal-layout hidden />} {minimalLayout && <MinimalHeader />} - <StrapiStructuredData structuredData={data?.seo?.structuredData} /> + <StrapiSeoStructuredDataFromSeo seo={data?.seo} /> <main className={cn("flex w-full flex-col")}> <Container> <Breadcrumbs diff --git a/apps/ui/src/components/page-builder/components/seo-utilities/StrapiSeoStructuredData.tsx b/apps/ui/src/components/page-builder/components/seo-utilities/StrapiSeoStructuredData.tsx new file mode 100644 index 0000000..5ef2053 --- /dev/null +++ b/apps/ui/src/components/page-builder/components/seo-utilities/StrapiSeoStructuredData.tsx @@ -0,0 +1,43 @@ +import type { Locale } from "next-intl" + +import type { SeoComponent } from "@/lib/metadata/build-from-seo" +import { fetchSeo } from "@/lib/strapi-api/content/server" + +import { StrapiStructuredData } from "./StrapiStructuredData" + +interface FromSeoProps { + readonly seo?: SeoComponent | null + readonly scriptId?: string +} + +/** Renders JSON-LD from an already-loaded `shared.seo` component. */ +export function StrapiSeoStructuredDataFromSeo({ + seo, + scriptId = "strapiSeoStructuredData", +}: FromSeoProps) { + return ( + <StrapiStructuredData structuredData={seo?.structuredData} id={scriptId} /> + ) +} + +interface ByFullPathProps { + readonly fullPath: string + readonly locale: Locale + readonly scriptId?: string +} + +/** Loads page SEO by `fullPath` and renders JSON-LD when `structuredData` is set. */ +export async function StrapiSeoStructuredDataByFullPath({ + fullPath, + locale, + scriptId = "strapiSeoStructuredData", +}: ByFullPathProps) { + const res = await fetchSeo("api::page.page", fullPath, locale) + + return ( + <StrapiStructuredData + structuredData={res?.data?.seo?.structuredData} + id={scriptId} + /> + ) +} diff --git a/apps/ui/src/components/page-builder/components/seo-utilities/StrapiStructuredData.tsx b/apps/ui/src/components/page-builder/components/seo-utilities/StrapiStructuredData.tsx index fcf4f85..534968b 100644 --- a/apps/ui/src/components/page-builder/components/seo-utilities/StrapiStructuredData.tsx +++ b/apps/ui/src/components/page-builder/components/seo-utilities/StrapiStructuredData.tsx @@ -2,8 +2,10 @@ import type { Data } from "@repo/strapi-types" export function StrapiStructuredData({ structuredData, + id = "strapiStructuredData", }: { structuredData: Data.Component<"shared.seo">["structuredData"] + id?: string }) { if (structuredData) { // we need to use a plain `script` tag instead of the `Script` component @@ -12,7 +14,7 @@ export function StrapiStructuredData({ // - if no id is specified, a new script tag will be added with the new content, which schema validators are not able to parse. // `script` tag is properly re-rendered and replaced with the new content return ( - <script id="articleStructuredData" type="application/ld+json"> + <script id={id} type="application/ld+json"> {JSON.stringify(structuredData)} </script> ) diff --git a/apps/ui/src/lib/metadata/blog.ts b/apps/ui/src/lib/metadata/blog.ts deleted file mode 100644 index acc8828..0000000 --- a/apps/ui/src/lib/metadata/blog.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { mergeWith } from "lodash" -import type { Metadata } from "next" -import type { Locale } from "next-intl" -import { getTranslations } from "next-intl/server" - -import { getEnvVar } from "@/lib/env-vars" -import { isProduction } from "@/lib/general-helpers" -import { - getDefaultMetadata, - getDefaultOgMeta, - getDefaultTwitterMeta, -} from "@/lib/metadata/defaults" -import { - getMetaRobots, - preprocessSocialMetadata, - seoMergeCustomizer, -} from "@/lib/metadata/helpers" -import { - fetchBlogPostSeo, - fetchGlobalSeo, -} from "@/lib/strapi-api/content/server" - -export async function getBlogPostMetadata({ - slug, - locale, -}: { - slug: string - locale: Locale -}): Promise<Metadata | null> { - const t = await getTranslations({ locale, namespace: "seo" }) - const siteUrl = getEnvVar("APP_PUBLIC_URL") - - if (!siteUrl) { - return null - } - - const translationMeta = getDefaultMetadata(siteUrl, t) - const translationOgMeta = getDefaultOgMeta(locale, `/blog/${slug}`, t) - const translationTwitterMeta = getDefaultTwitterMeta(t) - - const globalRes = await fetchGlobalSeo() - const globalSeo = globalRes?.data?.defaultSeo - - const globalStrapiMeta: Metadata = { - title: globalSeo?.metaTitle, - description: globalSeo?.metaDescription, - keywords: globalSeo?.keywords, - } - const globalSocialMeta = preprocessSocialMetadata(globalSeo) - - const defaultMeta = mergeWith( - translationMeta, - globalStrapiMeta, - seoMergeCustomizer - ) - const defaultOgMeta = mergeWith( - translationOgMeta, - globalSocialMeta.openGraph, - seoMergeCustomizer - ) - const defaultTwitterMeta = mergeWith( - translationTwitterMeta, - globalSocialMeta.twitter, - seoMergeCustomizer - ) - - try { - const res = await fetchBlogPostSeo(slug, locale) - const post = res?.data - const seo = post?.seo - - if (!seo) { - return { - ...defaultMeta, - openGraph: defaultOgMeta, - twitter: defaultTwitterMeta, - } - } - - const strapiMeta: Metadata = { - title: seo.metaTitle || post?.title || undefined, - description: seo.metaDescription || post?.description || undefined, - keywords: seo.keywords, - } - - const forbidIndexing = !isProduction() - const robots = getMetaRobots(seo.metaRobots, forbidIndexing) - const strapiSocialMeta = preprocessSocialMetadata(seo) - - return { - ...mergeWith(defaultMeta, strapiMeta, seoMergeCustomizer), - openGraph: mergeWith( - defaultOgMeta, - strapiSocialMeta.openGraph, - seoMergeCustomizer - ), - twitter: mergeWith( - defaultTwitterMeta, - strapiSocialMeta.twitter, - seoMergeCustomizer - ), - robots, - } - } catch (e: unknown) { - console.warn( - `Blog post SEO for "${slug}" wasn't fetched:`, - (e as Error)?.message - ) - - return { - ...defaultMeta, - openGraph: defaultOgMeta, - twitter: defaultTwitterMeta, - } - } -} diff --git a/apps/ui/src/lib/metadata/build-from-seo.ts b/apps/ui/src/lib/metadata/build-from-seo.ts new file mode 100644 index 0000000..4b6d11a --- /dev/null +++ b/apps/ui/src/lib/metadata/build-from-seo.ts @@ -0,0 +1,185 @@ +import type { Data } from "@repo/strapi-types" +import { mergeWith } from "lodash" +import type { Metadata } from "next" +import type { Locale } from "next-intl" +import { getTranslations } from "next-intl/server" + +import { getEnvVar } from "@/lib/env-vars" +import { isProduction } from "@/lib/general-helpers" +import { + getDefaultMetadata, + getDefaultOgMeta, + getDefaultTwitterMeta, +} from "@/lib/metadata/defaults" +import { + getMetaAlternates, + getMetaRobots, + preprocessSocialMetadata, + seoMergeCustomizer, +} from "@/lib/metadata/helpers" +import { fetchGlobalSeo } from "@/lib/strapi-api/content/server" +import type { StrapiLocalization } from "@/types/api" +import type { SocialMetadata } from "@/types/general" + +export type SeoComponent = Data.Component<"shared.seo"> + +export interface ResolvedMetadataDefaults { + defaultMeta: Metadata + defaultOgMeta: Metadata["openGraph"] + defaultTwitterMeta: Metadata["twitter"] +} + +export interface AssembleMetadataFromSeoParams { + defaults: ResolvedMetadataDefaults + seo?: SeoComponent | null + fullPath: string + locale: Locale + localizations?: StrapiLocalization[] + fallbackMeta?: Metadata + customMetadata?: Metadata +} + +export async function resolveMetadataDefaults( + locale: Locale, + fullPath?: string +): Promise<ResolvedMetadataDefaults | null> { + const t = await getTranslations({ locale, namespace: "seo" }) + const siteUrl = getEnvVar("APP_PUBLIC_URL") + + if (!siteUrl) { + console.warn("APP_PUBLIC_URL is not defined, cannot generate metadata") + + return null + } + + const translationMeta: Metadata = getDefaultMetadata(siteUrl, t) + const translationOgMeta: Metadata["openGraph"] = getDefaultOgMeta( + locale, + fullPath, + t + ) + const translationTwitterMeta: Metadata["twitter"] = getDefaultTwitterMeta(t) + + const globalRes = await fetchGlobalSeo() + const globalSeo = globalRes?.data?.defaultSeo + + const globalStrapiMeta: Metadata = { + title: globalSeo?.metaTitle, + description: globalSeo?.metaDescription, + keywords: globalSeo?.keywords, + robots: globalSeo?.metaRobots, + } + const globalSocialMeta: SocialMetadata = preprocessSocialMetadata(globalSeo) + + return { + defaultMeta: mergeWith( + translationMeta, + globalStrapiMeta, + seoMergeCustomizer + ), + defaultOgMeta: mergeWith( + translationOgMeta, + globalSocialMeta.openGraph, + seoMergeCustomizer + ), + defaultTwitterMeta: mergeWith( + translationTwitterMeta, + globalSocialMeta.twitter, + seoMergeCustomizer + ), + } +} + +export function assembleMetadataFromSeo({ + defaults, + seo, + fullPath, + locale, + localizations, + fallbackMeta, + customMetadata, +}: AssembleMetadataFromSeoParams): Metadata { + const forbidIndexing = !isProduction() + + const strapiMeta: Metadata = { + title: seo?.metaTitle, + description: seo?.metaDescription, + keywords: seo?.keywords, + robots: seo?.metaRobots, + } + + const robots = getMetaRobots(seo?.metaRobots, forbidIndexing) + const alternates = getMetaAlternates({ + seo, + fullPath, + locale, + localizations, + }) + const strapiSocialMeta: SocialMetadata = preprocessSocialMetadata( + seo, + alternates?.canonical + ) + + const mergedBase = mergeWith( + defaults.defaultMeta, + fallbackMeta, + strapiMeta, + seoMergeCustomizer + ) + + const { alternates: customAlternates, ...restCustomMetadata } = + customMetadata ?? {} + + return { + ...mergedBase, + openGraph: mergeWith( + defaults.defaultOgMeta, + strapiSocialMeta.openGraph, + seoMergeCustomizer + ), + twitter: mergeWith( + defaults.defaultTwitterMeta, + strapiSocialMeta.twitter, + seoMergeCustomizer + ), + robots, + alternates: customAlternates + ? { ...alternates, ...customAlternates } + : alternates, + ...restCustomMetadata, + } +} + +export interface GetMetadataFromSeoEntryParams { + locale: Locale + fullPath: string + seo?: SeoComponent | null + localizations?: StrapiLocalization[] + fallbackMeta?: Metadata + customMetadata?: Metadata +} + +export async function getMetadataFromSeoEntry({ + locale, + fullPath, + seo, + localizations, + fallbackMeta, + customMetadata, +}: GetMetadataFromSeoEntryParams): Promise<Metadata | null> { + const defaults = await resolveMetadataDefaults(locale, fullPath) + + if (!defaults) { + return null + } + + return assembleMetadataFromSeo({ + defaults, + seo, + fullPath, + locale, + localizations, + fallbackMeta, + customMetadata, + }) +} diff --git a/apps/ui/src/lib/metadata/case-study.ts b/apps/ui/src/lib/metadata/case-study.ts deleted file mode 100644 index f52c946..0000000 --- a/apps/ui/src/lib/metadata/case-study.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { mergeWith } from "lodash" -import type { Metadata } from "next" -import type { Locale } from "next-intl" -import { getTranslations } from "next-intl/server" - -import { getEnvVar } from "@/lib/env-vars" -import { isProduction } from "@/lib/general-helpers" -import { - getDefaultMetadata, - getDefaultOgMeta, - getDefaultTwitterMeta, -} from "@/lib/metadata/defaults" -import { - getMetaRobots, - preprocessSocialMetadata, - seoMergeCustomizer, -} from "@/lib/metadata/helpers" -import { - fetchCaseStudySeo, - fetchGlobalSeo, -} from "@/lib/strapi-api/content/server" - -export async function getCaseStudyMetadata({ - slug, - locale, -}: { - slug: string - locale: Locale -}): Promise<Metadata | null> { - const t = await getTranslations({ locale, namespace: "seo" }) - const siteUrl = getEnvVar("APP_PUBLIC_URL") - - if (!siteUrl) { - return null - } - - const translationMeta = getDefaultMetadata(siteUrl, t) - const translationOgMeta = getDefaultOgMeta(locale, `/user-stories/${slug}`, t) - const translationTwitterMeta = getDefaultTwitterMeta(t) - - const globalRes = await fetchGlobalSeo() - const globalSeo = globalRes?.data?.defaultSeo - - const globalStrapiMeta: Metadata = { - title: globalSeo?.metaTitle, - description: globalSeo?.metaDescription, - keywords: globalSeo?.keywords, - } - const globalSocialMeta = preprocessSocialMetadata(globalSeo) - - const defaultMeta = mergeWith( - translationMeta, - globalStrapiMeta, - seoMergeCustomizer - ) - const defaultOgMeta = mergeWith( - translationOgMeta, - globalSocialMeta.openGraph, - seoMergeCustomizer - ) - const defaultTwitterMeta = mergeWith( - translationTwitterMeta, - globalSocialMeta.twitter, - seoMergeCustomizer - ) - - try { - const res = await fetchCaseStudySeo(slug, locale) - const caseStudy = res?.data - const seo = caseStudy?.seo - - const fallbackMeta: Metadata = { - title: caseStudy?.title, - description: caseStudy?.description, - } - - if (!seo) { - return { - ...mergeWith(defaultMeta, fallbackMeta, seoMergeCustomizer), - openGraph: defaultOgMeta, - twitter: defaultTwitterMeta, - } - } - - const strapiMeta: Metadata = { - title: seo.metaTitle ?? caseStudy?.title, - description: seo.metaDescription ?? caseStudy?.description, - keywords: seo.keywords, - } - - const forbidIndexing = !isProduction() - const robots = getMetaRobots(seo.metaRobots, forbidIndexing) - const strapiSocialMeta = preprocessSocialMetadata(seo) - - return { - ...mergeWith(defaultMeta, strapiMeta, seoMergeCustomizer), - openGraph: mergeWith( - defaultOgMeta, - strapiSocialMeta.openGraph, - seoMergeCustomizer - ), - twitter: mergeWith( - defaultTwitterMeta, - strapiSocialMeta.twitter, - seoMergeCustomizer - ), - robots, - } - } catch (e: unknown) { - console.warn( - `Case study SEO for "${slug}" wasn't fetched:`, - e instanceof Error ? e.message : String(e) - ) - - return { - ...defaultMeta, - openGraph: defaultOgMeta, - twitter: defaultTwitterMeta, - } - } -} diff --git a/apps/ui/src/lib/metadata/cms-comparison.ts b/apps/ui/src/lib/metadata/cms-comparison.ts deleted file mode 100644 index 90a2e2b..0000000 --- a/apps/ui/src/lib/metadata/cms-comparison.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { mergeWith } from "lodash" -import type { Metadata } from "next" -import type { Locale } from "next-intl" -import { getTranslations } from "next-intl/server" - -import { getEnvVar } from "@/lib/env-vars" -import { isProduction } from "@/lib/general-helpers" -import { - getDefaultMetadata, - getDefaultOgMeta, - getDefaultTwitterMeta, -} from "@/lib/metadata/defaults" -import { - getMetaRobots, - preprocessSocialMetadata, - seoMergeCustomizer, -} from "@/lib/metadata/helpers" -import { - fetchCmsComparisonSeo, - fetchGlobalSeo, -} from "@/lib/strapi-api/content/server" - -export async function getCmsComparisonMetadata({ - slug, - locale, -}: { - slug: string - locale: Locale -}): Promise<Metadata | null> { - const t = await getTranslations({ locale, namespace: "seo" }) - const siteUrl = getEnvVar("APP_PUBLIC_URL") - - if (!siteUrl) { - return null - } - - const translationMeta = getDefaultMetadata(siteUrl, t) - const translationOgMeta = getDefaultOgMeta( - locale, - `/headless-cms/comparison/${slug}`, - t - ) - const translationTwitterMeta = getDefaultTwitterMeta(t) - - const globalRes = await fetchGlobalSeo() - const globalSeo = globalRes?.data?.defaultSeo - - const globalStrapiMeta: Metadata = { - title: globalSeo?.metaTitle, - description: globalSeo?.metaDescription, - keywords: globalSeo?.keywords, - } - const globalSocialMeta = preprocessSocialMetadata(globalSeo) - - const defaultMeta = mergeWith( - translationMeta, - globalStrapiMeta, - seoMergeCustomizer - ) - const defaultOgMeta = mergeWith( - translationOgMeta, - globalSocialMeta.openGraph, - seoMergeCustomizer - ) - const defaultTwitterMeta = mergeWith( - translationTwitterMeta, - globalSocialMeta.twitter, - seoMergeCustomizer - ) - - try { - const res = await fetchCmsComparisonSeo(slug, locale) - const seo = res?.data?.seo - - if (!seo) { - return { - ...defaultMeta, - openGraph: defaultOgMeta, - twitter: defaultTwitterMeta, - } - } - - const strapiMeta: Metadata = { - title: seo.metaTitle, - description: seo.metaDescription, - keywords: seo.keywords, - } - - const forbidIndexing = !isProduction() - const robots = getMetaRobots(seo.metaRobots, forbidIndexing) - const strapiSocialMeta = preprocessSocialMetadata(seo) - - return { - ...mergeWith(defaultMeta, strapiMeta, seoMergeCustomizer), - openGraph: mergeWith( - defaultOgMeta, - strapiSocialMeta.openGraph, - seoMergeCustomizer - ), - twitter: mergeWith( - defaultTwitterMeta, - strapiSocialMeta.twitter, - seoMergeCustomizer - ), - robots, - } - } catch (e: unknown) { - console.warn( - `CMS comparison SEO for "${slug}" wasn't fetched:`, - e instanceof Error ? e.message : String(e) - ) - - return { - ...defaultMeta, - openGraph: defaultOgMeta, - twitter: defaultTwitterMeta, - } - } -} diff --git a/apps/ui/src/lib/metadata/entry-metadata.ts b/apps/ui/src/lib/metadata/entry-metadata.ts new file mode 100644 index 0000000..eb48d15 --- /dev/null +++ b/apps/ui/src/lib/metadata/entry-metadata.ts @@ -0,0 +1,162 @@ +import type { Data } from "@repo/strapi-types" +import type { Metadata } from "next" +import type { Locale } from "next-intl" + +import { + type SeoComponent, + getMetadataFromSeoEntry, +} from "@/lib/metadata/build-from-seo" +import { getMetadataFromStrapi } from "@/lib/metadata/page-metadata" +import { + fetchBlogPostSeo, + fetchCaseStudySeo, + fetchCmsComparisonSeo, + fetchPostCategory, + fetchPostTag, +} from "@/lib/strapi-api/content/server" + +type WithSeo<T> = T & { seo?: SeoComponent | null } + +export async function getBlogPostMetadata({ + slug, + locale, +}: { + slug: string + locale: Locale +}): Promise<Metadata | null> { + const fullPath = `/blog/${slug}` + const res = await fetchBlogPostSeo(slug, locale) + const post = res?.data + + return getMetadataFromSeoEntry({ + locale, + fullPath, + seo: post?.seo, + fallbackMeta: { + title: post?.seo?.metaTitle ?? post?.title ?? undefined, + description: post?.seo?.metaDescription ?? post?.description ?? undefined, + }, + }) +} + +export async function getCaseStudyMetadata({ + slug, + locale, +}: { + slug: string + locale: Locale +}): Promise<Metadata | null> { + const fullPath = `/user-stories/${slug}` + const res = await fetchCaseStudySeo(slug, locale) + const caseStudy = res?.data + + return getMetadataFromSeoEntry({ + locale, + fullPath, + seo: caseStudy?.seo, + fallbackMeta: { + title: caseStudy?.seo?.metaTitle ?? caseStudy?.title ?? undefined, + description: + caseStudy?.seo?.metaDescription ?? caseStudy?.description ?? undefined, + }, + }) +} + +export async function getCmsComparisonMetadata({ + slug, + locale, +}: { + slug: string + locale: Locale +}): Promise<Metadata | null> { + const fullPath = `/headless-cms/comparison/${slug}` + const res = await fetchCmsComparisonSeo(slug, locale) + const comparison = res?.data as + | WithSeo<Data.ContentType<"api::cms-comparison.cms-comparison">> + | undefined + + return getMetadataFromSeoEntry({ + locale, + fullPath, + seo: comparison?.seo, + }) +} + +export async function getPostCategoryMetadata({ + slug, + locale, +}: { + slug: string + locale: Locale +}): Promise<Metadata | null> { + const fullPath = `/blog/categories/${slug}` + const res = await fetchPostCategory(slug, locale) + const category = res?.data as + | WithSeo<Data.ContentType<"api::post-category.post-category">> + | undefined + + const fallbackName = slug + .replaceAll("-", " ") + .replaceAll(/\b\w/g, (c) => c.toUpperCase()) + const name = category?.name ?? fallbackName + + return getMetadataFromSeoEntry({ + locale, + fullPath, + seo: category?.seo, + fallbackMeta: { + title: category?.seo?.metaTitle ?? `${name} — Blog`, + description: category?.seo?.metaDescription ?? undefined, + }, + }) +} + +export async function getPostTagMetadata({ + slug, + locale, +}: { + slug: string + locale: Locale +}): Promise<Metadata | null> { + const fullPath = `/blog/tags/${slug}` + const res = await fetchPostTag(slug, locale) + const tag = res?.data as + | WithSeo<Data.ContentType<"api::post-tag.post-tag">> + | undefined + + const fallbackName = slug + .replaceAll("-", " ") + .replaceAll(/\b\w/g, (c) => c.toUpperCase()) + const name = tag?.name ?? fallbackName + + return getMetadataFromSeoEntry({ + locale, + fullPath, + seo: tag?.seo, + fallbackMeta: { + title: tag?.seo?.metaTitle ?? `${name} — Blog`, + description: tag?.seo?.metaDescription ?? undefined, + }, + }) +} + +export async function getBlogIndexMetadata({ + locale, +}: { + locale: Locale +}): Promise<Metadata | null> { + const { routing } = await import("@/lib/navigation") + const localePath = routing.defaultLocale !== locale ? `/${locale}` : "" + + return getMetadataFromStrapi({ + locale, + fullPath: "/blog", + customMetadata: { + alternates: { + types: { + "application/rss+xml": `${localePath}/blog/rss.xml`, + }, + }, + }, + }) +} diff --git a/apps/ui/src/lib/metadata/index.ts b/apps/ui/src/lib/metadata/index.ts index 65e6a39..4a0221d 100644 --- a/apps/ui/src/lib/metadata/index.ts +++ b/apps/ui/src/lib/metadata/index.ts @@ -1,161 +1,13 @@ -import type { UID } from "@repo/strapi-types" -import { mergeWith } from "lodash" -import type { Metadata } from "next" -import type { Locale } from "next-intl" -import { getTranslations } from "next-intl/server" - -import { getEnvVar } from "@/lib/env-vars" -import { isProduction } from "@/lib/general-helpers" -import { - getDefaultMetadata, - getDefaultOgMeta, - getDefaultTwitterMeta, -} from "@/lib/metadata/defaults" -import { - getMetaAlternates, - getMetaRobots, - preprocessSocialMetadata, - seoMergeCustomizer, -} from "@/lib/metadata/helpers" -import { fetchGlobalSeo, fetchSeo } from "@/lib/strapi-api/content/server" -import type { SocialMetadata } from "@/types/general" - -export async function getMetadataFromStrapi({ - fullPath, - locale, - customMetadata, - uid = "api::page.page", -}: { - fullPath?: string - locale: Locale - customMetadata?: Metadata - // Add more content types here if we want to fetch SEO components for them - uid?: Extract<UID.ContentType, "api::page.page"> -}): Promise<Metadata | null> { - const t = await getTranslations({ locale, namespace: "seo" }) - const siteUrl = getEnvVar("APP_PUBLIC_URL") - if (!siteUrl) { - console.warn("APP_PUBLIC_URL is not defined, cannot generate metadata") - - return null - } - - const translationMeta: Metadata = getDefaultMetadata(siteUrl, t) - const translationOgMeta: Metadata["openGraph"] = getDefaultOgMeta( - locale, - fullPath, - t - ) - const translationTwitterMeta: Metadata["twitter"] = getDefaultTwitterMeta(t) - - // Merge Global SEO from Strapi on top of translation defaults - const globalRes = await fetchGlobalSeo() - const globalSeo = globalRes?.data?.defaultSeo - - const globalStrapiMeta: Metadata = { - title: globalSeo?.metaTitle, - description: globalSeo?.metaDescription, - keywords: globalSeo?.keywords, - robots: globalSeo?.metaRobots, - } - const globalSocialMeta = preprocessSocialMetadata(globalSeo) - - const defaultMeta = mergeWith( - translationMeta, - globalStrapiMeta, - seoMergeCustomizer - ) - const defaultOgMeta = mergeWith( - translationOgMeta, - globalSocialMeta.openGraph, - seoMergeCustomizer - ) - const defaultTwitterMeta = mergeWith( - translationTwitterMeta, - globalSocialMeta.twitter, - seoMergeCustomizer - ) - - // skip page-level strapi fetching and return merged defaults - if (!fullPath) { - return { - ...defaultMeta, - openGraph: defaultOgMeta, - twitter: defaultTwitterMeta, - } - } - - try { - return await fetchAndMapStrapiMetadata( - locale, - fullPath, - defaultMeta, - defaultOgMeta, - defaultTwitterMeta, - customMetadata, - uid - ) - } catch (e: unknown) { - console.warn( - `SEO for ${uid} content type ("${fullPath}") wasn't fetched:`, - (e as Error)?.message - ) - - return { - ...defaultMeta, - openGraph: defaultOgMeta, - twitter: defaultTwitterMeta, - } - } -} - -async function fetchAndMapStrapiMetadata( - locale: Locale, - fullPath: string | null, - defaultMeta: Metadata, - defaultOgMeta: Metadata["openGraph"], - defaultTwitterMeta: Metadata["twitter"], - customMetadata?: Metadata, - uid: Extract<UID.ContentType, "api::page.page"> = "api::page.page" -) { - const forbidIndexing = !isProduction() - const res = await fetchSeo(uid, fullPath, locale) - - const { seo, localizations } = res?.data || {} - - const strapiMeta: Metadata = { - title: seo?.metaTitle, - description: seo?.metaDescription, - keywords: seo?.keywords, - robots: seo?.metaRobots, - } - - const robots = getMetaRobots(seo?.metaRobots, forbidIndexing) - const alternates = getMetaAlternates({ - seo, - fullPath, - locale, - localizations, - }) - const strapiSocialMeta: SocialMetadata = preprocessSocialMetadata( - seo, - alternates?.canonical - ) - - return { - ...mergeWith(defaultMeta, strapiMeta, seoMergeCustomizer), - openGraph: mergeWith( - defaultOgMeta, - strapiSocialMeta.openGraph, - seoMergeCustomizer - ), - twitter: mergeWith( - defaultTwitterMeta, - strapiSocialMeta.twitter, - seoMergeCustomizer - ), - robots, - alternates, - ...customMetadata, - } -} +export { + getMetadataFromSeoEntry, + resolveMetadataDefaults, +} from "@/lib/metadata/build-from-seo" +export { + getBlogIndexMetadata, + getBlogPostMetadata, + getCaseStudyMetadata, + getCmsComparisonMetadata, + getPostCategoryMetadata, + getPostTagMetadata, +} from "@/lib/metadata/entry-metadata" +export { getMetadataFromStrapi } from "@/lib/metadata/page-metadata" diff --git a/apps/ui/src/lib/metadata/page-metadata.ts b/apps/ui/src/lib/metadata/page-metadata.ts new file mode 100644 index 0000000..0820c5a --- /dev/null +++ b/apps/ui/src/lib/metadata/page-metadata.ts @@ -0,0 +1,71 @@ +import type { UID } from "@repo/strapi-types" +import type { Metadata } from "next" +import type { Locale } from "next-intl" + +import { + assembleMetadataFromSeo, + resolveMetadataDefaults, +} from "@/lib/metadata/build-from-seo" +import { fetchSeo } from "@/lib/strapi-api/content/server" +import type { StrapiLocalization } from "@/types/api" + +export async function getMetadataFromStrapi({ + fullPath, + locale, + customMetadata, + uid = "api::page.page", +}: { + fullPath?: string + locale: Locale + customMetadata?: Metadata + uid?: Extract<UID.ContentType, "api::page.page"> +}): Promise<Metadata | null> { + const defaults = await resolveMetadataDefaults(locale, fullPath) + + if (!defaults) { + return null + } + + if (!fullPath) { + const { alternates: customAlternates, ...restCustomMetadata } = + customMetadata ?? {} + + return { + ...defaults.defaultMeta, + openGraph: defaults.defaultOgMeta, + twitter: defaults.defaultTwitterMeta, + ...(customAlternates ? { alternates: customAlternates } : {}), + ...restCustomMetadata, + } + } + + try { + const res = await fetchSeo(uid, fullPath, locale) + const { seo, localizations } = res?.data || {} + + return assembleMetadataFromSeo({ + defaults, + seo, + fullPath, + locale, + localizations: localizations as StrapiLocalization[] | undefined, + customMetadata, + }) + } catch (e: unknown) { + console.warn( + `SEO for ${uid} content type ("${fullPath}") wasn't fetched:`, + (e as Error)?.message + ) + + const { alternates: customAlternates, ...restCustomMetadata } = + customMetadata ?? {} + + return { + ...defaults.defaultMeta, + openGraph: defaults.defaultOgMeta, + twitter: defaults.defaultTwitterMeta, + ...(customAlternates ? { alternates: customAlternates } : {}), + ...restCustomMetadata, + } + } +}