# Migrate Strapi Content

> Batch-migrate content from the old strapi.io Strapi instance (production-old MCP) to the current production Strapi (production MCP). Fully autonomous — paste URLs and nothing else is needed. Supports ALL content types — user-stories, blog-posts, news, jobs, pages (including cross-type migrations where old use-cases/universals map to new pages), comparators. Operates MCP-to-MCP (no HTML scraping). Triggers: migrate, batch migrate, migrate from production-old, bulk seed, batch seed urls, paste urls to migrate, migrate pages, migrate solutions, migrate user stories, migrate blog posts, migrate case studies, migrate comparators.

- Skill: `strapi/migrate-strapi-content` (Agent Skill, multi-file: 5 files)
- Install (CLI): `npx skillmds@latest add strapi/migrate-strapi-content`
- Raw SKILL.md: https://api.skillmd.com/api/skills/strapi/migrate-strapi-content/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: strapi (https://skillmd.com/u/strapi)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/strapi/migrate-strapi-content

---


# Migrate Strapi Content (batch, MCP-to-MCP)

Batch-migrate records from `production-old` (Strapi v4 at `api-prod.strapi.io`) to `production` (Strapi v5) via the Strapi MCP. Built for the common case where you paste a batch of URLs and want zero further input.

## Hard rule: NEVER ask the user anything — no exceptions

This skill runs **fully autonomous**. Do NOT use `AskUserQuestion`. Do NOT ask for approval. Do NOT ask to choose replace/append. Do NOT ask to publish. Do NOT pause before overwriting curated production content — the user has already agreed to `replace` by invoking the skill.

Apply the defaults below and report at the end. The user's input is the URL list — that is the entire contract.

If something truly cannot be decided from defaults + schema inspection (e.g. target content type unclear from URL, or target record can't be located), **skip that URL**, log it in the report under `unmatchedUrls` or per-URL `error`, and continue. Do not stop the batch to ask.

Override is possible only if the user explicitly writes `interactive` or `ask me` in the prompt — then (and only then) you may ask.

## Mapping references

**Authoritative cheatsheet**: `components-cheatsheet.csv` next to this file. One row per old slice / root field, with the v5 target, variant/layout flags, and a short `notes` field describing the field-mapping rule. Step 6 below is the code-form expansion of those rows — when CSV and Step 6 disagree, the CSV is the source of truth and Step 6 should be updated to match.

**Visual reference**: a live component library is available at **https://website-ui-omega.vercel.app/dev/component-library**. When uncertain how a v5 component renders, fetch that page (Playwright or WebFetch) and compare against the v4 source. Prefer a real v5 component over `migration.data-sink`.

## Default mapping heuristic (use this as your mental model)

Most of the content in old slices falls into a handful of shapes. Before checking the detailed table, ask which of these the old slice renders as:

1. **Columns / grid of cards (each with title + description)** → `sections.feature-card-grid`
   - Use when the slice has a repeatable `cards[]`, `features[]`, `items[]`, or `integrations[]` array of tiles.
   - The optional `section` field (utilities.section-header) carries the heading + description above the grid.
2. **Single boxed content block (title + description + optional CTA + optional image position)** → `cards.feature-card`
   - Use when the slice is a standalone content panel with one title and one body of text.
   - Examples: `slices.section-with-image`, `slices.text-next-to-image`, `slices.simple-text-next-to-image`, `slices.text-next-to-big-image`, `slices.text-with-image-and-gradient`.
3. **Multi-column feature list with a shared heading (smaller text-only tiles, not full cards)** → `sections.two-column-grid`
   - Use when items are short title+description pairs tightly grouped under one heading — no images, no CTAs.
   - Items are `elements.how-it-works-item` (title + description). The grid's `section` field is REQUIRED — always populate it from the slice's intro/title.
4. **Self-contained heading / section separator** → `sections.section-header`
   - Use for standalone label+title+description blocks that introduce a section but don't contain items.
   - Background/boxed variants live here too (e.g. interview's "dark + boxed" wrapper).
5. **Paragraphs of markdown text** → `sections.richtext`
6. **Hero at the top of a page** → `sections.hero` (slices OR root-level fields like `useCaseHero`, `homeHero`, `whiteHero`, `careersHero`, `featuresHero`, `communityHero` — all PREPENDED to newContent)
7. **CTA banner with title/text/button** → `sections.cta-banner` (background=dark|dark-inverse; the only "light" variant is achieved via `sections.section-header` with background=light instead)
8. **Quote** → `testimonials.quote`
9. **Reference to a case study** → `cards.case-study-card` (with target lookup for `companyName`/`title`)
10. **Logo / brand grid** → `media.brand-logo-grid` (logos are uploaded from old CDN, deduped by filename)
11. **Image gallery / slider** → `media.image-gallery` (images uploaded same way as brand logos)
12. **Newsletter signup** → `forms.newsletter` (limited — only the `hubspotForm` relation is migrated; frontend renders fixed copy)
13. **Disclaimer notice** → `sections.disclaimer` (title + content; SKIP if the v5 frontend renders it hardcoded for the route)
14. **3-column stat/issue grid (required heading)** → `sections.three-column-grid` (items = `elements.how-it-works-item`; `itemStyle: "bordered"` for emphasis variants like `slices.issues-header`)
15. **Tabbed feature strip (pill tabs, one feature-overview per tab)** → `sections.tabbed-feature-overview` (each tab requires an image — uploaded via the brand-logo routine; tabs without images get SKIPPED)
16. **Customer reviews carousel** → `sections.reviews` (title required; reviews relation resolved against `api::review.review` by author match; relation left empty for manual follow-up if no matches)
17. **Auto-fetched chronological list (no fields)** → `sections.news-list` (empty payload — frontend handles fetching)

Everything else → `migration.data-sink` (if allowed) or SKIP.

The detailed per-slice rules in Step 6 are refinements of this heuristic with the exact field mapping.

## Inputs

| Input           | Required | Default          | Description                                                                                              |
| --------------- | -------- | ---------------- | -------------------------------------------------------------------------------------------------------- |
| `urls`          | yes      | —                | Whitespace/newline-separated list of URLs (any host — only the path matters)                             |
| `source_server` | no       | `production-old` | Strapi MCP server to read from                                                                           |
| `target_server` | no       | `production`     | Strapi MCP server to write to                                                                            |
| `mode`          | no       | `replace`        | `replace` wipes+rebuilds the target dynamic zone; `append` adds to existing. Default is `replace`.       |
| `publish`       | no       | `true`           | After successful migration, publish the target record                                                    |
| `media_policy`  | no       | `reuse-existing` | If target record already has cover/logo set, leave them; else upload from old if old has them; else skip |

Everything else (target documentId, category documentIds, schema mapping) is discovered at runtime.

## URL → content type mapping

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/<slug>`                                                                                                                    | `api/case-studies`                                                                                                                                                                                                                                                                                   | `api/case-studies`    | case-study            | slug                             |
| `/blog/<slug>`                                                                                                                            | `api/blog-posts`                                                                                                                                                                                                                                                                                     | `api/blog-posts`      | blog-post             | slug                             |
| `/news/<slug>`                                                                                                                            | `api/news-items`                                                                                                                                                                                                                                                                                     | `api/news-items`      | news-item             | slug                             |
| `/jobs/<slug>`                                                                                                                            | `api/internal-jobs`                                                                                                                                                                                                                                                                                  | `api/internal-jobs`   | internal-job          | slug                             |
| `/comparators/<slug>` or `/<slug>-vs-<slug>` or `/headless-cms/comparison/<slug>`                                                         | `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/<slug>`                                                                                                                       | `api/use-cases`                                                                                                                                                                                                                                                                                      | `api/pages`           | **page** (cross-type) | `fullPath` = `/solutions/<slug>` |
| `/features` and `/features/<slug>`                                                                                                        | (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.                                                                                                              |
| `/<slug>` (top-level, no prefix above)                                                                                                    | `api/universals` — **FALLBACK: if no universal matches the slug, probe a same-named single-type `api/<slug>`** (confirmed: `careers`→`api/career`, `community`→`api/community`; these hold `slices[]` directly, sometimes without a universal-style root hero)                                       | `api/pages`           | **page** (cross-type) | `fullPath` = `/<slug>`           |

**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.

If the URL path matches none of the above, skip it (add to `unmatchedUrls`). Do NOT guess.

Mixed batches are fine — group per-type internally so schema inspection only runs once per type.

## Steps

### 1. Verify MCP + servers

```
mcp__strapi-local__strapi_list_servers()
```

If `source_server` or `target_server` is missing → report and stop. Do not offer to install MCP. If user clearly needs setup, recommend `/setup-strapi-mcp` in the final report.

### 2. Parse URLs and group by content type

Extract `{pathSegment, slug, contentType, oldEndpoint, newEndpoint, matchField, matchValue}` per URL. Drop unmatched. De-duplicate.

### 3. Inspect local target schema (once per content type)

Read `apps/strapi/src/api/<content-type>/content-types/<content-type>/schema.json`. Capture:

- Dynamic-zone field name (usually `content`) and its allowed component UIDs
- Required top-level fields
- Relation fields and their targets (for category-like lookups)
- Media/component fields (coverImage, logoImage, seo, etc.)

Also walk each allowed component's schema (`apps/strapi/src/components/<group>/<name>.json`) to capture required subcomponent fields (e.g. `cards.feature-card.title`, `sections.feature-card-grid.items` required, `media.brand-logo-grid.items[].image.media` required — uploading media is expensive; prefer to SKIP such slices unless explicitly requested).

### 4. Discover target category-like relations (once per content type)

For each relation field pointing to a taxonomy (e.g. `case-study-category`, `blog-tag`), fetch all records from the **target** server:

```
mcp__strapi-local__strapi_rest({ server: target_server, endpoint: "api/<plural>", method: "GET", params: { pagination: { limit: 100 } } })
```

Build a `{ name → documentId }` map per relation. Use this at write time — do not hardcode documentIds.

### 5. Find target documentIds for the batch (once per content type)

For slug-matched types:

```
mcp__strapi-local__strapi_rest({
  server: target_server,
  endpoint: "api/<plural>",
  method: "GET",
  params: {
    filters: { slug: { $in: [<slug1>, <slug2>, ...] } },
    fields: ["slug", "title", "description", "companyName", "publishedAt"],
    pagination: { limit: 100 },
    status: "draft"
  }
})
```

For `fullPath`-matched pages, use `filters: { fullPath: { $in: [<"/market-guidelines">, <"/solutions/ecommerce-cms">, ...] } }` and include `fullPath` in `fields`.

For each row:

- **Found** → reuse `documentId`
- **Not found** → create a draft shell first via POST with minimal required fields so the agent has a documentId to write to.

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("<TITLE>"))
     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. **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 (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`     | 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                                                                        

…(truncated)
