diff --git a/README.md b/README.md index a6cbbbd..1a385f9 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Contentstack Skills -A bundle of 21 ready-to-use [Contentstack](https://www.contentstack.com) agent skills for AI coding tools, covering CMS implementation, Brand Kit guidance, delivery SDK development, migration workflows, Developer Hub app architecture, and Launch automation. +A bundle of 22 ready-to-use [Contentstack](https://www.contentstack.com) agent skills for AI coding tools, covering CMS implementation, Brand Kit guidance, delivery SDK development, migration workflows, Developer Hub app architecture, Launch automation, and Contentstack Studio. ## What's in here? @@ -29,11 +29,11 @@ After install, the router at `skills/CLAUDE.md` is loaded into context and Claud ### Cursor -Install via Cursor's plugin marketplace, **or** copy `cursor/rules/*.mdc` into your project's `.cursor/rules/` directory. The `00-router.mdc` rule is marked `alwaysApply: true`, so Cursor always knows which skill to reach for. +Install via Cursor's plugin marketplace, **or** copy `cursor/rules/*.mdc` into your project's `.cursor/rules/` directory. The `00-router.mdc` rule is marked `alwaysApply: true`, so Cursor always knows which skill to reach for. The Studio rule reads its specialist files from `skills/studio/`, so copy that folder to your project's `skills/studio/` as well. ### Codex / OpenAI agents -Point your agent at this repo or copy the `codex/` directory into your project. The entry point is [`codex/AGENTS.md`](codex/AGENTS.md). +Point your agent at this repo or copy the `codex/` directory into your project. The entry point is [`codex/AGENTS.md`](codex/AGENTS.md). `codex/studio/` holds only the Studio router; it reads its specialist files from `skills/studio/`, so copy that folder too. ### Gemini CLI @@ -72,6 +72,7 @@ npx skills add @ | `launch-sync-environment-variables-from-env-example` | Sync Launch environment variables from .env.example | Launch | Comparing a local `.env.example` with Launch environment variables and patching missing keys without printing secrets. | | `launch-trigger-and-monitor-launch-deployments` | Trigger and Monitor Launch Deployments | Launch | Triggering Launch deployments, polling deployment status, retrieving failure logs, and summarizing likely causes and next steps. | | `migration-companion` | Contentstack Migration Companion | Developer Experience | Guided Contentful-to-Contentstack migration workflow covering prerequisites, content migration, code migration, bundled validation scripts, and completion reporting. | +| `studio` | Studio | Studio | Contentstack Studio end to end: SDK install, project provisioning, component registration, sections and templates, data binding, canvas and preview routes, verification, and troubleshooting. A router skill that loads one of 109 bundled specialist files per task. | ## How it works diff --git a/codex/AGENTS.md b/codex/AGENTS.md index e86f870..7b2c239 100644 --- a/codex/AGENTS.md +++ b/codex/AGENTS.md @@ -25,3 +25,4 @@ Use the table below to route a user request to the right skill. Each row maps a | Use when you need to automate Launch deployments for a known project and environment, monitor progress, and surface failure diagnostics. This is appropriate for CI/CD or operator workflows that need a deterministic deploy status check and log-based troubleshooting. | [Trigger and Monitor Launch Deployments](./launch-trigger-and-monitor-launch-deployments/SKILL.md) | | Use when developers ask about content personalization, A/B testing, audience segmentation, variant creation, or integrating Personalize with the CMS. | [Variants & Personalization](./cms-variants-personalization/SKILL.md) | | Use when a user needs help designing or building a Contentstack Developer Hub or Marketplace app. | [Developer Hub App Architect](./developer-hub-app-architect/SKILL.md) | +| Use when the user is working with Contentstack Studio: installing the Studio SDK, provisioning a Studio project, registering components, building sections and templates, binding data, wiring the canvas and preview routes, verifying a build, or troubleshooting the canvas, compositions, data binding or SSR. Start at the Studio router, which picks the matching specialist from its own index. | [Studio](./studio/SKILL.md) | diff --git a/codex/studio/SKILL.md b/codex/studio/SKILL.md new file mode 100644 index 0000000..4ae48d2 --- /dev/null +++ b/codex/studio/SKILL.md @@ -0,0 +1,95 @@ +# studio + +> This skill's files, including `skills-index.md` and every `.md` it routes to, are in `../../skills/studio/` relative to this file. Resolve every relative path and skill name below against that folder, not against this file. If that folder is missing, the pack is installed without its files: say so and stop. + + +# Studio router + +You are working on Contentstack Studio. The pack has one specialist skill per task, covering install, provisioning, component registration, section/template authoring, data binding, preview wiring, verification, troubleshooting, and migration. + +Loading every skill's frontmatter into every turn was the previous design and it burned ~8K always-on tokens. The current design keeps only this skill hot and routes everything else through a lookup file. + +## Resolving a skill file: three layouts, try each + +**The pack ships in one shape and installs in another, and the shape differs per host. A Read against the wrong one returns "File does not exist", and the failure is silent**: routing stops and the answer gets improvised from memory, which is the exact thing this router exists to prevent. + +For a skill named ``, resolve in this order and use the first that exists: + +| # | Path | Where this is the layout | +|---|---|---| +| 1 | `..//SKILL.md` | **Nested**: Claude Code gives every skill its own directory (`~/.claude/skills//SKILL.md`). | +| 2 | `./.md` | **Flat**: the repo (`skills/src/`, `docs/prompts/`), and Windsurf / Cline / Continue installs. | +| 3 | `./.mdc` | **Flat, Cursor**: `.cursor/rules/` loads only `.mdc`, so the installer changes the extension. Contents are identical. | + +Same rule for `skills-index.md`: try `./skills-index.md`, then `../studio/skills-index.md`, then `./skills-index.mdc`. + +**If both fail, say so out loud**: "the skill pack looks mis-installed, `` resolved to neither path", and do NOT quietly fall back to general knowledge. A silent fallback looks identical to the pack working, so the user never learns their install is broken. Every cross-reference in this file and in `skills-index.md` is written as `.md` for readability. Apply this resolution rule to all of them. + +## What to do + +**Setup intent takes precedence over keyword matching.** “Enable Studio”, “make this project Studio ready”, “Studio setup”, and equivalent whole-app requests run `convert-project-to-studio` through verification. `install-studio` alone is for an explicit SDK-only request. Preserve the active setup objective across replies such as “yes”, “start”, and “OAuth done”; resume the pending step rather than routing that reply as a new task. Use the orchestrator's scoped completion checklist; authoring requirements below apply only when components or compositions are authored. + +**Discover MCP execution before resolving raw HTTP credentials.** Follow `install-contentstack-mcp` to inspect callable tools in the current host and verify the target with a read call. A stored OAuth session is not evidence of a connected MCP server. Prefer available MCP operations; use OAuth HTTP for missing operations or an explicitly recorded connection limitation. Never infer tool availability solely from a conversation flag. + +1. Read `skills-index.md` (resolution above). It has two parts. + +2. **Trigger table.** Scan the user's most recent message against the phrases in the left column. If one row matches, Read that skill's file and follow it. If several match, run them in the order the table implies (installs before authoring, `analyze-project-fit` before anything state-changing). + +3. **Topic map.** Use this when no trigger phrase fires but the topic is Studio-shaped. The user is exploring or asking a conceptual question. Pick the closest skill by name + one-liner and Read it. + +4. If nothing matches after both passes, answer from general Studio knowledge, but say so, and check whether a skill is missing that should exist. + +## Standing rule: atomic and reusable, every time + +**This applies on every Studio turn, whatever the user asked for. It is not optional and it is not only a planning concern.** + +Before creating, registering, or authoring anything: + +1. **Decompose first.** Never register or build a component that renders more than one piece of content. The test: does it render more than one heading / image / body / list? If yes, run [`decompose-jsx-to-atomics`](../../skills/studio/decompose-jsx-to-atomics.md) before anything else. One Section per page, or one component swallowing a whole page, is a defect, not a shortcut. +2. **Compose from atoms by default: do NOT reach for a pre-composed composite.** A palette usually contains both **atoms + layout primitives** (`*-atom-heading`, `*-stack`, `*-row`, `*-grid`, `*-section-shell`) and **composites** that already bundle a whole shape (`*-feature-card`, `*-hero`, `*-card-grid`). Dropping one composite is faster and looks identical on the canvas, and it is the wrong default: its internals are fixed in code, so the author cannot restructure it, the spacing inside it cannot be matched to a comp, and the design is no longer expressed as a composition. **Build the shape out of atoms unless the user explicitly asks to reuse an existing component.** If a composite would clearly save real work, say so and let them choose. Do not decide it silently. +3. **Reuse atoms, not shapes.** Reuse means the same `*-atom-heading` everywhere, not a ready-made card. Two things differing only in spacing are one Section plus an exposed prop, never two. +4. **Shared shells are Sections.** Header, footer, nav, menus, breadcrumbs render on every page and are the highest-value reusable pieces in any project. They are never "atomic, no binding". Their menus are **Repeaters over a field**, never hardcoded lists. Otherwise an author cannot add a menu item without a developer. +5. **One atomic prop maps to one CT field.** A single prop taking a whole object is how binding breaks: the binder recursively unwraps single-key objects for every type except `object`/`array`, so a coarse prop renders blank with no error. + +If the user's request would skip this ("just register the page component", "make the whole page one section"), say what it costs (nothing reusable, authors can't reorder, bindings break) and propose the decomposed shape instead. Do it their way only if they confirm after hearing that. + +## Standing rule: definition of done + +**Also every Studio turn.** Eight deliverables decide whether what you built is usable by a marketer rather than only by a developer. Every one fails silently. The page still renders, nothing errors, and the gap surfaces only when a non-developer opens Studio: + +1. `cslp: { appendTags: true }` in the SDK init, once per project. +2. `studioAttributes` on the root + `wrap: false` in the register entry + `$prop` twins, **every** registered component. +3. `thumbnailUrl`, **every** `registerComponent` call. +4. `ui_preview`, **every** Section composition. The editor writes this for itself by screenshotting the canvas on save. **A CMA-authored Section has it empty forever** unless you supply an asset uid explicitly. So "at write time" means have the asset ready and include it, not "the create call handles it". See [`author-composition-via-api`](../../skills/studio/author-composition-via-api.md) § Section thumbnails for the three ways to do it. Scripted authoring is exactly where this gets skipped, and the only signal is a blank tile in the palette. +5. Live Preview enabled on the stack + preview token, once per project. +6. The `/canvas` route mounted, once per project. +7. **Published** to the environment the project targets, every Section and Template, and again after every tree edit. Delivery otherwise serves the old version while the CMA and canvas both look correct. +8. **Composed of Sections**: every Template. Read the saved composition back and count `section-composition` nodes. Zero means the page is one component bound to the whole body. The standing rule above already forbids that shape, and a build still reached it, because a monolith renders correctly and fails no other check here. Counting is the only thing that catches it. + +Read [`complete-the-build`](../../skills/studio/complete-the-build.md) for the how and the verification block. The runnable node count is in [`build-connected-template`](../../skills/studio/build-connected-template.md) § Post-build structural check. Never report a build done with any of these outstanding. Say "created, thumbnail pending" and then finish it. State them in the plan up front too, so what the user approves is what they get. + +## Standing rule: auth preflight + +**Settle the credential before the first API call of the turn, not when one fails.** Every specialist repeats this. It lives here because it must hold even before a specialist is pulled. + +1. Resolve OAuth-first per [`authenticate-cma`](../../skills/studio/authenticate-cma.md): `CS_OAUTH_ACCESS_TOKEN`, else the Contentstack MCP's stored session (`~/Library/Application Support/ContentstackMCP/oauth-config.json` and its platform equivalents). +2. **Never ask the user for a session `authtoken`.** It is a full user-session credential and is the last rung, requested only after a freshly refreshed OAuth token has been observed to fail. +3. Access tokens last **59 minutes** and refresh tokens **rotate**. Tooling that refreshes must persist the new pair. A bare `curl` loop does neither, so long work belongs in a script. +4. If nothing resolves, or a refresh returns `400 invalid_refresh_token`, hand the user `! CONTENTSTACK_REGION= npx @contentstack/mcp --auth` and wait. It needs a TTY and a browser, you cannot run it for them. The region must be **in** the command. `DEFAULT_REGION` is `NA`, so omitting it silently authenticates against NA prod. +5. `403 error_code 316` is **not** an auth failure. It is a valid credential aimed at a stack outside its org. Re-authenticating fixes nothing. Check with `GET /v3/stacks` + `organization_uid`. + +## Rules + +- Always re-Read the skill file. Never answer from a remembered summary. The source of truth is the file on disk. +- **A failed Read is a hard stop, not a shrug.** If a skill file won't resolve under either layout above, report it. Improvising over a broken install is worse than erroring: the user believes the pack ran when it didn't. +- Never skip step 1. The lookup is cheap. Missing a skill is expensive. +- If the user's request implies multiple skills (e.g. "install and set up preview routes"), pull each in the order they must run: skills-index.md's Trigger table encodes that ordering. +- Prerequisite skills: many authoring skills assume `install-studio` + `configure-studio` are done. If the user's project state is unclear, run `analyze-project-fit` first. + +## Why this exists + +The old design put a rich `description` + `when-to-use` in every skill's frontmatter so Claude Code's classifier could auto-fire the right one. That worked but cost every downstream customer ~8K tokens per turn, every skill description loaded on every single message, whether the turn was about Studio or not. + +The new design collapses those descriptions into `skills-index.md`, keeps only this file hot, and shifts routing into an explicit two-step read. Per-turn always-on cost drops by ~7.4K tokens. On non-Studio turns, this skill doesn't fire and the whole pack sits cold. + +Nothing was deleted. Every trigger phrase from the old per-skill `when-to-use` fields lives in skills-index.md's Trigger table, verbatim. diff --git a/cursor/rules/00-router.mdc b/cursor/rules/00-router.mdc index bffe8f7..fd6b500 100644 --- a/cursor/rules/00-router.mdc +++ b/cursor/rules/00-router.mdc @@ -30,3 +30,4 @@ Use the table below to route a user request to the right skill. Each row maps a | Use when you need to automate Launch deployments for a known project and environment, monitor progress, and surface failure diagnostics. This is appropriate for CI/CD or operator workflows that need a deterministic deploy status check and log-based troubleshooting. | [Trigger and Monitor Launch Deployments](../../skills/launch-trigger-and-monitor-launch-deployments/SKILL.md) | | Use when developers ask about content personalization, A/B testing, audience segmentation, variant creation, or integrating Personalize with the CMS. | [Variants & Personalization](../../skills/cms-variants-personalization/SKILL.md) | | Use when a user needs help designing or building a Contentstack Developer Hub or Marketplace app. | [Developer Hub App Architect](../../skills/developer-hub-app-architect/SKILL.md) | +| Use when the user is working with Contentstack Studio: installing the Studio SDK, provisioning a Studio project, registering components, building sections and templates, binding data, wiring the canvas and preview routes, verifying a build, or troubleshooting the canvas, compositions, data binding or SSR. Start at the Studio router, which picks the matching specialist from its own index. | [Studio](../../skills/studio/SKILL.md) | diff --git a/cursor/rules/22-studio.mdc b/cursor/rules/22-studio.mdc new file mode 100644 index 0000000..561abef --- /dev/null +++ b/cursor/rules/22-studio.mdc @@ -0,0 +1,98 @@ +--- +description: Router skill for Contentstack Studio and the Contentstack stack behind it (content types, entries, assets, publishing). Load it before using Contentstack MCP tools. Consult skills-index.md, pull the matched skill, then answer. This is the one always-on entry to the Studio skill pack. Fire whenever the user's message touches Contentstack Studio, canvas, composition, template, section, Studio SDK, Studio provisioning, Studio authoring, Studio preview, Studio troubleshooting, or stack setup. Trigger words — Studio, canvas, composition, template, section, Contentstack, stack, content type, seed entries, Studio SDK, register component, install Studio, preview route, section slot, repeater, freeform, connected template, BYOC, Live Preview, Visual Editor, deploy, Contentstack Launch. Broad on purpose — one miss makes the whole tree invisible. Do NOT fire on unrelated tasks (a random React question, a bug in the user's own code that never mentions Studio). +alwaysApply: false +--- + +> This skill's files, including `skills-index.md` and every `.md` it routes to, are in `../../skills/studio/` relative to this rule. Resolve every relative path and skill name below against that folder, not against this rule. If that folder is missing, the pack is installed without its files: say so and stop. + + +# Studio router + +You are working on Contentstack Studio. The pack has one specialist skill per task, covering install, provisioning, component registration, section/template authoring, data binding, preview wiring, verification, troubleshooting, and migration. + +Loading every skill's frontmatter into every turn was the previous design and it burned ~8K always-on tokens. The current design keeps only this skill hot and routes everything else through a lookup file. + +## Resolving a skill file: three layouts, try each + +**The pack ships in one shape and installs in another, and the shape differs per host. A Read against the wrong one returns "File does not exist", and the failure is silent**: routing stops and the answer gets improvised from memory, which is the exact thing this router exists to prevent. + +For a skill named ``, resolve in this order and use the first that exists: + +| # | Path | Where this is the layout | +|---|---|---| +| 1 | `..//SKILL.md` | **Nested**: Claude Code gives every skill its own directory (`~/.claude/skills//SKILL.md`). | +| 2 | `./.md` | **Flat**: the repo (`skills/src/`, `docs/prompts/`), and Windsurf / Cline / Continue installs. | +| 3 | `./.mdc` | **Flat, Cursor**: `.cursor/rules/` loads only `.mdc`, so the installer changes the extension. Contents are identical. | + +Same rule for `skills-index.md`: try `./skills-index.md`, then `../studio/skills-index.md`, then `./skills-index.mdc`. + +**If both fail, say so out loud**: "the skill pack looks mis-installed, `` resolved to neither path", and do NOT quietly fall back to general knowledge. A silent fallback looks identical to the pack working, so the user never learns their install is broken. Every cross-reference in this file and in `skills-index.md` is written as `.md` for readability. Apply this resolution rule to all of them. + +## What to do + +**Setup intent takes precedence over keyword matching.** “Enable Studio”, “make this project Studio ready”, “Studio setup”, and equivalent whole-app requests run `convert-project-to-studio` through verification. `install-studio` alone is for an explicit SDK-only request. Preserve the active setup objective across replies such as “yes”, “start”, and “OAuth done”; resume the pending step rather than routing that reply as a new task. Use the orchestrator's scoped completion checklist; authoring requirements below apply only when components or compositions are authored. + +**Discover MCP execution before resolving raw HTTP credentials.** Follow `install-contentstack-mcp` to inspect callable tools in the current host and verify the target with a read call. A stored OAuth session is not evidence of a connected MCP server. Prefer available MCP operations; use OAuth HTTP for missing operations or an explicitly recorded connection limitation. Never infer tool availability solely from a conversation flag. + +1. Read `skills-index.md` (resolution above). It has two parts. + +2. **Trigger table.** Scan the user's most recent message against the phrases in the left column. If one row matches, Read that skill's file and follow it. If several match, run them in the order the table implies (installs before authoring, `analyze-project-fit` before anything state-changing). + +3. **Topic map.** Use this when no trigger phrase fires but the topic is Studio-shaped. The user is exploring or asking a conceptual question. Pick the closest skill by name + one-liner and Read it. + +4. If nothing matches after both passes, answer from general Studio knowledge, but say so, and check whether a skill is missing that should exist. + +## Standing rule: atomic and reusable, every time + +**This applies on every Studio turn, whatever the user asked for. It is not optional and it is not only a planning concern.** + +Before creating, registering, or authoring anything: + +1. **Decompose first.** Never register or build a component that renders more than one piece of content. The test: does it render more than one heading / image / body / list? If yes, run [`decompose-jsx-to-atomics`](../../skills/studio/decompose-jsx-to-atomics.md) before anything else. One Section per page, or one component swallowing a whole page, is a defect, not a shortcut. +2. **Compose from atoms by default: do NOT reach for a pre-composed composite.** A palette usually contains both **atoms + layout primitives** (`*-atom-heading`, `*-stack`, `*-row`, `*-grid`, `*-section-shell`) and **composites** that already bundle a whole shape (`*-feature-card`, `*-hero`, `*-card-grid`). Dropping one composite is faster and looks identical on the canvas, and it is the wrong default: its internals are fixed in code, so the author cannot restructure it, the spacing inside it cannot be matched to a comp, and the design is no longer expressed as a composition. **Build the shape out of atoms unless the user explicitly asks to reuse an existing component.** If a composite would clearly save real work, say so and let them choose. Do not decide it silently. +3. **Reuse atoms, not shapes.** Reuse means the same `*-atom-heading` everywhere, not a ready-made card. Two things differing only in spacing are one Section plus an exposed prop, never two. +4. **Shared shells are Sections.** Header, footer, nav, menus, breadcrumbs render on every page and are the highest-value reusable pieces in any project. They are never "atomic, no binding". Their menus are **Repeaters over a field**, never hardcoded lists. Otherwise an author cannot add a menu item without a developer. +5. **One atomic prop maps to one CT field.** A single prop taking a whole object is how binding breaks: the binder recursively unwraps single-key objects for every type except `object`/`array`, so a coarse prop renders blank with no error. + +If the user's request would skip this ("just register the page component", "make the whole page one section"), say what it costs (nothing reusable, authors can't reorder, bindings break) and propose the decomposed shape instead. Do it their way only if they confirm after hearing that. + +## Standing rule: definition of done + +**Also every Studio turn.** Eight deliverables decide whether what you built is usable by a marketer rather than only by a developer. Every one fails silently. The page still renders, nothing errors, and the gap surfaces only when a non-developer opens Studio: + +1. `cslp: { appendTags: true }` in the SDK init, once per project. +2. `studioAttributes` on the root + `wrap: false` in the register entry + `$prop` twins, **every** registered component. +3. `thumbnailUrl`, **every** `registerComponent` call. +4. `ui_preview`, **every** Section composition. The editor writes this for itself by screenshotting the canvas on save. **A CMA-authored Section has it empty forever** unless you supply an asset uid explicitly. So "at write time" means have the asset ready and include it, not "the create call handles it". See [`author-composition-via-api`](../../skills/studio/author-composition-via-api.md) § Section thumbnails for the three ways to do it. Scripted authoring is exactly where this gets skipped, and the only signal is a blank tile in the palette. +5. Live Preview enabled on the stack + preview token, once per project. +6. The `/canvas` route mounted, once per project. +7. **Published** to the environment the project targets, every Section and Template, and again after every tree edit. Delivery otherwise serves the old version while the CMA and canvas both look correct. +8. **Composed of Sections**: every Template. Read the saved composition back and count `section-composition` nodes. Zero means the page is one component bound to the whole body. The standing rule above already forbids that shape, and a build still reached it, because a monolith renders correctly and fails no other check here. Counting is the only thing that catches it. + +Read [`complete-the-build`](../../skills/studio/complete-the-build.md) for the how and the verification block. The runnable node count is in [`build-connected-template`](../../skills/studio/build-connected-template.md) § Post-build structural check. Never report a build done with any of these outstanding. Say "created, thumbnail pending" and then finish it. State them in the plan up front too, so what the user approves is what they get. + +## Standing rule: auth preflight + +**Settle the credential before the first API call of the turn, not when one fails.** Every specialist repeats this. It lives here because it must hold even before a specialist is pulled. + +1. Resolve OAuth-first per [`authenticate-cma`](../../skills/studio/authenticate-cma.md): `CS_OAUTH_ACCESS_TOKEN`, else the Contentstack MCP's stored session (`~/Library/Application Support/ContentstackMCP/oauth-config.json` and its platform equivalents). +2. **Never ask the user for a session `authtoken`.** It is a full user-session credential and is the last rung, requested only after a freshly refreshed OAuth token has been observed to fail. +3. Access tokens last **59 minutes** and refresh tokens **rotate**. Tooling that refreshes must persist the new pair. A bare `curl` loop does neither, so long work belongs in a script. +4. If nothing resolves, or a refresh returns `400 invalid_refresh_token`, hand the user `! CONTENTSTACK_REGION= npx @contentstack/mcp --auth` and wait. It needs a TTY and a browser, you cannot run it for them. The region must be **in** the command. `DEFAULT_REGION` is `NA`, so omitting it silently authenticates against NA prod. +5. `403 error_code 316` is **not** an auth failure. It is a valid credential aimed at a stack outside its org. Re-authenticating fixes nothing. Check with `GET /v3/stacks` + `organization_uid`. + +## Rules + +- Always re-Read the skill file. Never answer from a remembered summary. The source of truth is the file on disk. +- **A failed Read is a hard stop, not a shrug.** If a skill file won't resolve under either layout above, report it. Improvising over a broken install is worse than erroring: the user believes the pack ran when it didn't. +- Never skip step 1. The lookup is cheap. Missing a skill is expensive. +- If the user's request implies multiple skills (e.g. "install and set up preview routes"), pull each in the order they must run: skills-index.md's Trigger table encodes that ordering. +- Prerequisite skills: many authoring skills assume `install-studio` + `configure-studio` are done. If the user's project state is unclear, run `analyze-project-fit` first. + +## Why this exists + +The old design put a rich `description` + `when-to-use` in every skill's frontmatter so Claude Code's classifier could auto-fire the right one. That worked but cost every downstream customer ~8K tokens per turn, every skill description loaded on every single message, whether the turn was about Studio or not. + +The new design collapses those descriptions into `skills-index.md`, keeps only this file hot, and shifts routing into an explicit two-step read. Per-turn always-on cost drops by ~7.4K tokens. On non-Studio turns, this skill doesn't fire and the whole pack sits cold. + +Nothing was deleted. Every trigger phrase from the old per-skill `when-to-use` fields lives in skills-index.md's Trigger table, verbatim. diff --git a/manifest.json b/manifest.json index 564e68e..ed673fe 100644 --- a/manifest.json +++ b/manifest.json @@ -1,6 +1,6 @@ { "exported_at": "2026-04-28T08:27:32.000Z", - "skill_count": 21, + "skill_count": 22, "skills": [ { "slug": "brand-kit-assistant", @@ -106,6 +106,11 @@ "slug": "migration-companion", "title": "Contentstack Migration Companion", "product": "Developer Experience" + }, + { + "slug": "studio", + "title": "Studio", + "product": "Studio" } ] } diff --git a/scripts/build-codex-skills.sh b/scripts/build-codex-skills.sh index 7fcac62..4ba13c7 100644 --- a/scripts/build-codex-skills.sh +++ b/scripts/build-codex-skills.sh @@ -5,8 +5,13 @@ # codex/AGENTS.md — router, copied from skills/CLAUDE.md with link paths rewritten # codex//SKILL.md — one per skill, frontmatter stripped, body only # codex//* — bundled skill assets such as references/ and scripts/ +# +# Skills in IN_PLACE_SKILLS are not copied: codex//SKILL.md links back to +# skills// for its bundled files, so the repo holds one copy of them. set -euo pipefail +IN_PLACE_SKILLS="studio" + ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" SKILLS_DIR="$ROOT/skills" OUT_DIR="$ROOT/codex" @@ -28,9 +33,20 @@ for dir in "$SKILLS_DIR"/*/; do title="$(awk '/^name:/ {sub(/^name:[[:space:]]*/, ""); print; exit}' "$skill_md")" [ -z "$title" ] && title="$slug" printf '# %s\n\n' "$title" - awk 'BEGIN{fm=0} /^---$/{fm++; next} fm>=2{print}' "$skill_md" + case " $IN_PLACE_SKILLS " in + *" $slug "*) + printf '> This skill'"'"'s files, including `skills-index.md` and every `.md` it routes to, are in `../../skills/%s/` relative to this file. Resolve every relative path and skill name below against that folder, not against this file. If that folder is missing, the pack is installed without its files: say so and stop.\n\n' "$slug" + awk 'BEGIN{fm=0} /^---$/{fm++; next} fm>=2{print}' "$skill_md" | + SLUG="$slug" perl -pe 's{\]\((?!(?:[a-z][a-z0-9+.-]*:|#|/))(?:\./)?}{](../../skills/$ENV{SLUG}/}gi' + ;; + *) + awk 'BEGIN{fm=0} /^---$/{fm++; next} fm>=2{print}' "$skill_md" + ;; + esac } > "$OUT_DIR/$slug/SKILL.md" + case " $IN_PLACE_SKILLS " in *" $slug "*) continue ;; esac + find "$dir" -mindepth 1 -maxdepth 1 ! -name "SKILL.md" ! -name "__pycache__" -exec cp -R {} "$OUT_DIR/$slug/" \; find "$OUT_DIR/$slug" \( -name "__pycache__" -type d -o -name "*.pyc" -type f \) -prune -exec rm -rf {} + done diff --git a/scripts/build-cursor-rules.sh b/scripts/build-cursor-rules.sh index 06b7cfb..37991f6 100644 --- a/scripts/build-cursor-rules.sh +++ b/scripts/build-cursor-rules.sh @@ -4,8 +4,14 @@ # Reads each skills//SKILL.md, extracts its YAML frontmatter, and emits: # cursor/rules/00-router.mdc — alwaysApply router, copied from skills/CLAUDE.md # cursor/rules/NN-.mdc — one per skill, with cursor frontmatter +# +# Skills in IN_PLACE_SKILLS keep their bundled files in skills// only. +# Their rule's relative links are rewritten to ../../skills//, and a note +# says where relative paths resolve, since a rule does not sit next to them. set -euo pipefail +IN_PLACE_SKILLS="studio" + ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" SKILLS_DIR="$ROOT/skills" OUT_DIR="$ROOT/cursor/rules" @@ -47,7 +53,16 @@ for dir in "$SKILLS_DIR"/*/; do printf 'alwaysApply: %s\n' "$always" printf -- '---\n\n' # Body = SKILL.md with frontmatter stripped - awk 'BEGIN{fm=0} /^---$/{fm++; next} fm>=2{print}' "$skill_md" + case " $IN_PLACE_SKILLS " in + *" $slug "*) + printf '> This skill'"'"'s files, including `skills-index.md` and every `.md` it routes to, are in `../../skills/%s/` relative to this rule. Resolve every relative path and skill name below against that folder, not against this rule. If that folder is missing, the pack is installed without its files: say so and stop.\n\n' "$slug" + awk 'BEGIN{fm=0} /^---$/{fm++; next} fm>=2{print}' "$skill_md" | + SLUG="$slug" perl -pe 's{\]\((?!(?:[a-z][a-z0-9+.-]*:|#|/))(?:\./)?}{](../../skills/$ENV{SLUG}/}gi' + ;; + *) + awk 'BEGIN{fm=0} /^---$/{fm++; next} fm>=2{print}' "$skill_md" + ;; + esac } > "$out" i=$((i + 1)) diff --git a/skills/CLAUDE.md b/skills/CLAUDE.md index e86f870..7b2c239 100644 --- a/skills/CLAUDE.md +++ b/skills/CLAUDE.md @@ -25,3 +25,4 @@ Use the table below to route a user request to the right skill. Each row maps a | Use when you need to automate Launch deployments for a known project and environment, monitor progress, and surface failure diagnostics. This is appropriate for CI/CD or operator workflows that need a deterministic deploy status check and log-based troubleshooting. | [Trigger and Monitor Launch Deployments](./launch-trigger-and-monitor-launch-deployments/SKILL.md) | | Use when developers ask about content personalization, A/B testing, audience segmentation, variant creation, or integrating Personalize with the CMS. | [Variants & Personalization](./cms-variants-personalization/SKILL.md) | | Use when a user needs help designing or building a Contentstack Developer Hub or Marketplace app. | [Developer Hub App Architect](./developer-hub-app-architect/SKILL.md) | +| Use when the user is working with Contentstack Studio: installing the Studio SDK, provisioning a Studio project, registering components, building sections and templates, binding data, wiring the canvas and preview routes, verifying a build, or troubleshooting the canvas, compositions, data binding or SSR. Start at the Studio router, which picks the matching specialist from its own index. | [Studio](./studio/SKILL.md) | diff --git a/skills/studio/SKILL.md b/skills/studio/SKILL.md new file mode 100644 index 0000000..6588507 --- /dev/null +++ b/skills/studio/SKILL.md @@ -0,0 +1,95 @@ +--- +name: studio +description: Router skill for Contentstack Studio and the Contentstack stack behind it (content types, entries, assets, publishing). Load it before using Contentstack MCP tools. Consult skills-index.md, pull the matched skill, then answer. This is the one always-on entry to the Studio skill pack. Fire whenever the user's message touches Contentstack Studio, canvas, composition, template, section, Studio SDK, Studio provisioning, Studio authoring, Studio preview, Studio troubleshooting, or stack setup. Trigger words — Studio, canvas, composition, template, section, Contentstack, stack, content type, seed entries, Studio SDK, register component, install Studio, preview route, section slot, repeater, freeform, connected template, BYOC, Live Preview, Visual Editor, deploy, Contentstack Launch. Broad on purpose — one miss makes the whole tree invisible. Do NOT fire on unrelated tasks (a random React question, a bug in the user's own code that never mentions Studio). +--- + +# Studio router + +You are working on Contentstack Studio. The pack has one specialist skill per task, covering install, provisioning, component registration, section/template authoring, data binding, preview wiring, verification, troubleshooting, and migration. + +Loading every skill's frontmatter into every turn was the previous design and it burned ~8K always-on tokens. The current design keeps only this skill hot and routes everything else through a lookup file. + +## Resolving a skill file: three layouts, try each + +**The pack ships in one shape and installs in another, and the shape differs per host. A Read against the wrong one returns "File does not exist", and the failure is silent**: routing stops and the answer gets improvised from memory, which is the exact thing this router exists to prevent. + +For a skill named ``, resolve in this order and use the first that exists: + +| # | Path | Where this is the layout | +|---|---|---| +| 1 | `..//SKILL.md` | **Nested**: Claude Code gives every skill its own directory (`~/.claude/skills//SKILL.md`). | +| 2 | `./.md` | **Flat**: the repo (`skills/src/`, `docs/prompts/`), and Windsurf / Cline / Continue installs. | +| 3 | `./.mdc` | **Flat, Cursor**: `.cursor/rules/` loads only `.mdc`, so the installer changes the extension. Contents are identical. | + +Same rule for `skills-index.md`: try `./skills-index.md`, then `../studio/skills-index.md`, then `./skills-index.mdc`. + +**If both fail, say so out loud**: "the skill pack looks mis-installed, `` resolved to neither path", and do NOT quietly fall back to general knowledge. A silent fallback looks identical to the pack working, so the user never learns their install is broken. Every cross-reference in this file and in `skills-index.md` is written as `.md` for readability. Apply this resolution rule to all of them. + +## What to do + +**Setup intent takes precedence over keyword matching.** “Enable Studio”, “make this project Studio ready”, “Studio setup”, and equivalent whole-app requests run `convert-project-to-studio` through verification. `install-studio` alone is for an explicit SDK-only request. Preserve the active setup objective across replies such as “yes”, “start”, and “OAuth done”; resume the pending step rather than routing that reply as a new task. Use the orchestrator's scoped completion checklist; authoring requirements below apply only when components or compositions are authored. + +**Discover MCP execution before resolving raw HTTP credentials.** Follow `install-contentstack-mcp` to inspect callable tools in the current host and verify the target with a read call. A stored OAuth session is not evidence of a connected MCP server. Prefer available MCP operations; use OAuth HTTP for missing operations or an explicitly recorded connection limitation. Never infer tool availability solely from a conversation flag. + +1. Read `skills-index.md` (resolution above). It has two parts. + +2. **Trigger table.** Scan the user's most recent message against the phrases in the left column. If one row matches, Read that skill's file and follow it. If several match, run them in the order the table implies (installs before authoring, `analyze-project-fit` before anything state-changing). + +3. **Topic map.** Use this when no trigger phrase fires but the topic is Studio-shaped. The user is exploring or asking a conceptual question. Pick the closest skill by name + one-liner and Read it. + +4. If nothing matches after both passes, answer from general Studio knowledge, but say so, and check whether a skill is missing that should exist. + +## Standing rule: atomic and reusable, every time + +**This applies on every Studio turn, whatever the user asked for. It is not optional and it is not only a planning concern.** + +Before creating, registering, or authoring anything: + +1. **Decompose first.** Never register or build a component that renders more than one piece of content. The test: does it render more than one heading / image / body / list? If yes, run [`decompose-jsx-to-atomics`](decompose-jsx-to-atomics.md) before anything else. One Section per page, or one component swallowing a whole page, is a defect, not a shortcut. +2. **Compose from atoms by default: do NOT reach for a pre-composed composite.** A palette usually contains both **atoms + layout primitives** (`*-atom-heading`, `*-stack`, `*-row`, `*-grid`, `*-section-shell`) and **composites** that already bundle a whole shape (`*-feature-card`, `*-hero`, `*-card-grid`). Dropping one composite is faster and looks identical on the canvas, and it is the wrong default: its internals are fixed in code, so the author cannot restructure it, the spacing inside it cannot be matched to a comp, and the design is no longer expressed as a composition. **Build the shape out of atoms unless the user explicitly asks to reuse an existing component.** If a composite would clearly save real work, say so and let them choose. Do not decide it silently. +3. **Reuse atoms, not shapes.** Reuse means the same `*-atom-heading` everywhere, not a ready-made card. Two things differing only in spacing are one Section plus an exposed prop, never two. +4. **Shared shells are Sections.** Header, footer, nav, menus, breadcrumbs render on every page and are the highest-value reusable pieces in any project. They are never "atomic, no binding". Their menus are **Repeaters over a field**, never hardcoded lists. Otherwise an author cannot add a menu item without a developer. +5. **One atomic prop maps to one CT field.** A single prop taking a whole object is how binding breaks: the binder recursively unwraps single-key objects for every type except `object`/`array`, so a coarse prop renders blank with no error. + +If the user's request would skip this ("just register the page component", "make the whole page one section"), say what it costs (nothing reusable, authors can't reorder, bindings break) and propose the decomposed shape instead. Do it their way only if they confirm after hearing that. + +## Standing rule: definition of done + +**Also every Studio turn.** Eight deliverables decide whether what you built is usable by a marketer rather than only by a developer. Every one fails silently. The page still renders, nothing errors, and the gap surfaces only when a non-developer opens Studio: + +1. `cslp: { appendTags: true }` in the SDK init, once per project. +2. `studioAttributes` on the root + `wrap: false` in the register entry + `$prop` twins, **every** registered component. +3. `thumbnailUrl`, **every** `registerComponent` call. +4. `ui_preview`, **every** Section composition. The editor writes this for itself by screenshotting the canvas on save. **A CMA-authored Section has it empty forever** unless you supply an asset uid explicitly. So "at write time" means have the asset ready and include it, not "the create call handles it". See [`author-composition-via-api`](author-composition-via-api.md) § Section thumbnails for the three ways to do it. Scripted authoring is exactly where this gets skipped, and the only signal is a blank tile in the palette. +5. Live Preview enabled on the stack + preview token, once per project. +6. The `/canvas` route mounted, once per project. +7. **Published** to the environment the project targets, every Section and Template, and again after every tree edit. Delivery otherwise serves the old version while the CMA and canvas both look correct. +8. **Composed of Sections**: every Template. Read the saved composition back and count `section-composition` nodes. Zero means the page is one component bound to the whole body. The standing rule above already forbids that shape, and a build still reached it, because a monolith renders correctly and fails no other check here. Counting is the only thing that catches it. + +Read [`complete-the-build`](complete-the-build.md) for the how and the verification block. The runnable node count is in [`build-connected-template`](build-connected-template.md) § Post-build structural check. Never report a build done with any of these outstanding. Say "created, thumbnail pending" and then finish it. State them in the plan up front too, so what the user approves is what they get. + +## Standing rule: auth preflight + +**Settle the credential before the first API call of the turn, not when one fails.** Every specialist repeats this. It lives here because it must hold even before a specialist is pulled. + +1. Resolve OAuth-first per [`authenticate-cma`](authenticate-cma.md): `CS_OAUTH_ACCESS_TOKEN`, else the Contentstack MCP's stored session (`~/Library/Application Support/ContentstackMCP/oauth-config.json` and its platform equivalents). +2. **Never ask the user for a session `authtoken`.** It is a full user-session credential and is the last rung, requested only after a freshly refreshed OAuth token has been observed to fail. +3. Access tokens last **59 minutes** and refresh tokens **rotate**. Tooling that refreshes must persist the new pair. A bare `curl` loop does neither, so long work belongs in a script. +4. If nothing resolves, or a refresh returns `400 invalid_refresh_token`, hand the user `! CONTENTSTACK_REGION= npx @contentstack/mcp --auth` and wait. It needs a TTY and a browser, you cannot run it for them. The region must be **in** the command. `DEFAULT_REGION` is `NA`, so omitting it silently authenticates against NA prod. +5. `403 error_code 316` is **not** an auth failure. It is a valid credential aimed at a stack outside its org. Re-authenticating fixes nothing. Check with `GET /v3/stacks` + `organization_uid`. + +## Rules + +- Always re-Read the skill file. Never answer from a remembered summary. The source of truth is the file on disk. +- **A failed Read is a hard stop, not a shrug.** If a skill file won't resolve under either layout above, report it. Improvising over a broken install is worse than erroring: the user believes the pack ran when it didn't. +- Never skip step 1. The lookup is cheap. Missing a skill is expensive. +- If the user's request implies multiple skills (e.g. "install and set up preview routes"), pull each in the order they must run: skills-index.md's Trigger table encodes that ordering. +- Prerequisite skills: many authoring skills assume `install-studio` + `configure-studio` are done. If the user's project state is unclear, run `analyze-project-fit` first. + +## Why this exists + +The old design put a rich `description` + `when-to-use` in every skill's frontmatter so Claude Code's classifier could auto-fire the right one. That worked but cost every downstream customer ~8K tokens per turn, every skill description loaded on every single message, whether the turn was about Studio or not. + +The new design collapses those descriptions into `skills-index.md`, keeps only this file hot, and shifts routing into an explicit two-step read. Per-turn always-on cost drops by ~7.4K tokens. On non-Studio turns, this skill doesn't fire and the whole pack sits cold. + +Nothing was deleted. Every trigger phrase from the old per-skill `when-to-use` fields lives in skills-index.md's Trigger table, verbatim. diff --git a/skills/studio/adapt-collection-component.md b/skills/studio/adapt-collection-component.md new file mode 100644 index 0000000..0435e3d --- /dev/null +++ b/skills/studio/adapt-collection-component.md @@ -0,0 +1,165 @@ +--- +name: adapt-collection-component +--- + +## When to use + +Adapt an existing production wrapper into a Studio-compatible List Section by decomposing it into a wrapper Section (with `slot`) + Repeater + leaf adapter that wraps the real production card. This is the **compatibility-adapter path**: reach for it only when the native array-prop path (see below) won't work. + +Use when the production wrapper's leaf takes an object shape that can't easily be reduced to scalars, when template authors need to swap different leaf Sections per template instance (Section-Slot semantics), or when the list iterates a Modular Block (polymorphic: Condition Blocks needed). Phrases: my wrapper needs template-instance swaps, per-CT rendering inside a list, migrate a carousel to Studio, wrap a legacy band in a List Section. Do NOT use if the production component already accepts an `array` prop and no per-instance swap or polymorphism is needed, use `build-repeating-section` § When to skip the Repeater entirely: the array-prop alternative instead (simpler, native, no adapter). + +> **Auth preflight: settle the credential before the first API call.** Resolve it OAuth-first per [`authenticate-cma`](authenticate-cma.md): `CS_OAUTH_ACCESS_TOKEN`, else the Contentstack MCP's stored session: Read that skill for where it is, even with no MCP connected. **Never ask the user for a session `authtoken`.** If nothing resolves, or a refresh fails with `400 invalid_refresh_token`, hand them `! CONTENTSTACK_REGION= npx @contentstack/mcp --auth` (it needs a TTY and a browser, so it cannot be run for them) and wait. `403 error_code 316` is a valid credential aimed at another org: fix the org or the `api_key`, do **not** re-authenticate. + +# Adapt a production collection-shaped component into a Studio List Section + +## Which path?: decide before you build + +Studio has two ways to render a CMS collection through a production wrapper. Pick before writing any adapter: + +**Path A: native array prop** (`build-repeating-section` § array-prop alternative). Register the production wrapper with a `type: "array"` prop, bind that prop to a Reference field on the section's linked schema, populate `data_sources.resolvedReferences`. The SDK's data-binder reads the bound array and delivers full resolved entries. The component does its own `.map()`. No Repeater, no Condition Block. Simpler and native. + +Use Path A when: the production wrapper already accepts an `array` prop of full entry-like objects. The list iterates a **single Reference field** (one CT, no per-item polymorphism). Template authors don't need to swap the child Section per template instance. You don't need Studio Preview Mode to show N rendered children on the canvas. + +**Path B: compatibility adapter** (this skill). Decompose into a wrapper Section registered with a `slot` prop + Repeater + leaf adapter. Use when Path A doesn't fit. + +Use Path B when any of these are true: + +- The list iterates a **Modular Block**, needs Condition Blocks per block type. +- Template authors need to **swap a different child Section per template instance** via the Section Slot. +- The production leaf's interface is object-shaped in ways that don't reduce cleanly to `array` items (nested objects, per-item variant flags Studio needs to expose to authors). +- You need Studio to show **real iteration preview** of N rendered children on the canvas (Preview Mode). + +If Path A works, use it. This skill covers Path B: the migration path when the leaf can't be simplified into an array-item shape. + +## Context (Path B) + +The Path B decomposition: + +- The **wrapper** (band chrome, layout container, header, CTA) becomes a registered component with a `slot` prop plus scalar props for header/CTA overrides. This is a Simple Section-shaped registration (scalar props all the way) that a List Section will drop a Repeater into. +- The **iteration** (looping over items) becomes a Repeater dropped into the wrapper's slot at template-authoring time, bound to the multi-value field. +- Each **leaf** (one card) becomes a registered adapter with scalar props, wrapping the real production card 1:1. + +The leaf is the interesting reuse point: because leaf props are scalars, the Studio adapter just imports and renders the production card verbatim. Layout drifts at the wrapper. Leaf pixels stay identical. + +## Task + +1. **Inventory the target component.** Read the production component + every call site. Record: which props are arrays/objects vs scalars, what layout the wrapper does (grid vs carousel vs stack), what per-item props the leaf card takes, and any data transforms the call site does before passing `items` (subtitle composition, per-CT image fallbacks, URL building, defaults). Every literal prop value at every call site is a preset the marketer will otherwise lose. + +2. **Split into three registrations.** Draw the decomposition explicitly before writing code: + + | Layer | What it does | Registration shape | + |---|---|---| + | **List Section wrapper** (wrapper) | Owns background, heading area, "View more" CTA, and the layout container (grid tracks, or the carousel scroll-viewport CSS) | Component with a `slot` prop for its main region + `imageurl`/`string`/`href`/`choice` props for header/CTA overrides | + | **Iteration** | Loops over the bound multi-value field | Not a component. It's a Repeater the template author drops into the band's slot | + | **Leaf adapter** | Renders one item | Component whose props are exactly the leaf card's scalars, mirror the production `Card`'s interface | + +3. **Register the leaf card first.** Import the production leaf card into a thin adapter and re-export it registered with scalar props. If the production leaf takes an object (`item: { title, url, image }`), the adapter destructures to scalar props (`title`, `url`, `imageUrl`) and constructs the object for the real card internally. Every scalar prop gets a `defaultValue` for palette preview. + + ```tsx + // studio-adapters/GlossaryCardAdapter.tsx + import { CaseStudyCard } from "@/components/cards/CaseStudyCard"; + + export function GlossaryCardAdapter(props: { + title: string; url: string; imageUrl: string; description?: string; + isInteractive?: boolean; // ← literal from the live call site — critical + }) { + // Reconstruct the shape the production card expects. + return ; + } + ``` + + **Import the real leaf, never reimplement it.** The whole point of this pattern is that leaves stay pixel-identical. A reimplementation defeats the purpose. + +4. **Register the List Section wrapper with a `slot` prop.** The band owns everything around the iteration. The slot is where the Repeater lands. + + ```tsx + // studio-adapters/GlossaryCardBand.tsx + export function GlossaryCardBand({ heading, subheading, ctaLabel, ctaHref, items }: { + heading: string; subheading?: string; ctaLabel?: string; ctaHref?: string; + items: React.ReactNode; // slot + }) { + return ( +
+
+

{heading}

+ {subheading &&

{subheading}

} + {ctaLabel && ctaHref && {ctaLabel}} +
+
+ {items} +
+
+ ); + } + ``` + + Transcribe the production wrapper's CSS **line by line**. Every class (heading typography, gap, padding, item basis, peek percentages, breakpoint splits) is a drift risk if approximated. A `// KEEP-IN-SYNC WITH ` comment on both files is the minimum viable version-control gate until the visual-parity skill lands (`verify-visual-parity`). + +5. **In the section, drop a Repeater into the band's slot** and bind the Repeater's source to the multi-value entry field. Inside the Repeater, drop the leaf card adapter and bind each of its scalar props to the item's fields. If the field can hold multiple content types (a heterogeneous Reference or Modular Block), wrap the leaf adapter in a Condition Block with one branch per type. See `use-condition-block`. + +6. **Preserve display logic that lived at the call site.** The production call site often normalizes data before passing to the wrapper (subtitle composition, image fallbacks per CT, URL building). Studio's binding is a bare `field → prop` path. It has nowhere to put that logic. Route each piece to the right home: + + | Call-site logic | Where it goes in Studio | + |---|---| + | `props.copy = entry.short_description ?? entry.description` | Inside the leaf adapter: accept `copy` as prop, adapter re-routes to the right field. Or: expose both as separate props and let a preset choose. | + | `subtitle = "Related to " + entry.title` | Static text on the band, OR bind a `string` prop to the page entry (S5 dep) once page-scope binding lands. Until then: static text with a note in the migration doc. | + | `image = entry.tile_data?.thumb ?? entry.image` | Per-branch Condition Block bindings: each branch points at the right path for that CT. | + | Boolean flags set by the call site (`isInteractive={false}`, `isLogoBgWhite={false}`) | Registered as scalar props with the call-site literal as `defaultValue`. Missing this is the #1 cause of "looks similar but off." | + +7. **Carry every literal prop from every call site into `defaultValue` or a preset.** Grep the codebase for every JSX use of the production wrapper. Each literal (`variant="compact"`, `columns={3}`, `showRating={false}`) becomes either a `defaultValue` on the adapter's corresponding prop, or a named preset. Skipping this is what makes the composed page "look 80% right". See the `isInteractive` bug in the meta-observation. + +8. **Match the CSS mechanism the production band uses.** Carousels are the classic trap: + + | Production mechanism | Studio adaptation | + |---|---| + | Embla / Swiper (JS library that measures children) | Repeater wraps each iteration in `display:contents`, so JS libraries see zero-width items and break. Use a **CSS-only** carousel (scroll-snap + `overflow-x: auto` + item `flex-basis` with peek), OR wrap each iteration in a real DOM element (product gap, file as Part 1 #10). | + | CSS Grid | Grid tracks on the band's slot container, leaf just fills its cell. | + | Flexbox with `flex-wrap` | Same layout on the slot container. Leaf renders `width: 100%`. | + + For the carousel case: gap-subtracted `flex-basis` (`calc(90% - var(--gap))`) prevents the last item peek from being cropped by the container's right padding. + +9. **Verify per band with the visual-parity skill.** Run `verify-visual-parity` (or the `docs/for-developers/migration/` playbook's manual pass) at authored data (real entries pinned) for each band as it lands, not once at the end. Drift compounds if you defer verification. Catching it band-by-band is 10× faster than debugging a whole page. + +## Inputs needed from the user + +1. Path to the production wrapper component (the one taking an array/object prop). +2. Path to the production leaf card. +3. The entry field the collection binds to (Reference / Modular Block / multi-Group). +4. Any call sites of the production wrapper that carry literal props (grep-able list). + +## Acceptance + +- [ ] List Section wrapper registered with a `slot` prop for the iteration region + scalar props for header/CTA. +- [ ] **Every generated component carries its CSLP tags**: `studioAttributes` spread on the root element **and** `wrap: false` in its register entry, and each bindable prop's `$`-twin spread on the element rendering it. Confirmed in the DOM: the root and each bound text/image element show `data-cslp`. Without it the component renders but Visual Editor and Live Preview cannot edit it. See [`register-component` § The `studioAttributes` contract](register-component.md). +- [ ] Leaf adapter registered with scalar props only. Imports the production leaf verbatim (no reimplementation). +- [ ] Every literal prop from every production call site is preserved as a `defaultValue` or preset. +- [ ] Wrapper CSS transcribed line-by-line from production. Both files carry a `KEEP-IN-SYNC` comment naming the counterpart. +- [ ] Repeater bound to the multi-value field. Heterogeneous fields wrapped in a Condition Block (see `use-condition-block`). +- [ ] Carousel bands use CSS-only mechanisms (scroll-snap + `flex-basis`), not JS libraries that measure children. +- [ ] `verify-visual-parity` run against a pinned entry with real data. Drift classified and closed band by band. + +## Common pitfalls + +| Pitfall | Why it bites | Fix | +|---|---|---| +| Reimplementing the leaf card in the adapter | Diverges from production immediately. The "same code, same pixels" property is lost | Import the production leaf and pass reconstructed props. Never re-write. | +| Skipping the call-site literal sweep | Production sets `isInteractive={false}`, `variant="compact"`, etc. The adapter registers these as `defaultValue: true` (framework default). Rendered page has correct data but wrong behavior. | Grep every JSX use of the production wrapper. Carry every literal into `defaultValue` or a preset. | +| Approximating wrapper CSS classes | Heading typography drifts (`cs-h2` vs `h1-display-compact`), item basis peeks wrong, gaps mismatch | Transcribe class-by-class, add a `KEEP-IN-SYNC` comment naming the production file. | +| Using Embla/Swiper inside a Repeater | Repeater's `display:contents` wrappers zero out item widths, so the library computes garbage | CSS-only carousel (scroll-snap + `basis` + `overflow-x`). Or file the real-DOM Repeater option (product gap). | +| Data normalization dropped on the floor | Production composed subtitles / per-CT image fallbacks / URL builders lived at the call site. Studio's bindings are bare paths, so the logic silently disappears | Route each transform per the decision table in Step 6 (adapter / static / Condition Block branch / product-gap flag). | + +## See also + +- `build-repeating-section`: the greenfield flavor of List Section authoring (no production wrapper to preserve). +- `understand-sections`: Simple Section vs List Section distinction. How they compose. +- `register-component` § Array & object props do not bind: why arrays/objects don't bind. When to reach for this skill instead. +- `use-repeater`: how to bind the Repeater to the multi-value field. +- `use-condition-block`: required inside the Repeater when the field is heterogeneous. +- `use-section-slot`: how to carve the slot the Repeater drops into. +- `verify-visual-parity`: verification loop. Run per List Section, not once at the end. +- `docs/for-developers/migration/` playbook: the codified end-to-end brownfield migration this skill lives inside. diff --git a/skills/studio/analyze-project-fit.md b/skills/studio/analyze-project-fit.md new file mode 100644 index 0000000..6578246 --- /dev/null +++ b/skills/studio/analyze-project-fit.md @@ -0,0 +1,196 @@ +--- +name: analyze-project-fit +--- + +## When to use + +Read-only diagnostic that inspects the user's project (package.json, framework, React version, Contentstack deps) and emits a tailored install path before any state-changing skill runs. + +Use as the FIRST step when the user wants to add Studio to an existing project, or is unsure if their setup is compatible. Phrases: "add Studio to my app", "is my project compatible", "before I install". Always run before `install-studio`, `install-live-preview`, or `setup-section-preview`. Skip if already run this session. + +> **Auth preflight: settle the credential before the first API call.** Resolve it OAuth-first per [`authenticate-cma`](authenticate-cma.md): `CS_OAUTH_ACCESS_TOKEN`, else the Contentstack MCP's stored session: Read that skill for where it is, even with no MCP connected. **Never ask the user for a session `authtoken`.** If nothing resolves, or a refresh fails with `400 invalid_refresh_token`, hand them `! CONTENTSTACK_REGION= npx @contentstack/mcp --auth` (it needs a TTY and a browser, so it cannot be run for them) and wait. `403 error_code 316` is a valid credential aimed at another org: fix the org or the `api_key`, do **not** re-authenticate. + +# Analyze project fit: pre-flight diagnostic for Studio install + +## Why this skill exists + +`install-studio` assumes a known project shape. In reality projects vary across: + +- **React version**: the SDK supports React 18 and React 19. `@contentstack/studio-react` 1.5.1 and later peer-dep `react ^18.0.0 || ^19.0.0`. The one real blocker is a **stale SDK pin**: a lockfile already holding `@contentstack/studio-react` at 1.5.0 on a React 19 app fails with ERESOLVE. Fix the pin, never downgrade React. +- **Framework**: Next.js needs `NEXT_PUBLIC_*` env prefixes and SSR-safe init, Vite needs `dedupe` and a prod-build canvas, CRA needs `REACT_APP_*`. +- **Bundler quirks**: Next 16 + Turbopack has tree-shake edge cases. Custom webpack configs may not externalize React. +- **Existing Contentstack SDKs**: adding Studio to an app that already has `@contentstack/delivery-sdk` is a minimal-add, not a greenfield install. +- **Local serving**: Studio iframes the canvas. `http://localhost` works, mkcert is optional. Chrome asks for local network access: allow it. + +Running blind through `install-studio` against these variants is what produces the long debug sessions documented in `STUDIO_INTEGRATION_ISSUES.md` (dev-optimizer dual React, stale SDK pin, missing trusted HTTPS, etc.). This skill front-loads the inspection so the next skill receives a clean, known shape. + +This skill is **read-only**. It never writes files, never installs packages, never modifies the project. It produces a plan, the user confirms, then the appropriate downstream skill runs. + +## Task + +### 1. Inspect the project (no writes) + +Run these checks in order: + +```bash +# 1a. package.json — root + workspace packages if monorepo +cat {{projectRoot}}/package.json + +# 1b. Lockfile → package manager +ls {{projectRoot}}/pnpm-lock.yaml {{projectRoot}}/yarn.lock {{projectRoot}}/package-lock.json {{projectRoot}}/bun.lockb 2>/dev/null + +# 1c. Workspace / monorepo signals +ls {{projectRoot}}/turbo.json {{projectRoot}}/pnpm-workspace.yaml {{projectRoot}}/nx.json {{projectRoot}}/lerna.json 2>/dev/null + +# 1d. TypeScript presence +ls {{projectRoot}}/tsconfig.json 2>/dev/null + +# 1e. Single React check (the dual-React problem) +cd {{projectRoot}} && npm ls react 2>&1 | head -20 + +# 1f. Bundler configs +ls {{projectRoot}}/vite.config.* {{projectRoot}}/next.config.* {{projectRoot}}/webpack.config.* {{projectRoot}}/tsup.config.* 2>/dev/null + +# 1g. Local HTTPS readiness +which mkcert ; ls {{projectRoot}}/certs/*.pem 2>/dev/null + +# 1h. Deploy target +ls {{projectRoot}}/launch.json {{projectRoot}}/.cs-launch.json 2>/dev/null + +# 1i. App-side i18n (every locale needs its own env Base URL later) +grep -E '"(next-intl|next-i18next|i18next|react-i18next|react-intl|@lingui/[a-z]+|next-international)"' {{projectRoot}}/package.json +find {{projectRoot}}/app {{projectRoot}}/src/app -maxdepth 2 -type d \( -name '[[]locale[]]' -o -name '[[]lang[]]' \) 2>/dev/null +grep -n "i18n" {{projectRoot}}/next.config.* 2>/dev/null ; ls {{projectRoot}}/middleware.* {{projectRoot}}/src/middleware.* 2>/dev/null +``` + +### 2. Classify the project + +From the output above, extract: + +| Signal | Detection rule | +|---|---| +| **Framework** | `package.json` deps: `next` means Next.js, `vite` means Vite, `react-scripts` means CRA, `@remix-run/*` means Remix, `astro` means Astro, and anything else is Custom | +| **React version** | the `react` field in `package.json` deps gives the version range. Resolve to the actual installed major via `npm ls react`. Classify as **18**, **19**, **17 or older** | +| **Single React** | `npm ls react` must show exactly one tree entry, on **18.x or 19.x** (the SDK peer-deps `react ^18.0.0 \|\| ^19.0.0` from 1.5.1): if it shows two entries or a deduped warning, that's a duplicate risk. A single 19.x entry is a pass, not a blocker | +| **Package manager** | `pnpm-lock.yaml` means pnpm, `yarn.lock` means yarn, `bun.lockb` means bun, and `package-lock.json` means npm | +| **TypeScript** | `tsconfig.json` exists | +| **Monorepo** | Any of `turbo.json` / `pnpm-workspace.yaml` / `nx.json` / `lerna.json` | +| **Existing Studio integration** | Any `@contentstack/studio-*` already in deps | +| **Existing CS app** | Any `@contentstack/*` already in deps (not Studio), implies minimal-add | +| **Next + Turbopack** | Next deps + `next dev --turbo` or `next dev --turbopack` in scripts | +| **mkcert ready** | `which mkcert` returns a path | +| **Data center / region** | See § 2b: Determine the data center below. **Never skip this.** | + +### 2b. Determine the data center: prod region vs non-prod (``, stag, …) + +**Nothing else in the skill pack figures this out, and several skills assume you already have.** Establish it here, once, and carry it forward. + +**Why it matters more than it looks.** The Delivery SDK defaults to the **US prod CDA**, and the Studio React SDK derives its Studio API host from whatever stack you hand it. So a project targeting `` that never sets `host:` will silently read and write **prod**, no error, no warning, wrong data center. Non-prod also has no `region` shortcut: every host must be overridden explicitly (`install-studio` § Regional host map). + +Resolve in this order, stopping at the first hit: + +```bash +# 1. Explicit host already in the app? +grep -rnE "https?://|host:|CONTENTSTACK_[A-Z_]+|CMA_HOST|STUDIO_API_HOST|CDA_HOST|PREVIEW_HOST|region" .env* src/ app/ lib/ 2>/dev/null | head + +# 2. Region shortcut in use? (prod only — NA/EU/AU/AZURE_NA/AZURE_EU/GCP_NA/GCP_EU) +grep -rn "region:\s*['\"]" src/ app/ lib/ 2>/dev/null | head +``` + +3. **Ask, if neither is conclusive**: and ask for the URL, not the name, because the URL is unambiguous: + + > Which Contentstack environment is this stack on? Paste the URL you use to open Contentstack: `app.contentstack.com` (US prod), a regional prod host, or something like `-app.` (non-prod). + +The editor URL determines every other host. For a non-prod env ``, the full set is `-cdn` / `-rest-preview` / `-api` / `-composable-studio-api` / `-images` `.`, table in [`install-studio`](install-studio.md) § Non-prod hosts. + +**Record it in the report (step 4) as an explicit line.** Downstream skills consume it: + +| Consumer | What changes on non-prod | +|---|---| +| [`install-contentstack-mcp`](install-contentstack-mcp.md) | API calls need `CONTENTSTACK_CMA_BASE_URL` / `CDA_BASE_URL` plus a valid region code (a non-prod environment name is not a region, and the CLI exits on it), and its **OAuth login can't reach a custom DC at all** (authorize host is region-derived, no override). Use a management token or `csdx` there. Defaults to **NA prod** if this answer is missing | +| **OAuth on a custom DC** | Needs that DC's **own** OAuth app id + client id: apps are registered per data center, so one data center's ids do not exist on another. **Ask the user, never reuse ids across DCs.** A wrong id makes the authorize page return "App with id … not found". See [`install-contentstack-mcp` § OAuth on a custom region](understand-contentstack-mcp.md#oauth-on-a-custom-region) | +| [`install-studio`](install-studio.md) | Every host set explicitly. `host:` on `Contentstack.stack({...})` is mandatory, not optional | +| [`verify-setup`](verify-setup.md) | `previewHost` comes from this determination rather than the prod region map | +| [`author-composition-via-api`](author-composition-via-api.md) | `curl` not Python for CMA (non-prod chain isn't in Python's CA store) | + +### 3. Choose a path + +Pick exactly one based on the classification: + +| Path | Detected shape | What to do next | +|---|---|---| +| **A. Greenfield-friendly** | **Vite / CRA / Next (App or Pages Router)** + React 18 or 19 + single React + no existing CS deps | Run `install-studio` directly. No pre-work needed. A fresh Next 15 + React 19 app lands here — framework is not what gates Path A; a stale SDK pin (B), React 17 (C), existing CS deps (D) or duplicate React (G) are. On Next + Turbopack, also read Path E's flag before installing. | +| **B. Stale SDK pin on React 19** | React 19.x detected AND `npm ls @contentstack/studio-react` (or the lockfile) shows 1.5.0 | **Block** `install-studio` until the pin moves. **Evidence:** `@contentstack/studio-react` 1.5.1+ peer-deps `react ^18.0.0 \|\| ^19.0.0`. 1.5.0 alone peer-deps `^18.0.0`, so the install fails with ERESOLVE on React 19. **Fix:** ` add @contentstack/studio-react@latest` (run [`upgrade-studio-sdk`](upgrade-studio-sdk.md) so the sibling packages move together), then re-run `analyze-project-fit`. **Never downgrade React or Next to satisfy an old SDK pin.** A React 19 app with no prior Studio pin is Path A or D. | +| **C. React 17 or older** | React < 18 detected | **Block** `install-studio`. The user must upgrade to React 18 first. Refer them to the React 18 upgrade guide. The SDK won't work on 17: this is a hard requirement, not a soft one. | +| **D. Existing CS app (minimal-add)** | `@contentstack/delivery-sdk` or `@contentstack/live-preview-utils` already present | Do NOT re-init Delivery SDK. Take the minimal-add branch in `install-studio` (Studio-only install, reuses existing Delivery SDK init). | +| **E. Next + Turbopack** | Detected | Proceed to `install-studio` with Next-specific paths. Flag: SDK ships a canvas-route built-ins auto-bootstrap, but if the user is pinned to an older SDK there's a known Turbopack tree-shake regression. `troubleshoot-canvas` has the safety-net snippet. | +| **H. Non-prod / custom data center** | Editor URL or config shows your team's non-prod domain | Not a blocker, but pin it before any state-changing skill. Every host must be set explicitly (no `region` shortcut), the MCP's OAuth login cannot reach a custom DC (its API calls can, with host overrides), and CMA calls use `curl`. Carry the `` prefix into `install-studio`, `verify-setup` and any provisioning skill. | +| **F. Custom bundler** | Webpack/tsup/rollup with no `external: ['react']` detected | Walk the user through `dedupe` config OR (recommended) switch to serving a prod build for the Studio canvas. The dev bundler is the most common source of dual-React issues. | +| **G. Multiple React instances** | `npm ls react` shows >1 entry | **Block** `install-studio`. Resolve duplicates first: identify which dep pulls the second React (`npm ls react`), add `overrides`/`resolutions` in `package.json` to pin a single version, reinstall, re-run `analyze-project-fit`. | + +### 4. Report: what to tell the user + +Print a single readable summary block: + +``` +✓ Project shape detected: + Framework: + React: <18.x | 19.x> — + Package manager: + TypeScript: + Monorepo: + Existing CS SDKs: + Local HTTPS: + Deploy target: + i18n: + +Chosen path: — + +Next steps (in order): + 1. + 2. Invoke skill: + 3. Then: + +Blockers to resolve before install: + • +``` + +Wait for explicit user confirmation before invoking the next skill. + +### 5. Hand-off + +Once the user confirms, invoke the next skill in the chosen path. Pass forward what you discovered (framework, package manager, React version, TS) so the downstream skill doesn't re-prompt for things you already know. + +## Inputs needed from the user + +1. `projectRoot`: path to the project. Usually `.` (current dir). + +## Acceptance + +This skill succeeds only when ALL of the following are true. + +- [ ] Inspection commands ran and outputs were classified (no guessing: every signal in the classification table either matched a detection rule or was reported as "not detected"). +- [ ] **The data center is established and stated explicitly**: a prod region (NA/EU/AU/AZURE_NA/AZURE_EU/GCP_NA/GCP_EU) or a non-prod `` prefix. Never left as "assumed prod": the SDK silently defaults to US prod, so a wrong assumption writes real data to the wrong data center with no error. +- [ ] Exactly one path (A to G, plus H if non-prod) was chosen with justification. +- [ ] The summary block was printed including the chosen path + next steps. +- [ ] If the path is a blocker (B/C/G), `install-studio` was NOT invoked: the user is given the prep step and asked to re-run `analyze-project-fit` after. +- [ ] If the path is an enabler (A/D/E/F), the downstream skill was invoked with the discovered context. +- [ ] No files were modified by this skill itself. + +## Common pitfalls + +| Pitfall | Why it bites | Fix | +| --- | --- | --- | +| Trusting `package.json` `react` field over `npm ls react` | The dep range may say `^18.0.0` but the actual installed version could be 18.x or higher depending on resolution + lockfile | Always run `npm ls react` to confirm the installed major | +| Assuming React 19 is blocked | The SDK supports React 19 since 1.5.1. Telling a React 19 user to downgrade to 18.3.1 rewrites their app for nothing and can break Next 15 features they rely on | React 19 is Path A or D. Only a stale `@contentstack/studio-react` pin at 1.5.0 blocks it, and the fix is upgrading the SDK (Path B) | +| Skipping the monorepo check | Studio installed in the wrong workspace package doesn't get picked up by the canvas-app | Detect `pnpm-workspace.yaml` / `turbo.json`, ask which workspace package is the canvas-app | +| Routing every project through Path A by default | The smooth path only applies to a specific shape | Use the classification table: if any signal points to B to G, go there | +| Re-running `analyze-project-fit` after the user makes a change without re-running the inspection commands | Stale classification = wrong path | After any user-side prep (downgrade, dedupe), re-run inspection commands fresh | + +## See also + +- `install-studio`: the downstream skill for paths A/D/E/F +- `setup-section-preview`: for the canvas-iframe wiring +- `setup-local-https-canvas`: optional mkcert recipe, only if the app needs local HTTPS +- `troubleshoot-canvas`: reactive diagnosis if install proceeded and something failed downstream +- `verify-setup`: layered smoke test after install diff --git a/skills/studio/authenticate-cma.md b/skills/studio/authenticate-cma.md new file mode 100644 index 0000000..f50370f --- /dev/null +++ b/skills/studio/authenticate-cma.md @@ -0,0 +1,238 @@ +--- +name: authenticate-cma +--- + +## When to use + +Canonical home for **how any Studio skill authenticates an API call**: CMA (`api.contentstack.io/v3`), the Studio API (`/v1/projects`), and the compositions service. Read it before writing a `curl`, a provisioning script, or any tooling that talks to Contentstack. + +Phrases: "which token do I use", "OAuth instead of authtoken", "401 error_code 105", "422 Stack not found", "authtoken is not valid", "do I have to paste a token", "we're logged in with OAuth", "management token vs authtoken", "the MCP is authenticated, use that". + +Do NOT use for: installing the MCP ([`install-contentstack-mcp`](install-contentstack-mcp.md)), or minting **delivery/preview** tokens, which are a different credential class entirely ([`enable-visual-experience`](enable-visual-experience.md)). + +# Authenticating Contentstack API calls: OAuth first, session token last + +Before reading a credential store for HTTP work, check the active host's callable tools per [`install-contentstack-mcp`](install-contentstack-mcp.md). Use read-verified MCP operations where supported. This skill resolves credentials for remaining HTTP operations; successful OAuth login does not establish MCP readiness. A missing readiness flag triggers discovery, not automatic HTTP fallback. + +**Default assumption: there is no session `authtoken`, and none is coming.** The user authenticates once via OAuth (the MCP's `--auth`, or `csdx auth:login --oauth`) and every skill runs on that. A skill whose only documented path is `-H "authtoken: "` is broken for the normal case: it stops the run to demand a credential the user was never going to produce. + +## The credential ladder: verified from the CLI's own resolver + +Not inferred. This is `feature-status/build-auth-headers.js` in `@contentstack/cli-utilities`, the resolver every `csdx` command runs through, plus `http-client/oauth-decorator.js`: + +| Priority | Credential | Headers it sends | Scope pairing | +|---|---|---|---| +| 1 | **Management token** | `Authorization: ` (**no `Bearer` prefix**, that is the API contract) | + `api_key` (stack-scoped) | +| 2 | **OAuth access token** | `authorization: Bearer ` | + `organization_uid` | +| 3 | **Session authtoken** | `authtoken: ` | + `organization_uid` | +| 4 | **Session authtoken** | `authtoken: ` | + `api_key` | + +`oauth-decorator.js` confirms the same split at the transport layer: `authorisationType === 'OAUTH'` produces `authorization: Bearer …`, and `'BASIC'` produces `authtoken: …`. **So the whole CMA surface that `csdx` drives (content types, entries, assets, publish, export/import) is reached with OAuth Bearer every day.** OAuth is the CLI's preferred rung, above the session token, not a fallback. + +## The `$CS_AUTH` convention: what every snippet in this pack uses + +Resolve auth **once**, then every call is credential-agnostic: + +```bash +# Pick the first that applies. Never prompt for a session authtoken before OAuth has been tried. +CS_AUTH="authorization: Bearer $CS_OAUTH_ACCESS_TOKEN" # 1. OAuth (+ CS_SCOPE below) +# CS_AUTH="Authorization: $CS_MANAGEMENT_TOKEN" # 2. management token — NO "Bearer" +# CS_AUTH="authtoken: $CS_AUTHTOKEN" # 3. session token — last resort only + +CS_SCOPE="organization_uid: $CS_ORG_UID" # org-scoped calls (Studio /v1/projects, OAuth generally) +# CS_SCOPE="api_key: $CS_API_KEY" # stack-scoped calls (CMA v3 content operations) + +curl -s -H "$CS_AUTH" -H "$CS_SCOPE" "https://api.contentstack.io/v3/content_types?limit=100" +``` + +Stack-scoped CMA content calls take `api_key`. Org-scoped calls take `organization_uid`. **Sending `api_key` where the endpoint wants the org is the `422 error_code 21` "Stack not found" signature**: the auth was accepted, the scope was wrong. That error means "fix the scope header", not "OAuth is unsupported here". + +## OAuth access tokens expire: refresh before concluding anything + +`csdx` calls `compareOAuthExpiry()` **before every single request** and silently refreshes via the stored refresh token (`auth-handler.js`). A raw `curl` does none of that. So a token that worked an hour ago returns **401** now, and the 401 is indistinguishable from "this endpoint rejects OAuth". + +**Never conclude an endpoint doesn't support OAuth from one 401.** Refresh first, then retry. Only a fresh token's failure is evidence about the endpoint. + +**The old exception is gone: `error_code 39` no longer means "switch credential class".** The compositions service accepted a session `authtoken` only until 2026-08-25. It now forwards OAuth Bearer too. `39` today means no credential arrived at all. See the matrix row below. + +### The numbers, and the refresh call + +| | Value | Source | +|---|---|---| +| Access-token lifetime | **59 minutes** from issue | `auth-handler.js`: `oauthDate + 59 * 60 * 1000` | +| Checked | before **every** request | `compareOAuthExpiry()` | +| Refresh endpoint | `POST {developerHub}/token` | MCP `oauth.ts`, management SDK `core/oauthHandler.js` | +| Refresh body | **form-encoded**: `grant_type=refresh_token&client_id=…&refresh_token=…&redirect_uri=…` | MCP `oauth.ts` | +| `client_id`, `redirect_uri` | The MCP's public app defaults: `cGQZujH3Y_oYkf59`, `http://localhost:8184`. Env `CONTENTSTACK_OAUTH_CLIENT_ID` / `CONTENTSTACK_OAUTH_REDIRECT_URI` override them. Not secrets. Other MCP version: read them from its installed `dist` | `@contentstack/mcp` 0.9.0 constructor | +| Returns | `access_token`, `refresh_token`, `expires_in` | same | + +### Refresh tokens rotate: so "read-only" credential sharing is impossible + +`POST /token` **invalidates the refresh token you present** and issues a new one. That single fact kills the obvious-looking design: + +> "I'll read another tool's stored session but never write to it, so I can't corrupt anything." + +The moment that consumer refreshes, the pair still sitting in the other tool's store is **dead**. That tool's next refresh returns `400 {"message":["invalid refresh_token"]}`, and the user has to log in again, caused by the consumer that was trying to be careful. + +Measured exactly that way: an in-memory-only refresh succeeded once, and every later process failed against the now-stale store. + +**Sharing a rotating credential means owning its rotation.** Either persist the new pair back to the store you read it from (atomically, preserving the other fields) or don't refresh that credential at all and tell the user to re-authenticate. There is no third option. + +A `400 invalid refresh_token` therefore means "something refreshed and didn't write back", not "your login expired". + +**Recovering from it needs a human.** `npx @contentstack/mcp --auth` is an interactive TUI plus a browser login. No agent can complete it. Hand the user the command with the region already filled in. See [`install-contentstack-mcp`](install-contentstack-mcp.md) § `--auth` is an interactive TUI. + +**The body is `application/x-www-form-urlencoded`, and `redirect_uri` is required.** A JSON body is rejected. This is worth stating because it is easy to get wrong from the SDK's TypeScript alone: our own first implementation sent JSON without `redirect_uri` and failed on the first live expiry. + +**Anything that runs longer than an hour must handle this.** Seeding 80 sections, backfilling thumbnails, uploading assets: all cross the boundary, and the failure lands mid-run with half the work done. + +Two ways to survive it: + +- **Re-mint before the run**: `csdx auth:login --oauth`, re-export the token. Fine for a short run. A coin-flip for a long one. +- **Refresh in-process**: hold the refresh token and `client_id`, refresh when stale, and retry once on a 401. Refresh tokens rotate, so after each refresh **write the new pair back** to the store it came from (atomically, keeping every other field). + +**A bare `curl` in a loop has neither.** If a snippet in this pack is going to run more than a few minutes, drive it from a script that refreshes. Don't paste a token into a shell loop and hope. + +## Failure signatures: what each response actually means + +| Response | Cause | Fix | +|---|---|---| +| `401` `error_code 105` "authtoken is not valid" | OAuth token sent in the **`authtoken:`** header | OAuth goes in `authorization: Bearer`. Wrong header, not a wrong token | +| `401` after previously working | Expired OAuth access token, no refresh | Refresh the token, retry. Not an endpoint limitation | +| `401` `error_code 39` (compositions) / `48` (registered components) "Missing Contentstack auth token" | **Neither** credential reached the service: no `authtoken` and no `authorization: Bearer`. Since 2026-08-25 both styles are accepted, so this is no longer "OAuth was refused" | Send one credential. Check the `Bearer ` prefix is present: a bare `authorization` value is read as a management token and never relayed | +| `401` "not logged in" | **Management token** sent as `authtoken:` | Management token goes in `Authorization:` **without** `Bearer`, paired with `api_key` | +| `422` `error_code 21` "Stack not found" | Auth accepted, scope header wrong or the app token can't resolve the stack | Pair OAuth with `organization_uid`. For stack-scoped calls send `api_key` | +| `403` `error_code 316` "You don't have the permission to do this operation" | The `api_key` belongs to a **different org** than the OAuth session. Auth was fine. Measured: same token, `200` on one stack, `403` on another | Re-auth against the owning org, or use an `api_key` from the token's org. `GET /v3/stacks` + `organization_uid` lists what it can reach | +| `200` + the change didn't apply | Wrong envelope or wrong key, **not an auth problem** | See [`enable-visual-experience`](enable-visual-experience.md): `PUT /v3/stacks` returns "Stack updated successfully" while ignoring an unrecognized payload | + +That last row is the most expensive one in this table, because it looks like success. + +## Which credential each service takes + +| Service | OAuth Bearer | Session authtoken | Management token | +|---|---|---|---| +| CMA v3: content types, entries, assets, publish | **Yes**, verified, the path every `csdx` command uses | **Yes** | **Yes** (stack-scoped) | +| CMA v3: `/v3/stacks/settings` (Live Preview toggle) | **Yes**, verified for read AND write: `POST` returned `201` and the re-read showed `enabled: true` | **Yes**, verified | Untested | +| CMA v3: `/v3/stacks/delivery_tokens` (incl. `?create_with_preview_token=true`) | **Yes**, verified for read AND write: `POST` returned `201` with a 26-char `preview_token`, `DELETE` `200`. See [`provision-studio-project`](provision-studio-project.md) step 6 | **Yes**, verified | Untested | +| CMA v3: `POST /v3/environments` | **Yes**, verified, `201` | **Yes** | **Yes** | +| CMA v3: `GET /v3/stacks` (org-scoped) | **Yes**, verified: `200` with OAuth Bearer + `organization_uid` | **Yes** | - | +| Studio API: `/v1/projects` | **Yes**, verified on prod NA, `GET` returns `200` + the project list with `organization_uid`. Non-prod read+write after the same change | **Yes** | **No**, `401` "not logged in" | +| Compositions service (`/v1/projects/{uid}/compositions`) | **Yes**, supported since 2026-08-25: the service forwards Bearer to the CMA | **Yes**, also accepted | **No** | +| Registered components (`/v1/projects/{uid}/registered-components`) | **Yes**, supported since 2026-08-27: forwards Bearer to the component service | **Yes**, also accepted | **No** | + +**"Untested" means nobody has measured that combination. "No" means measured and refused.** For an untested row: attempt OAuth, read the response, and record what you observe. Never pre-emptively downgrade to a session token because a row says untested. Rows in this table have **three times** been declared impossible and turned out to be wrong: the compositions row above is the third. See [`enable-visual-experience`](enable-visual-experience.md) § Correction. Treat "this endpoint doesn't do OAuth" as a claim with a short shelf life. + +**Reads and writes both measured**, on a throwaway stack: `POST /v3/environments` returned `201`, `POST /v3/stacks/settings` returned `201` with the re-read confirming `enabled: true`, `POST /v3/stacks/delivery_tokens?create_with_preview_token=true` returned `201` carrying a 26-character `preview_token`, and `DELETE` returned `200`. OAuth Bearer covers the write surface, not just reads. + +**And the failures along the way were all `422`, never `401`/`403`**: which is the useful part. A `422` means the credential authenticated and authorized, and the request was then rejected on **content**. Two real payload rules surfaced that way: + +| Rejected payload | Response | Rule | +|---|---|---| +| `{stack_settings:{live_preview:{enabled:true}}}` | `422` `150`, `errors["settings.live_preview"]`: "You must activate the enable button and provide default URL and environment" | `enabled` alone is not enough: `default-env` (an environment **uid**) is required, so the environment must exist first | +| delivery-token `scope` with only an `environment` entry | `422` `141` "Delivery Token creation failed. Please try again.", real cause in `errors["scope.branch_or_alias"]` | add `{"module":"branch","branches":["main"],"acl":{"read":true}}` | + +Both are documented in [`enable-visual-experience`](enable-visual-experience.md) § Do it by API. **Read the payload from there rather than reconstructing it**: the top-level error messages are generic and the actionable detail hides in `errors`. + +### The Studio API now takes OAuth everywhere: correction, 2026-08-27 + +**This pack used to carry a standing exception: the compositions service was session-`authtoken` only.** That is no longer true, and the guidance it produced ("ask the user for a session token before calling compositions") is now wrong. OAuth-first applies to the entire pack, with no carve-out. + +What changed, in `composable-studio-api`: + +| Date | Change | +|---|---| +| 2026-08-25 | Compositions forward OAuth access tokens to the CMA | +| 2026-08-27 | Registered components forward them to the component service | + +The service authenticates nothing itself: the gateway does, and injects the principal (`x-user-type: OAuthBot` for OAuth callers) that the policy checks already understood. Inbound OAuth always worked. The missing piece was the **outbound** hop, where `buildHeaders()` only ever emitted `authtoken`. Both hops now carry whichever credential you presented: + +``` +authtoken: -> forwarded as authtoken: +authorization: Bearer -> forwarded as authorization: Bearer +``` + +Three rules survive the change: + +- **The `Bearer ` prefix is mandatory.** Upstream reads a bare `authorization` value as a management token, so dropping the prefix fails as the wrong credential kind. A non-`Bearer` value is deliberately never relayed: the service does not pass on stack-scoped credentials it did not issue. +- **Send one credential, not two.** If both headers arrive, the gateway-asserted principal picks the winner, so the forwarded credential matches the identity the permission check ran on. Sending both and hoping is how a caller ends up authenticated as somebody else. +- **`39` and `48` now mean "no credential at all".** Neither is evidence that OAuth was refused. Refreshing still doesn't help, but sending a credential does. + +**The lesson for this table, not just this row.** This is the third entry in the OAuth matrix declared impossible and later disproved. Prefer attempting OAuth and reading the response over trusting a row that says it can't work. + +### `403 error_code 316` is the org trap, not an auth failure + +Measured: the same OAuth token returned `200` on one stack and `403` on another in the same run. + +``` +GET /v3/content_types stack in the token's org -> 200 +GET /v3/content_types stack in a DIFFERENT org -> 403 error_code 316 + "You don't have the permission to do this operation." +``` + +**OAuth sessions are org-scoped.** The MCP and `csdx` capture one org at login. An `api_key` from any other org returns `403 / 316`: the credential is perfectly valid, it just doesn't reach that stack. Confirmed twice, the second time with a **freshly refreshed** token in the same run: `200` listing stacks in the token's own org, `403 / 316` for both a stack and a stack-list in a different org. A fresh token failing on one org and succeeding on another is proof the problem is scope, not the credential. Reading this as "OAuth doesn't work" and escalating to a session token is the wrong move. The fix is to authenticate against the owning org, or use the right `api_key`. Confirm which org you're on with `GET /v3/stacks` + `organization_uid`, which lists exactly the stacks the token can reach. + +### Probe: re-run this when you change org, environment or data center + +```bash +# Read-only. Safe on any stack. Prints status codes only, never the token. +for ep in "content_types?limit=1" "stacks/settings" "delivery_tokens?limit=1"; do + code=$(curl -s -o /dev/null -w '%{http_code}' \ + -H "authorization: Bearer $CS_OAUTH_ACCESS_TOKEN" -H "api_key: $CS_API_KEY" \ + "https://api.contentstack.io/v3/$ep") + echo "GET /v3/$ep -> $code" +done +``` + +`200` on all three means the writes authenticate too: same credential, same service. A `403 / 316` means the `api_key` is outside the token's org, **not** that OAuth failed. Anything else: check the signature table above before blaming OAuth. + +## Where the credential actually lives: three separate stores + +This is what makes "we're on OAuth" ambiguous in practice. The same login flow writes to a different place depending on who ran it: + +| Who authenticated | Store | Read by | +|---|---|---| +| `csdx auth:login --oauth` | `~/Library/Preferences/@contentstack/…` (macOS) | `csdx` commands | +| `npx @contentstack/mcp --auth` | `~/Library/Application Support/ContentstackMCP/oauth-config.json` (`%LOCALAPPDATA%` on Windows, `~/.config` elsewhere) | the MCP server's tool calls | +| an env var you exported | `CS_OAUTH_ACCESS_TOKEN` | scripts and `curl` | + +**Same OAuth flow, same credential class, three different homes, and none of them reads the others.** So "I authenticated the MCP" historically did not mean a script or a `curl` was authenticated. + +A script closes that gap by preferring the env var, then falling back to **reading the MCP's store**, so one `--auth` covers scripts too. Refreshing means **writing the new pair back** to that file (atomic, other fields kept). The MCP's stored config also carries `organization_uid`, which is why org-scoped calls need no extra env var. Its `stack_api_key` is empty, which is why the stack key is still the one thing you supply. + +**If the host blocks reading that file**, the login is fine: don't send the user to `--auth`. Say what was blocked and offer: approve the read, an allow rule `Read(/**)`, or `export CS_OAUTH_ACCESS_TOKEN`. Meanwhile use the MCP's tools where they cover the task. + +The MCP refreshes on its own schedule: `token_issued_at + expires_in` (3600s), refreshed within **5 minutes** of expiry. Match that margin in your script, so neither hands out a token the other would have rotated. + +## Why a session authtoken is the last rung, not a convenience + +A session `authtoken` is a **full user-session credential**: every org, every stack, every permission that user holds, valid until the session ends. It isn't a scoped grant. Pasting one into a shell, a script or a `.env` leaves copies in shell history, process lists and logs. + +So: attempt OAuth, read the actual response, and escalate only on an observed failure with a fresh token. If you do end up using one, scope it to the single call and discard it: never persist it, never echo it, never commit it. + +## Acceptance + +- [ ] No skill run asked the user for a session `authtoken` before OAuth was attempted and observed to fail **with a freshly refreshed token**. +- [ ] Every call pairs its credential with the right scope header: `organization_uid` for org-scoped, `api_key` for stack-scoped. +- [ ] A management token, if used, is sent as `Authorization: ` with **no** `Bearer` prefix. +- [ ] Any 401/422 was matched against the signature table before being reported as "OAuth isn't supported here". +- [ ] No session token was written to a file, an env var that outlives the call, or a commit. + +## Common pitfalls + +| Pitfall | Why it bites | Fix | +|---|---|---| +| Reading another tool's OAuth store and refreshing "read-only" | Refresh tokens rotate: the refresh spends the stored one, so that tool's next refresh fails with `400 invalid refresh_token` and the user must re-authenticate | Write the new pair back atomically, or don't refresh a credential you don't own | +| A long run on a token minted at the start | Access tokens last **59 minutes**. A seeding or backfill run crosses that and 401s with half the work done, and the 401 reads as an endpoint limitation | Refresh in-process and write the rotated pair back, or re-mint immediately before a short run | +| Hardcoding `-H "authtoken: …"` in a skill's snippet | The user authenticated with OAuth and has no session token: the run stops to demand a credential that isn't coming | Use the `$CS_AUTH` convention. Resolve auth once at the top | +| Prefixing a management token with `Bearer` | `401`. The API contract for management tokens is the bare token in `Authorization:` | No prefix. Only OAuth uses `Bearer` | +| Reading one `401` as "this endpoint rejects OAuth" | Access tokens expire and raw `curl` never refreshes. `csdx` refreshes before every call | Refresh, retry, and only then record a capability limit | +| Pairing OAuth with `api_key` on an org-scoped call | `422 error_code 21` "Stack not found": reads as a missing stack, is actually a wrong scope header | OAuth + `organization_uid` for org-scoped endpoints | +| Treating a `200` as proof the write landed | `PUT /v3/stacks` accepts an unrecognized envelope and reports success | Re-read the resource and assert the field changed | +| Asking for an authtoken "just in case" up front | Spreads a full-session credential for a run that would have worked on OAuth | Ask only after a fresh-token failure, and say which call failed and how | + +## See also + +- [`install-contentstack-mcp`](install-contentstack-mcp.md): how the OAuth session is established, and non-prod / custom-DC constraints +- [`provision-studio-project`](provision-studio-project.md) § step 10: the `/v1/projects` ladder, applied +- [`enable-visual-experience`](enable-visual-experience.md): the stack-settings and preview-token writes, and the `200`-but-ignored trap +- [`complete-the-build`](complete-the-build.md): the audits that run over these endpoints diff --git a/skills/studio/author-composition-bindings.md b/skills/studio/author-composition-bindings.md new file mode 100644 index 0000000..aec1cd5 --- /dev/null +++ b/skills/studio/author-composition-bindings.md @@ -0,0 +1,158 @@ +--- +name: author-composition-bindings +--- + +## When to use + +The binding contract: all binding types, where bindings and children live in the `ui` node tree, and the Repeater binding shapes that actually resolve. + +Part of the API-authoring set. Start at [`author-composition-via-api`](author-composition-via-api.md), which routes to this one. + +## Binding types: the full set + +The SDK defines **6 binding `type` values** that Studio's Data Picker emits. Each type is available in a specific composition context. Picking one that doesn't fit the context is the most common trap. + +| `type` | Available in | Configured in Studio via | Shape | +|---|---|---|---| +| `static_value` | Connected + Freeform + Sections (always) | Right panel: in Settings, type the literal into the prop input | `{ type: "static_value", value: "" }`, where `` looks up `entry.static_value.[]`. See § How `static_value` binding resolves | +| `template` | Connected templates + Sections placed on Connected templates | Right panel: in the Data Picker, pick a field on the connected entry | `{ type: "template", value: { path: {…} } }` | +| `repeater` | Anywhere inside a Repeater | Right panel: Data Picker under the Repeater's scope, auto-picked | `{ type: "repeater", value: { repeaterUID: "", path: {…} } }` | +| `contentstack` | Requires **Freeform enabled** on the project; then available on freeform templates AND Connected Templates. Data tab, under **Pinned Entries** | In the Data tab, pin an entry, then the Data Picker binds to its fields | `{ type: "contentstack", value: { uid, _content_type_uid, path: {…} } }` | +| `contentstack_queries` | Requires **Freeform enabled** on the project; then available on freeform templates AND Connected Templates. Data tab, under **Queries** (Pinned Queries) | In the Data tab, save a query, then Repeater `items` picks it | `{ type: "contentstack_queries", value: { queryUID: "", path: {…} } }` | +| `component_props` | Anywhere: component's registered `defaultValue` OR the Data tab's **External Data** | Registration path (`registerComponent`) or Data tab External Data | `{ type: "component_props", value: "" }` | + +`path` is a nested-object leaf structure: `{ products: { 0: { url: {} } } }` reads `products.0.url`. The path is flattened at resolution. `{ products: {} }` and `{ products: { "": {} } }` are equivalent. + +### Authoring UX: the same for every binding kind + +**No inline click-to-edit exists on the canvas for any binding kind.** Every value edit happens in the right panel: + +- **Static bindings**: edited in the **Settings** tab. Type into the prop input. The literal writes to `binding.value`. +- **Template bindings**: edited via the **Data Picker** in Settings (pick a different field on the connected entry). The rendered value itself is read-only: to change the underlying content, edit the connected entry in the CMS. +- **Pinned bindings** (`contentstack`, `contentstack_queries`): the pin itself is set up in the **Data** tab, which requires Freeform enabled on the project. With it on, Connected Templates can pin too. Once pinned, bindings reference it through the Data Picker in Settings. Rendered values are read-only from Studio's perspective. Edit the source entry in the CMS to change them. + +### Context mismatches: the real trap + +Picking a binding type that doesn't fit the composition's context is silent. The shape is valid, but resolution returns `undefined` at render: + +| Mismatch | Symptom | Fix | +|---|---|---| +| `template` binding in a **Freeform** template | Binding never resolves. Freeform has no page-level entry to source from | Use `contentstack` (pinned entry) or `contentstack_queries` (pinned query) via the Data tab, or `static_value` for a literal | +| `contentstack` / `contentstack_queries` binding in a **Connected** template | The binding works, but it bypasses the connected entry. You've hard-pinned data that should come from the page. Usually a mistake | Use `template` with a path into the connected entry's schema | +| `repeater` binding used on a node NOT inside a Repeater | `repeaterUID` refers to a scope that doesn't exist above the node, resolves to `undefined` | Place the node inside the referenced Repeater's `slots`, or switch to a `template`/`contentstack` binding matching the current scope | + +## Node anatomy: where bindings and children actually live in the `ui` tree + +Node shape (canonical Repeater example, binding wrapper included): + +```jsonc +{ + "type": "", // "repeater" / "condition-block" / a registered component type + "uid": "", + "metadata": { // node-level metadata + "mode": "preview" // - Repeater: "preview" vs implicit Design Mode + // "condition": { ... } // - Condition Block: discriminator (see below) + }, + "props": { // bindings live HERE — one per prop + "": { + "binding": { "type": "", "value": { /*…*/ } }, + "value": "" + }, + // CONTAINER nodes ONLY — wire the child slot to the built-in's slot-typed + // `children` prop, or children never render (see point 4): + "children": { "type": "slot", "slot": "" } + }, + "slots": { // children go HERE, grouped by slot uid (NOT a flat `children` array) + "": [ // …this uid MUST equal props.children.slot above + { /* child node */ } + ] + } +} +``` + +Eight things to internalize: + +0. **Every node carries the full envelope (`uid`, `type`, `props`, `metadata`, `slots`, `attrs`, `styles`) even when empty.** `metadata` is not only for Repeater `mode` and Condition `condition`: the renderer reads `metadata.visible` on **every** node, so a leaf authored without the key throws `Cannot read properties of undefined (reading 'visible')` and the whole canvas renders `Component Loading Error`, no partial paint, no indication of which node. **A missing `slots` fails differently and worse, because the canvas still looks fine.** `Node.isNode` requires a truthy `slots`, so a slots-less node is not recognized as a node at all: tree walkers skip it silently (its props never appear in the Data tab), and any walker handed it as its ROOT crashes on `Object.entries(undefined)`. Measured: `Pricing Systems Cards` carried 4 slots-less atoms. Clicking one white-screened Studio's Data tab through `collectComponentProps`, with no error boundary to catch it. The section rendered perfectly on canvas and on the site the whole time. Leaves need `"metadata": {}` and `"slots": {}` exactly as containers do. The minimum leaf, copied from a production section: + +```jsonc +{ + "uid": "1v7tfQQBhdETYAN", + "type": "alpha-atom-heading", + "attrs": {}, + "metadata": {}, // ⛔ required even when empty + "slots": {}, // ⛔ required even when empty + "props": { /* … */ }, + "styles": { "default": { "classes": [""], "responsiveStyles": { "default": {} } } } +} +``` + +0a. **A `choice` value is stored as a LIST, every other type as a scalar.** `"value": ["h1"]`, not `"value": "h1"`. Measured across production `static_value` buckets: `choice` is a list in **938** entries against 18 stray strings, while `boolean` is a bare bool (180), `text` a bare string (582) and `number` a bare int (39). + +**This fails in the worst possible way: it renders correctly and configures wrongly.** The renderer accepts the bare string, so the canvas shows the right 72px heading and every visual check passes. Studio's Settings panel matches the stored value against the registered `options`, finds no match for a string, and displays **`Select…`**, so the author sees Tag, Size Variant and Color as unset on a node that looks finished, and re-picks them by hand. Neither a screenshot nor a computed-style assertion catches it. Only reading the entry back does. + +```jsonc +"choice": [ { "key": "-tag", "value": ["h1"] } ], // ✅ list +"boolean": [ { "key": "-wrap", "value": true } ], // ✅ scalar +"text": [ { "key": "-text", "value": "80%" } ] // ✅ scalar +``` + +0b. **Write EVERY registered prop, not only the ones the design dictates.** An unwritten prop still renders (it falls back to the registration default) but Studio's Settings panel shows it **blank**, so the author sees a half-configured node and fills the dropdowns by hand. That is the API author's omission surfacing as manual work downstream. For each registered prop: use the comp's value if the design determines one, otherwise write the registration's own `defaultValue` explicitly. Only a prop the registration declares with **no** `defaultValue` may stay unset. Measured on one section, this took the node count from 64 written props to 135. The other 71 were defaults the panel had been showing as empty. + +0c. **Name the layer and mark it visible.** Studio reads `metadata.title` as the layer name in the Layers tree. With no title the tree reads as repeated type names (`Stack / Box`, `Stack / Box`, `Stack / Box`) and is unnavigable. Give each node a purpose-name: `Stack — Card content`, `CTA — Read the full story`. Production nodes also carry `visible: true` and `locked: false`, and the root `page` node carries `metadata.sectionExposedProps: []`. + +```jsonc +"metadata": { + "title": "Card Shell — Stat card", // layer name in the Layers panel + "visible": true, + "locked": false +} +``` + +**`attrs` is where the panel's Attributes section keeps the ID and custom attributes** (`"attrs": { "id": "car-stat-card" }`). Across 650 production nodes it is empty on every one, and the components verified here do not spread it to the DOM, so set it when you want the ID field pre-filled in the panel, and don't rely on it reaching the rendered markup. + +1. **Bindings are at `props..binding`.** Not directly on the node, not in a flat `bindings` map. +2. **Children are in `slots.: [...]`.** Not a `children` array. Each slot has a uid. Child nodes live inside the array under that uid. +3. **Discriminators live in `metadata`.** Condition Block's `condition` and Repeater's `mode` are both `metadata.`, not top-level node fields. +4. **Container nodes must ALSO wire `props.children` to their slot: putting children in `slots` alone is not enough.** Every built-in container exposes a **slot-typed prop** in its definition: `page`, `section`, `box`, `columns`, `rows`, `repeater`, and `condition-block` all declare `children: { type: "slot" }`. The SDK fills that prop only when the node points at the slot: `props.children = { "type": "slot", "slot": "" }`, with `` matching a key in `slots`. Omit the pointer and the prop resolves to `undefined`: the container renders empty even though the child nodes sit in `slots`. A Repeater is the sharpest trap: it still iterates (N `
` wrappers appear in the DOM), but clones **zero** children into each, so it renders blank with no error. (The `page` node in every working composition carries `props.children`. Mirror it on every container you author.) A registered component with a `slot`-typed prop is wired the same way, under whatever name the registration gave the slot prop. + +### Built-in node `type` values + +These are the built-in `type` strings the SDK ships with. Anything else must be a component you registered via `registerComponent`. + +| Group | `type` values (the palette label and its `type` string) | +|---|---| +| System | `page` | +| Basic: text | `header` · `plain-text` · `collapsible-text` · `rich-text` · `text` · `number` · `json-rte` | +| Basic: button/link | `button` · `link` · `link-container` | +| Media | `image` · `video` · `embed` | +| Container / layout | `section` · `box` · **`hstack`** (palette: Columns) · **`vstack`** (palette: Rows) · `fragment` | +| Composition primitives | `condition-block` · `section-slot` | +| Iteration | `repeater` | +| HTML | `html-element` · `style-sheet` | + +Palette labels are author-facing. `type` strings are what appear in the `ui` tree. **Non-obvious pairings:** palette Columns becomes `hstack`, palette Rows becomes `vstack`. Inspect a UI-authored composition (decode `ui`, see [`troubleshoot-canvas`](troubleshoot-canvas.md) § Diagnostic tooling) to confirm the exact `type` string before hand-authoring a node of that kind. + +None of these support inline click-to-edit on the canvas. All value edits go through the right panel (both built-ins and BYOC, see [`build-section`](build-section.md) § Basic field components vs custom registered components). + +## Binding a Repeater: the shapes that actually work + +Copy these from a working composition in the target project rather than inferring them. The path shape differs per field kind and a wrong one yields **zero iterations** with no error. + +| Iterating | `items` binding path | `metadata.repeaterBindingFieldType` | +|---|---|---| +| A modular-block field | `{"page_sections": {}}`, plain | `"modular_block"` | +| A multi-reference field | `{"edge_card": {"": {}}}`, note the `{"": {}}` leaf | `"reference"` | + +Also required on the Repeater: `metadata.mode: "preview"`, or the canvas shows one placeholder while production renders N. + +**Worked failure.** A carousel repeater bound with the reference shape (`{"case_study_cards": {"": {}}}`) over a modular-block field rendered **0** cards. The same tree with the plain path and `repeaterBindingFieldType: "modular_block"` rendered all **6**, with each Condition Block routing to its branch. Nothing in the entry data or the panel indicated which shape was wrong. The Layers panel read "Iterates Case Study Cards" in both cases. + +**Scope the section at the content type root when the repeated field is top-level.** A `linked_schemas` entry of `{content_type_uid}` alone makes root fields addressable. Adding `selected_field` narrows the scope to that field, after which a path naming the same field no longer resolves. + +**Verify a `resolvedReferences` path against the API before assuming it is wrong.** The paths feed `include[]`, so they are directly testable: + +```bash +curl ".../entries/?include[]=.." +``` + +A resolved object back means the path is right and the gap is elsewhere. Measured on one project: `case_study_cards.case_study_card_with_image_statistics.card` resolved fully, while `case_study_cards.card` and `card` returned null, confirming the `..` form. **A reference nested inside a repeated modular block resolved through the API but did not resolve in the canvas**, so every prop behind it fell back to its registration default. When a block's only field is a reference, that blocks all of its content, worth checking early, because no amount of path fixing helps. diff --git a/skills/studio/author-composition-encoding.md b/skills/studio/author-composition-encoding.md new file mode 100644 index 0000000..4979d4c --- /dev/null +++ b/skills/studio/author-composition-encoding.md @@ -0,0 +1,48 @@ +--- +name: author-composition-encoding +--- + +## When to use + +Encoding rules for the `ui` tree: never pass the compressed spec inline through a shell variable, and preflight-validate the tree before publishing. + +Part of the API-authoring set. Start at [`author-composition-via-api`](author-composition-via-api.md), which routes to this one. + +## Never pass the compressed `ui` inline via a shell variable + +Silent-corruption trap. The `zlib:` blob is binary-ish. Interpolating it into a `curl -d "..."` string via a shell variable mutates one byte and the SDK renders a blank canvas with **no error anywhere**: `hasSpec: true`, spec present, SSR looks fine, and every child slot silently resolves empty because the key `"children"` was corrupted to `"c1ildren"` (or similar) throughout the tree. Background colors still paint (from `responsiveStyles`), masking the bug until you inspect the raw HTML. + +Always write the full payload to a temp file and POST with `-d @file`: + +```bash +python3 -c "import json,zlib,base64; \ + raw = json.dumps(ui_tree); \ + assert '\"c1ildren\"' not in raw, 'corrupt key'; \ + assert raw.count('\"children\"') >= , 'children count wrong'; \ + compressed = 'zlib:' + base64.b64encode(zlib.compress(raw.encode())).decode(); \ + open('/tmp/entry.json','w').write(json.dumps({'entry': {'ui': compressed, ...}}))" + +curl -X POST "/v3/content_types//entries" \ + -H "api_key: ..." -H "$CS_AUTH" \ + -H "Content-Type: application/json" \ + -d @/tmp/entry.json +``` + +The count-assert catches shell corruption **before** compression: once the tree is zlib+base64'd, the corruption is opaque. + +**Non-prod hosts + Python.** Against a non-prod data center, Python's `urllib`/`requests` fail SSL verification (`SSLCertVerificationError: unable to get local issuer certificate`), the non-prod chain isn't in the default Python CA store, but `curl` reads the system keychain and succeeds. Rule: use `curl` for the CMA POST/PUT itself. Use Python only to build/assert the payload. If Python HTTP is unavoidable (dev tooling only, never app code), pass a permissive SSL context: `ctx = ssl.create_default_context(); ctx.check_hostname = False; ctx.verify_mode = ssl.CERT_NONE; urllib.request.urlopen(req, context=ctx)`. + +## Preflight: validate the `ui` tree before publishing + +The SDK's runtime renderer walks the composition node tree and throws on certain malformed shapes. An unpublishable error reaches the editor BEFORE you see it (`TypeError: Cannot read properties of undefined (reading 'slots')` + `Cannot find a descendant at path [...] in node`), and the entire composition's render aborts: every section after the throw goes blank. The most common cause is an empty Section Slot. Skill's duty is to **never emit one** in the first place. + +Before calling `POST /v3/.../entries` (or `PUT` on update), walk the inflated `ui` tree and assert: + +- [ ] **Every `slots[]` value is a non-empty array**, no `{ "": [] }` entries. Either populate the slot with at least one node, OR remove the slot from the parent's `slots` object entirely. Two exceptions: a `section-slot` node keeps `slots: { "": [] }` until a template fills it, and the page-side `section-composition` node keeps an empty entry for each slot the template hasn't filled yet ([`author-composition-sections`](author-composition-sections.md) § `section-slot` node shape). Skip that shape in the check. +- [ ] **No node references a removed descendant**: every uid mentioned in a parent's `slots` array maps to an actual node in the tree. (Common when refactoring: removing a node but leaving its uid in a parent's slot array.) +- [ ] **Every Repeater has `metadata.mode: "preview"` AND a Condition Block child (for reference / modular block iteration).** Missing either silently renders zero iterations. +- [ ] **Every Section Slot inside the iteration has its child Section's node in place**. See `build-repeating-section` § Step 6. + +Cross-check matches the renderer-side symptom row in [`troubleshoot-canvas`](troubleshoot-canvas.md) (the "Whole composition renders blank or partially blank" row). The runtime crash should never reach the user. That's a Studio bug, but emitting a clean tree avoids it deterministically. + +If publishing through a script, gate the publish call behind these assertions and fail loudly. A composition with an empty slot is invalid. Do not emit one. diff --git a/skills/studio/author-composition-entry.md b/skills/studio/author-composition-entry.md new file mode 100644 index 0000000..2ccc993 --- /dev/null +++ b/skills/studio/author-composition-entry.md @@ -0,0 +1,129 @@ +--- +name: author-composition-entry +--- + +## When to use + +Write the composition entry itself: the CMA body, and the `url` + `url_metadata` matched pair. Read before the first POST. `static_value` resolution is in [`author-composition-static-values`](author-composition-static-values.md). + +Part of the API-authoring set. Start at [`author-composition-via-api`](author-composition-via-api.md), which routes to this one. + +## The composition entry: what you actually write to the CMA + +A composition is a single entry on the compositions CT. The fields (see § Complete example below for a real published Freeform composition): + +| Field | Type | Holds | +|---|---|---| +| `title` | string | Display name in the Compositions list | +| `url` | string | URL pattern (e.g. `/products/{{entry.slug}}` for Connected. A fixed path like `/campaigns/spring-2026` for Freeform). Populated for both kinds | +| `composable_uid` | string | Composable identity for the composition. Must be **unique across the compositions CT**. Studio's UI fills it from the name as a readable slug (`"spring_2026_landing"`). The entry uid also works. The runtime resolves pages and embeds by `composable_uid`. **It never places a section:** a template's `section-composition` node references the section by its entry uid (`metadata.compositionUID: "blt…"`), and a `composable_uid` there fails with `Component with type '' is not registered`. A mismatch produces "Composition Not Found" | +| `connected_content_type` | string | The CT this template binds to (Connected only). Empty string `""` for Freeform / sections | +| `place_composition_as` | string | `"page"` for a template composition (Connected or Freeform). `"section"` for a section composition. `"template"` also legal but not what Studio writes for the top-level template composition. The Compositions tab lists `"page"` entries under Templates | +| `schema_version` | string | Composition schema version stamped by Studio (`"1.0.0"`, `"1"`, `"2"`, …), a **string**, not a number | +| `ui` | string | The compressed composition tree, `zlib:` (see `troubleshoot-canvas` § Diagnostic tooling) | +| `data_sources` | JSON string | The stringified `resolvedReferences` map + pinned data-source list. Wire shape is `"[]"` when empty, not `[]` | +| `static_value` | group | Per-key literal store. Every `binding.type: "static_value"` on a node references a **key** here. The actual literal lives in `static_value.[]` under that key. See [`author-composition-static-values`](author-composition-static-values.md) | +| `url_metadata` | group | **Required on every template composition** (`place_composition_as: "page"`): `url_source` + `url_queries`. Never write `url` without it. See § `url` + `url_metadata`: a matched pair below. Sections carry neither `url` nor `url_metadata` | +| `linked_schemas` | **group-multiple** | Sections only. Declares parent CT + `selectedField` the section scopes to. `[]` for templates / freeform | +| `linked_sections` | reference (multiple) | Template's references to section-composition entries placed in its `ui` tree. `[]` for freeform / sections | +| `ui_preview` | file (asset uid) | **Required on every section composition** (`place_composition_as: "section"`). The editor writes this for itself by screenshotting the canvas on save. **The CMA never populates it**, so an API-created Section stays blank in the Sections palette forever and nothing errors. Supply an asset uid. See § Section thumbnails Fix C for the two calls. Templates don't need it | + +After write, publish the entry. Studio queries by `composable_uid` + published status. An unpublished entry won't appear in the project's Compositions list or load in the canvas. + +**A section write is not finished until `ui_preview` is set.** It fails the way publish state does (silently, with a correct-looking `200` and a page that renders), so it is invisible until an author opens the palette and finds unlabeled gray tiles. Set it in the same pass as the create, per § Section thumbnails: screenshot or generate the image, `POST /v3/assets`, then `PUT { entry: { ui_preview: } }`. Batching it to "later" is how a whole project ends up with none. Measured: 254 API-authored Sections in one project, zero thumbnails, while the UI-authored Sections beside them all had one. + +**Reading publish state back requires an explicit flag.** A plain `GET /v3/content_types//entries/` returns **no `publish_details`**. The field is simply absent, which reads identically to "not published" and makes the verification step above unfalsifiable. Ask for it: + +```bash +curl -s "https:///v3/content_types//entries/?include_publish_details=true" \ + -H "api_key: " -H "$CS_AUTH" | jq '.entry.publish_details' +``` + +A published entry returns an array with one object per environment/locale. `[]` or `null` means not published to anything. Same flag applies when listing entries. + + + +### `url` + `url_metadata`: a matched pair, never one without the other + +**Rule: any entry you write with `place_composition_as: "page"` gets BOTH `url` and `url_metadata`. No exceptions, no "minimal envelope" shortcut.** + +`url` is the pattern. `url_metadata.url_source` is how the pattern is derived. Write the pattern alone and Studio has no derivation to read, so the composition behaves as the `legacy_url` source (the pre-variable wildcard mode) regardless of what the pattern actually contains. + +**Why this matters.** Omitting `url_metadata` doesn't fail the CMA write and often doesn't break delivery either: a Connected template with `url: "{{entry.url}}"` can return 200 on the live route off the pattern alone. That green page is exactly what makes the omission expensive. It's discovered later, in the editor, not at write time: + +- **Studio's URL panel loses the derivation.** The editor can't show that the URL comes from the connected CT's `url_pattern`, so the Edit-URL modal renders it as an opaque hand-typed string. First author who touches it re-authors the pattern by hand and the CT link is gone for good. +- **Candidate-set risk.** The SDK's composition-candidate query filters on `url_metadata.$exists: true` (see [`troubleshoot-composition-resolution` § Symptom matrix](troubleshoot-composition-resolution.md)). A composition with no `url_metadata` at all is not guaranteed to enter the candidate set. One code path resolving it today is not the same as every path resolving it. +- **`legacy_url` semantics are stricter.** They match against a `url` field populated on every entry, with a leading slash. A pattern written for `content_type_url_pattern` semantics but resolved under `legacy_url` fails the moment the entry set grows past the one you tested. + +#### Which `url_source` to write + +| Composition | `url` | `url_source` | Notes | +|---|---|---|---| +| **Connected** template, CT is a page type (`is_page: true` + CT `url_pattern`) | `//{{entry.title}}` | `content_type_url_pattern` | **The default for the API path.** The CT pattern is the derivation, so Studio re-derives correctly on every editor load. Pair with CT `url_prefix: "//"`. See [`build-connected-template` § Single-entry vs multi-entry URL pattern rule](build-connected-template.md#single-entry-vs-multi-entry-url-pattern-rule) | +| **Connected** template, CT **has** a `url_pattern` (entries get a real `url`) | `//{{entry.title}}` | `content_type_url_pattern` | Resolution keys on **`entry.url`**: the SDK finds the entry whose stored `url` equals the request path, so write the template `url` in the shape of a real entry's `url` (read one first; a CT `url_prefix` the stored `url` lacks misses with `PREVIEW_ENTRY_NOT_FOUND`). The composition `url` is NOT variable-substituted under this source (see below). Requires every entry to carry a populated `url` | +| **Connected** template, **singleton** CT with no `url_pattern` (homepage, pricing, a one-entry rewards page) | `/rewards` (literal) | `content_type_url_pattern`, **but only if you populate `url` on the entry by hand** | The source's real requirement is a non-empty `url` field on at least one entry, not a CT `url_pattern`. A singleton has no pattern to generate one, so set `url: "/rewards"` on the entry yourself. Skip that and the editor fails with `MISSING_CT_PATTERN_URL_FIELD`, shown as "No entry has a URL field populated yet", while SSR renders the page fine | +| **Connected** template whose URL derives from a field **other than** `url` (`{{entry.slug}}`, `{{entry.author.slug}}`) | `/blog/{{entry.slug}}` | `user_specified_pattern` | This is the only source that extracts and matches on pattern variables. `content_type_url_pattern` would ignore the pattern and look for `url` instead | +| **Freeform** template, identity URL | `//` | `default_url_pattern` | The Studio-generated default. Use this unless the path was deliberately hand-chosen | +| **Freeform** template, hand-chosen path | `/campaigns/spring-2026` | `user_specified_pattern` | Semantically correct: there's no CT to derive from. Write `url_queries` explicitly. Nothing else generates it on the API path | +| **Connected** template, hand-typed **variable** pattern on a CT that does have a `url_pattern` | - | Avoid, prefer `content_type_url_pattern` | Here `user_specified_pattern` reverts: `url_queries` is generated by the UI's Edit-URL then Save flow and Studio re-derives from the CT pattern on load. Use `content_type_url_pattern`. See [`build-connected-template` § URL traps (c)](build-connected-template.md). **This is not a blanket ban on `user_specified_pattern` for Connected**. It's the correct source when the CT has no pattern to derive from (row above) | +| **Section** (`place_composition_as: "section"`) | (omit) | (omit) | Sections have no URL. Don't invent one | + +`custom_preview_url` (in Stack, under Visual Experience) and `legacy_url` (match a `url` field on every entry) are the two remaining values. Neither is something you write by hand on the API path: `custom_preview_url` is stack config, `legacy_url` is the state you fall into by accident. + +#### What each `url_source` actually does: and the editor error it produces + +Verified in the Studio app (`iframe-url/resolvePreviewEntry.ts`, `useResolvedPreviewEntry.ts`). The editor picks a **preview entry** by querying the connected CT, and the query shape depends entirely on `url_source`: + +| `url_source` | Editor's query | Variable substitution? | Error when nothing matches | +|---|---|---|---| +| `content_type_url_pattern` | Fixed: **`url` field existence** (`requiredVariables: ["url"]`), no field predicates | **No** | `MISSING_CT_PATTERN_URL_FIELD`, shown as "No entry has a URL field populated yet" | +| `legacy_url` | Same fixed `url`-existence query | **No** | `MISSING_LINKED_URL_FIELD` | +| `user_specified_pattern` | Extracts `{{entry.x}}` variables from the pattern and matches entries on those fields | **Yes** | `NO_ENTRY_WITH_ALL_VARIABLES` | + +Two consequences that bite in practice: + +1. **`content_type_url_pattern` does not substitute your pattern.** `{{entry.title}}` in the composition `url` is display/matching metadata. The editor resolves purely off the entry's **`url` field**. The requirement is therefore "at least one entry has a non-empty `url`", **not** "the CT has a `url_pattern`". Two ways to satisfy it: a CT `url_pattern` (the usual route for multi-entry CTs), or setting `url` by hand (the route for a singleton). Satisfy neither and the editor dead-ends on "No entry has a URL field populated yet" while SSR keeps rendering. The mismatch reads as a Studio bug and isn't one. +2. **`user_specified_pattern` is the substituting branch.** Pick it when the URL derives from fields other than `url` (`{{entry.slug}}`, a reference field) or for a freeform hand-chosen path. It's wrong for a multi-entry CT that already has a `url_pattern`. Studio re-derives and the hand-set metadata reverts. + + + +#### Editor vs SSR: an API-authored template can render live and still refuse to open + +`prepareResolution` bails with `no-fetch` / reason `no-composition` when the composition has **no `url_metadata` at all**. The editor then has nothing to derive a preview URL from, so the template won't open, while SSR resolves the same composition off the `url` pattern and the entry's `url` and serves the page perfectly. + +Same asymmetry family as `linked_sections` (P27) and `ui_preview`: the editor depends on metadata the UI writes for itself and the API path silently omits. **A passing SSR render is not evidence the template opens in Studio.** Verify both: load the route AND open the template in the editor. Cross-reference [`troubleshoot-composition-resolution` § Editor vs SSR: URL derivation](troubleshoot-composition-resolution.md#editor-vs-ssr-url-derivation). + +#### Two authoring hazards worth avoiding up front + +- **Bare `{{entry.url}}` with no literal prefix** matches every path. Fine with one template. The moment a second exists it wins or ties on specificity and swallows the other's URLs (ties break by `composable_uid`, deterministic but arbitrary). Give every template a literal segment. See [`setup-template-preview-routes` § Bare `{{entry.url}}` catch-all](setup-template-preview-routes.md#bare-entry-url-hazard). +- **Don't put a locale in the composition `url`.** Locale is a context variable (`{{locale}}`), not part of the stored path. Studio composes ` + `. On a locale-prefixed app (`next-intl`, `localePrefix: "always"`) the unprefixed path 307-redirects and the preview iframe dies on the redirect. Carry locale via the env base URL or the SDK `locale` option instead. + +#### The wire shape + +`url_metadata` is a **group with two text sub-fields**. `url_queries` is a **JSON string, not an object**. Send an object and the CMA rejects with a 422 type mismatch: + +```jsonc +// Connected template — CT is a page type +"url": "/blog/{{entry.title}}", +"url_metadata": { + "url_source": "content_type_url_pattern", + "url_queries": "{\"include\":[],\"only\":{},\"where\":{}}" +} +``` + +```jsonc +// Freeform template — identity URL +"url": "/compositions/spring_2026_landing", +"url_metadata": { + "url_source": "default_url_pattern", + "url_queries": "{\"include\":[],\"only\":{},\"where\":{}}" +} +``` + +Leave `include` as `[]`. Reference expansion for URL variables like `{{entry.author.slug}}` is driven by `data_sources[template].resolvedReferences`, not by `url_queries.include`. A bare `""` for `url_queries` is also accepted alongside `content_type_url_pattern` (that's the shape in the verified multi-entry recipe). Prefer the explicit `{include, only, where}` JSON string, which is what the UI writes. + +**Precondition: the CT must model the field.** If the project's compositions CT has no `url_metadata` group with `url_source` + `url_queries` sub-fields, the CMA **silently drops** the object on write: the POST returns 201, the entry reads back with no `url_metadata`, and you're in the `legacy_url` case with no error anywhere. Verify the CT first. See [`provision-studio-project`](provision-studio-project.md). Read the field back after your first write. + +### `static_value` bindings: write two places + +A `static_value` binding stores a key. The literal lives in the entry's `static_value.[]` under that key, so write both, in the subtype matching the prop's `type`. Full rules: [`author-composition-static-values`](author-composition-static-values.md). diff --git a/skills/studio/author-composition-exposed-props.md b/skills/studio/author-composition-exposed-props.md new file mode 100644 index 0000000..2f2c651 --- /dev/null +++ b/skills/studio/author-composition-exposed-props.md @@ -0,0 +1,87 @@ +--- +name: author-composition-exposed-props +--- + +## When to use + +Worked recipe for exposed section props: per-instance overrides a template author can set without forking the Section. + +Part of the API-authoring set. Start at [`author-composition-via-api`](author-composition-via-api.md), which routes to this one. + +## Recipe: Exposed section props (per-instance override) + +Some sections need to expose a prop the **template** can override per drop instance, a Card Grid whose column count is set by the page, or a Hero whose tone changes per template. Verified end-to-end (Case 14 in `docs/api/sections.md`). + +**Two-sided shape**: section declares the exposure. Template supplies the per-instance value. + +### Section side: at the ui ROOT + +The exposed-props declaration lives on the section's `ui.metadata` (NOT on the individual node). Each entry names one prop on one node, gives it a stable exposure `uid`, and captures the binding at expose time as a fallback: + +```jsonc +{ + "type": "page", + "uid": "", + "metadata": { + "sectionExposedProps": [ + { + "nodeUid": "CARD1", // the node inside the section that owns the prop + "propKey": "tone", // the prop being exposed + "uid": "exp-tone-1", // stable identifier — templates reference this + "displayName": "Card tone", // label shown on the template's right panel + "propType": "choice", // the widget kind — matches props..type + "bindingAtExposeTime": { // fallback binding if the template doesn't override + "type": "static_value", + "value": "CARD1-tone" + } + } + ] + }, + "slots": { /* … normal section ui … */ } +} +``` + +`sectionExposedProps` is on the section's `ui` **ROOT node's `metadata`**, not on the composition entry, and not on the individual node the prop lives on. Getting the placement wrong (on the node, or on the entry) means the template's right panel doesn't render the field. + +### Template side: per instance + +The template's `section-composition` node carries a `props` map keyed by the exposure `uid`: + +```jsonc +{ + "type": "section-composition", + "uid": "INST-1", + "metadata": { + "compositionUID": "", + "sectionBindingOverride": { "selectedField": "…" } // (P23 — separate concern) + }, + "props": { + "exp-tone-1": { // keyed by section's exposure uid + "type": "choice", // must match section's propType + "binding": { + "type": "static_value", + "value": "INST-1-tone-override" // static_value key on the TEMPLATE entry + } + } + } +} +``` + +Two things to internalize: + +1. **Per-instance uid**: the same section dropped twice on one template = two `section-composition` nodes with **different `uid`s**. Each carries its own `props.` map, so each drop can have a different override value. +2. **Static-value keys live on the template entry**, not the section entry. When the override is a static_value, the `binding.value` key is looked up in the **template**'s `static_value.[]` bucket (per the two-level lookup rule earlier). + +### Fallback resolution + +For each exposed prop the SDK resolves in this order at render time: + +1. Template's `section-composition.props..binding`: if present, use it. +2. Section's `ui.metadata.sectionExposedProps[…].bindingAtExposeTime`: fallback. +3. Node's own `props..binding` in the section's `ui`: final fallback. + +So a section can drop with no override (fallback to expose-time binding) OR with a per-instance override (template controls it). Leaving all three unset means the prop renders as the component's `defaultValue`. + +### Verifying an exposed prop + +Same as sections generally: SSR cold-load and dump `spec.data.section_scoped_data[]`. The resolved override appears as the concrete `props.` value on the section instance. If it's the fallback, either the template didn't provide an override or the `exposure-uid` doesn't match. diff --git a/skills/studio/author-composition-migration.md b/skills/studio/author-composition-migration.md new file mode 100644 index 0000000..e3f64c5 --- /dev/null +++ b/skills/studio/author-composition-migration.md @@ -0,0 +1,41 @@ +--- +name: author-composition-migration +--- + +## When to use + +Move compositions between stacks: uid, index and schema remaps, and what breaks if you skip them. + +Part of the API-authoring set. Start at [`author-composition-via-api`](author-composition-via-api.md), which routes to this one. + +## Migrating compositions between stacks: uid, index, and schema remaps + +Copying compositions from a source stack (a dev/staging stack, another project) to a destination is NOT a byte copy. The entry JSON carries three classes of stack-bound values that MUST be rewritten, or the composition writes cleanly, resolves in the SSR fetch, and still renders blank or errors in the canvas. All three are verified live (from a non-prod data center to NA prod). + +### 1. Template references to sections must be REMAPPED to each destination entry's NEW uid + +A template places each section by the section's **entry uid** (`metadata.compositionUID`), and both the canvas and the runtime fetch sections by entry uid. On the destination, `POST` gives each section a **new** entry uid, so every template reference to the old one is dead: **`Component with type '' is not registered`**, once per section. + +`composable_uid` is different: it only has to be unique across the compositions CT, and Studio's UI fills it with a readable name. Carrying it over is fine, as long as no destination composition already uses it. + +The fix (do both, in order): +1. Create each section on the destination. Capture its new entry uid. +2. In the template: rewrite every `ui` `section-composition` node's `metadata.compositionUID` from the source uid to the destination uid, and set `linked_sections` to the destination uids (`{uid, _content_type_uid}`, the compositions CT). Publish. + +`linked_sections` is a **reference field storing entry uids**: those are always stack-specific and always need remapping (see § Authoring a Template: populate `linked_sections`). `metadata.compositionUID` inside the `ui` is what the client resolves by, so it must match the new uid too. + +### 2. Block-index paths must be remapped when the destination CT's field ORDER differs + +Bindings into an ordered multi-value field bake the index into the path: `{ modular_blocks: { "2": { image_grid: {} } } }`. If the destination entry orders those blocks differently (an extra block inserted, a different author order), every baked index points at the wrong block, which produces wrong data or a blank. Before writing, diff the source vs destination entry's block order and rewrite each index in the `ui` binding paths (the path flattener keeps numeric keys, so a global `old→new` index map over `modular_blocks.` is enough). + +### 3. Binding paths + `selected_field` must match the DESTINATION CT's schema, not the source's + +The same logical field can sit at a different path across stacks (schema drift): a hero image at `image` in one stack, `image_options.image` in another. A migrated binding of `hero.0.image.url` silently resolves to nothing on a stack whose `hero_banner` nests the file under `image_options`. Symptom: text/scalar props render (their paths matched) but one field is blank. Verify each bound path against the destination CT schema (`GET /v3/content_types/`), and fix the drifted ones. Same for `linked_schemas.selected_field`. + +### 4. Assets are stack-scoped + +File/image fields reference asset uids that don't exist on the destination. Either re-upload the assets to the destination (raw multipart `POST /v3/assets`, then publish) and re-point the fields, or bind to existing destination assets. A migrated composition whose entry data still points at source-stack asset uids renders with broken/blank media. + +### Pre-flight for a cross-stack migration + +Verify BOTH paths, exactly as § Authoring a Template prescribes: SSR resolves (fetch returns the sections) **and** the canvas opens without `not registered` errors. The uid remap (#1) is the one that passes SSR and fails the canvas, so an SSR-only check is not enough. diff --git a/skills/studio/author-composition-nested-repeaters.md b/skills/studio/author-composition-nested-repeaters.md new file mode 100644 index 0000000..27d1b1b --- /dev/null +++ b/skills/studio/author-composition-nested-repeaters.md @@ -0,0 +1,100 @@ +--- +name: author-composition-nested-repeaters +--- + +## When to use + +Worked recipe for nested Repeaters: a list inside each iterated item, and the `repeaterUID` chain that makes the inner scope resolve. + +Part of the API-authoring set. Start at [`author-composition-via-api`](author-composition-via-api.md), which routes to this one. + +## Recipe: Nested Repeaters (a list inside each iterated item) + +A Repeater inside another Repeater's iteration, e.g. a footer with multiple **columns**, each column having its own **links** list. The outer Repeater iterates columns. The inner Repeater iterates each column's links. The trick is the inner Repeater's `items` binding: it must reach into the **outer iteration's current item** to find that column's `links` array, which means `type: "repeater"` (with the outer's `repeaterUID`), not `type: "template"`. + +### The two binding shifts + +| Node | `items` binding `type` | `value` | What it resolves to at runtime | +|---|---|---|---| +| **Outer Repeater** (`R-outer`) | `template` | `{ path: { footer_columns: {} } }` | `dataSources.template.footer_columns` (the array on the parent entry) | +| **Inner Repeater** (`R-inner`) | **`repeater`** | `{ repeaterUID: "R-outer", path: { links: {} } }` | `dataSources.repeater.R-outer.context.links` (each outer iteration's `links` field) | +| **Inner card props** | `repeater` | `{ repeaterUID: "R-inner", path: { label: {} } }` | `dataSources.repeater.R-inner.context.label` (each link's `label`) | + +Repeater-scoped bindings resolve as `dataSources.repeater..context.`. So the inner Repeater's items binding correctly reads the outer iteration's current `links`, and the inner card's bindings read each link inside that. + +### Assembled + +```jsonc +{ "type": "repeater", "uid": "R-outer", + "metadata": { "mode": "preview" }, + "props": { + "items": { "binding": { + "type": "template", + "value": { "path": { "footer_columns": {} } } + } }, + "children": { "type": "slot", "slot": "" } // ← wires the slot below + }, + "slots": { "": [ + /* outer iteration body — one column */ + { "type": "site-column-header", "uid": "CH1", + "props": { "title": { "binding": { + "type": "repeater", + "value": { "repeaterUID": "R-outer", "path": { "heading": {} } } + } } } }, + { "type": "repeater", "uid": "R-inner", + "metadata": { "mode": "preview" }, + "props": { + "items": { "binding": { + "type": "repeater", // ← NOT template + "value": { "repeaterUID": "R-outer", // ← outer's UID + "path": { "links": {} } } // ← path on outer's iteration item + } }, + "children": { "type": "slot", "slot": "" } + }, + "slots": { "": [ + { "type": "site-footer-link", "uid": "L1", + "props": { + "label": { "binding": { "type": "repeater", + "value": { "repeaterUID": "R-inner", "path": { "label": {} } } } }, + "href": { "binding": { "type": "repeater", + "value": { "repeaterUID": "R-inner", "path": { "href": {} } } } } + } } + ] } } + ] } } +``` + +Both Repeaters carry `props.children` pointing at their own slot. Without it the Repeater iterates but renders nothing ([`author-composition-repeater-recipes`](author-composition-repeater-recipes.md) step 2). + +### Source-type combos + +| Source-type combo | Extra prerequisites | +|---|---| +| group-multiple (e.g. `footer_columns`) containing group-multiple (`column.links`) | (nothing extra), inline values both ways | +| group-multiple containing multi-reference (e.g. column.related_entries) | Add `data_sources.resolvedReferences` for the nested reference path (e.g. `"root.footer_columns.0.related_entries": ["footer_columns.related_entries"]`). Add Condition Block as immediate child of `R-inner`. | +| multi-reference containing group-multiple (e.g. category.products[].images) | `resolvedReferences` for the outer reference (`root.products`). Condition Block as child of `R-outer`. Inner iteration is over the (inline) `images` group-multiple, no extra prerequisites for `R-inner`. | +| multi-reference containing multi-reference | `resolvedReferences` entries for BOTH the outer reference AND each inner reference path, inner path is `.`, with **no** content-type uid between them (§ [Where a reference path must terminate](author-composition-repeater-recipes.md#reference-path-termination)). Condition Block in both Repeaters. | +| **Modular Block with a reference inside a block** (e.g. `blocks[].feature_block.related_products`) | `resolvedReferences` path must include the **block uid** segment, `"blocks.feature_block.related_products"`, NOT `"blocks.related_products"`. Condition Block per block uid on the Repeater. See § Modular Block to reference: the block-uid segment below. | + +The decision tree from the single-level recipes (group-multiple vs reference) applies per Repeater independently: outer's source determines what `R-outer` needs. Inner's source determines what `R-inner` needs. + +### Modular Block to reference: the block-uid segment + +**The trap:** a Modular Block field whose blocks each contain a reference (`MB → Ref` in every block). A Section linked to the MB renders its blocks, bindings look correct in the picker, and the referenced content inside each block renders nothing. + +A modular-block item is shaped `{ : { …fields } }`. That block-uid level does **not** exist for a group-multiple, so the group shape (`footer_columns.related_entries`, one row up) generalizes wrong here: write `blocks.feature_block.related_products`, never `blocks.related_products`. + +Omitting the block uid fails silently, for the reason given in § [Where a reference path must terminate](author-composition-repeater-recipes.md#reference-path-termination). Read that first if this is new to you. It is also where the numeric-prefix and CT-uid rules live. + +**Where it goes:** on the **parent template** composition, keyed `"template"`, never on the Section composition, even though the reference is nested inside the Section's linked field. Same placement rule as every other reference iteration (§ Authoring a Section composition: scoping rules). + +```jsonc +// Template composition — MB field `blocks`, block uid `feature_block`, +// reference field `related_products` inside that block. +"data_sources": "[{\"uid\":\"template\",\"data\":null,\"resolvedReferences\":{\"template\":[\"blocks.feature_block.related_products\"]}}]" +``` + +One entry **per block uid** that contains a reference. Blocks are independent shapes, so a second block with its own reference needs its own path. + +### The key pitfall + +Setting the inner Repeater's `items` binding to `type: "template"` with path `{ footer_columns: { 0: { links: {} } } }` is the **nested-`.0.` trap**, same family as the "all cards identical" trap from #14/P19, but one level up. A fixed index doesn't substitute. Inner repeater would always read column-0's links, so every outer iteration's links list is identical. Use `type: "repeater"` with the outer UID instead. diff --git a/skills/studio/author-composition-pitfalls.md b/skills/studio/author-composition-pitfalls.md new file mode 100644 index 0000000..6b22b41 --- /dev/null +++ b/skills/studio/author-composition-pitfalls.md @@ -0,0 +1,115 @@ +--- +name: author-composition-pitfalls +--- + +## When to use + +Verify a composition actually resolves (a `200` does not mean it does), and the full pitfall table for API-authored compositions. + +Part of the API-authoring set. Start at [`author-composition-via-api`](author-composition-via-api.md), which routes to this one. + +## Verify the write: a `200` does not mean the composition resolves + +Nothing validates that a `static_value` key on a node exists in the entry's bucket. Write half the pair and the CMA still returns `200`. The canvas then renders every affected prop as its registration `defaultValue`, which looks like a working section made of placeholder copy. + +**Worked failure.** A hero was authored with all seven props stripped to `static_value`. The write returned `200`. The canvas showed "Your headline here" and two buttons both reading "Get started", indistinguishable from a deliberate skeleton. The entry actually held: + +```jsonc +"static_value": { + "boolean": [ { "value": false, "_metadata": { … } } ], // ⛔ no "key" + "href": [ { "value": "#", "_metadata": { … } } ], // ⛔ no "key" + "choice": [], "imageurl": [], "number": [], "object": [] +} // ⛔ no "text" bucket at all +``` + +Every item was missing its `key`, and the bucket holding the headline and button labels was never written. **Zero of the bindings could resolve, and nothing anywhere reported it.** + +Run this check after every write. It is three lines and catches the entire class: + +1. Re-fetch the entry and decompress `ui`. +2. Walk the tree collecting every `(props..type, binding.value)` pair where `binding.type === "static_value"`. +3. Assert each one has a matching `{key}` in `static_value[]`. Any miss is a prop that will silently render its default. + +Do the same before writing, against the payload you are about to send. On the corrected hero the counts read `refs=26 statics=26 missing=[]`. The original would have reported 7 missing keys and 1 absent bucket. + +The same discipline applies to `template` bindings: [`decompose-design`](decompose-design.md) § Step 8b is this check at plan time, and `build-section` § Field-existence gate is it at bind time. A prop that silently falls back to `defaultValue` is the single failure mode all three exist to catch. + +### Preflight: assert the envelope on every node before you write + +The rule above is stated. Nothing enforces it. A sweep of one live stack found **54 nodes across 10 compositions** missing `slots` (the whole pricing family plus two carousels), every one authored by tooling that skipped it on leaves. They rendered fine for months and only surfaced as a white screen when someone opened the Data tab. + +So check the payload you are about to send, and the entry after you write it: + +``` +for every node in the ui tree: + assert all of ("uid","type","props","metadata","slots","attrs","styles") are present and non-null +``` + +Three lines, catches the entire class. Run it in the same pass as the `static_value` key check, same idea, different envelope. On a correct composition it reports zero. On the pricing sections above it would have reported 4, 8, 11. + +**Repairing existing data** is additive: insert `"slots": {}` where the key is absent, leave everything else untouched. Note it creates an unpublished version on a published composition, so republish afterwards or the live entry keeps the old shape. + +## Common pitfalls + +| Pitfall | Symptom | Fix | +|---|---|---| +| Rebuilding a template's root slot from a fresh node list | Sibling nodes that sat beside the main one (a closing CTA band, a comparison table, a content list) are dropped. The page still renders, so nothing flags it | Rebuild from the newest version whose root slot holds no `section-composition` node, and carry every sibling across as its own Section | +| Deriving a composition title by stripping a suffix from a scope name | Two scopes collapse to one title. Titles are unique per project, so the second create returns `409` and that Section is missing from the template | Keep the title 1:1 with the scope it is rooted at | +| Creating a Section and placing it in the same pass without publishing | The Section resolves to nothing at delivery and the page renders `Component with type '' is not registered` | Publish every Section before the Template that references it. See [`complete-the-build`](complete-the-build.md) | +| Missing `data_sources.resolvedReferences` | Repeater renders 0 items, reference stubs never materialise | Add the resolution map entry: `{ uid:"template", resolvedReferences: { "root.": [""] } }` | +| Authoring from the registration without reading the component source | The registration lists prop names and options but not how they are consumed. A prop that overrides another, or is compared by equality, discards correct values with no error | Open the component file for every component you author. See § Read the component source before authoring | +| A list-wrapped `choice` hitting a strict equality check in component code | `['auto'] !== 'auto'` is true, so an override branch fires and the props it guards are dropped, `justify` silently stops working | Leave equality-compared props unwritten so the component's string default applies. Report the array-handling bug to the library owner | +| Composing atomically without reading how the composite derives its values | A composite computes child values from its own scheme: `isDark ? 'medium' : 'light'`. Hand-authoring the same tree with the atom defaults gets colors wrong in a way that looks plausible | Treat the composite's derivation logic as the specification. See § Read the component source before authoring point 3 | +| Authoring the prop the registration advertises rather than the one the component reads | A media atom exposing both `url` and `media` uses only `media?.url`. A `url`-only node renders nothing at all and reads as a missing design element | Read the component's first lines: early returns name the prop it actually requires | +| Writing an `object` or `array` value as a real structure | Those buckets store a **JSON string**. A real object fails the whole entry with `422 static_value.object.N.value is not text` | `json.dumps` the value before writing | +| Relying on `height: 100%` inside a composition | The SDK wraps every node in its own unstyled `
`. That wrapper collapses to content height, so `100%` resolves against the wrapper, not the card, measured 324px inside a 472px card | Use an **explicit px height** on the container that must distribute. It does not depend on the wrapper | +| Assuming a card primitive's own `justify-between` will distribute your nodes | The padded inner element of a card component is often `justify: normal`, and it is inside the component where no prop reaches it. Children pack at the top however they are nested | Put one stack inside with an explicit height (card height minus the component's own padding) and set `justify` on that | +| Setting `justify: between` on a container with no height | Distribution needs free space. A card whose height was removed to avoid clipping packs its children at the top, so CTAs sit at ragged heights instead of pinned to the bottom | Give the container the comp's height. Note that `height: 100%` may not chain through the SDK's per-node wrapper divs | +| Setting `justify` on a row that has no width | `justify` distributes free space. A shrink-wrapped row has none, so the value applies and nothing moves. A parent stack with `align: start` shrink-wraps its children | Give the row a width and the parent `align: stretch`. Measure the rendered box, not the stored prop | +| A numeric value written into the `number` bucket as a string | The bucket is strictly typed: the CMA rejects the whole entry with `422 static_value.number.N.value is not number`. Easy to hit when defaults are parsed out of source, where `1` arrives as `"1"` | Coerce to a real int/float before writing. Only `choice` wraps in a list. `number` stays a bare number | +| Using a decorative shape primitive as an in-flow rule or divider | Its wrapper is `position:absolute; inset:0`, so it takes no space and pins to the section, not the card. It measures correct and renders in the wrong place: four stacked indicators read as one stray white line over the heading | Read the component first. Verify the rendered box sits inside its intended parent, not just that its size and color match | +| Reporting a value as impossible without checking for a free-form primitive | A `Deviations` row that says impossible stops anyone looking again, so an expressible value stays unbuilt indefinitely | Search the registry for shape/box primitives with raw CSS `color` / `width` / `height` first | +| `choice` value stored as a bare string instead of a list | **Renders correctly**: the canvas is right and every visual check passes, but the Settings panel matches it against the registered `options`, finds nothing, and shows `Select…`. The author re-picks a value that was already set | `"value": ["h1"]`. Only `choice` wraps. Boolean/text/number stay scalar. See § Node anatomy point 0a | +| Writing only the props the design dictates | Every other registered prop shows **blank** in Studio's Settings panel, so an author has to pick each dropdown by hand: the API author's omission becomes someone else's manual work | Write every registered prop: comp value, else the registration's own `defaultValue`. See § Node anatomy point 0b | +| Nodes with no `metadata.title` | The Layers tree shows repeated type names (`Stack / Box` ×6) and cannot be navigated | Give each node a purpose-name: `Stack — Card content`, `CTA — Read the full story` | +| Leaf node authored without `metadata` / `slots` | Whole canvas shows `Component Loading Error — Cannot read properties of undefined (reading 'visible')`. Nothing paints and the message names no node | Every node carries the full envelope, `"metadata": {}` and `"slots": {}` included. See § Node anatomy point 0 | +| `static_value` items written without their `key` (or the bucket omitted entirely) | CMA returns `200`. Every affected prop renders its registration `defaultValue`, so the section looks like an intentional placeholder skeleton | Write `{key, value}` pairs into the bucket matching each prop's declared `type`, then read back and resolve every key. See § Verify the write | +| Design props left unwritten on an API-authored section | Section renders with the library's defaults, not the comp: a stack defaults to `column`, so side-by-side CTAs stack. No error | Author design props explicitly, skeleton or not. See § Skeleton first, bind later | +| Card prop bindings use `template` type at `.0.` (fixed `.0.` index) | EVERY iteration renders item 0, all cards identical (the worst kind: looks "correct" with a single-item list, fails as soon as the list grows) | Use `repeater` type with `repeaterUID`, path relative to the iteration item, no index | +| Repeater node missing `metadata.mode: "preview"` | Canvas shows 1 placeholder iteration. Production still renders N | Set `metadata.mode: "preview"` on the Repeater node | +| Missing Condition Block child of Repeater (reference / modular-block iteration) | Iteration silently fails to resolve child bindings | Insert a Condition Block per allowed type (CT for refs, block UID for modular blocks) | +| Writing `url` on a template composition but omitting `url_metadata` ("minimal envelope") | **Nothing fails.** The CMA returns 201, the live route returns 200, delivery resolves off the `url` pattern alone, so the omission survives review. But Studio has no derivation to read, treats the composition as `legacy_url`, and the editor's Edit-URL panel shows the pattern as an opaque hand-typed string instead of "derived from the connected CT". The CT link is lost the first time an author edits the URL, and `legacy_url` matching (entry `url` field, leading slash, every entry) is stricter than what the pattern was written for | Always write the pair. For Connected on a page-type CT, use `url_source: "content_type_url_pattern"`. For a Freeform identity URL, use `default_url_pattern`. For a Freeform hand-chosen path, use `user_specified_pattern`. `url_queries` is a JSON string. See § [`url` + `url_metadata`: a matched pair](author-composition-entry.md#url-metadata-matched-pair) | +| Compositions CT has no `url_metadata` group (or is missing the `url_source` / `url_queries` sub-fields) | CMA **silently drops** the object: 201 returned, entry reads back with no `url_metadata`, same `legacy_url` outcome as omitting it, with no error to trace | Verify the CT models `url_metadata` as a group with both text sub-fields (see `provision-studio-project`). Read the entry back after the first write and assert `url_metadata.url_source` is what you sent | +| Setting `url_source: "user_specified_pattern"` on a **Connected** template via the API | Reverts: `url_queries` is only generated by the UI's Edit-URL then Save flow, and Studio re-derives from the CT pattern on editor load | Make the CT a page type (`is_page: true` + CT `url_pattern`), then use `content_type_url_pattern` with `{{entry.x}}` in the composition `url`. `user_specified_pattern` on the API path is for Freeform only | +| API-authored Section with no `ui_preview` | Blank/placeholder tile in Studio's Sections accordion for every section, while UI-authored sections show a picture. Silent: the editor screenshots + uploads + PUTs `ui_preview` on save, and that chain only exists in the UI | Re-save each section in Studio (backfills automatically), or upload an asset + `PUT {"entry":{"ui_preview":""}}` + publish **both** asset and entry. See § [Section thumbnails](author-composition-thumbnails.md#section-thumbnails-ui-preview) | +| `ui_preview` set to an unpublished asset uid | Same blank tile: the accordion resolves `ui_preview.url` against the delivery environment and gets nothing. Looks identical to never setting the field | Publish the asset to the environment the Studio project targets, not just the entry | +| Template places a section by its `composable_uid` | `Component with type '' is not registered`: sections are looked up by entry uid | Set `metadata.compositionUID` to the section's entry uid (`blt…`) | +| Authored composition is unpublished | Not visible in compositions list. Can't load in canvas | Publish the entry. Studio queries by `composable_uid` AND published state | +| `linked_schemas` modeled as a reference field on the compositions CT instead of group-multiple | Section's `selectedField` derived from `linked_schemas` is always empty, so section scope is unset and repeaters render blank in the editor (P23). Runtime may still work via `sectionBindingOverride`. | Ensure compositions CT models `linked_schemas` as a group-multiple, see `provision-studio-project` | +| Treating this skill as a UI-authoring guide | Wastes time. Introduces shape inconsistencies | Use only for headless / scripted / seed / migration work. For interactive authoring, the Data Picker writes every shape correctly. | +| Cross-stack migration: keeping the source entry uids in the template | Every section is "not registered": the template still points at source-stack entry uids | Rewrite the template's `ui` `compositionUID`s + `linked_sections` to the destination entry uids. See `author-composition-migration` | +| Cross-stack migration: copying binding index paths / `selected_field` verbatim | Destination CT may order blocks differently or nest a field elsewhere (`image` vs `image_options.image`), which produces the wrong block or a blank field, with no error | Diff source vs destination CT schema + entry block order. Remap indices and field paths before writing | +| Binding into a **single reference** field's NESTED asset (e.g. `hero.0.image_options.image.url`) without listing the reference in `data_sources.resolvedReferences` | Scalars on the referenced entry may resolve (heading/body render) but the **nested file/asset** does not, so the binding returns `undefined` and the image is blank, with no error | Add the reference path to `resolvedReferences.template` (e.g. `"hero"`, and the nested `"hero.image_options.image"`) so the SDK deep-resolves it. Then bind `…image_options.image.url`. The file field resolves to an object with `.url`, matching the production component. One instance of the general rule: § [Where a reference path must terminate](author-composition-repeater-recipes.md#reference-path-termination) | +| Linking a **DAM / Assets-Manager** asset (`am…` uid, `parent_uid`) to a `file` field via **OAuth Bearer** CMA write | Rejected with `422 "is not a valid upload."`: the Bearer app-token grant can't link DAM assets, even though the asset exists + is published | Use a **session `authtoken`** for the write (it succeeds), or link via the DAM/asset picker in the UI. Note: DAM assets also emit a `/spaces/…` **preview** delivery URL that 404s in Live Preview while the published CDA emits `images.contentstack.io/…`, see `troubleshoot-canvas` | +| Placing bindings directly on the node instead of `props..binding` | The SDK can't find the binding, so the prop renders with `defaultValue` or empty | Always wrap: `"props": { "": { "binding": { type:…, value:{…} } } }`. | +| Using `children: [...]` instead of `slots: { : [...] }` for child nodes | Children invisible to the renderer. Canvas renders the parent as empty | Use `slots`. Each slot has a uid. See node anatomy above. | +| Placing Condition Block's discriminator on the node as `condition: {...}` instead of `metadata: { condition: {...} }` | Discriminator ignored. Iteration item's type can't be narrowed. Bindings inside fail to resolve | Put `condition` inside `metadata`. Same pattern as Repeater's `metadata.mode`. | +| `metadata.condition` without `conditionBinding` | Block renders nothing, no error. Design mode still shows it | Add it (`author-composition-repeater-recipes` step 4). Verify in preview. | +| Adding `resolvedReferences` for a group-multiple iteration | No harm (the SDK ignores it for non-references), but creates noise + makes the composition look more complex than it is | Skip `resolvedReferences` for group-multiple, multi-file, and scalar-list iteration. Only references need it. | +| Adding a Condition Block for group-multiple iteration | The block has no discriminator to apply. Runtime behavior may be inconsistent | Skip the Condition Block for group-multiple. Place the iteration child directly in the Repeater's slot. | +| Using `uid` instead of `queryUID` in a `contentstack_queries` binding value | Pinned-query result never resolves | Use `queryUID: ""`. | +| Inner Repeater's `items` binding uses `type: "template"` with a nested-`.0.` path (e.g. `{ footer_columns: { 0: { links: {} } } }`) | Every outer iteration's inner-repeater reads column-0's links, all inner lists identical. The nested-`.0.` family of the P19 trap, one level up. | Inner Repeater's `items` MUST use `type: "repeater"` with `repeaterUID: ""` and path `{ : {} }`. Then inner card bindings use the inner Repeater's UID. | +| Compositions CT models `linked_schemas` as a reference field instead of group-multiple (P23) | CMA silently drops the `{content_type_uid, selected_field}` object on every `PUT` (`linked_schemas` reads back as `[]`), editor canvas shows blank repeaters on placed sections even though runtime works correctly | Model `linked_schemas` as a group-multiple on the compositions CT (see `provision-studio-project`). As a temporary runtime-only fix, set `sectionBindingOverride.selectedField` on each `section-composition` node, but the editor canvas won't render correctly until `linked_schemas` is also fixed. | +| A section iterates `selectedField` and its top-level Repeater binds `{ path: { : {} } }` instead of scope-root `{ path: {} }` (P24) | Repeater renders nothing: `dataSources.template` IS the array (re-scoped via selectedField). `template.` is undefined | Use scope-root `{ path: {} }` for the top-level Repeater inside a `selectedField`-scoped section. Leaf bindings inside still use `repeater` type with `repeaterUID`. | +| Setting `selectedField` on a section that reads multiple page fields (Header reading brand + nav_links + signin_label. Hero reading the `hero` group) | Section loses access to the fields outside the scoped one, they resolve to undefined | Leave `selectedField` unset for multi-field sections. `template` becomes the whole page entry. Original `template.` paths work verbatim. | +| Authoring a section by API and expecting its own canvas to confirm scope | Section canvas has no data. One empty placeholder is normal | Verify via SSR cold-load: `await sdk.fetchCompositionData({ url })` then dump `spec.data.section_scoped_data[]`. That's the runtime truth. | +| API-placing a section in a template's `ui` without populating the template's `linked_sections` field (P27) | **"Template Did Not Load"** in the Studio editor, BUT the deployed site renders the template correctly via SSR: the editor needs the `?include[]=linked_sections` resolution to build `spec.sectionCompositions`. SSR walks the section-composition nodes directly so it works either way | When writing `section-composition` nodes into a template's `ui`, also populate the template entry's `linked_sections` field with `[{ uid: "", _content_type_uid: "" }, …]`, one entry per distinct placed section (dedupe: a section placed multiple times needs only one reference). Publish the template entry. | +| Passing the compressed `ui` inline via a shell variable in `curl -d "..."` | Blank canvas, no error, spec present, `hasSpec: true`, but every slot resolves empty because shell interpolation mutated `"children"` into `"c1ildren"` throughout the tree | Write payload to a temp file, POST with `-d @file`. Assert `raw.count('"children"') >= ` before compressing. See § Never pass the compressed `ui` inline above. | +| Using Python `urllib`/`requests` for CMA calls against a non-prod data center | `SSLCertVerificationError: unable to get local issuer certificate`, the non-prod chain isn't in Python's default CA store. `curl` reads the system keychain and works | Use `curl` for the HTTP call. Keep Python for payload build + assertions only. If Python HTTP is unavoidable (dev tooling), pass a permissive `ssl.create_default_context()` with `check_hostname=False` + `verify_mode=CERT_NONE`. Never do this in app code | +| `section-slot` node written without `slots: { "": [] }` (empty-array entry keyed by the node's own uid) | The template has no slot uid to fill under: canvas renders the wrapper with no slot placeholder. SSR resolves the slot as absent | Every `section-slot` node needs `slots: { "": [] }`, the one legitimate empty-array exception to the preflight rule. See § `section-slot` node shape | +| Pattern B fill section's leaf bindings use `type: "repeater"` with the wrapper's `repeaterUID` | Bindings resolve to `undefined`: the wrapper's SDK scoping already unwraps the iteration item into the fill section's `template`. The wrapper's repeater scope isn't visible inside the fill section | Fill section's leaf bindings use `type: "template"` with paths relative to the reference target CT / block schema. Only the WRAPPER section uses repeater-scope bindings inside its own tree | +| Pattern B fill written as `metadata.sectionSlotCompositions` on the placed wrapper | The SDK never reads that key, so each iteration's slot renders blank | Put the fill in the wrapper's `slots`: `slots.` holds a `section-composition` node with `metadata.compositionUID` = the fill section's entry uid. Add the fill section entry to the template's `linked_sections` | +| Exposed-prop declaration placed on the individual node's `metadata.sectionExposedProps` or on the composition entry, instead of `ui.metadata.sectionExposedProps` at the ui ROOT | Template's right panel doesn't render the field. Overrides never get read | Place `sectionExposedProps` on the section's `ui` ROOT node's `metadata` (the top-level `page` node). It's an array of `{nodeUid, propKey, uid, displayName, propType, bindingAtExposeTime}`, one entry per exposure. See § Recipe: Exposed section props | +| Template's `section-composition.props.` key uses the section's node uid or prop key instead of the exposure `uid` | Override never matches: SDK looks up by exposure `uid`. When unmatched it falls back to `bindingAtExposeTime`, which looks like the section is ignoring the template | Key the template's `section-composition.props` map by the `uid` field from the section's `sectionExposedProps` entry (e.g. `"exp-tone-1"`), NOT by nodeUid or propKey | +| Same section dropped twice on a template shares one override value | Both instances render with the same override, user expected per-instance control | Each drop is a separate `section-composition` node with its own `uid`. Each carries its own `props.` map. Static-value keys should also differ per instance (e.g. `INST-1-tone`, `INST-2-tone`) so the template's `static_value.` bucket stores each independently | +| API-authoring a `json_rte` embedded-entry `reference` node with `attrs` missing `locale` | CMA rejects with `"Reference must contain content-type-uid, entry-uid, locale and display-type."` at write time. SDK read path doesn't need `locale` (derived from context) so this only surfaces on POST/PUT | Every embed reference node needs `attrs: { type: "entry", "entry-uid": "…", "content-type-uid": "…", "display-type": "block"\|"inline"\|"link", "locale": "en-us" }`. Applies to both node-level `json_rte` prop values and `static_value.json_rte[*].value` embeds. See [`register-json-rte`](register-json-rte.md) pitfall row | diff --git a/skills/studio/author-composition-practice.md b/skills/studio/author-composition-practice.md new file mode 100644 index 0000000..ff72e3a --- /dev/null +++ b/skills/studio/author-composition-practice.md @@ -0,0 +1,135 @@ +--- +name: author-composition-practice +--- + +## When to use + +How to approach API authoring: skeleton first then bind, read the component source not just the registration, and look for a free-form primitive before declaring a value inexpressible. + +Part of the API-authoring set. Start at [`author-composition-via-api`](author-composition-via-api.md), which routes to this one. + +## Skeleton first, bind later: authoring structure before the content model exists {#skeleton-first} + +Translating a design set into Sections does not require the content model to be finished. **Author the structure with static values only, bind fields in a later pass.** This is the cheaper order when a Figma board defines 30 section shapes and the CT blocks for them do not exist yet. + +A skeleton composition is an ordinary composition with one difference: **no `template` bindings**. It is not a composition with fewer props. + +**Skeleton means unbound, NOT unset. Design props must still be authored to match the comp.** + +| | Skeleton pass | Binding pass | +|---|---|---| +| **Content** props (`text`, `href`, `src`) | `static_value` carrying the comp's real copy | `template` binding to a CT field | +| **Design** props (`direction`, `variant`, `size`, `color`, `gap`, `align`) | **set explicitly, same as the final section** | unchanged | +| Needs a linked schema? | **no** | yes | +| Needs a preview entry / block instance? | **no** | yes | +| Renders on canvas? | **yes**: comp copy, final layout | yes, real content | + +**Omitting a design prop does not leave it blank. It silently selects the library's `defaultValue`, which is rarely the comp.** Worked failure, from authoring a Figma hero against a production library: the comp showed two CTAs side by side, and the authored section rendered them stacked. Cause: + +```js +// registration +direction: { type: 'choice', options: stackDirectionOptions, defaultValue: 'column' } +``` + +The `direction` prop was never written, so the stack defaulted to `column`. Nothing errored. The canvas simply disagreed with the design. The same pass omitted both buttons' `variant` and `text`, so a brand CTA and a secondary CTA both rendered as the identical default button. **Every visual difference between the comp and the canvas was a prop left unwritten.** + +The rule that follows: **a design prop is skeleton-time work, because it is static in the final section too** (see [`design-component-library`](design-component-library.md): design props stay static, only content binds). Leaving it for the binding pass means it never gets written at all. + +For content props, prefer the comp's own copy as the `static_value` over relying on a default. Two buttons that both read "Get started" hide the fact that they are different CTAs. `"Explore our platform"` and `"Try for free"` make the structure legible and make the later binding pass obvious. + +**A Repeater is authored in the skeleton too: it does not wait for data.** A repeating region is one child inside a `repeater`, never N copies of the same subtree. Leave `items` unbound and the canvas renders a single placeholder iteration, which is exactly what the skeleton needs: + +```jsonc +// repeater node +"props": { + "children": { "type": "slot", "slot": "" }, // ONE child: the repeated shape + "items": { "type": "array", + "binding": { "type": "static_value", "value": "-items" } } +} +// entry.static_value.array — key present, NO value +[ { "key": "-items" } ] +``` + +The wrapper holding the repeater carries `metadata.repeaterWrapper: true`, and its `direction` / `gap` / `wrap` props produce the row or grid the iterations flow into. Verified against a production section authored this way with `linked_schemas: []` and no data sources at all. + +**A list whose items have different shapes needs one Condition Block per shape, all of them, in the skeleton.** A carousel showing stat / image / quote cards is not three lists and not one card repeated. It is one Repeater with three branches. Build every branch. Stopping after the first leaves two thirds of the design unbuilt while the canvas looks plausible, and the omission is invisible once the sheet is written. + +```jsonc +// repeater.slots. holds ONE condition-block per item type +{ + "type": "condition-block", + "metadata": { + "slotNames": { "": "Contents" }, + "condition": { + "type": "modular_block", "operator": "eq", "value": "", + "conditionBinding": { "type": "repeater", + "value": { "repeaterUID": "", "path": { "": {} } } }, + "dataBinding": { /* same shape */ } + } + }, + "props": { "children": { "type": "slot", "slot": "" } } +} +``` + +The branches can be authored **before the Modular Block exists**. Name the block uids the schema uses (`stat_card`, `image_card`, `quote_card`) and the binding pass creates them. Verified on an unbound Repeater: all three branches render their placeholder side by side, so the skeleton shows the whole design rather than one card. + +**Building N copies instead is the defect this prevents.** Five hand-placed cards look identical on the canvas, but the shape is fixed at five, editing one leaves four stale, and the binding pass has nothing to point at a collection. One card in a Repeater binds later by setting `items`. The subtree never changes. + +Two consequences worth stating: + +- **The canvas looking right proves structure, not data.** A skeleton and a correctly-bound section are visually similar. Both show plausible copy. `build-section` § Field-existence gate covers the inverse hazard: a **bound** prop falling back to `defaultValue` reads as real data when the binding is actually broken. Skeleton-first is safe precisely because nothing is claimed to be bound yet. +- **Bind by rewriting the same nodes.** The binding pass replaces `props..binding` from `static_value` to `template` on the existing tree. Node uids, slots and layout stay untouched. Keep the skeleton's `static_value` entries or drop them: an unreferenced key in `entry.static_value` is inert. + +## Read the component source before authoring, not just the registration + +The registration gives prop **names, types, options and defaults**. It does not say how the component **consumes** them, and that is where authored values silently stop working. Open the component file (`components/…/.tsx` in the host project) for every component you author, and look for four things. + +**1. Props that override other props.** A component may ignore one prop when another is set: + +```tsx +// AlphaRow.tsx — position overrides align/justify when set +position && position !== 'auto' + ? ROW_POSITION_CLASS[position] + : cn(ALIGN_CLASS[align], JUSTIFY_CLASS[justify]) +``` + +**2. Equality comparisons: the trap that follows from list-wrapped `choice` values (§ point 0a).** A list works for an object-key lookup and fails a strict comparison: + +```js +['auto'] !== 'auto' // true → takes the override branch +ROW_POSITION_CLASS[['auto']] // "" → align AND justify are dropped +JUSTIFY_CLASS[['end']] // 'justify-end' → key lookup still works +``` + +Worked failure: a pagination row authored with `justify: end` rendered **left**-aligned. Every value was correct and the panel showed them correctly. `position: ["auto"]` (written only because the rule says fill every registered prop) sent the component down the override branch and discarded `justify`. **Where a component compares a prop by equality, leave that prop unwritten** and let its own string default apply. This is a component-side bug (it should normalize the array), so record it for the library owner rather than working around it silently everywhere. + +**3. Values the component derives from another prop.** A component often computes a child's value from its own color scheme rather than taking it verbatim: + +```tsx +// AlphaFeatureGrid.tsx +const bodyColor = isDark ? 'medium' : 'light'; +const iconColor = isDark ? 'inverse' : 'strong'; +``` + +Authoring the same tree atomically means supplying those values yourself, and the correct one depends on context. A dark feature grid authored with `color: light` on the body and `default` on the icon looked reasonable and was wrong on both. The shipped component uses `medium` and `inverse` for dark. **When you compose from atoms what a composite renders internally, its derivation logic is the specification for the values you write.** + +**4. Which prop the component actually reads.** A registration often exposes several props for the same thing, and the component uses one of them. Worked failure: a media atom's registration offers `url` (string) **and** `media` (object). The component's first line is `if (!media?.url) return null`. A node authored with `url` rendered **nothing**: no error, no placeholder, just an absent image that read as a missing design element. The registration is a menu, not a contract. The component decides. + +**5. Whether a layout prop needs a width or a height to act on.** `justify` distributes free space. A shrink-wrapped row has none. The same pagination row, with `justify: flex-end` correctly applied, still sat left until its parent stack moved from `align: start` (which shrink-wraps children) to `align: stretch`. **Alignment is a two-node problem**: the prop on the child, the width it is given by the parent. Verify by measuring the rendered box, not by reading the prop back. + +## Before declaring a value inexpressible, look for a free-form primitive + +A constrained prop is not the whole library. Most palettes ship an escape hatch: a shape / box / spacer primitive whose `color`, `width` and `height` are **raw CSS strings** rather than token choices. It renders exactly what the comp asks for. + +**Check WHERE it renders, not just that it renders.** A shape/box primitive is often decorative background art, not an in-flow element: + +```js +// outer wrapper of a typical shape primitive +{ position: 'absolute', inset: 0, pointerEvents: 'none' } +``` + +With that wrapper the node occupies **zero layout space** and pins to the nearest positioned ancestor, usually the section, not the card you nested it in. Worked failure: three accent rules and four slide indicators were authored as shapes. Every one measured at the exact width, height and color asked for, and every one rendered at the **section origin**, stacked on top of each other over the heading. Size and color were verified. Position was not. Read the component before using a shape as a rule, and confirm the rendered box sits inside its intended parent. + +Worked failure: three accent rules were reported as impossible because `card-shell.border` is uniform and its width is a two-value choice. A card needing `border-top: 6px solid #899CFA` seemed unreachable. The same library had a `shape` primitive taking `color: "#899CFA"`, `width: "421px"`, `height: "6px"`, which reproduced all three rules exactly, including a `1px rgba(255,255,255,0.19)` hairline. **Grep the registry for a primitive with free-form size/color props before writing a row in Deviations.** + +The trade-off is real, so state it: a shape used as a rule is a positioned box, not a border. It does not follow the element on resize the way a real border does, and it adds a node. When a token-level fix exists (a per-side border prop on the container), the shape is the interim answer and the registration change is the durable one. diff --git a/skills/studio/author-composition-repeater-recipes.md b/skills/studio/author-composition-repeater-recipes.md new file mode 100644 index 0000000..2d361a1 --- /dev/null +++ b/skills/studio/author-composition-repeater-recipes.md @@ -0,0 +1,286 @@ +--- +name: author-composition-repeater-recipes +--- + +## When to use + +Worked recipes for repeating a card over a multi-reference field and over a GROUP-MULTIPLE field, with the full node trees. + +Part of the API-authoring set. Start at [`author-composition-via-api`](author-composition-via-api.md), which routes to this one. + +## Recipe: Repeat a card over a multi-reference field + +The canonical pattern. Requires FIVE shapes in concert. Missing any one yields a silently-empty repeater or (worse) an all-items-identical render (the `template`-at-`.0.` trap below). + +Scenario: a `category` template (Connected to the `category` CT). Each category entry has a `products` multi-reference field. We want to render one card per referenced product. + +### 1. Composition-level: `data_sources.resolvedReferences` + +The reference-resolution map. Tells the SDK "when bindings resolve through this reference path, materialize each stub into a real entry": + +```jsonc +"data_sources": [ + { + "uid": "template", + "data": null, + "resolvedReferences": { + "template": ["products"] + } + } +] +``` + +Example: a section (Card Grid) that iterates `related_posts`: + +```json +"data_sources": "[{\"uid\":\"template\",\"data\":null,\"resolvedReferences\":{\"template\":[\"related_posts\"]}}]" +``` + +**Wire format quirk:** on the CMA `data_sources` is stored as a **JSON-encoded string** (`"[]"`, `"[{…}]"`), not the parsed array. Serialize before write. Parse after read. + +**Key semantics: depends on the data source, and getting it wrong fails silently:** + +| Data source | Key | How the SDK reads it | +|---|---|---| +| `template` (the page entry) | Opaque label, Studio writes `"template"` | Flattens `Object.values(resolvedReferences)`, key content is ignored | +| `contentstack` (pinned entries) | **Must be the entry uid** | Direct lookup `resolvedReferences[entry.uid]`, a key that doesn't match the entry falls through to `[]` | + +Values are the field paths on the parent entry in both cases. + +So the "key doesn't matter" rule is **template-scope only**. For pinned entries the key is a lookup, not a label: mis-key it and the SDK finds no paths, adds no includes, and the references stay stubs, with no error, exactly as if you had never set `resolvedReferences` at all. + +Without this, reference fields are just `[{uid, _content_type_uid}, …]` stubs at runtime. The iteration sees no items. + +#### Where a reference path must terminate: the four shapes {#reference-path-termination} + +**One rule, four instances.** A `resolvedReferences` value becomes a CDA `include[]` entry, so it must name a **real, fetchable field chain** ending at the reference (or at the nested field you need resolved). Include every intermediate level, and no segment that isn't a field. + +| Source shape | Path to write | Not | +|---|---|---| +| Multi- or single reference on the parent | `products` | - | +| Reference inside a **Modular Block** | `blocks..` | `blocks.`, block uid omitted | +| Reference inside a **reference** | `carousel.cards` | `carousel.products_carousel.cards`, CT uid leaked | +| **Nested asset** inside a reference | both `hero` **and** `hero.image_options.image` | `hero` alone, scalars resolve, the asset doesn't | +| Reference inside a **group-multiple** | `footer_columns.related_entries` | - (groups add no extra level) | + +**Why this matters: every violation fails the same silent way.** A path that doesn't name a fetchable chain would 422 the CDA query, so Studio filters it out before the query is sent rather than letting it error. Nothing warns you. The reference stays a stub, the binding resolves against that stub, and the component renders empty. **A correct-looking binding with missing data is the signature of a bad path, not of a bad binding.** + +Two segment types are not fields and must not appear: + +- **Array indexes** (`0`, `2`) and the `entries` array-wrapper: the SDK strips both. +- **Reference-target CT uids.** A reference-inside-a-reference produces the inner reference's target CT uid directly after the reference field (`carousel`, then `products_carousel`, then `cards`). Studio drops it positionally: a segment is a discriminator **iff** it is a known CT uid **and** the preceding segment is a reference field whose `reference_to` includes it. That precision is deliberate: the same uid is often also a modular-block uid elsewhere in the path, and a block (having no preceding reference) is correctly preserved. + +Numeric prefixes on block segments (`2_feature_block`) are stripped by both Studio and the SDK, so write the plain uid. + +### 2. Repeater node's `items` binding: at `props.items.binding`, type `template` + +The Repeater consumes its iteration source through its `items` prop: + +```jsonc +{ + "type": "repeater", + "uid": "R1", + "props": { + "items": { + "binding": { + "type": "template", + "value": { "path": { "products": {} } } + } + }, + "children": { "type": "slot", "slot": "R1-slot" } + }, + "metadata": { "mode": "preview" }, + "slots": { "R1-slot": [ /* see step 4 */ ] } +} +``` + +`props.children` is **required**: it wires the `R1-slot` slot to the Repeater's built-in `children` prop. Without it the Repeater iterates but renders nothing (§ Node anatomy point 4). The `slots` key MUST match `props.children.slot`. + +For a section whose `selectedField` is the reference itself (`build-section`'s scope-aware sections / P22-P24), the path collapses to scope-root `{ "path": {} }`, the section's `dataSources.template` is already the resolved array. + +### 3. Repeater node's `metadata.mode: "preview"` + +On the same Repeater node, in `metadata` (parallel to `props`). Per `use-repeater`'s two-mode model: Design Mode (default) renders one placeholder. Preview Mode renders N real iterations. UI authors toggle this in Properties, under Configuration. API authors set the metadata directly. See the Repeater node shape in step 2 above for placement. + +### 4. Condition Block as the immediate child of the Repeater (under `slots`) + +Required by Studio's composition schema for reference iteration (single-CT AND multi-CT, `use-condition-block`). Narrows the iteration item to a specific content type before child bindings resolve. + +The Condition Block lives under a slot of the Repeater. Its discriminator is in `metadata.condition`: + +```jsonc +{ + "type": "repeater", + "uid": "R1", + // …items binding / metadata.mode as in step 2… + "props": { + "items": { "binding": { /* …as step 2… */ } }, + "children": { "type": "slot", "slot": "" } // ← wire the Repeater's slot + }, + "slots": { + "": [ + { + "type": "condition-block", + "uid": "CB1", + "metadata": { + "condition": { + "type": "reference", "operator": "eq", "value": "product", + "conditionBinding": { "type": "repeater", + "value": { "repeaterUID": "R1", "path": { "_content_type_uid": {} } } } + } + }, + "props": { + "children": { "type": "slot", "slot": "" } // ← Condition Block needs it too + }, + "slots": { + "": [ + /* card subtree here — step 5 */ + ] + } + } + ] + } +} +``` + +Without `conditionBinding` the block silently renders nothing. Design mode hides this: verify in preview. + +For modular-block iteration: `type: "modular_block"`, `value: ""`, `conditionBinding` and `dataBinding` on path `{ "": {} }`. + +Both container nodes carry `props.children` pointing at their own slot (§ Node anatomy point 4). The Condition Block also exposes a `children` slot prop. Skip its pointer and the card subtree never renders even though the Repeater iterates correctly. + +### 5. Card prop bindings: type `repeater`, with `repeaterUID` + +**The "all cards identical" trap:** Do NOT use `template` bindings indexed at `.0.` (e.g. `products.0.title`). A fixed `.0.` index does NOT get substituted by the iteration: every iteration renders item 0. This is what bit P19. + +Use repeater-scope bindings: `type: "repeater"`, with `repeaterUID` naming the parent Repeater node, and a path relative to the iteration item (NO index). The card node lives inside the Condition Block's slot: + +```jsonc +{ + "type": "site-product-card", // the registered component's type + "uid": "CARD1", + "props": { + "title": { + "binding": { + "type": "repeater", + "value": { + "repeaterUID": "R1", + "path": { "title": {} } + } + } + }, + "image": { + "binding": { + "type": "repeater", + "value": { + "repeaterUID": "R1", + "path": { "images": { "0": { "url": {} } } } + } + } + }, + "price": { + "binding": { + "type": "repeater", + "value": { "repeaterUID": "R1", "path": { "price": {} } } + } + } + } +} +``` + +The `repeaterUID` ("R1" here) tells the SDK at render time which Repeater's current item this binding reads from. The path is on the iteration item itself. + +### Assembled + +``` +Composition entry +├── composable_uid: "<15-char>" (= CMA entry uid) +├── data_sources: [{ uid:"template", +│ resolvedReferences:{ "template":["products"] } }] +└── ui (zlib-inflated tree) + └── Section "ProductGrid" + └── Box (grid layout — register-component § Layout contract) + └── { type:"repeater", uid:"R1", + metadata:{ mode:"preview" }, + props:{ items:{ binding:{ type:"template", + value:{ path:{ products:{} } } } } }, + slots:{ "":[ + { type:"condition-block", uid:"CB1", + metadata:{ condition:{ …step 4… } }, + slots:{ "":[ + { type:"site-product-card", uid:"CARD1", + props:{ + title:{ binding:{ type:"repeater", + value:{ repeaterUID:"R1", path:{ title:{} } } } }, + image:{ binding:{ type:"repeater", + value:{ repeaterUID:"R1", path:{ images:{ 0:{ url:{} } } } } } }, + price:{ binding:{ type:"repeater", + value:{ repeaterUID:"R1", path:{ price:{} } } } } + } } + ] } } + ] } } +``` + +Five shapes: composition `resolvedReferences` + Repeater `props.items.binding` + Repeater `metadata.mode` + Condition Block in slot with `metadata.condition` + repeater-scope card bindings. Any one missing produces an empty render or an all-identical render. + +## Recipe: Repeat a card over a GROUP-MULTIPLE field + +Sibling to the multi-reference recipe above, but **simpler**: group-multiple values are inline on the parent entry, so no reference resolution is needed. + +Scenario: a `category` entry has a `features` field of type `group` + `multiple: true` (a list of inline objects, each with sub-fields like `headline`, `body`, `icon`). Render one card per feature. + +### What's different vs the multi-reference case + +| Shape | Multi-reference | **Group-multiple** | +|---|---|---| +| Composition `data_sources.resolvedReferences` | **Required**: reference stubs need resolving | **NOT needed**: values are inline on the parent entry | +| Condition Block as Repeater's immediate child | **Required**: references are polymorphic (any allowed CT). The block narrows | **NOT needed**: group-multiple has one shape, not polymorphic | +| Repeater `props.items.binding` | `type: "template"`, path at the multi-ref field | (same) `type: "template"`, path at the group-multiple field | +| Repeater `metadata.mode: "preview"` | Required for canvas iteration | (same) Required | +| Card prop bindings | `type: "repeater"`, `repeaterUID`, no index | (same) `type: "repeater"`, `repeaterUID`, no index | + +So group-multiple drops to **THREE shapes** (Repeater items binding + Repeater metadata.mode + repeater-scope card bindings). No composition-level resolved-references map. No Condition Block. + +### Group-multiple Repeater + card, assembled + +``` +Composition entry +├── composable_uid: "<15-char>" (= CMA entry uid) +├── data_sources: [/* no resolvedReferences entry needed */] +└── ui (zlib-inflated tree) + └── Section "FeatureList" + └── Box (flex / grid layout — register-component § Layout contract) + └── { type:"repeater", uid:"R2", + metadata:{ mode:"preview" }, + props:{ items:{ binding:{ type:"template", + value:{ path:{ features:{} } } } } }, + slots:{ "":[ + /* card goes here directly — no Condition Block wrapper */ + { type:"site-feature-card", uid:"FC1", + props:{ + headline:{ binding:{ type:"repeater", + value:{ repeaterUID:"R2", path:{ headline:{} } } } }, + body:{ binding:{ type:"repeater", + value:{ repeaterUID:"R2", path:{ body:{} } } } }, + icon:{ binding:{ type:"repeater", + value:{ repeaterUID:"R2", path:{ icon:{ url:{} } } } } } + } } + ] } } +``` + +The card sits **directly inside the Repeater's slot**, no Condition Block in between, because the iteration item has one shape (the group's sub-fields). Each card prop reads from the iteration item via `repeaterUID:"R2"` + the field path on the item. + +### Decision rule: which recipe to use + +Look at the field's `data_type` on the source CT: + +| Field shape | Recipe to use | +|---|---| +| `data_type: "reference"`, `field_metadata.ref_multiple: true` (multi-reference) | Multi-reference recipe, needs `resolvedReferences` + Condition Block | +| `data_type: "reference"`, `field_metadata.ref_multiple: false` (single reference) | Multi-reference recipe minus the Repeater (a single Section drop, not iteration). The reference still needs `resolvedReferences`. | +| `data_type: "group"`, `multiple: true` (group-multiple) | Group-multiple recipe, no `resolvedReferences`, no Condition Block | +| `data_type: "blocks"` (Modular Block) | Multi-reference recipe shape, but Condition Block discriminator is `metadata.condition.type: "modular_block"`, value is the block UID. The MB field itself is inline: it needs **no** `resolvedReferences`. A reference **inside** a block does, and its path must carry the block uid: `blocks..`. See § Modular Block to reference: the block-uid segment | +| `data_type: "file"`, `multiple: true` (multi-file) | Multi-reference recipe minus `resolvedReferences` (files don't need resolution). Minus Condition Block (one shape). Like group-multiple. Path uses `.0.url` etc. inside each iteration. | +| `data_type: ""` (array of strings, numbers, etc.) | Group-multiple recipe shape. Each iteration is one scalar. The card might be a single component reading the scalar via repeater scope | + +The OPEN doc's P19 captures exactly the group-multiple vs reference split: same iteration shape, different prerequisites (`resolvedReferences` + Condition Block) depending on the source. diff --git a/skills/studio/author-composition-section-slot-fill.md b/skills/studio/author-composition-section-slot-fill.md new file mode 100644 index 0000000..82f0d8a --- /dev/null +++ b/skills/studio/author-composition-section-slot-fill.md @@ -0,0 +1,113 @@ +--- +name: author-composition-section-slot-fill +--- + +## When to use + +Pattern B by API: a wrapper Section iterates a reference or modular-block field and exposes a `section-slot` per item, and the template fills each slot with a separate Simple Section. Phrases: "Pattern B", "fill a section slot by API", "wrapper section with a slot". + +Part of the API-authoring set. Start at [`author-composition-via-api`](author-composition-via-api.md). Read [`author-composition-sections`](author-composition-sections.md) first: its Repeater question and `section-slot` node shape apply here. + +# Pattern B by API: wrapper + `section-slot`, filled by a Simple Section + +Sibling to the multi-reference recipe in [`author-composition-repeater-recipes`](author-composition-repeater-recipes.md). Same iteration outcome (one card per reference / MB item), but the leaf content lives in a **separate section composition** rather than in the wrapper's own `ui`. Verified end-to-end for references (Case 8) and modular blocks (Case 9) in `docs/api/sections.md`. + +**When to prefer Pattern B over Pattern A (self-contained):** +- Leaf content is reused across multiple wrappers (e.g. an author card shown in blog lists AND author-page hero). +- Leaf content varies per CT and you want each CT's binding tree in its own composition entry. +- You want to swap the fill section per template instance without editing the wrapper. + +**Shape overview:** + +| Composition | Kind | Role | +|---|---|---| +| Wrapper section | **List Section**: Repeater at root + CB per CT/block, CB slot holds a `section-slot` node | Iterates the reference/MB, emits one slot per item | +| Fill section (one per branch) | **Simple Section**: no root Repeater, `linked_schemas.selected_field` scoped to the reference target CT (or block) | Binds leaf fields | +| Template | Has a `section-composition` node embedding the wrapper. Its `slots.` holds a `section-composition` node for each fill section | Wires the slot to the fill | + +## Wrapper section: Repeater + CB + section-slot + +```jsonc +// ui tree of the wrapper section +{ + "type": "repeater", "uid": "R1", + "metadata": { "mode": "preview" }, + "props": { + "items": { "binding": { "type": "template", "value": { "path": {} } } }, + "children": { "type": "slot", "slot": "" } // required, see repeater-recipes step 2 + }, + "slots": { "": [ + { "type": "condition-block", "uid": "CB1", + "metadata": { "condition": { "type": "reference", "operator": "eq", "value": "author", + "conditionBinding": {…} } }, // required, see repeater-recipes step 4 + "props": { "children": { "type": "slot", "slot": "" } }, + "slots": { "": [ + { "type": "section-slot", "uid": "SS1", + "slots": { "SS1": [] } } + ] } } + ] } } +``` + +Wrapper section entry also declares `linked_schemas: [{ content_type_uid: "", selected_field: "" }]` (group-multiple field, see P23). + +## Fill section: Simple Section per branch + +One fill section per allowed CT (references) or block (MB). No root Repeater. Its top-level node is the leaf component. `linked_schemas.selected_field` names the reference target CT (or block schema path) so the SDK scopes each iteration's item into the section's `dataSources.template`. + +```jsonc +// ui tree of the fill section (e.g. author-card) +{ + "type": "author-card", "uid": "AC1", + "props": { + "name": { "binding": { "type": "template", "value": { "path": { "name": {} } } } }, + "photo": { "binding": { "type": "template", "value": { "path": { "photo": { "url": {} } } } } } + } +} +``` + +Note the fill section's bindings use `type: "template"` (NOT `type: "repeater"`). The wrapper's SDK scoping already unwraps the iteration item into the fill section's `template` scope. + +## Template: fill nodes in the placed wrapper's `slots` + +The template's `section-composition` node embedding the wrapper holds each fill as a real `section-composition` node in its `slots`, keyed by the section-slot uid. Studio writes this shape when an author fills a slot: + +```jsonc +{ + "type": "section-composition", + "uid": "INST-1", + "metadata": { "compositionUID": "" }, + "slots": { + "SS1": [ + { "type": "section-composition", "uid": "FILL-1", + "metadata": { "compositionUID": "" }, + "props": {}, "slots": {}, "attrs": {}, "styles": {} } + ] + // one key per CB branch's section-slot uid, each holding its own fill + } +} +``` + +Also populate the template's `linked_sections` (P27) with references to BOTH the wrapper section entry AND every distinct fill section entry. + +## Assembled: reference-iterating wrapper filled by an author fill + +``` +Wrapper section linked_schemas: [{ ct: "blog_post", selected_field: "primary_author" }] + ui: Repeater(items:{path:{}}, mode:"preview") + └── ConditionBlock(condition:{type:"reference", value:"author", conditionBinding}) + └── section-slot uid:"SS1" slots:{"SS1":[]} + +Author fill section linked_schemas: [{ ct: "blog_post", selected_field: "primary_author" }] + ui: author-card + ├── name: binding template path:{name:{}} + └── photo: binding template path:{photo:{url:{}}} + +Template (blog_post) + data_sources: [{ uid:"template", resolvedReferences:{ "template":["primary_author"] } }] + linked_sections: [wrapper-entry-ref, author-fill-entry-ref] + ui: page → … → section-composition uid:"INST-1" + metadata.compositionUID: "" + slots.SS1: [section-composition compositionUID:""] +``` + +MB variant (Case 9): swap the CB condition to the modular-block form ([`author-composition-repeater-recipes`](author-composition-repeater-recipes.md) step 4). Wrapper's `selected_field` names the MB field. Fill section's `selected_field` names the same MB path plus the block key. Leaf bindings inside the fill section still use `type: "template"`. diff --git a/skills/studio/author-composition-sections.md b/skills/studio/author-composition-sections.md new file mode 100644 index 0000000..c51d8ed --- /dev/null +++ b/skills/studio/author-composition-sections.md @@ -0,0 +1,149 @@ +--- +name: author-composition-sections +--- + +## When to use + +Author a Section composition by API: the scoping rules (P22-P25), `selectedField` priority, the `section-slot` node shape, and the wrapper + section-slot pattern. + +Part of the API-authoring set. Start at [`author-composition-via-api`](author-composition-via-api.md), which routes to this one. + +## Authoring a Section composition: scoping rules (P22-P25) + +So far the recipes have been about page-level compositions (Templates). Authoring **Section** compositions via API has its own contract: sections have **no data of their own**, so how they get the right scope at runtime is non-obvious. + +> **STOP before writing the section: ask the Repeater question.** If its `selected_field` is a `multiple: true` group, global field, file or link, ask the user *"This field holds multiple items. Should the section show every item (adds a Repeater) or one item?"* and wait. Ask even when the request seems to imply an answer: Studio asks every time. The answer sets `cardinality` and the root Repeater ([`understand-linked-schemas`](understand-linked-schemas.md)). Never ask for a reference, modular-block, single-value or whole-entry section. + +### How a Section gets data when placed on a Template: (P22) + +A Section composition is data-less when authored standalone (its own canvas has nothing to bind against). It only gets data when **placed inside a Template**. The Template hands it a scoped slice of the page entry. + +The SDK's scoping pipeline: + +``` +Section's dataSources.template = +``` + +- **`selectedField` empty/absent** makes the section's `template` the **whole page entry** (with any reference following). Use this when a section reads multiple fields off the page. +- **`selectedField` = "products"** makes the section's `template` the **`products` field's value** (an array, with references resolved). Use this when a section iterates one field. +- **`selectedField` = "hero.cta"** (dotted path) traverses. Each segment can drill into nested groups or follow references. +- **`selectedField` does NOT traverse array indices.** The dotted path drills nested Groups and single references, but a **numeric** segment into a multi-value field (`modular_blocks.1.…`, `products.0.…`) resolves the section's `template` to `null` (verified: `section_scoped_data[uid].template` came back `null`, whole section blank). To iterate an array nested inside a Modular Block or at a fixed index, leave `selectedField` **empty** (whole-entry scope) and put the full indexed path on the Repeater's `items` binding instead: `value.path = { "modular_blocks": { "1": { "image_grid": { "image": {} } } } }`. The binding-path flattener keeps numeric keys even though the scoping walk does not. Reserve `selectedField` for top-level Group / reference fields. + +This means the section's own canvas always shows **one empty placeholder** (no data exists standalone). That's not a bug. The section only fills out at template-rendering time. (See `build-section` for the UI-side framing.) + +### Where `selectedField` is read from: three sources, in priority order (P23) + +`collect-data-needs.ts` (runtime) and `sectionInstanceBindings.ts` (editor) BOTH read in this order: + +1. **`node.metadata.sectionBindingOverride.selectedField`**: set on the template's `section-composition` node when API-placing the section +2. **`node.metadata.selectedField`**: legacy fallback on the same node +3. **The section's `linked_schemas` default**: declared on the section composition itself: `[{ content_type_uid: "", selected_field: "" }]` + +The Studio web UI sets all three correctly when you drag a section onto a template. API authors set them explicitly. + +### The `linked_schemas`-as-reference trap (P23) + +The compositions CT's `linked_schemas` MUST be a **group-multiple** field, NOT a reference field. If it's a reference field, the CMA **silently drops** any `{content_type_uid, selected_field}` object you `PUT` to it, every read comes back `linked_schemas: []`. The editor reads `linked_schemas` to derive the `UPDATE_SECTION_CONTEXTS` push that scopes each placed section instance. An empty `linked_schemas` means an empty scope push, meaning the editor canvas shows blank repeaters even though `fetchCompositionData` (runtime) renders correctly via the override. + +Two outcomes from this: + +- **Proper fix:** model `linked_schemas` as a group-multiple on the compositions CT. See `provision-studio-project` for the CT field schema. This makes every UI-driven path work without manual overrides. +- **Runtime escape hatch (if you can't change the CT shape right now):** set `metadata.sectionBindingOverride` on each template's `section-composition` node: + ```jsonc + { + "type": "section-composition", + "uid": "INST-1", + "metadata": { + "compositionUID": "", + "sectionBindingOverride": { "selectedField": "products" } + } + } + ``` + This makes the runtime/SSR path correct (`collect-data-needs.ts` honors it). The editor canvas will still show blank repeaters until `linked_schemas` is also populated. So this escape hatch covers production rendering but doesn't unblock editor authoring: both must agree. + +### The editor vs runtime asymmetry (P23, in detail) + +| Path | What it reads | Source file | +|---|---|---| +| **Runtime / SSR** (`sdk.fetchCompositionData`) | `sectionBindingOverride.selectedField` first, then `metadata.selectedField`, then the `linked_schemas` default | `collect-data-needs.ts` | +| **Editor canvas** (live editing in Studio web app) | `UPDATE_SECTION_CONTEXTS` push from the Studio web app, derived from the section's `linked_schemas` field. `sectionBindingOverride` is also honored as an override on top. | `sectionInstanceBindings.ts` + Studio web app messaging | + +So you can have a section that renders perfectly on the deployed site (runtime path uses the override) yet shows empty repeaters in the editor (editor path's `linked_schemas` is empty). Both metadata paths must point to the same `selectedField` for editor + runtime to agree. + +### Recipe: section iterates one field on the page (P24) + +When a section's `selectedField` is set to the iterated field (e.g. `selectedField: "products"` and the section's job is to render one card per product), the section's `dataSources.template` IS the products array. **A top-level Repeater inside the section must bind to scope-root, NOT to `{ path: { products: {} } }`**: + +```jsonc +{ + "type": "repeater", + "uid": "R-section-products", + "metadata": { "mode": "preview" }, + "props": { + "items": { + "binding": { + "type": "template", + "value": { "path": {} } // ← scope-root, NOT { products: {} } + } + }, + "children": { "type": "slot", "slot": "" } // ← required, wires the slot below + }, + "slots": { + "": [ + // ConditionBlock + card here, per the multi-reference recipe; + // card bindings still use type:"repeater" with repeaterUID + ] + } +} +``` + +`getBindingStringForCS` flattens `{path: {}}` to `""`, so the binding resolves to `dataSources.template` directly. With `selectedField: "products"`, that IS the products array. A path of `{products: {}}` would resolve to `dataSources.template.products`, undefined when the section's template IS the array, not an object containing one. + +Reference iterations still need `data_sources.resolvedReferences` on the composition: key `"template"`, value is the array of field paths on the parent entry (e.g. `"template": ["products"]`). The runtime resolves these along the `selectedField` path. You set them on the parent (template) composition, not the section composition. + +### Decision rule: single-field repeater section vs whole-entry section (P25) + +| Section's job | `selectedField` | Repeater binding (if any) | When to use | +|---|---|---|---| +| One repeater over one field of the page (a card grid, a feature list) | Set to that field (`"products"`) | Scope-root `{ path: {} }` | Most reusable sections: Editions list, Features grid, Card grid | +| A custom component reading several fields off the page entry (header reading brand + nav_links + signin_label. Hero reading the `hero` group) | **Leave unset**: section's `template` is the whole page entry | Original `template.` paths work verbatim | Single-purpose sections with no iteration: Header, Hero, static 3D block | + +The choice is data-shape-driven: one field iterated calls for `selectedField` plus a scope-root. Many fields read calls for whole-entry scope and the original paths. + +### Verifying a section's scope headlessly (P26 preview) + +The section's own canvas can't confirm binding correctness (no data exists standalone). Use the SSR cold-load instead: + +```js +const spec = await sdk.fetchCompositionData({ url: "/" }); +console.log(JSON.stringify(spec.data.section_scoped_data[""], null, 2)); +// → { selectedField: "products", parentRepeaterUID: null, +// template: [, , ...] } +``` + +`spec.data.section_scoped_data[]` is the runtime resolution: `selectedField` + the actual scoped `template` array. If `template` shows the right array length and right entry sample, the section's scope is correct. If it's `undefined` or wrong-shaped, the bindings won't work. + + +## `section-slot` node shape: the one legitimate empty-slots node + +The built-in `section-slot` type declares a **placeholder** inside a section's `ui`. The template that embeds this section then fills the slot via a `section-composition` node (see next recipe: Pattern B). Non-obvious shape: + +```jsonc +{ + "type": "section-slot", + "uid": "SS1", + "props": {}, + "slots": { "SS1": [] } // ← key is the node's OWN uid; value is [] until filled +} +``` + +Two things that trip API authors: + +1. **`slots.: []` is required**: the section-slot's own uid appears as the slot key, with an empty array. This is one of only two legitimate exceptions to the preflight rule "no empty slot arrays". The other is the page-side `section-composition` node, where Studio writes `slots.: []` for each slot the template hasn't filled yet. Omitting the entry (no `slots` at all, or `slots: {}`) leaves the template unable to target the slot when composing. +2. **The slot is filled by the TEMPLATE, not the section.** In the section's authored tree the slot stays empty. The template's `section-composition` node holds the fill in its own `slots`, keyed by the section-slot uid ([Pattern B](author-composition-section-slot-fill.md)). + +Section slots typically sit inside a Repeater + Condition Block on the wrapper section (Pattern B for reference / MB iteration) or directly under the section root (Pattern B for a simpler single-slot section). + +## Pattern B: wrapper + `section-slot`, filled by another Section + +When the leaf content belongs in its own Section (reused across wrappers, different per content type, or swapped per template), a wrapper List Section exposes a `section-slot` per item and the template fills it. Recipe and node trees: [`author-composition-section-slot-fill`](author-composition-section-slot-fill.md). diff --git a/skills/studio/author-composition-static-values.md b/skills/studio/author-composition-static-values.md new file mode 100644 index 0000000..79e1f77 --- /dev/null +++ b/skills/studio/author-composition-static-values.md @@ -0,0 +1,25 @@ +--- +name: author-composition-static-values +--- + +## When to use + +Write `static_value` bindings by API: the binding stores a key, and the literal lives in the composition entry's `static_value.[]` bucket. Phrases: "static_value binding", "static value renders the default", "where does the literal go". + +Part of the API-authoring set. Start at [`author-composition-via-api`](author-composition-via-api.md). The entry fields are in [`author-composition-entry`](author-composition-entry.md). + +# How `static_value` binding resolves: two-level lookup + +**Not obvious from the binding shape**: the literal ISN'T on the node. The `binding.value` on a `static_value` binding is a lookup key into the composition entry's `static_value.[]` bucket: + +1. The node's prop binding stores a **key string** in `binding.value` (e.g. `"mHjnXsgKbBlj_PZ-headline"`), NOT the literal. +2. The literal lives in the composition entry's top-level `static_value.[]` array under that key: `{ "key": "mHjnXsgKbBlj_PZ-headline", "value": "Welcome to Studio" }`. +3. The SDK resolves by keying into `entry.static_value` with that key at render time. + +Consequences for API authoring: + +- **You must write to TWO places** for every static-value prop: `props..binding.value = ""` on the node AND `entry.static_value.` (matching the prop's declared `type`: `text` / `textarea` / `href` / `imageurl` / `number` / `choice` / `boolean` / `json_rte` / `array` / `object` / `any` / `datestring` / `html_rte`) with an entry `{ key, value }` pair. +- **Key uniqueness matters**: the resolver looks up the key against the whole `static_value.` bucket. Reuse the same key across multiple nodes to share a literal. Use unique keys to keep them independent. +- **Widget-to-subtype pairing.** The node's `props..type` (`"string"` / `"number"` / `"href"` / `"imageurl"` / `"choice"` / …) determines which sub-bucket the SDK looks in. If you write the key to the wrong subtype array, the resolver misses and the prop renders as the schema default. +- **`type` also decides whether the binder FLATTENS the value: never write `"any"` for structured data.** The binder runs a recursive single-key unwrap on every type except `object` and `array` (`retrieve-data.ts`: `if (type !== "object" && type !== "array" …) resolvedData = getFlattenedData(resolvedData)`), descending while an object has exactly one key (`$` and `_metadata` don't count). So a node prop typed `"any"` and bound to a single-field block (`image_grid → { image: [...] }`) receives the **inner array**, not the block: lookups return `undefined`, indices surface as `{0,1,2,3}`, and it renders blank with no error. Write `"object"` for a group / Global Field / block object and `"array"` for a list. Those two skip the unwrap. Full mechanism: [`register-component` § The `any` flatten trap](register-component.md). +- **Convention Studio uses:** keys are `-` (e.g. `mHjnXsgKbBlj_PZ-headline`). Not required but stable and readable. diff --git a/skills/studio/author-composition-templates.md b/skills/studio/author-composition-templates.md new file mode 100644 index 0000000..b071b6d --- /dev/null +++ b/skills/studio/author-composition-templates.md @@ -0,0 +1,259 @@ +--- +name: author-composition-templates +--- + +## When to use + +Author a Template by API: populate `linked_sections` for every API-placed section (P27), plus a complete worked Freeform composition body. + +Part of the API-authoring set. Start at [`author-composition-via-api`](author-composition-via-api.md), which routes to this one. + +## Authoring a Template: populate `linked_sections` for every API-placed section (P27) + +Sibling concern to P22-P25 (which were section-side). When a Template's `ui` tree contains `section-composition` nodes that were placed via API, the template's **`linked_sections` reference field** ALSO has to be populated, even though SSR walks the nodes directly. Same editor-vs-runtime asymmetry as P23 but at the template layer. + +### The asymmetry + +| Path | How it discovers placed sections | +|---|---| +| **Editor canvas** (template open in Studio web app) | Loads the template with `?include[]=linked_sections` (resolves the references inline), builds `spec.sectionCompositions` from the resolved `linked_sections` field. If empty, there are no section specs and you get **"Template Did Not Load"** | +| **SSR / runtime** (`sdk.fetchCompositionData`) | Walks the template's `ui` tree looking for `section-composition` nodes. Reads each node's `metadata.compositionUID`. Fetches each section composition separately | + +So a template can render perfectly on the deployed site (SSR walks the nodes) yet refuse to open in the editor (`linked_sections` is empty, editor has no specs to resolve the nodes against). **"Template Did Not Load"** in the editor + a passing SSR render = empty `linked_sections`. + +### Recipe: populate `linked_sections` when API-placing sections + +When your composition-authoring script writes `section-composition` nodes into a template's `ui`, ALSO populate the template entry's `linked_sections` field with a reference per placed section's composition entry: + +```jsonc +// Template composition entry (CMA write) +{ + "title": "Product Page", + "url": "/products/{{entry.title}}", + "url_metadata": { + "url_source": "content_type_url_pattern", + "url_queries": "{\"include\":[],\"only\":{},\"where\":{}}" + }, + "composable_uid": "product_page_template", + "connected_content_type": "product", + "place_composition_as": "page", + "ui": "zlib:", + "linked_sections": [ + { + "uid": "", + "_content_type_uid": "" // the project's compositions CT + }, + { + "uid": "", + "_content_type_uid": "" + } + // …one reference per distinct placed section. Dedupe: a section placed + // multiple times in the same template only needs one entry here. + ] +} +``` + +The references can be bare `{uid, _content_type_uid}` stubs. The CMA stores them. The editor's `?include[]=` resolves them inline when loading. There's no UI-only metadata to mirror (unlike P23's `selectedField`). + +Then **publish the template entry**. The editor reads from the published entry. An unpublished update won't show up. + +### Pre-flight check for an API-authored template + +Before you mark a template as ready, verify both paths agree: + +```bash +# 1. Editor path — does linked_sections resolve to actual sections? +curl -s "https:///v3/content_types//entries/?include[]=linked_sections" \ + -H "api_key: " -H "access_token: " \ + | jq '.entry.linked_sections | length, .entry.linked_sections[0].uid' + +# 2. SSR path — does fetchCompositionData find the same sections? +# (Use verify-setup Layer 7's script and dump section_scoped_data; the key count +# should equal the count of distinct section-composition nodes in the template's ui.) +``` + +Both must agree: +- If the editor path returns 0 sections but the SSR path returns N, `linked_sections` is not populated. Populate per the recipe above. +- If the editor returns N sections but SSR returns 0, `linked_sections` is set but the `ui` tree has no `section-composition` nodes (probably a separate authoring bug). +- If both return matching N, both paths are wired correctly. + +## Complete example: a real Freeform composition body + +Every field below is what Studio actually stores for a real Freeform composition. Use this shape verbatim when hand-authoring one. The tree is a single `page` root holding a `doc-hero` component, which holds a `doc-product-card` in the hero's `"Actions"` slot, all bound with `static_value` keys. + +### The `ui` tree (inflated, before `zlib+base64`) + +```json +{ + "uid": "mZfkYLfEsk28cuV", + "type": "page", + "attrs": {}, + "metadata": {}, + "styles": {}, + "props": { + "children": { "type": "slot", "slot": "lW5KA7MYsHQxdHC" } + }, + "slots": { + "lW5KA7MYsHQxdHC": [ + { + "uid": "mHjnXsgKbBlj_PZ", + "type": "doc-hero", + "attrs": {}, + "metadata": { "slotNames": { "7Opc3nDCi1wwpyy": "Actions" } }, + "styles": { "default": { "classes": [""], "responsiveStyles": { "default": {} } } }, + "props": { + "headline": { "type": "string", "binding": { "type": "static_value", "value": "mHjnXsgKbBlj_PZ-headline" } }, + "subhead": { "type": "string", "binding": { "type": "static_value", "value": "mHjnXsgKbBlj_PZ-subhead" } }, + "cover": { "type": "imageurl", "binding": { "type": "static_value", "value": "mHjnXsgKbBlj_PZ-cover" } }, + "ctaLabel": { "type": "string", "binding": { "type": "static_value", "value": "mHjnXsgKbBlj_PZ-ctaLabel" } }, + "ctaHref": { "type": "href", "binding": { "type": "static_value", "value": "mHjnXsgKbBlj_PZ-ctaHref" } }, + "children": { "type": "slot", "slot": "7Opc3nDCi1wwpyy" } + }, + "slots": { + "7Opc3nDCi1wwpyy": [ + { + "uid": "Wo-7Ly0xHXWsOXQ", + "type": "doc-product-card", + "attrs": {}, + "metadata": {}, + "styles": { "default": { "classes": [""], "responsiveStyles": { "default": {} } } }, + "props": { + "title": { "type": "string", "binding": { "type": "static_value", "value": "Wo-7Ly0xHXWsOXQ-title" } }, + "image": { "type": "imageurl", "binding": { "type": "static_value", "value": "Wo-7Ly0xHXWsOXQ-image" } }, + "price": { "type": "number", "binding": { "type": "static_value", "value": "Wo-7Ly0xHXWsOXQ-price" } }, + "badge": { "type": "choice", "binding": { "type": "static_value", "value": "Wo-7Ly0xHXWsOXQ-badge" } }, + "ctaHref": { "type": "href", "binding": { "type": "static_value", "value": "Wo-7Ly0xHXWsOXQ-ctaHref" } } + }, + "slots": {} + } + ] + } + } + ] + } +} +``` + +Notes on the tree that aren't in the recipe examples above: +- Every node carries `attrs`, `metadata`, `styles`, `props`, `slots`, even when empty (`{}`). Studio writes them. You should too. +- The root's `type` is `"page"`, the built-in Page system component. Freeform / Connected templates wrap their content in this root. +- `props.children.type: "slot"` + `props.children.slot: ""`: declares that this node's children live in the matching key inside `slots`. Same pattern on nested slot-owning components. +- `metadata.slotNames.: ""`: components with named slots (like `doc-hero`'s `"Actions"` slot) register the label here so the canvas shows it. +- Each `props..type` (`"string"`, `"imageurl"`, `"number"`, `"href"`, `"choice"`) is the widget kind. It **must match** the sub-bucket of `entry.static_value` the resolver looks in (see the two-level-lookup rule above). + +### The full CMA POST body + +The `ui` value on the wire is the tree above, JSON-stringified, `zlib.compress`ed, base64-encoded, and prefixed with `zlib:`. The rest of the entry: + +```json +{ + "entry": { + "title": "Spring 2026 Landing", + "composable_uid": "spring_2026_landing", + "connected_content_type": "", + "url": "/campaigns/spring-2026", + "url_metadata": { + "url_source": "user_specified_pattern", + "url_queries": "{\"include\":[],\"only\":{},\"where\":{}}" + }, + "place_composition_as": "page", + "schema_version": "1.0.0", + "data_sources": "[]", + "linked_schemas": [], + "linked_sections": [], + "static_value": { + "text": [ + { "key": "mHjnXsgKbBlj_PZ-headline", "value": "Welcome to Studio" }, + { "key": "mHjnXsgKbBlj_PZ-ctaLabel", "value": "" }, + { "key": "Wo-7Ly0xHXWsOXQ-title", "value": "Studio Pro" } + ], + "textarea": [ + { "key": "mHjnXsgKbBlj_PZ-subhead", "value": "Visual composition for content teams — using the React components your engineering already built." } + ], + "imageurl": [ + { "key": "mHjnXsgKbBlj_PZ-cover", "value": "" }, + { "key": "Wo-7Ly0xHXWsOXQ-image", "value": "" } + ], + "href": [ + { "key": "mHjnXsgKbBlj_PZ-ctaHref", "value": "" }, + { "key": "Wo-7Ly0xHXWsOXQ-ctaHref", "value": "https://example.com" } + ], + "number": [ + { "key": "Wo-7Ly0xHXWsOXQ-price", "value": 49 } + ], + "choice": [ + { "key": "Wo-7Ly0xHXWsOXQ-badge", "value": [] } + ], + "array": [], + "boolean": [], + "any": [], + "datestring":[], + "json_rte": [], + "html_rte": [], + "object": [], + "ui": "zlib:eNqtlFtvgjAUgP9L…" + } + } +} +``` + +Studio-generated `_metadata.uid` on each `static_value.*` entry is added by the CMA on write. Do not populate it yourself. + +### Python builder: produces the exact body above + +Paste into a `.py` file, fill the two `<…>` placeholders, run against a fresh entry uid: + +```python +import base64, json, urllib.parse, zlib + +UI_TREE = { ...the tree from § "The ui tree" above... } +COMPOSITION_ENTRY_UID = "<15-char CMA entry uid you'll POST to>" # optional if using composable_uid slug + +raw = json.dumps(UI_TREE, separators=(",", ":")) +assert raw.count('"children"') >= 2, "children key corrupted before compression" +compressed = "zlib:" + base64.b64encode(zlib.compress(raw.encode())).decode() + +body = { + "entry": { + "title": "Spring 2026 Landing", + "composable_uid": "spring_2026_landing", + "connected_content_type": "", + "url": "/campaigns/spring-2026", + "url_metadata": { + # Freeform + hand-chosen path → user_specified_pattern. + # Connected template → "content_type_url_pattern". NEVER omit this block. + "url_source": "user_specified_pattern", + "url_queries": json.dumps({"include": [], "only": {}, "where": {}}), # JSON STRING on the wire + }, + "place_composition_as": "page", + "schema_version": "1.0.0", + "data_sources": "[]", + "linked_schemas": [], + "linked_sections": [], + "static_value": { + "text": [{"key": "mHjnXsgKbBlj_PZ-headline", "value": "Welcome to Studio"}, ...], + "textarea": [{"key": "mHjnXsgKbBlj_PZ-subhead", "value": "…"}], + "imageurl": [{"key": "mHjnXsgKbBlj_PZ-cover", "value": ""}, ...], + "href": [{"key": "mHjnXsgKbBlj_PZ-ctaHref", "value": ""}, ...], + "number": [{"key": "Wo-7Ly0xHXWsOXQ-price", "value": 49}], + "choice": [{"key": "Wo-7Ly0xHXWsOXQ-badge", "value": []}], + "array": [], "boolean": [], "any": [], "datestring": [], + "json_rte": [], "html_rte": [], "object": [], + }, + "ui": compressed, + } +} + +open("/tmp/entry.json", "w").write(json.dumps(body)) +``` + +Then POST with `curl -d @file` (never `-d "..."`, see § Never pass the compressed `ui` inline): + +```bash +curl -X POST "https:///v3/content_types/documentation_compositions/entries" \ + -H "api_key: " -H "$CS_AUTH" \ + -H "Content-Type: application/json" \ + -d @/tmp/entry.json +``` + +Publish the entry after create. See § Sanity-check the output below. diff --git a/skills/studio/author-composition-thumbnails.md b/skills/studio/author-composition-thumbnails.md new file mode 100644 index 0000000..e355b54 --- /dev/null +++ b/skills/studio/author-composition-thumbnails.md @@ -0,0 +1,92 @@ +--- +name: author-composition-thumbnails +--- + +## When to use + +Section thumbnails are set by the UI and never by the API. What that means when authoring headlessly, and how to supply one yourself. + +Part of the API-authoring set. Start at [`author-composition-via-api`](author-composition-via-api.md), which routes to this one. + + + +## Section thumbnails: (the UI sets it for you. The API never does) + +**Symptom:** every API-authored Section shows a blank/placeholder tile in Studio's **Sections** accordion, while UI-authored ones show a picture of the section. Nothing errors. + +**Why.** Studio's Sections accordion renders `section.ui_preview.url` (`ComponentTab/SectionsAccordion`: `ui_preview` is a `file` field holding an asset). When an author saves a Section **in the Studio UI**, the app screenshots the canvas node (`GET_NODE_SCREENSHOT`), converts the base64 to a blob, uploads it as an asset, and PUTs `{ entry: { ui_preview: } }` on the composition entry, fire-and-forget, master locale, then invalidates the sections query so the tile appears without a reload. **That whole chain lives in the editor.** A composition entry created by CMA has `ui_preview` empty forever. + +Same asymmetry family as `linked_sections` (P27): the UI populates a field behind the scenes, so API authoring must do it explicitly. + +### Fix A: zero-script backfill (best when the sections already exist) + +Open each Section in Studio and hit **Save**, no edits needed. The screenshot/upload/PUT chain runs on save and backfills `ui_preview` with a real render of the section. Cheapest correct fix, and the thumbnail is an actual screenshot rather than a placeholder. Only worth scripting instead when the section count makes clicking impractical. + +### Fix B: pick an image in the Edit Section modal (no canvas render, no script) + +Studio's **Edit Section** modal has a preview-image field: open it, **Choose** an existing asset or **Upload** one, Save. That writes `ui_preview` on the entry directly (`useEditSectionComposition`, then `handleImageSelect`, then `handleSubmit`), with no screenshot and no CMA scripting. + +Use this when Fix A isn't practical: the section can't render standalone (needs page context), the canvas is slow to load, or you want a designed thumbnail rather than a screenshot of the live section. Trade-off: the image is whatever you pick, so it drifts from the section's real appearance as the section changes. Fix A stays the better default when the section renders cleanly. + +### Fix C: set it from the API + +Three calls per section. `ui_preview` takes an **asset uid string** on write. It reads back as an asset object with `.url`. + +```bash +# 1. Upload the thumbnail asset +curl -X POST "https:///v3/assets" \ + -H "api_key: " -H "$CS_AUTH" \ + -F "asset[upload]=@hero-section.png" \ + -F "asset[title]=Hero Section Preview" +# → { "asset": { "uid": "blt", ... } } + +# 2. Point the composition entry at it (master locale — compositions are master-only) +curl -X PUT "https:///v3/content_types//entries/" \ + -H "api_key: " -H "$CS_AUTH" \ + -H "Content-Type: application/json" \ + -d '{"entry": {"ui_preview": "blt"}}' + +# 3. Publish BOTH the asset and the entry to the environment the project targets +``` + +**Publish the asset too.** An unpublished asset gives the accordion a uid that resolves to nothing, a blank tile again, indistinguishable from never having set the field. + +**Precondition:** the compositions CT must carry the `ui_preview` file field. If it's missing, the CMA silently drops the key on write: 201/200 returned, field reads back absent. See [`provision-studio-project`](provision-studio-project.md). + +**What to use as the image.** A real screenshot of the rendered section is what the UI produces and what authors expect. A generated placeholder (labeled color block) is acceptable as a stopgap and still beats a blank tile, but say so rather than presenting it as a preview. + +### MANDATORY: a section without `ui_preview` is not finished + +This is not a follow-up task or a nice-to-have. **Attaching the thumbnail is part of creating the section**, in the same run, before you report the section as built. + +- Write the composition, **immediately** attach `ui_preview`, and only then report success. +- Creating five sections means five thumbnails. Batch the write and the thumbnail together per section, or run `--missing` at the end of the batch. Never move on to the next page while any section you created is blank. +- **Never report "section created" for a section whose `ui_preview` is unset.** Say "created, thumbnail pending" and then finish it. +- If the thumbnail step errors, that is a failure to **report and retry**, not to skip silently. Cosmetic means "does not corrupt data". It does not mean optional. + +The symmetric rule for components is `thumbnailUrl` on every `registerComponent` call (see [`register-component`](register-component.md)). Both are rows in [`complete-the-build`](complete-the-build.md), the handover gate. Between the two, **nothing in the Components panel is ever a blank tile**: sections carry `ui_preview`, components carry `thumbnailUrl`. + +**How this gets missed in practice** (observed, twice): a build script writes the compositions and the thumbnail is left to a separate command that then does not get run. The sections look finished in every check except the one an author actually sees. Put the thumbnail call inside the build script. + +### Fix D: generate one from the composition's own `ui` (scripted, no canvas, no clicking) + +```bash +tsx scripts/make-ui-preview.ts --entry +tsx scripts/make-ui-preview.ts --composable-uid +tsx scripts/make-ui-preview.ts --missing # backfill every section lacking one +tsx scripts/make-ui-preview.ts --entry --dry-run # render the PNG, upload nothing +``` + +Decodes the entry's `ui` (`zlib:` + base64), renders a schematic (containers in their real direction and gap, the outermost repeater as three items, media as a framed block, static text as text), screenshots it at 1440×810, uploads it as ` Preview`, and PUTs `ui_preview`. Use it when Fix A is impractical at scale: one live project had **50 of 79 sections** blank, backfilled in a single run. + +It sits between Fix B and Fix C: no hand-picked image, no per-section curl, and the result tracks the section's real structure. It is still a schematic, not a render. For a true screenshot of a bound section, Fix A remains better. + +**Why not script the canvas screenshot instead.** A section canvas has no preview entry selected (`PREVIEW ENTRY: No preview entry`), so every bound prop renders its registration default. Measured across 11 captures: **0 to 1.2% ink coverage**, content confined to the top 8% of the frame. Uploading those fills the accordion with near-identical "Your headline here" tiles, worse than a blank tile, because they stop reading as empty, and once `ui_preview` is set every capture-when-missing trigger skips that section forever. + +| Pitfall | Why it bites | Fix | +| --- | --- | --- | +| Posting `ui` as `{ slots: … }` | The `ui` field IS the root node, not a wrapper around one. Without its own `uid` and `type: "page"` the create fails `422 The root node of a composition must have type "page", got "undefined"`, and the message reads like a child-node problem, so the fix gets applied one level too deep | Give `ui` a `uid` and `type: "page"`, put the tree under its `slots`, and point `props.children` at the slot id | +| Reading `static_value` as a map when generating the image | Buckets are arrays of `{key, value, _metadata}` rows, so `sv[type][key]` is always `undefined` and every label silently renders as a placeholder bar | Flatten rows to `key → value` first. `choice` values arrive as single-element arrays | +| Drawing every repeater as three items | A nested repeater iterates a field within one item (a logo, a tag). Three-by-three turns one slide into a nine-column grid | Only the outermost repeater repeats. Nested ones render one item | +| Auditing `ui_preview` coverage over the delivery API | The published list is short: one project read 86 compositions on the CDA vs **122** on the CMA, hiding 36 unpublished sections that were also blank | Audit over the CMA, never the CDA | +| Assuming an unpublished thumbnail always shows blank | Measured otherwise: the accordion loaded unpublished preview assets fine (1440×810, `naturalWidth > 0`), Studio's own save-path upload never publishes either | Publishing still matters for anything reading `ui_preview.url` through the **delivery** API. Verify in the accordion rather than assuming either way | diff --git a/skills/studio/author-composition-via-api.md b/skills/studio/author-composition-via-api.md new file mode 100644 index 0000000..fa99f2c --- /dev/null +++ b/skills/studio/author-composition-via-api.md @@ -0,0 +1,112 @@ +--- +name: author-composition-via-api +--- + +## When to use + +Author Studio compositions by writing composition JSON via the CMA, the manual contract Studio's Data Picker hides. For headless / scripted / seed-pipeline projects, not interactive UI authors. + +Use when authoring compositions programmatically via CMA: seed pipelines, scripted provisioning, migration scripts, debugging via diff. Phrases: "compositions from JSON", "API author composition", "seed compositions", "headless author", "bulk import". Do NOT use for UI authoring. The Data Picker handles every shape this skill describes. For UI use `build-section` / `build-connected-template`. + +> **Mandatory auth preflight: settle the credential before the first API call.** Resolve it OAuth-first per [`authenticate-cma`](authenticate-cma.md): `CS_OAUTH_ACCESS_TOKEN`, else the Contentstack MCP's stored session: Read that skill for where it is, even with no MCP connected. **Never ask the user for a session `authtoken`.** If nothing resolves, or a refresh fails with `400 invalid_refresh_token`, hand them `! CONTENTSTACK_REGION=<code> npx @contentstack/mcp --auth` (it needs a TTY and a browser, so it cannot be run for them) and wait. `403 error_code 316` is a valid credential aimed at another org: fix the org or the `api_key`, do **not** re-authenticate. + +# Author a composition by API (the manual contract) + +> **Approved plan required: this path has no other gate.** This skill writes composition JSON. It does not decide what to build. If there is no plan the user has explicitly approved (Sections with their linked schemas, Section Slots, per-Section components, bindings, and the template kind), **stop and run [`plan-studio-architecture`](plan-studio-architecture.md) first**. The canvas build skills carry this guard. Scripted CMA authoring is the fastest way to reach a finished project without ever passing one, and [`decompose-design`](decompose-design.md) names this skill its default handoff. A template composition whose root slot holds a single component node is a monolith (no Sections, no slots, no reuse) and it renders perfectly, so nothing downstream will catch it. Exception: the user is iterating on a piece already in an approved plan. + +> **Mandatory structural check before reporting success.** Read the saved template back and count `section-composition` nodes in `ui` and entries in `linked_sections`. Both zero means a monolith. The runnable check is [`build-connected-template`](build-connected-template.md) § Post-build structural check. It applies identically here, and it is the only acceptance check a monolith fails. When the page body is one modular-blocks field, [`decompose-blocks-page`](decompose-blocks-page.md) is the recipe for splitting it. + + +## When to use this: and when NOT to + +Studio's Data Picker writes every binding shape this skill describes **automatically**. UI authors never need to know about `data_sources.resolvedReferences`, the `repeaterUID` discriminator, or any of the binding types. Studio's runtime resolves them under the hood. + +**Use this skill ONLY** when bypassing the Data Picker: +- A seed pipeline that creates compositions programmatically (a docs / demo project's bootstrap, an automated stack provisioner) +- Migrating compositions between stacks +- Bulk-authoring compositions from another source (an existing CMS, a generator) +- Debugging: capture a working UI-authored composition, decode it, diff against your broken one + +For interactive authoring, use Studio's UI. The Data Picker is the **source of truth** for these shapes. If your manually-written shape disagrees with what the Data Picker emits, the picker is right. Your manual shape is wrong. + +## Prerequisite: the project, the compositions CT, the credentials + +This skill assumes: +- A Studio project exists, bound to a stack +- That stack has a compositions CT (the CT the project's `contentTypeUid` points at) with the required field schema (see `provision-studio-project`) +- You have a CMA auth token + the stack API key with permission to create entries on that CT +- For headless verification later: a Delivery Token + paired Preview Token (`enable-visual-experience` Step 4) + +If any of these is missing, this skill is the wrong starting point. + +> **Auth: OAuth first.** Snippets here write `-H "$CS_AUTH"`, resolved once per run. **Assume there is no session `authtoken`**. The user authenticates with OAuth (MCP `--auth` / `csdx auth:login --oauth`) and never pastes one. Resolution order, scope headers (`organization_uid` vs `api_key`), the expiry/refresh rule and the 401/422 signature table: [`authenticate-cma`](authenticate-cma.md). + +> **MCP-first execution.** Discover tools and verify the target with a read call per [`install-contentstack-mcp`](install-contentstack-mcp.md). When supported tools are callable, the entry write + publish run as MCP tool calls (`create_an_entry` / `update_an_entry` / `publish_an_entry`) with no pasted `authtoken`. The composition **shape** is unchanged: you still build the `ui` (`zlib:<base64>`), `data_sources`, `static_value`, and `composable_uid` (= entry uid) exactly as below. The MCP only replaces the raw CMA transport. **Shell-corruption caveat on the MCP path:** `create_an_entry` takes `entry_data` as an **inline JSON object: there is no file-path param**, so the temp-file / `curl -d @file` guard does NOT apply. Instead **build the entire `entry_data` object programmatically and hand it to the tool, never hand-transcribe the `ui` blob into the arguments**, and assert the `"children"` count in the tree before the call (see the shell-corruption pitfall). If hand-assembly is unavoidable, keep the raw `curl -d @file` path for the write. A missing readiness flag requires discovery; use HTTP only for unavailable operations or a recorded connection limitation. + +## The API-authoring set: read the one you need + +This skill is the entry point. It carries the decision to author by API at all, the +prerequisites, and the acceptance gate. Everything else lives in a focused sibling, so a +single task loads what it needs instead of the whole contract. + +| Read this | For | +|---|---| +| [`author-composition-entry`](author-composition-entry.md) | The CMA body, `url` + `url_metadata`. **Read first.** | +| [`author-composition-static-values`](author-composition-static-values.md) | Where a `static_value` binding's literal lives | +| [`author-composition-bindings`](author-composition-bindings.md) | Every binding type, node anatomy, Repeater binding shapes. **Read second.** | +| [`author-composition-encoding`](author-composition-encoding.md) | Compressing the `ui` tree, and preflight-validating it before publish | +| [`author-composition-repeater-recipes`](author-composition-repeater-recipes.md) | Repeat a card over a multi-reference or GROUP-MULTIPLE field | +| [`author-composition-nested-repeaters`](author-composition-nested-repeaters.md) | A list inside each iterated item | +| [`author-composition-sections`](author-composition-sections.md) | Section scoping (P22-P25), `selectedField`, `section-slot` | +| [`author-composition-section-slot-fill`](author-composition-section-slot-fill.md) | Pattern B: fill a wrapper's slots with another Section | +| [`author-composition-exposed-props`](author-composition-exposed-props.md) | Per-instance overrides on a placed Section | +| [`author-composition-thumbnails`](author-composition-thumbnails.md) | Why the API never sets a Section thumbnail | +| [`author-composition-templates`](author-composition-templates.md) | `linked_sections` (P27), plus a complete Freeform body | +| [`author-composition-pitfalls`](author-composition-pitfalls.md) | Verifying the write, and the full pitfall table. **Read before reporting done.** | +| [`author-composition-practice`](author-composition-practice.md) | Skeleton-first authoring, reading component source, free-form primitives | +| [`author-composition-migration`](author-composition-migration.md) | Moving compositions between stacks | + +Minimum path for any composition: `author-composition-entry`, then +`author-composition-bindings`, then `author-composition-pitfalls` before you report done. + + +## Acceptance + +This skill succeeds only when ALL are true. + +- [ ] The composition entry was created with a unique `composable_uid` (a readable name or the entry uid), published. +- [ ] For a cross-stack migration: the template's `ui` `compositionUID`s + `linked_sections` were remapped to the destination entry uids (not carried over from the source). +- [ ] **Every `place_composition_as: "page"` entry has BOTH `url` and `url_metadata`**, `url_source` matching how the pattern is derived (`content_type_url_pattern` for Connected, `default_url_pattern` / `user_specified_pattern` for Freeform), `url_queries` as a JSON string. **Read the entry back after the write** and confirm `url_metadata` is present: a compositions CT missing the group makes the CMA drop it silently, leaving the composition on `legacy_url`. +- [ ] `data_sources.resolvedReferences` is set per reference field the composition iterates. +- [ ] Every Repeater node has `metadata.mode: "preview"` (for canvas iteration). +- [ ] Every Repeater iterating a reference or modular block has a Condition Block as its immediate child. +- [ ] **Every Condition Block's `metadata.condition` has `operator` and `conditionBinding`**, and the composition was checked in **preview mode**. Without the binding the block renders nothing, but design mode still shows it. +- [ ] Card / iteration child bindings use `type: "repeater"` with `repeaterUID`, NOT `type: "template"` with `.0.<field>` fixed-index paths. +- [ ] **Every Section composition created in this run has `ui_preview` set, verified by re-reading each entry.** A section with a blank tile is an unfinished section. Do not report the work as done. Also true of sections created as a side effect (clones, rescopes, batch builds). +- [ ] **Every Section composition has `ui_preview` set**, an uploaded, **published** asset uid, verified by re-reading the entry (`.entry.ui_preview.url` resolves). Blank tiles in Studio's Sections accordion otherwise. Fastest fix is often opening each section in Studio and pressing Save. See § [Section thumbnails](author-composition-thumbnails.md#section-thumbnails-ui-preview) +- [ ] **Envelope complete**: every node in the written tree has all seven keys (`uid`, `type`, `props`, `metadata`, `slots`, `attrs`, `styles`), verified by re-reading the entry. A missing `slots` renders fine and white-screens the Data tab later. +- [ ] **Preflight passed**: every `slots[<uid>]` is a non-empty array (exception: `section-slot` nodes carry `slots: { "<own-uid>": [] }` by design). No orphan descendant references in the tree. +- [ ] **[Pattern B](author-composition-section-slot-fill.md) (if used)**: wrapper section has `section-slot` node with `slots:{"<own-uid>":[]}`. Fill section's leaf bindings use `type:"template"` (NOT `repeater`). Template's `section-composition` node has `slots.<slot-uid>: [<fill section-composition node>]`. Template's `linked_sections` includes BOTH wrapper and fill entries. +- [ ] **Every `section-composition` node's `metadata.compositionUID` is the placed section's entry uid** (`blt…`), never its `composable_uid`. A `composable_uid` there fails in the canvas with `Component with type '<it>' is not registered`. +- [ ] **A section whose `selected_field` is a multiple group / global field / file / link** has `cardinality` from the user's answer (asked, never assumed): `"collection"` with a root Repeater, or `"single"` without one. Reference and modular-block sections carry no `cardinality` ([`understand-linked-schemas`](understand-linked-schemas.md)). +- [ ] **Exposed section props (if used)**: `sectionExposedProps` array on section's `ui` ROOT `metadata`. Template's `section-composition.props` map keyed by each exposure's `uid`. Per-instance override values use distinct static_value keys. +- [ ] The composition resolves at runtime, verified by either curl against the CDA preview API (returns 200 + entries body, see `troubleshoot-canvas` § CORS error on the CDA preview host) OR a SSR cold-load `await sdk.fetchCompositionData({ url })` returning `spec.data.section_scoped_data[<instance>]` with the expected `template` array (P26-style verification). + +## Sanity-check the output + +After authoring, verify by ONE of: + +1. **CDA preview API curl**: confirm the composition is returned. See `troubleshoot-canvas` § CORS error on the CDA preview host for the curl shape. Status 200 + JSON entries body = the contract resolves. +2. **SSR cold-load**: `await sdk.fetchCompositionData({ url })` from a throwaway server route. It **rejects** if the URL resolves to nothing, so a thrown `NO_COMPOSITION_FOR_URL` / `URL_MATCHED_NO_ENTRY` here is itself the verification result: the composition isn't resolving. In a throwaway script that's fine. In app code wrap it ([`configure-csr-vs-ssr`](configure-csr-vs-ssr.md#resolve-composition-helper)). Dump `spec.data.section_scoped_data[<instance>]` to verify each section's `selectedField` + scoped `template` arrives correctly. The most reliable check. +3. **Decode the stored `ui`**: the read-only `zlib`-inflate snippet in `troubleshoot-canvas` § Diagnostic tooling. Diff your composition's node tree against a known-working UI-authored one to spot any shape mismatch. Useful for debugging. + +## See also + +- `troubleshoot-canvas` § Diagnostic tooling (inspecting a stored composition's ui) the zlib decoder +- `troubleshoot-canvas` § CORS error on the CDA preview host: preview-token verification curl shape +- `use-repeater`: UI authoring side. The Preview Mode toggle the API directly sets via `metadata.mode` +- `use-condition-block`: the Condition Block schema requirement for reference / modular-block iteration +- `build-section`: section's `selectedField` scoping (the parent of most API-authored sections) +- `understand-auto-binding`: the UI mechanism that writes these shapes for UI authors +- `provision-studio-project` (planned): compositions CT field schema, including `linked_schemas` as group-multiple +- `troubleshoot-data-binding`: the "registered as lazy but not loaded yet" error class diff --git a/skills/studio/author-without-code.md b/skills/studio/author-without-code.md new file mode 100644 index 0000000..4e9d8ce --- /dev/null +++ b/skills/studio/author-without-code.md @@ -0,0 +1,113 @@ +--- +name: author-without-code +--- + +## When to use + +End-to-end authoring path for content teams on a Studio project a developer has already set up (pick a template, bind data, preview, save, publish) no code required. + +Use when the user wants to compose pages WITHOUT engineering involvement: pure UI authoring against an already-installed Studio project. Phrases: "build pages in Studio", "I just want to compose", "no code". Do NOT use for installs (`install-studio`), registration (`register-component`), or troubleshooting. Those are engineering tasks. + +> **Auth preflight: settle the credential before the first API call.** Resolve it OAuth-first per [`authenticate-cma`](authenticate-cma.md): `CS_OAUTH_ACCESS_TOKEN`, else the Contentstack MCP's stored session: Read that skill for where it is, even with no MCP connected. **Never ask the user for a session `authtoken`.** If nothing resolves, or a refresh fails with `400 invalid_refresh_token`, hand them `! CONTENTSTACK_REGION=<code> npx @contentstack/mcp --auth` (it needs a TTY and a browser, so it cannot be run for them), and wait. `403 error_code 316` is a valid credential aimed at another org: fix the org or the `api_key`, do **not** re-authenticate. + +# Author a page in Studio: no code required + +## Context + +Studio is split into two surfaces, and confusing them is the #1 reason content authors stall. + +1. **Contentstack**: where the **content** lives (entries, assets, content types). This is the CMS you already know. URL: `app.contentstack.com/.../stacks/...`. +2. **Studio**: where the **layout** lives (which sections, in what order, bound to which fields). URL: `app.contentstack.com/.../projects/...`. + +A published page combines layout (Studio) with content (a Contentstack entry), which produces the URL the developer's app renders. You move between both surfaces. This skill tells you which surface to use for each step. + +Two kinds of pages exist in Studio, with very different authoring flows: + +| Page kind | What it is | When you'll use it | Where to start | +|---|---|---|---| +| **Connected Template** (the main authoring surface) | One layout, bound to a whole content type. Every entry of that CT renders through this layout at a URL derived from the entry (e.g. `/blog/<slug>` for every blog post). Also used for one-off pages backed by a single-entry CT (the homepage, the about page, etc. Your developer may have modelled those as single-entry CTs). | "Add a new blog post." "Add a new product." "Update the homepage hero copy." | **Contentstack**: create or edit the entry in the CT. Studio auto-renders it. | +| **Freeform template** (only if the project has the Freeform feature turned on) | One layout not tied to any content type. Its content comes from pinned entries, pinned queries, or values typed straight into the layout. | A one-off landing page the developer built as Freeform. | **Studio**: open the template from the **Templates** sub-tab. Content edits happen in the layout (Flow C). To build a new one, use `build-freeform-template`. | + +**Most content-team work is content-first**: edit the entry in Contentstack, the page updates automatically. Open Studio only when the layout itself needs to change. + +## Task + +### 1. Open Studio and orient + +Open the URL the developer shared. You should land in a **Studio project** with two tabs in the top nav: + +- **Compositions**: every page-shaped layout in this project. The page splits into two sub-tabs inside: **Templates** (the page templates you'd publish at a URL: Connected Templates, plus Freeform templates if the project has Freeform turned on) and **Sections** (reusable layout blocks the developer prepared, dropped into compositions: you do not usually create these directly). This is the main authoring surface. +- **Settings**: project-level config (rarely needed by authors). + +Registered React components don't have a top-level tab. They're visible from the left palette when you have a composition open. There is no UI to create or edit components from inside Studio. + +If the **Templates** sub-tab is empty, stop. Ask the developer to confirm at least one template has been created. Without a starting point, you cannot author. + +### 2. Decide your path from the goal + +Map your `goal` to one of these three flows. + +- **"Publish a new blog post / new product / new entry of an existing CT"** maps to Flow A (edit in Contentstack, no Studio needed) +- **"Change the text / image / link on an existing page"** without changing the layout maps to Flow D (Live Preview or Visual Editor: fastest path, no Studio) +- **"Update the layout of an existing page"** (reorder sections, swap a hero, change a binding) maps to Flow C (open the existing composition in Studio) + +### 3. Run the matching flow + +#### Flow A: New entry of a Connected content type (most common) + +1. Switch to **Contentstack** (same nav, different surface). +2. Open the relevant content type, then click **+ New Entry**. +3. Fill in **every field** the Connected Template binds. If you skip a field that the template uses, the live page renders that part as empty. Hover the field name to see help text the developer wrote. +4. **Save** the entry (in CS, not Studio). +5. **Publish** the entry to the environment your site reads from (usually "production", confirm with the developer if unsure). +6. Visit the live URL. The entry should render via the template within seconds (publish latency varies). + +#### Flow C: Edit layout of an existing composition + +1. In Studio, open the **Compositions** tab, then click the row for the page you want to edit. +2. The canvas opens with the page as currently saved/published. The status badge (top right) shows **Draft** if unsaved changes exist, **Published** otherwise. +3. Make changes: drag sections in/out, rebind fields, change order. +4. **Save**, then **Deploy**. Save persists the draft in Studio. Deploy makes the live URL render the new layout. + +#### Flow D: Just change content (text / image / link) on an existing page + +For a Freeform template page, there is no template entry. Text from a pinned entry or query is edited on that entry in Contentstack (as below). Anything else lives in the layout, so use Flow C. + +For a Connected template page: it's a Contentstack entry edit. Flow A, but on an existing entry: open the entry, edit the fields, click **Update**, then **Publish** (Contentstack's own publish action, not Studio's Deploy). + +If you don't know whether a field is bound or static: open the composition in Studio, click the field on the canvas, and read the Properties panel. It will say either "Static" (Flow C edit) or show a binding chip pointing to an entry (Flow A-style edit on that entry). + +### 4. Verify before stepping away + +Open the live URL in an **incognito / private browser window** (so you're not seeing a cached or preview version) on the production environment. Check: + +- The page loads at the URL you expect. +- All content is filled: no `[unbound]`, no `{placeholder}`, no broken images. +- Links work. +- Mobile width looks OK (resize the window). + +If any of these fail, return to the relevant flow. Don't tell the team "it's live" until you've checked the live URL in incognito. + +### 5. Bookmark where to come back + +Bookmark the entry in Contentstack. That's your edit point forever. If you also need to change the layout later, bookmark the composition in Studio too. + +## When to escalate to the developer + +Stop and ask the developer rather than guessing if any of these are true: + +- The Compositions tab is empty, or no template covers your CT. +- The section you need does not exist in the Sections palette. +- A field you need to bind isn't on the entry (the content type needs a new field, a CMS schema change). +- You see `[unbound]` after binding and don't know why. +- Publishing succeeds but the live URL still shows the old version after 5+ minutes. +- The URL pattern you need conflicts with another composition's pattern (you'll see a warning in Studio). + +These are not author-fixable: they need developer changes or are bugs. + +## What this skill is NOT + +- Not a guide to set up Studio (use `install-studio` / `studio-tour`). +- Not a guide to register components (use `register-component`, developer task). +- Not a guide to building sections (use `build-section`, typically a developer / designer task). +- Not how Studio works internally (use `studio-tour` for that). diff --git a/skills/studio/build-connected-template.md b/skills/studio/build-connected-template.md new file mode 100644 index 0000000..7a52f5d --- /dev/null +++ b/skills/studio/build-connected-template.md @@ -0,0 +1,207 @@ +--- +name: build-connected-template +--- + +## When to use + +Scaffold a Connected (content-type-bound) Studio template by dropping sections, wiring linked-schema bindings, and saving. Every entry of that CT renders at a derived URL. Use Studio UI, driven through Playwright per [`drive-studio-canvas-ui`](drive-studio-canvas-ui.md). Don't hand the clicks to the user or switch to the API unless they ask; that's [`author-composition-via-api`](author-composition-via-api.md). + +Use for a Connected template, a page bound 1:1 to a content type (blog post, product) where every entry renders at a derived URL. Phrases: "build a blog template", "PDP template", "connected to a content type". Assumes `install-studio` is done. + +> **Mandatory auth preflight: settle the credential before the first API call.** Resolve it OAuth-first per [`authenticate-cma`](authenticate-cma.md): `CS_OAUTH_ACCESS_TOKEN`, else the Contentstack MCP's stored session: Read that skill for where it is, even with no MCP connected. **Never ask the user for a session `authtoken`.** If nothing resolves, or a refresh fails with `400 invalid_refresh_token`, hand them `! CONTENTSTACK_REGION=<code> npx @contentstack/mcp --auth` (it needs a TTY and a browser, so it cannot be run for them) and wait. `403 error_code 316` is a valid credential aimed at another org: fix the org or the `api_key`, do **not** re-authenticate. + +> **Pattern preflight: mandatory.** Before the first write, run [`match-existing-pattern`](match-existing-pattern.md): read the Templates and Sections already in the project, **reuse what already covers this schema**, and build to the pattern they use. Skip it only when the project has no compositions at all. Authoring something structurally foreign to what is there is rework, and it is invisible until someone opens the Layers panel. + +# Build a Connected Template in Studio + +> **Approved plan required.** This skill builds. It does not decide. If there is no plan the user has explicitly approved (Sections with their linked schemas, Section Slots, per-Section components (registered vs built-in), bindings, and the template kind), **stop and run [`plan-studio-architecture`](plan-studio-architecture.md) first**, then come back with the approved block for this piece. Building without it is how a project ends up as one monolithic template with no Sections, no slots and no reuse. Exception: the user is explicitly iterating on a piece already in an approved plan. + +## STOP: apply the Section-qualification rule before dropping anything + +Before dropping components or bindings directly on this Template, check the **golden rule** from [`plan-studio-architecture`](plan-studio-architecture.md) § Step 2: + +- **Reused across Templates?** Any bound compound component that appears on more than one Template MUST be built as a Section first (`build-section` or `build-repeating-section`), then dropped here. +- **Schema iteration (Modular Block / multi-Reference / group-multiple)?** MUST become a **List Section** (Repeater + CBs). Never author the iteration directly on the Template. + +Only when BOTH answers are no is it correct to drop registered components straight onto this Template. When in doubt, build the Section first: Sections are cheap to add, direct-drop is expensive to unwind. + +## Context + +Templates compose Sections. Build Sections first via `build-section`. See `understand-templates` § Templates compose Sections. + +**Connected is the default flavor**: including one-off pages backed by a single-entry CT. + +A **Connected template** is bound to one content type. Every entry renders through it at a URL derived from the CT's `url` field (or VX preview URL, or per-template override). What makes it Connected: + +1. **Content-type binding** (chip in canvas chrome). +2. **Linked-schema auto-binding**: Studio reads each section's `linked_schemas` and wires it to a matching field on the CT. + +Studio uses one preview entry at a time (auto-picked, most recently updated matching the URL pattern). Bindings record field paths, not entry UIDs. + +Two render surfaces: +- **Studio canvas iframe**: `<StudioCanvas />` on the canvas route, for authoring. +- **Live site**: `<StudioComponent />` on ONE catch-all (`app/[[...slug]]/page.tsx` / `<Route path="*">`). Studio resolves every URL via `sdk.fetchCompositionData(...)`. Per-template routes are NOT needed. Without the catch-all, the derived URL 404s even after Deploy. + +This skill creates the template, drops sections, confirms bindings, saves. It does NOT publish/deploy. + +## Task + +1. **Confirm prerequisites.** Before touching the UI, verify (ask the user to confirm if you can't check): + - Studio project exists and is linked to the stack. + - Content type `contentTypeUid` exists and has at least one entry. + - **A URL pattern is available.** Studio resolves which entry to preview by matching the request path against a URL pattern. **One of these five sources must be in play**: `custom_preview_url` (set in the stack's Visual Experience settings), `content_type_url_pattern` (page CT with `url_pattern`), `default_url_pattern` (Studio-generated default, applied automatically for connected templates when nothing else is configured), `user_specified_pattern` (authored via the canvas's Edit URL flow), or `legacy_url` (binding against a CT `url` field on every entry). You do NOT need a CT `url` field. Studio's `default_url_pattern` covers the no-config case. Only verify upfront if the project uses the `legacy_url` path: in that case every entry must have `url` populated AND starting with `/` (without a leading slash the entry is invisible to the matcher. Canvas opens blank with "No composition for /…"). + - At least one **published** entry exists for the connected CT. Connected canvas fetches via the CDA. Unpublished entries return 404. Canvas appears blank. + - At least one Section composition exists whose `linked_schemas` references a field on `contentTypeUid`, otherwise auto-binding will silently leave every section unbound. If unsure, list sections via the Studio Sections tab and check each one's `linked_schemas`. + - For a custom URL pattern: prefer configuring the **Preview URL** in the stack's **Settings**, under **Visual Experience**, rather than per-template overrides. If the CT already has a `url` field with a pattern, Studio will derive from it automatically. + +2. **Open the Templates tab** in the target Studio project. Empty state shows an illustration with a **+ New Template** button. Populated state shows a list with columns Title · Connected Content Type · Publish Status · Modified At · Actions. + +3. **Click `+ New Template`** (top right). The Create New Template chooser modal opens with the Connected Template option ready to create. + +4. **Fill in the modal:** + - Title: `templateName` + - Connected Content Type: pick `contentTypeUid` from the dropdown (lists every CT in the stack) + - Click **Create Connected Template** + +5. **Verify the canvas chrome** once Studio opens the new template: + - Composition title matches `templateName` (top bar) + - Content-type chip matches `contentTypeUid` + - `PREVIEW ENTRY :` (entry name) with a `⇄` swap icon. Studio auto-picks the most recently updated entry that satisfies the URL pattern + - URL line directly below evaluates against that entry (no `{{...}}` leftovers) + + **Symptom: "No entry has a URL field populated yet" (canvas shows the message, no preview entry is auto-selected):** this is the editor's `MISSING_CT_PATTERN_URL_FIELD` error, raised **only** under `url_source: "content_type_url_pattern"` (or `legacy_url`). Those sources resolve the preview entry by querying for a populated `url` **field**, with no pattern substitution. So it means: the connected CT isn't a page type, OR no entry of that CT has its `url` field set. **If the CT is a singleton with no `url_pattern` by design** (a fixed-URL page), don't add a pattern you don't want, just set the entry's `url` field by hand (e.g. `/rewards`). The source only needs a non-empty `url`, not a CT pattern. Switch to `user_specified_pattern` only when the URL must derive from a different field (`{{entry.slug}}`, a reference). See [`author-composition-via-api` § Which `url_source` to write](author-composition-entry.md#url-metadata-matched-pair). Fix: ensure the CT is `is_page: true` with a `url` field + `url_pattern` (see [URL pattern rule below](#single-entry-vs-multi-entry-url-pattern-rule)). For every entry, set the `url` field, then click `⇄` and pick a preview entry. After a CT `url_pattern` change, re-save + republish entries so `entry.url` recomputes, otherwise old entries still carry stale URLs. + +6. **(Optional) Edit the URL pattern.** Only if `urlPattern` is provided OR the derived URL is wrong. Click the pencil icon, then Edit URL. Use Insert chips for `{{entry.<field>}}`, `{{environment}}`, `{{taxonomy:<uid>}}`. **Save** validates + refetches. **Avoid `{{locale}}`**: carry locale via routing + SDK `locale` query option. + + <a id="single-entry-vs-multi-entry-url-pattern-rule"></a>**Single-entry vs multi-entry URL pattern rule.** Connected templates split into two cases that need different URL shapes: + + - **Single-entry template** (homepage, about, pricing: one entry, one URL): a literal URL like `/home` works. The matcher only needs to resolve one path. + - **Multi-entry template** (blog posts, products, recipes: many entries through one template): the literal pattern `/blog/:slug` does NOT work. The `:slug` placeholder comes back **literal**: Contentstack's **CMS-side** CT URL-pattern compiler (separate from the Studio SDK pattern compiler) doesn't substitute `:slug`, the entry's `url` field saves as `/:slug` instead of a real slug, and the SDK has no per-entry URL to match against. **The proven shape:** CT `url_pattern: "/:title"`, Contentstack's CMS slugifies the title field into `entry.url` (`:title` is the working CMS substitution token, `:slug` is treated literal) + CT `url_prefix: "/<route>/"` (e.g. `/blog/`), composition `url: "/<route>/{{entry.title}}"` with `url_metadata.url_source: "content_type_url_pattern"` and `url_queries: ""`. The SDK then queries entries by `entry.url`. CT pattern and composition URL must be **consistent by construction**. After changing the CT pattern, re-save + republish every entry so `entry.url` recomputes. + + **URL traps: same symptom ("No composition for /…"), different fixes:** + + - **(a) Literal URL on a multi-entry template.** A composition `url` like `/home` works for single-entry, but a multi-entry template needs a placeholder. **Fix:** use `/<route>/{{entry.url}}` or `{{entry.title}}` (NOT `:slug`, see rule above). + - **(b) Entry `url` field missing leading slash.** SDK matches the full request path. `url: "home"` never matches `/home`. **Fix:** every entry's `url` starts with `/`. + - **(c) API-set `user_specified_pattern` reverts.** `url_metadata.url_queries` is only generated by the UI's Edit-URL and Save flow. **Zero-UI workaround:** make the CT a page type (`is_page: true` + CT-level `url_pattern`), set composition `url_metadata.url_source: content_type_url_pattern`, put `{{entry.x}}` in composition `url`. + - **(d) `:slug` placeholder comes back literal.** Contentstack's CT URL pattern recognizes `:title` but treats `:slug` as a literal string: `url_pattern: "/:slug"` saves and stays `/:slug`, never substituting. **Fix:** use `:title` in the CT pattern (Contentstack slugifies it). Use `{{entry.title}}` in the composition URL. + +7. **Drop the primary section.** If `primarySectionUid` was provided, drag it from the left palette (Sections category) or the Sections sidebar onto the empty canvas. Drop targets highlight as you drag. + +8. **Confirm auto-binding.** When the section drops, Studio reads its `linked_schemas` and tries to bind to a matching field on the connected CT: + - **One match**: bound silently. Verify by selecting the section, then opening the **Settings** tab in the right panel. + - **Multiple matches**: bound to one. Switch via the dropdown in the section's right-panel **Settings** tab. + - **No match**: drops unbound. Either (a) add a matching field to the CT, (b) edit the section's `linked_schemas`, or (c) manually pick a compatible field from the section's Settings if one exists. + For sections that render lists, the section internally uses a Repeater bound to the matched field. Per-item field bindings inside the section read from `repeater.<field>` and resolve automatically, no per-template wiring needed. + +9. **Fill any Section Slots.** If the section exposes a Section Slot (e.g. `Hero Strip` slot for a CTA), drag a component (Button, Link, registered component) into the slot's outlined region on the canvas. Slots are carved out of the section's structure: drop into the outlined region, not the surrounding section frame. + +10. **Override exposed props per template instance.** Select the section, then open the **Settings** tab in the right panel. Each exposed prop (e.g. Card Title) shows an input. Type the per-template override (e.g. `"Related Blogs"`). This stores on the template instance, not on the section composition itself. + +11. **(Optional) Drop individual components and bind them.** From the left palette (Basic / Media / Container / Smart Containers / Registered Components / HTML Elements). Select the component on canvas, open the **Data** tab in the right panel, and click the binding chip on a prop. The Data Picker opens: pick `entry.<field>`, a reference field, or a context value (including `repeater.<field>` when inside a Repeater). + +12. **Sanity-check with a swap.** Click `⇄` next to PREVIEW ENTRY and pick an entry with different content shapes (long titles, missing images, empty references). Canvas re-renders instantly. Empty fields mean the entry lacks the value, not that the binding is wrong. Don't re-edit the binding to "fix" an empty render. Swap back and confirm. + +13. **Save.** Top bar **Save** persists the template to Contentstack. This does NOT publish. Deploy is a separate step that moves the template through the publishing workflow to an environment. + +14. **Smoke-test on the live site.** Hit the derived URL on the host app. The catch-all route should match it and mount `<StudioComponent />`. If the canvas iframe renders but the live URL doesn't, the catch-all route is missing. Add it via [`setup-template-preview-routes`](setup-template-preview-routes.md) (default = ONE catch-all that handles every URL. No per-template route needed) before claiming success. The live page fetches its data via `sdk.fetchCompositionData(...)` under the hood. + +15. **Connected templates preview at the ENVIRONMENT base URL, NOT the Canvas URL.** + - **Sections** preview at the **Canvas URL** (in Studio, open the project, go to Settings, and open Configuration). + - **Connected templates** preview at the **environment base URL** + resolved path (`Stack → Settings → Environments → <env> → Base URL`). + + If sections render but a connected template shows "localhost didn't send any data" or "SDK Not Initialized", env base URL is wrong (common cause: scheme mismatch, env says `http://localhost:5173` but dev server is HTTPS-only via mkcert). + + **Fix:** `PUT /v3/environments/<env>` so `urls[...]` scheme matches the served canvas-app. + +16. **Multiple-file fields bind to `.0.url`, not the field name.** When a CT has a `file` field of type multiple (an asset array), and you bind it to a scalar prop like `imageurl`: + + - Wrong: `entryBind("imageurl", "images")` binds the array, which renders "No image" (or the array's `.toString()`). + - Right: `entryBind("imageurl", "images.0.url")` picks the first asset's URL. + + If the prop should iterate (multiple images), use a **Repeater** bound to the array and bind `imageurl` to `repeater.url` inside the repeater item. The `.0.url` shortcut is for single-image props only. + +## Inputs needed from the user + +Ask in this order. Stop if any required input is missing. + +1. `contentTypeUid`: content type uid (required) +2. `templateName`: human title (required) +3. `urlPattern`: optional override. Leave blank to use CT-derived URL +4. `primarySectionUid`: optional. Leave blank to skip auto-drop and let the user choose interactively + +If the user doesn't know the content-type UID, point them at `app.contentstack.com`, where they open the stack, go to **Content Models**, and click a model. The UID is on the right-rail. + +## Acceptance + +Succeed only when ALL of the following are true. If any fails, surface exactly which step broke and stop. + +- [ ] A new row appears in the Studio Templates list with `templateName` as title and `contentTypeUid` populated in the Connected Content Type column +- [ ] The canvas opens cleanly on reload: preview-entry URL line evaluates without `{{...}}` leftovers +- [ ] Every section dropped is either bound (linked-schema match shown in right panel) or explicitly flagged unbound with the reason +- [ ] Bound props render real values from the auto-picked preview entry. Swapping the preview entry re-renders without errors +- [ ] Save completed without error (top bar reverts from dirty to clean state) +- [ ] Hitting the derived URL on the host app's catch-all route renders the page (a 404 means the catch-all is missing or the URL pattern doesn't match what Studio expects. Do not claim success) +- [ ] **The finished tree is composed of Sections, not one component.** Read the saved composition back and count `section-composition` nodes in `ui` and entries in `linked_sections`. **Both zero means you built a monolith**, the outcome the approved-plan guard at the top of this skill exists to prevent. It is easy to reach without noticing: a page whose whole body is one modular-blocks field invites a single component bound to the array, which renders correctly and passes every check above. Stop, decompose per the approved plan, and rebuild the tree from Sections. See § Post-build structural check below. + +## Post-build structural check + +The acceptance list above is all render-time: a monolithic template passes every line of it. This check is the only one that catches the structural failure, so run it after saving and before reporting success. + +```bash +# Decode the saved template's ui and count what it is actually made of. +curl -s "$STUDIO_API/projects/$PROJECT/compositions/$UID" -H "$CS_AUTH" \ +| python3 -c ' +import sys, json, zlib, base64 +c = json.load(sys.stdin)["composition"] +ui = c["ui"] +ui = json.loads(zlib.decompress(base64.b64decode(ui[5:])) if isinstance(ui, str) and ui.startswith("zlib:") else ui) +n = [] +def walk(x): + if not isinstance(x, dict): return + if x.get("type") == "section-composition" or x.get("metadata", {}).get("compositionUID"): n.append(x) + for a in (x.get("slots") or {}).values(): + if isinstance(a, list): [walk(i) for i in a] +walk(ui) +ls = c.get("linked_sections") +ls = json.loads(ls) if isinstance(ls, str) else (ls or []) +print(f"section-composition nodes: {len(n)} linked_sections: {len(ls)}") +print("MONOLITH — decompose before reporting success" if not n and not ls else "composed of Sections") +' +``` + +Two numbers, and they must agree: every placed Section is both a `section-composition` node in the tree **and** an entry in `linked_sections`. A node without its `linked_sections` entry is the P27 trap: the page renders, the editor misbehaves later. + +**If you are converting an existing monolith,** lift each top-level node into its own Section composition (`place_composition_as: "section"`), then replace the node with a `section-composition` referencing it. The bindings inside the node move with it unchanged and resolve exactly as before. Verify by diffing rendered text length before and after. It should not move. Node uids must stay unique across the tree, so generate them rather than deriving them from a truncated slug. + +## Common pitfalls + +| Pitfall | Why it bites | Fix | +| --- | --- | --- | +| "Template did not load." | URL pattern doesn't match anything the host app's catch-all routes through Studio | Fix the pattern or wire the catch-all. See [template preview routes](../../docs/10-setup/studio-project/template-preview-routes.md). Per-template routes are NOT the fix. The catch-all covers every URL. | +| Bound value renders empty | Current preview entry lacks that field | Swap via `⇄`, don't re-edit the binding | +| Registered component drops blank | Missing default data on the component | Developer needs to add Component Default Data. Not a Studio-side fix | +| Section drops unbound | No `linked_schemas` match on the CT | Add a matching field, edit the section's `linked_schemas`, or bind manually from Settings | +| **One component bound to the page's whole blocks field** | The fastest way to make a page render, and it passes every render-time acceptance check, so nothing flags it. But the page is one node: nothing inside is separately editable, reorderable or reusable in Studio, and the next page repeats the work instead of reusing a Section | Treat a single `*_sections` / modular-blocks field as a **list of Sections to build**, not one prop to bind. Run the post-build structural check above. Two zeros means rebuild from Sections | +| "No element is currently selected" in the right panel | Nothing on canvas is selected | Click the component on canvas first | +| Editing URL from the composition list | That view is read-only for URLs | Edit from the canvas navbar pencil icon | +| Assuming Save with preview entry = X pins the template to X | Bindings record field paths. Preview entry is authoring-only | Treat preview-entry swaps as visual diagnostics, not state | +| Dropping a Rows / Box wrapper before the first real component | Leaves visible "Drop Here" placeholder zones in canvas + screenshots. Rows is an e2e-test scaffolding habit, NOT a Studio authoring pattern. | Drop the first section directly onto the canvas root slot. Wrap in a container ONLY for explicit layout (e.g. `hstack` for two cards side-by-side). | +| Adding a Repeater for a non-list use case | Repeater iterates a multi-valued field. Single-hero + single-body needs zero iteration | Use Repeater ONLY when the field is genuinely a list (Modular Block list, multi-Group, multi-Reference). For one-of-each: drop directly. | +| Leaving empty "Drop Here" zones at the bottom | Saved compositions render those zones as visible placeholders in screenshots and at runtime in builder mode | Before Save, switch to Layers and delete any orphan empty Box / Slot rows | + + + +## Driving the canvas + +Moving a component on the Studio canvas through browser automation has a required event sequence, and the high-level drag helpers do not satisfy it. See [`drive-studio-canvas-ui`](drive-studio-canvas-ui.md) before attempting a drop. + +## See also + +- `docs/31-templates/overview.md`: Templates tab, list columns, create modal +- `docs/31-templates/connected-content-type.md`: URL derivation, preview entry, edit URL modal +- `docs/31-templates/using-sections-and-components-in-a-template.md`: palette categories, auto-binding flow, Save vs Deploy +- `docs/32-sections/binding-to-cms.md`: linked-schema matching rules +- `docs/34-smart-containers/section-slots.md`: filling carved-out slots +- `docs/32-sections/expose-section-props.md`: what becomes overridable per template instance +- `docs/90-reference/url-variables-reference.md`: full URL variable list +- Pair with `install-studio` (SDKs) and `setup-template-preview-routes` (host-app **catch-all route** mounting `<StudioComponent />`, one route handles every URL). diff --git a/skills/studio/build-freeform-template.md b/skills/studio/build-freeform-template.md new file mode 100644 index 0000000..6ebe863 --- /dev/null +++ b/skills/studio/build-freeform-template.md @@ -0,0 +1,147 @@ +--- +name: build-freeform-template +--- + +## When to use + +Author a Freeform (Static) template with Pinned Entries and Pinned Queries. Connected is the default. Freeform is the last resort. Use Studio UI, driven through Playwright per [`drive-studio-canvas-ui`](drive-studio-canvas-ui.md). Don't hand the clicks to the user or switch to the API unless they ask; that's [`author-composition-via-api`](author-composition-via-api.md). + +Use ONLY when the page genuinely cannot be modelled by any content type (not even a single-entry CT). First run `choose-connected-vs-freeform`. If its recommendation is Connected, stop and use `build-connected-template` instead. Reach for this when the page composes from multiple unrelated sources with no anchor CT. Three-AND Freeform rule applies. + +> **Mandatory auth preflight: settle the credential before the first API call.** Resolve it OAuth-first per [`authenticate-cma`](authenticate-cma.md): `CS_OAUTH_ACCESS_TOKEN`, else the Contentstack MCP's stored session: Read that skill for where it is, even with no MCP connected. **Never ask the user for a session `authtoken`.** If nothing resolves, or a refresh fails with `400 invalid_refresh_token`, hand them `! CONTENTSTACK_REGION=<code> npx @contentstack/mcp --auth` (it needs a TTY and a browser, so it cannot be run for them) and wait. `403 error_code 316` is a valid credential aimed at another org: fix the org or the `api_key`, do **not** re-authenticate. + +> **Pattern preflight: mandatory.** Before the first write, run [`match-existing-pattern`](match-existing-pattern.md): read the Templates and Sections already in the project, **reuse what already covers this schema**, and build to the pattern they use. Skip it only when the project has no compositions at all. Authoring something structurally foreign to what is there is rework, and it is invisible until someone opens the Layers panel. + +# Build a Freeform template + +> **Approved plan required.** This skill builds. It does not decide. If there is no plan the user has explicitly approved (Sections with their linked schemas, Section Slots, per-Section components (registered vs built-in), bindings, and the template kind), **stop and run [`plan-studio-architecture`](plan-studio-architecture.md) first**, then come back with the approved block for this piece. Building without it is how a project ends up as one monolithic template with no Sections, no slots and no reuse. Exception: the user is explicitly iterating on a piece already in an approved plan. + +## STOP: Freeform is the very very very last resort + +**Reach for Freeform only after confirming ALL THREE of these are true:** + +1. **Short-lived**: the page has an expected end date (campaign, splash, takeover, time-boxed promo). +2. **Owns ZERO content of its own**: no hero copy, no page-specific title, no subheading, no CTA text, no callouts. Literally nothing the page itself authors. Hero copy IS content. "Throwaway HTML" is owned content in disguise. +3. **Visible content is 100% assembled from existing entries**: pinned entries, pinned queries, references into existing CTs. The page is a pure vitrine. + +If ANY of those is missing (even a hero title specific to this page) the page has owned content. Owned content belongs in a CT, which means **Connected** (single-entry CT for one-offs, multi-entry / `campaign` CT for recurring shapes). Most pages users initially tag as Freeform DO own copy and should be Connected. + +**If you arrived here without running `choose-connected-vs-freeform` or `plan-studio-architecture`, stop and run one of those.** This skill exists for genuine vitrine cases. They are rare. + +## Before you start: confirm Freeform is the right choice + +If you haven't already run `choose-connected-vs-freeform`, do that first. Only proceed when every Connected option (existing CT, extended CT, single-entry CT) has been ruled out. The Project-level **Enable Freeform Feature** toggle is often deliberately OFF. If off, usually leave it off and create a CT. + +**Section-qualification rule still applies.** Before dropping components + bindings directly on this Freeform template, apply the golden rule from [`plan-studio-architecture`](plan-studio-architecture.md) § Step 2: **Reused across Templates?** Build a Section first. **Schema iteration (MB / multi-ref / group-multiple)?** Build a List Section first. Freeform doesn't exempt you from this. A shared Header pinned to two Freeform pages must be a Section, and a Pinned-Query Repeater rendering N items belongs in a List Section, not authored inline. + +## Context + +**Freeform** (labeled **Static Template** in the create modal, **FREEFORM MODE** on canvas, same thing) is not tied to a content type. Bring data in by pinning entries and queries directly on the template. Use only as the documented exception. + +The right panel (nothing selected) surfaces three sections: + +- **Pinned Entries**: explicitly chosen single entries. Bindable roots in the Data Picker. +- **Pinned Queries**: saved CDA queries. Repeater item sources. +- **External Data**: non-Contentstack sources via Component Default Data at runtime. + +Reference docs: `docs/33-freeform/`. + +## Prerequisite + +In Project Configuration, **Enable Freeform Feature** must be ON. Without it, the right panel collapses to **Settings only**, no Design tab, no Data tab, no way to pin. If the user sees only Settings, stop and direct them to flip the feature flag at the project level before continuing. + +## Task + +1. **Confirm Freeform is enabled** on the Studio project. If not, instruct the user to open Project Configuration and toggle **Enable Freeform Feature** on, then resume. + +2. **Create the template.** + - From the Studio templates list, click **New Template**. + - In the Create New Template chooser modal, pick **Freeform** (the chooser's two options are Connected Template and Freeform). The follow-up dialog is titled "Create Static Template". Static is Studio's internal name for Freeform. Same thing. + - Enter the `templateName` from inputs and confirm. + + On the canvas verify: + - A **FREEFORM MODE** badge in the top bar (replaces the content-type chip used by Connected templates). + - A **PREVIEW COMPOSITION:** label (not **PREVIEW ENTRY:**), preview drives off the composition itself. + +3. **Edit the URL pattern.** + - Click the **pencil icon** next to the URL in the canvas navbar to open **Edit URL**. + - Replace the auto-generated default with `urlPattern` from inputs. + - The Insert chip row exposes exactly one entry-style variable: `{{composition_uid}}`. There is intentionally no `{{entry.*}}` and no `{{content_type_uid}}`. Freeform has no connected entry/content-type. You may type context vars (`{{environment}}`, `{{branch}}`) by hand. `{{locale}}` is also accepted by the pattern engine but **avoid using it**. The recommended way to carry locale is via your routing layer + the SDK's `locale` query option (see [multi-locale-at-scale](../../docs/50-advanced/managing-multiple-locales-at-scale.md)). + - Save. + +4. **Open the template-level Data tab.** + - Click empty canvas space to ensure no component is selected (otherwise the right panel switches to component-prop bindings). + - In the right panel, open the **Data** tab and confirm three accordion sections appear: **Pinned Entries**, **Pinned Queries**, **External Data**. Empty state reads "No Data Connected Yet". + +5. **Pin an entry (Pinned Entries).** If `pinnedEntryContentTypeUid` was provided: + - In the **Pinned Entries** section click the empty-state prompt / **Link Entry** button. + - In the entry-picker modal, set the content-type filter to `pinnedEntryContentTypeUid`. + - Use search/sort/checkbox selection in the entries table, then click **Add Selected Entries**. + - The pinned entry now appears with its title + content type and is bindable from any component's Data Picker. + - Re-pinning (swap to a different entry of the same shape) preserves existing bindings. + +6. **Pin a query (Pinned Queries).** If `pinnedQueryContentTypeUid` was provided: + - In the **Pinned Queries** section click **+ New Query**. + - In the **New Query** modal fill **Name** and set **Content Type** = `pinnedQueryContentTypeUid`. + - Either configure filters/order/limit directly, or type a free-text prompt in **"Tell me what data you need"**. Quick-prompt seeds include "Show the last 5 recent entries", "List all entries from the past week", "Filter the entries by specific tags", "Fetch the entries containing taxonomy". The prompt is converted to a stored CDA query at save time. It is not re-translated per render. + - Save. The query appears under **Pinned Queries** with its Name + Content Type. + +7. **Bind components on the canvas.** + - Drop a card, hero, or other component, then in the right panel open the **Data** tab and click a prop. The **Data Picker** opens. Pinned entries appear as bindable roots alongside their fields. + - For lists: drop a **Repeater**, bind **Items** to a Pinned Query, then drop the iteration component inside the Repeater body. Bindings inside resolve against the current iteration (`repeater.<field>`). + - Static prop values and Component Default Data remain available for purely presentational content. + +8. **Verify the rendered route.** The host app's catch-all route (default + recommended: `app/[[...slug]]/page.tsx` for Next.js, `<Route path="*">` for React Router) mounts `<StudioComponent />` and handles every URL, including the freeform template's resolved `urlPattern`. No per-template route is needed. Studio's CDA query inside `sdk.fetchCompositionData` resolves the composition. + + Open the resolved URL in the browser to confirm the pinned content appears. If it 404s, the catch-all isn't wired. Run [`setup-template-preview-routes`](setup-template-preview-routes.md) first. + +## Inputs needed from the user + +In order. Two are optional. Skip the corresponding step if blank. + +1. `templateName`: template display name (required) +2. `urlPattern`: URL pattern. Defaults to `/compositions/{{composition_uid}}` (required). Avoid encoding `{{locale}}`. Carry locale via routing + the SDK `locale` query option. +3. `pinnedEntryContentTypeUid`: optional. Skip step 5 if blank +4. `pinnedQueryContentTypeUid`: optional. Skip step 6 if blank + +If the user is unsure which content types to pin, ask what data the page needs to show (single entry vs. list) and map accordingly. + +## Acceptance + +This skill succeeds only when ALL applicable items below are true. If any fails, do not claim success. Surface the failure and stop. + +- [ ] **FREEFORM MODE** badge visible in the canvas top bar. +- [ ] **PREVIEW COMPOSITION:** label (not PREVIEW ENTRY) shown in the preview row. +- [ ] Edit URL modal shows the `{{composition_uid}}` chip and no `{{entry.*}}` chips. The saved pattern uses only allowed tokens. +- [ ] With nothing selected on canvas, the right panel's Data tab lists all three sections: Pinned Entries, Pinned Queries, External Data. +- [ ] If a pinned entry was requested, it appears under Pinned Entries with its title + content type and is reachable from a component's Data Picker. +- [ ] If a pinned query was requested, it appears under Pinned Queries with its Name + Content Type and is bindable as a Repeater **Items** source. +- [ ] The app route at the resolved URL mounts `<StudioComponent />` and renders the freeform layout with pinned content. (For lists, the Repeater shows real iterations after toggling **Preview Mode** in Properties, the canvas previews one iteration by default.) + +## Common pitfalls + +| Pitfall | Why it bites | Fix | +| --- | --- | --- | +| Right panel shows only Settings | Freeform feature is OFF at project level | Enable it before continuing | +| A component is selected when you wanted template-level pins | Data tab swaps to component-prop bindings | Click empty canvas to deselect | +| Trying to use `{{entry.title}}` or `{{content_type_uid}}` in the URL pattern | Freeform has no entry / no connected content type | Only `{{composition_uid}}` + context vars work | +| "Static Template" (create modal) vs "Freeform Mode" (canvas badge) | Same thing, different label | Treat as one | +| Pinned Entry vs Pinned Query mix-up | A single entry goes in Pinned Entries. A list goes in Pinned Queries (Repeater source). A pinned query re-runs every render. A pinned entry does not refresh as content changes. | Pick by cardinality | +| Pinned query prompt isn't live | Natural-language prompt is converted once at save time | Re-open the query to tune later | +| Repeater renders one row on the canvas | Design Mode preview shows one iteration | Select Repeater in Layers and toggle **Preview Mode** in Properties | +| Template doesn't render at the resolved URL | Catch-all missing, or not mounting `<StudioComponent />`, or pattern doesn't resolve inside `sdk.fetchCompositionData` | Verify all three. The catch-all covers every URL. Per-template routes are NOT needed. | +| Dropping a Rows / Box wrapper before the first real component | Leaves visible "Drop Here" placeholder zones. Rows is an e2e-test scaffolding habit, NOT a Studio authoring pattern. | Drop the first component directly onto the canvas root slot. Wrap in a container ONLY for explicit layout. | +| Adding a Repeater for a non-list use case | Repeater iterates a multi-valued field. One hero + one promo card needs zero iteration | Use Repeater ONLY when the data source is genuinely a list (Pinned Query, multi-entry Pinned Reference) | +| Leaving empty "Drop Here" zones at the bottom | Saved compositions render those zones as visible placeholders | Before Save, switch to Layers and delete any orphan empty Box / Slot rows | + + + +## Driving the canvas + +Moving a component on the Studio canvas through browser automation has a required event sequence, and the high-level drag helpers do not satisfy it. See [`drive-studio-canvas-ui`](drive-studio-canvas-ui.md) before attempting a drop. + +## See also + +- `install-studio`: install SDKs and wire `studioSdk.init` at the app shell. +- `setup-section-preview`: add the `<StudioCanvas />` route for previewing Sections in Studio. +- `docs/33-freeform/freeform-templates.md`, `page-data-tab.md`, `pinned-entries.md`, `pinned-queries.md`: reference documentation. diff --git a/skills/studio/build-page-builder-template.md b/skills/studio/build-page-builder-template.md new file mode 100644 index 0000000..f87aeb1 --- /dev/null +++ b/skills/studio/build-page-builder-template.md @@ -0,0 +1,115 @@ +--- +name: build-page-builder-template +--- + +## When to use + +Assemble the pattern that lets an author **pick and order sections per page**, a page content type whose modular-block field lists the available section types, one Section per block type, and one template that renders whichever blocks the author added, in their order. Use Studio UI, driven through Playwright per [`drive-studio-canvas-ui`](drive-studio-canvas-ui.md). Don't hand the clicks to the user or switch to the API unless they ask; that's [`author-composition-via-api`](author-composition-via-api.md). + +Use when the ask is "authors should build their own pages", "a page builder", "let marketing add sections in any order", "drag sections onto a page", "one template for many different page layouts", or when a design set shows the same page shape recomposed differently per page (a marketing site, a landing-page system). Phrases: "page builder", "section picker", "modular page", "authors choose the sections", "reorder sections". Do NOT use for a page whose sections are fixed in a known order (that's `build-connected-template` dropping sections directly). Do NOT use to build the individual Sections (`build-section`) or decide the primitive library (`design-component-library`). + +> **Mandatory auth preflight: settle the credential before the first API call.** Resolve it OAuth-first per [`authenticate-cma`](authenticate-cma.md): `CS_OAUTH_ACCESS_TOKEN`, else the Contentstack MCP's stored session: Read that skill for where it is, even with no MCP connected. **Never ask the user for a session `authtoken`.** If nothing resolves, or a refresh fails with `400 invalid_refresh_token`, hand them `! CONTENTSTACK_REGION=<code> npx @contentstack/mcp --auth` (it needs a TTY and a browser, so it cannot be run for them) and wait. `403 error_code 316` is a valid credential aimed at another org: fix the org or the `api_key`, do **not** re-authenticate. + +> **Pattern preflight: mandatory.** Before the first write, run [`match-existing-pattern`](match-existing-pattern.md): read the Templates and Sections already in the project, **reuse what already covers this schema**, and build to the pattern they use. Skip it only when the project has no compositions at all. Authoring something structurally foreign to what is there is rework, and it is invisible until someone opens the Layers panel. + +# Build a page-builder template + +## Context + +A Connected template normally places a fixed set of Sections. That is correct when every page of that type looks the same. It is wrong the moment marketing wants a hero on one page, a hero plus comparison table on another, and the same three sections in a different order on a third, because each variation then needs its own template. + +The page-builder pattern moves that choice into content: + +``` +Page CT (alpha_page) + page_sections : Modular Block + ├── hero_centered { heading, description, cta_1, media } + ├── benefits_section { heading, benefits[] } + └── comparison_table { … } + +One Section per block type, each linked to alpha_page.page_sections.<block_uid> + +Template (connected to alpha_page) + repeater [items → page_sections] + condition-block {modular_block = hero_centered} → section-composition (Hero Centered) + condition-block {modular_block = benefits_section} → section-composition (Benefits) + condition-block {modular_block = comparison_table} → section-composition (Comparison) +``` + +The author adds blocks to `page_sections` in any order. The Repeater walks them. Each Condition Block matches one block type and renders the Section built for it. **One template renders every page of that type, whatever the author composed.** + +Verified against a production Studio site: one template, 98 nodes, **30 Condition Blocks**, 30 section types an author can choose from, with no second template. + +## Task + +### Step 1: Model the page content type + +One Modular Block field holds the page body. Each block type is one section's content shape. + +- Field uid: something neutral (`page_sections`, `body`, `blocks`). It is the page's spine, not a section name. +- One block per section type. The block's sub-fields are exactly what that section binds: `heading`, `description`, `cta_1`, `media`. +- Do **not** add design fields (`gap`, `align`, `variant`). Those are static values on the canvas. See [`design-component-library`](design-component-library.md) § content binds, design stays static. + +Schema work belongs to [`provision-studio-stack`](provision-studio-stack.md) (new CT) or [`migrate-ct-schema`](migrate-ct-schema.md) (existing CT). + +### Step 2: Build one Section per block type + +Each Section links to that **specific block**, not the Modular Block field: + +``` +linked schema: alpha_page . page_sections . hero_centered + CT MB field block uid +``` + +`build-section` calls this the **Block** linked-schema kind: the block shape is fixed, so the Section is single (no Repeater at this level). Its bindings then read the block's own fields directly, `text → heading`, `href → cta_1.url`. + +Build each Section from the registered primitive library ([`compose-marketing-section`](compose-marketing-section.md)), not from bespoke components. That is what makes 30 sections share one palette. + +### Step 3: Assemble the template + +On a Connected template bound to the page CT: + +1. Drop a **Repeater**, bind `items` to the Modular Block field (`page_sections`). +2. Binding `items` opens **Add Condition**: tick every block type and click **Add** (not Skip). Studio inserts **one Condition Block per block type**, siblings, not nested. Each carries `condition.type: "modular_block"` and the block uid as its value ([`use-condition-block`](use-condition-block.md)). +3. Drop each block type's **Section** into the Box inside its Condition Block. Dropping a Section never creates a condition. + +The Condition Block is what makes a polymorphic list resolve: without it the iteration item has no known schema and every binding inside renders empty. + +### Step 4: Verify with a real entry + +Create one entry that uses **several different block types, out of order**, and confirm: + +- Each block renders its own Section, in the author's order. +- A block type with no Condition Block renders **nothing, silently** (the most common defect here). Enumerate the CT's block types against the template's Condition Blocks and confirm the counts match. +- Adding a new block to the entry needs no developer. + +## Acceptance + +- [ ] Page CT has one Modular Block field holding the page body. No design props among its sub-fields. +- [ ] One Section per block type, each linked at `<ct>.<mb_field>.<block_uid>` (Block kind, not the MB field). +- [ ] Template has exactly one Repeater bound to the Modular Block field. +- [ ] One Condition Block per block type, as siblings under the Repeater: **count matches the CT's block count**. +- [ ] Each Condition Block contains the Section for its block type. +- [ ] An entry mixing several block types out of order renders each in the author's order. +- [ ] Adding a block type end-to-end touches three places: the CT (new block), then a new Section, then a new Condition Block. No template rewrite. + +## Common pitfalls + +| Pitfall | Why it bites | Fix | +| --- | --- | --- | +| One template per page layout | The variation is content, not structure: N templates all rendering the same components, each needing the same fix N times | One template, one Repeater, Condition Blocks per block type | +| Section linked to the Modular Block **field** instead of a **block** | The field is a list of mixed shapes, so bindings cannot resolve to one block's sub-fields | Link at `<ct>.<mb_field>.<block_uid>`, the Block kind in `build-section` | +| A block type with no Condition Block | That block renders **nothing**, with no error. Authors report "my section disappeared" | Enumerate CT block types vs template Condition Blocks. Counts must match | +| Authoring the iteration inside a Section instead of on the template | The Section then owns the whole page body and cannot be reused per block | Repeater lives on the template. Each Section handles exactly one block | +| Design fields (`gap`, `align`, `variant`) added to blocks so authors can "adjust" | Schema becomes unreadable and pages drift visually per entry | Design is a static value on the canvas | +| Bespoke components per block type | 30 blocks become 30 one-off components and the palette is unusable | Compose every Section from the shared primitive library | + +## See also + +- [`design-component-library`](design-component-library.md): decide the primitives before building 30 Sections from them. +- [`compose-marketing-section`](compose-marketing-section.md): build each Section from those primitives. +- [`build-section`](build-section.md) § linked-schema kinds: the **Block** kind this pattern depends on. +- [`use-condition-block`](use-condition-block.md): the discriminator metadata on each branch. +- [`use-repeater`](use-repeater.md): the iteration over the Modular Block field. +- [`build-connected-template`](build-connected-template.md): the template this pattern is assembled on. +- [`decompose-design`](decompose-design.md) / [`decompose-site`](decompose-site.md): deriving the block list from designs. diff --git a/skills/studio/build-repeating-section.md b/skills/studio/build-repeating-section.md new file mode 100644 index 0000000..1a120f3 --- /dev/null +++ b/skills/studio/build-repeating-section.md @@ -0,0 +1,220 @@ +--- +name: build-repeating-section +--- + +## When to use + +Build a Section that renders a list of cards/tiles: parent Section with Repeater + Section Slot, plus child Section bound to the iterated item. Use Studio UI, driven through Playwright per [`drive-studio-canvas-ui`](drive-studio-canvas-ui.md). Don't hand the clicks to the user or switch to the API unless they ask; that's [`author-composition-via-api`](author-composition-via-api.md). + +Use when a Section renders N cards/tiles from a multi-valued field (Reference, Modular Block, Group multi, multi-Reference). Phrases: "card grid", "list of N items", "iterate related posts". Do NOT use for single-instance compositions (use `build-section`). For nested repeaters, see `author-composition-via-api` § Recipe: Nested Repeaters (hand-author via API. Studio's UI can't author that shape). + +> **Ask before adding the Repeater.** For a multiple group, global field, file or link field, ask the user "every item, or one item?", as `build-section` does. Never decide it yourself; if Studio's Add Repeater box is open, click Add or Skip only after they answer. + +> **Auth preflight: settle the credential before the first API call.** Resolve it OAuth-first per [`authenticate-cma`](authenticate-cma.md): `CS_OAUTH_ACCESS_TOKEN`, else the Contentstack MCP's stored session: Read that skill for where it is, even with no MCP connected. **Never ask the user for a session `authtoken`.** If nothing resolves, or a refresh fails with `400 invalid_refresh_token`, hand them `! CONTENTSTACK_REGION=<code> npx @contentstack/mcp --auth` (it needs a TTY and a browser, so it cannot be run for them) and wait. `403 error_code 316` is a valid credential aimed at another org: fix the org or the `api_key`, do **not** re-authenticate. + +# Build a repeating Section: parent + child pattern + +> **Approved plan required.** This skill builds. It does not decide. If there is no plan the user has explicitly approved (Sections with their linked schemas, Section Slots, per-Section components such as registered vs built-in, bindings, and the template kind), **stop and run [`plan-studio-architecture`](plan-studio-architecture.md) first**, then come back with the approved block for this piece. Building without it is how a project ends up as one monolithic template with no Sections, no slots and no reuse. Exception: the user is explicitly iterating on a piece already in an approved plan. + +## Context + +The canonical "repeat the same card per item" pattern (product grids, blog lists, related items), **two Sections working together**: + +- A **parent Section** owning the layout container, Repeater, and one Section Slot inside the iteration. +- A **child Section** bound to the iterated item's CT, dropped into the parent's Slot at template-authoring time. Bindings come along. No per-prop clicks. + +This is **Path B** from `build-section`. See `build-section` § Path A vs Path B. + +Composes: `build-section` (child + parent frame), `use-repeater`, `use-section-slot`, `understand-section-slots`. + +## When to skip the Repeater entirely: the array-prop alternative + +Before reaching for the parent+child+Repeater pattern, check whether a **single registered component with an array prop** would work. If the list rendering is straightforward (no per-iteration variant authoring, no template-author swap) and the items come from a single **Reference field**, this path is simpler AND avoids the Repeater + Condition Block schema-disambiguation dance entirely. + +The pattern: + +1. **Register a component** with an `array`-typed prop (e.g. `cards: { type: "array" }`). +2. **Bind that prop to a Reference field** on the section's linked schema. +3. **Populate `data_sources.resolvedReferences`** in the composition. This tells the SDK to include the referenced entries via CDA `?include[]=<ref_field>`, so the bound array arrives at the component as **full resolved entry objects**, not `{uid}` stubs. +4. **The component does its own `.map()`** to render each item, no Studio Repeater, no Condition Block, no iteration scope inside Studio. + +```tsx +// Registered component +registerComponent({ + type: "doc-card-list", + component: CardList, + props: { + cards: { type: "array" }, // <-- bound to a multi-Reference field on the section's linked schema + }, +}); + +function CardList({ cards }: { cards: Array<{ title: string; url: string }> }) { + return ( + <ul> + {cards.map((c) => <li key={c.url}><a href={c.url}>{c.title}</a></li>)} + </ul> + ); +} +``` + +**Why this works:** the SDK's data-binder handles `type: "array"` bindings by reading the bound array directly. When `resolvedReferences` includes the field, the array arrives populated with full entries. The component renders them with its own JSX. No iteration scope means no need for a Condition Block to disambiguate per-iteration item schemas. + +**When the Repeater pattern is still right:** + +- Author needs to **swap a different child Section per template instance** via the Section Slot (the whole reason the Repeater + Section Slot pattern exists). +- The list iterates a **Modular Block** (polymorphic: different block types need different rendering. The Repeater + Condition Block sibling chain is built for this). +- The Studio canvas should show **real iteration preview** of N rendered child Sections (Preview Mode). +- The same iteration shape is reused across multiple Sections. + +**When the array-prop pattern wins:** + +- The list is a **single Reference field** (or single multi-entry pinned query) with one rendering shape. +- The component is owned by engineering and won't change per template instance. +- You want to skip the Repeater UI overhead and the schema-disambiguation requirement. + +If unsure, default to the Repeater pattern documented below. It's the canonical Studio path and surfaces variability to authors. The array-prop alternative is a known-good shortcut for reference-list cases where author variability isn't needed. + +## Prerequisite: the canvas chain must be wired + +This is a canvas operation. If the Section canvas is blank, run `setup-section-preview` first. + +## Task + +### Step 1: Build the child Section first + +If `childSectionName` doesn't exist yet, build it now via `build-section`: + +- Linked schema: `childLinkedSchema` +- Drop registered components for the card visual. Bind their props via Data Picker against the child's linked schema +- Slot-candidate scan: expose Slots for any Global Field / Modular Block / Group / Reference fields (see `understand-section-slots`) +- Components stay **layout-agnostic**, see `register-component` § Layout contract + +Gate: open the child standalone. It must adapt at any canvas width. If it renders full-bleed or collapses, fix the child's layout before continuing. + +### Step 2: Create the parent Section frame + +In Studio, open the Sections tab, then click + New Section. Name it `parentSectionName`. + +Link the parent to `parentLinkedSchema` (the CT / Global Field that holds `iterationSourceField`). Use the same Connect-A-Schema flow `build-section` describes. + +### Step 3: Drop a layout container Box first + +**Don't drop the Repeater bare onto the Section root**: without a layout container the Slot gets a full-canvas-width cell (see `use-section-slot` § Layout container). Drop a **Box** onto the Section root, configure per `layoutShape`: + +| `layoutShape` | Box CSS | +|---|---| +| `grid` | `display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 24px;` | +| `flex-row` | `display: flex; flex-wrap: wrap; gap: 16px;` with `flex-basis: calc(33% - 16px)` on the child cell | +| `vertical-list` | `display: flex; flex-direction: column; gap: 16px;` | +| `horizontal-scroll` | `display: flex; gap: 16px; overflow-x: auto; scroll-snap-type: x mandatory;` with `flex: 0 0 280px` on the cell | + +The Box defines the cell size. The Slot will sit inside the Repeater inside this Box, so each iteration fills one grid track / flex item / list row. + +### Step 4: Drop a Repeater inside the Box + +Drag **Repeater** from the palette's Smart Containers category into the Box. + +Bind the Repeater's **Bind items** property to `iterationSourceField` via the Data Picker. The picker shows the parent Section's linked-schema fields. Pick the multi-valued field (the picker disables scalars for this slot). + +For multi-reference / Modular Block iteration, see Step 5 (Condition Block). For `group` / `array` / `pinned-query`, skip to Step 6. + +### Step 5: Wrap in a Condition Block (when required) + +If `iterationItemKind` is `reference` (single-CT or multi-CT) or `modular-block`, the Repeater's immediate child must be a **Condition Block** (Studio schema requirement); for Visual Editor's item controls, those may sit inside one Box that is the Repeater's only child ([`make-visual-editor-editable`](make-visual-editor-editable.md) § 2). Two ways: + +- **Preferred**: drop a Section Slot inside the Repeater (Step 6), then when you later drop the child Section into the Slot at template time, Studio surfaces an inline hint that this iteration source needs a Condition Block. Accept the wrap and Studio inserts it for you. +- **Manual**: drop a Condition Block inside the Repeater first. Then drop the Section Slot inside the Condition Block. + +For `group` / `array` / `pinned-query`, no Condition Block is needed. + +### Step 6: Drop exactly ONE Section Slot inside the Repeater (or its Condition Block) + +Drag a **Section Slot** from the palette into the Repeater (or the Condition Block from Step 5). One Slot, not multiple. + +Set the Slot's **Drop placeholder label** to something intent-revealing: `Drop ${childSectionName} here` (e.g. `Drop ProductCard here`). The label is the only contract template authors have with this Section. + +**Do not** drop the child Section directly into the Slot at this point. The Slot is filled at template-authoring time (Step 8). In the parent Section's own canvas, the Slot is an inert labeled placeholder. + +### Step 7: Save the parent Section + verify on its own + +Save. In Layers, the tree should read: + +``` +Section "<parentSectionName>" +└── Box (layoutShape container) + └── Repeater (bound to <iterationSourceField>) + └── (Condition Block, if reference/modular-block) + └── Section Slot (label: Drop <childSectionName> here) +``` + +In the parent Section's own canvas (with no template open), the Repeater shows one placeholder iteration containing the empty Slot. This is normal. Fills happen at template / parent-of-parent time. + +### Step 8: Drop the child Section into the Slot at usage time + +When the parent Section is dropped on a Template (or in another Section's Slot), each iteration shows the empty Slot. Drag `childSectionName` from the Sections palette into the Slot. + +What happens: +- Studio drops the child Section into the Slot +- Section-level auto-binding kicks in: the child's linked schema (`childLinkedSchema`) is matched to the Repeater's iteration scope. The child Section's bindings resolve against each iterated item. +- Zero manual binding clicks. The child Section is pre-bound by virtue of being a Section. + +If the iteration source is a single-CT / multi-CT Reference (or Modular Block) and you didn't add a Condition Block manually in Step 5, Studio prompts you to wrap. Accept. + +### Step 9: Toggle Preview Mode to verify + +Select the Repeater, open **Configuration** in the right panel, and toggle **Preview Mode** on. See `use-repeater` step 7. The canvas should render N iterations with real per-item data. + +Troubleshooting: +- Wrong iteration count: re-check the Step 4 Repeater binding. +- Same value across iterations: the child's linked schema doesn't match the iterated item's CT. +- Card full-bleed: the parent Box (Step 3) doesn't define cell size. + +## Inputs needed from the user + +Collect in order. Don't guess `iterationSourceField` or `iterationItemKind`. + +1. `parentSectionName`: the grid / list container name +2. `parentLinkedSchema`: the CT / Global Field the parent binds to +3. `iterationSourceField`: the multi-valued field on that schema +4. `iterationItemKind`: reference / modular-block / group / array / pinned-query +5. `childSectionName`: the card / tile Section (built in Step 1 if missing) +6. `childLinkedSchema`: what the child binds to (should match the iterated item's CT) +7. `layoutShape`: grid / flex-row / vertical-list / horizontal-scroll + +## Acceptance + +This skill succeeds only when ALL are true: + +- [ ] The child Section exists, is layout-agnostic, and renders correctly standalone at any width. +- [ ] The parent Section exists with this nesting: a Box inside the Section, a Repeater inside the Box, one item Box inside the Repeater, a Condition Block inside that where one is needed, and a Section Slot innermost. A Section Slot directly under the Repeater drops the item's Visual Editor tag ([`make-visual-editor-editable`](make-visual-editor-editable.md) § 2). +- [ ] **BOTH Sections (child and parent) have a `ui_preview`**, a blank tile in the Sections accordion otherwise. UI-created sections get one from Studio on Save. API-created ones need `tsx scripts/make-ui-preview.ts --entry <uid>`. See [`author-composition-via-api` § Section thumbnails](author-composition-thumbnails.md#section-thumbnails-ui-preview). +- [ ] The parent Section's Box has explicit layout CSS for `layoutShape` (grid tracks, flex with basis, etc.) defining each cell's size. +- [ ] The Repeater is bound to `iterationSourceField` via the Data Picker (confirmed by inspecting the Bind items breadcrumb). +- [ ] Exactly ONE Section Slot sits inside the Repeater (or its Condition Block). +- [ ] When the parent Section is dropped on a template and the child Section is dropped into the Slot, **no manual prop binding** is needed. The child binds automatically at Section level. +- [ ] Preview Mode on the Repeater renders N iterations of the child Section, each iteration's bindings showing the corresponding item's data. +- [ ] No iteration renders full-bleed (parent Box defines the cell size correctly). + +## Common pitfalls + +| Pitfall | Fix | +| --- | --- | +| Dropping a raw registered card into the Slot | Build it as a Section first. Drop the Section. See `understand-section-slots`. | +| Skipping the Box (Step 3), Repeater bare on Section root | Wrap Repeater in a sized Box. See `use-section-slot` § Layout container. | +| Multiple Section Slots inside the Repeater | Exactly ONE Slot per Repeater. Put multi-region content inside the child. | +| Skipping Condition Block for Reference / Modular Block iteration | Accept Studio's inline wrap prompt, or insert Condition Block manually (Step 5). | +| Binding from parent's linked-schema root inside the Repeater | Inside Repeater, picker shows "Repeater Data". Bind from that root. See `use-repeater` § The scope rule. | +| Child depends on layout ancestor (`.fs-grid > .fs-card`) | Keep child layout-agnostic. See `register-component` § Layout contract. | +| Empty Slot in Design Mode mistaken for binding error | Toggle Preview Mode. See `use-repeater` step 7. | +| Preview Mode on but Repeater still empty | Toggle isn't the issue: diagnose binding (Step 4), source field has items, Condition Block in place (Step 5), child schema matches. See `troubleshoot-data-binding`. | +| Monolithic Section (Path A) when card is reusable | Use Path B, see `build-section` § Path A vs Path B. | + +## See also + +- `build-section`: for both the parent and child Sections. Covers Path A vs Path B +- `use-repeater`: for the Repeater step +- `use-section-slot`: for the Slot step +- `use-condition-block`: for Reference / Modular Block iteration +- `understand-section-slots`: what fills a Slot (a Section, not a raw component) +- `register-component`: the layout-agnostic contract for components inside the child Section +- `byoc-end-to-end`: the broader BYOC arc this skill fits into diff --git a/skills/studio/build-section-component-choice.md b/skills/studio/build-section-component-choice.md new file mode 100644 index 0000000..fa8b305 --- /dev/null +++ b/skills/studio/build-section-component-choice.md @@ -0,0 +1,56 @@ +--- +name: build-section-component-choice +--- + +## When to use + +Decide whether a Section uses Studio's basic field components or your own registered components: the simple-versus-styled tradeoff, and what each choice costs later. + +Part of the Section-authoring set. Start at [`build-section`](build-section.md). + +## Basic field components vs custom registered components: the simple-vs-styled tradeoff + +Studio palettes ship two kinds of nodes you can drop into a Section. **All editing happens in the right panel. Neither kind supports inline-typing in the canvas.** The real tradeoff is shape and styling: + +| | **Basic field components** (palette: Basic): `Heading`, `Paragraph`, `Text`, `Image`, `Link`, `RichText`, etc. | **Custom registered components** (palette: Registered Components): your `<Hero>`, `<ProductCard>`, etc. | +|---|---|---| +| Shape | Single value (a string for `Heading` / `Paragraph` / `Text`, a URL for `Image`, a JSON tree for `RichText`). The author sets it via the right panel: either as a **static value** typed inline, or as a **binding** to a CT field. | N props as declared in `registerComponent` schema. Each prop set via the right panel: static value or binding to a CT field. | +| Visual styling | Plain HTML by default: `<h1>`, `<p>`, `<img>`. Styling comes from node `styles.default.responsiveStyles.default` set via the Design panel, OR app-side CSS targeting the rendered DOM. RTE embeds use the default serializer unless you register a custom one (see `register-json-rte`). | Whatever your React + CSS produces. Pixel-perfect. Uses your design system. | +| Layout authoring | Composable via Basic `Box` + node `responsiveStyles` (see pitfall row below). | Owned by the React component code. | +| Authoring UX | Quick: drop, type a value or bind a field in the right panel, done. Good for editorial copy that lives ONLY in the composition (not modelled in the CMS). | Higher-fidelity: drop a fully-styled block, bind props to CT fields. Right-panel surface is exactly the prop schema you registered. | +| Best for | Headlines, body copy, captions, CTA labels, RTE blocks, wherever literal copy can live in the composition itself OR map 1:1 to a CT field with no surrounding shell. | Marquee / styled / interactive sections: hero, carousel, 3D viewer, anything pixel-sensitive or with multi-field internal structure. | + +**These are two render paths.** The local standalone page (custom `Hero`) and the Studio composition (Basic `Heading` + `Paragraph`) will NOT look identical by default. Treat the gap as design work, not a bug. + +**Recommended pattern: hybrid.** Use both inside one Section: + +- **Custom registered components** for the styled / interactive shells: the hero with its background, the carousel with its animations. +- **Basic field components** for editorial copy that doesn't need a custom shell (headline, intro paragraph, footnote) or for repeated content blocks where authors should be able to swap copy quickly via the right panel without engineering ever touching the codebase. +- **Match the Basic-component styling to your brand** by setting node `responsiveStyles` via the Design panel (typography, spacing, color), or write CSS that targets the rendered semantic DOM. For RTE, register a custom renderer (`register-json-rte`). + +Decision rule: + +- Is THIS region a single string / scalar value that maps 1:1 to a CT field (or is composition-local copy)? Use a Basic field component. +- Is THIS region a multi-prop / styled / interactive piece? Use a custom registered component. +- Both, in the same Section, is normal and good. + +### Migrating an existing component: register it whole, or decompose it? + +When a Section is being built from JSX that already exists, there's a prior question the rule above doesn't answer: does the whole `<Hero>` become **one registered component**, or does it get **decomposed into built-ins** (`Box` + `Image` + `Heading` + `Paragraph` + `Link`) that reproduce the same markup? + +A Section made of one registered component is a **black box to authors**: they can re-bind its props and nothing else. No restyling, no reordering its internals, no removing the subtitle. That's often the right trade, but it must be a stated choice, not a default that happens because registering was easier. + +| Signal | Verdict | +|---|---| +| Interactive: local state, event handlers, cookies, personalization, modals, carousel logic | **Must be one registered component.** Studio built-ins can't express behavior. Not a judgment call | +| Pixel-sensitive: bespoke CSS, animations, `::before` layering, container queries | **One registered component.** Decomposing means rebuilding the styling from Design-panel values, and 1:1 fidelity is unlikely | +| Static presentational: image + heading + body + link in a layout | **Decompose into built-ins.** Authors get full in-Studio editing: restyle, reorder, delete, add. This is the case that most often gets black-boxed by mistake | +| Styled shell wrapping editable content | **Hybrid**: register the shell with a `slot` prop, fill the slot with built-ins. Best of both. See `register-component` § slot prop | + +**Surface the choice. Don't just pick.** For any Section that isn't forced by the first two rows, tell the user which way you're going and what it costs them: + +> `Hero` is static presentational. I can either register it as one component (exact markup, authors can only re-bind props) or decompose it into Studio built-ins (authors can restyle and restructure in Studio, but I rebuild the styling and exact markup fidelity isn't guaranteed). Defaulting to **decompose** for author editability. Say the word if you want 1:1 fidelity instead. + +Silence is not agreement. If the plan is to black-box a static component, that line goes in the plan explicitly with its cost. + +**Binding fields is a separate axis, and it is never optional.** A registered-component Section still binds real CT fields to its props (`image.url`, `headline`, `body`). Black-box refers to author editability of structure, not to whether data is wired. Likewise a **Section Slot** is a third, unrelated concept, a swap point where a template author drops a different Section per instance. Add one only when a region genuinely needs per-instance swapping. A fixed hero doesn't. See [`understand-section-slots`](understand-section-slots.md). diff --git a/skills/studio/build-section-pitfalls.md b/skills/studio/build-section-pitfalls.md new file mode 100644 index 0000000..37085fa --- /dev/null +++ b/skills/studio/build-section-pitfalls.md @@ -0,0 +1,34 @@ +--- +name: build-section-pitfalls +--- + +## When to use + +The pitfall table for authoring a Section: what fails silently on the canvas, why, and the fix. Read before reporting a Section done. + +Part of the Section-authoring set. Start at [`build-section`](build-section.md). + +## Common pitfalls + +| Pitfall | Why it bites | Fix | +| --- | --- | --- | +| Trying to link the section to a scalar field (`title`, `description`, `image`) | Studio's picker doesn't offer scalar fields: a section composes UI against a shape, not a value | Pick the parent Group / Global Field / CT / Block. Expose individual props later via Expose Section Prop | +| Linking to a multi-variant schema without dropping a Repeater | Section composes against the whole list as one shape. Renders only the first item or fails to bind | When `isMultiple=y`, drop a Repeater bound to the array path and place components inside it | +| Picking Modular Block instead of a specific Block type | Bindings are typed against the union of block shapes: most fields don't resolve | If the section is for one block type, pick `block` and select that specific block. If the section handles many, use Repeater + Condition Block per block type | +| Skipping Connect A Schema | Section is static-only. Won't auto-bind on templates. Authors wire every value by hand | Pick a structural schema. Only skip when you know the section is purely presentational | +| **Binding a prop to a field that isn't in the Data Picker** (forcing a path, keeping a stale binding to a removed field) | The bind resolves to `undefined` at runtime, so the component falls back to its registration `defaultValue`. The canvas shows plausible-looking placeholder content, so the break is invisible until production renders empty. | Field-existence gate (Step 5d): only bind fields the picker offers. When the field is missing, fix the linked schema or the CT (`migrate-ct-schema`). Never force the bind. | +| **A whole-array/object component prop with no multi-valued field on the CT** (e.g. `Article Grid` bound to `articles` on a CT that has no `articles` reference) | Nothing to bind the collection to, so the component renders its default array forever (`ARTICLE ONE / TWO / THREE`). Looks like it "works" in Design Mode. | Collection-source gate (Step 5e): add the multi-Reference / Modular Block / multiple Group, or pin a query, or decompose to Repeater + atomic child Section (`build-repeating-section`). | +| **Reading Design-Mode placeholder as real bound data** | Design Mode renders `registerComponent` defaults. A broken bind and a working bind look identical there. Ship it and production is empty. | Always verify in **Preview Mode** (Step 5f). If Preview still shows the generic defaults, the bind didn't resolve. Re-check field existence + source. | +| Dropping Studio default components instead of Registered Components | No brand consistency. Section ignores the project's component library | Switch palette category to **Registered Components** before dragging | +| Skipping the Expose Props step on Save | Template authors get a locked section, no value overrides possible | Toggle the props that should be overridable in the Expose Props modal | +| Assuming the Section is broken because the canvas shows defaults, not CMS values | Design Mode renders registration defaults. Preview Mode renders real bindings. See `use-repeater` for the two-mode model. | Toggle Preview Mode in Properties, then Configuration. | +| Binding inside a Repeater without a Condition Block on references / modular blocks | Iteration items are polymorphic. Bindings silently fail | Wrap with Condition Block (single-CT references included) | +| Picking from the Section's linked-schema root for a prop that sits inside a Repeater | Wrong scope: every iteration shows the same value (the parent's field), not the per-item value | Inside a Repeater, the Data Picker switches to the iteration item's fields automatically. Bind from that root | +| Naming the section "Section 1" / "New Section" | Authors cannot tell sections apart in the palette | Use an intent-revealing name like `Hero Strip`, `Card Grid`, `Testimonial Card` | +| Dropping a **Rows / Box wrapper** before the first real component | Leaves a visible "Drop Here" placeholder zone in the canvas (and in every screenshot) that authors must clean up later. Rows is an e2e-test scaffolding habit, NOT a Studio authoring pattern. | Drop the first component directly onto the canvas root slot. Wrap in a container ONLY when you need explicit layout (e.g. an `hstack` to put two cards side-by-side). | +| Adding a **Repeater for a single, non-list use case** | Repeater iterates a multi-valued field. A static page with one Hero + one Product Card needs zero iteration. Adding one introduces a phantom iteration scope and shifts the Data Picker so the parent Section's fields aren't reachable from inside. | Use Repeater ONLY when the linked field is genuinely a list (Modular Block list, multi-Group, multi-Reference, multi-entry pinned query). For a single hero or single card: drop the component directly. | +| Path A when the card visual may be reused elsewhere | Locks the card into one parent. Reuse contexts drift out of sync | Default to Path B (see § Path A vs Path B). | +| Slot inside Repeater without a sized layout container | Child stretches full canvas width | Wrap in grid/flex/`max-width` Box. See `use-section-slot` § Layout container. | +| Leaving empty "Drop Here" zones at the bottom of the canvas | Saved compositions render those zones as visible placeholders in screenshots and to authors browsing the section gallery. | Before Save, switch to Layers and delete any orphan empty Box / Slot rows. Acceptance: the Layers tree contains ONLY the components you intended. No orphan structural wrappers remain. | +| **Typing a Tailwind (or any scanned-utility) class into the Design panel and seeing no effect**: the class is on the element in DevTools with no rule behind it | Tailwind emits CSS only for classes it finds while scanning source files. A composition is JSON in Contentstack, not a file in the repo, so a class that exists only in the Design panel is never generated. Identical symptom to a typo'd class name. | Keep load-bearing utilities in source: put the layout on a registered component, or list the class in Tailwind's `safelist` so it is always emitted. Reach for the Design panel for values the design system already ships. | +| **Assigning a CSS class to a Basic `box` and expecting `display: flex/grid` to apply**: the box renders but the children stack vertically (no flex) or single-column (no grid). | The Basic `Box` component is just `<div {...rest} {...studioAttributes}>`. Studio renders box layout from the node's **`styles.default.responsiveStyles.default`** (set via the Design panel on the node) and surfaces it through the renderer's style pipeline. An external CSS class on the app side has no node-level styles to attach to and only applies whatever rules its stylesheet defines. It does NOT make Studio's renderer emit `display: flex`. | Author Basic-box layout via node `responsiveStyles` in the Design panel: select the Box, open the Design tab, then set `display`, `gap`, `alignItems`, `flexDirection`, etc. The renderer writes these into the DOM. Reach for a CSS class only for tokens/colors/typography already wired through your design system, never for the load-bearing layout shape. (Custom registered components are different: they can apply layout via their own React/CSS.) | diff --git a/skills/studio/build-section.md b/skills/studio/build-section.md new file mode 100644 index 0000000..faece53 --- /dev/null +++ b/skills/studio/build-section.md @@ -0,0 +1,196 @@ +--- +name: build-section +--- + +## When to use + +Author a Section composition by linking it to a structural schema (CT / Global Field / Group / Modular Block / Block / Reference) and dropping registered components. + +Use when a NEW reusable Section must exist for a Template to drop. Run AFTER components are registered, before Template work. Phrases: "build a hero section", "create card grid". Do NOT use to MODIFY an existing Section. Use Studio UI, driven through Playwright per [`drive-studio-canvas-ui`](drive-studio-canvas-ui.md). Don't hand the clicks to the user or switch to the API unless they ask; that's `author-composition-via-api`. Do NOT use for one-off page composition. Use `build-connected-template`. + +> **Auth preflight: settle the credential before the first API call.** Resolve it OAuth-first per [`authenticate-cma`](authenticate-cma.md): `CS_OAUTH_ACCESS_TOKEN`, else the Contentstack MCP's stored session: Read that skill for where it is, even with no MCP connected. **Never ask the user for a session `authtoken`.** If nothing resolves, or a refresh fails with `400 invalid_refresh_token`, hand them `! CONTENTSTACK_REGION=<code> npx @contentstack/mcp --auth` (it needs a TTY and a browser, so it cannot be run for them) and wait. `403 error_code 316` is a valid credential aimed at another org: fix the org or the `api_key`, do **not** re-authenticate. + +> **Pattern preflight: mandatory.** Before the first write, run [`match-existing-pattern`](match-existing-pattern.md): read the Templates and Sections already in the project, **reuse what already covers this schema**, and build to the pattern they use. Skip it only when the project has no compositions at all. Authoring something structurally foreign to what is there is rework, and it is invisible until someone opens the Layers panel. + +# Build a Section + +> **Approved plan required.** This skill builds. It does not decide. If there is no plan the user has explicitly approved (Sections with their linked schemas, Section Slots, per-Section components (registered vs built-in), bindings, and the template kind), **stop and run [`plan-studio-architecture`](plan-studio-architecture.md) first**, then come back with the approved block for this piece. Building without it is how a project ends up as one monolithic template with no Sections, no slots and no reuse. Exception: the user is explicitly iterating on a piece already in an approved plan. + +## Context + +A Section is a reusable composition that template authors drop onto pages. + +**Qualification gate: before authoring, confirm this Section qualifies.** A Section pays off when **the binding work amortizes**, i.e., when the same N bindings would otherwise be repeated by the author on every template drop. Qualifies if either: + +1. **The component has ≥2 separate prop bindings**: Hero with `title` + `cover.url`, Card with `image` + `title` + `summary` + `link`, ProductHighlight composed of multiple sub-components each with their own bindings. Without a Section, the author re-wires every prop on every drop. +2. **The component takes one prop bound to a structural shape**: Modular Block list, Reference array, Group, Global Field. The outer bind is 1 click, but the internal rendering walks N sub-fields per iteration. That sub-field work amortizes. + +A Section does **NOT** qualify for: + +- **A single atomic component bound to a single primitive (scalar) field**: `Heading` bound to `entry.title`, `Image` bound to `entry.cover.url`, `Text` bound to `entry.tagline`. One bind, one drop, no repetition cost. Studio's Connect-a-Schema picker also rejects scalar fields. Use these components inline. + +**Diagnostic question:** "How many bindings will the author wire by hand on the next drop of this component?" If ≥2, make it a Section. If 1, leave it as a registered component used inline. + +If the candidate fails the qualification, STOP. It's not a Section. See [`understand-sections`](understand-sections.md) § What qualifies as a Section. + +Its data contract is defined by a **linked schema**, and Studio only accepts **structural shapes** here, not single scalar fields. Allowed kinds: + +| Kind | What it is | Single or multiple | +|---|---|---| +| **Content Type** | The whole entry of one CT | Single: one entry at a time | +| **Global Field** | A reusable named schema (the most common section anchor) | Single OR multiple (a list of group instances) | +| **Group** | A nested object on a CT | Single OR multiple (a list of group instances) | +| **Modular Block** | A field that holds a list of blocks. Each block can be a different shape | Always multi: pair with Repeater + Condition Block | +| **Block** | A specific block type inside a Modular Block field | Single: the block shape is fixed | +| **Reference** | Points to one or more entries of one or more CTs | Single OR multi-entry: multi pairs with Repeater + Condition Block (always required, single-CT included) | + +A section connecting to a multiple variant is meant to live **inside a Repeater** on the template. The Repeater iterates the list. Each iteration places one instance of the section's shape. + +Studio's picker omits scalar fields (`text`, `number`, `boolean`, `file`, `link`, `date`, etc.). A section composes UI against a shape. For a single value override at template level, use **Expose Section Prop** on a component inside the section. + +Bindings on registered components come from the Data Picker. The picker shows the Section's linked-schema fields by default. Inside a Repeater it switches root to the iteration item's fields. See `use-repeater` § The scope rule, in user terms. All References inside a Repeater (single-CT and multi-CT) require a Condition Block. + +## Path A vs Path B: monolith Section or Section-with-Slot + child Section + +When a Section iterates over a list of cards (a multi-reference field, a Modular Block list, a multi-Group, or a multi-entry pinned query), there's a recurring architectural choice: + +- **Path A: one monolithic Section.** The Section owns the Repeater AND the card markup inside it. Card props bind from the Repeater Data root (the iterated item). +- **Path B: Section-with-Slot + separate child Section.** The parent Section owns the Repeater and exposes a Section Slot inside the iteration. A separate child Section (bound to the iterated item's CT) gets dropped into the Slot at template-authoring time. The child Section is pre-bound to its own linked schema, no per-prop binding needed. + +**Deciding question:** "Will this card visual ever appear outside this list?" (Featured product hero, related-items strip, recommended carousel, search result row, etc.) + +- **Yes, probably yes, or unsure: take Path B.** Sections exist so visuals can be reused. Splitting the card into its own Section costs one extra Section and unlocks reuse everywhere else. **Default to Path B.** +- **Genuinely no, single-use: Path A** is acceptable. Easier to collapse Path B into Path A later than to extract a Section out of a monolith. When in doubt, Path B. + +In Path B, the parent's Repeater contains a Section Slot (see `use-section-slot`), filled at template time by the child Section, never a raw component (see `understand-section-slots`). Auto-binding wires it because the child Section's linked schema matches the iterated item. + +## Layout lives in the Section + +A Section that exposes a Slot must wrap it in a sized layout container. See `use-section-slot` § Layout container and `register-component` § Layout contract. + +Reference: `docs/32-sections/overview.md`, `docs/32-sections/binding-to-cms.md`, `docs/20-bring-your-own-components/register-components.md`, `docs/34-smart-containers/create-repeatable-content-with-repeaters.md`. + +## How a Section gets its data: the `selectedField` model + +A Section has **no data of its own**. When it's dropped on a template, the SDK hands it a scoped slice of the page entry via `getScopedData(pageEntry, selectedField)`: + +- **`selectedField` set** to a field uid: the section's `template` becomes that field's value (references and groups resolved along the path). +- **`selectedField` unset**: the section's `template` becomes the **whole page entry**. + +The section's own canvas therefore always shows **one empty placeholder** (no page context exists standalone). That's not a bug: the section only fills out at template-render time. Verify via SSR cold-load: `await sdk.fetchCompositionData({ url })` on the parent template's URL and dump `spec.data.section_scoped_data[<instance-uid>]`. + +### Decision rule: set `selectedField` or leave it unset + +| Section shape | Rule | Where its bindings resolve against | +|---|---|---| +| **One Repeater over one field** (Card Grid over `related_posts`, feature list over `features[]`) | Set `selectedField=<that field>` + use scope-root repeater binding `{ path: {} }` inside | `template` becomes the field's array value directly | +| **Multiple fields on the page entry** (Header reading `brand` + `nav_links` + `signin_label`, Hero reading a `hero` group) | **Leave `selectedField` unset**: the section needs the whole entry | `template` is the full page entry. Keep original `template.<field>` binding paths | + +Setting `selectedField` on a multi-field section loses access to everything outside the scoped path. Bindings to those fields resolve to `undefined`. + +Background only (this skill builds in the UI): full mechanism, wire shapes, and the reference-iteration `data_sources.resolvedReferences` handling: `author-composition-via-api` § Authoring a Section composition (scoping rules and § Decision rule) single-field repeater section vs whole-entry section. + + +## Prerequisite: the canvas chain must be wired + +Section authoring requires the full canvas chain: a route mounting `<StudioCanvas />`, the project's **Canvas URL** pointing at it, and (most often missing) a **non-empty per-locale Base URL on the environment the project targets**. Missing any link = blank or Playground canvas with no explanation. If you can't confirm all three, run [`setup-section-preview`](setup-section-preview.md) first. + +## Task + +1. **Open Studio, then the Sections tab, then + New Section.** Confirm you are in section authoring mode (the palette shows the **Registered Components** category and **Smart Containers** category, both are section-mode signals). + +2. **Name the section** using the supplied `sectionName`. The display name is what template authors see in the Sections palette. + +3. **Connect A Schema.** The Schema panel offers structural shapes only, no scalar fields. Pick the kind matching `linkedSchemaKind`: + - `content-type`: pick the connected CT + - `global-field`: pick the Global Field UID + - `group`: pick the group field on the parent CT + - `modular-block`: pick the Modular Block field. The section sees the union of allowed block types + - `block`: pick a specific block type inside a Modular Block. The section sees just that block's fields + - `reference`: pick the Reference field. The section sees the union of `reference_to` CTs + + If the user asks to "link to the title field" or similar, redirect: that's not how sections work. Either pick the parent group / object / CT, or use **Expose Section Prop** at the component level later. If `linkedSchemaUid` is blank, warn explicitly: the section is static-only, no auto-binding, every value typed by hand at template-drop time. + + When the picked schema is a multiple group / global field / file / link, Studio opens the **Add Repeater** prompt. **Do not click Add or Skip yourself:** ask the user "every item, or one item?" and click their answer ([`understand-linked-schemas`](understand-linked-schemas.md)). Add: root Repeater, `cardinality: "collection"`. Skip: `"single"`, meant to be dropped inside a Repeater on a template, which iterates the list and places the section once per item. + +4. **Scan the linked CT for Slot candidates BEFORE binding fields directly.** Inspect the CT for any **Global Field**, **Modular Block**, **Group**, or **Reference** fields. For each one, decide: + - **Expose it as a Section Slot** (the preferred default) when the nested data is composable, shared across pages, or likely to have variants. Carve a Slot at that location via `use-section-slot` instead of binding the nested shape inline. Doing so keeps the nested shape replaceable with any compatible Section per template instance. + - **Bind it inline** only when the shape is genuinely one-off, small, and unlikely to be reused. This is the rarer case. + + Default for Global Field / Modular Block / Group / Reference fields: **expose a Slot**. Inlining throws away the reusability of the nested structure. If unsure, expose a Slot. It can always be filled with a single fixed child Section. + + See `understand-section-slots` § When to expose one: the Global Field / Modular Block / Group / Reference heuristic for the full rationale (Global Fields are the strongest Slot candidate of the four). + +5. **Drop registered components.** For each entry in `initialComponents`: + a. Open the palette and switch to the **Registered Components** category. Do not drop Studio default components: they bypass the project's brand system. + b. Drag the component onto the section canvas. Use the canvas drop indicators to confirm placement inside the intended container. + c. Select the dropped node and bind each prop via the Data Picker. The picker shows the Section's linked-schema fields by default. If the dropped node sits inside a Repeater, the picker switches root to the **iteration item's** fields automatically (labeled "Repeater Data"). Bind from whichever root the picker is showing. Add a Condition Block around any Reference or Modular Block iteration. + + d. **Field-existence gate: before binding ANY prop, confirm the target field is actually in the Data Picker.** The picker lists exactly the fields the linked schema (or iteration-item scope) exposes. If the field you intend to bind is **not in the list, STOP**: do not force a bind, do not invent a path, do not accept a stale binding that shows a field name the picker no longer offers. A bind that references a non-existent field resolves to `undefined` at runtime, and the component silently falls back to its registration `defaultValue`, which reads on the canvas as "real data that's actually placeholder." When the field is missing, the linked schema is wrong (see the collection-source gate below) or the CT needs a schema change first via [`migrate-ct-schema`](migrate-ct-schema.md). Report the mismatch and stop. Never paper over it with a bind that can't resolve. + + e. **Collection-source gate: a component prop that takes a whole array/object needs a matching multi-valued SOURCE on the linked schema.** If a registered component exposes ONE prop shaped as an array or object (a `Card Grid` / `Article Grid` / list wrapper that `.map()`s internally, the native array-prop pattern, see [`build-repeating-section`](build-repeating-section.md)), the linked schema **must** have a multi-valued field to feed it: a **multi-Reference**, **Modular Block list**, **Group with `multiple:true`**, or a **Pinned Query** (Freeform). Verify that field is in the Data Picker **before** binding. If the linked schema has **no such field** (e.g. an `Article List` CT with only `Title`/`URL`/`Description`/`SEO` and no `articles` reference), then there is nothing to bind the collection prop to, and the component renders its default array forever. **STOP and pick a fix, do not bind:** + - Add the missing multi-valued field to the CT ([`migrate-ct-schema`](migrate-ct-schema.md)), the usual fix when the list lives in a separate CT (articles, products) that the page CT should reference, **or** + - Source it dynamically via a Pinned Query on a Freeform template ([`pin-query-to-freeform`](pin-query-to-freeform.md)), **or** + - **Decompose to atomic** instead of one whole-array component. Use a Repeater over the multi-valued field + a child Section bound to the iterated item's CT, with atomic components (Heading / Image / Text) each bound per-item. This surfaces per-field binding in Studio rather than hiding it inside the component. See [`build-repeating-section`](build-repeating-section.md) and [`use-repeater`](use-repeater.md). + + f. **Verify the bind resolved to REAL data, not the component's default.** After binding, toggle **Preview Mode** (select the node or Repeater, then Configuration, then Preview Mode) and confirm the canvas shows values from the **preview entry**, not the registration `defaultValue`. Tell them apart: default data is the generic placeholder baked into `registerComponent` (`"ARTICLE ONE"`, `"A short teaser…"`, a gray image icon). Real data is the seeded entry's actual field values. If Preview Mode still shows the defaults, the bind did **not** resolve. Re-run gates (d) and (e). Design Mode showing defaults is expected. **Preview Mode** showing defaults means the binding is broken. + +6. **Optional smart containers.** + - For a section whose linked schema IS the multiple variant, you usually drop a Repeater bound to `template.items[]` (or whatever the array path is) and place sub-components inside it. + - Carve a **Section Slot** (see `use-section-slot`) if you want template authors to drop arbitrary components into a named region. + - **Expose Section Props** (see `expose-section-props`) for value-level overrides, toggling a flag or swapping a label per template instance. + +7. **Save.** Studio surfaces the **Expose Props** modal on Save. Toggle which component props template authors should be able to override per page. If you skip this step the section is locked: template authors can drop it but cannot change any value. + +## Inputs needed from the user + +In this order. Stop and ask if any is missing. DO NOT guess a `linkedSchemaUid` or invent component types. + +1. `sectionName`: display name (reject empty or generic "Section 1", ask again). +2. `linkedSchemaKind`: one of `content-type / global-field / group / modular-block / block / reference`. If the user says "field name X", redirect: ask for the enclosing structural shape. +3. `linkedSchemaUid`: UID of the structural shape. Allow skip with an explicit static-only warning. +4. `isMultiple`: y/n. Affects whether the section is intended to live inside a Repeater on the template. +5. `initialComponents`: comma-separated list of registered component types. Reject any type not currently registered in the project. + +## Acceptance + +This skill succeeds only when ALL of the following are true. If any fails, do not claim success. Surface the failure and stop. + +- [ ] The new Section appears in the Studio Sections tab with the supplied `sectionName`. +- [ ] **The Section is published to the environment the Studio project targets**, and re-published after every later edit to its tree. An edit creates an unpublished version on a published composition, so delivery keeps serving the old shape while the CMA and the canvas both look correct. Row 7 of [`complete-the-build`](complete-the-build.md). +- [ ] **The section's atoms are inline-editable in the DOM**: the component root carries `data-cslp` (`studioAttributes` spread, with `wrap: false` in its register entry) and each bound text/image element carries its own `$`-twin tag. Verified by inspecting the rendered DOM, not assumed. Without it the section renders correctly and is invisible to Visual Editor. See [`complete-the-build`](complete-the-build.md). +- [ ] **The Sections accordion shows a thumbnail for it, not a blank tile. Mandatory, not cosmetic.** A section with an empty `ui_preview` is an unfinished section. Do not report it as created. If the thumbnail can't be set yet, say "created, thumbnail pending" and then finish it. Studio screenshots the canvas and uploads it to the composition's `ui_preview` field **on save**, so pressing Save in the editor is what produces the thumbnail. A blank tile means the section was created by API (that chain is editor-only) or the save's fire-and-forget upload failed. Fix: re-open the section and Save, or set `ui_preview` explicitly ([`author-composition-via-api` § Section thumbnails](author-composition-thumbnails.md#section-thumbnails-ui-preview)). +- [ ] If `linkedSchemaUid` was supplied, the section's Schema panel shows that UID under the correct structural kind (and **not** under any individual field). +- [ ] If the user asked to link to a scalar field, the request was redirected to the enclosing shape (or to Expose Section Prop). Single-field linking was NOT attempted. +- [ ] When `isMultiple=y`, the section's intent is documented for template authors: "drop inside a Repeater". +- [ ] Every component listed in `initialComponents` exists on the section canvas and was dropped from the **Registered Components** palette category (not the Studio defaults). +- [ ] Each bound prop on those components resolves under the right Data Picker root: the Section's linked-schema root for props outside any Repeater, and the **Repeater Data** root (iteration item) for props inside a Repeater. A Condition Block wraps any Reference or Modular Block iteration. +- [ ] **Every bound field exists in the Data Picker** (Step 5d). No prop is bound to a field the picker doesn't offer. No stale binding to a removed field was left in place. +- [ ] **Every collection-shaped prop (array/object) is backed by a real multi-valued source** on the linked schema: a multi-Reference, Modular Block list, `multiple:true` Group, or Pinned Query (Step 5e). If no such source exists, the Section was NOT saved with a dangling collection bind. The schema was fixed, a query pinned, or the component decomposed to atomic + Repeater. +- [ ] **Preview Mode shows real entry data, not registration defaults** (Step 5f). Placeholder text baked into `registerComponent` (`"ARTICLE ONE"`, generic teaser copy, gray image icons) is NOT being mistaken for a working bind. +- [ ] On Save, the **Expose Props** modal was acknowledged: either props were exposed or the locked state was a deliberate choice. +- [ ] Dropping the saved section onto a template that has a field of the linked schema's shape auto-binds with no manual picker steps. +- [ ] **Migration builds only**: if this Section is replacing an existing production Section on a live route, run `verify-visual-parity` against the production URL at all target viewports before declaring success. Structural checkpoints (component present, prop bound) are necessary but not sufficient. Pixel drift ships silently otherwise. Skip this line for greenfield Sections that have no production counterpart to compare. + + +## Driving the canvas + +Moving a component on the Studio canvas through browser automation has a required event sequence, and the high-level drag helpers do not satisfy it. See [`drive-studio-canvas-ui`](drive-studio-canvas-ui.md) before attempting a drop. + +## Reference for this skill + +| Read this | For | +|---|---| +| [`build-section-component-choice`](build-section-component-choice.md) | Basic field components vs your own registered components | +| [`build-section-pitfalls`](build-section-pitfalls.md) | What fails silently on the canvas. **Read before reporting done.** | + +## See also + +- `docs/32-sections/binding-to-cms.md`: how schema connection drives auto-binding +- `docs/20-bring-your-own-components/register-components.md`: getting components into the Registered Components palette +- `docs/34-smart-containers/create-repeatable-content-with-repeaters.md`: Repeater + multi-variant sections +- `docs/34-smart-containers/control-visibility-with-condition-blocks.md`: required wrapping for Reference / Modular Block iteration +- `use-section-slot`: carve a drop region into the section +- `expose-section-props`: value-level overrides on Save +- `build-connected-template`: drop the saved section onto a template with a matching field +- `build-repeating-section`: the dedicated guide for the "parent Section with Repeater + Slot, child Section fills the Slot per iteration" pattern (Path B from this skill, walked end to end) diff --git a/skills/studio/byoc-end-to-end.md b/skills/studio/byoc-end-to-end.md new file mode 100644 index 0000000..13701b0 --- /dev/null +++ b/skills/studio/byoc-end-to-end.md @@ -0,0 +1,169 @@ +--- +name: byoc-end-to-end +--- + +## When to use + +End-to-end orchestrator for the BYOC arc, chaining register-component (lazy), import-design-tokens, build-section, use-section-slot, build-connected-template, configure-csr-vs-ssr, setup-template-preview-routes, then verify-setup. + +Use when an engineer wants to bring their own component library into a Studio-backed app end-to-end. Walks the arc from registration to Section to Template to publish. Phrases: "BYOC", "use my components in Studio", "Studio with my design system". Do NOT use for one-off component registration (`register-component`) or once components already exist in Studio. + +> **Auth preflight: settle the credential before the first API call.** Resolve it OAuth-first per [`authenticate-cma`](authenticate-cma.md): `CS_OAUTH_ACCESS_TOKEN`, else the Contentstack MCP's stored session: Read that skill for where it is, even with no MCP connected. **Never ask the user for a session `authtoken`.** If nothing resolves, or a refresh fails with `400 invalid_refresh_token`, hand them `! CONTENTSTACK_REGION=<code> npx @contentstack/mcp --auth` (it needs a TTY and a browser, so it cannot be run for them) and wait. `403 error_code 316` is a valid credential aimed at another org: fix the org or the `api_key`, do **not** re-authenticate. + +# BYOC end-to-end: from registered components to a rendered Template + +BYOC ("Bring Your Own Components") chains the ~8 sub-skills that take registered React components and produce a rendered Template. This orchestrator only names + orders them. It doesn't replace any sub-skill. + +## Prerequisite: concepts first + +If the user can't crisply define Section, linked schema, Template, and `<StudioCanvas />` vs `<StudioComponent />` in one sentence each, run `start-here-zero-knowledge` first. + +## Task + +Print the plan for `projectShape` and `renderMode`, filling this in (Gate lines from the phases below, detours table as is). Name sub-skills, never run them: + +``` +BYOC plan: <existing | new> project, render mode <csr | ssr | rsc | unsure: decided by configure-csr-vs-ssr in Phase 5> +Phase 0a plan-studio-architecture Gate: <Phase 0a gate> +Phase 0 analyze-project-fit, install-studio, enable-visual-experience, + install-live-preview, setup-section-preview Gate: <Phase 0 gate> +Phase 0b (existing only) migrate-page-to-studio: one small page, not the homepage +Phase 1 register-component, import-design-tokens Gate: <Phase 1 gate> +Phase 2-3 build-section, one at a time Gate: <Phase 2 gate> +Phase 4 build-connected-template Gate: <Phase 4 gate> +Phase 5 configure-csr-vs-ssr, setup-template-preview-routes Gate: <Phase 5 gate> +Phase 6 complete-the-build, verify-setup, deploy-studio-site Gate: <Phase 6 gate> +If something breaks: <Common detours table> +``` + +### Phase 0a: Architecture planning (run BEFORE Preflight) + +- **`plan-studio-architecture`**: turns requirements into a printed architecture plan (Sections, Connected pages, content model, build order). Skip only if the user already has a written plan. + +Gate: printed plan with classified pages, content model, ordered Section + Template inventory, build order, sanity flags acknowledged. + +### Phase 0: Preflight (run once) + +- **`analyze-project-fit`**: confirm React 18 or 19, single React instance, no stale SDK pin. Catches the most common install foot-guns. +- **`install-studio`**: install SDK packages, confirm a single React, wire `ContentstackLivePreview.init`, set env Base URL pre-flight. Output: SDK installed + env Base URL for target locale is non-empty. +- **`enable-visual-experience`**: confirm stack-level Live Preview is on (commonly missed. Silently blocks the canvas) and that the delivery token carries a **preview token**. +- **`install-live-preview`**: the init must set `cslp: { appendTags: true }`. **Not optional, and not a "if they want Visual Editor" step**. Every Studio project is a Visual Editor project. With `appendTags` off or absent both tag bags arrive empty everywhere at once, and no per-component fix helps. +- **`setup-section-preview`**: create the `/canvas` route mounting `<StudioCanvas />`. Set the Canvas URL in Studio Project Settings. + +Preflight owns items 1, 5 and 6 of [`complete-the-build`](complete-the-build.md). Phases 1 to 3 own the rest. + +Gate: open Studio, open any composition (even an empty one). Canvas iframe loads cleanly (no blank frame, no Component Loading Error). If not, run `troubleshoot-canvas` before continuing. + +### Phase 1: Register components (lazy by default) + +- **`register-component`**: for each component the user wants Studio to surface in the palette. Default to the LAZY shape: `component: () => import("./Foo").then(m => ({ default: m.Foo }))`. Eager only when the component is tiny and on a hot path where Suspense fallback is unacceptable. +- **`register-breakpoints`** (optional): if the site needs custom responsive breakpoints registered for Studio's responsive editor. +- **`register-json-rte`** (optional): only if the site uses JSON-RTE-bound props. +- **`import-design-tokens`**: register the customer's design system. Do this BEFORE building any Section. Tokens here drive visual fidelity of every later canvas preview. + +Detour: `component: Hero` where `Hero` is `function Hero() { ... }` (no `props` arg) produces "Invalid hook call". Back to `register-component`, read the arity rule. The lazy shape avoids the trap. + +Gate: open the canvas. Switch the palette to **Registered Components**. Every component the user expects to see is present, with a non-blank preview (defaults populated). + +### Phase 2: Build the first Section + +- **`build-section`**: pick a linked schema (Content Type / Global Field / Group / Modular Block / Block / Reference). The picker only offers structural shapes. No scalar fields. **Default to Global Field as the section anchor** unless a different shape fits better. +- **Mid-step: scan for Slot candidates.** Inside `build-section` step 4, the user is told to inspect the linked CT for **Global Field / Modular Block / Group / Reference** fields. For each one, decide: + - Expose as a Slot (the preferred default): run `use-section-slot` for that field. + - Bind inline (rare): only when the shape is genuinely one-off. + - Global Field is the strongest Slot candidate of all four: build a separate Section against the Global Field's schema and expose it as a Slot in the parent. +- **`expose-section-props`**: for any prop the user wants overridable per Section-drop (e.g. background color, layout variant). Do this WHILE building the Section, not later. +- **`wire-component-default-data`** (optional): for components whose defaults aren't representable as static `defaultValue:` literals. + +Gate: the Section appears in the Studio Sections tab. Open it on the canvas, and bindings render real data from a sample entry. If not, run `troubleshoot-data-binding`. + +### Phase 3: Repeat Phase 2 for every Section the site needs + +- Most pages need 3 to 6 Sections (Header, Hero, Feature Grid, Testimonial, CTA, Footer, etc.). +- Build them ONE AT A TIME and verify each before moving on. +- Cross-references: if a Section iterates a list (Modular Block, multi-Reference, multi-Group), use **`build-repeating-section`**, the dedicated guide for the parent-with-Slot + child-Section pattern, which composes `use-repeater` + `use-section-slot` + `use-condition-block` into one flow. Don't drop raw cards into a repeating Slot. Build the card as its own Section and let the parent's Slot accept it. +- Discoverability: run `discover-sections` periodically to see what's already been built, avoids accidentally rebuilding a Section that already exists. + +### Phase 4: Choose Template flavor + assemble + +- **`build-connected-template`**: assemble the Sections from Phase 3 into the Template. Templates compose Sections. They don't model fields directly. If the user hasn't built any Sections yet, back to Phase 2. + +Gate: the Template renders on the canvas with the expected Section stack. Switching the preview entry updates the rendered content. If not, run `troubleshoot-composition-resolution`. + +### Phase 5: Visitor render path (CSR vs SSR vs RSC) + +- **`configure-csr-vs-ssr`**: the canonical decision skill. For `renderMode = unsure`, this skill picks based on the host framework (Next App Router: main or RSC entry, Vite + React Router: CSR, Remix: SSR). Output: a clear recommendation and the per-route shape to use. +- **`setup-template-preview-routes`**: create the visitor route(s) mounting `<StudioComponent />`. Either a catch-all `[[...slug]]` (recommended for most projects) or per-template routes (more control, more boilerplate). The route(s) here serve BOTH real visitors AND Studio's template-preview iframe, same code path. + +Gate: open `<env Base URL>` + `<Template URL>` directly in a browser (not through Studio). The Template renders with real data. Open the same Template in Studio's Templates tab, and the iframe shows the same render, with edit overlays. + +### Phase 6: Verify + ship + +- **`complete-the-build`**: the handover gate, run it first. Six deliverables, all of which fail silently: CSLP tags, `thumbnailUrl` per registration, `ui_preview` per Section, Live Preview + preview token, `appendTags`, `/canvas`. Run its verification block and **print the counts**. Anything outstanding is finished before shipping. A build that renders but that no marketer can edit is not shipped. +- **`verify-setup`**: 5-layer smoke test (Delivery SDK reachability, Live Preview channel, Visual Editor, Studio canvas, new-user trap smoke). ALL FIVE layers must pass before deploying. +- **`troubleshoot-ssr-rendering`** (if needed): for SSR-specific failures (RSC client-reference boundaries, hydration mismatches, lazy-not-loaded race when `fetchSpec` isn't awaited). +- **`deploy-studio-site`**: production checklist (env Base URL points at the deployed origin, Canvas URL still `/canvas`, prod build serves the right CSP headers for the iframe). + +Gate: deployed site renders a real Template at a real URL. Studio's preview of the same Template (inside Studio) shows the same content, **and** clicking a text node in that preview selects it for inline editing. If it doesn't, the CSLP half of the build is missing, not the rendering half. + +## Branch: brownfield (`projectShape = existing`) + +Insert `migrate-page-to-studio` between Phase 0 and Phase 1: pick one small existing page (not the homepage). In Phase 5, mount the new visitor route side-by-side with the old one. Redirect after verification. + +## Branch: greenfield (`projectShape = new`) + +Phase 0 includes project bootstrap. Recommended: Next.js App Router (catch-all route) or Vite + React Router (CSR). React 18.3 or later, or React 19. Canvas-app and visitor-app are the SAME app: one `package.json`, two route trees. + +## Common detours, and the step to back to + +| Symptom | Detour | Back to | +|---|---|---| +| Component throws "Invalid hook call" the moment the canvas renders it | The component was registered eagerly with arity 0 (e.g. `function Header() {...}` with no `props` arg) | `register-component`: switch to the lazy shape, OR add a `props` arg | +| Studio canvas blank, no console error | env Base URL empty, or Canvas URL set to a full URL instead of a path | `setup-section-preview` + `understand-canvas-url` | +| Components don't show up in the palette | Registration file isn't imported at app startup | `register-component`: `import "./register-components"` from the app entry | +| Sections panel is empty even after Phase 2 finished | Wrong project selected in Studio top-bar | switch project, OR re-run `provision-studio-project` if it was provisioned against a different stack | +| "Internal components missing: page" on the visitor route | SDK is pre-PR-#851 build | `upgrade-studio-sdk`: bump to the patch that ships the visitor-path auto-register | +| Template renders in Studio but not on the live route | The visitor route isn't mounting `<StudioComponent />`, OR is mounting `<StudioCanvas />` by mistake | `understand-canvas-vs-component` + `setup-template-preview-routes` | +| "registered as lazy but hasn't been loaded yet" | `fetchSpec` / `fetchCompositionData` wasn't awaited before `<StudioComponent />` mounted | `configure-csr-vs-ssr`: the SSR / RSC patterns show the right await placement | +| `useData()` warning in dev console only | Benign. Basic component hit the SSR pass before `DataCtxProvider` attached. Disappears post-hydration. | `troubleshoot-ssr-rendering`: confirm it's a warning, not an error | + +## Inputs needed from the user + +1. `projectShape`: `existing` or `new`. Used to insert the `migrate-page-to-studio` branch at the right point. +2. `renderMode`: `csr` / `ssr` / `rsc` / `unsure`. Used by `configure-csr-vs-ssr` in Phase 5. `unsure` is acceptable, that skill picks based on the host framework. + +## Acceptance + +This orchestrator succeeds when: +- [ ] **[`complete-the-build`](complete-the-build.md) ran at handover and all six rows passed, with counts printed**: `registerComponent` count equals `thumbnailUrl` count, zero Sections with an empty `ui_preview` (audited over the CMA), `cslp.appendTags` on, Live Preview + preview token on the stack, `/canvas` mounted. Not adjectives, numbers. +- [ ] **Every atomic component carries its CSLP tags**: `studioAttributes` spread on the root element **and** `wrap: false` in its register entry and each bindable prop's `$`-twin spread on the element rendering it. Verified in the DOM, not assumed: the root and each bound text/image element must show `data-cslp`. Without this the atom renders correctly but is invisible to Visual Editor and Live Preview. See [`register-component` § The `studioAttributes` contract](register-component.md) and § The `$`-twin contract. + +- [ ] The user sees the full BYOC path on one page, with every sub-skill named. +- [ ] Each phase has an explicit gate (the signal that says "ready for the next phase"). +- [ ] The plan is customized to their `projectShape` (existing vs new) and `renderMode`. +- [ ] Common detours are flagged WITH the step to back to, not just "something went wrong." +- [ ] The plan ends at "deployed + verified," not at "Template renders." + +## Common pitfalls + +| Pitfall | Why it bites | Right move | +|---|---|---| +| Treating this orchestrator as a sub-skill replacement | The orchestrator doesn't author Sections or Templates. It points at the skills that do. Running it doesn't accomplish any phase. | Use this to see the whole path. Invoke the named sub-skill for each phase. | +| Skipping the concept gate at the top | User registers components, builds a "Section" that's actually unwrapped components on a route, ships a one-off page, loses all reuse benefit | If the user can't define Section + Template + linked schema crisply, RUN `start-here-zero-knowledge` first. Five minutes of concepts saves weeks of rework. | +| Running phases out of order | E.g. building a Section before importing design tokens: the Section's canvas preview looks wrong, the user thinks the Section is broken, rebuilds it, same problem because tokens are still missing | Tokens before Sections. Sections before Templates. Templates before visitor routes. Verify before deploy. The order is load-bearing. | +| Building all Sections in one big batch before verifying any of them | If something is wrong with the SDK / registry / Live Preview channel, the user discovers it after 4 hours of building, then has to debug across all Sections at once | Build ONE Section, verify it on the canvas, move to the next. Catch breakage when the surface area is small. | +| Forgetting Phase 6 ("verify + ship") | Template renders on the canvas, so the user assumes prod will be fine, and then CSP headers block the iframe in prod, or the env Base URL still points at localhost in prod. | `verify-setup` and `deploy-studio-site` are mandatory phases, not optional. The plan doesn't end at "renders on canvas." | <!-- style-lint: allow --> + +## See also + +- `start-here-zero-knowledge`: the conceptual prerequisite (run BEFORE this skill if the user is new) +- `plan-studio-architecture`: Phase 0a, the requirements-to-architecture planner that runs before this skill +- `analyze-project-fit`: Phase 0 gate +- `install-studio`, `setup-section-preview`, `setup-template-preview-routes`: wiring +- `register-component`, `import-design-tokens`, `register-breakpoints`, `register-json-rte`: registration +- `build-section`, `use-section-slot`, `expose-section-props`, `use-repeater`, `use-condition-block`: Section authoring +- `build-repeating-section`: the dedicated end-to-end recipe for parent-with-Slot + child Section iteration (composes the four skills above) +- `build-connected-template`: Template authoring +- `configure-csr-vs-ssr`, `troubleshoot-ssr-rendering`: render modes +- `verify-setup`, `deploy-studio-site`: ship +- `migrate-page-to-studio`: brownfield branch diff --git a/skills/studio/choose-connected-vs-freeform.md b/skills/studio/choose-connected-vs-freeform.md new file mode 100644 index 0000000..77819c7 --- /dev/null +++ b/skills/studio/choose-connected-vs-freeform.md @@ -0,0 +1,93 @@ +--- +name: choose-connected-vs-freeform +--- + +## When to use + +Decide whether a new page is a Connected (content-type-bound) or Freeform (one-off) template. **Connected is the default. Freeform is the very last resort.** Hands off to the matching build skill. + +Use at the start of authoring a new page, before any template is created. Phrases: "Connected or Freeform?", "what kind of template", "should this be a landing page". Emits a recommendation (Connected default, Freeform only when justified by the Three-AND rule) and the next skill to run. Do NOT use to actually build the template. + +> **Auth preflight: settle the credential before the first API call.** Resolve it OAuth-first per [`authenticate-cma`](authenticate-cma.md): `CS_OAUTH_ACCESS_TOKEN`, else the Contentstack MCP's stored session: Read that skill for where it is, even with no MCP connected. **Never ask the user for a session `authtoken`.** If nothing resolves, or a refresh fails with `400 invalid_refresh_token`, hand them `! CONTENTSTACK_REGION=<code> npx @contentstack/mcp --auth` (it needs a TTY and a browser, so it cannot be run for them) and wait. `403 error_code 316` is a valid credential aimed at another org: fix the org or the `api_key`, do **not** re-authenticate. + +# Choose Connected vs Freeform Template + +## Context + +Studio offers two kinds of top-level compositions: **Connected templates** (bound 1:1 to a content type, so every entry of that CT renders through the same template at a derived URL) and **Freeform templates** (one-off pages assembled from any mix of sources (Pinned Queries, Pinned Entries, Component Default Data) with no content-type binding). + +**Default is Connected.** Connected scales with content (one template renders unlimited entries), auto-binds `template.*` against the connected entry, supports `{{entry.*}}` URL variables, gives authors a content-first workflow (edit the entry and the page updates), and benefits from CT-level governance (validation, locales, variants, publishing workflows). **Most pages should be Connected**, including pages that only ever have one instance. Model a single-entry content type rather than reaching for Freeform. + +**Freeform is the very very very last resort.** Use it ONLY when **ALL THREE** are true: + +1. **Short-lived**: the page has an expected end date (campaign, splash, takeover, time-boxed promo). Not a "one-off that lives forever." +2. **Owns ZERO content of its own**: no hero copy, no page-specific title, no subheading, no CTA text, no callouts, no closing message. **Literally no copy authored on the page.** Hero copy IS content. Throwaway HTML is owned content in disguise. If you find yourself writing copy in JSX, you have a content field. Model it as a single-entry CT and go Connected. +3. **Visible content is 100% assembled by pinning / pulling from existing entries**: pinned entries from existing CTs, pinned queries, references into existing collections. The page is a vitrine, not a source. + +If any one of those three is missing, it is **not Freeform.** Use Connected with the right CT shape (single-entry CT for one-off long-lived pages, multi-entry CT for repeating shapes including recurring campaigns, single-entry CT even for short-lived pages that own any copy). + +Examples that do NOT qualify for Freeform (despite tempting at first glance): +- **"Black Friday 2026 landing"**: even if short-lived and references existing product entries, it has its own campaign hero copy, headlines, CTA text. That is owned content, so model a single-entry CT or a generic `campaign` CT and go Connected. +- **"Conference splash"**: has its own conference hero, dates copy, sponsor framing. That is owned content, so go Connected. +- **"Marketing landing page"**: almost always has page-specific copy. That is owned content, so go Connected. +- **Homepage / About / Contact / Pricing**: long-lived AND own their content, so model a single-entry CT and go Connected. + +Genuine Freeform cases are rare and usually internal-tool-ish: a merchandiser dashboard pinning 12 products in a specific order, a monitoring vitrine of pinned analytics entries, an A/B test variant that's purely "pinned query, no other content, deleted next week", pure assembly of existing entries with no surrounding copy. + +## Task + +1. Ask the user for `pageDescription`, `numberOfPagesOfThisShape`, and `hasContentTypeMatch` if not already provided. +2. Check for a URL-pattern hard constraint. If the user mentions a URL that needs entry-field variables (e.g. `/blog/{{entry.slug}}`, `/products/{{entry.handle}}`), force **Connected**. Freeform URL patterns only support `{{composition_uid}}` plus `{{environment}}` / `{{branch}}`. They cannot resolve `{{entry.*}}` or `{{content_type_uid}}`. (`{{locale}}` is accepted by the pattern engine but not recommended for either flavor. Locale belongs in your routing layer + the SDK `locale` query option.) +3. Otherwise apply the decision tree in order. **Bias toward Connected at every branch**. Only return Freeform after explicitly confirming ALL THREE Freeform conditions (Step 4 below). + - (a) `numberOfPagesOfThisShape = many-with-CT-backing` (many pages, each maps to one entry of a single CT) means **Connected**. Canonical examples: blog posts, products, recipes, author profiles. + - (b) `numberOfPagesOfThisShape = one` AND `hasContentTypeMatch = yes` (one-off page that maps cleanly to one entry of a CT) means **Connected** with a hard-coded URL. Cheaper than Freeform because Connected auto-binds template fields against the connected entry. + - (c) `numberOfPagesOfThisShape = many` AND `hasContentTypeMatch = no` or `partial`, so **propose Connected via a new or extended CT**. Ask: can a content type be created (or an existing one extended) to back these pages? If yes (which is usually the case), model the CT first, then go Connected. + - (d) `numberOfPagesOfThisShape = one` AND `hasContentTypeMatch = no`, so ask: does the page own any copy of its own (hero title, subheading, CTA, callouts)? If YES, choose **Connected** with a single-entry CT (typical for homepages, about, contact, pricing, campaign landing pages: they all own their copy). The page-specific copy IS content. Model it. If NO (the page owns literally zero copy and purely assembles existing entries), continue to Step 4 (Freeform candidate). + +4. **Freeform last-resort check.** If (and only if) branch (d) led here with the user confirming the page owns no copy, run the three-AND rule. Re-confirm by asking explicitly: + - (i) Does this page have an **expected end date**? (Campaign, splash, takeover, not "one-off that lives forever.") + - (ii) Does the page own **ZERO content of its own**? No hero copy, no page-specific title, no subheading, no CTA text, no callouts, no closing message, literally nothing the page itself authors. (Hero copy IS content. Throwaway HTML IS content in disguise.) + - (iii) Is the visible content **100% assembled from existing entries**: pinned entries, pinned queries, references into existing CTs? + + If the user confirms all three, the answer is **Freeform**. If even one answer wavers or is "yes, but a hero title", flip back to **Connected** with a single-entry CT (or a `campaign` CT for recurring campaign shapes). Hero copy / page-specific copy means owned content. Owned content means Connected. +4. Print the recommendation in this exact shape: + ``` + Recommendation: <Connected | Freeform> template + Rationale: <one sentence tying back to the inputs> + Next skill: <build-connected-template | build-freeform-template> + ``` +5. If the recommendation is Freeform, append TWO reminder lines: + - `Reconsider: did you rule out Connected via a single-entry CT or an extended CT? Freeform is the exception, not the default — re-check before proceeding.` + - `Before running build-freeform-template, verify the project-level "Enable Freeform Feature" toggle is on (Studio → Settings → Configuration). Many teams keep it OFF deliberately to enforce the every-page-maps-to-a-CT discipline; if your project has it off, the right move is usually to leave it off and model a CT.` +6. Stop. Do not create the template yourself. The user (or the next skill invocation) runs the chosen build skill. + +## Inputs needed from the user + +- **pageDescription**: one sentence, free text. Used only for the rationale line. +- **numberOfPagesOfThisShape**: one of `one`, `many`, `many-with-CT-backing`. +- **hasContentTypeMatch**: one of `yes`, `no`, `partial`. + +If the user volunteers a URL pattern in `pageDescription`, parse it for `{{entry.*}}` or `{{content_type_uid}}` and apply the hard constraint from step 2 before the decision tree. + +## Acceptance + +- Exactly one recommendation is printed (Connected OR Freeform, never both, never "it depends"). +- The rationale references the user's actual inputs, not generic copy. +- The `Next skill` line names a real, runnable skill in the docs (`build-connected-template` or `build-freeform-template`). +- If the URL needs `{{entry.*}}`, the output is Connected and the rationale explicitly cites the Freeform URL-variable limitation. +- If the output is Freeform, the Enable Freeform Feature reminder is present. +- No template is created in Studio by this skill. + +## Common pitfalls + +| Pitfall | Why it bites | Right move | +|---|---|---| +| Recommending Freeform for "many blog posts" because the user said the word "landing" once | Freeform is 1:1. Each blog post would need its own template, exploding maintenance | If `many-with-CT-backing`, always Connected, regardless of stylistic words | +| Recommending Freeform when the URL contains `{{entry.slug}}` | Freeform URL patterns only resolve `{{composition_uid}}`, `{{environment}}`, `{{branch}}`. `{{entry.*}}` and `{{content_type_uid}}` will not interpolate | Force Connected the moment an entry-field variable appears | +| Treating "partial CT match" as automatic Freeform | A partial match often means the CT just needs one extra field. Connected is still right if the page shape repeats | Ask whether the CT can be extended before falling back to Freeform | +| Recommending Freeform without checking the project toggle | `Enable Freeform Feature` may be off at the project level. `build-freeform-template` will not see the option in the create-template modal | Always print the toggle reminder when the answer is Freeform | +| Choosing Freeform for a one-off page that maps to one entry | You lose Connected's auto-resolution of `template.*` bindings and have to wire Link Entry / Pinned Queries by hand | A one-off with a clean CT match means Connected with a hard-coded URL | +| Reaching for Freeform on a one-off page because "there's no obvious CT" | A single-entry CT (one CT, one entry) is almost always cheaper than Freeform for the homepage, about page, contact page, etc. Connected gives you auto-binding, locales, variants, and a content-first edit flow that Freeform doesn't | If the page has even one stable content shape, model it as a single-entry CT and go Connected | +| Treating Freeform as the default for "marketing / campaign" pages | The label "marketing page" doesn't decide the question. The data shape does. A "campaign" page that always has hero + promos + CTA can be a single-entry CT (Connected) | Ask what the data on the page looks like, not what the page is called. Connected wins whenever the shape repeats or stabilizes | +| Confusing Repeater iteration bindings with template bindings during the conversation | Inside a Repeater, fields bind as `repeater.<field>` (RepeaterBindingValue, discriminated by `repeaterUID`). At the template level they bind as `template.<field>` (TemplateBindingValue). This skill doesn't bind (it just chooses) but if the user asks "can I bind X?", route them to the matching build skill instead of guessing | Defer all binding shape questions to `build-connected-template` / `build-freeform-template` | +| Forgetting that references inside a Repeater always need a Condition Block | True for both single-CT and multi-CT reference fields, relevant when the user asks whether their repeater-of-refs page is feasible | Confirm feasibility, then hand off. The build skill handles the Condition Block step | diff --git a/skills/studio/classify-existing-component.md b/skills/studio/classify-existing-component.md new file mode 100644 index 0000000..8c4f36f --- /dev/null +++ b/skills/studio/classify-existing-component.md @@ -0,0 +1,184 @@ +--- +name: classify-existing-component +--- + +## When to use + +Diagnose what shape an **existing** component is (Atom, Layout, or Compound) and hand off to the right next skill. Runs [Q1 of the four-question framework](../../docs/00-getting-started/composable-primitives.md#q1-is-this-an-atom-a-layout-or-a-compound) as a standalone step, without committing to registration or decomposition upfront. + +Use when the user has a component (already registered, or about to be) and asks what it is, not what to do about it. Phrases: "is this atom or layout?", "classify this component", "what shape is this?", "audit my registered components", "which of my registrations are compounds?", "should this be decomposed?", "sanity-check my palette", "review this component before I register it". Do NOT use to **design** a fresh library (that's [`design-component-library`](design-component-library.md)), to **break a monolith apart** (that's [`decompose-jsx-to-atomics`](decompose-jsx-to-atomics.md)), to **write the registration call** (that's [`register-component`](register-component.md)), or to **derive a schema** from an already-classified atom (that's [`design-section-from-jsx`](design-section-from-jsx.md)). This skill answers what shape only, then routes. + +# Classify an existing component: Atom, Layout, or Compound + +## Context + +The full [four-question framework](../../docs/00-getting-started/composable-primitives.md#four-questions-to-answer-when-building-any-component) chains four questions in order: Q1 (classify), Q2 (reuse?), Q3 (props?), Q4 (expose?). Skills like `design-component-library` and `decompose-jsx-to-atomics` walk all four for a whole library or a whole monolith. This skill runs **only Q1**, on **one component at a time**, and stops. + +Why break Q1 out: + +- **Audit an existing registry**: a team inherits a Studio project with 30 registered components. They want to know which are honest atoms, which are honest layouts, and which are compounds that need decomposition, before committing to a decomposition run. +- **Sanity-check before registering**: a developer has a candidate component. They want confirmation of its shape before running `register-component`. +- **Triage**: a customer reports "authors can't rearrange anything" or "the data doesn't bind properly". Q1 on the offending component reveals whether it's actually a compound (single Section = single page block). + +The output is one word plus a reason plus a routing recommendation. Not a full decomposition tree. That's the next skill's job. + +## Task + +### Step 1: Locate the component + +Ask (only if not stated): + +- Where's the component's source? (path to `.tsx` / `.jsx`, or the `registerComponent()` call's `component` reference) +- Is it currently registered? (`csdx studio:component:list`, `registerComponent({ type: … })` grep, or the palette in Studio) + +If already registered, note the `type` UID. If not, note the file path. + +### Step 2: Read the render tree + +Open the source. Walk **what the component returns**, not its prop list. Prop lists tell you what the current caller passes. The render tree tells you what the component actually renders. + +For each element inside the return, note: + +- Tag or child component name (`<h1>`, `<Button>`, `<Card>`) +- What content it renders (a prop, a literal, a `.map()`, a fetch result) +- Whether it's a **content leaf** (single string / image / link / rich-text field) or a **container** (holds child elements laid out in a specific relationship) + +Set aside styling wrappers (`<div className="wrapper">` around one leaf). They don't count as containers unless they hold multiple children in a designed relationship. + +### Step 3 (Apply Q1) classify + +| The render tree looks like… | Classification | Reason | +|---|---|---| +| One content leaf (plus styling wrappers): a `<h1>{text}</h1>` or `<img src={src} alt={alt}/>` or `<a href={href}>{label}</a>` | **Atom** | Carries a single content unit. | +| A container that arranges children via a slot (`children`, `left`/`right`, `header`/`body`/`footer`) with no hardcoded content of its own | **Layout** | Arranges child components. Doesn't own content. | +| A `<div>` with hardcoded child structure (e.g. `<div><Heading/><Text/><Button/></div>`) where the children are baked in, not slot-passed | **Compound** | Owns both arrangement AND content. Not a valid Studio primitive. | +| A page-level component that renders a `<Header/>` + `<Hero/>` + `<Grid/>` + `<Footer/>` with no slot props | **Compound** | Whole-page monolith. | +| A `.map()` over an array rendering child components inline (e.g. `products.map(p => <ProductCard {...p}/>)`) | **Compound** | Iteration is baked in. A Studio Repeater should own it, not the component. | + +**Halt on ambiguity.** If the component mixes patterns (e.g. `<div><Header/>{children}</div>`) call out both signals and ask the user which one the component's real job is (arrange? contain? both?). Never guess. + +### Step 4: Report the classification + +Emit one paragraph in this shape: + +> **Classification: [Atom / Layout / Compound]** +> +> **Reason:** [1 to 2 sentence justification grounded in the render tree. Cite the specific elements]. +> +> **Confidence:** [High / Medium / Low]. [If low: which observation was ambiguous, and what would resolve it.] + +### Step 5: Route to the next skill + +| Classification | Next step | Why | +|---|---|---| +| **Atom**: not yet registered | [`register-component`](register-component.md) | Shape confirmed. Write the registration. | +| **Atom**: already registered, but you're auditing | Stop. It's fine. | Optionally verify the prop schema follows the [content-vs-design split](../../docs/00-getting-started/composable-primitives.md#the-rule-of-thumb-what-to-expose-as-a-prop) via `design-section-from-jsx`. | +| **Layout**: not yet registered | [`register-component`](register-component.md) with a `slot` prop | Layout confirmed. Write the registration with the slot contract. | +| **Layout**: already registered | Stop. It's fine. | Shape confirmed and the registration is in place. | +| **Compound**: from JSX | [`decompose-jsx-to-atomics`](decompose-jsx-to-atomics.md) | Break the render tree into Layer-1 atoms + Layer-2 containers. Re-run Q1 on each fragment. | +| **Compound**: from a design artifact (Figma / screenshot) | [`decompose-design`](decompose-design.md) | Same intent, visual input. | +| **Compound**: page-level monolith | [`decompose-jsx-to-atomics`](decompose-jsx-to-atomics.md), then [`discover-sections`](discover-sections.md) on the resulting fragments | A page becomes sections, and sections become primitives. | + +State the recommended next skill explicitly. Do not silently transition. The caller may want to stop after classification (an audit run) rather than start a decomposition. + +## Worked examples + +**Example 1: Heading component** + +```tsx +export function Heading({ level, text, align }: HeadingProps) { + const Tag = level; + return <Tag className={cn("heading", align && `text-${align}`)}>{text}</Tag>; +} +``` + +- Render tree: one `<Tag>{text}</Tag>`. `align` is design (className), not content. +- Classification: **Atom**. Reason: single content leaf (`{text}`), only styling around it. +- Confidence: High. +- Next: `register-component` if not yet registered. + +**Example 2: TwoColumn wrapper** + +```tsx +export function TwoColumn({ left, right }: TwoColumnProps) { + return ( + <div className="grid grid-cols-2 gap-8"> + <div>{left}</div> + <div>{right}</div> + </div> + ); +} +``` + +- Render tree: a `grid` container arranging two children by slot (`left`, `right`). +- Classification: **Layout**. Reason: slot-based children (`left`, `right`). No content of its own. +- Confidence: High. +- Next: `register-component` with two `slot`-typed props. + +**Example 3: HeroBand with baked-in children** + +```tsx +export function HeroBand({ headline, subheadline, ctaLabel, ctaHref, imageSrc }: Props) { + return ( + <section className="hero"> + <div className="hero-copy"> + <h1>{headline}</h1> + <p>{subheadline}</p> + <a href={ctaHref}>{ctaLabel}</a> + </div> + <img src={imageSrc} alt="" /> + </section> + ); +} +``` + +- Render tree: 4 content leaves (`<h1>`, `<p>`, `<a>`, `<img>`) arranged inside a designed relationship. +- Classification: **Compound**. Reason: owns both the arrangement (grid split, hero-copy stack) AND the content (heading, subheadline, CTA, image) as baked-in structure. Not slot-based. +- Confidence: High. +- Next: `decompose-jsx-to-atomics`. Split into `Heading`, `Text`, `Button`, `Image` atoms plus a `Split` or `Hero` layout with two slots. Then register each. + +**Example 4: ambiguous mix** + +```tsx +export function CardShell({ title, children }: Props) { + return ( + <div className="card"> + <h3>{title}</h3> + {children} + </div> + ); +} +``` + +- Render tree: one content leaf (`<h3>{title}</h3>`) + a slot (`{children}`). +- Classification: **Ambiguous**. This is either a Layout with a header prop, or a small Compound (owns the title AND the slot). +- Confidence: Low. +- Halt. Ask: "Should the title stay as part of the CardShell (Compound), or should authors compose their own header in the slot (pure Layout)?" + +## Pitfalls + +| Pitfall | Why it bites | Fix | +|---|---|---| +| Reading the prop list instead of the render tree | Props tell you what callers pass. The render tree tells you what the component contains. A `Hero({ children })` looks like a Layout by props but might render 5 hardcoded elements inside a `<div>` before the `{children}`. | Open the file. Walk the JSX. | +| Confusing a `<div className="wrapper">` styling shell for a Layout container | A single-leaf atom often has a styling `<div>` around it. That's still an Atom. | Layout = slot-based, multiple children. Single-child styling wrapper = Atom. | +| Classifying a `.map()` as Layout | Iteration is a Studio Repeater's job, not a component's. A `.map()` inside the render tree is almost always a Compound signal. The component is baking in what should be Repeater iteration. | Classify as Compound. `decompose-jsx-to-atomics` handles the split. | +| Skipping Q1 because "it's obviously an Atom" | Every skipped classification is a future "why can't I bind this properly?" bug. | Even one-liner: cite the leaf, say Atom, move on. Takes 15 seconds. | +| Running Q1 and then running the full framework anyway | This skill is deliberately scoped to Q1. If the caller wants Q2 to Q4, run `design-component-library` (fresh) or `decompose-jsx-to-atomics` (existing). | Stop after Step 5. Route explicitly. | + +## Why this exists (why-this-matters) + +Q1 is the classification gate that governs whether every downstream skill's advice will actually work. Getting Q1 wrong cascades: + +- Register a Compound as an Atom, and you hit **the `any` flatten trap** ([`register-component` § The `any` flatten trap](register-component.md)). The binder folds an object down to a single value and props render as `[object Object]` or nothing. +- Register a Compound as a Layout, and **authors can't rearrange anything.** The component's baked-in children override any slot fill. +- Miss a Layout that's actually an Atom, and **no slot means no author variability.** The palette entry ends up locked to whichever content the developer hardcoded. + +Every downstream failure starts here. Cheap to run, catches expensive mistakes. + +## See also + +- [`docs/00-getting-started/composable-primitives.md`](../../docs/00-getting-started/composable-primitives.md): the canonical 10-primitive taxonomy and the full four-question framework (Q1 to Q4). +- [`design-component-library`](design-component-library.md): walks all four questions across a whole library. Use this when starting fresh. +- [`decompose-jsx-to-atomics`](decompose-jsx-to-atomics.md): the next step after Q1 = Compound on an existing React component. +- [`decompose-design`](decompose-design.md): the next step after Q1 = Compound when input is a Figma frame or screenshot. +- [`register-component`](register-component.md): the next step after Q1 = Atom or Layout, when not yet registered. diff --git a/skills/studio/complete-the-build.md b/skills/studio/complete-the-build.md new file mode 100644 index 0000000..faaabee --- /dev/null +++ b/skills/studio/complete-the-build.md @@ -0,0 +1,173 @@ +--- +name: complete-the-build +--- + +## When to use + +The definition of done for any Studio build. Ten deliverables that decide whether the thing you built is usable by a marketer rather than merely rendering. Every one of them is silent when missing: nothing errors, the page still loads, and the gap only surfaces when a non-developer opens Studio and can't find, recognize, or edit anything. + +Phrases: "is this finished", "anything missed", "why is the palette all blank tiles", "why can't I click the text to edit it", "no thumbnail on my sections", "Visual Builder does nothing", "ready to hand over". Also run it **unprompted** at the end of any build (component registration, Section, Template, page migration) and cite it in the plan up front so the user knows these are coming. + +Do NOT use for: debugging a component that renders wrong (`troubleshoot-data-binding`), a canvas that won't load (`troubleshoot-canvas`), or first-time install (`install-studio`). + +> **Auth preflight: settle the credential before the first API call.** Resolve it OAuth-first per [`authenticate-cma`](authenticate-cma.md): `CS_OAUTH_ACCESS_TOKEN`, else the Contentstack MCP's stored session: Read that skill for where it is, even with no MCP connected. **Never ask the user for a session `authtoken`.** If nothing resolves, or a refresh fails with `400 invalid_refresh_token`, hand them `! CONTENTSTACK_REGION=<code> npx @contentstack/mcp --auth` (it needs a TTY and a browser, so it cannot be run for them) and wait. `403 error_code 316` is a valid credential aimed at another org: fix the org or the `api_key`, do **not** re-authenticate. + +# Complete the build: the ten things that are never optional + +For setup-only work, use `convert-project-to-studio` and `verify-setup` for completion. Registration, thumbnails, section structure, and publishing below apply only to authored components/compositions. Do not expand an enablement request into authoring to satisfy this checklist. HTTP reachability alone does not establish that the editor renders or live edits work. + +Architecture is half the job. A perfectly decomposed set of Sections with no thumbnails, no CSLP tags and Live Preview switched off is a **developer's** artifact: it renders, and nobody else can use it. These ten close that gap, the eighth from the other direction: a template that ships every one of the others and still has no Sections at all. + +Each is owned by exactly one skill. This file is the checklist and the gate: read the owner for the how. + +| # | Deliverable | Applies to | Owner skill | Failure when missing | +|---|---|---|---|---| +| 1 | `cslp: { appendTags: true }` in SDK init | once per project | [`install-live-preview`](install-live-preview.md) | Both tag bags arrive empty everywhere at once, no component fix helps | +| 2 | `studioAttributes` spread on the root **and** `wrap: false` in the register entry, plus `$prop` twins on bound elements | **every** registered component | [`register-component`](register-component.md) § The `studioAttributes` contract | Element renders but Visual Editor can't select or inline-edit it. `wrap: false` without the spread is worse: the node is unselectable entirely | +| 3 | `thumbnailUrl` data URI | **every** `registerComponent` call | [`register-component`](register-component.md) | Palette renders a text placeholder. A 20-component palette is an unbrowsable text wall | +| 4 | `ui_preview` asset | **every** Section composition | [`author-composition-via-api`](author-composition-via-api.md) § Section thumbnails | Blank tile in the Sections accordion, authors can't tell sections apart. The CMA never populates it: a scripted build leaves every Section blank and nothing errors | +| 5 | Live Preview enabled on the stack + preview token | once per project | [`enable-visual-experience`](enable-visual-experience.md) | Canvas blank / edits never reflect. Looks like a broken install | +| 6 | The `/canvas` route mounted | once per project | [`setup-section-preview`](setup-section-preview.md) | `MISSING_CANVAS_URL`. Nothing can be authored at all | +| 7 | **Published** to the environment the project targets | **every** Section and Template, and again after every tree edit | [`author-composition-via-api`](author-composition-via-api.md) | Delivery serves the previous version, or nothing. The CMA looks perfect and the canvas resolves the composition | +| 8 | **The template is composed of Sections**, not one component bound to the page body | **every** Template | [`build-connected-template`](build-connected-template.md) § Post-build structural check | Nothing. The page renders correctly, matches production and passes every other check here: it simply has no Sections, no Section Slots and no reuse, and an author cannot rearrange it. The only way to see it is to count `section-composition` nodes | +| 9 | Live Preview `mode: "builder"` (with `editInVisualBuilderButton: { enable: false }`) | once per project | [`install-studio`](install-studio.md) | Visual Editor opens on "Initializing the editor…" and nothing is clickable. Nothing errors | +| 10 | A **new** entry is editable on the page: `fetchTemplateEntry` completes it, lists carry `<field>` and `<field>.<N>` tags | **every** Template | [`make-visual-editor-editable`](make-visual-editor-editable.md) | A new entry shows "Your text here", its empty fields and image can't be clicked, and list items can't be added, moved or deleted | + +**Why this matters.** Every failure above is silent and asymmetric: the developer's own loop (SSR renders, unit tests, a page in the browser) passes in all six cases, because none of them affect rendering. They only affect the editor, which is the audience the entire exercise exists for. That asymmetry is why these get skipped, and why they need a gate rather than good intentions. + +## The two thumbnail systems are not the same thing + +They are constantly conflated, and fixing one does nothing for the other. + +| | Components (#3) | Sections (#4) | +|---|---|---| +| Field | `thumbnailUrl` in the `registerComponent` call | `ui_preview` on the composition entry | +| Lives in | your code | the CMA (a `file` field pointing to an asset) | +| Made by | you, as an inline SVG data URI | Studio's editor on Save, or set by API | +| Shows up in | the palette's Registered Components | the palette's Sections accordion | + +Between the two, **nothing in the Components panel is ever a blank tile.** That is the acceptance bar, not "most tiles have pictures". + +### Unpublished is the hardest of the runtime seven to see + +(Deliverable 8 is invisible in a different way: it never misrenders at all. Only a node count finds it.) + +Every other row fails visibly once you look. This one hides behind a completely healthy CMA: + +- the composition exists, is bound, and carries its `ui_preview` +- the canvas **resolves the right composition** and reports the right `url` +- delivery serves the previous published version, or nothing at all + +So the page renders the **old** tree while every check you'd naturally run says the new one is correct. Measured: seven freshly rebuilt sections, all `publish_details: (NONE)`, cost four dev-server restarts, a URL swap and a `url_metadata` change before anyone checked publish state, because the symptom (old components rendering) points at the template, not at the sections it links. + +**Rebuilding a tree creates an unpublished version on a published composition.** Editing is not shipping. Publish after every write, and after every edit. See [`author-composition-via-api`](author-composition-via-api.md). + +## Verification block: run this, don't assert it + +```bash +# 3 — every registration has a thumbnail (counts must be equal) +grep -c "registerComponent" <registration-file> +grep -c "thumbnailUrl" <registration-file> + +# 2 — both halves, counted separately; all three numbers should agree +grep -c "registerComponent" <registration-file> +grep -c "wrap: false" <registration-file> +grep -rl "studioAttributes" <components-dir> | wc -l + +# 1 — appendTags actually on +grep -rn "appendTags" <init-file> + +# 9 — builder mode and the completer +grep -rn 'mode: "builder"\|fetchTemplateEntry' <init-file> + +# 10 — on a NEW entry's page: one <field> tag and a <field>.<N> tag per item, for every list +curl -s "<new entry URL>" | grep -o 'data-cslp="[^"]*"' | sort -u +``` + +```bash +# 4 — sections missing ui_preview, over the CMA (never the CDA — it hides unpublished sections) +# +# ⛔ The discriminator is `place_composition_as` ("section" | "page"), NOT a `type` field. +# Filtering on `type` matches nothing and reports a clean run having inspected zero +# sections — a false pass, measured on a live project holding 94. +# ⛔ `limit` is capped at 100 server-side. Page with `skip`, or you audit the first 100 only. +curl -s -H "api_key: $API_KEY" -H "$CS_AUTH" \ + "https://api.contentstack.io/v3/content_types/compositions/entries?limit=100&include_count=true" \ +| python3 -c "import sys,json; d=json.load(sys.stdin); e=d['entries']; \ +sec=[x for x in e if x.get('place_composition_as')=='section']; \ +blank=[x['title'] for x in sec if not x.get('ui_preview')]; \ +unpub=[x['title'] for x in sec if not (x.get('publish_details') or [])]; \ +print(f'read {len(e)} of {d.get(\"count\")} | {len(sec)} sections | {len(blank)} blank | {len(unpub)} UNPUBLISHED'); \ +print('blank:', *blank[:20], sep='\n '); \ +print('unpublished:', *unpub[:20], sep='\n ')" +``` + +**Assert the field exists before trusting a pass.** If the compositions CT has no `ui_preview` field, the CMA **silently drops the key on write**, every section reads back blank while every write returns `200`: + +```bash +curl -s -H "api_key: $API_KEY" -H "$CS_AUTH" \ + "https://api.contentstack.io/v3/content_types/compositions" \ +| python3 -c "import sys,json; f=[x['uid'] for x in json.load(sys.stdin)['content_type']['schema']]; \ +print('ui_preview field:', 'present' if 'ui_preview' in f else 'MISSING - writes will no-op')" +``` + +**#2 is only verified in the DOM.** Open a page in Studio, inspect a rendered atom: its **root** element must carry `data-cslp`, and each bound text/image element must carry its own. With `wrap: false` that root is the component's own element: if you still see an SDK wrapper `<div>` around it, the entry is missing `wrap: false`. If the root carries no attributes at all, the component is missing the spread. A tagged root with bare fields means the `$`-twins were dropped, Visual Editor then looks like it "half works". A real audit of 19 atomic components found **0** spreading `studioAttributes`, nothing anywhere reported it. + +**#4 must be re-read, not assumed.** A `200` on the PUT is not proof: if the compositions CT lacks the `ui_preview` field the CMA silently drops the key. Re-read the entry and confirm `.entry.ui_preview.url` resolves. + +## Where this runs in the build + +Not a final sweep. Items 1, 5 and 6 are **preflight**. They gate everything after them. Items 2, 3 and 4 are **per-piece**, done as each piece is created, never batched to the end: + +``` +preflight : 1 appendTags · 5 Live Preview + token · 6 /canvas route · 9 builder mode +per component : 2 CSLP tags · 3 thumbnailUrl ← at registration time, same commit +per section : 4 ui_preview ← immediately after the composition write +handover : re-run the verification block; report counts, not adjectives +``` + +**Batching to the end is how it gets skipped.** A build script that writes 40 sections and leaves thumbnails "for a follow-up command" produces 40 blank tiles, because the follow-up never runs. One live project ended up with **50 of 79 sections** blank exactly this way. + +## Saying it in the plan: plain language for a non-technical approver + +These belong in the plan's Layer 1 summary, in this vocabulary, never by their field names: + +| Deliverable | How to say it | +|---|---| +| 2 + 5 + 9 + 10 | you can click any text or image on a preview of the real page and change it right there, add, move and remove list items, and edit a brand-new page the same way | +| 3 | each of your building blocks shows a picture of itself, so you can tell them apart when adding one | +| 4 | each reusable block shows a picture of itself in the block list | +| 1, 6 | (don't mention: plumbing, no user-visible promise) | + +Print them under a heading like **"What you'll be able to do yourself"**: the approver is deciding whether this is worth doing, and "editable in place, on the real page" is the whole answer. + +## Acceptance + +- [ ] All six rows verified by the commands above (or the DOM check for #2), and the **counts printed**, never "thumbnails added" without the number. +- [ ] `registerComponent` count **equals** `thumbnailUrl` count in every registration file. +- [ ] Zero Section compositions with an empty `ui_preview`, audited over the **CMA**, including sections created as a side effect (clones, rescopes, batch builds). The audit filtered on **`place_composition_as`**, paged past `limit=100`, and printed how many entries it actually inspected: `0 sections` is a broken audit, not a clean one. +- [ ] A rendered atom's root shows `data-cslp` in the DOM **and** its bound fields show their own. +- [ ] **Every Section and Template is published to the environment the Studio project targets**, verified by a non-empty `publish_details`, not by the write returning `200`. Re-published after every tree edit. +- [ ] Nothing was reported as done while any row was outstanding. The honest phrasing is "created, thumbnail pending", then finish it. + +## Common pitfalls + +| Pitfall | Why it bites | Fix | +|---|---|---| +| Editing a composition's tree and not republishing | The edit creates an unpublished version, delivery keeps serving the old one. The CMA, the canvas and the resolved `url` all look right, so the investigation goes to the template instead of the sections | Publish after every write **and** every edit, assert `publish_details` is non-empty | +| Treating these as polish to do later | All six are invisible to the developer loop, so "later" never arrives: the user discovers them when they open Studio and can't work | Preflight items gate the build, per-piece items ship in the same step that creates the piece | +| Filtering compositions on a `type` field | There is no such field: the discriminator is `place_composition_as`. The audit matches nothing and reports success having inspected **zero** sections. Measured on a live project holding 94 | Filter on `place_composition_as == "section"` and print the inspected count, so an empty match is visible | +| Trusting a single page of results | `limit` is capped at 100 server-side, so a project with 141 compositions silently audits the first 100 | Page with `skip` until `count` is reached | +| Auditing `ui_preview` over the delivery API | The published list is short: one project read 86 compositions on the CDA vs **122** on the CMA, hiding 36 blank sections | Audit over the CMA | +| Fixing `thumbnailUrl` and calling section thumbnails done (or vice versa) | Two unrelated systems, two different panels | See § The two thumbnail systems | +| `thumbnailUrl` pointing at a CDN / asset / `/public` URL | The palette renders inside Studio's iframe. External requests fail silently, which leaves a blank or broken tile, identical to no thumbnail | Inline SVG data URI | +| Auditing components one by one when tags are missing everywhere | Almost always `cslp.appendTags` off (#1), not a component bug | Check the SDK config first | +| Screenshotting a Section's own canvas to fill `ui_preview` | A section canvas has no preview entry, so every bound prop renders its default, measured 0 to 1.2% ink coverage. Near-identical "Your headline here" tiles are worse than blank, because they stop reading as empty and every capture-when-missing pass then skips that section forever | Re-save in the editor (real render), pick an image in the Edit Section modal, or generate a schematic from the composition's own `ui`, [`author-composition-via-api`](author-composition-via-api.md) § Fix D | + +## See also + +- [`plan-studio-architecture`](plan-studio-architecture.md): prints these six in the plan up front, so the user approves a build that includes them +- [`byoc-end-to-end`](byoc-end-to-end.md): the execution macro, this checklist is its Phase 6 gate +- [`authenticate-cma`](authenticate-cma.md): how the audits above authenticate, OAuth-first, no pasted session token +- [`verify-setup`](verify-setup.md): proves the installation works, this proves the build is usable +- [`verify-visual-parity`](verify-visual-parity.md): proves it matches the design diff --git a/skills/studio/compose-marketing-section.md b/skills/studio/compose-marketing-section.md new file mode 100644 index 0000000..e195592 --- /dev/null +++ b/skills/studio/compose-marketing-section.md @@ -0,0 +1,163 @@ +--- +name: compose-marketing-section +--- + +## When to use + +Given a design comp / description of a marketing section and a library of already-registered atomic + layout primitives (per [`docs/00-getting-started/composable-primitives.md`](../../docs/00-getting-started/composable-primitives.md)), pick the primitive tree that composes the section on Studio's canvas. + +Use when the user says "how do I build a Hero from primitives", "compose a feature grid in Studio", "which primitives for this testimonial layout", "make me a pricing tile from the atoms", "translate this Figma section into a primitive tree". Do NOT use before the primitives are registered (run `design-component-library` first). Do NOT use for whole-page decomposition (use `decompose-design`). Do NOT use to write the registration itself (use `register-component`). + +> **Auth preflight: settle the credential before the first API call.** Resolve it OAuth-first per [`authenticate-cma`](authenticate-cma.md): `CS_OAUTH_ACCESS_TOKEN`, else the Contentstack MCP's stored session: Read that skill for where it is, even with no MCP connected. **Never ask the user for a session `authtoken`.** If nothing resolves, or a refresh fails with `400 invalid_refresh_token`, hand them `! CONTENTSTACK_REGION=<code> npx @contentstack/mcp --auth` (it needs a TTY and a browser, so it cannot be run for them) and wait. `403 error_code 316` is a valid credential aimed at another org: fix the org or the `api_key`, do **not** re-authenticate. + +> **Pattern preflight: mandatory.** Before the first write, run [`match-existing-pattern`](match-existing-pattern.md): read the Templates and Sections already in the project, **reuse what already covers this schema**, and build to the pattern they use. Skip it only when the project has no compositions at all. Authoring something structurally foreign to what is there is rework, and it is invisible until someone opens the Layers panel. + +# Compose a marketing section from primitives + +## Context + +Once the 10-primitive library (4 atoms + 6 layouts) is registered, every marketing section is a composition, no code change per variant. This skill walks the user from "here's what I want to build" to a specific primitive tree they can drop into Studio's canvas. + +**This skill runs Q1** of the [four-question decision framework](../../docs/00-getting-started/composable-primitives.md#four-questions-to-answer-when-building-any-component): Can this be composed from the existing 10 primitives? + +- If **YES** (the common case): produce the composition tree. The user drops it on canvas. +- If **NO** (rare, genuinely new shape): halt and route to `design-component-library`, which starts at Q2 to decide what new primitive(s) to register. + +Never invent a new primitive silently. Never compose from a mix of registered primitives + "and one more component we'll add later". That's Q1 = no, escalate. + +The four canonical recipes cover the common cases and should be the first thing consulted: + +- [Hero from primitives](../../docs/40-recipes/hero-from-primitives.md): 5 variants (centered, split, full-bleed, thumbnail-top, two-CTA) +- [Feature grid from primitives](../../docs/40-recipes/feature-grid-from-primitives.md): 3-column feature callouts +- [Testimonial cards from primitives](../../docs/40-recipes/testimonial-cards-from-primitives.md): quote + avatar + author + role +- [Pricing tiles from primitives](../../docs/40-recipes/pricing-tiles-from-primitives.md): 3-tier with highlighted middle + +If the user's request matches one of these shapes, hand them the recipe directly. Only compose from scratch when the request diverges. + +## Task + +Walk the user through composing one section in four steps. + +### Step 1: Classify the section + +Ask (if not obvious from the request): + +- **1:1 or 1:many with the page entry?** A Hero binds to one landing_page entry directly. A Feature Grid iterates over N feature entries. This decides whether a Grid becomes a Repeater. +- **Does it have a background image?** If yes, BackgroundMedia is the wrapping layout under Section. +- **Is the content side-by-side or stacked?** Side-by-side means SplitRow. Stacked means Stack. +- **Does the section list distinct items with a shared shape?** Yes means Grid + Card iteration. No means a flat Stack of atoms. + +Map the answers to one of these shapes: + +| Shape | Wrapping layouts | List behavior | +|---|---|---| +| Single hero (1:1) | Section + optional BackgroundMedia + Stack | No Repeater | +| Split hero (1:1) | Section + SplitRow + Stack (left) + Image (right) | No Repeater | +| Item grid (1:many) | Section + Stack (intro) + Grid + Card per item | Grid is Repeater | +| Testimonial row (1:many) | Section + Stack + Grid + Card + SplitRow in footer | Grid is Repeater | +| Pricing row with highlight (1:many, conditional) | Section + Stack + Grid + Condition Block + Card | Grid is Repeater, Condition on `is_highlighted` | + +If the shape doesn't match any of these, note the deviation and pick the closest. The primitive tree adapts. + +### Step 2: Draw the composition tree + +Produce a text tree using the format from the recipes: + +``` +Section (spacing: <choice>, background: <choice>, contentAlign: <choice>) +└── <optional BackgroundMedia | SplitRow> + └── Stack (spacing: <choice>, alignment: <choice>) + ├── Heading (text: <content or binding>, level: <h1|h2|h3|h4>, emphasis: <default|inverse>) + ├── Description (text: <content or binding>, emphasis: <default|muted|inverse>) + └── Button (label: <content or binding>, variant: <choice>, size: <choice>) +``` + +Rules for the tree: + +- Every level indented under the previous with `└──` / `├──`. +- Each node lists its primitive name + non-default props inline. +- Content-bearing atoms (Heading, Description, Button, Image) show their `text` / `href` / `src`: either a literal value in double quotes or a `← entry.<field>` binding arrow. +- Layout primitives (Section, Stack, Grid, etc.) show their configuration props only: no children fields, since children are the sub-tree. +- Card compound uses labeled slot lines: `├── header:`, `├── content:`, `└── footer:` with a nested tree per slot. + +### Step 3: Name the bindings vs static values + +Every atom-level prop is either **bound** to a CT field or **static**. Every layout-level prop stays **static** on the Section. Split them explicitly: + +``` +BOUND (per entry — via Data Picker) + Heading.text ← entry.<field> + Description.text ← entry.<field> + Button.label ← entry.<field> + Button.href ← entry.<field> + Image.src ← entry.<field>.url + ... + +STATIC (design decisions — set once on the Section) + Section.spacing: <choice> + Section.background: <choice> + SplitRow.ratio: <choice> + Stack.spacing: <choice> + Grid.columns: <n> + Card.variant: <choice> + Card.padding: <choice> + BackgroundMedia.overlay: <choice> + +EXPOSED PROP CANDIDATES (optional — Template author can override per drop) + Section.background (per-Template treatment) + Grid.columns (2/3/4 variations per Template) + SplitRow.ratio (rare — usually stays static) +``` + +Never expose atom props. Content flows via CMS. Visual polish stays in the DS. + +### Step 4: Point at the closest recipe + +If the composition matches one of the four canonical recipes, close with: + +> This composition is a variation of the [Hero / Feature Grid / Testimonial / Pricing] recipe. Follow that recipe's step-by-step to build it in Studio. Substitute the tree above where the recipe's tree appears. + +If it diverges, close with: + +> The composition is novel enough to warrant its own recipe entry. Once built and validated, propose adding it as a fifth recipe under `docs/40-recipes/`. + +## Inputs needed from the user + +- **What section are you building?**: a rendered mock, a design description, or a target section type name. +- **CT relationship**: is the content 1:1 with the page entry, or a list of items? +- **Are the 10 primitives already registered?**: if not, run `design-component-library` first. + +If any is missing, ask before composing. Do NOT invent primitives: the tree must use only the registered 10. + +## Acceptance + +This skill succeeds only when ALL of the following are true: + +- [ ] User confirms the primitives are registered. +- [ ] The composition tree uses only primitives from the 10-primitive set (or explicitly flags a new atom/layout needed as a library extension). +- [ ] Bindings and static values are split explicitly. +- [ ] The tree matches (or clearly diverges from) one of the four canonical recipes. The appropriate one is linked. + +If the user's request needs a primitive that isn't registered, stop and route them back to `design-component-library` for the extension conversation. Never silently invent a new primitive. + +## Common pitfalls + +| Pitfall | Why it bites | Fix | +| --- | --- | --- | +| Adding a props table to an atom in the tree | The tree is composition, not registration | Only list config values as inline notes. The primitive's registered schema is the source of truth | +| Exposing an atom prop (e.g. Heading.emphasis) as an Exposed Prop | Author picks visual polish, DS drifts | Exposed Props are Section-level layout knobs only | +| Composing a "Hero" node instead of Section + BackgroundMedia + Stack + atoms | Reintroduces the monolith | Every section starts with Section as the outermost layout, atoms as the leaves | +| Binding layout props to CT fields | Layout becomes per-entry, DS control lost | Only atom-level content props bind | +| Missing a `Repeater` mention on 1:many shapes | Grid renders one iteration on drop, author sees only 1 card | Explicitly note "Grid, with a Repeater over `<field>`" when the CT relationship is 1:many | + +## See also + +- [`docs/00-getting-started/composable-primitives.md`](../../docs/00-getting-started/composable-primitives.md): the foundational pattern doc with all 10 schemas. +- [`docs/40-recipes/hero-from-primitives.md`](../../docs/40-recipes/hero-from-primitives.md): canonical Hero recipe (5 variants). +- [`docs/40-recipes/feature-grid-from-primitives.md`](../../docs/40-recipes/feature-grid-from-primitives.md): canonical Feature Grid recipe. +- [`docs/40-recipes/testimonial-cards-from-primitives.md`](../../docs/40-recipes/testimonial-cards-from-primitives.md): canonical Testimonial recipe. +- [`docs/40-recipes/pricing-tiles-from-primitives.md`](../../docs/40-recipes/pricing-tiles-from-primitives.md): canonical Pricing recipe. +- `design-component-library`: run first if the primitives aren't registered yet. +- `build-section`: once the tree is composed, actually save the Section in Studio. +- `use-repeater`: for the Repeater step on a 1:many Grid. +- `use-condition-block`: for the highlighted-tier logic in Pricing. diff --git a/skills/studio/configure-csr-vs-ssr.md b/skills/studio/configure-csr-vs-ssr.md new file mode 100644 index 0000000..5657d47 --- /dev/null +++ b/skills/studio/configure-csr-vs-ssr.md @@ -0,0 +1,337 @@ +--- +name: configure-csr-vs-ssr +--- + +## When to use + +**Whole BYOC plan** ("BYOC", "use my components in Studio")? Read [`byoc-end-to-end`](byoc-end-to-end.md) instead. + +Pick and wire a Studio page's render path: CSR (client, `useCompositionData`) or SSR/RSC (server, `sdk.fetchCompositionData`), on Next.js (App/Pages), Remix, Astro or Vite/SPA. Phrases: "CSR or SSR", "Next App Router setup", "hydration mismatch", "SEO meta missing", "Live Preview broken after switching render mode". Also diagnoses broken Studio iframe overrides after a render-mode change. + +> **Mandatory auth preflight: settle the credential before the first API call.** Resolve it OAuth-first per [`authenticate-cma`](authenticate-cma.md): `CS_OAUTH_ACCESS_TOKEN`, else the Contentstack MCP's stored session: Read that skill for where it is, even with no MCP connected. **Never ask the user for a session `authtoken`.** If nothing resolves, or a refresh fails with `400 invalid_refresh_token`, hand them `! CONTENTSTACK_REGION=<code> npx @contentstack/mcp --auth` (it needs a TTY and a browser, so it cannot be run for them) and wait. `403 error_code 316` is a valid credential aimed at another org: fix the org or the `api_key`, do **not** re-authenticate. + +# Configure CSR vs SSR for a Studio composition route + +## Read this first: SSR is available on every host + +The SDK ships two App Router paths (client-component SSR via the main entry, and an RSC entry with a ~142-byte structural bundle), plus recipes for Node/Express/Fastify, Next Pages Router, Remix, Astro, and Gatsby. Every host renders the full composition into the server HTML. `curl` returns real content, SEO works, no placeholder-defer. + +Pick your recipe from the framework recipes chapter and copy it verbatim: + +- **[Next.js Pages Router](../../docs/60-framework-recipes/nextjs-pages-router.md)**, **[Remix / React Router 7](../../docs/60-framework-recipes/remix.md)**, **[Astro](../../docs/60-framework-recipes/astro.md)**, **[Gatsby](../../docs/60-framework-recipes/gatsby.md)**: adapted recipes. +- **[Next.js App Router: RSC entry](../../docs/60-framework-recipes/nextjs-rsc.md)**: CI-tested on Next 14 + Next 16. Smallest possible client bundle. +- **[Next.js App Router: client-component SSR](../../docs/60-framework-recipes/nextjs-app-router.md)**: main-entry path, simplest wiring, full SEO. +- **[Next.js Pages Router](../../docs/60-framework-recipes/nextjs-pages-router.md)**, **[Remix / React Router 7](../../docs/60-framework-recipes/remix.md)**, **[Astro](../../docs/60-framework-recipes/astro.md)**, **[Gatsby](../../docs/60-framework-recipes/gatsby.md)**: adapted recipes. + +Same three-call contract everywhere (`fetchCompositionData`, then `<StudioComponent />`, then `getSSRStyleTags` or `<StudioServerStyles>`), plus `getCompositionMetadata` for SEO. Sections below cover the render-strategy decision itself. The mechanics live in the recipes above. + +## Context + +A Studio composition can be rendered via two data-fetching paths. Both return the same `StudioComponentSpecOptions` shape. Only where the fetch runs differs. + +- **CSR**: `useCompositionData({ url })` hook. Runs in the browser. Best for Vite SPAs / routes where SEO is not required. +- **SSR / RSC**: `sdk.fetchCompositionData({ url, searchQuery }, { locale })`. Runs on the server. Required for SEO and for routes where the initial HTML must contain the rendered composition. + +`<StudioComponent specOptions={...} />` is the single renderer for both visitor pages and pages opened inside Studio's builder iframe. It auto-detects mode and attaches the Visual Editor overlay when needed. `<StudioCanvas />` is a separate client-only component for the Studio canvas route and is **not** a render-strategy decision. + +**SDK shape.** `studioSdk` is the imported namespace. You must call `studioSdk.init({ stackSdk, contentTypeUid })` once at boot and use the **returned** object. Server fetches go through `sdk.fetchCompositionData(...)` on that returned object. + +```ts +import { studioSdk } from "@contentstack/studio-react"; +export const sdk = studioSdk.init({ stackSdk, contentTypeUid }); +// later, server-side: +const specOptions = await sdk.fetchCompositionData({ url, searchQuery }, { locale }); +``` + +Reference doc: `docs/10-setup/app-prerequisites/choosing-between-csr-and-ssr-rendering.md` and `05-ssr-composition-query.md`. + +## The render-pipeline rule (read this first) + +What determines **true SSR** (the composition DOM in the server HTML) is whether `<StudioComponent>` renders through a **`renderToString` pipeline** (Pages Router / Vite custom server / Remix: server React dispatcher present, built-ins execute server-side) OR through **React Server Components** (App Router: built-ins are `"use client"` and the SDK renderer detects them as `Symbol.for("react.client.reference")` and so emits `data-cs-defer-builtin` placeholders, deferring real render to client hydration). The data-fetch function (`fetchCompositionData` vs `useCompositionData`) is orthogonal and does **not** by itself produce SSR. + +**Net:** every mainstream host, including Next App Router (main entry and RSC entry), Pages Router, Remix, Astro, Gatsby, and plain Node, renders the composition into the server HTML via the SDK's three-call contract. See the [framework recipes](../../docs/60-framework-recipes/index.md) for the per-host wiring. The historical `data-cs-defer-builtin` placeholder path was removed in the SDK's dist-ESM restructure (#871 / #874). + +## Decision table + +| Situation | Path | True SSR? (composition DOM in server HTML) | +| --- | --- | --- | +| Vite / React SPA, no SEO | **CSR**: `useCompositionData` | No (client only) | +| **Next.js Pages Router** | **SSR**: `sdk.fetchCompositionData` inside `getServerSideProps` | **Yes**: `renderToString` | +| **Next.js App Router (main entry)** | `sdk.fetchCompositionData` in a Server Component, then a client wrapper renders `<StudioComponent />` | **Yes**: Next server-renders the client component's initial output | +| **Next.js App Router (RSC entry)** | `sdk.fetchCompositionData` in a Server Component, then `<StudioServerStyles />` plus `<StudioComponent />` from `@contentstack/studio-react/rsc` | **Yes**: ~142B client bundle for structural content. Custom components register as client islands automatically | +| Vite custom-server SSR | `renderToString` + `hydrateRoot` + `sdk.fetchCompositionData` (mirror SDK's `test-resources/ssr-react`) | **Yes** | +| Remix | `sdk.fetchCompositionData` inside the `loader` | **Yes**: `renderToString` | +| Static / rarely changing | **SSG/ISR**: `getStaticProps` + `revalidate` | **Yes** (at build/revalidate. Via `renderToString`) | + +CSR works on **any** framework including App Router. The `isLoading` gate keeps built-ins out of the server prerender entirely. Use CSR as the universal fallback when true SSR isn't viable on a given framework. + +<a id="resolve-composition-helper"></a> + +## `fetchCompositionData` does not return "not found": it throws. Wrap it. + +**Verified against the SDK source** (`studio-client/src/utils/composable-studio-error.ts`, `composable-studio/sdk.ts`). On a miss, `sdk.fetchCompositionData` **throws**. It does not resolve with `hasSpec: false`. Every `if (!specOptions.hasSpec) notFound()` line is therefore dead code on the path it was written for: the `await` throws first, the framework's error boundary catches it, and the visitor gets a **500 where a 404 belongs**. + +What it throws is a `ComposableStudioError`. **Do not branch on a list of names.** Call `isCompositionNotFound(err)`, exported for exactly this decision: + +```ts +import { isCompositionNotFound } from "@contentstack/studio-client"; +``` + +It answers *did we establish that nothing is here?* and is the only thing that should decide a 404. A list of names has to be kept in step with the SDK, and the cost of drift is one-way: mistake a setup failure for a missing page and a wrong api key or a delivery outage returns **404 on pages that exist**, de-indexing a working site. + +Also on the error: `err.code` (the precise case, for logs and support) and `err.errorClass`, of which only `NOT_FOUND` is a 404. `err.causes` carries `{ cause, fix }` pairs for that failure, most likely first: log it in dev, it is the fastest diagnosis available. + +```ts +// lib/resolve-composition.ts +import { isCompositionNotFound } from "@contentstack/studio-client"; + +export async function resolveComposition(sdk, args, opts = {}) { + try { + const specOptions = await sdk.fetchCompositionData(args, opts); + return { specOptions, notFound: false }; // resolved ⇒ renderable + } catch (err) { + if (isCompositionNotFound(err)) return { specOptions: null, notFound: true }; + throw err; // real failure — must stay a 500 + } +} +``` + +**Do not blanket-catch.** A bare `try { … } catch { notFound() }` turns every CDA outage, expired delivery token, and network blip into a silent 404. The site looks empty instead of broken, and monitoring sees nothing. Ask the predicate. Rethrow the rest. + +**`content_type_url_pattern` moves the failure, it doesn't remove it.** With `url_metadata.url_source: "content_type_url_pattern"`, the composition's pattern matches a whole family of URLs (`/blog/{{entry.title}}` matches any `/blog/*`), so a bad slug sails past **pattern match** and dies at **entry lookup** — a different code, `URL_MATCHED_NO_ENTRY` rather than `NO_COMPOSITION_FOR_URL`. Both are `NOT_FOUND`, so the predicate covers both without you listing either; this is the case that used to catch people out when they hand-rolled the list. See [`troubleshoot-composition-resolution`](troubleshoot-composition-resolution.md) § Runtime error taxonomy. + +**`useCompositionData` doesn't throw: it hands you the same object.** The hook catches the rejection and stores it verbatim in `error`, so `isCompositionNotFound(error)` classifies identically. An `if (error) return <ErrorState/>` branch renders an error state for URLs that simply have no composition. + +Every fetch snippet below is written against `resolveComposition`. If you copy a raw `sdk.fetchCompositionData` call from elsewhere, wrap it. + +## A server with a preview token can serve drafts to visitors + +The Delivery stack is shared by every request on a server process. `fetchCompositionData` sets a request's preview hash on it and never clears it when the next request has none (by design), so later visitor requests go to the preview host with the last hash. Measured: one request with `?live_preview=<hash>`, then the next plain request went to `rest-preview.contentstack.com`. Mechanism: [`troubleshoot-canvas`](troubleshoot-canvas.md) § shared stack instance hazard. + +A deployment with **no** preview token is safe ([`deploy-studio-site`](deploy-studio-site.md)). On a preview deployment, clear the hash when a request has none: + +```ts +const q = new URLSearchParams(searchQuery); +if (!q.get("hash") && !q.get("live_preview")) stack.livePreviewQuery({ live_preview: "" }); +``` + +Measured: the plain request then stayed on the CDN. Requests in flight at the same time still share the stack; a busy preview server needs a stack per request. + +## Task + +1. **Pick the path** from the decision table using `framework`. Run only the matching block below. Skip the others. + +2. **CSR (Vite / SPA, also the universal fallback on any framework, including App Router).** + ```tsx + "use client"; + import { useCompositionData, StudioComponent } from "@contentstack/studio-react"; + import { isCompositionNotFound } from "@contentstack/studio-client"; + + export default function Page() { + // window is browser-only — guard for any framework that prerenders this page (Next does, even with "use client"). + const url = typeof window !== "undefined" ? window.location.pathname : "/"; + const { specOptions, isLoading, error } = useCompositionData({ url }); + if (isLoading) return <Loading />; + // The hook surfaces the rejection as `error`, so a plain `if (error)` branch + // renders an error state for a URL that simply has no composition. Classify + // first — a miss is a 404, everything else is a real error. + const isMiss = isCompositionNotFound(error); + if (error && !isMiss) return <ErrorState message={error instanceof Error ? error.message : String(error)} />; + if (isMiss || !specOptions?.spec) return <NotFound />; + return <StudioComponent specOptions={specOptions} />; + } + ``` + The hook reads `window.location.search` automatically, no `searchQuery` argument required. CSR is the safe universal path: built-ins never enter a server pre-render, so the App-Router RSC crash from registering basics on the server can't happen. + + Ensure **built-ins + custom components are registered before render** (see `register-component`). On CSR, the registration must run client-side at module init. + + **Live Preview wiring is separate.** If this route needs to reflect author edits in real time, add the `onEntryChange` bridge from `install-live-preview` (step 7). CSR pages call the hook's `refetchData` inside the loop-safe callback — it re-fetches just the bound data, which is all a live edit changes. + +3. **App Router (main entry): SSR through a client wrapper.** A Server Component fetches, a `"use client"` wrapper renders, and Next puts that output in the initial HTML (verified with `curl` in the App Router recipe). Initialize the sdk in a server-only module (no `"use client"`). Register components in a `"use client"` module the wrapper imports. Rendering `<StudioComponent />` directly in a Server Component makes built-ins `react.client.reference` proxies: the SDK emits `data-cs-defer-builtin` placeholders, filled only on hydration. + ```tsx + // app/[[...slug]]/page.tsx — NO "use client" + import { sdk } from "@/lib/studio.server"; + import { ClientRender } from "./ClientRender"; + import { notFound } from "next/navigation"; + + export default async function Page({ params, searchParams }) { + const { slug } = await params; // a Promise on Next 15+ + const url = "/" + (slug?.join("/") ?? ""); + const searchQuery = new URLSearchParams((await searchParams) as Record<string,string>).toString(); + const { specOptions, notFound: miss } = await resolveComposition( + sdk, + { url, searchQuery }, + { locale: "en-us" }, + ); + // resolveComposition rethrows real failures, so reaching here with miss=true + // genuinely means "no composition at this URL" — 404 is the right answer. + // Do NOT add `|| !specOptions.hasTemplate` here. hasTemplate is false for + // every FREEFORM composition (no connected content type), so that guard + // 404s every freeform page however it is published. `miss` already carries + // the only not-found answer there is. + if (miss) notFound(); + return <ClientRender specOptions={specOptions} />; + } + + export async function generateMetadata({ params, searchParams }) { + const { slug } = await params; + const url = "/" + (slug?.join("/") ?? ""); + // Metadata must never throw — a miss here should degrade to empty tags, + // not take down the page that resolves fine one line later. + const { specOptions } = await resolveComposition(sdk, { + url, + searchQuery: new URLSearchParams(await searchParams).toString(), + }); + const seo = specOptions?.seo; + return seo + ? { title: seo.pageTitle, description: seo.pageDescription, + openGraph: { images: seo.openGraphImage ? [seo.openGraphImage] : [] } } + : {}; + } + ``` + Client wrapper (just renders, no Live Preview wiring here): + ```tsx + "use client"; + import { StudioComponent } from "@contentstack/studio-react"; + + export function ClientRender({ specOptions }) { + return <StudioComponent specOptions={specOptions} />; + } + ``` + + **Live Preview wiring is separate.** If this route needs real-time author updates, mount the `LivePreviewBridge` from `install-live-preview` (step 7) once in the app shell (e.g. in `app/layout.tsx` inside a client boundary). App Router routes call `router.refresh()` in the loop-safe callback. Do NOT put `[router]` in the effect deps. It produces an infinite refetch loop. + Canvas route stays client-only: + ```tsx + // app/studio-canvas/page.tsx + "use client"; + import { StudioCanvas } from "@contentstack/studio-react"; + export default function Canvas() { return <StudioCanvas />; } + ``` + +4. **SSR: Next.js Pages Router.** Also full SSR: the composition DOM lands in the server HTML via `renderToString`. Register all components (built-ins + custom) at module scope in `pages/_app.tsx` with **static imports** so the same React instance runs server + client. + ```ts + // pages/blog/[slug].tsx + export async function getServerSideProps(context) { + const searchQuery = context.req.url.split("?")[1] ?? ""; + const url = `/blog/${context.params.slug}`; + const { specOptions, notFound } = await resolveComposition(sdk, { url, searchQuery }); + if (notFound) return { notFound: true }; + return { props: { specOptions } }; + } + ``` + Page component renders `<StudioComponent specOptions={specOptions} />`. `specOptions` is JSON-serializable. + + **Live Preview wiring is separate.** If this route needs real-time author updates, mount the `LivePreviewBridge` from `install-live-preview` (step 7) in `pages/_app.tsx` inside a client boundary. Pages Router routes call `router.replace(router.asPath, undefined, { scroll: false })` in the loop-safe callback. Do NOT put `[router]` in the effect deps. The router identity changes on every replace, which would produce an infinite refetch loop. + + Verify true SSR with a **prod build** (`next build && next start`), not `next dev`. Dev is per-boot racy (recompile + module-init order + StrictMode double-invoke). Prod is deterministic. Acceptance: `curl <url>` returns 200 with the composition DOM (incl. Repeater iteration items) in `<body>` after stripping `<script>` tags, **no `data-cs-defer-builtin`** attributes. + +5. **SSR (Remix).** Put sdk init in `app/lib/studio.server.ts` so secrets tree-shake out of the browser bundle. + ```ts + import { json } from "@remix-run/node"; + import { sdk } from "~/lib/studio.server"; + + export async function loader({ request }) { + const url = new URL(request.url); + const { specOptions, notFound } = await resolveComposition(sdk, { + url: url.pathname, + searchQuery: url.search.replace(/^\?/, ""), + }); + if (notFound) throw new Response("Not Found", { status: 404 }); + return json({ specOptions }); + } + ``` + Component: `const { specOptions } = useLoaderData(); return <StudioComponent specOptions={specOptions} />;` + +6. **SSG / ISR.** Pass `searchQuery: ""` (no Studio iframe at build time), set `revalidate: 60` for ISR, and wire a Contentstack publish webhook to `res.revalidate(path)` for instant invalidation. Live Preview won't update SSG output until revalidation. If that matters, move the route to SSR. + +7. **Hydration discipline.** Pass the **same** `specOptions` the server fetched straight to the client wrapper. Do **not** re-fetch on mount with `useCompositionData` after an SSR fetch. That produces a hydration mismatch. Pin `locale` and `variantAlias` to identical values on both sides. + +8. **Iframe override forwarding.** On every SSR/RSC path, forward the full request query string into `searchQuery`. Studio's builder iframe appends its own params (locale, variant, preview entry) to the iframe URL. The SDK reads them itself. Your code must not parse or rely on specific param names. They're an internal contract between Studio and the SDK that may change. Just pass the raw query string through and the SDK does the rest. + +9. **Repeater iteration bindings.** If the composition includes a Repeater, the same `<StudioComponent />` resolves `repeater.<fieldUid>` bindings against each iteration's data, no extra wiring at the page level. + +10. **Verification.** Open the route in a regular tab and `view-source:`. For SSR/RSC/SSG the composition HTML must be in the initial response (not just a shell). `curl -A "Twitterbot" <url>` returns the og: meta tags. Open the same route inside Studio's preview iframe. The iframe's override params flow through `searchQuery` and the right entry/locale renders. Edit an entry in Live Preview. The page updates without a full reload. No React hydration warning in the console. + + **Verify SSR with `curl`, not a browser screenshot.** Next injects a FOUC guard (`body{display:none}` via `data-next-hide-fouc`) during SSR and removes it on hydration. A pre-hydration screenshot shows blank even when the real content is in the HTML. `curl -sk <url> | grep -q '<known-text>'` executes no JS and returns the truth. A blank browser screenshot does NOT mean SSR failed. + +## Inputs needed from the user + +1. `framework`: one of `next-app`, `next-pages`, `remix`, `vite-spa`, `astro`, `ssg` +2. `routePath`: the route file you'll write +3. `contentTypeUid`: Studio composition content type uid (defaults to `compositions`) +4. `defaultLocale`: defaults to `en-us` + +If `framework` is unclear, inspect `package.json`: `next` plus an `app/` dir means next-app, `next` plus a `pages/` dir means next-pages, `@remix-run/*` means remix, `vite` means vite-spa, and `astro` means astro. + +## Studio-authored styles on a server-rendered route: `getSSRStyles` + +Anything an author sets in the Design panel is generated at render time, not shipped in your stylesheet. A server render that emits only markup therefore arrives unstyled and restyles on hydration: a visible flash, worst on the slowest connections. + +`getSSRStyles(spec)` returns that CSS as a string. Emit it with the markup. + +```tsx +import { getSSRStyles, StudioComponent } from "@contentstack/studio-react"; + +const css = getSSRStyles(specOptions.spec); // design-token :root vars + component styles + +return ( + <> + {css ? <style precedence="default">{css}</style> : null} + <StudioComponent specOptions={specOptions} /> + </> +); +``` + +It takes one spec or an array of them, so a page assembled from several compositions emits one stylesheet. Outside React (Express, a custom server) `getSSRStyleTags(spec)` returns the same CSS already wrapped in a `<style>` tag, to concatenate into an HTML template after `renderToString`. + +CSR routes need neither: there is no server pass to be unstyled. + +## Acceptance + +Succeeds only when ALL of the following are true: + +- [ ] The route file uses the fetch path that matches the framework row in the decision table +- [ ] Server-rendered routes emit `getSSRStyles(specOptions.spec)` alongside the markup, checked by loading the route with JavaScript disabled, or `curl`, and confirming Design-panel styling is already applied +- [ ] Server-side paths forward `searchQuery` from the incoming request to `fetchCompositionData` +- [ ] **Every `fetchCompositionData` call is wrapped**, via `resolveComposition` or an equivalent try/catch that classifies known not-found ids as 404 and **rethrows everything else**. No bare `await sdk.fetchCompositionData(...)` on a route, and no blanket `catch { notFound() }` +- [ ] **Verified with a real miss:** request a URL with no composition (e.g. `/no-such-page`) and a valid-prefix-but-bad-slug URL under a connected template (e.g. `/blog/zzz-not-an-entry`). **Both return 404, not 500.** The second one is the `content_type_url_pattern` case and fails at a different stage. Testing only the first proves nothing about it +- [ ] `<StudioComponent />` is rendered inside a `"use client"` boundary on App Router / RSC paths +- [ ] If Live Preview is in scope for this route, the `LivePreviewBridge` from `install-live-preview` (step 7) is mounted in the app shell. Not needed for pure public SSR routes without Live Preview. +- [ ] No `useCompositionData` call exists on a route that already fetched server-side (no double-fetch) +- [ ] App Router routes export `generateMetadata` populated from `specOptions.seo` +- [ ] Management tokens, if any, only appear in `*.server.ts` (Remix) or non-`"use client"` modules (Next.js) +- [ ] Canvas route, if present, is a separate `"use client"` route mounting `<StudioCanvas />` +- [ ] Browser console shows no hydration mismatch warning on the rendered route + +If acceptance fails, report exactly which step broke and stop. + +## Common pitfalls + +| Pitfall | Symptom | Fix | +| --- | --- | --- | +| `useCompositionData` in a Server Component | "Hooks can only be called inside a function component" | Switch to `sdk.fetchCompositionData` + client wrapper | +| `<StudioComponent />` directly in a Server Component | Next.js client-component error | Wrap in a `"use client"` child taking `specOptions` as prop | +| `searchQuery` not forwarded | Iframe shows wrong locale/variant. Edit mode doesn't engage | Forward `request.url.search` / `new URLSearchParams(searchParams).toString()` | +| Re-fetched on the client after SSR | Hydration mismatch warning | Pass server `specOptions` straight to the client wrapper | +| Different `locale` server vs client | Mismatch warning, content flash | Pin both sides via the second arg of `fetchCompositionData` | +| Live Preview never updates the running page | Missing `onEntryChange` bridge | Wire the `LivePreviewBridge` from `install-live-preview` (step 7). Not a CSR-vs-SSR issue, same fix regardless of framework. | +| **Infinite `_next/data/<id>/<route>.json` (Pages) or RSC payload refetch: Network tab fires hundreds of times per edit** | `onEntryChange` wired in a `[router]`-dep effect | Move the wiring to the loop-safe pattern in `install-live-preview` (step 7): subscribe ONCE with empty deps, hold router in a `useRef`, skip the register-time fire, unsubscribe in cleanup. | +| **App Router route renders fine in the browser but `view-source` shows no composition DOM**: bound content only in `self.__next_f` flight payload | `<StudioComponent />` is rendered directly in a Server Component: the RSC pass sees every built-in as `react.client.reference`, emits `data-cs-defer-builtin` placeholders, and defers the real render to hydration. **Not true SSR.** | Render it through a `"use client"` wrapper (step 3), or move the route to **Pages Router** (or Vite custom-server / Remix). (P31) | +| **`transpilePackages: ['@contentstack/studio-*']` on App Router** | 500 with `Cannot read properties of null (reading 'useContext')` at `useData → RepeaterPreview` during the RSC server prerender | Don't transpile the SDK on App Router. It defeats the SDK's safe client-deferral (the basics resolve to real components, run on the server, and read a null React dispatcher). On Pages Router, transpiling is fine. (P31) | +| **Coexisting `app/` dir while SSR-ing a `pages/` route** | The Pages-Router SDK React resolves to the app-page runtime, which gives a null dispatcher and a crash | Make the project Pages-Router-only for the Studio SSR route (remove `app/` from that workspace). (P31) | +| **Judging SSR from `next dev`**: Repeater items intermittently render field defaults ("Your text here") | Per-boot dev race (recompile + module-init order + React StrictMode double-invoke) | Verify and ship from a **prod build**: `next build && next start`. Prod is deterministic. Dev is racy. (P31) | +| **`dynamic(() => import('@contentstack/studio-react'))` for the canvas, prod build fails** | `Package path "." is not exported from package` | `studio-react`'s `exports` map only exposes `.` under the `import` condition. Dynamic-import a **local wrapper** that statically imports `StudioCanvas`. (P31) | +| **Bare `await sdk.fetchCompositionData(...)` with an `if (!hasSpec) notFound()` guard below it** | Every unmatched URL returns **500, not 404**. The fetch rejects, so the guard line never executes. Error boundary shows a generic failure. Nothing names the missing composition | Wrap with [`resolveComposition`](#resolve-composition-helper). The guard was written for a resolve-with-falsy contract the SDK doesn't have | +| Blanket `try { … } catch { notFound() }` around the fetch | Every CDA outage, expired delivery token, and network blip silently becomes a 404. The site reads as "empty" instead of "broken" and monitoring stays green | Ask `isCompositionNotFound(err)`: true is a 404. Rethrow the rest so real failures stay 500s | +| Testing 404 handling only with a totally unmatched URL | Passes, while every bad slug under a `content_type_url_pattern` template still 500s if you hand-rolled a list of names. That path matches the pattern and fails later, at entry lookup, with a different code | Test both shapes. Use the predicate rather than a list, and both are covered | +| **Server-rendered route paints unstyled, then snaps into place on hydration** | Design-panel styles are generated at render time, not in your stylesheet. Markup alone carries none of them | Emit `getSSRStyles(specOptions.spec)` with the markup. See § Studio-authored styles on a server-rendered route | +| **Every bound image 400s through `next/image`** (`"hostname … is not configured under images"`) | Assets come from a Contentstack CDN host the optimizer doesn't trust by default. Nothing in the composition is wrong | Add the asset hosts to `images.remotePatterns` in `next.config.mjs`: `images.contentstack.io` plus your region's `*-images.contentstack.com`. Region-dependent, so read the URL the binding actually returned rather than guessing | +| SSG never updates after publish | Stale content | Configure ISR `revalidate` or wire a publish webhook to `res.revalidate(path)` | +| Management token in client module | Token shipped to browser | Move sdk init into `*.server.ts` / non-`"use client"` module | + +## See also + +- `install-studio`: installs the three SDKs and wires `studioSdk.init` +- `setup-section-preview`: adds the `<StudioCanvas />` route +- `troubleshoot-canvas`: when the iframe still doesn't render after both skills are run +- `docs/90-reference/composition-rendering-reference.md`: every prop + fetcher option the CSR / SSR pattern below pulls from diff --git a/skills/studio/configure-slot-defaults.md b/skills/studio/configure-slot-defaults.md new file mode 100644 index 0000000..7fb3381 --- /dev/null +++ b/skills/studio/configure-slot-defaults.md @@ -0,0 +1,130 @@ +--- +name: configure-slot-defaults +--- + +## When to use + +Set a Section Slot's Default section (render-time fallback when a placement leaves the slot empty) and Allowed sections (whitelist gating which sections a composer may drop). Use Studio UI, driven through Playwright per [`drive-studio-canvas-ui`](drive-studio-canvas-ui.md). Don't hand the clicks to the user or switch to the API unless they ask; that's [`author-composition-via-api`](author-composition-via-api.md). + +Use when a section exposes a Section Slot and the section author wants it to render something before anyone fills it, or wants to restrict what drops in. Phrases: "default section for the slot", "pre-fill empty slot", "only allow these sections", "restrict what drops into a slot". Do NOT use to create the slot: that's `use-section-slot`. + +> **Mandatory auth preflight: settle the credential before the first API call.** Resolve it OAuth-first per [`authenticate-cma`](authenticate-cma.md): `CS_OAUTH_ACCESS_TOKEN`, else the Contentstack MCP's stored session: Read that skill for where it is, even with no MCP connected. **Never ask the user for a session `authtoken`.** If nothing resolves, or a refresh fails with `400 invalid_refresh_token`, hand them `! CONTENTSTACK_REGION=<code> npx @contentstack/mcp --auth` (it needs a TTY and a browser, so it cannot be run for them) and wait. `403 error_code 316` is a valid credential aimed at another org: fix the org or the `api_key`, do **not** re-authenticate. + +# Configure Slot Defaults & Allowed Sections + +## Context + +> **Prerequisite: the slot must already exist.** Both settings are authored on an existing Section Slot. If the section has no slot yet, run [`use-section-slot`](use-section-slot.md) first. If the section canvas is blank, run [`setup-section-preview`](setup-section-preview.md). + +A Section Slot is an empty opening the next composer fills. Two optional settings let the **section author** shape that opening without giving up "anything goes": + +- **Default section**: one section that renders when a placement leaves the slot empty, so the section shows something useful before anyone touches it. +- **Allowed sections**: a whitelist of which sections a composer may drop into the slot. + +Both are per slot, both optional, and a slot with neither behaves exactly as before: empty until filled, accepts anything. + +Reference: `docs/34-smart-containers/slot-defaults.md`. + +Two properties that drive every judgment call below: + +- **The default is a render-time fallback, never a copy.** It lives only as a setting on the slot: nothing is written into the placement. Editing the default section later updates every empty slot pointing at it, and clearing a filled slot brings the default back. +- **An empty allowed list means no restriction**, not "allow nothing". The whitelist gates **sections only**: a composer can always drop plain components into a restricted slot. + +Stored on the slot node in the section's `ui` as `metadata.defaultSection` (a single `{ uid, _content_type_uid }` ref) and `metadata.allowedSections` (an array of the same). At save time the default is captured in the section's `linked_sections`. The allowed list deliberately is not, because it never renders. + +## When the default renders + +| Slot state | Section's own authoring canvas | Placement: Design mode | Preview mode | Deployed site | +| --- | --- | --- | --- | --- | +| Empty | Labeled placeholder (default **not** shown) | Drop zone + a hint naming the default | Renders the **default** | Renders the **default** | +| Empty, no default set | Labeled placeholder | Drop zone | Collapses to nothing | Collapses to nothing | +| Filled | - | Dropped content + drop affordances | Dropped content | Dropped content | + +Design mode keeps the drop zone on purpose: design is for building, preview is for seeing the result. A composer who drops the section and flips to Preview immediately sees a meaningful result. + +## Task + +1. **Open the section in section authoring mode**: not a page. These settings live on the section that owns the slot. Selecting a placed section's slot on a page opens a different panel (it lists what the composer has put in the slot). + +2. **Select the Section Slot node** on the canvas or in Layers. Open the right panel's **Settings** tab (gear icon; the **Data** tab is page data), then **Properties**. Two controls appear, in this order: + + | Control | Shape | Placeholder | + | --- | --- | --- | + | **Allowed sections** | multi-select | Select Sections | + | **Default section** | single-select, clearable | Select a default section | + + Both pickers search by title and page through the project's sections from the server (30 per request), so they reach every section, not just recently loaded ones. Both exclude the section currently being edited. + +3. **Set Allowed sections first when you want both.** Saving a non-empty allowed list clears a Default section that isn't in that list: pick the whitelist, then the default, and the default picker offers exactly the allowed set. + +4. **Pick the allowed sections.** Choose the sections that genuinely fit the opening's role and size. Leave the field empty to keep the slot open to anything. That is the correct choice unless a wrong drop would break the design. + +5. **Pick the Default section.** Choose one that reads as a sensible empty state (a placeholder media block, an empty-state card), not a section carrying real marketing copy: it renders on the live site until someone fills the slot. Clear the field to remove the default. + +6. **Save the section.** + +7. **Verify on a page.** Drop the section onto a page or template: + - Design mode: the empty slot shows its drop copy plus the hint "(When this slot is empty, the default section (**Title**) added by the section author is shown in Preview and on the live page.)" + - Flip to **Preview**, and the default renders. + - Drop a real component or section into the slot, and it replaces the default. + - Delete that content, and the default reappears. + +8. **Verify the whitelist** (only if set). Drag a section that is not on the list into the slot. Studio rejects it with "This section isn't allowed in this slot. Choose one of the slot's allowed sections, or drop a different component." and nothing is placed. The in-canvas **Select section** button and the right-panel picker offer only the allowed sections. A plain component still drops in: the gate is sections-only. + +## Nested defaults + +Defaults chain: if A's slot defaults to B and B's slot defaults to C, placing A and previewing expands the chain, A first, then B, then C, each default filling the next empty slot. Depth is unlimited. Studio resolves the chain with batched fetches, one round per nesting level, not one per section. + +Cycles are safe. The pickers list every section and do not gray out choices that would loop, but expansion **stops at render time** as soon as the chain would re-enter a section already in it: a chain of A, then B, then A again renders A, then B, then stops. Keep chains shallow anyway: every level is another fetch round. + +## Inputs needed from the user + +In this order. Stop and ask if the first two are missing, and require at least one of the last two. + +1. `sectionUid`: the section that owns the slot. +2. `slotLabel`: which slot (its drop placeholder label), when the section has more than one. +3. `defaultSectionUid`: the section to render while the slot is empty. +4. `allowedSectionUids`: the sections a composer may drop. + +If the user asks for a default that is not in a non-empty `allowedSectionUids`, say so and ask which one wins: writing it silently clears the default. + +## Acceptance + +This skill succeeds only when ALL of the following are true. If any fails, surface the failure and stop. + +- [ ] The settings are on the **section's** Section Slot node, not on a page-side placement. +- [ ] `defaultSectionUid` shows in the Default section field after save and reload. +- [ ] `allowedSectionUids` all show in the Allowed sections field after save and reload. +- [ ] When both are set, the default is one of the allowed sections. +- [ ] On a placement: the empty slot renders the default in **Preview** and shows the drop zone in **Design**, verified by a PNG screenshot of the canvas iframe, not by an a11y snapshot (iframe contents are opaque to it). +- [ ] Dropping content into the slot replaces the default. Deleting that content restores it. +- [ ] With a non-empty whitelist: a disallowed section is rejected with the warning and nothing is placed. A plain component still drops in. +- [ ] The default section renders correctly on the deployed/preview URL, not only inside Studio. + +## Common pitfalls + +| Pitfall | Why it bites | Fix | +| --- | --- | --- | +| Setting the Default section before the Allowed list | A non-empty whitelist that excludes the current default clears it, silently | Set Allowed sections first, then the default | +| Expecting the default in Design mode | Design mode keeps the drop zone by design | Flip to Preview, or check the deployed page | +| Expecting the default inside the section's own canvas | The section being authored shows only the labeled placeholder | Verify on a placement, not in section authoring | +| Reading an empty Allowed list as "allow nothing" | Empty means no restriction | To restrict, list the sections explicitly | +| Expecting the whitelist to block components | It gates sections only | Component-level constraints don't exist. Use the slot label to signal intent | +| Expecting the default to be copied into the page | It is never persisted into the placement | Treat it as live config: editing it updates every empty slot pointing at it | +| Real marketing copy in the default section | It renders on the live site until a composer fills the slot | Use an empty-state / placeholder section | +| Looking for these controls on a placed section | The page-side slot panel lists slot contents, not the whitelist | Open the owning section and select its slot | +| Trying to default a slot to its own section | Both pickers exclude the section being edited | Point at a different section. Nested chains handle recursion | +| Deep default chains | One fetch round per nesting level | Keep chains one or two levels deep | +| A circular default that renders nothing | Expansion stops on re-entry: that level renders blank, not an error | Expected. Point the default elsewhere if you wanted content | +| Default's own Repeater renders wrong or throws `items.map is not a function` | The default's data is scoped through a virtual instance. A shape mismatch with the slot's surrounding scope surfaces here | Check the default section's linked schema against the scope the slot sits in: `understand-linked-schemas`, `troubleshoot-data-binding` | +| Selected sections show as raw uids | Title lookup is best-effort and falls back to the uid | Harmless. Reload the panel, or confirm the section still exists | +| Section titles missing from the design-mode hint | The hint resolves the title from the locally-known catalog and drops it when absent | Cosmetic: the default still renders in Preview | + +## See also + +- `docs/34-smart-containers/slot-defaults.md`: full reference, edge-case table +- `docs/34-smart-containers/section-slots.md`: the placeholder these settings shape +- `use-section-slot`: carve the slot before configuring it +- `understand-section-slots`: what fills a slot, and the Section-in-Slot rule +- `expose-section-props`: the value-level sibling (override a prop, not the contents) +- `author-composition-via-api`: writing `metadata.defaultSection` / `metadata.allowedSections` programmatically diff --git a/skills/studio/configure-studio.md b/skills/studio/configure-studio.md new file mode 100644 index 0000000..9be0814 --- /dev/null +++ b/skills/studio/configure-studio.md @@ -0,0 +1,175 @@ +--- +name: configure-studio +--- + +## When to use + +Configure a Studio project's Environment, Language, and Canvas URL in one guided pass, replacing manual clicking through the Studio settings panels. + +Use right after `provision-studio-project` (or manual project creation) to go from "empty Studio project" to "ready to author". Phrases: "configure Studio project", "set canvas URL", "change environment". Do NOT use to register components (use `register-component`) or wire preview routes (use `setup-template-preview-routes`). + +> **Auth preflight: settle the credential before the first API call.** Resolve it OAuth-first per [`authenticate-cma`](authenticate-cma.md): `CS_OAUTH_ACCESS_TOKEN`, else the Contentstack MCP's stored session: Read that skill for where it is, even with no MCP connected. **Never ask the user for a session `authtoken`.** If nothing resolves, or a refresh fails with `400 invalid_refresh_token`, hand them `! CONTENTSTACK_REGION=<code> npx @contentstack/mcp --auth` (it needs a TTY and a browser, so it cannot be run for them) and wait. `403 error_code 316` is a valid credential aimed at another org: fix the org or the `api_key`, do **not** re-authenticate. + +# Configure a Studio project + +## Context + +Every Studio project has three configuration knobs that must be set before authoring is useful: + +1. **Environment**: which Contentstack environment the project's compositions resolve content against (the `?environment=` on every CDA call). +2. **Language**: default locale code (e.g. `en-us`). Studio's UI calls this "Language". The API and SDKs call it "locale", same thing. +3. **Canvas URL**: the **path** on your site that mounts `<StudioCanvas />` (e.g. `/canvas`, `/studio-canvas`). Studio builds the iframe address as **Base URL + Canvas URL**, where the Base URL (the origin) is resolved from the Environment + Language above. This field holds **only the path**, never the origin. + +To reach all three in Studio, open your project, go to Settings, and open Configuration. They can be edited individually any time, but a new project starts with all three blank and the user can't author until they're set. + +### Where the "compositions content type UID" comes from + +Studio installs a content type into your stack the first time a project is provisioned. That content type is where every composition lives as an entry. Its UID defaults to `compositions` but can be any UID the provisioning step chose. + +To find the exact UID for THIS project: + +- **Studio web app:** open the project, go to Settings, then Configuration, and read the "Composition Content Type" field (it shows the UID). +- **Contentstack web app:** open Content Models and look for a content type with the description "Studio compositions" (or similar). The UID column is what you want. +- **API:** `GET /v3/content_types?query={"_metadata.studio_managed":true}` returns the compositions content type. Its `uid` is the value. + +Pass this UID to `studioSdk.init({ contentTypeUid: "<uid>" })` in your app's `lib/contentstack.ts` (the `install-studio` skill writes it from the `studioContentTypeUid` input). **This is the SDK-init `contentTypeUid`, NOT `templateContentTypeUid`, which identifies a composition's CONNECTED content type at fetch time.** See `<StudioComponent />` [reference](../../docs/90-reference/composition-rendering-reference.md#two-different-contenttypeuid-concepts-dont-confuse-them) for the full distinction. + +Reference: `docs/10-setup/studio-project/configure-environment-language-and-canvas-url.md`, `docs/30-composition/canvas-url.md`. + +## Task + +<a id="select-the-studio-project"></a> + +0. **Select the Studio project first: list them, never assume.** An org routinely has several, and picking the wrong one is silent: the compositions list and Sections panel come up **empty** with no error, which reads as a broken install rather than a wrong project. This step is also what supplies `studioProjectId` to every downstream skill (`setup-section-preview`, `migrate-page-to-studio`, `troubleshoot-canvas`, `figma-generate-components`). + + **List the projects** (OAuth first, see [`provision-studio-project`](provision-studio-project.md) step 9 for the auth ladder): + + ```bash + curl -s "https://<studio-api-host>/v1/projects" \ + -H "Authorization: Bearer <oauth-access-token>" \ + -H "organization_uid: <org-uid>" | jq -r \ + '.projects[] | "\(.uid) \(.name) stack=\(.connectedStackApiKey) ct=\(.contentTypeUid)"' + ``` + + `<studio-api-host>` is `composable-studio-api.contentstack.com` (+ regional variants) on prod, `<env>-composable-studio-api.<non-prod-domain>` on a custom DC. See [`install-studio`](install-studio.md) § Non-prod hosts. + + **Two token-free alternatives (prefer these over asking for a credential):** + + > **OAuth Bearer is confirmed working for the Studio API on prod.** Runtime-verified: `GET /v1/projects` on `composable-studio-api.contentstack.com` with `Authorization: Bearer <oauth-access-token>` + `organization_uid` returned **200** with the project list, using a token minted by the Contentstack MCP's `--auth` flow. The ladder in [`provision-studio-project`](provision-studio-project.md) step 9 therefore resolves at its **first** rung on prod, no session authtoken needed. + + - **`csdx` (Studio plugin): use `project:set`, not `project:get`.** Source-verified in `@contentstack/studio` 0.0.3, and the two commands are not equivalent: + + | Command | Hits the API? | Use it for | + |---|---|---| + | `studio:project:get` | **No.** Reads the cached `projectId`. `projectDetails` is hardcoded `null` behind a "Fetching project information..." spinner | Seeing what this machine last selected, nothing more. It cannot tell you the project still exists or belongs to the right stack | + | `studio:project:set --project-id=<id>` | **Yes**: `fetchProjects()` calls `GET {base}/projects`, then `projects.find(p => p.uid === id)` | **Validating** a project id against your org, and switching to it | + + **Enumeration trick: `project:set` prints the list when the id is wrong.** On a miss it reports `Project with ID '<id>' not found in your organization`, then prints every available project as `• <name> (<uid>)`. So a deliberately-invalid id lists them without any manual token handling: + + ```bash + csdx studio:project:set --project-id=list-them-please # invalid on purpose + ``` + + **Both require OAuth**, not a session login: the plugin reads `oauthAccessToken` and `oauthOrgUid` from CLI config and sends `Authorization: Bearer <token>` + `organization_uid`. Absent those, it can't fetch. Run `csdx auth:login --oauth` first. (This is also independent corroboration for the auth ladder in [`provision-studio-project`](provision-studio-project.md) step 9. The shipped plugin authenticates the Studio API with an OAuth Bearer token, and scopes by **org**, not by stack api_key.) + + **Do not take `project:get` as confirmation of anything.** It succeeds on a machine with no OAuth token at all, because it never authenticates, which is exactly how a stale or wrong cached id survives unnoticed. + + **Runtime-verified, and worse than it sounds: the cached id survives a region switch.** On a machine moved from a non-prod DC to prod NA, `project:get` kept reporting a project id belonging to the **old data center**, while the org's live list (`GET /v1/projects`, 200) held a completely different single project. Nothing warned. The id simply looked plausible. **A cached id can point at a project in another DC entirely**, which is why the cross-check below compares against the live list, not against what the CLI remembers. + + **The plugin needs a region name the CLI core does not store.** Both of its lookups (`getComposableApiUrl()` and `getComposableApiBase()`) do `configHandler.get("region").name.toUpperCase()` against maps keyed with an **`AWS-` prefix**. The CLI core stores the short names, which aren't keys, so the plugin throws a **"Region not configured"** error naming `csdx config:set:region` even though `csdx config:get:region` reports the region correctly. <!-- style-lint: allow --> + + | `csdx config:set:region` stores | Plugin needs | Works by default? | + |---|---|---| + | `NA` | **`AWS-NA`** | **No**, set the prefixed name | + | `EU` | **`AWS-EU`** | **No**, set the prefixed name | + | `AU` | **`AWS-AU`** | **No**, set the prefixed name | + | `AZURE-NA` / `AZURE-EU` | same | Yes | + | `GCP-NA` / `GCP-EU` | same | Yes | + | a custom region name | that name, uppercased | Yes | + + Runtime-verified on prod: `config:set:region NA` makes the plugin fail. `config:set:region AWS-NA` makes it return the project. Only the three AWS short names need translating. + + **The two failures are distinguishable. Use them to tell which layer is broken.** Runtime-verified on prod: + + | Symptom from `studio:project:set` | Means | + |---|---| + | `Region not configured…` | Region **name** is wrong (a short name like `NA`). No request was sent | + | `You are not logged in…` followed by `Failed to fetch projects: Unauthorized` | Region name is **correct**. The host resolved and the request went out. Missing OAuth token. Run `csdx auth:login --oauth` | + + Reaching `Unauthorized` is therefore progress, not a regression: it proves the region maps to a real Studio API host. + + **Two traps that make this hard to diagnose:** + - Re-running `config:set:region` with the same short name cannot fix it. The message asks for the exact action that just failed. + - `config:set:region` prints a correct `Studio URL:` line, which looks like proof the Studio host is configured. The plugin never reads that value. It re-derives from its own map. A correct-looking Studio URL and a broken plugin coexist happily. + - **Studio UI**: the top-bar project switcher lists everything the user can reach. + + **Branch on the count:** + + | Projects found | What to do | + |---|---| + | **0** | Nothing to configure. Run [`provision-studio-project`](provision-studio-project.md) to create one, then return here. It re-checks and asks before creating, so it won't duplicate if a project appears in the meantime | + | **1** | Use it, but **state which one** ("Using project `<name>` (`<uid>`), stack `<api_key>`"). Don't switch silently | + | **2 or more** | **STOP and ask.** Print a numbered list with name, uid, connected stack and compositions CT, and let the user pick **by number**. You already have every uid from the listing, so never make them find or paste one. Never guess from a name resembling the repo | + + **Flag any mismatch before proceeding.** Compare the chosen project's `connectedStackApiKey` against the stack the app actually initializes (from `.env` or `.env.local`). If they differ, say so and stop. That mismatch is the cause of the "empty compositions list" and **HTTP 412** symptoms in [`troubleshoot-canvas`](troubleshoot-canvas.md), and configuring the wrong project just moves the problem. Same for `contentTypeUid`: it must match the compositions CT the app reads. + + **Record the chosen `uid`** and reuse it for the rest of the session rather than re-asking. + +**By API instead:** `PUT /v1/projects/<uid>` (PATCH returns 404) with `settings.configuration.environment` set to the environment's **uid** from `GET /v3/environments`, never its name. A name saves, but Studio shows no environment and the canvas falls back to Studio's default host. + +1. **Open Studio at the selected project**, then go to Settings, then Configuration. Verify the settings panel is visible (Environment / Language / Canvas URL). + +2. **Set Environment.** Dropdown shows every environment from the stack. Pick the value matching `environment`. If not present, the environment doesn't exist in the stack. Stop and tell the user to create the environment first, in the stack under Settings, then Environments. + +3. **Set Language.** Dropdown shows every locale enabled on the stack. Pick `defaultLanguage`. If not present, enable the locale in the stack, under Settings, then Languages. + +4. **Set Canvas URL** to `canvasUrl`. This is the **path** Studio appends to the Base URL when loading its iframe. Enter the path only, not a full origin. Common values: + - Any environment: `/canvas` or `/studio-canvas`, must match the route in the app that mounts `<StudioCanvas />`. The same value works for local dev and deployed. The origin differs but comes from the Base URL, not this field. + - Studio-hosted playground: leave blank to fall back to Playground Canvas (no canvas-app needed. Deploy is disabled in this mode, see `docs/10-setup/studio-project/try-studio-in-the-playground-canvas-without-an-app.md`) + +5. **Save.** Studio's Save button grays out post-save. That's the confirmation. + +6. **Read back the settings** via the Studio UI to confirm the writes. The Configuration panel should show the three values exactly as set. + +7. **By API, the body shape:** send it flat, `{ canvasUrl, settings: { configuration: { environment, locale } } }`. A `{ "project": … }` wrapper returns 200 and saves nothing. Read the project back to confirm. + +## Inputs needed from the user + +In order: + +1. `studioProjectId` / `projectName`: **resolved by Step 0, not asked for blind.** List the org's projects: with one, confirm which. With two or more, the user picks. With none, provision one first. +2. `environment`: must match an existing stack environment. Reject if not. +3. `defaultLanguage`: must match an existing stack locale. Reject if not. +4. `canvasUrl`: the path that mounts `<StudioCanvas />` (e.g. `/canvas`). Confirm it matches the canvas route in the app. Reject full origins like `http://localhost:5173/studio-canvas` and strip them to the path. A separate `verify-setup` call confirms the canvas actually loads. +Do NOT assume defaults silently for Environment / Language. These are stack-specific and wrong defaults strand the user. + +## Acceptance + +- [ ] **The project was selected from an actual list, and the choice was stated back.** With 2+ projects the user picked. With exactly 1 it was named rather than silently assumed. The chosen project's `connectedStackApiKey` matches the stack the app initializes, and its `contentTypeUid` matches the compositions CT the app reads. A mismatch was surfaced, not configured around. + +This skill succeeds only when ALL of the following are true. + +- [ ] Environment is set to `environment` and the value matches a stack environment. +- [ ] Language is set to `defaultLanguage` and the value matches a stack locale. +- [ ] Canvas URL is set to `canvasUrl` (or explicitly left blank for Playground Canvas). +- [ ] All three values are read back from the Studio UI to verify the write took. + +## Common pitfalls + +| Pitfall | Why it bites | Fix | +| --- | --- | --- | +| Setting Environment to a value that doesn't exist on the stack | CDA calls return 422. Canvas renders empty | Verify the environment in the stack, under Settings, then Environments, before configuring | +| Empty per-locale Base URL on the targeted environment | Studio can't compose the canvas iframe address (origin + path). It blocks the Canvas URL save and the canvas stays blank | Set the targeted environment's per-locale URL first: in the stack, open Settings, then Environments, then `<env>`, and set the URL for `<locale>` (your dev origin for local dev). See `setup-section-preview`. | +| Only the default locale has a Base URL on a localized project | Language here is one value, but authors switch locale in the canvas. Every locale without a Base URL goes blank the moment they do, with an error pointing at the Studio project | Set a Base URL for **every** locale the app serves (with the app's prefix if it uses one). [`convert-project-to-studio`](convert-project-to-studio.md) § Step 3 maps and writes them. | +| Pasting a full origin into Canvas URL (e.g. `http://localhost:5173/studio-canvas` or `https://yoursite.com`) | Canvas URL is the **path only**. Studio prepends the Base URL (origin) itself. A full URL here produces a broken iframe address (origin duplicated or wrong path). | Enter just the path: `/canvas` or `/studio-canvas`. The origin comes from Environment + Language (the Base URL), not this field. | +| Saying "Language" everywhere when the codebase says "locale" | User searches for "Language" in SDK / API docs and finds nothing | Note explicitly: UI label is "Language". SDK + API call it "locale". Same field. | +| Setting a Canvas URL that points to a host that isn't running | Canvas loads forever. Spinner never resolves | Pair with `verify-setup` after configuring. If Layer 4 fails on a freshly set Canvas URL, the host isn't running | +| **Running two Studio projects on the same stack against different local apps**: switching to project A breaks preview for project B (and vice versa) | The env Base URL is a **stack-level** resource (set on the environment via `urls: [{ locale, url }]`, see `provision.ts`), not a per-project setting. Multiple Studio projects on one stack **share that single Base URL**. Project A pointing at `http://localhost:5192` and project B at `http://localhost:3000` cannot coexist on the same environment. Only one can be the active iframe origin at a time. Connected-template canvas preview loads at this shared URL, so whichever project was last configured wins. | Pick ONE of: (a) **separate environment per project** (in the stack, open Settings, then Environments, and create `<project>_preview` for each project, pointing each at its own port. Set each Studio project's Environment to its dedicated env in `configure-studio` step 2), (b) **separate stack per project** (cleanest isolation, but more provisioning overhead), (c) **manual Base URL swap** when switching projects (edit the env's per-locale URL in Stack settings each time, only viable if you switch infrequently). Option (a) is the recommended default. | + +## See also + +- `docs/10-setup/studio-project/configure-environment-language-and-canvas-url.md`: the canonical reference +- `docs/30-composition/canvas-url.md`: what Canvas URL is for +- `docs/10-setup/studio-project/try-studio-in-the-playground-canvas-without-an-app.md`: Studio-hosted canvas (no canvas-app needed. Deploy disabled) +- `verify-setup`: run after this skill to confirm canvas loads +- `setup-template-preview-routes`: wire `/blog/:slug` style routes (separate from Canvas URL) diff --git a/skills/studio/convert-project-to-studio.md b/skills/studio/convert-project-to-studio.md new file mode 100644 index 0000000..b544992 --- /dev/null +++ b/skills/studio/convert-project-to-studio.md @@ -0,0 +1,234 @@ +--- +name: convert-project-to-studio +--- + +## When to use + +Orchestrator for "make my existing project Studio-compatible". Asks one scoping question (minimal conversion, one page, or the whole site), then drives the minimal conversion by chaining the install skills with their answers pre-filled: Studio SDK installed, `/canvas` mounted, and Studio wired as a **fallback renderer** behind the routes the app already owns, with the app's own 404 kept. Localized apps get every locale wired, not only the default. + +Use for whole-project Studio enable/setup requests, including an existing app or a new app. On a new app, run the same setup sequence and let route detection choose the optional catch-all. Use `install-studio` alone only for an explicit SDK-only request. Single-page migrations use `migrate-page-to-studio`; design-only inputs use `decompose-site`. + +> **Auth preflight: settle the credential before the first API call.** Resolve it OAuth-first per [`authenticate-cma`](authenticate-cma.md): `CS_OAUTH_ACCESS_TOKEN`, else the Contentstack MCP's stored session: Read that skill for where it is, even with no MCP connected. **Never ask the user for a session `authtoken`.** If nothing resolves, or a refresh fails with `400 invalid_refresh_token`, hand them `! CONTENTSTACK_REGION=<code> npx @contentstack/mcp --auth` (it needs a TTY and a browser, so it cannot be run for them) and wait. `403 error_code 316` is a valid credential aimed at another org: fix the org or the `api_key`, do **not** re-authenticate. + +# Convert an existing project to Studio: minimal first + +> ## Verification status +> +> Orchestrator over verified skills. The route pattern it wires is the documented one in [`partial-adoption-coexisting-with-a-code-driven-app`](../../docs/40-recipes/partial-adoption-coexisting-with-a-code-driven-app.md) (catch-all + code-route precedence). **The locale step (§ Step 3) is not yet runtime-verified against a `next-intl` app.** Until it is, treat the single Base URL rule there as the working model, run the redirect probe on every locale, and report anything that does not match rather than improvising around it. + +## Why this skill exists + +"Convert this project to Studio" has been answered with two scopes: decompose the whole site, or pilot one page. Both rebuild pages. Neither is what most teams need first, which is Studio **available** in the app without any existing page changing. Without a skill for that, the agent improvises a scope question and steers the user into a migration they did not ask for. + +The minimal conversion is a fixed contract. Every request resolves in this order: + +1. **A code route matches.** The project's own page renders, unchanged. Code routes win by precedence (Next.js more-specific-wins, React Router first-match). +2. **No code route matches.** The catch-all mounts `<StudioComponent />` and Studio resolves a composition for that URL. +3. **Studio has no composition either.** The project's **existing** 404 page renders. Never a blank div for visitors, never a 500. Studio's editor iframe stays exempt so unsaved templates still preview. + +Plus the `/canvas` route for Section authoring. No page migrated, no component decomposed, no Section built. Authors can create Studio pages at new URLs the same day. The larger scopes are supersets of this, so minimal is always phase one. + +## Task + +### Step 0: Confirm this is a whole-project ask + +If the user named one route or one page, stop and run [`migrate-page-to-studio`](migrate-page-to-studio.md) via the plan-first rule instead. If the input is a design with no code, run [`decompose-site`](decompose-site.md). Otherwise continue. + +### Step 1: Resolve scope from the request and prior answers + +For a broad enable/setup request, use minimal conversion and state that it includes the canvas and wildcard fallback. Respect explicit SDK-only, dedicated-route, and migration requests. Carry existing answers forward; do not ask a question already settled in the conversation. Ask the following only when the requested migration scope is ambiguous: + +> Three ways to bring Studio into this project. Pick one: +> +> **1. Minimal conversion (recommended).** I install the Studio SDK and mount Studio as a fallback behind your existing routes. Every page you have today keeps rendering exactly as it does now. Any URL your code does not claim is served by Studio, and if Studio has nothing for it either, your existing 404 page shows. Authors can start building new Studio pages at new URLs right away. Nothing is migrated or decomposed. About an hour. +> +> **2. Minimal, then migrate one page.** Everything in 1, then we pick one route, plan it, and swap it to Studio so you can validate authoring on real content. +> +> **3. Minimal, then a full-site plan.** Everything in 1, then a consolidated plan across every page type, built in dependency order. +> +> Reply **1**, **2**, or **3**. + +Record the resolved scope. A reply such as “yes” or “start” accepts an immediately preceding concrete proposal. Options 2 and 3 run minimal conversion first, then the appropriate migration planning skill. Approval for setup does not authorize unrelated migrations, publishing, or changes to other projects. + +Keep this non-secret progress record in the working context and resume it after login, restart, or compaction: + +```text +Scope and accepted choices: +Framework, package manager, route root, React/Studio versions: +MCP: callable/read-verified | unavailable (reason) | declined +Project, stack, region, branch, environment, locale map: +SDK initialization modules: +Canvas route and observed iframe URL: +Fallback route and observed template iframe URL: +Verification: passed | failed | unverified, with evidence per check +Next pending step: +``` + +Run all remaining setup steps within the accepted scope. A specialist's “next steps” section is a handoff back here, not the end of project setup. + +### Step 2: Preflight + +Run [`analyze-project-fit`](analyze-project-fit.md). Carry forward: framework, package manager, React major, data centre, existing Contentstack SDKs, and the i18n row. Blockers (Path B stale SDK pin, C, G) stop this skill until resolved. + +Run [`install-contentstack-mcp`](install-contentstack-mcp.md) discovery before requesting stack credentials or doing API work, even if OAuth credentials already exist. This is a gate, not a silent fallback: first determine whether callable Contentstack MCP tools are available in the active host, then offer MCP configuration/authentication if they are not. Do not ask the user to fill the app's `.env` merely because MCP is not yet connected. + +Use MCP for supported CMA reads and writes after a successful read probe. Use OAuth HTTP or the Studio UI for operations MCP does not expose, especially Studio project listing/configuration, Live Preview enablement, and delivery/preview-token creation. Record the reason for each fallback. MCP does not eliminate the app runtime configuration: after discovery/provisioning, the app still needs the selected stack API key, delivery token, preview token, environment, locale map, and the Studio project's exact `contentTypeUid` in its environment. Ask only for values MCP cannot discover or create, and never guess a project, stack, locale, or composition content type UID. + +Then [`enable-visual-experience`](enable-visual-experience.md): stack-level Live Preview must be on before any app-side work. + +### Step 3: Locale step. Detect, map, wire, verify. + +Studio treats locale as a context variable, not as part of a composition URL. Composition URLs are stored without a prefix (`/rewards`). Studio builds the preview iframe address as the environment's **per-locale Base URL** plus that composition URL, and an environment holds exactly one Base URL per locale. A localized app that skips this step gets a blank canvas when an author switches language, a doubled prefix such as `/en/en`, or a middleware redirect that kills the iframe. + +**The single rule this skill enforces:** + +- Composition URLs never carry a locale prefix. +- The environment's per-locale Base URL carries the app's prefix when the app is prefixed (`http://localhost:3000/en`, `http://localhost:3000/fr`), and is the bare origin when it is not. +- The catch-all strips the app's locale prefix from the request path before calling Studio (`/en/rewards` becomes `url: "/rewards"` with `locale: "en-us"`), so a doubled prefix cannot come from the route side. If compositions were already authored with prefixed URLs, list them and stop. Do not guess which side to change. + +**3a. Detect app-side i18n.** + +```bash +# Packages +grep -E '"(next-intl|next-i18next|i18next|react-i18next|react-intl|@lingui/[a-z]+|next-international)"' package.json +# Route segment +find app src/app -maxdepth 2 -type d \( -name '[[]locale[]]' -o -name '[[]lang[]]' \) 2>/dev/null +# Next config i18n key, locale middleware, React Router locale param +grep -n "i18n" next.config.* 2>/dev/null +grep -ln "locale\|createMiddleware" middleware.* src/middleware.* 2>/dev/null +grep -rn 'path="/:\(lang\|locale\)' src 2>/dev/null | head -3 +``` + +Record: prefix strategy (`always`, `as-needed`, or none), the default app locale code, every app locale code. Nothing found means un-localized: note that in one line and run only 3d for the default locale. + +**3b. Read stack-side locales.** + +```bash +# $CS_AUTH is the resolved credential header from `authenticate-cma` — on the +# OAuth path it is `authorization: Bearer <access_token>`. A bare +# `authorization: <token>` is the MANAGEMENT-token form and 401s under OAuth. +curl -sS "https://<cma-host>/v3/locales" -H "api_key: <stackApiKey>" -H "$CS_AUTH" +``` + +Record every enabled `code` and which one is `fallback_locale`-less (the master). + +**3c. Build the locale map and show it.** App code to stack code (`en` to `en-us`, `fr` to `fr-fr`). Print the table and ask the user to confirm. **Halt on any app locale with no stack locale** — that one is fatal, since the app would serve a URL the stack cannot resolve. Do not invent a mapping and do not proceed with the intersection. + +The reverse is **not** fatal: a shared stack routinely carries locales this app doesn't serve (other sites, other brands, locales not launched yet). List those as out of scope in one line, wire every app-served locale, and continue. Halting there would block the common shared-stack setup for a condition the app can't act on. + +| App prefix | Stack locale | Base URL to set on `<env>` | +|---|---|---| +| `en` (default) | `en-us` | `http://localhost:3000/en` | +| `fr` | `fr-fr` | `http://localhost:3000/fr` | + +**3d. Pre-flight every Base URL, not only the default.** Read `GET /v3/environments` as in [`install-studio`](install-studio.md) step 3 and check `urls[]` for **every** mapped locale. For a missing or wrong entry, print the exact `PUT /v3/environments/<env-name>` body with all locale URLs, ask for a yes, then write it. Do not write without the yes. + +**3e. Redirect probe, per locale.** Run the curl from [`setup-template-preview-routes` § middleware 307](setup-template-preview-routes.md#locale-prefixed-apps-the-middleware-307-breaks-the-iframe) against `<base URL>` and `<base URL>/canvas` for every mapped locale. Any 3xx stops this skill with the three documented fixes. Prefer "locale in the Base URL" since 3d already did it. A 308 on a trailing slash means `skipTrailingSlashRedirect: true` in `next.config`. + +**3f. Answers carried into the route step.** The catch-all lives inside the locale segment (`app/[locale]/[...slug]/page.tsx`, or `<Route path="/:lang/*">` registered last). It strips the prefix before building `url`. It passes the mapped stack code as `{ locale }` on the SSR fetch and the same value to `useCompositionData` on CSR. It forwards `searchQuery` untouched so Studio's own locale override still wins inside the iframe. Its not-found branch renders the app's **localized** 404 (`app/[locale]/not-found.tsx` where one exists). + +### Step 4: Install + +Run [`install-studio`](install-studio.md). Take the **minimal-add branch** whenever `@contentstack/delivery-sdk` or `@contentstack/live-preview-utils` is already present: do not re-init the stack, create a separate `lib/studio.ts`. The per-locale Base URL check inside it is already satisfied by 3d. Do not register components in this step. Built-in components are enough for a first Studio page, and [`register-component`](register-component.md) is offered after the conversion, not inside it. + +### Step 5: Canvas route + +Run [`setup-section-preview`](setup-section-preview.md). A route mounting `StudioCanvas` must exist before the catch-all. Record the actual Section iframe URL generated by the selected project/environment and ensure that exact path mounts the canvas without site chrome. Preserve a working root or locale-prefixed canvas route; do not infer its address from the template URL or add a duplicate based on a guessed concatenation rule. Verify every served locale in the browser. Canvas URL remains the configured path of that route. + +### Step 6: Fallback route + +Run [`setup-template-preview-routes`](setup-template-preview-routes.md) in **wildcard mode** with these answers fixed. Do not re-ask them: + +| Question it asks | Answer for the minimal conversion | +|---|---| +| Route mode (step 3) | **A. Wildcard.** Code routes win by precedence, so nothing the app owns changes | +| Opt-outs | None needed. Code routes already win — routes are ranked by specificity, so `/api/*` beats the catch-all whatever the order | +| `[...slug]` vs `[[...slug]]` (step 5) | `[...slug]` when a root page exists (it nearly always does on an existing app), else `[[...slug]]`. On a locale-prefixed app, inside the segment | +| `chromeOwner` (step 5a) | **`code`.** Header and footer stay in the layout | +| 404 branch (step 5a iii) | **Reuse the project's existing not-found** (`app/not-found.tsx`, `pages/404.tsx`, or the `NotFound` component). Localized apps: the per-locale one. Create a file only when none exists. Editor-mode exemption on | +| Locale | From Step 3f | + +Framework scope: Next.js App Router, Next.js Pages Router (`pages/[...slug].tsx`, existing `pages/404.tsx`), Vite + React Router (`path="*"` registered last, the old `<Route path="*" element={<NotFound />} />` removed and `NotFound` rendered from the no-spec visitor branch). Remix, Astro, or a custom router: stop and say the pack is under-specified for that router rather than guessing, per the pack's rule that silence is reported, not filled. + +### Step 7: Configure Studio + +Run [`configure-studio`](configure-studio.md). Environment = the one whose Base URLs were set in 3d. Language = the mapped default stack locale. Canvas URL = `/canvas`. + +### Step 8: Verify + +Run [`verify-setup`](verify-setup.md), then this smoke, **once per mapped locale**: + +| URL | Expected | +|---|---| +| An existing code page (`<base URL>/products/some-slug`) | Renders exactly as before. Same header, same footer, same markup | +| A bogus URL (`<base URL>/definitely-not-a-page`) | The project's own 404 page. Not a 500, not a blank page, not an empty div | +| `<base URL>/canvas` | `<StudioCanvas />` renders. The 404 page here means the catch-all claimed it | +| The redirect probe from 3e | `200`, empty `redirect_url` | + +Then in Studio: create a template, do not save it, open it. The canvas renders it (editor exemption works). On a localized project switch the canvas language once. The iframe reloads at the other locale's Base URL rather than going blank. + +### Step 9: Report and hand off + +Print, filled in: + +``` +Minimal conversion done. + Existing routes: untouched (<n> code routes win by precedence) + Fallback route: <path> mounts <StudioComponent /> for unclaimed URLs + 404: <file> for visitors, editor iframe exempt + Canvas route: /canvas (<file>) + Locales: <en-us, fr-fr | single locale en-us>, Base URLs set on <env> + Studio project: env <env>, language <locale>, Canvas URL /canvas + +Definition of done for this scope (from complete-the-build): + [x] cslp.appendTags in the SDK init + [x] stack Live Preview + preview token + [x] /canvas mounted + [x] per-locale Base URL for every locale served + [ ] component studioAttributes / thumbnailUrl / Section ui_preview / publishing: not applicable until something is authored + +Next: author a first Studio page at a URL no code route claims, then register your components (register-component) when you want them in the palette. +``` + +Option 2 or 3 chosen in Step 1: now run [`plan-studio-architecture`](plan-studio-architecture.md) for the scoped page(s). Option 1: stop here. + +## Inputs needed from the user + +1. `scope`: minimal for a broad setup request, or the user's explicit migration/SDK-only scope from Step 1. +2. Confirmation of the locale map (3c) and of every Base URL write (3d). + +## Acceptance + +This skill succeeds only when ALL of the following are true. + +- [ ] Scope and prior accepted choices are recorded. Broad setup includes canvas and fallback routing; explicit user constraints take precedence. +- [ ] MCP availability was checked in the active host and a read operation verified its target, or a concrete fallback reason is recorded. +- [ ] The verification report distinguishes HTTP reachability from rendering and live editing. Unverified browser checks are not marked passed. +- [ ] `analyze-project-fit` ran and no blocker path (B / C / G) was open when install started. +- [ ] Locale detection ran. On a localized app the map was confirmed by the user, every mapped locale has a Base URL on the targeted environment, and the redirect probe returned 200 for every locale. On an un-localized app this was stated in one line. +- [ ] No existing route file was modified or deleted. The only route files added are the canvas route and the catch-all. +- [ ] The per-locale smoke in Step 8 passed for every mapped locale: existing page unchanged, bogus URL renders the project's own 404 with no 500, `/canvas` renders `<StudioCanvas />`. +- [ ] No component was registered, decomposed, or authored as part of the minimal conversion. +- [ ] The report block in Step 9 was printed with the definition-of-done items marked as above. + +## Common pitfalls + +| Pitfall | Why it bites | Fix | +|---|---|---| +| Skipping the Step 1 gate and improvising "full site or one page" | Neither scope is what "make it compatible" asks for, and both start rebuilding pages | Ask the three-option gate verbatim. Minimal is the default | +| Wiring the catch-all before `/canvas` exists | The catch-all claims `/canvas` and every Section renders as a composition miss or the 404 page | Step 5 before Step 6, always | +| Optional `[[...slug]]` on an app with a root page | Both match `/`. Next.js reports a collision or the root page loses | `[...slug]` whenever any root `page.*` exists, including `app/[locale]/page.*` | +| Treating "no pages found" as greenfield | A fixed path list misses `src/app` and locale segments, so a catch-all lands in an app full of pages | Use the `find` in `setup-template-preview-routes` step 3 and confirm with `ls` before believing an empty result | +| 404 branch returns an empty `<div />` to visitors | The site never 404s. Correct for the editor iframe, wrong for everyone else | Gate on `isStudioEditorMode`: empty div in the editor, the project's 404 page for visitors | +| Unwrapped `fetchCompositionData` | It throws on a miss, so every bogus URL is a 500 instead of the 404 page | Use `resolveComposition` from `configure-csr-vs-ssr`, never a hand-rolled catch | +| Only the default locale has a Base URL | Switching language in the canvas produces a blank iframe with an error pointing at the Studio project | Step 3d checks and sets every mapped locale | +| Catch-all passes the prefixed path to Studio | `/en/rewards` never matches a composition stored as `/rewards`, or resolves to a doubled `/en/en` | Strip the prefix, pass the stack locale code separately, forward `searchQuery` untouched | +| App locale codes assumed equal to stack codes | `en` is not `en-us`. The fetch resolves nothing and the page 404s in one language only | Build and confirm the map in 3c. Halt on a mismatch | +| Decomposing or registering "while we are here" | Turns a one-hour conversion into a migration the user did not choose | Nothing beyond the fallback route until the user picks option 2 or 3 | + +## See also + +- [`partial-adoption-coexisting-with-a-code-driven-app`](../../docs/40-recipes/partial-adoption-coexisting-with-a-code-driven-app.md): canonical home of the catch-all + code-route precedence pattern +- [`setup-template-preview-routes`](setup-template-preview-routes.md): the route wiring, chrome and 404 handling, locale redirect probe +- [`analyze-project-fit`](analyze-project-fit.md), [`install-studio`](install-studio.md), [`setup-section-preview`](setup-section-preview.md), [`configure-studio`](configure-studio.md), [`verify-setup`](verify-setup.md): the chained skills +- [`migrate-page-to-studio`](migrate-page-to-studio.md), [`decompose-site`](decompose-site.md): the option 2 and option 3 follow-ons diff --git a/skills/studio/decompose-blocks-page.md b/skills/studio/decompose-blocks-page.md new file mode 100644 index 0000000..cd8cae2 --- /dev/null +++ b/skills/studio/decompose-blocks-page.md @@ -0,0 +1,110 @@ +--- +name: decompose-blocks-page +--- + +## When to use + +Decompose a page whose entire body is **one modular-blocks field** into one Section per block type, when a single production component already renders every block type behind a `switch`. Covers the scope-filter prop that makes one Section render one scope, occurrence pinning when a block type repeats, and the reuse ceiling that decides which Sections two templates can actually share. + +Use when the page CT has a `data_type: "blocks"` field carrying the whole page body and the codebase has one component that renders all of it. Phrases: "one component renders all my blocks", "the whole page is one modular blocks field", "split this blocks page into Sections", "my template has only one Section", "how do I decompose a page that is one array", "the page renders but there is only one node", "split the sections field into sections". Do NOT use for a design-first build (use [`decompose-design`](decompose-design.md)), for proposing scope boundaries from a schema (that is [`discover-sections-from-ct`](discover-sections-from-ct.md), which this skill executes), or for a list of like items inside one section (that is [`build-repeating-section`](build-repeating-section.md)). + +> **Reuse preflight: mandatory when the project already has compositions.** Before naming a single new atomic, component or Section, run [`match-existing-pattern`](match-existing-pattern.md) § Step 1b: it groups existing Sections by the schema they bind and ranks the component palette by real usage. Decomposition is where reuse has to bite. Once this step names `Heading` instead of the project's `alpha-atom-heading`, the duplicate survives into the plan and into the build. Mark every row reuse, extend, or new, and never new without having looked. + +# Decompose a one-blocks-field page into Sections + +## Context + +[`discover-sections-from-ct`](discover-sections-from-ct.md) already states the rule: **each block type in a `blocks` field is its own Section scope**, so a field with nine block types is nine Sections. This skill is the execution half, what to do when the code renders those nine types from a single component. + +## Why this matters + +A page whose body is one blocks field invites exactly one node: bind that component's array prop to the field and the entire page appears, correct in every pixel. Nothing errors. It renders, it matches production, it passes visual parity, it passes every render-time acceptance check ever written, and it is a monolith with no Sections, no reuse and nothing an author can rearrange. + +That is the trap: **the monolithic build is not a broken build, it is a working build with the wrong shape.** It cannot be caught by looking at the page. It is only caught by counting `section-composition` nodes. See [`build-connected-template`](build-connected-template.md) § Post-build structural check, which is the canonical check and applies to API-authored templates too. + +## Task + +### Step 1: Read the block sequence from the entry, never from the DOM + +Fetch a representative entry and record the ordered list of block type keys: + +```js +const seq = entry[blocksField].map((b) => Object.keys(b).find((k) => !k.startsWith('_') && k !== '$')); +// e.g. ["hero","feature_grid","quote","feature_grid","cta"] +``` + +The rendered DOM is not a substitute. Once any decomposition exists, the DOM shows what the current Sections do, so using it to check that decomposition is circular. The entry is the only independent source. + +### Step 2: Give the renderer a scope prop + +One Section must render one scope. Add a filter prop to the component and register it: + +```tsx +/** Render only this block type. Unset renders every block — the pre-decomposition + * behavior, kept so existing compositions keep working mid-migration. */ +block_type?: string; +/** Which occurrence of block_type to render when the page repeats the type. -1 = all. */ +block_index?: number; + +const blocks = selectBlocks(all, block_type, block_index); +``` + +```ts +block_type: { type: "string", displayName: "Render only this block type", defaultValue: "" }, +block_index: { type: "number", displayName: "Which occurrence (-1 = all)", defaultValue: -1 }, +``` + +Defaulting to "render everything" is what makes the migration safe: every composition authored before the prop existed keeps rendering while the templates are converted one at a time. + +### Step 3: Decide grouped or pinned, per template + +One Section per type renders all blocks of that type together. That is only faithful when each type's occurrences are already contiguous. Test it (do not assume): + +```js +const order = [...new Set(seq)]; +const grouped = order.flatMap((k) => seq.filter((x) => x === k)); +const groupSafe = JSON.stringify(grouped) === JSON.stringify(seq); +``` + +`groupSafe` false means the page interleaves: `hero, para, para, quote, para` regroups to `hero, para, para, para, quote`, which keeps every block and still ships the wrong page. Then emit one Section per **occurrence**, each pinned with `block_index`, and place them in entry order. + +### Step 4: Rebuild the root slot from the pre-decomposition version, not from the block list + +Read the newest composition version whose root slot holds **no** `section-composition` node (the layout before anyone decomposed it) and rebuild from that node list. A blocks-driven page routinely carries siblings beside the blocks node: a closing CTA band, a comparison table, a content list. Building the root slot from block types alone silently drops every one of them. + +Each non-blocks sibling becomes its own Section, wrapping that node verbatim, placed in its original position. Copy the node's other props too. A prop that filtered the whole array is a per-page setting and does not belong on a shared Section (see the pitfall table). + +### Step 5: Key Sections by field, not by template + +A Section's binding is `template.<field>`, so the field name (not the page) decides reuse. Slug Sections `sec_<field>_<block_type>` and two templates whose CTs name the field identically share Sections automatically. Templates whose CTs name it differently cannot share, no matter how alike the blocks look. + +**Do not try to escape this by binding the whole entry.** An empty binding path (`path: {}`) does resolve to the entry root, and it works right up until a sibling node contributes binding paths: the SDK builds the delivery projection from declared binding paths, the whole-entry binding contributes none, and the entry arrives carrying only the sibling's fields, `["uid", "<sibling_field>", "$"]`. The blocks field is never fetched and every Section renders its empty state. Bind the real field path. + +## Acceptance + +- [ ] Every template's root slot holds **only** `section-composition` nodes: run `build-connected-template` § Post-build structural check. +- [ ] Section count per template equals distinct block types (grouped) or block occurrences (pinned), **plus** one per non-blocks sibling node. +- [ ] The rendered block-type sequence matches the entry's sequence, in order. +- [ ] Visual Editor can add, move and delete blocks: the page renders the blocks field's `<field>` tag and one `<field>.<N>` per block ([`make-visual-editor-editable`](make-visual-editor-editable.md) § 2). +- [ ] Every Section and every Template is published: an edit creates an unpublished version and delivery keeps serving the old one. +- [ ] A before/after fingerprint of the page (headings in order, visible text length, image sources, block sequence) is unchanged. Content that only appears after decomposing usually means the previous build had dropped a node. + +## Common pitfalls + +| Pitfall | Why it bites | Fix | +|---|---|---| +| Judging the build by how the page looks | A monolith renders perfectly. Every render-time check passes | Count `section-composition` nodes before reporting success | +| One Section per type on an interleaved page | Blocks regroup: content survives, order does not | Run the group-safety test. Pin occurrences with `block_index` | +| Rebuilding the root slot from the block list | Sibling nodes beside the blocks node are dropped, silently and invisibly | Rebuild from the last version with no `section-composition` node | +| Copying a whole-array filter prop onto a shared Section | One page's exclusion leaks into every template reusing it: that Section renders nothing there | Drop array-wide filter props. Per-scope Sections make them meaningless | +| Binding the whole entry to make Sections field-agnostic | Projection is built from binding paths. A sibling's paths narrow the fetch and the blocks field never arrives | Bind `template.<field>` and accept the field-name reuse ceiling | +| Naming Sections after the template | Two templates on the same field can no longer share | Slug by field: `sec_<field>_<block_type>` | +| Reading the block order from the DOM | The DOM reflects the current decomposition, so it cannot verify it | Read the order from the entry | +| Deriving a Section title by stripping a suffix | `quote` and `quote_section` collapse to one title. Composition titles are unique, so the second create fails | Keep titles 1:1 with the block type | + +## See also + +- [`discover-sections-from-ct`](discover-sections-from-ct.md): proposes the scope boundaries this skill executes +- [`build-connected-template`](build-connected-template.md): the approved-plan guard and the post-build structural check +- [`author-composition-via-api`](author-composition-via-api.md): writing the resulting Sections and Template through the CMA +- [`plan-studio-architecture`](plan-studio-architecture.md): the plan this decomposition belongs in, approved before any of it is built diff --git a/skills/studio/decompose-design.md b/skills/studio/decompose-design.md new file mode 100644 index 0000000..b642a15 --- /dev/null +++ b/skills/studio/decompose-design.md @@ -0,0 +1,575 @@ +--- +name: decompose-design +--- + +## When to use + +Take a **design artifact** (a Figma frame or URL, a screenshot, a PDF mock, a wireframe, a whiteboard photo, a hand-drawn sketch, or a natural-language description of a page) and emit a full three-layer decomposition ready to feed the downstream Studio skills. Output is a machine-readable inventory of Layer-1 atomics, Layer-2 containers, Layer-2 layout components, Layer-3 Sections, and a proposed Content Type shape. + +Use as the FIRST skill on any greenfield Studio build starting from a design (no code yet), or before running `plan-studio-architecture` when the input is visual rather than a requirements doc. Phrases: "here's the design", "here's a Figma link", "here's a screenshot of the page", "map this design to Studio", "how would I build this in Studio", "how do I decompose this page", "decompose this design", "what components do I need for this design". Do NOT use when the customer already has a React codebase. Reach for `discover-sections` and `design-section-from-jsx` instead. Do NOT use for backend/data-model-only questions (no visual design). + +> **Mandatory auth preflight: settle the credential before the first API call.** Resolve it OAuth-first per [`authenticate-cma`](authenticate-cma.md): `CS_OAUTH_ACCESS_TOKEN`, else the Contentstack MCP's stored session: Read that skill for where it is, even with no MCP connected. **Never ask the user for a session `authtoken`.** If nothing resolves, or a refresh fails with `400 invalid_refresh_token`, hand them `! CONTENTSTACK_REGION=<code> npx @contentstack/mcp --auth` (it needs a TTY and a browser, so it cannot be run for them) and wait. `403 error_code 316` is a valid credential aimed at another org: fix the org or the `api_key`, do **not** re-authenticate. + +> **Reuse preflight: mandatory when the project already has compositions.** Before naming a single new atomic, component or Section, run [`match-existing-pattern`](match-existing-pattern.md) § Step 1b: it groups existing Sections by the schema they bind and ranks the component palette by real usage. Decomposition is where reuse has to bite. Once this step names `Heading` instead of the project's `alpha-atom-heading`, the duplicate survives into the plan and into the build. Mark every row reuse, extend, or new, and never new without having looked. + +# Decompose a design into Studio's three layers + +> **The sheet is not the build.** A decomposition that names N Sections and then gets authored as one component bound to the whole page body renders correctly and passes every render-time check. Whoever executes this sheet must count `section-composition` nodes before reporting done, [`build-connected-template`](build-connected-template.md) § Post-build structural check. When the page body is one modular-blocks field, [`decompose-blocks-page`](decompose-blocks-page.md) is the recipe. + +## Context + +Studio composes pages from three layers: **Layer-1 atomic components** (one CMS field each), **Layer-2 containers with slots** (holding atomics or nested containers, encoding design-system layout in code), and **Layer-3 Sections** (canvas compositions rooted at a schema scope). See [`from-designs-to-sections`](../../docs/00-overview/from-designs-to-sections.md) for the taxonomy. This skill assumes you've read it (or you're an agent applying its rules). + +This skill runs the taxonomy over a specific design artifact. Every downstream skill in the Studio flow (`register-component`, `build-section`, `build-connected-template`, `plan-studio-architecture`) assumes this decomposition is already done. When it's not, developers guess piece-by-piece and produce inconsistent registrations (over-exposed slots, under-exposed slots, missed layout components, wrong Section boundaries). + +Ship this decomposition BEFORE registering anything. Registrations are expensive to redo. The plan you emit here is what makes them right the first time. + +## The four-question decision framework + +This skill runs **all four questions** of the [decision framework](../../docs/00-getting-started/composable-primitives.md#four-questions-to-answer-when-building-any-component) once per identified section on the design. The framework is an open validator. It doesn't assume any specific pre-registered library: + +- **Q1. Classify: Atom, Layout, or Compound?** For each irreducible fragment of the design, ask what shape it is. A single content unit is an Atom. A slot-carrying arrangement is a Layout. Both content AND arrangement is a Compound. Refuse and decompose further, running Q1 recursively on each sub-fragment until every leaf is an atom or a layout. Sections themselves are compositions of primitives, not primitives. A marketing section design almost always starts as a compound that decomposes to Section + Stack + atoms. +- **Q2. Does the library already have it?** Check the target project's existing registry **and** the Studio project itself. [`match-existing-pattern`](match-existing-pattern.md) § Step 1b returns the palette ranked by usage plus every Section grouped by the schema it binds. If a matching primitive exists, mark the row as reuse. If a Section already covers the target schema, the row is reuse or extend, not a build. Otherwise mark as new registration. Aggressively dedup across sections on the same design. +- **Q3. Which props?** For each proposed prop on a new primitive: varies per-instance AND functional/content intent (not visual polish)? Refuse props that fail either half. +- **Q4. Which are Exposed?** For each schema prop: author-understandable AND DS-portable? If both, mark as Exposed Prop candidate. Never expose atom props. + +Every "Components" row in the emitted sheet ends with the Q1 to Q4 verdict for that component. When a customer designer or developer reads the sheet, they can see WHY each primitive was proposed. No black-box decisions. + +**Halt if any Q1 to Q4 answer is ambiguous.** Ask the user to disambiguate before adding to the sheet. + +## Audience: who consumes the sheet, and how it gets executed + +**The developer** is the sole audience. The sheet is not for content managers. It's a machine-readable build plan. It gets executed one of three ways, in priority order: + +### Execution path 1: API authoring (fastest, most reliable, no canvas clicks) + +For every Section + the Template on the sheet, chain to [`author-composition-via-api`](author-composition-via-api.md). It writes the composition JSON directly through the CMA: no Studio UI, no drag-drop, no iframe timing issues. Every drop-tree in the sheet maps 1:1 onto the composition's node tree. Every bind row maps 1:1 onto a `bindings` entry. Deterministic, idempotent, replayable. + +**Prerequisites:** stack credentials in the customer's env (management token). This skill's Step 1 already reads `.env` when fetching the CT. The same creds serve API authoring. + +This is the **default handoff** for `decompose-design`. Ship the sheet, then loop over Sections calling `author-composition-via-api` per Section, then post the Template. + +### Execution path 2: Playwright MCP driving the Studio canvas + +For customers who want the canvas trail (for review, screenshotting, or matching what a human author would do), chain to [`build-section`](build-section.md) per Section, then [`build-connected-template`](build-connected-template.md). These skills drive the Studio canvas via [Playwright MCP](install-playwright-mcp.md) using the proven `mouse.down/move/up` sequence documented in `build-section` § LLM execution caveat. Slower than API path, but produces a real Studio canvas trail you can screenshot. + +**Prerequisites:** Playwright MCP installed (`install-playwright-mcp`) + a running Studio session in a browser the MCP can drive. + +### Execution path 3: Developer follows the sheet in Studio's UI manually + +Only when neither API creds nor Playwright MCP is available. The sheet's drop-trees and bind tables are formatted to be read top-to-bottom in Studio's canvas: every node is a drop-target instruction, every table is a prop-to-field binding. Slowest path. Last resort. + +### Which path does `decompose-design` recommend? + +Determined by what the environment provides: + +| Environment signal | Handoff | +|---|---| +| Management token available (env vars / Contentstack profile) | **Path 1: API authoring.** Chain to `author-composition-via-api` per Section + Template. | +| No management token but Playwright MCP installed | **Path 2: canvas automation.** Chain to `build-section` + `build-connected-template`. | +| Neither | **Path 3: manual.** Print the sheet. Instruct the developer to follow it in Studio's UI. | + +Content managers benefit from what the sheet produces (exposed props, slots, modular-block areas) but they don't consume the sheet. If a content manager is doing this without a developer, run [`author-without-code`](author-without-code.md) instead. That's their skill. + +## Task + +### Step 0: Work from whatever input you were given, and close the gaps yourself + +**A user hands over one artifact, not a dossier.** The rules in this skill were derived from a session with a Figma link and a CSS dump and the shipped component and a live canvas to measure against. That is not the normal case. Each input alone still supports an accurate build, provided the missing numbers are **derived rather than guessed**, and provided whatever remains uncertain is **reported instead of silently invented**. + +| Given | Directly readable | Derive it | Cannot know: assume + report | +|---|---|---|---| +| **Figma link / node id** | element tree, names, x/y/w/h, Layout panel gaps | gaps from child coordinates (`96 − 72 = 24`). Gutters from `(total − n×col) / (n−1)`. Font size from glyph-band height. Line pitch from baseline deltas | which values are theme tokens. Whether a color has a registered counterpart | +| **CSS dump (Copy as code)** | every gap, padding, width, font-size, line-height, color, `display:none` | tree order from the comment sequence + `order:` values. Variant count from repeated blocks | which frames are components vs one-off frames | +| **Screenshot only** | order, alignment, relative emphasis | ratios against a known width. Font size by cap-height measurement | exact px on anything. Say so explicitly | +| **Component source only** | real prop names, block uids, derived values, breakpoint classes | comp values by reading Tailwind classes (`gap-12` means 48px, `text-7xl` means 72px) | which variant the user actually wants built | + +**Ask for one more artifact only when it changes the outcome.** A Figma link plus the host codebase is the strongest pair. The link gives geometry, the code gives uids and token names. If you have the link and the project, you already have ~everything. Do not stall waiting for a CSS paste. + +**Never let a missing input become a silent default.** An unknown gap becomes an explicit row in Deviations (§ Step 9), not a quiet `md`. The user's job is to correct a short, named list, not to discover what you assumed. + +### Step 0b: With more than one artifact, reconcile them BEFORE building + +More input is not more context to skim. It is **corroboration**, and it only pays if the sources are checked against each other first. Two artifacts that agree turn an assumption into a fact. Two that disagree are the single most valuable signal available, and they are worthless if the disagreement is discovered halfway through a build. + +**Each source is authoritative for different things. Never let one answer a question another owns:** + +| Question | Authority | Not authoritative | +|---|---|---| +| What should this instance look like: sizes, gaps, colors, order | **the comp** (Figma / CSS) | the component, which shows a configuration, often another variant | +| What are the real field / block / prop names | **the component + registration** | the comp, whose layer names are cosmetic | +| Is this value expressible at all | **the registration + component** | the comp, which has no notion of a token scale | +| Which value applies in this context | **the component's derivation logic** (`isDark ? …`) | the atom's own default | +| Which variants exist | **the component's type switch** | the comp, which shows only what was laid out | + +**Build the reconciliation table before authoring a single node.** One row per value the sources disagree on: + +| Value | Comp says | Code says | Resolution | +|---|---|---|---| +| Card top border | `6px #899CFA` | `border_width` default `thin` (3px) | comp wins: set `thick`. The default was never the design | +| Icon size | 48px | `icon_size` default `lg` (36px) | comp wins: instance override, the default is not the spec | +| Quote block gap | 30px | `gap-12` (48px) | **flag**: differing enough to be a different variant or a drifted implementation. Ask | +| Hero gap | 16px | shipped hero `gap-6` (24px), centered | **different variant**: shipped is the centered hero, comp is left-aligned. Do not "correct" toward the code | +| Stat color | `#899CFA` | `text-blue-accent` | same value, and the code names the token, so the ask is "expose `blue-accent`", not "add a hex" | + +**Resolution rules, in order:** + +1. **The comp wins on values**: the user is asking for this design, not for what shipped last quarter. A registration default that differs from the comp is not a counter-argument. It is the thing the comp is overriding. +2. **The code wins on names and feasibility**: uids, prop names, token names, derived values, and whether the comp's value can be expressed at all. +3. **A large disagreement means a different variant, not an error.** Two heroes differing by alignment and spacing are two components. Match the comp and say which shipped component it is not. +4. **Anything unresolved after those three is a question, asked before building**: one line, with both numbers in it. Cheaper than a rebuild. + +With one artifact, this step is skipped and its uncertainty lands in Deviations instead. **Do not down-weight a rich input set by building from the most convenient source and treating the rest as background.** + +### Step 1: Ingest the design + +Accept the artifact in whatever form the customer supplies: + +- **Figma URL or frame node ID**: request read access. Use the Figma MCP or a screenshot dump. Identify every frame that maps to one page + its child components. +- **Screenshot / image / PDF page**: inspect visually (Read tool + image viewing). Identify sections stacked top-to-bottom. +- **Wireframe / sketch**: same as image. Less fidelity but same decomposition rules apply. +- **Natural-language description**: "the page has a hero with a title on the left and an image on the right, then a three-across card grid…". Enumerate the sections + their contents mechanically. +- **HTML/CSS mock**: parse the markup. Each block-level region is a candidate Section, each leaf element is a candidate atomic. + +Output of this step: a numbered list of visual regions (Sections) top-to-bottom, each with a short description. + +### Step 2: Identify Layer-1 atomics + +For every design region, list every **leaf visual element** that renders one piece of content: + +- Any heading / title / label probably becomes a `string` atomic (`<Heading>`, `<Text>`). +- Any paragraph / body copy becomes a `string` atomic (`<Text>`, `<Paragraph>`). +- Any image becomes an `imageurl` atomic (`<Image>`). +- Any button or link becomes an `href` atomic (`<Button>`, `<Link>`). +- Any date / time-ago / "N min read" becomes a `datestring` or `number` atomic. +- Any badge / status pill / tag becomes a `choice` or `string` atomic. +- Any rich-text block (mixed formatting, embedded assets, lists) becomes a `json_rte` atomic (`<RichText>`). +- Any paragraph of **body copy or prose**, even if the mock shows it unformatted, becomes a `json_rte` atomic (`<RichText>`), not a plain-text atomic. A mock only shows one state. The author will eventually want a bold word or a link, and changing the field type later means a schema migration. Call it out in the sheet so the reader can downgrade it to plain text if they disagree. +- Any copy the design **truncates** ("read more", a fade-out, an accordion, "show details") becomes `<CollapsibleText>` on the same rich-text field. +- Any card, quote or media that appears **inside the flow of a text block** (rather than beside it) becomes an **embedded entry/asset** in that rich-text field, which needs `registerRTERenderer`. Record the content types an author may embed. See [`register-json-rte`](register-json-rte.md#rte-field-to-component). + +For each atomic, record: +- Proposed component name (`<Heading>`, `<Image>`, etc. Reuse across the design, don't invent new atomics per Section unless the visual really differs). +- Proposed prop schema (one scalar prop, plus its Studio prop type). +- Proposed CMS field the prop binds to (naming pattern: `entry.<field>` or `item.<field>` if inside a Repeater). + +Never propose a Layer-1 atomic with an `array`, `object`, or `React.ReactNode` prop. That's not atomic (atomic = one scalar that renders one visual). Those props are valid Studio prop types, but the component that owns them is a **Layer-2 self-composing shape**. See Step 3. + +### Step 1c: Search the host codebase for the section before decomposing it + +**A brownfield project usually already implements the design you were handed.** Grep the component directory for the section's name and its parts before deriving anything from the comp. The existing code is a higher-fidelity source than the design file, because it is what actually shipped. + +```bash +ls components/ # the directory layout names the domains +find components -iname '*carousel*' -o -iname '*card*' -o -iname '*hero*' +``` + +Four things it gives you that a comp cannot: + +| Read from the code | Why it beats the comp | +|---|---| +| **Real Modular Block uids**: `case_study_card_with_image_statistics`, `case_study_card_with_testimonial` | Condition Blocks authored against real uids bind to the existing CT. Invented names (`stat_card`) need a schema change that was never necessary. | +| **Which variants exist** | The type switch enumerates every branch, the definitive variant list, where a comp only shows the ones the designer laid out. | +| **Design-token names behind literal colors** | `text-blue-accent` tells you `#899CFA` is a named theme token, so "add it to the registration's color options" is a precise ask rather than a guess. | +| **Real dimensions and ratios** | `min-h-[472px]`, `lg:basis-[31.7%]` confirm the comp's numbers survived implementation. | + +Record what you find as reuse rows in Q2 of the framework. **Finding the section implemented does not mean dropping a composite onto the canvas**. The standing rule still says compose from atoms unless the user asks to reuse. It means the atoms you compose carry the same uids, tokens and dimensions the shipped code uses. + +### Step 2b: Every element in the comp ends in exactly one state (element-coverage discipline) + +Steps 8 and 8b check coverage from the **schema** side. This step checks it from the **design** side, and it is the cheapest of the three to skip by accident. + +**Enumerate the region's child elements from the artifact itself, never from your summary of it.** Re-read the Figma node, or re-view the screenshot, at the moment you write the atomic list. A one-line description written earlier in the run ("hero: heading plus two CTAs") is a lossy paraphrase. Anything it dropped is invisible from then on, because every later step reads the summary rather than the design. + +Each element must end this run in one of three states: + +- **Mapped**: becomes an atomic (Step 2) or a Layer-2 component (Step 3). +- **Absorbed**: carried by a layout prop rather than a component: a divider that is really `border`, spacing that is really `gap`, a background that is really the section's `background` choice. Say which prop absorbs it. +- **Dropped**: deliberately out of scope, with a reason on the sheet. + +No element is silently skipped. + +**Worked failure.** A hero comp read, top to bottom: a small muted eyebrow "Our platform", a six-line heading, then two side-by-side CTAs: "Explore our platform" (brand fill) and "Try for free" (secondary fill). It was decomposed from the shorthand "eyebrow, heading, two CTAs" and authored as heading + two buttons. The eyebrow never appeared in the atomic list, so it never reached the sheet, the section, or the CT, and the library already had a registered `Eyebrow` atom with `text` / `color` / `variant` props. Nothing errored. The section was simply missing a piece of the design, and no later step could detect it, because the omission happened before the first artifact was written. + +Count the elements in the comp and count the rows on the sheet. If the second number is smaller, name which elements are Absorbed or Dropped, and why. + +### Step 3: Identify Layer-2 components + +Layer 2 = any registered component that's not a pure Layer-1 atomic. Three concrete shapes to look for: + +**3a. Container with slots**: wrappers holding other components in fixed shape regions: + +- Card frames (border, radius, padding around a title + body). +- Modal / panel chrome (header + body + footer). +- Split / two-up shapes (`left` + `right`). +- Callout boxes (icon + message + action). +- Author cards (avatar + text block). + +For each, record: +- Proposed component name (`<Card>`, `<AuthorCard>`, `<Callout>`). +- Slot props (`body`, `left`, `right`, `header`, `content`). +- **Whether the container also binds to CMS scalars.** If some regions of the container are same-content-every-usage (e.g. `authorName` always binds to `author.name`), those become scalar bindable props on the container itself, not slots. See [`from-designs-to-sections` § When to expose a slot vs bind a scalar](../../docs/00-overview/from-designs-to-sections.md#when-to-expose-a-slot-vs-bind-a-scalar). +- Proposed scope (does this container bind to the CT root, a Group, a Reference, or is it purely structural). + +**3b. Self-iterating component (`type: "array"` prop)**: a component that receives a bound multi-value field and renders `.map()` internally, without a Studio Repeater. Use when the list rendering is straightforward (single-shape items, no per-iteration variant authoring, no template-author child-swap): + +- Bullet list of feature items (`<FeatureList>` bound to a **multi-value Group**, the values are already on the entry, no extra resolution needed). +- Compact tag strip (`<TagList>` bound to a **multi-Reference** of tag entries). +- Logo bar (`<LogoBar>` bound to a **multi-Reference** of brand entries). + +For each, record: +- Proposed component name. +- One `array` prop (e.g. `items`, `tags`, `logos`) with `items: { type: <inner shape> }`. +- Proposed CMS field binding: multi-value Group, multi-value scalar, Modular Block, or multi-Reference. +- **Reference-only requirement**: if the source is a Reference (single or multi), populate `data_sources.resolvedReferences` on the composition so CDA includes the referenced entries. Without it, References arrive as `{ uid }` stubs. Groups, Modular Blocks, and scalar multi-values live on the entry itself and need no resolution. The array arrives populated as-is. + +Do NOT use this shape when the design needs per-iteration variant authoring, Modular-Block polymorphism, template-author child-swap, or Studio's canvas Preview Mode showing N rendered children. Those cases want a **List Section with a Repeater** (Step 5). The full decision framework (array-prop vs Repeater-in-Simple-Section vs List Section) is documented at [`from-designs-to-sections` § Deciding how to iterate](../../docs/00-overview/from-designs-to-sections.md#deciding-how-to-iterate-array-prop-repeater-in-a-simple-section-or-list-section). Walk it every time a list appears in the design. Don't guess. + +**3c. Self-composing component (`type: "object"` prop)**: a component that receives a bound object shape and renders its subfields internally. Use when a nested Group has a fixed structure that always renders the same way: + +- Address block bound to a `location` Group with `street / city / zip` subfields. +- Contact card bound to a `contact` Group with `email / phone / role`. +- Nested stat block bound to a `stats` Group. + +For each, record: +- Proposed component name. +- One `object` prop with `properties: { … }` matching the Group's subfields. +- Proposed Group binding. + +**3d. Layout container**: covered in Step 4. + +For every Layer-2 component (any shape above), pick one of two design intents: + +- **Content shape** (3a / 3b / 3c): the component knows a specific content pattern. The fill is either slot-driven (3a) or data-driven (3b / 3c). +- **Layout shape** (Step 4): the component encodes a design-system layout rule (columns, gaps, breakpoints). + +A component can be BOTH: a hybrid layout+content component that renders a design-system 3-column grid AND binds to a multi-Reference through an internal `.map()`. Record it under the layout list with its array binding noted. + +### Step 4: Identify Layer-2 layout components + +Every layout decision (grid tracks, gaps, breakpoints, spacing rhythm) that recurs across the design gets its own registered layout component: + +- A 2-column split (hero image + text) becomes `<TwoColumn>`. +- A 3-across card grid becomes `<ThreeColumn>` (or `<Grid columns={choice}>`). +- Vertical spacing between Sections on the Template becomes `<Stack>`. +- Constrained content width becomes `<Container>` or `<PageWrapper>`. + +For each layout component, record: +- Proposed name. +- Slot props per region (`col1`, `col2` / `left`, `right` / `items` for repeating). +- Design-system values encoded in the component's CSS (columns, gap at each breakpoint). + +Do NOT propose registering `<Grid>` with a free-integer `columns` prop that lets authors invent unauthorized column counts. Constrain to design-system-approved values via a `choice` prop, or register named variants (`<TwoColumn>`, `<ThreeColumn>`, `<FourColumn>`). + +Do NOT propose registering `<Box>`. A generic wrapper is either an unnecessary registration (skip it) or actually a specific layout component (name it accordingly). + +### Step 4b: Map the comp's spacing to the library's token scale (never eyeball it) + +Every gap, padding and column width in the comp is a **number the design tool already knows**. Read it, then map it to the nearest registered token. Guessing "looks like medium" is how a section renders plausibly and measures wrong. A canvas that looks fine is not evidence, because a wrong token still produces a tidy layout. + +**Get the numbers, don't estimate them.** Best input, when the user can supply it, is Figma's **Copy as code**, then **CSS (all layers)**: it emits every frame's `display` / `flex-direction` / `gap` / `padding` / `width` / `font-size` / `line-height` / `color` as exact values, nested in tree order, with layer names as comments. It needs no MCP access and it removes the measuring step entirely. Ask for it by name. + +Failing that, the Layout panel gives Flow / Gap / Padding / Width / Height per frame. When a frame's gap is not shown, derive it from child coordinates: a child ending at `y=72` followed by one starting at `y=96` is a 24px gap. Column gutter comes out of the same arithmetic: three 400px columns inside 1296px means `(1296 − 3×400) / 2 = 48px`. + +**A CSS comment is a LAYER NAME, not the text on screen.** Figma emits each layer's name as the comment above its block. For a text layer the two usually match, until someone overrides the text inside a component instance, which changes what renders but leaves the layer name at its original value. The dump then reads as authoritative and is wrong. + +```css +/* Personalized by design */ /* layer name — the design actually reads "Our products" */ +/* Button */ /* no text at all: the label is an instance override */ +``` + +Worked failure: a section was built with the heading "Personalized by design" taken straight from the comment, while the design said **"Our products"**. And because the CTA's layer was simply named `Button`, a label was **invented** rather than flagged. Both shipped looking deliberate. + +Two rules follow: + +- **Never source copy from a CSS dump alone.** Take text from a screenshot, the Figma text content, or the user. Use the dump for geometry, type and color, the things it is authoritative for. +- **A layer named generically (`Button`, `Label`, `Text`) means the dump has no copy for that node.** That is a Deviations row or a question, never a placeholder you make up. Inventing copy is the one gap a reviewer cannot spot, because invented text looks exactly like real text. + +**Read the CSS for what is not built, too.** A layer carrying `display: none` is in the comp's code but not in the design, mark it **Dropped** (Step 2b) rather than building it. And `color` values map straight onto the token scale: `rgba(255,255,255,0.98)` is the `strong` (98%) text token, `0.67` is `light`, `#212121` / `#2B2D3B` / `#313A31` were each an exact card-background token. Matching a literal color against the registered scale is how you tell an exact hit from an approximation. + +**Then map each number onto the registered scale.** Read the scale out of the registration (`options` + `defaultValue`), never from memory: + +| Comp value | Nearest token | Note | +|---|---|---| +| 16px | `sm` | exact | +| 24px | `md` | exact | +| 48px | `xl` | exact | +| 80px | `2xl` | exact | +| 20px | `sm` or `md` | **no exact token**: pick one, say which, and say the delta | + +Record the mapping on the sheet as `gap: 48px → xl`, not `gap: xl`. The comp value is the requirement. The token is the implementation. Reviewers can only check the second against the first if both are written down. + +**A comp's "Fixed 1138px" is usually a result, not a prop.** Web layouts are fluid between breakpoints. The design tool can only show one frozen width. Before reaching for a `max_width` prop, check whether the number falls out of the gutters: + +``` +content width = frame width − (2 × horizontal padding) +``` + +Worked example: a hero comp reads `Width Fixed (1138px)`, `Left 72px`, so its board is `1138 + 72 + 72 = 1282`. The built section used `padding_x: md`, which resolves to `desktop:px-18` = 72px, and measured 1085 at a 1229 frame, 845 at 909, 559 at 591. Every point fits `frame − 2×gutter`, so at the comp's own 1282 the content is **1138 exactly**. Nothing needed capping. The comp width was the gutters doing their job. + +Only add a `max_width` when the content stops short of the gutters: a text column narrower than its siblings, a centered measure. Then map it like any other value, and check what the prop actually wraps: a component named `Container` may be a full layout shape (split, media slot, heading props) rather than a width wrapper, in which case using it for width alone drags in an entire unwanted structure. + +**A shipped page is the strongest artifact of all. Ask for it when the design is already live.** View-source (or right-click, then Inspect) on the production page gives the real copy, the real hrefs, the real class names and the real element count, none of which a design file can guarantee. Reconcile it as the top authority for what shipped, above both the comp and the component source. + +Worked example: a comp showed 5 cards and one body sentence. The live page had **6** cards, four slide indicators instead of three, and different copy in every card. The comp's gradient turned out to match the live CSS exactly, which also proved the token chosen from the library was the wrong one. + +**Watch for designs that decouple semantic tag from visual size.** Production markup routinely does this (`<h2 class="h1-standard">`, `<h3 class="cs-h4">`), using a heading level for document structure and a different class for the type scale. Many heading atoms cannot express it: they derive the class from the tag (`HEADING_CLASS[tag]`), so asking for `h2` yields `h2`-sized text and nothing offers the h1 scale on an h2. When you hit this, **match the visual size and record the semantic deviation**, because a wrong heading level is a fixable a11y note whereas wrong type sizing reads as a broken build. + +**Flag what the scale cannot express.** A token scale is deliberately coarse, and a component's prop surface is coarser still. Worked example: a feature grid whose comp has a **48px column gutter and an 80px row gap** was built on a layout component exposing a single `gap` choice applied to both axes. Both values exist on the scale (`xl`, `2xl`), but the component cannot hold both, so the grid renders 48px rows against the comp's 80px. That is not a build error to hide. It is one of three decisions, and the sheet must name which: + +1. **Accept the delta**: note `row gap 48px vs comp 80px` on the sheet and move on. +2. **Extend the registration**: add a `row_gap` prop (a library change, so it needs the owner). +3. **Mirror the comp's structure**: the comp itself often nests: two row frames inside a vertical parent, the 80px being the parent's gap. Rebuilding that nesting reproduces both values with the existing props, at the cost of a grid that no longer wraps freely. + +Whichever is chosen, the delta is on the sheet. A spacing mismatch that nobody wrote down gets rediscovered by whoever compares the built page to the comp. + +### Step 5: Draw Section boundaries (Layer 3) + +Apply the **schema-shape rule** from [`from-designs-to-sections` § Where Section boundaries fall](../../docs/00-overview/from-designs-to-sections.md#where-section-boundaries-fall-the-schema-shape-rule): each Section is rooted at exactly one schema scope. A new scope means a new Section. + +For each Section candidate: + +- Identify the schema scope it binds against: CT root / Global Field / Group / Modular Block / Reference (single) / Reference (multi). +- Classify as **Simple Section** (no root Repeater) or **List Section** (root Repeater over a multi-value field). +- List the Layer-2 containers + Layer-1 atomics dropped inside it. +- For List Sections iterating a polymorphic scope (multi-CT Reference, Modular Block), note the **Condition Blocks needed per allowed type**. + +### Step 6: Propose the Content Type shape + +Given the Sections and their scope requirements, propose the underlying Content Type shape: + +- Root fields directly on the CT (used by Sections rooted at the CT root). +- Groups for structured subfields. +- References (single) for "belongs to one entry" relationships. +- References (multi) for "belongs to N entries" relationships. +- Modular Blocks for "polymorphic list of typed blocks". +- Global Fields for schemas reused across CTs. + +For each field, propose: +- Field type (single-line, multi-line, number, boolean, isodate, link, file/asset, json_rte, reference, group, modular_block). +- `multiple` flag where applicable. +- Field UID (kebab-case). +- Which Section(s) bind to it. + +### Step 7: Apply the governance dial + +Before writing the sheet, classify each field / prop on the target Content Type into one of three governance states. This is a design-system decision: how much of the page is a marketer allowed to change? + +- **Content, bound and sealed.** Headlines, copy, images, links, prices. The CMS entry drives the value. The component reads it. The marketer cannot override on this page. Default for anything semantic. +- **Layout/style, exposed props with token-constrained values.** Columns, media side, gap, background, variant, alignment. Marketers pick from design-system-approved options via a `choice` prop. They don't invent new values. +- **Variable areas, marketer-open.** Story flows, campaign bodies, promoted-content strips. Modular Blocks or a Section Slot lets the marketer add / reorder / choose per-page without code. + +**Target: five marketer decisions per page, not fifty.** Fully data-driven Sections expose nothing (all-bound). A Section that exposes every prop is a design failure: authors either drift the design system or ignore Studio entirely. The governance dial output for each Section: "content bound + these three exposed props + this one slot for variable content." + +### Step 8: Every CT field ends in exactly one state (unmapped-fields discipline) + +Walk every field on the target Content Type. Each must end this run in one of four states: + +- **Bound**: used by an atomic or Layer-2 scalar prop inside a Section. +- **Exposed**: used by an exposed section prop (per Step 7's governance dial). +- **Handled outside Studio**: SEO metadata read by the framework's `<Head>`, route-level fields consumed by the app, etc. Note explicitly. +- **Unmapped**: not used by this design. Listed on the sheet's `Unmapped fields:` one-liner with a reason (deprecated, planned-but-not-yet, held-for-future-page, etc.). + +No field is silently skipped. If a field ends the run without a state, the plan isn't done. + +### Step 8b: Every binding resolves to a real (or proposed) field (binding-resolvability discipline) + +Step 8 checks each CT field against its design usage. This step checks the **reverse**: every binding the sheet proposes must resolve to a field that either **already exists on the target CT** or is **explicitly listed under `Proposed schema`**. A binding that points at neither is an **orphaned bind**. It resolves to `undefined` at runtime and the component silently renders its `defaultValue`, which looks like real content on the canvas but ships empty. This is the single most common decomposition failure. + +Walk every bind row on the sheet: + +- **Scalar bind** (`entry.<field>` / `item.<field>`): the field must exist on the CT (or the iterated item's CT), or appear under `Proposed schema`. If it's neither, STOP: either the design maps to a field that isn't there (add it to `Proposed schema`) or you named the wrong field. +- **Collection bind** (any `type: "array"` / `type: "object"` prop, or a Repeater/List-Section source). The **multi-valued source field must exist or be proposed**: a multi-Reference, Modular Block, `multiple:true` Group, multi-value scalar, or a Pinned Query (Freeform). A `Card Grid` / `Article Grid` whose `items` prop has **no backing multi-valued field on the CT** is the classic trap: e.g. an `Article List` CT holding only `Title`/`URL`/`SEO` while the articles live in a separate `Article` CT the page never references. When the list lives in another CT, the fix is a **multi-Reference on the page CT** (add it to `Proposed schema`) or a **Pinned Query**, never a collection bind with nothing behind it. + +Output of this step: zero orphaned bindings. Every bind row traces to an existing field or a `Proposed schema` line. If a required source doesn't exist and can't be added, the region can't be data-driven: drop it to static, or re-scope the CT. This is the same contract [`build-section`](build-section.md) § Step 5d/5e enforces at bind time and [`register-component`](register-component.md) warns about at registration. Catching it here, in the plan, is cheapest of all. + +### Step 9: Emit the build sheet + +Write a **build sheet**, an actionable spec a developer follows to build the page in Studio. The sheet is a markdown document with a fixed shape. Every line is either an instruction or a mapping. No purpose prose, no click-by-click UI. Ship it as `docs/<template-slug>-build-sheet.md` in the customer's repo (or print inline if no `docs/` directory exists). + +**Structure:** + +``` +BUILD SHEET · <Template name> +Connected → <ct_uid> · URL /<route>/{{entry.url}} (or: Freeform · /<url>) + +## Decisions made ← 5–6 one-liners, each overridable +## Components ← one table +## Sections S1…Sn ← children first; per-section format below +## Template ← assembly +## Proposed schema ← ONLY when no schema existed +## Deviations ← every value that is NOT an exact match (see below) +Unmapped fields: <field> (<reason>) · <field> (<reason>) +``` + +**Deviations**: the build will not be a pixel-exact reproduction, and the honest move is to say exactly where. One row per value that is approximated, assumed, or impossible, so the user's manual pass is a short named list rather than a hunt: + +| Value | Comp | Built | Why | Fix owner | +|---|---|---|---|---| +| Card row gap | 80px | 48px | layout component exposes one `gap` for both axes | library: add `row_gap` | +| Stat color | `#899CFA` | white | `blue-accent` exists in the theme, absent from the registration's color options | library: extend options | +| Card padding | `32/24/24` | `24` all round | `padding_y` is a single value | library: per-side padding | +| Heading to list gap | 42px | 48px | no 42px step on the token scale (`lg` 32, `xl` 48) | accept, or add a step | + +Three rules for this table: + +- **An empty table is a claim.** Only leave it empty when every value was matched exactly. A build with no deviations listed reads as pixel-perfect and gets trusted as such. +- **Name the fix owner.** library (registration or component change), schema, or accept. It tells the user which items they can act on and which need a developer. +- **Distinguish approximated from impossible.** A 42px gap rounded to 48 is a judgment call the user may overrule. A gradient that no prop can express is not. + +**Decisions made**: 5-6 one-liners covering: template pattern (Connected vs Freeform + CT + URL), any split/seal calls on components, any marketer-open areas (Modular Blocks / Section Slots), any fields deliberately unmapped, any schema reuse/extension calls. + +**Components table:** + +| Component | Tier | Status | Key props | +|---|---|---|---| +| Product Card | Layer-2 sealed | reuse: `components/ProductCard.tsx` | - | +| Card Grid | Layer-2 layout | **build** | Cards (slot) · columns · gap | +| Heading | Layer-1 atomic | **build** | text · level | + +"Sealed" = Layer-2 with all data props bound and no slots, brand-consistent, cannot be recomposed. Contrast with slot-based Layer-2 containers that leave regions marketer-fillable. + +**Per-section format**: three blocks: + +``` +### S2 · Product Rail +**Connect:** content type `campaign_page` → schema `featured_products` (multiple ref → `product`) + + Drop Card Grid (Brand · Patterns) + └─ in Cards slot → drop Repeater · bind Items → featured_products + └─ inside → drop Condition · content type is product + └─ inside → drop Product Card + +**Bind — Product Card** (root: Repeater Data) + +| Prop | → Field | +|---|---| +| Image | `hero_image` | +| Name | `name` | +| Price | `price` | +| Link | `url → href` *(bind the leaf, not the link object)* | + +**Expose:** Card Grid → Columns (**Rail columns**), Gap (**Rail spacing**) +``` + +Notation: +- `→` binds: `prop → field` +- `└─` drills in (tree double as step list: every node is a short action) +- Bindings inside a Repeater note their root: (root: Repeater Data) +- Condition guards: `Condition: content type is product` (References) / `Condition: is of type quote` (Modular Blocks) +- Exposed labels are **bold + unique within the section** + +**Template block:** + +``` +### Template · Campaign Landing +New Template → Connected → `campaign_page` → URL `/campaigns/{{entry.url}}` + +| # | Drop section | Auto-binds to | +|---|---|---| +| 1 | Campaign Hero | `hero` | +| 2 | Product Rail | `featured_products` | + +**Set exposed props:** Rail columns `3` · Hero media side `right` +``` + +**Proposed schema block** (only when no usable schema existed): + +| Field | UID | Type | Binds to | +|---|---|---|---| +| Hero | `hero` | group (eyebrow, headline, subcopy, image, cta) | S1 | +| Featured Products | `featured_products` | multiple reference to `product` | S2 repeater | + +**Unmapped fields:** `<field>` (reason) · `<field>` (reason) + +### Step 10: Close with two offers (developer decision points) + +The build sheet is the plan. Two things can happen next. Ask both, in one closing block: + +> **To make this buildable:** \<N\> new components need code + registration, namely \<names with tiers\>. \<Existing components\> register as-is. +> +> **Offer 1: code phase.** Want me to scaffold + register these components in your repo now? +> +> **Offer 2: Studio-canvas phase.** Once code is done, want me to author the Sections + Template automatically? +> - **A**, via API (fastest): chains `author-composition-via-api` per Section + Template. Requires a management token in env. +> - **B**, via Studio canvas (slower, produces canvas trail): chains `build-section` + `build-connected-template` through Playwright MCP. Requires `install-playwright-mcp` done. +> - **C**, hand me the sheet. I'll do it in Studio's UI myself. + +If the developer accepts **Offer 1**: hand off to `register-component` per new atomic + Layer-2, following the [palette conventions](../../docs/20-bring-your-own-components/component-palette-conventions.md). + +If the developer accepts **Offer 2A**: for each Section in the sheet, hand off to [`author-composition-via-api`](author-composition-via-api.md) passing the Section's drop-tree + binding table as the composition-node structure. Post the Template last. + +If the developer accepts **Offer 2B**: for each Section in the sheet, hand off to [`build-section`](build-section.md) / [`build-repeating-section`](build-repeating-section.md) passing the sheet's Section block as input. `build-section`'s LLM-execution rules (mouse.down/move/up sequence, stable selectors, Layers tree for invisible nodes) apply. + +If **Offer 2C**: the sheet's drop-trees and bind tables are already formatted for a developer to follow in Studio's UI, every node is a drop instruction, every table is a bind mapping. + +**Whichever path executes Offer 2, close with [`verify-setup`](verify-setup.md).** Every path lands at the same verified end state. + +## Inputs needed from the user + +1. The design artifact (Figma URL, screenshot path, PDF path, sketch, or natural-language description). +2. The target Content Type name (if known. Otherwise propose one from the design's page kind). +3. Any known design-system constraints: "we already have `<Card>`", "our grid always uses `<ThreeColumn>` with 24px gaps", etc. +4. Whether the design is greenfield (no existing code) or paired with a production page being migrated (if migrating, run [`discover-sections`](discover-sections.md) alongside). + +Do NOT invent designs. If the user says "figure out a design for a blog post," push back, ask for the design artifact or use [`plan-studio-architecture`](plan-studio-architecture.md) which handles the requirements-first path. + +## Acceptance + +This skill succeeds only when ALL of the following are true. + +- [ ] Every visual leaf in the design is classified as a Layer-1 atomic with a proposed scalar prop type + CMS field binding. +- [ ] When two or more artifacts were supplied, a reconciliation table was produced **before** authoring, each disagreement resolved by the precedence rules, and anything still unresolved raised as a question rather than assumed (Step 0b). +- [ ] The build ran from whatever artifact the user supplied. Missing numbers were derived (Step 0), not defaulted, and anything underivable is a named row in Deviations. +- [ ] The sheet carries a **Deviations** table naming every approximated / assumed / impossible value with its fix owner. Empty only when every value matched exactly. +- [ ] The host codebase was searched for an existing implementation of the section, and any real block uids / token names / dimensions found there are used instead of derived ones. +- [ ] Every distinct **item shape** in a repeating region is built, not just the first. Each has its own Condition Block under one Repeater. +- [ ] The atomic list was written while re-reading the artifact, not from an earlier summary of it. Every element in the comp is Mapped, Absorbed or Dropped (Step 2b) with no silent omissions. +- [ ] Every design prop the comp determines (direction, variant, size, color, gap, alignment) has a value on the sheet. "Registered default" is a value only when it was checked against the comp. +- [ ] Every content-shaped wrapper is classified as a Layer-2 container with slot props (and any bindable scalar props on itself). +- [ ] Every layout decision (columns, gaps, breakpoints, vertical rhythm) is classified as a Layer-2 layout component. No layout decision is "TBD via Design Panel." +- [ ] Every Section is rooted at exactly one schema scope per the schema-shape rule. Polymorphic scopes call out the needed Condition Blocks. +- [ ] The Content Type shape is proposed with concrete field types + UIDs. +- [ ] The plan is emitted as a single machine-readable YAML (or equivalent) document ready to hand to `register-component` + `build-section` + `plan-studio-architecture`. +- [ ] No **Layer-1 atomic** proposes an `array`, `object`, or `React.ReactNode` prop (atomic = one scalar that renders one visual). Components with `array` / `object` props ARE valid and register as **Layer-2 self-iterating** (Step 3b) or **self-composing** (Step 3c) shapes. +- [ ] Every `array` prop bound to a **Reference** field has `data_sources.resolvedReferences` planned. `array` props bound to Groups / Modular Blocks / scalar multi-values do NOT. +- [ ] No layout component proposes a free-integer `columns` prop or a generic `<Box>` registration. + +## Common pitfalls + +| Pitfall | Why it bites | Fix | +|---|---|---| +| Skipping the layout classification (Step 4) | Every layout decision drifts to Studio's Design Panel per Section, which produces design-system drift | Enumerate every recurring layout in the design and register a specific Layer-2 layout component for it (`<TwoColumn>`, `<Stack>`, etc.) | +| Proposing atomics per Section instead of per shape | Ends up with `<HeroTitle>`, `<CardTitle>`, `<FooterHeading>`, every one a `string` prop that could reuse a single `<Heading>` | Same atomic for same shape across the design. Use registered variants (`choice` prop) if the visual really differs | +| Over-exposing slots | Every Section author has to fill every slot for every drop. Component becomes tedious | Slot only what varies per usage. Scalar-bind everything that's same-per-usage. See [`from-designs-to-sections` § When to expose a slot vs bind a scalar](../../docs/00-overview/from-designs-to-sections.md#when-to-expose-a-slot-vs-bind-a-scalar) | +| Merging two schema scopes into one Section | Auto-binding won't match. A Section rooted at both CT root AND a Reference scope simultaneously isn't a valid shape | Split into two Sections per the schema-shape rule | +| Missing Condition Blocks on polymorphic References or Modular Blocks | List Section renders nothing per iteration when the source is multi-CT or polymorphic | Note the required Condition Blocks per allowed type on every polymorphic Section | +| Emitting the plan as prose | Downstream skills can't consume prose. Ambiguity re-enters the flow | Emit YAML (or equivalent structured document) that `register-component` / `build-section` can be pointed at | +| Building a region from a pre-composed composite (`*-feature-card`, `*-hero`) because the palette has one | Fastest path, identical on canvas, and the shape is now fixed in code. Internal spacing can't be matched to the comp, the author can't restructure it, and the design stops being a composition | Compose from atoms + layout primitives by default. Use a composite only when the user asks to reuse one. Router § Standing rule | +| Building from one artifact while others sit unread | Extra sources are corroboration, not background. Skimming them means the disagreements (a 3px default against a 6px comp border, a 36px default against a 48px icon) surface after the build instead of before it | Reconcile every source into a table first, then author (Step 0b) | +| Resolving a comp-vs-code conflict silently | Each source is authoritative for different things. Letting the code overrule the comp on a value builds last quarter's design. Letting the comp overrule the code on a uid invents a schema | Apply the precedence rules: comp wins on values, code wins on names and feasibility, large gaps mean a different variant | +| Ignoring the live page when the design already ships | Production markup carries the real copy, hrefs, element counts and class names. A comp can be older or newer than what shipped, so building from it alone reproduces a design nobody is running | Ask for view-source on the live URL and reconcile it as the top authority for what shipped | +| Assuming a heading atom can size text independently of its tag | Production decouples them (`<h2 class="h1-standard">`). Most atoms derive the class from the tag, so the requested level silently brings its own size | Match the visual size, record the semantic level as a deviation | +| Treating CSS-dump comments as text content | They are layer names. A text override inside a component instance leaves the name stale, so the dump confidently supplies the wrong copy, and a layer named `Button` supplies none at all | Source copy from the rendered design or the user. Use the dump for geometry, type and color | +| Inventing copy for a node the artifact does not supply | Invented text looks identical to real text, so no reviewer can spot it and it ships | Flag it as a Deviations row or ask. Never fill the gap silently | +| Stalling for more input than the user gave | The normal case is one artifact. A Figma link alone supports gaps from child coordinates, gutters from arithmetic and font size from glyph bands | Derive what is derivable (Step 0). Ask for a second artifact only when it changes the outcome | +| Shipping a build with no deviation list | Silence reads as pixel-perfect, so wrong spacing is trusted instead of corrected. The user then hunts for what moved | Emit the Deviations table. Empty only when every value matched exactly | +| Decomposing a design the host codebase already implements | The shipped component names the real Modular Block uids, every variant, and the theme token behind each literal color. Deriving them from the comp instead invents uids that need a schema change and turns named tokens into guesses | Grep `components/` for the section before decomposing, Step 1c | +| Building only the first item shape of a mixed list | A carousel reading A-B-C-A-B-C is **three** card shapes, not one. Building A alone leaves two thirds of the design unbuilt, and the canvas looks plausible so nothing flags it | Count the distinct shapes in the list, not the items. One Condition Block per shape, every one built, see [`author-composition-via-api`](author-composition-via-api.md) § Skeleton first, bind later | +| Placing N copies of a repeating shape instead of one Repeater child | Five cards look right and are fixed at five. Editing one leaves four stale, and the binding pass has nothing to point at a collection | One child inside a `repeater`, `items` left unbound in the skeleton. See [`author-composition-via-api`](author-composition-via-api.md) § Skeleton first, bind later | +| Decomposing from your own earlier summary of the design | A paraphrase written mid-run silently drops elements: an eyebrow, a caption, a second CTA. Every later step reads the summary, so nothing can detect the loss | Re-read the Figma node / screenshot while writing the atomic list. Reconcile element count vs sheet rows (Step 2b) | +| Leaving design props off the sheet because "the default will do" | The registered default is the library's choice, not the comp's, a stack defaults to `column` and side-by-side CTAs render stacked, with no error | Record every design prop the comp determines. See [`author-composition-via-api`](author-composition-via-api.md) § Skeleton first, bind later | +| Estimating spacing instead of reading it off the design tool | A gap picked by eye lands on the wrong token: a comp specifying 16px gets built as 24px. The section still looks tidy, so nothing flags it, and it only surfaces when someone measures against the comp | Read Flow / Gap / Padding from the Layout panel, or derive from child coordinates. Record `48px → xl`, not `xl` (Step 4b) | +| Silently absorbing a value the token scale or prop surface can't express | The build quietly disagrees with the comp, one `gap` prop covering both axes cannot hold a 48px gutter and an 80px row gap | Name the delta on the sheet and pick one of the three options in Step 4b: accept, extend the registration, or mirror the comp's nesting | + +## See also + +- [`from-designs-to-sections`](../../docs/00-overview/from-designs-to-sections.md): the taxonomy this skill applies. Read first. +- [`from-designs-to-sections`](../../docs/00-overview/from-designs-to-sections.md): the taxonomy this skill applies. Read first. +- [`plan-studio-architecture`](plan-studio-architecture.md): takes the plan this skill emits (or a requirements doc) and orders the build sequence. +- [`register-component`](register-component.md): mechanics of registering each Layer-1 and Layer-2 output. +- [`build-section`](build-section.md): mechanics of authoring each Section output. +- [`discover-sections`](discover-sections.md): companion skill for the code-first path (existing React codebase). +- [`design-section-from-jsx`](design-section-from-jsx.md): for individual JSX components, propose the linked-schema shape. +- [`figma-generate-components`](figma-generate-components.md): when the customer wants raw React code generated from Figma frames alongside this decomposition. +- [`adapt-collection-component`](adapt-collection-component.md): when the design uses a legacy production wrapper with an array/object prop. diff --git a/skills/studio/decompose-jsx-to-atomics.md b/skills/studio/decompose-jsx-to-atomics.md new file mode 100644 index 0000000..4ebbea9 --- /dev/null +++ b/skills/studio/decompose-jsx-to-atomics.md @@ -0,0 +1,232 @@ +--- +name: decompose-jsx-to-atomics +--- + +## When to use + +Break an existing React component (often a whole page in one file) into Layer-1 atomics, Layer-2 containers and Layer-3 Sections, so its parts become reusable instead of one monolithic block. + +Use when the customer HAS code and a component is too coarse to reuse: a page-level component, a "section" that renders six unrelated things, or anything you'd otherwise register whole. Phrases: "make this component reusable", "break this page into sections", "my whole page is one component", "decompose this React component", "this section is too big", "why can't I reuse this". Run BEFORE `discover-sections` on an existing codebase, and before `register-component` on any component larger than one content field. Do NOT use for a design artifact with no code (that's `decompose-design`) or to derive a schema from a component's existing props (that's `design-section-from-jsx`). + +> **Mandatory auth preflight: settle the credential before the first API call.** Resolve it OAuth-first per [`authenticate-cma`](authenticate-cma.md): `CS_OAUTH_ACCESS_TOKEN`, else the Contentstack MCP's stored session: Read that skill for where it is, even with no MCP connected. **Never ask the user for a session `authtoken`.** If nothing resolves, or a refresh fails with `400 invalid_refresh_token`, hand them `! CONTENTSTACK_REGION=<code> npx @contentstack/mcp --auth` (it needs a TTY and a browser, so it cannot be run for them) and wait. `403 error_code 316` is a valid credential aimed at another org: fix the org or the `api_key`, do **not** re-authenticate. + +> **Reuse preflight: mandatory when the project already has compositions.** Before naming a single new atomic, component or Section, run [`match-existing-pattern`](match-existing-pattern.md) § Step 1b: it groups existing Sections by the schema they bind and ranks the component palette by real usage. Decomposition is where reuse has to bite. Once this step names `Heading` instead of the project's `alpha-atom-heading`, the duplicate survives into the plan and into the build. Mark every row reuse, extend, or new, and never new without having looked. + +# Decompose existing JSX into atomics and Sections + +## Context + +**The failure this exists to prevent.** With an existing codebase, nothing used to look inside a component. `discover-sections` counts how many routes use a component name. `design-section-from-jsx` reads its props. So a page written as one `<RewardsPage>` produced one registered component and one Section covering the whole page. The consequences compound: + +| Symptom the customer reports | Actual cause | +|---|---| +| "Nothing is reusable. I can't put the hero on another page" | The hero was never a separate piece, it's markup inside a page component | +| "Authors can only change text, not rearrange anything" | One Section = one block on the canvas. Nothing to reorder | +| "We never used slots / repeaters / exposed props" | A monolith has no seams, so those primitives never come up | +| **"The data doesn't bind properly to entries"** | One coarse prop takes a whole object, often typed `any`, which the binder then flattens (see [`register-component` § The `any` flatten trap](register-component.md)). Atomic props bind one field each and can't flatten | + +An existing React component is **not** a reason to skip decomposition. It's the input to it: **a component becomes a Section by being broken up**, and its parts become the reusable pieces. + +Taxonomy (Layer-1 atomic / Layer-2 container / Layer-3 Section) is defined once in [`from-designs-to-sections`](../../docs/00-overview/from-designs-to-sections.md) and applied to visual input by [`decompose-design`](decompose-design.md). This skill applies the same taxonomy to **code**. Don't re-derive the layers here. Read them there. + +## Task + +1. **Read the render tree, not the prop list.** Open `componentPath` and walk what it returns. Props tell you what the current caller passes. The render tree tells you what the component actually contains. That's where the reusable pieces are hiding. Note for each element: tag/component, what content it renders, and where that content comes from (a prop, a literal, a `.map()`, a fetch). + +2. **Classify every leaf as a Layer-1 atomic, one piece of content each.** + + | In the JSX | Atomic | Prop type | + |---|---|---| + | `<h1>`, `<h2>`, heading, title, label | `Heading` / `Text` | `string` | + | `<p>` of **plain** copy, no formatting possible | `Text` / `Paragraph` | `string` | + | `<img>`, background image | `Image` | `imageurl` | + | `<a>`, button with a destination | `Link` / `Button` | `href` (+ `string` label) | + | **Formatted copy**: `dangerouslySetInnerHTML`, a markdown renderer, `ReactMarkdown`, `<div>` holding `<strong>`/`<ul>`/`<a>` from data, anything an author can style | **`RichText`** | `json_rte` (JSON RTE) or `any` (HTML RTE / Markdown) | + | Copy that **truncates**, "read more", accordion body, `<details>`, show/hide | **`CollapsibleText`** | as above | + | Formatted copy that can **embed a card or media** mid-flow | `RichText` **+ `registerRTERenderer`** with `embeddedEntry` / `embeddedAsset` | as above | + | A date, price, count | `Text` | `datestring` / `number` | + | An interactive control (form field, toggle, carousel arrow) | **stays inside a registered component**: built-ins can't express behavior | - | + + **On an existing codebase, flag the upgrade: don't perform it.** Code that renders prose through a plain `<p>` is often just what was quickest to write. Two cases, very different costs: + + - The CT field is **already rich text** and the code renders it plainly, so switching to `RichText` is **free**: no schema change, and it fixes markup currently showing as literal characters. + - The field is **plain text** holding prose, so suggest `json_rte`, state that it is a schema change on live entries ([`migrate-ct-schema`](migrate-ct-schema.md)), and proceed with plain text if the user declines. + + Say it as a recommendation with the cost attached, then respect the answer. Never migrate a field as a side effect of decomposing a component. + + **Deciding plain vs rich:** ask whether an author could ever want **bold, a link, a list, or an embed** in that copy. If yes it is rich text. `Text`/`Paragraph` render `String(text)` and would show the markup as literal `<p>` characters. Body copy, descriptions and long-form prose are rich text far more often than they look. Headings and labels almost never are. Mapping: [`register-json-rte` § Which component for which field](register-json-rte.md#rte-field-to-component). + + **A hardcoded literal is still an atomic.** `<h1>Join our rewards</h1>` becomes a `string` atomic bound to a CT field. That's precisely how the author gains the ability to edit it. Leaving literals in code is the single biggest source of "why can't I change this text". + +3. **Classify wrappers as Layer-2 containers with slots.** A `<div>`/`<section>` that exists for layout (grid, two-column, card shell, band with a background) becomes a registered component with a **`slot` prop**, not a hardcoded parent. The layout stays in your code (where the design system lives). What goes inside becomes author-controlled. See [`use-section-slot`](use-section-slot.md). + +4. **Every `.map()` is a list, but check WHAT it iterates before calling it a Repeater.** Two different answers, and getting it wrong models runtime data as content: + + | The `.map()` iterates | It is | Skill | + |---|---|---| + | A **CMS field** on the entry: Modular Blocks, multi-Reference, multi-Group, scalar list (`entry.modular_blocks.map(…)`) | **Repeater** bound to that field, item shape as its own child Section | `build-repeating-section`, `use-repeater` | + | Items whose type varies per entry (Modular Blocks, multi-CT references) | Repeater **+ a Condition Block per type** | `use-condition-block` | + | **Runtime / external data**: a fetch result, search results, cart contents, anything in `useState` (`searchResults.map(…)`) | **Not a Repeater and not a CMS field.** The component owns it. Feed it through the `data` prop | `wire-external-data` | + + **Modeling runtime results as a CMS field is a real failure mode**, not a harmless mismatch. Flight-search results or an API response have no entry to bind to. You'd be creating content fields nobody can populate. Test: could an author edit these items in Contentstack? If no, it's external data. + + Iterating with a fixed index (`items[0]`, `items[1]`, `items[2]`) over a CMS field is the Repeater case wearing a disguise. Decompose it as a Repeater, not three atomics. + + **A partially-handled block list is a bug worth surfacing.** Code like `if (block.hasOwnProperty("marquee")) return <Marquee …/>` renders **nothing** for every other block type, silently. Enumerate the CT's block types and give each a Condition Block, or state explicitly that the others are unsupported. + +5. **Draw Layer-3 Section boundaries: one per recognizable page part.** A Section is a hero, a form band, a feature grid, a CTA strip. **One Section for a whole page is a defect, not a shortcut.** If your boundary list has one entry and the page has visibly distinct regions, go back to step 1. + + Boundary test, in order: + - Could a person point at it and name it? If yes, it is its own Section. + - Could it plausibly appear on another page? If yes, it is definitely its own Section. + - Does it read from a different part of the entry (a group, a reference, a block list)? If yes, it is its own Section, scoped to that field. + +6. **Dedupe against what already exists.** Before proposing anything new, check the codebase and Studio for pieces that already cover it: `grep -rl "<Heading\|<Text\|<Image" src` for atomics already registered, plus the Studio-side reuse inventory, [`match-existing-pattern`](match-existing-pattern.md) § Step 1b, which groups existing Sections by `linked_schemas` and ranks the component palette by real usage. **Both halves, not just the grep.** The codebase says what is registered. Only the project says what is used, and an atom nothing uses is not a convention to build on. Two Sections that differ only in spacing are one Section with an exposed prop, not two. + +7. **Emit the binding map: one atomic prop, one CT field.** This is what fixes the binding complaints: + + ``` + Hero band (Section, scoped to entry.hero group) + Image image → entry.hero.image (imageurl) + Heading headline → entry.hero.headline (string) + Text body → entry.hero.body (string) + Link cta → entry.hero.cta.href (href) + ``` + + **A single prop taking the whole object is the failure mode**, not a shortcut. `content: { type: "any" }` fed the `hero` group renders blank. The binder recursively unwraps single-key objects for every type except `object`/`array`. Bind field-by-field. Use `object`/`array` only when the component genuinely consumes a whole group or list. + +8. **Print the breakdown, then hand off.** + + ``` + DECOMPOSITION — <component path> + + Was: 1 component rendering the whole page. + Now: <N> Sections · <M> Layer-1 atomics · <K> Layer-2 containers + + Sections (build in this order): + 1. <Hero band> scope: entry.hero reusable on: any page with a hero + atomics: Image, Heading, Text, Link + 2. <Offers list> scope: entry.offers Repeater + child Section per offer + atomics (child): Image, Heading, Text + 3. <Signup form> stays one registered component — interactive + + Reused, not rebuilt: + <Heading / Text / Image already registered — no work> + + New CT fields needed: + <entry.hero.headline (string), entry.hero.image (file), …> + ``` + + Then run [`register-component`](register-component.md) for each atomic and container not already registered, [`design-section-from-jsx`](design-section-from-jsx.md) if a Section's linked schema doesn't exist, [`build-section`](build-section.md) per Section in the printed order, and [`build-repeating-section`](build-repeating-section.md) for any list. If a plan is in flight, feed this breakdown into [`plan-studio-architecture`](plan-studio-architecture.md) Step 2 rather than building straight away. + +## Worked example: a page component becomes four reusable pieces + +```jsx +// Before — one component, nothing reusable +export function RewardsPage({ data }) { + return ( + <main> + <section className="hero"> + <img src={data.heroImage} /> + <h1>Join our rewards</h1> {/* hardcoded */} + <p>{data.heroBody}</p> + <a href={data.ctaHref}>Sign up</a> + </section> + <div className="grid"> + {data.offers.map((o) => ( + <article key={o.id}><img src={o.icon} /><h3>{o.title}</h3><p>{o.text}</p></article> + ))} + </div> + <RewardsSignupForm categories={data.categories} /> + </main> + ); +} +``` + +| Piece | Layer | Becomes | +|---|---|---| +| `<img>`, `<h1>`, `<p>`, `<a>` in `.hero` | 1 | 4 atomics, one CT field each, including the hardcoded `<h1>`, now editable | +| `.hero` wrapper | 2 | Hero band container (layout stays in code) | +| `.grid` + `.map()` | 3 | **Offers list** = Repeater over `entry.offers` + child **Offer card** Section | +| `<article>` internals | 1 | 3 atomics on the child Section, reusable wherever a card appears | +| `RewardsSignupForm` | 2 | Stays one registered component: interactive, plus an `action` prop so an author picks the submit behavior | + +**Result:** 3 Sections (Hero band, Offers list, Signup form) + 1 child Section (Offer card), 7 atomics, all reusable on any future page. Before: one block an author could not rearrange, reuse, or restyle. + +## Every atom is born with its CSLP tags: not retrofitted + +An atom that renders correctly but carries no `data-cslp` is invisible to Visual Editor: authors can see it and cannot click it. Nothing errors, so it ships. A real audit of 19 atomic components found **0** carrying the node-level tag. + +So write the tags into the atom as you create it. This is the shape for **every** atom this skill produces, two attribute bags, always both: + +```tsx +// The template. `studioAttributes` on the root, one `$`-twin per bindable prop. +type Props = { + headline?: string; + $headline?: Record<string, string>; // injected by the SDK, never built by hand + studioAttributes?: Record<string, string>; +}; + +export default function AtomHeading({ headline, $headline, studioAttributes, ...rest }: Props) { + return ( + <h2 {...rest} {...studioAttributes} {...$headline}> + {headline} + </h2> + ); +} +``` + +| Bag | Spread on | Without it | +| --- | --- | --- | +| `studioAttributes` | the atom's **root** element | the node cannot be selected on canvas or in VB | +| `$<prop>` | the element rendering that prop | that field has no inline click-to-edit | + +Three rules that follow, and they are not optional: + +1. **One root element per atom.** A fragment (`<>…</>`) has nowhere to put `studioAttributes`. If an atom genuinely cannot have a single root, register it with `wrap` instead. +2. **Root only.** Spreading `studioAttributes` on an inner element makes Studio measure the wrong box, so selection and drop-targeting land in the wrong place. +3. **Never hand-build a `$`-twin.** It arrives as a prop when the field is bound. Constructing one yourself produces a tag pointing at nothing. + +The full contract, the `wrap` alternative and the `cslp: { appendTags: true }` prerequisite live in [`register-component` § The `studioAttributes` contract](register-component.md). Do not restate them, but do not ship an atom without them either. + +## Inputs needed from the user + +1. `componentPath`: the component to decompose. If they point at a route file, decompose the page component it renders. +2. `entryShape`: the content type / entry the page reads from, if it exists. Without it, propose the CT fields in step 7 and hand them to `design-section-from-jsx`. +3. Nothing else. Don't ask which parts to split. Propose the breakdown and let them correct it. + +## Acceptance + +- [ ] The **render tree** was read, not just the prop list. +- [ ] **Every atomic component carries its CSLP tags**: `studioAttributes` spread on the root element **and** `wrap: false` in its register entry and each bindable prop's `$`-twin spread on the element rendering it. Verified in the DOM, not assumed: the root and each bound text/image element must show `data-cslp`. Without this the atom renders correctly but is invisible to Visual Editor and Live Preview. See [`register-component` § The `studioAttributes` contract](register-component.md) and § The `$`-twin contract. +- [ ] **Every leaf is an atomic with exactly one content field**, hardcoded literals included. +- [ ] **Every `.map()` was classified before being converted**: a CMS field becomes a Repeater (+ Condition Block for varying types), runtime or external data becomes the `data` prop, **not** a Repeater. No runtime list was modelled as a CMS field. +- [ ] **Partially-handled block lists were surfaced**: every block type has a Condition Block, or the unsupported ones are stated. +- [ ] **More than one Section**, unless the page genuinely has one region, and if it's one, that's stated with a reason. +- [ ] Every atomic prop binds to **exactly one CT field**. No prop takes a whole object except a deliberate `object`/`array`. +- [ ] Existing atomics and Sections were **reused, not duplicated**. +- [ ] Interactive parts stayed inside a registered component, with `action` props where an author should choose behavior. + +## Common pitfalls + +| Pitfall | Why it bites | Fix | +| --- | --- | --- | +| Registering the page component whole because "it's used on one route" | Reuse count is a prioritization signal, not a qualification gate. A one-off page component is the **strongest** decomposition candidate. It's where the monolith is | Decompose first, then register the parts. See `discover-sections` § scoring | +| Reading props instead of the render tree | Props describe today's caller. The reusable pieces are in what the component renders | Walk the JSX return value | +| Leaving hardcoded copy in code | Authors then can't change it and file it as a bug. "Why can't I edit this heading" is almost always a literal that was never made an atomic | Every literal string/image becomes an atomic bound to a CT field | +| `.map()` kept as a hardcoded list | Content can't grow, adding a fourth offer needs a developer | Repeater + child Section (+ Condition Block for blocks/references) | +| Turning a **runtime** `.map()` into a Repeater: search results, a fetch response, `useState` contents | There's no entry behind it, so you create CMS fields nobody can populate and the Repeater binds to nothing | Ask "could an author edit these items in Contentstack?" If no, use `wire-external-data`'s `data` prop | +| Converting a block list that only handles one block type | The other types render nothing today and will keep rendering nothing, the gap just moves into Studio | Enumerate the CT's block types. One Condition Block each, or say which are unsupported | +| One Section for the whole page | Nothing to reorder, nothing reusable, and the author never meets slots or repeaters | One Section per recognizable region. One-Section pages need a stated reason | +| One coarse prop taking the whole object (`type: "any"`) | The binder recursively unwraps single-key objects for every type except `object`/`array`, the component renders blank with no error | Bind field-by-field. `object`/`array` only when the component truly consumes a group or list. See `register-component` § The `any` flatten trap | +| Splitting on styling differences | Two "different" heroes that differ by padding become two Sections to maintain | One Section + an exposed prop, see `expose-section-props` | + +## See also + +- [`from-designs-to-sections`](../../docs/00-overview/from-designs-to-sections.md): the Layer-1/2/3 taxonomy this applies +- [`from-designs-to-sections`](../../docs/00-overview/from-designs-to-sections.md): the Layer-1/2/3 taxonomy this applies +- [`decompose-design`](decompose-design.md): same taxonomy, visual input instead of code +- [`discover-sections`](discover-sections.md): which components recur across routes. Run this after decomposition +- [`design-section-from-jsx`](design-section-from-jsx.md): derive a Section's linked schema once boundaries are drawn +- [`register-component`](register-component.md): register each atomic and container. Prop types and the `any` trap +- [`build-repeating-section`](build-repeating-section.md) · [`use-section-slot`](use-section-slot.md) · [`expose-section-props`](expose-section-props.md) diff --git a/skills/studio/decompose-site.md b/skills/studio/decompose-site.md new file mode 100644 index 0000000..dc2fd3a --- /dev/null +++ b/skills/studio/decompose-site.md @@ -0,0 +1,259 @@ +--- +name: decompose-site +--- + +## When to use + +Take a **whole-site design** (multiple templates / page kinds) and emit one consolidated schema plan plus per-template build sheets. Orchestrator around [`decompose-design`](decompose-design.md): loops it per template, deduplicates atomics + Layer-2 components across pages, identifies cross-page shapes for Global Fields, and produces a single site-wide build plan the agent can execute end-to-end. + +Use when the greenfield build has 3+ page kinds sharing common atoms (Hero, Cards, Footer), **or when the input is one Figma board holding many section shapes rather than whole pages** (§ Step 1b). Phrases: "decompose this whole site", "here are all the page designs", "build the marketing site", "multi-page decomposition", "site-wide plan", "here's the whole Figma board", "build every section in this file", "all the hero variants", "turn this design file into sections". Do NOT use for single-page builds (use `decompose-design` directly). Do NOT use as a substitute for the taxonomy. This skill assumes you already understand Layer 1 / 2 / 3. + +> **Auth preflight: settle the credential before the first API call.** Resolve it OAuth-first per [`authenticate-cma`](authenticate-cma.md): `CS_OAUTH_ACCESS_TOKEN`, else the Contentstack MCP's stored session: Read that skill for where it is, even with no MCP connected. **Never ask the user for a session `authtoken`.** If nothing resolves, or a refresh fails with `400 invalid_refresh_token`, hand them `! CONTENTSTACK_REGION=<code> npx @contentstack/mcp --auth` (it needs a TTY and a browser, so it cannot be run for them) and wait. `403 error_code 316` is a valid credential aimed at another org: fix the org or the `api_key`, do **not** re-authenticate. + +> **Reuse preflight: mandatory when the project already has compositions.** Before naming a single new atomic, component or Section, run [`match-existing-pattern`](match-existing-pattern.md) § Step 1b: it groups existing Sections by the schema they bind and ranks the component palette by real usage. Decomposition is where reuse has to bite. Once this step names `Heading` instead of the project's `alpha-atom-heading`, the duplicate survives into the plan and into the build. Mark every row reuse, extend, or new, and never new without having looked. + +# Decompose an entire site: one schema, N build sheets, zero duplicate registrations + +> **The sheet is not the build.** A decomposition that names N Sections and then gets authored as one component bound to the whole page body renders correctly and passes every render-time check. Whoever executes this sheet must count `section-composition` nodes before reporting done: [`build-connected-template`](build-connected-template.md) § Post-build structural check. When the page body is one modular-blocks field, [`decompose-blocks-page`](decompose-blocks-page.md) is the recipe. + +> ## Verification status +> +> Orchestrator over verified skills. Trust is inherited from the underlying skills. The orchestration logic itself is verified against one synthetic 6-page design. +> +> - Underlying [`decompose-design`](decompose-design.md) is verified. This skill just loops it per template + dedupes. +> - Verified: chain-in skills at the closing offer (`provision-studio-stack`, `register-component`, `author-composition-via-api`, `import-content`, `verify-setup`, `deploy-studio-site`) all exist and are documented. Their individual verification status is stated in their own frontmatter callouts. +> - **Full orchestration logic runtime-verified** against a synthetic **6-page design** (homepage + blog_post + product_detail + author_bio + category_listing + contact). 18/18 acceptance-criteria claims pass: page-kind rows emitted, component registry ≤ 15 entries (produced 6 at N=6 pages, so dedup holds under scale), atomics + layer-2 deduped across pages by prop-shape, `hero` / `seo` / `footer` identified as Global Fields (each embedded in all 6 CTs), build order strictly topologically sorted, `site-build-plan.md` + one build sheet per page kind emitted. +> - **Real-design edge cases runtime-verified.** Three failure modes the skill's guidance addresses were checked in the same run: +> - **Variant-choice-prop merge**: 3 button variants (primary / secondary / tertiary) collapse into ONE `button` component via a `variant: choice` prop, not three separate components. +> - **Slot-shape divergence**: `Card` is deduped by prop-shape (identical props on every usage), and the plan tracks the 3 distinct slot content-shapes it carries per usage (`text+image+cta`, `icon+text`, `image+text+price+cta`) so section authors pick the right slot content. +> - **Single-usage roles**: `body`, `features`, `contact_form`, `author_card`, etc. (roles that appear on only ONE CT) are NOT wrongly promoted to Global Fields. GF extraction requires the 2+-CT threshold to hold. +> - Verified with **N > 5 page kinds**: the verification run covered 6 CTs, dedup holds under scale (6-entry registry, well below the 15-entry budget), all 3 GFs consistent across all 6 CTs. Structural claims scale linearly. + +## Context + +`decompose-design` handles one template at a time. A real marketing site has 5-30 page kinds: blog post, product, case study, campaign, author, category listing, etc. Running `decompose-design` per page in isolation produces: + +- **Duplicate atomics.** Every page has a Heading, Text, Image. Registering them 5 times is wrong. +- **Duplicate Layer-2 components.** A Card appears on 6 pages. Registering `BlogCard / ProductCard / TestimonialCard / …` as separate components misses the reuse. +- **Fragmented schemas.** Every page proposes its own `hero: group`, but if 4 CTs share the same hero shape, that shape wants a **Global Field**. +- **No build order.** Which Sections to build first? Which components block what? + +This skill fixes all four by decomposing site-wide, not page-wide. It emits **one consolidated schema plan** + **one shared component registry** + **N build sheets** (one per template), with the site-level dependency order encoded. + +## The four-question decision framework, applied site-wide + +Every fragment on every page is walked through the [four-question decision framework](../../docs/00-getting-started/composable-primitives.md#four-questions-to-answer-when-building-any-component). The framework is an open validator (works on any input, no assumed registry). The multi-page context adds a deduplication dimension the single-template `decompose-design` doesn't have: + +- **Q1. Classify: Atom, Layout, or Compound?** Applied per distinct shape across ALL templates. A `Card` shape identified on 6 pages is classified once as a layout. Compounds (marketing sections, page hero blocks) always decompose recursively until every leaf is an atom or a layout. +- **Q2. Does the shared registry already have it?** Two layers: (a) does the customer's existing project registry cover it? (b) has it already been proposed for the shared registry by an EARLIER template in this pass? Aggressively dedup: 6 pages proposing "Card" produce one Card entry, not six. +- **Q3. Which props?** For each proposed prop on a proposed new primitive: does the value vary across the SITE (not just across one template) AND represent functional/content intent? Site-wide invariants (e.g. Section spacing is always `comfortable` across all 30 templates) are hardcoded, not exposed as a prop. +- **Q4. Which are Exposed?** For each schema prop: author-understandable + DS-portable? Additionally: does any template on the site actually need to override this prop? If zero templates need override, keep static globally. Site-wide "exposed for one template only" is a valid reason. + +**The shared component registry every skill invocation produces has one Q1 to Q4 verdict PER PROPOSED PRIMITIVE**, not per template use. The build sheets reference the registry by name. Each template's build order just says "drop Section S3 (registered per registry)" and stops. + +**Halt if any Q1 to Q4 answer is ambiguous** across templates. Ask the user to disambiguate before committing to the shared registry. + +## Task + +### Step 1: Ingest all page designs + +Accept the whole-site design as one of: + +- **A Figma file with multiple frames**, one per page kind. List every frame that maps to a template. +- **A folder of screenshots / PDF pages**, named per page kind (e.g. `blog-post.png`, `product-detail.png`). +- **A design description document** listing pages + their contents. +- **A live sitemap URL**: crawl every unique route and treat each as a page kind. + +Output: a numbered list of page kinds. **STOP: end your turn with the list and a question; write nothing until the user confirms.** False positives (same kind counted twice) waste effort. False negatives (missed page kind) require another pass. + +### Step 1b. Variant boards: collapse before you count + +A design file is often **not** one frame per page. It is a board of section shapes: twelve hero treatments, four product heroes, two product templates. Counting frames here and building one Section each is how a 10-component library turns into 40 one-off components. + +**Detecting a variant board**: any of these means the frames are variants, not page kinds: + +- **Repeated frame names.** Four frames literally named `Product hero`, two named `Product Template`. Observed on a real Contentstack board: 22 top-level nodes, of which `Product hero` appeared 4× and `Alpha` and `Product Template` 2× each. +- **A text label acting as a group header**: a `Hero options:` text node sitting above six frames named `Hero Full View`, `Hero Half View`, `Hero Form`, `Hero Product View`, `Hero Center small or no image`, `Hero Blog type`. The label names the family. The frames are its axes. +- **Names encoding a modifier**: `… Full / Half / Center`, `… With Form`, `… Expanded`, `… small or no image`. The words after the family name are the variant axis. + +**Name the axes, then classify each one.** For the hero family above the axes are: media size (full / half / none), media kind (image / product), action (CTA / form), and body type (marketing / blog). For each axis ask **does it change what an author types, or only how it looks?** + +| Axis changes | Becomes | Why | +|---|---|---| +| Only appearance: alignment, media side, spacing, emphasis | A `choice` prop on the existing component, static on the canvas | Design decisions live in the library, not the schema. See [`design-component-library`](design-component-library.md) § content binds, design stays static. | +| Which region is filled: image present or absent, form instead of buttons | A **slot**, or an omitted optional prop | One structure renders both. The author fills what applies | +| What fields exist: a form variant needs `form_id`, a blog variant needs `author` + `read_time` | A **separate block** in the page CT's Modular Block | Different content shape is the one case that earns a new Section | + +Only the third row produces a new Section. **The first two rows collapse.** Six hero frames typically resolve to one or two Sections carrying two or three `choice` props, not six. + +**Emit a variant table before Step 2**, and confirm it with the user: + +| Family | Frames | Axes | Collapses to | +|---|---|---|---| +| Hero | 6 | media size, media kind, action, body type | 2 Sections (`hero`, `hero_form`) + `media` choice + optional image slot | +| Product hero | 4 | layout only | 1 Section + `layout` choice | +| Product template | 2 | layout only | 1 Section | + +Then run Step 2 over the **collapsed** list. A board of 22 frames that yields 22 Sections means Step 1b was skipped. + +Sections that survive collapse are block types on the page CT. Assemble them with [`build-page-builder-template`](build-page-builder-template.md), which is what lets an author pick and order them per page. + +### Step 2: Run `decompose-design` in dry-mode per page + +For each page kind, invoke [`decompose-design`](decompose-design.md) in a **plan-only** mode (don't emit build sheets yet). Collect: + +- List of atomics proposed per page (with proposed prop shape). +- List of Layer-2 containers + layout components proposed per page. +- Proposed Section boundaries + their schema scopes. +- Proposed CT / Group / Reference / Modular Block fields. + +### Step 3: Deduplicate atomics + Layer-2 components across pages + +Merge by **prop-shape match**, not by proposed name. Two atomics with the same prop types (`string` maps to text) render the same visual. They're one component. + +Emit a **shared component registry** table: + +| Component | Tier | Prop shape | Used on pages | +|---|---|---|---| +| Heading | Layer 1 atomic | `text: string, level: choice` | Blog, Product, Case Study, Campaign | +| Image | Layer 1 atomic | `src: imageurl, alt: string` | Every page | +| Card | Layer 2 slot-based | `body: slot, title: string` | Blog related, Product PDP, Case Study grid | +| ThreeColumn | Layer 2 layout | `col1/col2/col3: slot` | Blog related, Case Study grid, Homepage | +| … | … | … | … | + +**Registration budget:** the shared registry should have ~5-15 components for a typical marketing site. If it has 50, you're proposing too many one-offs. Merge more aggressively by prop-shape. + +### Step 4: Identify Global Field candidates + +A field shape appearing on 2+ CTs is a **Global Field candidate**. From the per-page decomposition: + +- Every `hero: group` proposed independently on 3 CTs becomes one Global Field `hero` referenced by all three. +- Every `seo: group` (title, description, og_image) becomes one Global Field `seo`. +- Any structural shape recurring across CTs becomes a GF. + +**Emit a GF table** showing which fields become GFs + which CTs embed them. GF-linked Sections auto-bind across every CT embedding the GF. This is the single biggest reuse win at the Section layer. + +### Step 5: Emit the consolidated schema plan + +One document listing: + +- **Global Fields** (with their sub-field structure). +- **Content Types**, each with its fields (referring to GFs by UID for shared groups + declaring CT-specific fields). +- **Reference relationships** between CTs (e.g. `blog_post.author → author`, `product.related_products → product multi`). + +The schema plan is the **first thing to provision** on the target stack. Every other step depends on it existing. + +### Step 6: Determine build order + +Some Sections + Templates depend on others. The build order is deterministic: + +1. **Register components**: every atomic + Layer-2 in the shared registry, in tier order (atomics first, containers next, layouts last). +2. **Provision the schema**: GFs first, then CTs referencing them. +3. **Author Sections**: build Sections in dependency order: + - Sections bound to CT root or Group scopes (no dependencies). + - Sections bound to Reference scopes (depend on the target CT existing). + - Sections bound to Global Fields (depend on the GF being embedded in ≥1 CT). +4. **Author Templates**: one per page kind, composing the already-built Sections. +5. **Verify per page**: `verify-setup` after each Template lands. + +Emit this order as a numbered list in the site plan. + +### Step 7: Emit the site plan + per-template build sheets + +Two output artifacts: + +**Site plan (`docs/site-build-plan.md`):** + +``` +SITE PLAN · <Site name> + +## Page kinds (N templates) +| # | Page kind | Template UID | CT | +|---|---|---|---| +| 1 | Blog post | blog_post_template | blog_post | +| 2 | Product PDP | product_template | product | +| ... | + +## Global Fields +| UID | Fields | Embedded in CTs | +|---|---|---| +| hero | eyebrow, headline, subcopy, image, cta | blog_post, product, case_study, campaign | +| seo | title, description, og_image | every CT | +| ... | + +## Content Types +| UID | Fields | Sections | +|---|---|---| +| blog_post | title, excerpt, cover, body, hero(GF), seo(GF), author(ref), related_posts(multi-ref) | Hero, Body, Author Card, Related Posts | +| ... | + +## Shared component registry (register once, use everywhere) +[Component table from Step 3] + +## Build order +1. Register components (atomics → containers → layouts): ... +2. Provision schema (GFs first, then CTs): ... +3. Author Sections in dependency order: ... +4. Author Templates: ... +5. Verify: ... +``` + +**Per-template build sheet (`docs/<template-slug>-build-sheet.md`), one per page kind:** + +Same format as `decompose-design` § Step 9: Decisions made, Components table (referencing the shared registry, not per-page duplicates), Sections, Template, Unmapped fields. Every reference to a component says "reuses `Card` from shared registry" or "builds new, see shared registry". + +### Step 8: Close with orchestrated offers + +After all artifacts land, emit the closing offers in one block: + +> **Site scope:** N page kinds, M shared components (K new, M-K existing), G global fields, X content types. +> +> **Offer 1: schema.** Provision the schema (Global Fields + Content Types) on the target stack? Chains [`provision-studio-stack`](provision-studio-stack.md). +> **Offer 2: components.** Scaffold + register the K new components? Chains [`register-component`](register-component.md) per component. +> **Offer 3: compositions.** Author every Section + Template per the build order? Uses the API path (default) or Playwright MCP canvas path per [`decompose-design`](decompose-design.md) § Execution paths. +> **Offer 4: content.** Populate the CTs with real entries? Chains [`import-content`](import-content.md) if source data exists. Otherwise leaves entries for manual authoring. +> **Offer 5: verify + deploy.** Run [`verify-setup`](verify-setup.md) end-to-end and [`deploy-studio-site`](deploy-studio-site.md). + +An agent accepting all five drives the full site build with no manual canvas clicks. Failures at any offer stop the pipeline and report which step needs attention, see [`agent-idempotency`](../../docs/50-advanced/agent-idempotency.md) for the resume semantics. + +## Inputs needed from the user + +1. The whole-site design (Figma URL, folder of screenshots, description doc, or sitemap). +2. Target Contentstack stack UID + management token (via env or explicit). +3. Any existing schema / components in the stack that should be reused rather than re-created. +4. Which page kinds are in scope (in case the design has out-of-scope pages). + +## Acceptance + +- [ ] Every page kind from the design has a corresponding row in the Site plan's page-kinds table. +- [ ] Shared component registry has ≤ 15 entries for a typical marketing site. If more, aggressive merging by prop-shape wasn't done. +- [ ] Every field shape appearing on 2+ CTs is a Global Field, not a duplicate. +- [ ] Build order is a strict topological sort. No Section depends on a Section built later. No Template depends on a Section not yet authored. +- [ ] `docs/site-build-plan.md` + one `docs/<template-slug>-build-sheet.md` per page kind emitted. +- [ ] Every page's build sheet references the shared registry for reused components, not new proposals. +- [ ] Closing offer block accurately counts new vs existing components. + +## Common pitfalls + +| Pitfall | Why it bites | Fix | +|---|---|---| +| Proposing per-page atomic variants (`BlogHeading`, `ProductHeading`) | Registration explosion, brand-drift risk, template authors confused which to use | Merge by prop-shape into one `Heading` component with a `level` choice prop. Same for `Text`, `Image`, `Button`. | +| Missing GF opportunities | Every CT has its own `hero: group`. Editing the hero shape means 5 schema changes. Zero cross-page auto-binding. | Any group appearing on 2+ CTs is a GF candidate. Extract before finalizing the schema. | +| Circular dependencies in build order | Section A binds to a Reference to CT B, but B references A. Chicken-and-egg. | Order: register components first, then provision the schema all at once, then author Sections in topological order. If two Sections depend on each other, one must be extractable to a Layer-2 container inside the other. | +| Emitting one giant build sheet instead of per-template files | Unusable for per-page focus. Downstream skills expect one sheet per template. | One `<template-slug>-build-sheet.md` per page kind. Site plan is the index. | +| Skipping the "confirm page kinds" step | User's design has 30 frames but only 8 are unique page kinds. You decompose 30 identical things | Ask the user to confirm the list of page kinds after Step 1 before proceeding. | +| Treating a variant board as N page kinds | Six hero treatments become six Sections and six near-identical component sets. The palette becomes unusable and a copy change means six edits | Run Step 1b first. Collapse appearance-only axes into `choice` props. Only a different field set earns a new Section. | +| Waiting for the content model before building any Section | The board has 22 frames and the CT does not exist yet, so nothing gets built and the design is never validated against the primitives | Author skeletons first: structure with static values, bind later. [`author-composition-via-api`](author-composition-via-api.md) § Skeleton first, bind later. | + +## See also + +- [`composable-primitives`](../../docs/00-getting-started/composable-primitives.md): the pattern the multi-page decomposition converges on. Every atom + layout deduplicated across templates should map to one of the 10 primitives (or become a candidate addition to the library, per the additive-growth rule). +- [`decompose-design`](decompose-design.md): per-template skill this orchestrator loops. Same output format per template. +- [`build-page-builder-template`](build-page-builder-template.md): assembles the collapsed Sections into one template an author can compose per page. +- [`author-composition-via-api`](author-composition-via-api.md) § Skeleton first, bind later: build the Sections before the schema exists. +- [`plan-studio-architecture`](plan-studio-architecture.md): requirements-first planner. Complementary. Use `plan-studio-architecture` when the input is a requirements doc. Use `decompose-site` when the input is a visual design covering multiple pages. +- [`provision-studio-stack`](provision-studio-stack.md): schema provisioning. Chained by Offer 1. +- [`register-component`](register-component.md): component registration. Chained by Offer 2. +- [`import-content`](import-content.md): content ingestion. Chained by Offer 4. +- [`from-designs-to-sections`](../../docs/00-overview/from-designs-to-sections.md): the three-layer taxonomy this skill applies at scale. +- [`from-designs-to-sections`](../../docs/00-overview/from-designs-to-sections.md): the three-layer taxonomy this skill applies at scale. diff --git a/skills/studio/deploy-studio-site.md b/skills/studio/deploy-studio-site.md new file mode 100644 index 0000000..c0f9ea7 --- /dev/null +++ b/skills/studio/deploy-studio-site.md @@ -0,0 +1,135 @@ +--- +name: deploy-studio-site +--- + +## When to use + +Ship a Studio site to Contentstack Launch: Launch project and environments, production env vars, composition Deploy, Live Preview gating, region config, cache and 401 gotchas. + +Use when deploying a Studio site, setting up Launch, configuring production env vars, wiring Live Preview only on the preview environment, or debugging "deployed but visitors see old content / blank page / a login screen / Live Preview chrome in production / 401 from Delivery API". Two layers ship independently: composition (Save+Deploy in canvas) and host (Launch environment variables + deployment). + +> **Mandatory auth preflight: settle the credential before the first API call.** Resolve it OAuth-first per [`authenticate-cma`](authenticate-cma.md): `CS_OAUTH_ACCESS_TOKEN`, else the Contentstack MCP's stored session: Read that skill for where it is, even with no MCP connected. **Never ask the user for a session `authtoken`.** If nothing resolves, or a refresh fails with `400 invalid_refresh_token`, hand them `! CONTENTSTACK_REGION=<code> npx @contentstack/mcp --auth` (it needs a TTY and a browser, so it cannot be run for them) and wait. `403 error_code 316` is a valid credential aimed at another org: fix the org or the `api_key`, do **not** re-authenticate. + +# Deploy a Studio site + +## Context + +A Studio site ships in two independent layers, and both must be correct for the visitor URL to render the latest composition cleanly. + +- **Composition Deploy**: publishes the composition record through Contentstack's publishing workflow (from the canvas to the target environment). See `docs/30-composition/save-vs-deploy-a-composition.md`. +- **App host**: your React/Next app on Contentstack Launch that renders `<StudioComponent />` and reads from the Delivery SDK (via `sdk.fetchCompositionData` on the server when using SSR composition-by-URL). A Launch project holds environments; each Launch environment is one Git branch (or uploaded ZIP) with its own URL and variables, and is separate from the stack's environments. + +**Tools.** Drive Launch through the Contentstack MCP's `launch` group (`GROUPS` includes `launch`, plus `CONTENTSTACK_ORGANIZATION_UID` and `CONTENTSTACK_LAUNCH_PROJECT_ID`; see [`install-contentstack-mcp`](install-contentstack-mcp.md)). It uses the same OAuth session. The Launch CLI (`csdx plugins:install @contentstack/cli-launch`) accepts only `csdx login`, which the user must run. + +The Delivery Token, Preview Token, and Environment are configured per Launch environment: production must NOT carry a Preview Token (otherwise visitors see Live Preview overlays), and the preview environment SHOULD carry one (otherwise authors lose the live edit-and-see-it loop). + +Goal state: visitor hits the production URL, gets the latest deployed composition rendered cleanly (no Studio chrome, no Live Preview overlays), and authors see real-time updates only in preview contexts. Match the success screenshot in `assets/screenshots/template-blog-post-full-stack.png`. + +## Task + +### A. Host the app + +1. **Confirm the build output type.** CSR (Vite/CRA, which produces static output) or SSR (Next/Remix). Pick the matching Launch framework preset (`NextJs`, `CSR`, …). Next.js on Launch needs App Router 13.4.6+ (Pages Router 12.2+). + +2. **Create or reuse the Launch project** from a Git repository (GitHub, Bitbucket Cloud) or a ZIP upload, with the preset, build command and, for SSR, server command. The MCP will not start the `launch` group without a project ID (checked in 0.9.0), so create a **new** project in the Launch UI first (Git or ZIP upload), then set its uid as `CONTENTSTACK_LAUNCH_PROJECT_ID` and restart the MCP. + +3. **Set production env vars** on the Launch production environment (Environments, Settings, Env. Variables, or `update_an_environment`). Same names as `.env.local` per `docs/10-setup/app-prerequisites/install-the-delivery-sdk.md`. Pick the prefix matching the framework (`NEXT_PUBLIC_`, `VITE_`, `REACT_APP_`, `PUBLIC_`, or plain `process.env` for Remix): + - `<PREFIX>_CONTENTSTACK_API_KEY`: stack API key (`blt…`) + - `<PREFIX>_CONTENTSTACK_DELIVERY_TOKEN`: `cs…`, scoped to the **production environment + branch** + - `<PREFIX>_CONTENTSTACK_ENVIRONMENT`: `<prodEnvironment>` (e.g. `production`) + - `<PREFIX>_CONTENTSTACK_DEFAULT_LOCALE`: e.g. `en-us` + - `<PREFIX>_CONTENTSTACK_PREVIEW_TOKEN`: **omit on the production deploy**, set only on preview deploys + - SSR composition-by-URL ONLY (server-only, no `PUBLIC` prefix): `STUDIO_COMPOSITION_CT_UID`, `CONTENTSTACK_DELIVERY_TOKEN`, `CONTENTSTACK_ENVIRONMENT`, `CONTENTSTACK_BRANCH`. See `docs/10-setup/app-prerequisites/ssr-composition-query.md`. + +4. **Two Launch environments**: + - **Production**: auto-deploy on commits off, `environment=<prodEnvironment>`, no preview token, Live Preview `enable: false`. **Turn off Contentstack Authentication** (on by default): it puts a login screen in front of every visitor. + - **Preview**: auto-deploy on, `environment=<previewEnvironment>`, preview token set, Live Preview `enable: true`. Point that stack environment's Base URL at this Launch URL so Studio's canvas and preview load the deployed app ([`configure-studio`](configure-studio.md)). + +5. **Gate Live Preview import and init** on the preview token. First run `grep -rn 'from "@contentstack/live-preview-utils"' src app lib`: every hit is a static import that shows the Edit button in production, however `init` is gated. Replace it with this (per `docs/10-setup/app-prerequisites/install-live-preview.md`): + ```ts + const previewToken = process.env.NEXT_PUBLIC_CONTENTSTACK_PREVIEW_TOKEN; + + if (previewToken) { + import("@contentstack/live-preview-utils").then(({ default: ContentstackLivePreview }) => + ContentstackLivePreview.init({ + ssr: false, + enable: Boolean(previewToken), + stackDetails: { apiKey, environment }, + editButton: { enable: Boolean(previewToken) }, + })); + } + ``` + Why: `live-preview-utils` (4.5.3) draws its Edit button on **import**, even when `init` never runs. Remove its re-export too. + +6. **Non-US regions.** Set the Live Preview `host` to the regional preview host (e.g. `eu-rest-preview.contentstack.com`) and the Delivery SDK `region` to match. Regional CDN hosts: `eu-cdn.contentstack.com`, `azure-na-cdn.contentstack.com`, `azure-eu-cdn.contentstack.com`, `gcp-na-cdn.contentstack.com`, `au-cdn.contentstack.com`. **Non-prod (the non-prod domain your team uses) has no `region` shortcut**: every host is `<env>-<service>.<non-prod-domain>` and must be set explicitly. The full table is [`install-studio`](install-studio.md) § Non-prod hosts. Establish which data center you're on first via [`analyze-project-fit`](analyze-project-fit.md) § 2b: Determine the data center. See `docs/10-setup/troubleshoot-common-studio-issues.md`. + +7. **Deploy**: push to the production environment's branch, or redeploy it (`create_deployment`; a ZIP project uploads first). Read the build log (`get_deployment_logs`) for the expected `<PREFIX>_*` injection. Launch applies variable changes only to new deployments, and `NEXT_PUBLIC_*` (and equivalents) are inlined at build time: redeploy after every variable change. + +8. **Caching (Next.js).** Launch does not support ISR revalidation, `revalidatePath`/`revalidateTag` or the App Router Data Cache. Opt pages out with `dynamic = 'force-dynamic'` or `revalidate = 0`, set `Cache-Control` headers in `next.config`, and after publishing purge with `revalidate_cdn_cache` (every redeploy also purges). Statically built pages need a deploy hook registered as a Contentstack webhook on publish. + +### B. Deploy the composition + +9. **Save** in the canvas. Save persists the draft. Deploy publishes whatever was last saved. See `docs/30-composition/save-vs-deploy-a-composition.md`. + +10. **Preview** in the canvas with Live Preview, switching the Preview Entry across a few real entries (for linked templates) to confirm bindings resolve. Repeater iteration bindings use `repeater.<field>`. Verify each iteration renders the expected entry data. + +11. **Click Deploy** in the canvas toolbar (right cluster, next to Save). The Deploy modal opens: pick the target environments, decide "Send with References" vs "Send without References", confirm. The modal title says "Deploy Template" even for Sections. That's a label quirk, not a bug. + +12. **Publish connected entries to the same environment.** For linked templates, the template can deploy fine but the page renders blank if the connected entry isn't published to `<prodEnvironment>`. Publish each connected entry to the target environment. + +13. **Wait for publishing workflow.** If the stack has approvals or scheduling configured, the deploy is queued, not immediate. Check the Contentstack publish queue. + +## Inputs needed from the user + +In this order. Stop and ask if any required value is missing. + +1. `framework`: picks the Launch preset and the env-var prefix +2. `launchProject`: an existing Launch project, or create one from a Git repo + branch or a ZIP upload +3. `prodEnvironment`: Contentstack environment name for the production Launch environment +4. `previewEnvironment`: Contentstack environment name for the preview Launch environment (optional, skip if not using one) +5. `region`: defaults to `us` +6. `ssrCompositionByUrl`: yes/no, controls whether server-only env vars are required + +If the user doesn't know an environment name, send them to `app.contentstack.com`, where they open the stack, go to Settings, and open Environments. + +## Acceptance + +This skill succeeds only when ALL of the following are true. If any fails, do not claim success. Surface the failure and stop. + +- [ ] The Launch production environment has `<PREFIX>_CONTENTSTACK_ENVIRONMENT` set to `<prodEnvironment>`, **no** preview token, and Contentstack Authentication off +- [ ] The Launch preview environment (if configured) has `<PREFIX>_CONTENTSTACK_PREVIEW_TOKEN` set and `environment=<previewEnvironment>` +- [ ] The live deployment was built after the last variable change +- [ ] `live-preview-utils` is imported only when the preview token is present, and `enable` / `editButton.enable` are gated on it +- [ ] If SSR composition-by-URL: server-only `STUDIO_COMPOSITION_CT_UID`, `CONTENTSTACK_DELIVERY_TOKEN`, `CONTENTSTACK_ENVIRONMENT`, `CONTENTSTACK_BRANCH` are set +- [ ] Region/host pair matches the stack's region (non-US only) +- [ ] Composition is Saved and Deployed to `<prodEnvironment>` via the canvas Deploy modal +- [ ] All connected entries for linked templates are published to `<prodEnvironment>` +- [ ] Visitor URL opened in an incognito session renders the composition with no Studio chrome, no Live Preview edit buttons +- [ ] In DevTools, the Network tab shows Delivery API calls hitting `cdn.contentstack.io` (or the regional CDN), not the preview host +- [ ] The preview environment (if configured) updates live on entry edit without reload + +## Common pitfalls + +| Symptom | Cause | Fix | +| --- | --- | --- | +| Deployed but visitors see old content | Launch CDN cache, or a statically built page | `force-dynamic` / `revalidate = 0` with `Cache-Control`, purge with `revalidate_cdn_cache`; static pages need a deploy hook on publish. `revalidatePath`/`revalidateTag` do nothing on Launch | +| Visitors get a Contentstack login screen | Contentstack Authentication is on by default per Launch environment | Turn it off on the production environment | +| Page renders blank post-deploy | Connected entry not published to target env | Publish the entry to `<prodEnvironment>` | +| Visitors see draft content after someone previewed | A server with a preview token keeps the last request's preview hash on its shared stack | No preview token on production. On a preview deployment, clear the hash per request: [`configure-csr-vs-ssr`](configure-csr-vs-ssr.md) | +| Visitors see Live Preview edit buttons / overlays | `enable: true` unconditional in prod build, or `live-preview-utils` imported statically (it draws the button on import) | Import it only when the preview token exists (step 5) | +| Preview environment doesn't update live | `onEntryChange` not wired to refetch, or preview token mismatch | See `docs/10-setup/verify-end-to-end.md`. Verify token matches the one in Contentstack | +| `region` / `host` mismatch error | Non-US stack with US defaults | Set Delivery SDK `region` and Live Preview `host` to the region | +| Deploy "succeeded" but composition didn't change | Pending approval / scheduled publish | Check Contentstack publish queue | +| Build fails, or a changed variable has no effect | Launch applies variables only to new deployments, and `<PREFIX>_*` are inlined at build time | Set vars on the Launch environment, then redeploy. No single quotes in values | +| Delivery token returns 401 in production | Token scoped to wrong environment or branch | Generate a token scoped to the prod environment + branch. Rotate in the Launch environment's variables, then redeploy | +| Search engines don't index the site | The default `*.contentstackapps.com` domain is served `noindex` | Add a custom domain to the production environment | +| SSR composition-by-URL 500s | Missing server-only `STUDIO_COMPOSITION_CT_UID` / `CONTENTSTACK_BRANCH` | Add server-only vars per `docs/10-setup/app-prerequisites/ssr-composition-query.md` | + +## See also + +- `docs/30-composition/save-vs-deploy-a-composition.md`: Save vs Deploy semantics +- `docs/10-setup/app-prerequisites/install-the-delivery-sdk.md`: env var names +- `docs/10-setup/app-prerequisites/install-live-preview.md`: Live Preview init +- `docs/10-setup/app-prerequisites/ssr-composition-query.md`: SSR composition-by-URL env vars + `sdk.fetchCompositionData` +- `docs/10-setup/verify-end-to-end.md`: end-to-end smoke test +- `docs/10-setup/troubleshoot-common-studio-issues.md`: regional host config diff --git a/skills/studio/design-component-library.md b/skills/studio/design-component-library.md new file mode 100644 index 0000000..489f582 --- /dev/null +++ b/skills/studio/design-component-library.md @@ -0,0 +1,273 @@ +--- +name: design-component-library +--- + +## When to use + +Decide the SHAPE of a Studio component library (which atomic and layout primitives to register) BEFORE any `registerComponent` call is written. Applies the atom + layout taxonomy from `docs/00-getting-started/composable-primitives.md` to the user's design system so authors compose sections on the fly without visual drift. + +Use when the user says "which components should I register", "design our Studio library", "what should the component library look like", "we're starting fresh, what do we register", "atomic + layout primitives for Studio", or when someone brings a design system (Tailwind + shadcn, Chakra, MUI, a custom kit) and asks how it maps to Studio. Do NOT use to write registration calls for one component (use `register-component`). Do NOT use to break one over-large existing component into pieces (use `decompose-jsx-to-atomics`). Do NOT use for per-page decomposition of a specific design (use `decompose-design`). + +> **Auth preflight: settle the credential before the first API call.** Resolve it OAuth-first per [`authenticate-cma`](authenticate-cma.md): `CS_OAUTH_ACCESS_TOKEN`, else the Contentstack MCP's stored session: Read that skill for where it is, even with no MCP connected. **Never ask the user for a session `authtoken`.** If nothing resolves, or a refresh fails with `400 invalid_refresh_token`, hand them `! CONTENTSTACK_REGION=<code> npx @contentstack/mcp --auth` (it needs a TTY and a browser, so it cannot be run for them) and wait. `403 error_code 316` is a valid credential aimed at another org: fix the org or the `api_key`, do **not** re-authenticate. + +# Design a Studio component library + +## Context + +The trap is registering either (a) one giant Hero with 20 props, or (b) ten differently-shaped Heros. Both fragment the design system: (a) hides variety behind a monolith authors can't extend without engineering. (b) lets every Hero pick its own spacing and type scale. The alternative is a small set of **atomic** and **layout** primitives, the 10-primitive pattern in [`docs/00-getting-started/composable-primitives.md`](../../docs/00-getting-started/composable-primitives.md). + +## The four-question decision framework + +This skill walks the [four-question framework](../../docs/00-getting-started/composable-primitives.md#four-questions-to-answer-when-building-any-component) from the pattern doc. **The framework is an open validator**. It works on any input (React component, Figma frame, verbal description) regardless of what's already registered: + +- **Q1. Classify: Atom, Layout, or Compound?** Look at the input in isolation. A component that carries a single content unit is an Atom. One that arranges children via slots is a Layout. One that does both content AND arrangement is a Compound (refuse and decompose recursively). This is a validator question. It tells you what shape the component actually is. +- **Q2. Does the library already have it?** Only after Q1 confirms atom or layout, check reuse. On a fresh project the answer is trivially "no" for the first primitives. In an existing library, check the registry. With legacy monoliths, check the atom/layout registry, not the monolith list. +- **Q3. Which props?** For each proposed prop, does it vary per-instance AND represent functional/content intent (not visual polish)? +- **Q4. Which are Exposed?** For each schema prop, is it author-understandable AND DS-portable? + +**Halt if any answer is ambiguous.** Never guess. Ask the user to disambiguate before proceeding. + +This skill's job is to produce that primitive list for the user's specific design system BEFORE any registration is written. Get it right up front and the registration work that follows is mechanical. + +## The rule of thumb: expose only when the value + +1. **Varies per instance, AND** +2. **Represents functional or content intent, not visual polish.** + +Concretely: +- **Good:** `level: h1 | h2 | h3` on Heading (semantic hierarchy, so DS tokens set size and weight) +- **Good:** `variant: default | secondary | ghost` on Button (intent maps to color + border tokens) +- **Good:** `text` on Heading, `href` on Button, `src` on Image (content) +- **Avoid:** `size: sm | md | lg | xl` on Heading (level already decided the size) +- **Avoid:** `color: hex` on anything (drift risk: use `emphasis: default | muted | inverse` instead) +- **Avoid:** `borderRadius`, `fontWeight`, `letterSpacing`, `boxShadow`, `padding: 32` (design-system concerns) + +## Task + +Walk the user through a five-step decomposition of their design system into a primitive list ready to hand to `register-component`. + +### Step 1: Confirm the design system + +Ask (only if not stated): +- Which UI library are you on? (Tailwind + shadcn/ui, Chakra, MUI, custom Radix, custom tokens) +- Where are your semantic tokens defined? (CSS variables, Tailwind config, theme file) +- Does the DS already expose semantic variant names (primary / secondary / default) or raw color tokens? + +Do NOT invent tokens. Every semantic option in a primitive's schema must map to an existing DS token. If a token doesn't exist for a proposed option, flag it and offer to defer that primitive until the DS adds the token. + +### Step 1b. The split that makes a library reusable: content binds, design stays static + +**Measured on a 58-section production Studio site: 300 bound props vs 1803 static. 86% of every prop set is design, not content.** + +An atom therefore has **few content props and many design props**, and they are wired differently: + +| | Content props | Design props | +|---|---|---| +| Examples | `text`, `title`, `href`, `media`, `alt` | `size`, `color`, `align`, `variant`, `tag`, `gap`, `padding_*` | +| Wired as | **bound** to a CT field | **static value** set on the canvas | +| Who decides | the author, per entry | the composer, per placement | +| Count per atom | 1 to 3 | 4 to 13 | + +Real signatures from that site: + +``` +heading content: text design: tag, color, align, variant, size +body-string content: text design: size, color, align, variant +cta-button content: title, href, open_in_new_tab design: style, icon_position, full_width, align, variant +media content: media, alt design: fill, aspect_ratio, fit +``` + +**Never model a design prop as a CT field.** `gap`, `align` and `variant` as content fields produce a schema an author cannot understand and a section that drifts visually per entry. If it changes how the thing looks rather than what it says, it is a static value. + +The corollary is what makes the library reusable: **the same four atoms compose every section**, differing only in their static design values. `heading` is used 23× across that site, `cta-button` 33×, `stack` 66×, not one variant per section. + +### Step 2: List the four content atoms + +Every marketing surface needs the same four content-only atoms: + +| Atom | Props | Notes | +|---|---|---| +| **Heading** | `text`, `level: h1..h4`, `emphasis: default | inverse` | `level` drives DS tokens for size + weight. `emphasis: inverse` is for text over dark BackgroundMedia. | +| **Description** | `text`, `emphasis: default | muted | inverse` | Paragraph body copy. No size, no maxWidth. | +| **Button** | `label`, `href`, `variant`, `size`, `icon` | Variants + sizes mirror shadcn's `cva` (or the DS's equivalent) exactly. | +| **Image** | `src`, `alt`, `aspect: auto | 1:1 | 4:3 | 16:9 | 21:9`, `fit: cover | contain` | No borderRadius, no shadow. | + +For each atom, confirm the DS has tokens for every semantic option. If the DS uses different variant names (e.g. `emphasis` values named `primary/secondary/accent` on Chakra), keep the DS's names. The schema should read like the DS. + +**Body copy needs one atom per rich-text shape, not one `Description`.** The production reference registers four, because the render path differs per storage format. See [`register-json-rte`](register-json-rte.md#rte-field-to-component): + +| Atom | For | Prop type | +|---|---|---| +| `body-string` | plain single-paragraph copy | `plaintext` | +| `body-multiline` | plain multi-paragraph copy | `plaintext` | +| `body-json-rte` | JSON RTE | `json_rte` | +| `body-html-rte` | HTML RTE / Markdown | `json_rte` (or `any` on an SDK without the widened binding) | + +**Add these atoms when the designs call for them**: each is a real atom on the reference site, and each is one an agent otherwise inlines into a bigger component: `eyebrow` (kicker above a heading), `icon`, `breadcrumb`, `cta-row` (a group of buttons), `numbered-item` / `step-number` (ordered lists), `share-buttons`, `form-embed`, and `shape` / `accents` (decorative, no content props at all). + +### Step 3: List the six layout primitives + +| Layout | Props | Slots | Notes | +|---|---|---|---| +| **Section** | `spacing: compact/comfortable/spacious`, `background: none/surface/muted/brand/inverse`, `contentAlign` | `children` | Outer padded wrapper. Spacing is semantic, not px. | +| **BackgroundMedia** | `image`, `overlay: none/subtle/strong`, `focalPoint`, `height: standard/tall/full` | `children` | Background image behind foreground content. | +| **SplitRow** | `ratio: 50-50 | 60-40 | 40-60 | 70-30 | 30-70`, `verticalAlign`, `reverseOnMobile` | `leftSlot`, `rightSlot` | Two-column layout. | +| **Stack** | `spacing: tight/normal/loose`, `alignment` | `children` | Vertical grouping, the workhorse. | +| **Grid** | `columns: 2 | 3 | 4`, `spacing` | `children` | N-column responsive grid. Collapses to 1 on mobile. | +| **Card** | `variant: default/outline/ghost`, `padding` | `header`, `content`, `footer` | Compound container, mirrors shadcn Card. | + +**Two more the production reference relies on, both easy to omit and expensive to retrofit:** + +| Layout | Props | Slots | Why it earns a place | +|---|---|---|---| +| **Box** | none | `children` | A bare slot wrapper, **49 uses** on the reference site, second only to Stack. It gives a grouping seam with no styling opinion, so a slot can be filled without inheriting spacing. Without it, composers reach for Stack and inherit gap they then have to cancel. | +| **Columns (named slots)** | `split`, `align`, `gap`, `stack_on_mobile` | `left`, `center`, `right` | Distinct from Grid: **named** slots let a composer target a specific column, and let a template author swap one side only. Grid's single `children` slot cannot express that. | + +Stack carries the spacing vocabulary. On the reference site: `direction`, `gap`, `align`, `align_mobile`, `justify`, `max_width`, `margin_x/y`, `padding_x/y`, `width`, `height`. Every one is static. It is used **66×**. Treat it as the workhorse and give it the full vocabulary up front, because adding a spacing prop later means editing every placement. + +**Name by layer, with one prefix.** The reference uses `alpha-` throughout: `alpha-atom-heading`, `alpha-stack`, `alpha-section-shell`, `alpha-card-shell`. A 60-primitive palette is unnavigable otherwise. The prefix groups them, and `-atom-` marks the layer at a glance. Pick any prefix. Apply it to every primitive. + +**Group them in the palette too: the name is not enough.** Each registration declares the group it appears under, and the reference uses exactly two: + +```ts +const ATOMS_GROUP = 'Alpha Atoms'; +const LAYOUT_GROUP = 'Alpha Layout'; +// … +{ type: 'alpha-atom-eyebrow', sections: [ATOMS_GROUP], thumbnailUrl: T.text, … } +``` + +Decide the group names here, at library-design time. `sections` + `thumbnailUrl` mechanics belong to [`register-component`](register-component.md) § Palette group + thumbnail. + +### Design props are `choice` with fixed options: this is what enforces the split + +Step 1b says design stays static. **`choice` is the mechanism that makes it stick.** Every design prop on the reference declares a closed option list and a default, so an author picks from a menu and cannot type a raw value: + +```ts +props: { + text: { type: 'string', defaultValue: 'New Feature' }, // content + color: { type: 'choice', options: ['brand','default','inverse','muted'], defaultValue: 'brand' }, + variant: { type: 'choice', options: ['text','badge'], defaultValue: 'badge' }, + badge_color: { type: 'choice', options: ['green'], defaultValue: 'green' }, +} +``` + +One content prop, three design props, closed options on each. A `string` design prop (`color: '#6c5ce7'`, `gap: '24px'`) re-opens exactly the drift the library exists to prevent. + +**Give every prop a `defaultValue`.** It is what lets a composition omit design props entirely and still render. The authored section then carries only its content bindings, and the primitive supplies the rest. + +Confirm the DS has: +- Padding tokens for `spacing: compact/comfortable/spacious` at both Section and Card levels. +- Background theme tokens for `background: none/surface/muted/brand/inverse`. +- Gap tokens for `Stack.spacing` and `Grid.spacing`. +- Card border/shadow tokens for `Card.variant`. +- Overlay opacity tokens for `BackgroundMedia.overlay`. +- Min-height tokens for `BackgroundMedia.height`. + +If any is missing, list it as a **DS gap**. The primitive can still register but the token must land before the option is enabled. + +### Step 4: Sanity-check against target section types + +Ask which section types the user needs from day one. Common set: + +- Hero: needs Section + BackgroundMedia + SplitRow + Stack + all 4 atoms +- Feature Grid: needs Section + Stack + Grid + Card + all 4 atoms +- Testimonial row: needs Section + Grid + Card + SplitRow + Stack + all 4 atoms +- Pricing tiles: needs Section + Stack + Grid + Card + all 4 atoms (+ a Condition Block for tier highlighting) +- CTA band: needs Section + Stack + all 4 atoms +- Logo strip: needs Section + Grid + Image + +Confirm every section on the user's list can be built from the 10-primitive set. If any needs an atom or layout not on the list (e.g. a `Rating` atom for testimonials, a `Tabs` layout for a comparison table), note it as a **future addition**. Do NOT add it to the initial registration. Grow the library additively. + +### Step 5: Emit the decomposition sheet + +Give the user a plain-text summary they can paste into a PR description: + +``` +Studio component library — initial 10 primitives + +ATOMS (content-only) + Heading · props: text, level, emphasis + Description · props: text, emphasis + Button · props: label, href, variant, size, icon (mirrors shadcn Button) + Image · props: src, alt, aspect, fit + +LAYOUTS (composition) + Section · props: spacing, background, contentAlign, children + BackgroundMedia · props: image, imageAlt, overlay, focalPoint, height, children + SplitRow · props: ratio, verticalAlign, reverseOnMobile, leftSlot, rightSlot + Stack · props: spacing, alignment, children + Grid · props: columns, spacing, children + Card · props: variant, padding, header, content, footer (mirrors shadcn Card compound) + +DESIGN-SYSTEM TOKEN CHECK + All semantic options map to existing DS tokens — no gaps + OR + Gaps: <list — token that must land before shipping the primitive> + +TARGET SECTION TYPES COVERED + Hero (5 variants) · Feature Grid · Testimonial · Pricing · CTA band · Logo strip + +NOT INCLUDED (deferred, add additively) + <list any section types that would need a new atom or layout> + +NEXT STEP + Run register-component for each of the 10 primitives, in one PR. +``` + +## Inputs needed from the user + +- **Design system name**: Tailwind + shadcn / Chakra / MUI / custom +- **Where tokens live**: CSS vars / Tailwind config / theme file +- **Target section types for day one**: Hero, Feature Grid, Testimonial, Pricing, other + +If any is missing, ask before decomposing. Do NOT invent DS tokens or section types. + +## Acceptance + +This skill succeeds only when ALL of the following are true: +- [ ] **Every atomic component carries its CSLP tags**: `studioAttributes` spread on the root element **and** `wrap: false` in its register entry and each bindable prop's `$`-twin spread on the element rendering it. Verified in the DOM, not assumed: the root and each bound text/image element must show `data-cslp`. Without this the atom renders correctly but is invisible to Visual Editor and Live Preview. See [`register-component` § The `studioAttributes` contract](register-component.md) and § The `$`-twin contract. + +- [ ] User confirms the design system + where tokens live. +- [ ] All 10 primitives listed with their prop schemas. +- [ ] Every semantic option is either mapped to a DS token or explicitly flagged as a gap. +- [ ] User's target section list is confirmed buildable from the 10 primitives OR future additions are flagged. +- [ ] Decomposition sheet emitted for the PR description. + +If the user asks for a primitive that doesn't fit the atom/layout taxonomy (e.g. "register a Hero"), stop and explain the rule of thumb. Do not silently register the monolith. + +## What "done" looks like: a production reference + +Measured on a live 58-section Studio site (contentstack.com 2025), for calibration: + +| Signal | Value | Reads as | +|---|---|---| +| Sections | 58 | one per marketing pattern, not per page | +| Nodes per section | **9.1 avg**, depth 3.8 | genuinely decomposed, a 1-node section means the component swallowed the page | +| Props static vs bound | **86% / 14%** | design lives on the canvas, content in the CT | +| Reuse of the top atom | `cta-button` 33×, `heading` 23×, `stack` 66× | the same primitives everywhere, varied by static values | +| Studio primitives | 32 condition blocks, 17 section slots, 15 repeaters | polymorphism and swap points are used, not avoided | + +If a proposed library would produce sections averaging 2 to 3 nodes, or would bind design props to CT fields, it is not decomposed enough. Go back to Step 1b. + +## Common pitfalls + +| Pitfall | Why it bites | Fix | +| --- | --- | --- | +| Modeling a design prop (`gap`, `align`, `variant`, `size`) as a CT field | Produces a schema authors can't reason about, and lets a section drift visually per entry. 86% of props on the production reference are static for this reason | If it changes how the thing looks rather than what it says, it is a static value set on the canvas | +| One `Description` atom for all body copy | The four rich-text shapes render through different paths. A single plain-text atom shows JSON RTE or Markdown as literal characters | Register `body-string` / `body-multiline` / `body-json-rte` / `body-html-rte` per `register-json-rte` | +| Skipping a bare `Box` because Stack "already wraps things" | Stack carries spacing, so every grouping inherits gap the composer must then cancel. The reference uses Box 49× | Register a props-free slot wrapper alongside Stack | +| Unprefixed primitive names | A 60-entry palette with no grouping is unnavigable in the component picker | One prefix for every primitive, with the layer in the name (`x-atom-heading`, `x-stack`) | +| Exposing `size` on Heading | Layout drift: every H1 picks its own size | `level` drives size via DS tokens. `size` never appears | +| `color: hex` on any atom | Color drift across the library | `emphasis: default/muted/inverse` maps to DS tokens | +| Padding in pixels on Section | Spacing scale fragments | `spacing: compact/comfortable/spacious` maps to Tailwind padding tokens | +| Registering `Hero` as an atom | Monolithic Section: new variant needs a code change | Register the 4 atoms + 4 layouts. Compose Heros on canvas | +| A `Rating` atom before you need it | Library bloat, unused primitives | Grow additively. Add only when a section actually needs it | +| Semantic option without a DS token | Silent visual failure at render | Add the token before enabling the option in the schema | + +## See also + +- [`docs/00-getting-started/composable-primitives.md`](../../docs/00-getting-started/composable-primitives.md): foundational pattern doc. +- `register-component`: the mechanical registration once the shape is decided. +- `decompose-jsx-to-atomics`: for breaking one existing over-large component into its parts. +- `decompose-design`: for taking a single design and mapping it to the primitive set + a Content Type shape. +- `import-design-tokens`: get the DS tokens into Studio's Design Panel before registration. diff --git a/skills/studio/design-section-from-jsx.md b/skills/studio/design-section-from-jsx.md new file mode 100644 index 0000000..fbb7c5b --- /dev/null +++ b/skills/studio/design-section-from-jsx.md @@ -0,0 +1,159 @@ +--- +name: design-section-from-jsx +--- + +## When to use + +From a component used across routes, propose the Section's linked-schema shape and create the Global Field / Group / Modular Block so `build-section` can run. + +Use BEFORE `build-section` when migrating a hand-coded route into Studio AND the section's linked schema does not exist yet. Phrases: "design schema for my Hero", "what fields does this section need", "migrate this component to a section". Pairs with `discover-sections`. Skip if the schema already exists. Do NOT use to redesign live schemas. + +> **Mandatory auth preflight: settle the credential before the first API call.** Resolve it OAuth-first per [`authenticate-cma`](authenticate-cma.md): `CS_OAUTH_ACCESS_TOKEN`, else the Contentstack MCP's stored session: Read that skill for where it is, even with no MCP connected. **Never ask the user for a session `authtoken`.** If nothing resolves, or a refresh fails with `400 invalid_refresh_token`, hand them `! CONTENTSTACK_REGION=<code> npx @contentstack/mcp --auth` (it needs a TTY and a browser, so it cannot be run for them) and wait. `403 error_code 316` is a valid credential aimed at another org: fix the org or the `api_key`, do **not** re-authenticate. + +# Design a section's linked schema from existing JSX + +## Context + +Studio sections **link to a structural schema**: Content Type / Global Field / Group / Modular Block / Block / Reference (never a scalar). For a migration, the natural section schema is whatever shape the existing JSX has been binding against. If `<Hero>` is currently rendered as `<Hero headline={entry.title} subhead={entry.tagline} cover={entry.cover.url} />` across four routes, the section's linked Global Field needs `headline / subhead / cover` fields with types that match. + +`build-section` assumes the schema already exists. For greenfield Studio installs that's fine: model the schema first in Contentstack, then build the section. For a **migration**, this skill reverses the order: read what the existing JSX is binding to and propose the schema. + +This skill **does not write to Contentstack**: it has no auth token, no Management Token, no CMA access (Studio's public skills never ask for those credentials). It emits the schema spec + step-by-step UI instructions. The user creates the Global Field manually in Contentstack's web app, then runs `build-section` to author the section against it. + +Reference: `docs/90-reference/linked-schema-matching-rules.md` (rules for linked-schema shapes), `docs/40-recipes/migrating-hand-coded-pages-to-studio.md` (section-first migration flow). + +## Task + +1. **Read `componentPath`.** Extract the component's prop names and types. Use the TypeScript interface (best), `propTypes` (second), or destructured args (fallback). + +2. **For each route in `usingRoutes`, read the file and find the JSX element matching the component.** For each prop assignment, capture the expression on the right, so `headline={entry.title}` becomes `{ prop: "headline", binding: "entry.title", routeFile: "..." }`. + +3. **Build a binding union across routes.** For each prop: + - If all routes bind to the same field (`headline ← entry.title` everywhere), that is a strong signal, and the schema gets a `title` field, exposed as `headline`. + - If routes bind to different field names (`headline ← entry.title` on /blog vs. `entry.headline` on /products), flag the prop as ambiguous. Ask the user which canonical name to use OR propose making the binding configurable via `exposed-props`. + - If a prop is sometimes set and sometimes not, mark it optional in the schema. + - If a prop is a literal (`layout="centered"` on every route), it is not a CMS field. Keep it as a registered component prop's `defaultValue`, NOT in the section's linked schema. + +4. **Map prop types to Contentstack field types.** Use the table: + + | Component prop type | Contentstack field type | Notes | + |---|---|---| + | `string` | `text` (single-line) | Multiline becomes `text` with `multiline:true` | + | `string` (long-form / RTE) | `json_rte` or `markdown` | Default to `json_rte` unless prop was rendered as `<div dangerouslySetInnerHTML>` | + | `number` | `number` | | + | `boolean` | `boolean` | | + | URL string passed as `href` | `link` | Contentstack `link` has `title` + `href` | + | URL string passed as `src` (image) | `file` | Contentstack `file` is an asset reference | + | ISO date | `isodate` | | + | `string` constrained to literals (union) | `text` with `choices` validation | Section's `choice` prop has its own `options:` | + | Array of objects | `modular_block` (mixed) or `reference` (entries) or `group` with `multiple:true` | Pick based on what each item is | + | Reference to another CT entry | `reference` | Single vs multi controlled by `reference_to` cardinality | + +5. **Emit the schema spec** in markdown for the user to review: + + ``` + ## Proposed Global Field: <proposedSchemaUid> + Display name: <sectionName> data + + | Field UID | Type | Required | Multiline | Default | Bound prop | + |---|---|---|---|---|---| + | headline | text | yes | no | — | Hero.headline | + | subhead | text | yes | yes | — | Hero.subhead | + | cover | file | yes | — | — | Hero.cover | + | cta_label | text | no | — | "Get started" | Hero.ctaLabel | + | cta_href | link | no | — | — | Hero.ctaHref | + + Ambiguous bindings — pick canonical name per row: + | Prop | /blog binding | /products binding | Canonical UID? | + |---|---|---|---| + | headline | entry.title | entry.headline | ? + + Singleton (literal) props — leave as registered-component defaults, NOT in this schema: + - Hero.layout = "centered" + - Hero.theme = "light" + ``` + +6. **Pause for user review.** Print exactly: `Review the schema above. To proceed, confirm: (y) the schema is correct — give me the UI steps to create it, (e) edit the proposed schema first.` Wait. **The skill never writes to Contentstack**: the user creates the Global Field in the web UI. + +7. **On confirm, print step-by-step UI instructions** for the user to create the Global Field manually: + + ``` + Open https://app.contentstack.com → your stack → Content Models → Global Fields → "+ New Global Field". + + 1. Display Name: <sectionName> data + 2. UID: <proposedSchemaUid> + 3. Description: "Used by Studio's <sectionName> section. Schema derived from existing JSX bindings on: <usingRoutes>." + + Add these fields in order: + + | Field Name | UID | Field Type | Multi-line | Required | Notes | + |-------------|-----------|-------------|------------|----------|------------------------| + | Headline | headline | Single Line | no | yes | — | + | Subhead | subhead | Multi Line | yes | yes | — | + | Cover Image | cover | File | — | yes | — | + | CTA Label | cta_label | Single Line | no | no | Default: "Get started" | + | CTA Link | cta_href | Link | — | no | — | + + Click Save. Note the UID — it's already <proposedSchemaUid> if you followed step 2. + ``` + + For users who prefer a programmatic path, also emit the equivalent JSON spec the [contentstack CLI](https://www.contentstack.com/docs/developers/cli) accepts (`csdx cm:stacks:import` or `csdx cm:global-fields:create`): + + ```json + { + "global_field": { + "title": "<sectionName> data", + "uid": "<proposedSchemaUid>", + "schema": [ + { "data_type": "text", "display_name": "Headline", "uid": "headline", "field_metadata": { "_default": true, "version": 3 }, "mandatory": true, "multiple": false }, + { "data_type": "text", "display_name": "Subhead", "uid": "subhead", "field_metadata": { "_default": true, "multiline": true, "version": 3 }, "mandatory": true, "multiple": false }, + { "data_type": "file", "display_name": "Cover Image", "uid": "cover", "field_metadata": { "description": "" }, "mandatory": true, "multiple": false }, + { "data_type": "text", "display_name": "CTA Label", "uid": "cta_label", "field_metadata": { "_default": true, "version": 3, "default_value": "Get started" }, "mandatory": false }, + { "data_type": "link", "display_name": "CTA Link", "uid": "cta_href", "field_metadata": { "description": "" }, "mandatory": false } + ] + } + } + ``` + + The user can save this JSON and run `csdx cm:global-fields:create --stack-api-key <key> --filename <path-to-json>` with their own CLI session (the CLI has its own auth flow Studio doesn't touch). + +8. **Print the hand-off:** `Once you've created the Global Field manually in the Contentstack web UI (or via csdx CLI), run: build-section with sectionName="<name>" linkedSchemaKind="global-field" linkedSchemaUid="<uid>". build-section will then create the Studio section and bind <component> against the schema you just created.` + +## Inputs needed from the user + +1. `componentPath`: required, exact file path. +2. `usingRoutes` (required. If empty, the skill can't extract bindings) refuse and ask the user to run `discover-sections` first to identify routes. +3. `sectionName`: required. +4. `proposedSchemaUid`: required (default lower-snake from sectionName). + +If the user hasn't run `discover-sections`, recommend that first. The skill works without it but the user-supplied `usingRoutes` becomes a guess. + +## Acceptance + +- [ ] Every route in `usingRoutes` was read and its JSX scanned for the component. +- [ ] Per-prop binding union table is produced: covers every prop on the component. +- [ ] Ambiguous-binding rows surface for the user to disambiguate. The skill does NOT pick a canonical name silently. +- [ ] Singleton (literal) props are listed separately and explicitly NOT in the proposed schema: they stay as component-registration defaults. +- [ ] Proposed schema field types map cleanly to Contentstack field types per the table above. No invented types. +- [ ] Step-by-step Contentstack web-UI instructions are printed for the user to create the Global Field. Optionally, the contentstack-cli JSON spec is also printed. +- [ ] The skill did NOT attempt to call CMA, did NOT ask for an auth token or Management Token, and did NOT write to the stack. The user creates the schema themselves in the UI (or via their own CLI session). +- [ ] The hand-off line names the next skill (`build-section`) and its three required inputs. + +## Common pitfalls + +| Pitfall | Why it bites | Fix | +| --- | --- | --- | +| Picking a canonical UID for an ambiguous binding without asking | Routes start failing because the new field name doesn't match what the entry has | Always ask for ambiguous-row decisions explicitly | +| Folding literal props into the linked schema | The schema gets a `layout: text` field nobody writes to. Defaults are lost | Keep literal-only props as `defaultValue` on the registered component, NOT in the schema | +| Choosing `text` for a string that's actually a rich-text body | Authors get a single-line input for a long-form field | If the JSX renders the prop via `dangerouslySetInnerHTML` or a markdown renderer, propose `json_rte` instead | +| Creating a Global Field when the data clearly belongs on one CT | Authors can't edit it from the entry's natural form | If `usingRoutes` is just one CT's pages, propose a `group` field on that CT, not a Global Field | +| Inferring `reference` cardinality from prop name | "relatedPosts" plural may still be one entry. "relatedPost" singular may be multiple | Look at how the prop is rendered (`.map(...)` means multiple, a direct render means single) | + +## See also + +- [`composable-primitives`](../../docs/00-getting-started/composable-primitives.md): the pattern this schema-design applies. This skill runs **Q3 + Q4 of the [four-question framework](../../docs/00-getting-started/composable-primitives.md#four-questions-to-answer-when-building-any-component)**: for each JSX prop, decide whether it earns a place in the linked schema (Q3: varies + functional intent) and whether it's an Exposed Prop candidate (Q4: author-understandable + DS-portable). Refuse polish props at schema-design time. +- `discover-sections`: run BEFORE this skill to identify which components become sections. This skill designs the schema for each +- `build-section`: run AFTER this skill creates the schema, binds the component against it +- `docs/90-reference/linked-schema-matching-rules.md`: the full rules for linked-schema kinds +- `docs/20-bring-your-own-components/component-schema-prop-types.md`: registered-component prop type reference (for distinguishing CMS-field props from defaults-only props) +- `docs/40-recipes/migrating-hand-coded-pages-to-studio.md`: the section-first migration recipe. This skill automates the "design the schema" sub-step diff --git a/skills/studio/discover-sections-from-ct.md b/skills/studio/discover-sections-from-ct.md new file mode 100644 index 0000000..ebf05f0 --- /dev/null +++ b/skills/studio/discover-sections-from-ct.md @@ -0,0 +1,190 @@ +--- +name: discover-sections-from-ct +--- + +## When to use + +Given an EXISTING Contentstack Content Type (already provisioned, entries populated), propose Studio Section boundaries by reading the CT's schema tree. Outputs a Section inventory: one Section per binding scope (root / Group / Reference / Modular Block) with the correct `linkedSchemaKind` per boundary. + +Use when adopting Studio on top of a stack that already has real content (Profile D, the "existing CT" starting state). Phrases: "we have blog_post entries already, how do I map that to Sections", "I have a stack + populated content, what Sections should I build", "propose Sections from my existing CT schema". Complements [`discover-sections`](discover-sections.md), which scans route files. This skill scans a CT schema instead. Run once per CT you're adding Studio on top of. Do NOT use for greenfield installs (no existing CT), or when the CT is being redesigned (design-first flow via [`decompose-design`](decompose-design.md) instead). + +> **Auth preflight: settle the credential before the first API call.** Resolve it OAuth-first per [`authenticate-cma`](authenticate-cma.md): `CS_OAUTH_ACCESS_TOKEN`, else the Contentstack MCP's stored session: Read that skill for where it is, even with no MCP connected. **Never ask the user for a session `authtoken`.** If nothing resolves, or a refresh fails with `400 invalid_refresh_token`, hand them `! CONTENTSTACK_REGION=<code> npx @contentstack/mcp --auth` (it needs a TTY and a browser, so it cannot be run for them) and wait. `403 error_code 316` is a valid credential aimed at another org: fix the org or the `api_key`, do **not** re-authenticate. + +# Discover Sections from an existing Content Type + +## Context + +Studio's model is one Section per **scope**. A scope is a place in the CT schema tree where a Section binds: the CT root, a Group field, a Reference field, or a Modular Block. Each scope has its own `linkedSchemaKind` and its own set of visible fields. You can't cross scope boundaries in one Section. + +If a customer already has a well-populated CT (e.g. `blog_post` with 400 published entries), they don't need `decompose-design`. The schema is settled. They need help mapping the existing schema shape onto Section boundaries. This skill produces that mapping. + +The output is a **Section proposal**: a prioritized list of Section candidates, one per scope, with the fields visible in each scope + a proposed component structure per Section. You hand the top entry to [`build-section`](build-section.md), ship it, then hand the next entry to `build-section`. The proposal stays valid until the CT schema changes. + +This skill is **discovery, not authoring**. It doesn't write any composition. It produces a markdown report. + +## Task + +1. **Fetch the target CT schema.** Given `ctUid` + a stack management token, GET `/v3/content_types/<ctUid>`. Extract `schema` (the field tree) and `options` (`is_page`, `url_pattern` if set). + +2. **Walk the schema tree and enumerate scopes.** Every scope is a Section candidate. Categories: + - **Root scope**: the top-level CT. Fields at the root that aren't nested in Group / Reference / Modular Block. Every CT has this. + - **Group scopes**: each `data_type: "group"` field is a Section-authorable scope. Nested groups (group-of-group) are their own scopes. + - **Reference scopes**: each `data_type: "reference"` field is a Section scope. Both single-reference (`ref_multiple: false`) and multi-reference (`ref_multiple: true`). + - **Modular Block scopes**: each block type within a `data_type: "blocks"` field is its own Section scope. If the field has 3 block types, that's 3 scopes. + +3. **For each scope, list visible fields.** Visible = fields declared at that scope + fields inherited via `_content_type_uid` on references. Do NOT include fields from parent or sibling scopes. Sections can't bind across scope boundaries. + +4. **Propose a component structure per Section.** Best-effort inference from field names + types: + - `title` / `headline` / `heading` maps to a Heading component with `text` bound to the field. + - `image` / `cover_image` / `hero_image` maps to an Image component. + - **Rich text: decide by `data_type`/`field_metadata`, never by field name.** These carry formatting, so `Text`/`Heading` would render markup as literal characters: + + | Schema shape | Component | Prop type | + |---|---|---| + | `data_type: "json_rte"` | **`RichText`** | `json_rte` | + | `data_type: "text"` + `field_metadata.allow_rich_text` (HTML RTE) | **`RichText`** | `any` | + | `data_type: "text"` + `field_metadata.markdown` | **`RichText`** | `any` | + | `data_type: "text"` + `multiline`, no rich-text flags | `Text` | `plaintext` | + + Choose **`CollapsibleText`** instead wherever the design truncates the copy ("read more", accordion). If any such field permits **embedded entries or assets**, record it. The project needs one `registerRTERenderer` call, and unregistered embeds render as bare delivery-API spans. See [`register-json-rte`](register-json-rte.md#rte-field-to-component). + - `body` / `content` / `description` that is plain long text maps to a Text component, **and record it as an upgrade candidate** (below). + + **Rich-text upgrade candidates: always surface these, never apply them silently.** An existing model often holds prose in a plain text field simply because that was the quickest thing to pick when it was created. Report them and let the user decide: + + | What you found | Suggest | Cost to accept | + |---|---|---| + | Field is **already** `json_rte` / rich text, but the current code renders it through a plain text component | Switch the component to `RichText` | **None**: no schema change, no migration. Free correctness win | + | Plain **multiline `text`** field holding body copy, description or prose | Consider `json_rte` so authors can bold, link, list and embed | A schema change on a live CT. See [`migrate-ct-schema`](migrate-ct-schema.md). Existing entry values carry over as plain paragraphs | + | Long prose the site truncates behind "read more" / an accordion | `CollapsibleText` over the same field | None if the field is already rich text | + + Phrase it as a recommendation with the cost attached ("`summary` is plain text today. Making it `json_rte` would let authors add links, but it's a schema change on N live entries. Want it?") and proceed with the existing type if they decline. Never migrate a field as a side effect of a discovery run. + - `cta_label` + `cta_url` (or `link`) maps to a Button component. + - `is_featured` / `is_premium` (boolean) maps to a Condition Block around premium-only children. + - Multi-value fields inside a Repeater (already the scope's iteration boundary if Modular Block or multi-reference). + - Flag unknown fields in "Unmapped fields" so the developer decides. + +5. **Score + prioritize.** Sort scopes by rough authoring value: + - Root scope + top-level fields: HIGHEST (every entry uses this Section). + - Groups referenced from multiple contexts: HIGH. + - Modular Blocks (composability wins): MEDIUM-HIGH. + - References not yet used elsewhere: MEDIUM. + - Rarely-populated fields (empty on >50% of entries. Check via a CDA sample of 10 entries): LOW. + +6. **Emit the Section proposal** as a markdown document at `docs/_discovery/<ct_uid>-sections.md`: + + ```markdown + # Section proposal — `<ct_uid>` CT + + ## Summary + - CT UID: <ct_uid> + - Total scopes: <N> + - Recommended Sections to build: <top 3-5> + + ## Scope-by-scope Section candidates + + ### 1. `<ct_uid>` — root scope (linkedSchemaKind: entry) + + **Fields in scope:** `title`, `slug`, `hero_image`, `excerpt`, `published_date`, `is_featured` + + **Proposed Section: `<ct_uid>_hero`** + - Layer 2 composition containing Heading + Image + Text + Condition Block + - Bindings: + - Heading.text ← `title` + - Image.src ← `hero_image` + - Text.value ← `excerpt` + - Condition Block around a "Featured" badge, condition `is_featured == true` + - Exposed props: `heading_level` (choice: h1/h2/h3), default h1 + - Priority: HIGH — used on every entry + + ### 2. `<ct_uid>.body_blocks` — Modular Block scope + + **Block types in this field:** `text_block`, `image_block`, `quote_block` + + Three Section candidates, one per block type: + + #### 2a. `<ct_uid>_body_text_block` (linkedSchemaKind: block, block_type: text_block) + [...] + + #### 2b. `<ct_uid>_body_image_block` (linkedSchemaKind: block, block_type: image_block) + [...] + + ### 3. `<ct_uid>.author` — Reference scope (linkedSchemaKind: reference) + + **Referenced CT:** `author` + **Fields visible in this scope:** `author.name`, `author.avatar`, `author.bio` + + **Proposed Section: `<ct_uid>_author_card`** + [...] + + ## Unmapped fields + + These fields exist in the CT but the skill couldn't confidently propose a Section shape. Decide per-field: + + - `<field_uid>` (data_type: <type>) — [suggestion, or "review manually"] + + ## Build order + + 1. Build `<ct_uid>_hero` first (touches every entry). + 2. Then Modular Block Sections (2a, 2b, 2c) — enables authoring flexibility on body content. + 3. Then reference Sections (author_card, related_posts). + 4. Finally the Template composing all Sections in order. + + ## What this proposal is NOT + + - Not a schema migration — no CT fields change. + - Not a design proposal — component names / layout are best-effort inference; feel free to override. + - Not an author-experience proposal — governance choices (which props to expose per Template) are yours. + ``` + +7. **Sanity-check the proposal.** + - Every listed scope has at least one populated entry (spot-check via CDA `/v3/content_types/<ctUid>/entries?limit=1&only[BASE][]=<field>`). + - No proposed Section spans two scopes. + - Every scope has a valid `linkedSchemaKind` value. + - Unmapped fields flagged: do NOT silently drop them. + +## Inputs needed from the user + +1. **Content Type UID**: the CT to discover Sections against. +2. **Stack API key + management token**: for the CMA read. +3. **Optional: sample size for populated-check**: default 10 entries. Increase for larger stacks. +4. **Optional: output path**: default `docs/_discovery/<ct_uid>-sections.md`. + +## Acceptance + +- [ ] Every scope in the CT schema is enumerated. +- [ ] Each Section candidate has a valid `linkedSchemaKind` matching its scope. +- [ ] Fields are correctly attributed to scopes, no cross-scope leakage. +- [ ] Component-structure inference is documented as a proposal (not a decision). +- [ ] Unmapped fields are flagged with the field UID + data type. +- [ ] Build order is topologically sound: foundational Sections before dependents. +- [ ] Output file emitted at the requested path. + +## Common pitfalls + +| Pitfall | Why it bites | Fix | +|---|---|---| +| Proposing a Section that binds fields from two scopes | Studio rejects cross-scope bindings at author time | Every scope gets its own Section. If you want to combine, use a Template that composes both Sections. | +| Assuming Modular Block = one Section | Each block type is its own scope. A 3-block-type field is 3 Sections | Enumerate block types explicitly. | +| Skipping empty scopes | A rarely-populated field might still need a Section for the entries that DO have it | Include, mark LOW priority, let developer decide. | +| Over-inferring component structure | Component name doesn't have to match field name. Developer's design system may not have "Heading" | Propose, flag as suggestion, developer overrides. | +| Missing `_content_type_uid` resolution on references | Reference scopes need the referenced CT's schema fetched too, otherwise fields aren't visible | For each reference field, fetch the referenced CT's schema too. | +| Forgetting to check populated-ness | Skill proposes 15 Sections. 8 of them cover fields that no entry actually populates | Sample 10 entries. Downgrade scopes where >50% of samples don't populate. | + +## Handoff to downstream skills + +After the proposal is emitted, the customer walks the build order: + +1. **Read the proposal**: the developer + design lead review + edit. +2. **Register any missing components** via [`register-component`](register-component.md). +3. **Build the top-priority Section** via [`build-section`](build-section.md). Hand it the scope + field list from the proposal. +4. **Verify it renders** against a real entry via [`verify-setup`](verify-setup.md) Layer 6-7. +5. **Repeat** for each Section in the build order. +6. **Assemble the Template** via [`build-connected-template`](build-connected-template.md). + +## See also + +- [`discover-sections`](discover-sections.md) (Section discovery from EXISTING ROUTE FILES (Profile C) hand-coded migration). Same output shape, different input. +- [`decompose-design`](decompose-design.md) (Section discovery from a DESIGN (Profile B) greenfield with a design). +- [`build-section`](build-section.md): authors each Section this proposal identifies. +- [`from-designs-to-sections`](../../docs/00-overview/from-designs-to-sections.md): the three-layer mental model + scope rules (one Section per scope). +- [`migrate-ct-schema`](migrate-ct-schema.md): evolves the CT AFTER this proposal, if scope boundaries need adjusting. diff --git a/skills/studio/discover-sections.md b/skills/studio/discover-sections.md new file mode 100644 index 0000000..2f24eeb --- /dev/null +++ b/skills/studio/discover-sections.md @@ -0,0 +1,137 @@ +--- +name: discover-sections +--- + +## When to use + +Scan all route files and propose Studio Section candidates by finding components that recur across routes. Outputs a section inventory prioritized by reuse count. + +Use at the START of a migration from hand-coded / Visual Editor pages to Studio, BEFORE building any sections. Phrases: "which components should be sections", "migration inventory". Produces the section list `register-component` / `build-section` / `build-connected-template` assume you have. Run once per codebase. Do NOT use for greenfield installs. + +> **Auth preflight: settle the credential before the first API call.** Resolve it OAuth-first per [`authenticate-cma`](authenticate-cma.md): `CS_OAUTH_ACCESS_TOKEN`, else the Contentstack MCP's stored session: Read that skill for where it is, even with no MCP connected. **Never ask the user for a session `authtoken`.** If nothing resolves, or a refresh fails with `400 invalid_refresh_token`, hand them `! CONTENTSTACK_REGION=<code> npx @contentstack/mcp --auth` (it needs a TTY and a browser, so it cannot be run for them), and wait. `403 error_code 316` is a valid credential aimed at another org: fix the org or the `api_key`, do **not** re-authenticate. + +# Discover sections: propose a build-order from your existing routes + +## Context + +Section-first migration depends on knowing which compositions repeat across your routes. This skill produces that inventory automatically. + +**Reuse count orders the build. It does not decide what becomes a Section.** A Hero on three routes is Section #1 because it pays off three times. A Hero on one route is still a Section, just later in the queue. Every recognizable page part gets one. See [`plan-studio-architecture`](plan-studio-architecture.md) § Golden rule for why (authoring surface, independent preview, scoped data, retrofit cost). A route whose components all appear exactly once is not a route that skips Sections. It's a route whose Sections are all priority-1-instance. Do not report "no section candidates" for it. + +The output is a **section build plan**: a prioritized list of sections to build, with the routes each section will cover, and the components each section needs. You hand the top entry to `build-section`, ship it, then hand the next entry to `build-section`. The plan stays valid until you add a new route shape. + +This skill is **discovery, not authoring**. It doesn't write any code or touch Studio's UI. It produces a markdown report. + +Reference: `docs/40-recipes/migrating-hand-coded-pages-to-studio.md` Step 1 (Inventory components, sections, and templates). This skill automates the inventory tables in that step. + +## Task + +1. **Find all route files** under `appRoot` matching `routeGlob`. List each file path. + +2. **Extract component usage from each route.** For each file, parse the JSX (acceptable to use a simple regex pass for capital-letter JSX element names (`<Hero>`, `<ProductCard>`, etc.) since this is a heuristic inventory, not a binding-grade parser). Build a `componentToFiles` map: `{ "Hero": [routeA, routeB, routeC], "ProductDetails": [routeB], ... }`. + +3. **Score each component.** Sort by route-file count (descending). The score = number of routes that import or use it. + +4. **Classify.** For each component: + - `score >= minOccurrencesForSection` AND component qualifies as a Section (see below) means a **section candidate** + - `score == 1` **and the component is genuinely atomic** (renders one piece of content) means register-only, no Section needed + - `score == 1` **and the component is coarse**: a page-level component, or anything rendering several distinct regions is **NOT register-only.** Send it to [`decompose-jsx-to-atomics`](decompose-jsx-to-atomics.md) first, then re-score its parts + - Fails qualification for another reason: see the drop list below + + **`score == 1` is not an exemption from decomposition.** This step counts how many routes use a component name. It cannot see inside one. A whole page written as a single `<RewardsPage>` scores 1, and that makes it the **strongest** decomposition candidate, not a register-only singleton. Treating it as one produced the classic outcome: one Section covering an entire page, nothing reusable, authors unable to reorder anything, and one coarse prop swallowing the whole entry. Judge coarseness by what the component renders, not by how many routes reference it. + + Quick test before calling anything register-only: does it render **more than one** heading / image / body / list? If yes, it's coarse. Decompose it. + + **Qualification filter: count the bound props per component.** A Section pays off when the binding work amortizes. The practical test is "how many bindings would an author wire by hand on the next drop?": ≥2 qualifies, 1 does not. + + **Drop from section candidates:** + + - **Components with 1 bound prop to a scalar field**: `<Heading>{entry.title}</Heading>`, `<Image src={entry.cover.url} />`, `<Text>{entry.tagline}</Text>`. One bind = no amortization. Use inline as Basic field components or registered atoms. Studio's picker rejects scalar fields anyway. + - **Layout-only / no-CMS-binding components**: `<Container>`, `<Stack>`, `<Spacer>` with no entry references in their props. + - **Framework primitives**: `<Link>` from `next/link`, `<Image>` from `next/image` when used as plain primitives without composition. + + **Keep as section candidates:** + + - **Components with ≥2 bound props**: `<Hero>` with `headline + subhead + cover`, `<ProductCard>` with `image + title + price + cta`. Author would otherwise wire each prop on every drop. + - **Components with 1 prop bound to a structural shape**: Group / Global Field / Modular Block / Block / Reference. Outer bind is 1 click, but the inside walks N sub-fields per iteration. That work amortizes. Multi-field destructuring is the tell: `<Hero {...entry.hero} />` where `entry.hero` is a Group, `<CardList items={entry.related_posts} />` where `related_posts` is a multi-Reference. + + See [`understand-sections`](understand-sections.md) § What qualifies as a Section for the canonical rule. + +5. **Detect co-occurrence groups.** For section candidates, find pairs/triples that **always appear together** across the same files (e.g. `Hero` + `Body` always co-occur on /blog AND /products, so they're likely one section, not two). Group co-occurring candidates into a single proposed section. + +6. **Infer linked-schema kind for each candidate.** For each proposed section: + - Look at the props the component receives on each route. If all routes pass `entry.<fieldname>` for prop X, the section's linked schema needs a field named `<fieldname>`. + - If the section consistently binds against fields of one content type, propose `linkedSchemaKind: content-type`. + - If the props come from a nested group on the entry, propose `global-field` (and recommend creating that Global Field in Contentstack first). + - If unsure, mark as TBD and ask the user during `build-section`. + +7. **Order the build plan.** Highest reuse first. Flag dependencies: e.g. Card Grid section depends on Card component being registered + bound, so Card registration comes before Card Grid section. + +8. **Emit the report.** Markdown, in this exact shape, easy to copy into a migration spec: + + ``` + ## Section build plan — N sections proposed + + ### Section 1: Hero Strip (reuse: 4 routes) + - Routes: /blog/[slug], /products/[sku], /case-studies/[slug], / + - Components to drop: Hero + - Proposed linkedSchemaKind: global-field + - Proposed Global Field UID: hero_strip + - Run next: `register-component` for Hero, then `build-section` Hero Strip + + ### Section 2: Card Grid (reuse: 3 routes) + - Routes: /blog/[slug] (related), /products/[sku] (related), / (highlights) + - Components to drop: Card, inside a Repeater + - Proposed linkedSchemaKind: reference (multi-entry) + - Run next: `register-component` for Card, then `build-section` Card Grid + `use-repeater` + + ### Section 3: Testimonial Strip (reuse: 2 routes) + - ... + + ## Singleton components — register-only, no section needed + - ProductDetails (1 route — /products/[sku]) + - CartSummary (1 route — /checkout) + - PaymentForm (1 route — /checkout) + + ## Total: 3 sections, 5 singletons, 8 components to register + ``` + +9. **Pause for user review.** Don't auto-invoke any other skill. The user reads the plan, may merge or split candidates, then explicitly invokes `register-component` + `build-section` skills for each entry. + +## Inputs needed from the user + +1. `appRoot`: required. If `app/` exists, default to that. Otherwise ask. +2. `routeGlob`: required (with a sensible default per framework). +3. `minOccurrencesForSection`: default 2. Larger codebases may set 3 or 4. + +If the route files use a different convention (e.g. React Router with `Route.tsx` files in nested dirs), broaden the glob with the user's input. + +## Acceptance + +- [ ] Every route file under `appRoot/routeGlob` was scanned. +- [ ] Component usage table lists every JSX component (capital-letter element name) appearing in at least one file. +- [ ] Section candidates are correctly classified (count >= minOccurrencesForSection). +- [ ] Singletons are listed separately with a "register-component only, no section" note. +- [ ] Co-occurring components (always appear together across files) are proposed as a single section, not two. +- [ ] For each candidate, the proposed `linkedSchemaKind` is one of: `content-type` / `global-field` / `group` / `modular-block` / `reference` / `tbd`. If `tbd`, the report says "ask the user during build-section." +- [ ] Build order is reuse-descending. Dependencies are flagged. +- [ ] The report is plain markdown the user can copy into a spec. + +## Common pitfalls + +| Pitfall | Why it bites | Fix | +| --- | --- | --- | +| Treating every multi-route component as its own section | Co-occurring components (Hero + Body always on the same routes) should be ONE section, not two | Detect co-occurrence. Group into a single proposed section | +| Inferring `content-type` linked schema when the component binds to nested fields | The author has to refactor mid-build-section | If props come from a group or nested object, propose `global-field` or `group` | +| Proposing sections for components used only on /admin or /internal routes | Internal-tooling routes usually don't need to be authorable | Optional: exclude routes under user-supplied path patterns | +| Counting `<div>`, `<span>`, native HTML elements as components | Inflates the report. Not section material | Filter to capital-letter JSX element names only | +| Re-running on a partially-migrated codebase and counting `<StudioComponent />` as a section candidate | StudioComponent is the renderer, not a section | Skip `StudioComponent`, `StudioCanvas`, `<Suspense>`, and other framework primitives | + +## See also + +- [`composable-primitives`](../../docs/00-getting-started/composable-primitives.md): the pattern the discovery targets. This skill runs **Q1 of the [four-question framework](../../docs/00-getting-started/composable-primitives.md#four-questions-to-answer-when-building-any-component)** implicitly: components recurring across ≥ 2 routes ARE section candidates (Q1 answer = "compose from existing primitives across routes"). Components appearing on 1 route only fail Q1 and route to `design-component-library` for potential atom/layout registration instead. +- `docs/40-recipes/migrating-hand-coded-pages-to-studio.md`: full section-first migration recipe (this skill produces its Step 1 inventory) +- `register-component`: invoke for each component the report lists +- `build-section`: invoke for each section the report proposes +- `design-section-from-jsx`: sibling skill. Given a proposed section, designs the linked-schema shape from the existing JSX +- `migrate-page-to-studio`: final route-swap step, after all sections + templates exist diff --git a/skills/studio/drive-studio-canvas-ui.md b/skills/studio/drive-studio-canvas-ui.md new file mode 100644 index 0000000..06a0382 --- /dev/null +++ b/skills/studio/drive-studio-canvas-ui.md @@ -0,0 +1,83 @@ +--- +name: drive-studio-canvas-ui +--- + +## When to use + +Driving the Studio canvas through browser automation: the drag-and-drop sequence that actually commits, and why the high-level helpers silently do nothing. Read before any skill that moves a component on the canvas via Playwright MCP. + +Referenced by `build-section`, `build-connected-template`, `build-freeform-template` and `studio-tour`, which all drive the same canvas. + +## LLM execution caveat: drag-drop works, but only with the right sequence + +Studio's canvas is a React-DnD iframe. Palette tiles listen on `mousedown` / `mousemove` / `mouseup` (NOT HTML5 native drag), and the drop COMMITS only when mousemove fires intermediate events between mousedown and mouseup. The high-level `dragTo()` helper fires HTML5 `dragstart`/`drop` which Studio does not honor. You must use `page.mouse.down()` / `page.mouse.move({steps})` / `page.mouse.up()` directly. + +**Stable selectors (verified by execution):** + +- Palette tile: `[data-builder-component="true"][data-node-type="<type>"]` where `<type>` is e.g. `doc-hero`, `doc-card`, `repeater`, `header`, `box`. (Section tiles use the section's composition UID as the type.) +- Canvas iframe: `[data-testid="canvas-iframe"]` +- Drop slot inside the iframe: `[data-composable-studio-slot="true"]` (the `="true"` filter is required. Without it you can match elements that have the attribute but aren't active drop targets) +- Layers row title (to select a node for deletion or inspection): `[data-testid="layer-editable-title-container"]` +- Node IDs (to verify a drop committed): `[data-composable-studio-id]` inside the FrameLocator + +**The drop sequence (proven working pattern):** + +```ts +const item = page.locator('[data-builder-component="true"][data-node-type="doc-hero"]'); +const frame = page.frameLocator('[data-testid="canvas-iframe"]'); +const slot = frame.locator('[data-composable-studio-slot="true"]').first(); + +await page.bringToFront(); // 0. Chrome holds mouse input to a background tab: calls hang +await item.hover(); // 1. position cursor over palette tile +await page.mouse.down(); // 2. mousedown → posts PARENT_DRAG_START to iframe +const sb = await slot.boundingBox(); +await page.mouse.move(sb.x + sb.width / 2, // 3. move cursor in STEPS — required for mousemove events to fire + sb.y + sb.height / 2, + { steps: 10 }); +await slot.hover(); // 4. final settle on the slot (FrameLocator handles cross-frame) +await page.mouse.up(); // 5. mouseup → commits the drop +``` + +The `page.mouse.move({steps: 10})` between mousedown and mouseup is the critical detail. Without intermediate mousemove events, the iframe's drag-tracking code never registers the path and the drop is silently swallowed. + +**Anti-phantom guardrail.** Always verify a NEW `data-composable-studio-id` appeared inside the FrameLocator after each drop: + +```ts +const idsBefore = await frame.locator('[data-composable-studio-id]') + .evaluateAll(els => els.map(e => e.getAttribute('data-composable-studio-id'))); +// ... drop sequence ... +let newIds = []; +for (let i = 0; i < 15 && newIds.length === 0; i++) { // poll up to ~3 s: a commit can land after 800 ms + await page.waitForTimeout(200); + const idsAfter = await frame.locator('[data-composable-studio-id]') + .evaluateAll(els => els.map(e => e.getAttribute('data-composable-studio-id'))); + newIds = idsAfter.filter(id => !idsBefore.includes(id)); +} +if (newIds.length === 0) { + throw new Error('Drop did not commit; do not continue.'); +} +``` + +If `newIds.length === 0`: stop and surface the failure. Do not fabricate completion. + +**Sibling drops after the root slot is consumed.** `[data-composable-studio-slot="true"]` exists only while a slot is empty, and the page root has no box (`display: contents`), so releasing in empty space below a node drops nothing. Release **on an existing node**: bottom half inserts below it, top half above, its left/right 10px beside it. The canvas is scaled to fit (1440px frame drawn 1066px wide): page point = iframe rect origin + frame point × (rect width / frame `innerWidth`). Check the frame's `elementFromPoint` there is that node, not an inner child. After `mouse.down()` wait ~150 ms, move with `{steps: 15}` plus two 1–2px nudges, confirm the parent shows `[data-direction="horizontal"]`, then `mouse.up()`. + +**Binding through the data picker.** Click the row's title text (`[data-outlinename="<path>"] > span[tabindex="0"]`), not its chevron or toggle. The bind writes to the selected node, so reselect the node if the canvas reloaded. It stuck when the prop shows a binding chip instead of its input. + +**Execution-path matrix:** + +| Path | Drag-drop status | +|---|---| +| Human in their own Studio browser | **Yes**. Native: this is how authors use Studio every day | +| Playwright with direct `page.mouse.down/move/up` access | **Yes**. Use the proven sequence above | +| Playwright `dragTo()` only | **No**. Fires HTML5 drag events Studio does not honor | +| Synthetic `DragEvent` dispatched from page-context JS | **No**. Same reason | + +**What ALSO works programmatically (verified):** + +- Click a Layers row and press `Delete`, which removes the node and persists +- Click the Save button, which persists the composition. The button grays out post-save +- Switch right-panel tabs (Settings / Design / Data) via direct DOM clicks +- Open Configuration / URL Pattern / Schema Picker modals via their action buttons +- Read iframe canvas state via `frameLocator` (read-only operations) +- Switch palette accordion sections (Basic / Media / Container / Smart Containers / Registered Components / HTML Elements) via direct DOM clicks diff --git a/skills/studio/embed-composition.md b/skills/studio/embed-composition.md new file mode 100644 index 0000000..346ab3a --- /dev/null +++ b/skills/studio/embed-composition.md @@ -0,0 +1,228 @@ +--- +name: embed-composition +--- + +## When to use + +Embed a Studio-managed editable region inside a code-owned page using `useCompositionData({ compositionUid })`. Section-in-Slot, not raw component. + +Use when exposing ONE editable region inside a code-owned page (marketing band on a PDP, promo strip, recommendations shelf) without making the whole page Studio-managed. Phrases: "embed a Studio band", "marketer-editable PDP region", "promo strip", "one editable section". Skip if the user wants the whole page in Studio (`migrate-page-to-studio`) or every URL through Studio (`setup-template-preview-routes`). + +> **Auth preflight: settle the credential before the first API call.** Resolve it OAuth-first per [`authenticate-cma`](authenticate-cma.md): `CS_OAUTH_ACCESS_TOKEN`, else the Contentstack MCP's stored session: Read that skill for where it is, even with no MCP connected. **Never ask the user for a session `authtoken`.** If nothing resolves, or a refresh fails with `400 invalid_refresh_token`, hand them `! CONTENTSTACK_REGION=<code> npx @contentstack/mcp --auth` (it needs a TTY and a browser, so it cannot be run for them) and wait. `403 error_code 316` is a valid credential aimed at another org: fix the org or the `api_key`, do **not** re-authenticate. + +# Embed a Studio-managed region inside a code-owned page + +## Context + +This skill operationalizes the [Embed composition recipe](../../docs/40-recipes/embedding-a-composition-in-a-code-owned-page.md). The user has a code-owned page (PDP, checkout, home, account). One region should be marketer-editable without code changes. + +Mechanism: `useCompositionData({ compositionUid })` resolves a known composition by its slug. The result hands to `<StudioComponent specOptions={specOptions} />` rendered inline. A Section composition renders correctly through this path. + +## Pre-flight checks + +Stop and report if any are missing: + +1. **`install-studio` already ran.** Check `package.json` for `@contentstack/studio-react`, and verify a stack init exists (typically `lib/contentstack.ts` or `lib/studio.ts` with `export const sdk = studioSdk.init({...})`). +2. **Components are registered** at the app shell, at least the components that the embedded composition uses. +3. **Studio project is configured**: in Project Settings, Configuration is filled in (Environment, Language, Canvas URL pointing at the section-authoring route). +4. **Target page file exists** and contains the `insertionAnchor`. If the anchor isn't found, report the file's first 20 lines and ask the user to specify a better anchor. + +## Task + +### Step 1: Show the user the Studio UI steps for creating the composition + +Print this VERBATIM (substitute `{bandName}` and `{compositionFlavor}`): + +``` +First, you'll create the composition manually in Studio. Open +https://app.contentstack.com → your stack → Studio → your project → +Sections → "+ New Section". + +Configure it as: + Display Name: {bandName} (use Title Case — Studio will auto-fill) + Section UID: {bandName} ← the composable_uid the embed will reference + Description: "Embedded band inside <target page>. Edited by marketing; + rendered inline by code via useCompositionData." + +There's no URL to configure. Studio previews a section on the +section-authoring canvas. + +SAVE the section. Then come back here and I'll wire the React side. +``` + +Then PAUSE and wait for the user to confirm: "Composition created." Do NOT proceed until they confirm: the embed code references the `composable_uid` that doesn't exist yet. + +### Step 2: Scaffold the `<StudioEmbed>` wrapper (if not present) + +> **Clarification: `<StudioEmbed>` is NOT an SDK export.** This skill scaffolds a thin wrapper component **inside the user's codebase** that bundles the SDK's `useCompositionData` hook + `<StudioComponent />` renderer. The SDK ships only those two primitives. The wrapper exists purely to avoid repeating loading/error boilerplate on every embed. The wrapper can be named anything (`<StudioEmbed>`, `<ManagedBand>`, `<EditableRegion>`, etc.). This skill uses `StudioEmbed` as a sensible default. If the user prefers a different name, accept it. + +Check whether the project already has such a wrapper component at `src/components/StudioEmbed.tsx` (Vite/CRA) or `app/_components/StudioEmbed.tsx` (Next.js App Router) or any path containing both `useCompositionData` AND `StudioComponent` AND `compositionUid` (`grep -rEn "useCompositionData.*compositionUid" src/ app/ 2>/dev/null | head -3`). If found, reuse it. If not, emit one: + +**Vite / React Router / CRA (`src/components/StudioEmbed.tsx`):** + +```tsx +"use client"; // harmless in non-Next; Next App Router needs it +import { + useCompositionData, + StudioComponent, +} from "@contentstack/studio-react"; + +interface StudioEmbedProps { + /** composable_uid slug of the Studio composition to embed */ + compositionUid: string; + /** rendered while the composition is loading; default: null (no layout shift) */ + fallback?: React.ReactNode; +} + +/** + * Render a Studio-managed composition inline inside a code-owned page. + * Verified pattern (see docs/40-recipes/embedding-a-composition-in-a-code-owned-page.md). + * + * <StudioEmbed compositionUid="pdp_marketing_band" /> + */ +export function StudioEmbed({ compositionUid, fallback = null }: StudioEmbedProps) { + const { specOptions, isLoading, error } = useCompositionData({ compositionUid }); + + if (isLoading) return <>{fallback}</>; + if (error || !specOptions?.spec) { + if (process.env.NODE_ENV === "development") { + console.warn( + `[StudioEmbed] No composition found for compositionUid="${compositionUid}". ` + + `Either it's not created yet, the slug is misspelled, or it was deleted in Studio.`, + ); + } + return null; + } + return <StudioComponent specOptions={specOptions} />; +} +``` + +**Next.js App Router CSR variant**: same file content, marked `"use client"` (already shown above). + +**Next.js App Router SSR variant (when the embed must render server-side)**: emit BOTH a server fetcher and a client wrapper. Use this when the surrounding page is a Server Component: + +```tsx +// app/_components/StudioEmbed.tsx — Server Component (no "use client") +import { sdk } from "@/lib/contentstack"; // or @/lib/studio for additive integrations +import { resolveComposition } from "@/lib/resolve-composition"; // configure-csr-vs-ssr +import { StudioEmbedClient } from "./StudioEmbedClient"; + +export async function StudioEmbed({ compositionUid }: { compositionUid: string }) { + // fetchCompositionData THROWS when the composition is missing — it does not + // resolve empty. Unwrapped, a deleted or misspelled embed takes down the whole + // host page with a 500. An embed must degrade to nothing, never to an outage. + // NOTE: a compositionUid lookup throws COMPOSITION_UID_NOT_FOUND — NOT the + // ..._BY_URL id. resolveComposition covers all three not-found ids. + const { specOptions, notFound } = await resolveComposition(sdk, { + compositionUid, + searchQuery: "", // required on server (no window.location.search) + }); + if (notFound) return null; // real failures still throw — see resolveComposition + return <StudioEmbedClient specOptions={specOptions} />; +} +``` + +```tsx +// app/_components/StudioEmbedClient.tsx — Client Component +"use client"; +import { StudioComponent } from "@contentstack/studio-react"; +export function StudioEmbedClient({ specOptions }: { specOptions: any }) { + return <StudioComponent specOptions={specOptions} />; +} +``` + +Detect the framework (Next App Router vs Vite/CRA) before picking. Same rule as `install-studio`: presence of `app/` dir + `next` in package.json means App Router. Else default to the client-only variant. + +### Step 3: Insert the embed into the target page + +Read the target page file. Find the `insertionAnchor`. Insert the `<StudioEmbed compositionUid="{bandName}" />` line at `insertionPosition` (before/after the anchor). + +Also add the import at the top of the file: +- Relative path inferred from where the wrapper landed in step 2 (e.g. `import { StudioEmbed } from "@/components/StudioEmbed"`). + +Show the user the diff before writing: + +``` +target page changes (preview before write): + + + import { StudioEmbed } from "@/components/StudioEmbed"; + + … + <ProductSpecs specs={product.specs} /> + + <StudioEmbed compositionUid="{bandName}" /> + <BuyBox product={product} /> + … + +Proceed? [y/n] +``` + +Wait for explicit confirmation. Then write. + +### Step 4: Verify the embed renders + +1. Print: "Start your dev server (`npm run dev`) if it isn't running, then visit the target page in a browser, `<embed should appear at insertion point>`." +2. Tell the user: "In another tab, open Studio, go to Sections, and open `{bandName}`. Make a visible change (drop a Hero, change static text), Save, then reload the visitor page. The change should appear without any code redeploy." +3. If nothing renders, the debugging checklist (run these in order): + - In the browser DevTools Console, look for a `[StudioEmbed]` warning (slug mismatch / not created) + - In the Network tab, look for the CDA fetch for the composition. Check status code + - In Studio, verify the composition's `composable_uid` exactly matches `{bandName}` (case-sensitive) + - If iframe error appears: composition exists but is empty: drop at least one component in Studio + Save + +### Step 5: Register the embed in `STUDIO_EMBEDS.md` + +For projects with multiple embeds, a single registry file keeps the page-to-compositionUid mapping visible. Create or append to `STUDIO_EMBEDS.md` at the repo root: + +```markdown +# Studio Embeds Registry + +Each row = one Studio-managed band embedded inside a code-owned page. +Update when adding/removing/renaming embeds. + +| Composition UID (slug) | Flavor | Embedded in | Component | Notes | +|---|---|---|---|---| +| {bandName} | {compositionFlavor} | `{targetPageFile}` | `<StudioEmbed compositionUid="{bandName}" />` | | +``` + +If the file already exists, append the row before the closing of the table. Print: "Added a row to `STUDIO_EMBEDS.md`. Keep this file updated when the embed list changes." + +## Inputs needed from the user + +In order. Stop and ask if any is missing. + +1. `targetPageFile`: exact file path. The skill reads + modifies it. +2. `bandName`: kebab-case slug. Validate: lowercase, underscores or hyphens only, ≤32 chars. Reject invalid (would break Studio's composable_uid validation). +3. `compositionFlavor`: always `section`. Sections are the intended shape for embedded compositions. +4. `insertionAnchor`: string the skill greps for in the target page file. If multiple matches, ask the user to disambiguate. +5. `insertionPosition`: `before` or `after`. Default `after`. + +## Acceptance + +This skill succeeds only when ALL are true: + +- [ ] User confirmed the Studio composition exists (step 1, explicit "Composition created" response). +- [ ] `<StudioEmbed>` wrapper exists (either reused or scaffolded in step 2). +- [ ] Target page file imports `StudioEmbed` AND renders `<StudioEmbed compositionUid="{bandName}" />` at the chosen position. +- [ ] Visiting the target page in a browser renders the embed (step 4 visual confirmation). +- [ ] `STUDIO_EMBEDS.md` has a row for this embed. + +If the embed doesn't render visually, do NOT claim success: surface the debugging checklist from step 4. + +## Common pitfalls + +| Pitfall | Why it bites | Fix | +|---|---|---| +| Slug typo between Studio + code | `compositionUid` is case-sensitive, `pdp_marketing_band` ≠ `Pdp_Marketing_Band` | Copy the `composable_uid` directly from Studio's composition list. Keep `bandName` in lowercase_snake_case | +| Composition is empty in Studio | `specOptions.spec` resolves but `<StudioComponent>` renders nothing | Drop at least one component in Studio and Save before testing the embed | +| Embed renders during SSR, disappears after hydration | Mixing the SSR variant + CSR variant inadvertently | Use the SSR pattern (server-fetch + client render) when the surrounding page is a Server Component. Use the CSR variant when the surrounding page is `"use client"` | +| Layout shift when band loads | Default `fallback={null}`, so the page reflows when content arrives | Pass `fallback={<div style={{height: 200}} />}` (or a skeleton) matching expected band height | +| Embed breaks after rename in Studio | Renaming a composition changes its `composable_uid`. Embed still references the old slug | Don't rename embedded compositions. If you must, do a global find-replace in the codebase AND update `STUDIO_EMBEDS.md` in the same commit | +| Author edits but no live updates | CDA cache | Republish the composition (Save in Studio is draft. Publish is what visitors fetch) | +| Embed in a Server Component page calls the client-only hook | `useCompositionData` is CSR-only by definition (uses React state) | Use the SSR variant (server-fetch via `sdk.fetchCompositionData` + client-wrap) when the parent is RSC | + +## See also + +- [`docs/40-recipes/partial-adoption-coexisting-with-a-code-driven-app.md`](../../docs/40-recipes/partial-adoption-coexisting-with-a-code-driven-app.md): when to embed vs use catch-all vs full route swap +- [`docs/40-recipes/partial-adoption-coexisting-with-a-code-driven-app.md`](../../docs/40-recipes/partial-adoption-coexisting-with-a-code-driven-app.md): when to embed vs use catch-all vs full route swap +- `migrate-page-to-studio`: when you want the WHOLE page Studio-managed instead of just one band +- `setup-template-preview-routes`: for the catch-all route pattern that handles full pages +- `install-studio`: prerequisite (SDK + init must be wired before any embed works) diff --git a/skills/studio/enable-visual-experience.md b/skills/studio/enable-visual-experience.md new file mode 100644 index 0000000..775f358 --- /dev/null +++ b/skills/studio/enable-visual-experience.md @@ -0,0 +1,240 @@ +--- +name: enable-visual-experience +--- + +## When to use + +Enable Live Preview + Visual Experience at the stack level: prerequisite before any Studio / Live Preview / Visual Editor work. Without it, app installs silently fail. + +Use as the FIRST step before `install-studio`, `install-live-preview`, `configure-studio`, or any Studio setup skill. Phrases: "enable Live Preview", "set up Visual Experience", "canvas is blank", "preview events not arriving". Also when `verify-setup` reports Layer 2 failing or `troubleshoot-canvas` finds canvas blank with no preview events. Not doable from CLI: click-through in the Contentstack web app, gates downstream skills. + +> **Mandatory auth preflight: settle the credential before the first API call.** Resolve it OAuth-first per [`authenticate-cma`](authenticate-cma.md): `CS_OAUTH_ACCESS_TOKEN`, else the Contentstack MCP's stored session: Read that skill for where it is, even with no MCP connected. **Never ask the user for a session `authtoken`.** If nothing resolves, or a refresh fails with `400 invalid_refresh_token`, hand them `! CONTENTSTACK_REGION=<code> npx @contentstack/mcp --auth` (it needs a TTY and a browser, so it cannot be run for them) and wait. `403 error_code 316` is a valid credential aimed at another org: fix the org or the `api_key`, do **not** re-authenticate. + +# Enable Visual Experience + Live Preview at the stack level + +## Why this skill exists + +Studio, Live Preview, and Visual Editor share **one stack-level checkbox**: `Stack → Settings → Visual Experience → General → Live Preview → Enable Live Preview`. When unchecked, every downstream piece works locally and then **silently fails**: + +- The preview channel doesn't exist. Preview Tokens resolve to nothing +- The Studio canvas can't fetch content via the preview environment +- Visual Editor's inline editing doesn't render +- The `Preview URL` sub-tab is locked with: "Custom Preview URL Unavailable: You don't have permission to use a Custom Preview URL because Live Preview is disabled in the General settings." +- The top-nav `Visual Experience` link (which appears at the route `/visual-editor` when Live Preview is on) doesn't exist + +> **Side effect verified live:** enabling Live Preview + clicking Save adds a top-level **`Visual Experience`** link to the main stack navigation (between Content Models and Publish Queue). Disabling + Save removes it. This is a useful sanity-check signal: if the top-nav link is missing after Step 1, the save didn't actually persist. + +## Do it by API: this is automatable {#api-path} + +> **Auth: OAuth first.** Snippets here write `-H "$CS_AUTH"`, resolved once per run. **Assume there is no session `authtoken`**: the user authenticates with OAuth (MCP `--auth` / `csdx auth:login --oauth`) and never pastes one. Resolution order, scope headers (`organization_uid` vs `api_key`), the expiry/refresh rule and the 401/422 signature table: [`authenticate-cma`](authenticate-cma.md). `<stack>`: MCP-registration key ([`understand-contentstack-mcp`](understand-contentstack-mcp.md)), user-confirmed. + +**Correction (2026-08-23):** earlier versions of this skill declared the Live Preview toggle a hard CMA boundary. That was wrong: it was the wrong endpoint. Both the toggle and the preview token are scriptable, verified end to end on a prod test stack. Prefer this path. Fall back to the UI walkthrough when you have no CMA authtoken. + +### 1. Enable Live Preview: `POST /v3/stacks/settings` + +Read first, flip the flag, write the whole object back: + +```bash +curl -s -H "api_key: <stack>" -H "$CS_AUTH" \ + "https://<cma-host>/v3/stacks/settings" # → { "stack_settings": { … } } + +curl -s -X POST -H "api_key: <stack>" -H "$CS_AUTH" \ + -H "Content-Type: application/json" \ + "https://<cma-host>/v3/stacks/settings" \ + -d '{"stack_settings":{ …every existing key…, + "live_preview":{"enabled":true,"default-env":"<environment-uid>","default-url":"", + "is-always-open-in-new-tab":false,"lp-onboarding-setup-visible":true}}}' +# → 201 Created, and it persists +``` + +**Send the FULL `stack_settings` object** with only the flag changed. Siblings (`visual_builder`, `discrete_variables`, `timeline`, `entries`, `stack_variables`) must survive the write, so read-modify-write rather than posting `live_preview` alone. + +`default-env` takes an **environment uid**, so Step 2 below is settable in the same call. + +### 2. Mint the delivery token and its preview token in one call + +```bash +curl -s -X POST -H "api_key: <stack>" -H "$CS_AUTH" \ + -H "Content-Type: application/json" \ + "https://<cma-host>/v3/stacks/delivery_tokens?create_with_preview_token=true" \ + -d '{"token":{"name":"studio","description":"Studio + Live Preview","scope":[ + {"module":"environment","environments":["<env-name>"],"acl":{"read":true}}, + {"module":"branch","branches":["main"],"acl":{"read":true}}]}}' +# → 201, response.token.preview_token (≈26 chars, "cs…") +``` + +Read one back later with `GET /v3/stacks/delivery_tokens/<uid>?include_preview_token=true`. + +| Trap | What you see | Fix | +| --- | --- | --- | +| `PUT /v3/stacks {stack:{settings:{live_preview:{enable:true}}}}` | **200 OK, "Stack updated successfully."** and the setting does NOT persist: wrong endpoint, wrong envelope (`stack.settings` vs `stack_settings`), wrong key (`enable` vs `enabled`) | Use `POST /v3/stacks/settings` with the `stack_settings` envelope and `enabled` | +| `create_with_preview_token` placed in the request **body** | Token created, `preview_token` silently absent | It is a **query param** | +| Token creation on a branch-enabled stack without a branch scope | `error_code 141`, "Delivery Token creation failed. Please try again.". The real reason hides in `errors["scope.branch_or_alias"]` | Add `{"module":"branch","branches":["main"],"acl":{"read":true}}` to `scope` | <!-- style-lint: allow --> +| Reading LP state from `GET /v3/stacks?include_settings=true` and finding `{}` | That is genuinely "off": the field is readable, so **verify instead of asking the user** | - | + +**Verified with a session `authtoken`. OAuth Bearer is untested on this endpoint, attempt it first and branch on the response** ([`authenticate-cma`](authenticate-cma.md) § Which credential each service takes carries the probe). Whether a stack-scoped **management token** is accepted for the settings write is untested. If it 401s, fall back to the UI steps. + +**Method note worth keeping:** this was believed impossible for two sessions because the wrong endpoint answered `200`. It was settled by opening the Network tab in DevTools while a human ticked the checkbox. When a CMA operation looks impossible, capture what the product's own UI calls before writing it off. + +### After an API write, reload the UI before trusting your eyes + +An already-open **Visual Experience** tab under **Settings** does NOT reflect a settings write made by API: the SPA holds the state it loaded with. Verified live: the API read said `enabled: true` while the open tab still showed the checkbox unchecked. A reload showed it checked, with the Default Preview Environment populated and the **`Visual Experience`** top-nav link present. + +So confirm an API-driven enable in this order: + +1. **Read it back**: call `GET /v3/stacks/settings` and read `stack_settings.live_preview.enabled`. This is the source of truth and it is instant. +2. Only then look at the UI, and **reload the page first**. A stale tab is the single most likely reason a working write looks broken. + +The top-nav link is the cheapest human-visible signal: it appears when Live Preview is on, whether it was switched on by API or by checkbox, and disappears when it is off. + +## Fallback: the UI walkthrough + +Use this when the API path 401s with a **freshly refreshed** token (an expired OAuth token 401s identically, refresh before concluding anything), or no credential resolves at all. It is also what a human follows to verify what the API did. + +## Task + +**Run the [API path](#api-path) first, then route.** Do not walk a human through work you have already done. That is the failure this routing exists to prevent. + +| Step | Covered by the API path? | What to do | +| --- | --- | --- | +| 1. Enable Live Preview | Yes, `POST /v3/stacks/settings` | **SKIP the walkthrough.** Confirm by read-back: `live_preview.enabled === true`. Do not ask for a "done" | +| 2. Default Preview Environment | Yes, the same call with `default-env: <env-uid>` | **SKIP.** Confirm `live_preview["default-env"]` is non-empty | +| 3. Custom Preview URL | No, not API-settable (untested) | **Walk the human through it**, the only step that still needs one | +| 4. Preview Token | Yes, `POST …/delivery_tokens?create_with_preview_token=true` | **SKIP.** Confirm `token.preview_token` came back | +| 5: Summary | - | Always print it, stating which steps were done by API and which by hand | + +So on the API path the human is asked for **one** thing (Step 3), not four. Announce what you did rather than asking them to verify it: "Live Preview is on, default preview environment is `development`, preview token minted, all by API. One thing left for you: the Custom Preview URL." + +If no credential resolved at all, or a write failed with a **freshly refreshed** token, fall through to the full walkthrough below. **Then** (and only then) ask the user to explicitly type "done" after each step, and do NOT batch the checks. + +### Step 1: Enable Live Preview at the stack level + +**Skip this step if the API path already set it**: verify with `GET /v3/stacks/settings`, reading `live_preview.enabled`, and move on without asking for confirmation. + +Tell the user verbatim: + +> 1. Open the Contentstack web app and switch to **this stack**. Confirm by checking that the API key matches `{{stackApiKey}}` (in Settings, open Stack, then API Credentials, and read the Api Key). +> 2. Click **Settings** in the left nav. +> 3. In Settings, click **Visual Experience** (URL: `/settings/visual-experience`). +> 4. On the **General** sub-tab (the default landing), find the **Live Preview** section heading. +> 5. Check the **Enable Live Preview** checkbox. +> 6. You'll see a note below it: "As Live Preview is now locale-based, the default base URL is no longer required.". That's expected. +> 7. Click **Save** at the bottom of the page. +> 8. **Sanity check:** after Save succeeds, a new top-level link **`Visual Experience`** appears in the main stack navigation (between Content Models and Publish Queue). If you don't see it, the save didn't persist. Retry. +> +> Reply **"done"** once the checkbox is checked, Save is confirmed, and the top-nav link appears. + +If the user reports the checkbox is grayed out, they lack the Settings role on the stack. Stop. Escalate to the stack owner / org admin. + +### Step 2: Set the Default Preview Environment + +**Skip this step if the API path already set `default-env`**: verify by read-back rather than asking. + +Tell the user verbatim: + +> 1. Still on the **General** sub-tab of **Visual Experience** in **Settings**, scroll to **Default Preview Environment** directly below the Enable Live Preview checkbox. (It's disabled until Step 1 is saved.) +> 2. The dropdown auto-populates from the **Environments** page in **Settings**: every environment in the stack appears as an option. +> 3. Select `{{previewEnvironmentName}}` (or whichever environment you use for previewing). **Don't pick `production`**: Live Preview reads draft content, which doesn't live in the production environment. +> 4. Click **Save**. +> +> If the dropdown is empty: in a new tab, open **Settings**, go to **Environments**, and create an environment named `{{previewEnvironmentName}}` first. Return to Visual Experience and refresh. +> +> Reply **"done"** with the env name you confirmed. + +### Step 3: Set the Custom Preview URL on the Preview URL sub-tab + +Tell the user verbatim: + +> 1. On the **Visual Experience** page in **Settings**, click the **Preview URL** sub-tab. If it's locked with "Custom Preview URL Unavailable: You don't have permission to use a Custom Preview URL because Live Preview is disabled in the General settings," go back and re-do Step 1. +> 2. Turn the **Enable Custom Preview URL** toggle ON (helper text: "Build preview URLs that match your live website structure"). This is a separate toggle from the Enable Live Preview checkbox in Step 1. +> 3. Configure the **Base URL** section (required): +> - **Alias**: pick a short name (e.g. `local`, `staging`, `prod`). Useful for multi-brand setups where one stack feeds multiple sites. +> - **Pattern**: paste `{{canvasAppUrl}}` as the host pattern (e.g. `http://localhost:5173`). +> - Use the **Insert** buttons below the Pattern field to add Contentstack's template placeholders if needed: `{{entry.title}}`, `{{taxonomy:brand}}`, `{{environment}}`, `{{locale}}`. **Use double-brace `{{ }}` syntax, not `${ }`**: Contentstack uses Handlebars-style, not JS template literals. +> - Click **Add URL** if you want to add more aliases for additional environments. +> 4. Configure the **URL Path** section (required): +> - One row per content type. Default path is `/{{entry.url}}`. Studio content types need prefixed rows, e.g. `/blog/{{entry.url}}`. +> - Click **Add Path** to add per-CT overrides (e.g. blog posts at `/blog/{{entry.slug}}`, products at `/products/{{entry.sku}}`). +> - Drag rows to reorder. Matching is top-to-bottom. +> 5. **Advanced Config** is collapsible. Leave it alone for the standard case. +> 6. Click **Save** at the bottom. +> +> Reply **"done"** with the Base URL alias name + pattern you set. + +### Step 4: Generate the Preview Token on the Delivery Token + +**Skip this step if the token was minted with `?create_with_preview_token=true`**: you already hold the value. Do not ask the user to create or paste one. + +Tell the user verbatim: + +> 1. In the left nav, open **Settings**, then **Tokens**, then **Delivery Tokens**. +> 2. Open the Delivery Token your app uses. The token must be scoped to a **Publishing Environment** that matches what you want to preview: `{{previewEnvironmentName}}` for the standard case (visible as a radio button under Publishing Environments on the token edit page). +> 3. On the token edit page, scroll past the Stack API Key and Delivery Token (read-only) fields. You'll see a button labeled **Create Preview Token** with helper text: "Live Preview relies on the Preview token. We strongly recommend using this token instead of a read-only Management token." +> 4. Click **Create Preview Token**. +> 5. After the click, the button **disappears** and a new **Preview Token (read only)** field shows up directly below the Delivery Token. The value starts with `cs` (e.g. `cs449d1c52e12fd4fea68ad6d0`). +> 6. Copy the Preview Token value. This is what goes into your app's `.env.local` as `*_PREVIEW_TOKEN`: +> - Vite: `VITE_CS_PREVIEW_TOKEN=cs...` +> - Next: `NEXT_PUBLIC_CS_PREVIEW_TOKEN=cs...` +> - CRA: `REACT_APP_CS_PREVIEW_TOKEN=cs...` +> +> **Important:** there is **no delete / revoke button** for the Preview Token in this UI. Once you click Create Preview Token, the token exists for the lifetime of the Delivery Token. The only way to invalidate it is to delete the Delivery Token itself. So **only click it on the Delivery Token you intend to use for previewing.** +> +> Reply **"done"** when the Preview Token field is visible and you've copied the value. + +### Step 5: Print the final summary + +``` +✅ Stack-level Visual Experience is now ready. + + Live Preview: ENABLED (Settings → Visual Experience → General) + Default Preview env: <env name from Step 2> + Custom Preview URL: + Base URL alias: <alias from Step 3> + Base URL pattern: <pattern from Step 3> + URL Path default: /{{entry.url}} (plus prefixed per-CT rows) + Preview Token: generated on <delivery token name from Step 4> + +Sanity-check signal: a top-level "Visual Experience" link should now +appear in the main stack nav. + +Next: + • Run `install-studio` (or `install-live-preview`) to wire the app side. + • Run `verify-setup` after install to test all four layers end-to-end. +``` + +## Inputs needed from the user + +1. `stackApiKey` (required). +2. `canvasAppUrl` (required): used in Step 3's Base URL pattern. +3. `previewEnvironmentName` (default `preview`): selected in Step 2, scopes the Delivery Token in Step 4. + +## Acceptance + +- [ ] **CSLP tags reach the DOM**: the SDK config sets `cslp: { appendTags: true }`, and a rendered component's root shows `data-cslp` with each bound field tagged too. Live Preview can be fully enabled and still not be editable if components drop these, see [`register-component` § The `studioAttributes` contract](register-component.md). +- [ ] User confirmed Step 1 (Enable Live Preview checked + Save clicked + top-nav `Visual Experience` link appeared) +- [ ] User confirmed Step 2 (Default Preview Environment selected, NOT production) +- [ ] User confirmed Step 3 (Enable Custom Preview URL ON, Base URL alias + pattern set, URL Path set, Save clicked) +- [ ] User confirmed Step 4 (Preview Token generated, value copied) +- [ ] Final summary printed with all captured values + +## Common pitfalls + +| Pitfall | Why it bites | Fix | +|---|---|---| +| Skill skipped entirely | Most common Studio-setup failure. App installs cleanly, runtime silently broken. | Always run this skill first on a stack that has never used Live Preview | +| Enable Live Preview checkbox grayed out | User lacks Settings role on the stack | Stop. Escalate to the stack owner | +| Save clicked but top-nav `Visual Experience` link doesn't appear | The save didn't actually persist (sometimes happens on slow networks or with stale session) | Refresh the page, re-check the checkbox, click Save again | +| Default Preview Environment dropdown empty | No preview-only environment exists in the stack | Create one in Settings, then Environments, then New Environment (e.g. `preview`). Don't share with production. | +| Picked `production` as Default Preview Environment | Live Preview reads draft content, which doesn't exist in production | Pick a non-production environment (e.g. `preview`) | +| Preview URL sub-tab shows "Custom Preview URL Unavailable" | Step 1 wasn't saved | Go back to General, re-check Enable Live Preview, click Save, return to Preview URL | +| Used `${entry.uid}` or `${entry.url}` template syntax | Contentstack uses Handlebars-style `{{ }}`, not JS template literals `${ }` | Use the Insert buttons under the Pattern field, they emit the correct syntax automatically | +| No Preview Token visible on Delivery Token edit page | The token was never generated. Only the **Create Preview Token** button shows by default | Click the button. The token only exists after the click. | +| Tried to revoke / delete Preview Token after generation | There is NO revoke button in this UI | The Preview Token persists for the lifetime of the Delivery Token. To invalidate, delete the Delivery Token itself. | +| Created Preview Token on the wrong Delivery Token (e.g. one scoped to production) | The app's preview reads from this token's environment, so previewing the wrong env shows wrong content | Generate the Preview Token on the Delivery Token that's scoped to the preview environment. Each Delivery Token has its own Preview Token. | + +## See also + +- `install-studio`: calls this skill as **Step 0a**. +- `install-live-preview`: calls this skill first. +- `verify-setup`: Layer 0 confirms this skill was completed. +- `troubleshoot-canvas`: checks this first when canvas is blank. diff --git a/skills/studio/expose-section-props.md b/skills/studio/expose-section-props.md new file mode 100644 index 0000000..a29a797 --- /dev/null +++ b/skills/studio/expose-section-props.md @@ -0,0 +1,95 @@ +--- +name: expose-section-props +--- + +## When to use + +Expose individual component props on a Section so template authors can override them per instance. Use Studio UI, driven through Playwright per [`drive-studio-canvas-ui`](drive-studio-canvas-ui.md). Don't hand the clicks to the user or switch to the API unless they ask; that's [`author-composition-via-api`](author-composition-via-api.md). + +Use when a single value inside a section (headline, CTA label, image, href) needs to be overridable per template instance. Phrases: "expose this prop", "let authors override the headline", "per-instance override". Do NOT use for a swappable component subtree (use Section Slots) or a different entry list per page (Linked schema). + +> **Mandatory auth preflight: settle the credential before the first API call.** Resolve it OAuth-first per [`authenticate-cma`](authenticate-cma.md): `CS_OAUTH_ACCESS_TOKEN`, else the Contentstack MCP's stored session: Read that skill for where it is, even with no MCP connected. **Never ask the user for a session `authtoken`.** If nothing resolves, or a refresh fails with `400 invalid_refresh_token`, hand them `! CONTENTSTACK_REGION=<code> npx @contentstack/mcp --auth` (it needs a TTY and a browser, so it cannot be run for them) and wait. `403 error_code 316` is a valid credential aimed at another org: fix the org or the `api_key`, do **not** re-authenticate. + +# Expose Section Props + +## Context + +> **Prerequisite: the canvas must render.** This is a canvas operation. If the section or template canvas is blank, the canvas chain isn't wired (route + Canvas URL path + the targeted environment's per-locale Base URL). Run [`setup-section-preview`](setup-section-preview.md) first: it walks the whole chain, including the env Base URL. + +Studio sections are reusable building blocks composed of one or more components. By default, every prop the section author bound or typed is locked: template authors can drop the section but cannot change anything inside it. + +**Exposing a prop** lifts a single component prop out of that locked interior and surfaces it on the section's right-panel Properties when the section is dropped onto a template. Template authors can then override the value as a literal or re-bind it to template data (e.g. `entry.title`). + +Three rules: + +- **Per-instance, not per-name.** If a section contains three Hero components, each Hero's `headline` toggles independently. Exposing one does not expose the others. +- **Exposed As must be unique within the section.** Studio disables Save when two exposed props share the same label. Always qualify by position or role. +- **Renaming the label is safe.** Existing template overrides keep their values. Only the displayed label changes. + +When the user is unsure whether they want Expose Props, use this decision table: + +| User wants… | Use | +| --- | --- | +| Different **value** per template instance (text, image, href) | Expose Prop | +| Different **component subtree** per template instance | Section Slot | +| Different **list of entries** per template instance | Linked schema | + +Reference: `docs/32-sections/expose-section-props.md`. + +## Task + +1. **Open the section in the section canvas.** In Studio, switch to the Sections tab, find `sectionName` in the list, and open it. The section opens in its own canvas, distinct from a template canvas. + +2. **For each entry in `propsToExpose`, locate the component node.** Click the node on the canvas, or pick it in the Layers panel. Confirm in the right panel that the selected node is the right component instance, not its parent container, and not a sibling of the same component type. + +3. **(Optional) Pre-toggle exposure from the Settings tab.** With the node selected, open the right-panel **Settings** tab and toggle exposure for the props you'll expose. This pre-marks them so the Save-time modal opens with those rows already ON. Skip this if you prefer to do everything in the Save modal. + +4. **Click Save on the section.** The **Expose Props modal** opens, listing every candidate prop on every component in the section, grouped by component type. + +5. **For each candidate row, set three fields:** + - **Expose**: toggle ON for props in `propsToExpose`. Leave OFF for everything else. The section author's value stays locked. + - **Component Prop**: read-only internal prop name (e.g. `text`, `headline`, `src`, `href`). Use this to identify the row. + - **Exposed As**: the label template authors see. Rewrite to disambiguate in the context of the whole section: "Top hero headline", "Primary CTA label", "Card thumbnail image", not bare "headline" or "text". + +6. **Resolve duplicate labels.** If two exposed props end up with the same **Exposed As**, Studio disables Save. Qualify by position ("Top hero headline" / "Bottom hero headline") or by role ("Primary CTA label" / "Secondary CTA label"). + +7. **Save the modal.** The section is persisted with its exposed-prop manifest. + +8. **Verify in a template.** Open any template, drag this section onto the canvas, select the section instance, and inspect the right-panel Properties. Every prop listed in `propsToExpose` must appear under its **Exposed As** label, pre-filled with the section's internal value as the starting value. Props you left OFF must NOT appear anywhere on the template-side panel. + +9. **Confirm per-instance isolation.** Drop the same section a second time on the same template. Override one exposed prop on instance A. Confirm that instance B and the section's internal value are unchanged. + +## Inputs needed from the user + +In this order. If unclear, ask before proceeding. + +1. `sectionName`: the section to edit +2. `propsToExpose`: concrete list of `component → prop → friendly label` triples. Push back if the user says "expose everything". That defeats the purpose of a section. + +## Acceptance + +This skill succeeds only when ALL of the following are true: + +- [ ] The section's Save completed without a duplicate-label error +- [ ] In the template canvas, the section instance's right-panel shows exactly the props listed in `propsToExpose`, each under its **Exposed As** label +- [ ] Each exposed prop's input control matches the prop's type (string gets a text input, image gets an image picker, href gets a link picker) +- [ ] Editing an exposed prop on template instance A does not affect template instance B or the section's internal value +- [ ] Re-binding an exposed prop via the data picker (e.g. to `entry.title`) works exactly like binding a fresh component prop +- [ ] Un-exposed props are not visible anywhere on the template-side panel + +If any check fails, do not claim success. Surface the failure and stop. + +## Common pitfalls + +| Pitfall | Why it bites | Fix | +| --- | --- | --- | +| Exposing too much | Section becomes a loose component bundle with no shape | Expose only what varies per instance. Leave structural/styling props internal | +| Bare prop names as labels | Ambiguous once a section has multiple headings | Rename at Save time, costs nothing | +| Confusing with Section Slots | Wrong mechanism for the need | A value per page needs Expose, a subtree per page needs a Slot, a list per page needs a Linked schema | +| Section's internal value used as a placeholder ("TODO") | Becomes the default authors see on the template side | Set the in-section value to a sensible default | + +## See also + +- `docs/32-sections/expose-section-props.md`: full reference +- `use-section-slot` skill: for swappable component subtrees +- `understand-linked-schemas` skill: for per-instance list data diff --git a/skills/studio/figma-generate-components.md b/skills/studio/figma-generate-components.md new file mode 100644 index 0000000..7885147 --- /dev/null +++ b/skills/studio/figma-generate-components.md @@ -0,0 +1,229 @@ +--- +name: figma-generate-components +--- + +## When to use + +Generate React components from a Figma design and register them with Studio via the Studio Figma Plugin + `csdx studio:component:sync/add/register` CLI commands. Optionally imports Figma design tokens. + +Use when the user wants real, committable React components in their project generated from a Figma design (component library, design system, frames). NOT for one-off pastes (for that, point them at [Copy and paste from Figma to Studio](../../docs/20-bring-your-own-components/copy-and-paste-from-figma-to-studio.md). Phrases) "generate components from Figma", "Figma to React", "import my design system". This skill produces source files + registry entries + (optionally) design tokens. + +> **Auth preflight: settle the credential before the first API call.** Resolve it OAuth-first per [`authenticate-cma`](authenticate-cma.md): `CS_OAUTH_ACCESS_TOKEN`, else the Contentstack MCP's stored session: Read that skill for where it is, even with no MCP connected. **Never ask the user for a session `authtoken`.** If nothing resolves, or a refresh fails with `400 invalid_refresh_token`, hand them `! CONTENTSTACK_REGION=<code> npx @contentstack/mcp --auth` (it needs a TTY and a browser, so it cannot be run for them) and wait. `403 error_code 316` is a valid credential aimed at another org: fix the org or the `api_key`, do **not** re-authenticate. + +# Generate Studio components from a Figma design + +## Standard: REQUIRED reading + +This skill orchestrates the **canonical Studio Figma Plugin + Studio CLI workflow**. The authoritative in-repo documents are: + +- `/docs/20-bring-your-own-components/generate-components-from-figma.md`: the full developer-facing flow with reference diagram, four CLI commands, plugin walkthrough, screenshots +- `/docs/20-bring-your-own-components/copy-and-paste-from-figma-to-studio.md`: the lighter-weight Copy to Studio path +- `/docs/20-bring-your-own-components/studio-design-tokens-overview.md`: the design-token side + +The workflow has **two sides**: + +| Side | Tool | Who drives it | +|---|---|---| +| **Designer** | Studio Figma Plugin (Figma Marketplace plugin id `1541766192464484605`) | Designer, inside Figma | +| **Developer** | Studio CLI (`csdx`) | Developer, in the terminal | + +They meet via **Component IDs** and **Design Token IDs** generated by the plugin and consumed by the CLI. No Figma MCP server is involved. Treat any mention of "Figma MCP" here as stale. + +## The four CLI commands + +The Studio CLI exposes four primary commands for the Figma workflow (plus a design-token auxiliary). Every step maps to one. Name the command when you tell the user what you're doing. + +| Command name | `csdx` invocation | What it does | +|---|---|---| +| **Project** | `csdx studio:project:set` / `csdx studio:project:get` | Connect the CLI to a specific Studio project. Every later command scopes to it. | +| **Sync** | `csdx studio:component:sync --component-path=<path>` | Publish existing code components into the Studio project so the Figma plugin's Auto Map can match Figma layers to them. | +| **Add** | `csdx studio:component:add --component-id=<id>` | Generate a new React component from a Figma design (run after the plugin produces a Component ID). | +| **Register** | `csdx studio:component:register --component-path=<path>` or `--component-dir=<dir>` | Add a local or custom component to Studio so authors can use it on the canvas. | +| (aux) **Design token: add** | `csdx studio:design-token:add --design-token-id=<id>` | Pull design tokens from the plugin's output into `register-design-tokens.ts`. | + +Mental model: + +- **`Project`** scopes everything. +- **`Sync`** flows existing code into the plugin (one-way, upstream). +- **`Add`** flows the plugin's Figma output into new code in the repo (one-way, downstream). +- **`Register`** publishes any local component to Studio's canvas. + +## Task + +### Step 0: Detect which side of the workflow the user is on + +If the user **has a Component ID already** (from the plugin's Generate Design step), they're on the developer side and want this skill to run the `Add` + `Register` commands. Jump to Step 4. + +If the user **doesn't have a Component ID yet**, walk them through the full setup first (Steps 1 to 3), then have them open Figma and run the plugin (Step 3), then come back for Step 4. + +### Step 1: Verify CLI is installed and a project is linked + +Run: + +```bash +csdx --version +csdx studio:project:get +``` + +If `csdx` is not installed: + +```bash +npm i -g @contentstack/cli +csdx plugins:install @contentstack/studio +csdx config:set:region # pick the user's Contentstack region +csdx login --oauth # opens browser for OAuth +``` + +If `csdx studio:project:get` does NOT return the project the user wants: + +```bash +csdx studio:project:set --project-id=<projectId> +``` + +Use the `projectId` input if provided. Otherwise ask the user. + +### Step 2: Sync existing components (if using Auto Map) + +If `componentPathToSync` was provided OR if this is the first run on this repo, publish existing components so the Figma plugin can match against them: + +```bash +csdx studio:component:sync --component-path=<componentPathToSync> +``` + +Skip if the user has already synced recently and no components have changed. + +### Step 3: Have the designer (or the user themselves) run the Studio Figma Plugin + +Print these instructions verbatim: + +> **Designer step (in Figma):** +> +> 1. Install **Contentstack Studio** from the Figma Marketplace (plugin id 1541766192464484605) if not already installed. +> 2. Open the file, go to Plugins, choose Contentstack Studio, then click **Run**. +> 3. Pick region, click **Authorize**: completes OAuth, returns to Figma. +> 4. Confirm the Figma file is **editable** (read-only files block Auto Layout updates and mapping). +> 5. Select the frame(s) or component(s) to generate (Shift+click for multiple). +> 6. Add a **layout prompt** if needed (e.g. "Make this a carousel"). Prompts shape layout, spacing, and responsiveness only, not behavior. +> 7. Pick a **responsiveness** option: Optimize for responsiveness (desktop/tablet/mobile) or Fixed-size screens. +> 8. Let **Auto Map** match Figma layers to your synced code components. Override any row manually. +> 9. Click **Generate Design**. +> 10. The plugin shows four output buttons. Click **Copy CLI Command** to get a ready-to-run `csdx studio:component:add --component-id=<id>` on your clipboard. If design tokens were generated, also note the Design Token ID. + +Then ask: "Paste the Component ID (or the full CLI command) the plugin gave you." + +If the user pastes a full command like `csdx studio:component:add --component-id=abc123`, extract `abc123` as the `componentId`. + +### Step 4: Run `Add` to materialise the Figma design as React source + +```bash +csdx studio:component:add --component-id=<componentId> +``` + +The CLI auto-detects the project's styling method (Tailwind / CSS modules / vanilla CSS) and writes three files into the project's components directory: + +- A React `.tsx` component file +- A matching style file +- A metadata file capturing the prop schema + +Capture the paths the CLI prints. You'll need them for Step 6. + +If the user wants to customize the generation, pass an additional prompt argument to the `Add` command (refer to the CLI's `--help` for the exact flag, varies by CLI version). Common customizations: + +- "Use the existing `useBreakpoint` hook for the responsive switch" +- "Use `clsx` for conditional class names" +- "Match the export style of `src/components/Button.tsx`" + +### Step 5: (Optional) Run `Design token: add` if tokens were generated + +If `designTokenId` was provided: + +```bash +csdx studio:design-token:add --design-token-id=<designTokenId> +``` + +The CLI scans the project's existing token sources (JS/TS files, CSS files with custom properties, JSON token files) and prompts: + +1. **Include Studio's default design tokens?** (Y/N): default Y to keep canonical Studio tokens alongside custom ones. +2. **Access level**: `dynamic` (entry-linked only), `tokens` (dynamic + custom), or `arbitrary` (all canvas properties). Pick `tokens` for most cases. +3. **Output path**: accept the default. + +The CLI writes `register-design-tokens.ts`. **Then add the import to the app entry** (e.g. `src/main.tsx` or `src/bootstrapStudioSdk.ts`): + +```ts +import "./register-design-tokens"; +``` + +Verify the import comes BEFORE any `<StudioComponent />` mount. + +### Step 6: Run `Register` to publish the new component to Studio's canvas + +```bash +csdx studio:component:register --component-path=<path-from-step-4> +``` + +After this, the component appears in the Studio canvas palette. Authors can drop it on a composition and bind its props. + +### Step 7: Print next steps + +``` +Generated and registered <N> components from Figma. + +Source: <component-path-from-add> +Tokens: <register-design-tokens.ts> (if design tokens were run) +Registered: via csdx studio:component:register + +Open Studio → any composition → Components palette → look for the new tile. +Drop it on a canvas to verify the render matches the Figma source. + +Re-run `csdx studio:component:sync --component-path=<path>` if you want this +new component (or any other code-side changes) to be visible to the next +Figma plugin run's Auto Map. +``` + +## Inputs needed from the user + +1. `componentId`: required for the developer side (Steps 4 to 7). If absent, walk through Steps 0 to 3 first. +2. `designTokenId`: optional, only needed if the plugin produced tokens. +3. `projectId`: optional, only needed if the project isn't already linked. +4. `componentPathToSync`: optional, only needed if code components changed since last sync OR first run. + +Ask clarifying questions when: + +- `csdx` isn't installed AND the user hasn't confirmed they want to install it +- Multiple Studio projects could be the target. Confirm with `csdx studio:project:get` first +- The Figma file's `editable` status isn't clear (the plugin will surface this. If not, ask) + +## Acceptance + +The skill succeeds only when ALL are true: + +- [ ] `csdx studio:project:get` returns the intended project +- [ ] **Every generated component carries its CSLP tags**: `studioAttributes` spread on the root element **and** `wrap: false` in its register entry, and each bindable prop's `$`-twin spread on the element rendering it. Confirmed in the DOM: the root and each bound text/image element show `data-cslp`. Without it the component renders but Visual Editor and Live Preview cannot edit it. See [`register-component` § The `studioAttributes` contract](register-component.md). +- [ ] If Sync was needed: `csdx studio:component:sync` ran successfully (CLI exits 0) +- [ ] `csdx studio:component:add --component-id=<id>` produced a `.tsx`, a style file, and a metadata file at known paths +- [ ] If tokens were involved: `register-design-tokens.ts` exists AND is imported from the app entry point +- [ ] `csdx studio:component:register` ran successfully +- [ ] After dev server restart, the new component appears in Studio's palette with the correct name + thumbnail +- [ ] Dropping the new component on a canvas renders without console errors + +If any check fails, surface the specific failure with file paths. + +## Common pitfalls + +| Pitfall | Why it bites | Fix | +|---|---|---| +| Skipping `Sync` before the designer runs the plugin | Auto Map has nothing to match against. Every Figma layer becomes a generic frame | Run `csdx studio:component:sync --component-path=<path>` before Step 3 | +| Running `Add` against a stale Component ID | The plugin generated a different design later. IDs are versioned | Re-run the plugin's Generate Design and grab the new ID | +| Forgetting to import `register-design-tokens.ts` from the app entry | Tokens never register. They exist in the file but Studio doesn't see them | Add `import "./register-design-tokens";` to `main.tsx` (or wherever the SDK bootstraps) before any `<StudioComponent />` | +| Running `Register` on a path the CLI doesn't recognize as a component | Register fails: needs a component file (named export matching a React component) or a directory containing them | Pass the exact `.tsx` path returned by `Add`, or the parent directory if registering many at once | +| Confusing `Sync` and `Register` | They look similar but face different audiences | `Sync` makes code components visible **to the Figma plugin** (for Auto Map). `Register` makes components visible **to authors on the Studio canvas**. | +| Mixing responsive + fixed-size in one plugin run | Plugin refuses, only one mode per Generate Design call | Run the plugin twice, once per responsiveness mode | +| Read-only Figma file | Blocks Auto Layout updates, token adjustments, component mapping | Get edit access first | + +## See also + +- [Copy and paste from Figma to Studio](../../docs/20-bring-your-own-components/copy-and-paste-from-figma-to-studio.md): the simpler "Copy to Studio" flow, for a one-off composition tree instead of committable React source +- `register-component` skill: for hand-authored components (skips the plugin entirely, only runs `Register`) +- `import-design-tokens` skill: for token imports from non-Figma sources +- [`/docs/20-bring-your-own-components/generate-components-from-figma.md`](../../docs/20-bring-your-own-components/generate-components-from-figma.md): the full developer-facing flow with reference diagram +- `/docs/20-bring-your-own-components/studio-design-tokens-overview.md`: token reference (source of truth for commands/flags) diff --git a/skills/studio/gate-third-party-scripts.md b/skills/studio/gate-third-party-scripts.md new file mode 100644 index 0000000..f306de8 --- /dev/null +++ b/skills/studio/gate-third-party-scripts.md @@ -0,0 +1,55 @@ +--- +name: gate-third-party-scripts +--- + +## When to use + +Keep the host site's third-party scripts (cookie banners, chat widgets, GTM/analytics, ad pixels, feature-flag SDKs) out of the Studio canvas and preview sessions using the SDK's stable editor-mode contract: detect what the project loads, wrap each init in the right helper, verify. + +Use when the user reports a cookie banner / chat bubble inside the canvas, analytics counting author sessions, pixel conversions from editing, or asks to "hide X in Studio" / "gate scripts" / "detect canvas mode". Also run proactively at the end of a Studio install on any site that has GTM/OneTrust/chat scripts. Do NOT use for third-party scripts that crash the canvas with runtime-error overlays. That's `troubleshoot-canvas` § third-party overlays. + +> **Mandatory auth preflight: settle the credential before the first API call.** Resolve it OAuth-first per [`authenticate-cma`](authenticate-cma.md): `CS_OAUTH_ACCESS_TOKEN`, else the Contentstack MCP's stored session: Read that skill for where it is, even with no MCP connected. **Never ask the user for a session `authtoken`.** If nothing resolves, or a refresh fails with `400 invalid_refresh_token`, hand them `! CONTENTSTACK_REGION=<code> npx @contentstack/mcp --auth` (it needs a TTY and a browser, so it cannot be run for them) and wait. `403 error_code 316` is a valid credential aimed at another org: fix the org or the `api_key`, do **not** re-authenticate. + +# Gate third-party scripts out of the Studio canvas + +Reference: [Detect the Studio canvas](../../docs/40-recipes/detect-the-studio-canvas.md), the canonical recipe with all signals, semantics, and the decision table. This skill is the executable path. + +## Step 1: inventory what the project loads + +Grep the app for third-party inits: + +```bash +grep -rniE "onetrust|cookiebot|osano|intercom|drift|qualified|hubspot|posthog|mixpanel|amplitude|segment|gtag\(|googletagmanager|dataLayer|fbq\(|ttq\.|launchdarkly|splitio|split\.io" src app pages components --include="*.ts" --include="*.tsx" --include="*.js" --include="*.jsx" --include="*.html" -l +``` + +Classify each hit with the recipe's rule: analytics / flags / banners / chat / pixels get gated. Design-system CSS, font loaders, and SDKs the composed components need (maps, video players) do NOT. + +## Step 2: pick the gate per integration point + +| Integration point | Gate | +|---|---| +| JS init in app code (banner, chat, flags) | `if (!isStudioCanvas()) { … }` | +| Analytics / pixels in app code | `if (!isStudioEditorMode()) { … }` (preview sessions aren't visitors either) | +| GTM snippet in `<head>` (runs before SDK) | Inline `dataLayer.push({ cs_studio_canvas: new URLSearchParams(location.search).has("cs-composable-studio") })` BEFORE the GTM script, then tell the user to add the per-tag trigger condition `{{cs_studio_canvas}} equals false` in the GTM console. The SDK cannot do the GTM-console half | +| Declaratively injected widgets you can't reach in code | CSS: `html.cs-studio-canvas <widget-selector> { display: none !important; }` | +| Server-emitted script tags (SSR) | `isStudioCanvas(<request search params>)` (no-arg form is always `false` on the server) | + +Import from the package root: `import { isStudioCanvas, isStudioEditorMode } from "@contentstack/studio-react";`. Requires the SDK version shipping the editor-mode contract (PR #878+). On older versions, fall back to the query-param check only. + +## Step 3: apply + +Wrap each inventoried init. Do not delete or re-order third-party code, only wrap. For GTM, add the inline push snippet immediately above the GTM `<script>` and report the GTM-console trigger instruction to the user as a required manual step. + +| Pitfall | Why it bites | Fix | +|---|---|---| +| Gating with `window.self !== window.top` | Breaks when the site is legitimately embedded elsewhere. Not part of the contract | Use the SDK helpers / documented signals only | +| Gating analytics with `isStudioCanvas` | Live-preview sessions still fire page views | Use `isStudioEditorMode` for analytics/pixels | +| Checking `location.search` in app code | Client-side navigation strips the param. The check flips mid-session | Use the helpers: the `window.__CS_STUDIO_MODE__` marker survives navigation | +| Gating the design system or component-required SDKs | Canvas renders unstyled/broken components | Only gate the recipe's "No" rows | + +## Step 4: verify (acceptance) + +- [ ] Visitor route in a normal tab: banner + chat appear, analytics fires, `window.__CS_STUDIO_MODE__ === "visitor"`. +- [ ] Same route with `?cs-composable-studio=true`: nothing third-party fires, `<html>` has `cs-studio-canvas`, marker is `"canvas"`. +- [ ] Click an internal link inside the canvas (param disappears from URL): marker still `"canvas"`, gates still hold. +- [ ] If SSR: `curl` the page with and without the param, script tags present/absent accordingly. diff --git a/skills/studio/import-content.md b/skills/studio/import-content.md new file mode 100644 index 0000000..22ccd12 --- /dev/null +++ b/skills/studio/import-content.md @@ -0,0 +1,297 @@ +--- +name: import-content +--- + +## When to use + +Populate Contentstack Content Type entries from a customer-owned source: a JSON API, an internal JSON export, a CSV of records, an existing Contentstack stack, or a folder of Markdown files. Uses the Contentstack Management API (CMA) with idempotency + resumability so re-runs pick up where a failed run left off. Handles asset uploads for images referenced in the source. + +Use when Sections + Templates exist but need real content to render. Phrases: "populate entries from CSV", "seed content from JSON", "import content from our API", "ingest content into this CT", "backfill CMS content", "import Markdown blog posts". Do NOT use for schema provisioning (`provision-studio-stack`), fixture / dummy-data seeding (also `provision-studio-stack`), or one-off manual entry authoring (Contentstack UI is faster). **Migration from other CMS platforms (WordPress, Contentful, Sitecore, AEM, etc.) is out of scope**: Contentstack ships a dedicated CLI (`@contentstack/cli-cm-import`) with per-source connectors. Use that. This skill covers customer-owned data sources only. + +> **Auth preflight: settle the credential before the first API call.** Resolve it OAuth-first per [`authenticate-cma`](authenticate-cma.md): `CS_OAUTH_ACCESS_TOKEN`, else the Contentstack MCP's stored session: Read that skill for where it is, even with no MCP connected. **Never ask the user for a session `authtoken`.** If nothing resolves, or a refresh fails with `400 invalid_refresh_token`, hand them `! CONTENTSTACK_REGION=<code> npx @contentstack/mcp --auth` (it needs a TTY and a browser, so it cannot be run for them) and wait. `403 error_code 316` is a valid credential aimed at another org: fix the org or the `api_key`, do **not** re-authenticate. + +# Import content into Contentstack CTs: idempotent, resumable, asset-aware + +> ## Verification status +> +> This skill's mechanics are **runtime-verified end-to-end** against a live Contentstack stack. Reproducers committed under [`scripts/verify-import-*.ts`](../../scripts/). +> +> - `@contentstack/json-rte-serializer` (v3.1.0) confirmed real on npm. Use this package to convert HTML into JSON RTE. +> - CMA endpoints (`/v3/assets`, `/v3/content_types/<ct>/entries`, `/v3/content_types/<ct>/entries/publish`) match those used by [`provision-studio-stack`](provision-studio-stack.md) and [`author-composition-via-api`](author-composition-via-api.md). +> - Retry + rate-limit pattern, production-tested against Contentstack's Digital Concierge API: concurrency 2; retry 429 / 502 / 503 / 504 and network errors, honouring `Retry-After`, else exponential backoff with jitter (1s doubling to 30s); stop after 5 retries. +> - **Preview mode (Step 1a) runtime-verified**: the bundled sample JSON parses to exactly 3 posts / 2 unique authors / 2 embedded assets, and `@contentstack/json-rte-serializer` + `jsdom` produce a valid JSON RTE doc (with h2 heading, list, and inline `<img>` preserved) that round-trips back to HTML. Reproduce with `npx ts-node scripts/verify-import-preview.ts`. +> - **End-to-end run verified against a live stack**: provisions an isolated CT with a `source_uid` field, uploads an asset (`/v3/assets` multipart), POSTs 3 entries with JSON-RTE bodies, re-runs the same import and confirms **0 duplicates created** + same entry UIDs returned, then cleans up. 8/8 structural claims pass. Reproduce with `npx ts-node scripts/verify-import-e2e.ts` (requires `CS_API_KEY` + `CS_AUTH_TOKEN` in `.env`). +> - **`source_uid` field convention** works as documented. **Important:** the field UID MUST NOT start with an underscore: Contentstack rejects CTs whose field UIDs begin with `_` (error 115 "The UID must begin with a letter"). Use `source_uid`, not `_source_uid`. +> +> Rate limits (~10 uploads/sec, ~15 entries/sec) are documented defaults. Tune via `Retry-After` observation on your own plan. If you find a specific claim broken in practice, file a docs issue: the skill converges on the truth via real usage. + +## Context + +After [`decompose-design`](decompose-design.md) or [`decompose-site`](decompose-site.md) provisions schema and authors Sections + Templates, the CTs are empty. For a fresh brand launch that's fine: marketers author entries manually. For **bulk ingest** (an existing JSON feed, a CSV export from another system, a Markdown blog folder), a scripted import is table stakes. + +This skill handles the general case. Sources vary widely. The mechanics are the same: read the source, map the fields, upload the assets, POST the entries, track what succeeded, and resume on failure. + +## Task + +### Step 1: Get the source content from the user + +Ask the user exactly one of the following, in this priority order: + +1. **File path**: "Where's your source file?" (accepts `.json`, `.csv`, or a folder of `.md` files). Preferred: no copy-paste, full fidelity. +2. **Inline content**: "Paste the source content here if it's small." Useful for CSVs of ≤50 rows or a Markdown file. Not for anything with binary assets: you can't paste an image. +3. **API endpoint + auth**: "Which URL should I fetch from, and what auth?" For JSON APIs or an existing Contentstack stack. +4. **Preview mode**: "No file yet? Type `preview` to run the pipeline against a small bundled sample." Uses the fixture at the bottom of this skill to prove the transform + POST + idempotency chain end-to-end before you commit to a real import. + +If none of the above is supplied, do NOT infer: ask again explicitly. Silent invention of source data is the single most common failure mode of import scripts. Also collect: + +- **Target CT**: the Content Type UID to import into (from the site plan, from `provision-studio-stack`'s output, or explicit from the user). +- **Field mapping**: how source fields map to target CT fields. Propose a mapping from the source's field names. Confirm before proceeding. Never mutate without a confirmed mapping. + +### Step 1a: Preview mode (dry-run against a bundled sample) + +If the user chose `preview`, or explicitly asked "test this on a sample first," use the fixture below **without hitting any real Contentstack stack** and print each transform stage's output so the user sees what a real run would produce. This is the safety valve: verify the transform chain works before pointing it at production data. + +**Bundled sample JSON (3 posts, 2 authors, 2 assets):** + +```json +{ + "posts": [ + { + "id": "post-101", + "title": "Welcome to Studio", + "slug": "welcome-to-studio", + "excerpt": "A quick tour of Studio's role in modern content workflows.", + "body_html": "<h2>Composable content</h2><p>Studio lets marketers build pages from your registered components.</p><ul><li>Engineering registers components.</li><li>Marketers compose without a PR.</li></ul><p><img src=\"https://picsum.photos/id/1/800/400\" alt=\"Composable hero\" /></p>", + "author": "Alice Kim", + "published_at": "2026-02-03T12:00:00.000Z", + "status": "published", + "tags": ["product-news", "composable"] + }, + { + "id": "post-102", + "title": "Two-step content ingest", + "slug": "two-step-content-ingest", + "excerpt": "A hands-on look at bulk-loading content into Contentstack.", + "body_html": "<h2>Two-step ingest</h2><p>Export from your source, transform, POST to Contentstack.</p><p><img src=\"https://picsum.photos/id/2/800/400\" alt=\"Ingest steps\" /></p>", + "author": "Bob Rivera", + "published_at": "2026-02-12T09:00:00.000Z", + "status": "published", + "tags": ["how-to"] + }, + { + "id": "post-103", + "title": "Design tokens in Studio", + "slug": "design-tokens-in-studio", + "excerpt": "How Studio exposes your design tokens to marketers.", + "body_html": "<h2>Brand-consistent authoring</h2><p>Design tokens flow from your Tailwind config to Studio's design panel.</p>", + "author": "Alice Kim", + "published_at": "2026-02-24T14:00:00.000Z", + "status": "draft", + "tags": [] + } + ] +} +``` + +**Preview mode output (what to print without touching any stack):** + +``` +PREVIEW MODE — no writes to Contentstack + +Parsed 3 posts, 2 authors, 2 assets from sample JSON. + +Field mapping preview (against target CT `blog_post`): +| Source (JSON) | Target field | Sample from post-101 | +|---|---|---| +| title | title | "Welcome to Studio" | +| body_html | body (JSON RTE) | { doc: [{ type: "h2", children: […]}, …] } | +| excerpt | excerpt | "A quick tour of Studio's role…" | +| published_at | publish_date (ISO) | "2026-02-03T12:00:00.000Z" | +| author | author (ref) | → author "Alice Kim" (created if missing) | +| slug | url | "/blog/welcome-to-studio" | +| tags | tags | ["product-news", "composable"] | +| id | source_uid | "post-101" | +| <img src> | body inline asset | download picsum.photos/id/1 → upload → attach | + +Preview asset list (would download + upload, not doing either): + - https://picsum.photos/id/1/800/400 (referenced by post-101) + - https://picsum.photos/id/2/800/400 (referenced by post-102) + +Preview author resolution: + - "Alice Kim" — 2 posts (post-101, post-103). Would create author entry if not found. + - "Bob Rivera" — 1 post (post-102). Would create author entry if not found. + +Idempotency preview: + Second run over the same JSON: + - post-101 already imported → skip. + - post-102 already imported → skip. + - post-103 was a draft; already imported → skip. (Publish state ignored on re-run.) + +Ready to run for real? Provide: + 1. Path to your real source file, OR paste the export inline. + 2. Target stack UID + management token (env var CS_MANAGEMENT_TOKEN). + 3. Confirm the field mapping above matches your CT `blog_post` (or say what to change). +``` + +If the user confirms, proceed to Step 2 with their real file. If the user just wanted to see what the skill would do, the preview alone is enough: no writes have happened. + +**Real-file workflow:** everything from Step 2 onward runs on the user's actual file. Preview mode reuses every downstream step's logic so what you see in preview is what the real run does, no divergence. + +### Step 2: Emit an import plan + +Before any CMA writes, output a plan: + +``` +IMPORT PLAN +Source: blog-export.json — 342 posts +Target: Contentstack CT `blog_post` on stack `blt3f2a9d7…` + +Field mapping: +| Source | Target field | Transform | +|---|---|---| +| title | title | text as-is | +| body_html | body | html → json_rte (see conversion notes) | +| excerpt | excerpt | text as-is | +| featured_image_url | cover_image | download → upload asset → reference | +| slug | url | prepend "/blog/" | +| author | author | look up author entry by name, create if missing | +| tags | tags | flat list; drop taxonomy hierarchy | + +Assets: 342 featured images to upload (avg 180 KB, total ~62 MB). + +Resumable state file: docs/_import-state/blog_post-import-state.json +``` + +Wait for user approval before writing to the target stack. **Never write without an explicit go-ahead.** + +### Step 3: Idempotency setup + +Every import produces a state file at `docs/_import-state/<ct_uid>-import-state.json`: + +```json +{ + "source": "blog-export.json", + "target_ct": "blog_post", + "target_stack": "blt3f2a9d7...", + "started_at": "2026-...", + "records": { + "post-42": { "status": "imported", "entry_uid": "blt5a8...", "assets": ["blt6c1..."] }, + "post-43": { "status": "asset-upload-failed", "error": "...", "retried": 2 }, + "post-44": { "status": "pending" } + } +} +``` + +Every source record has a stable ID (JSON `id` field, CSV row number, Markdown filename). The state file maps each ID to its status. On re-run: + +- `status: imported` means **skip**. +- `status: pending` means attempt. +- `status: <error>` means retry (up to N times, configurable). + +**Idempotency check before every entry POST:** query CMA for an existing entry with the same source-ID stashed in a `source_uid` field (or a per-run configured key). If found, update instead of create. Never create duplicates. + +### Step 4: Asset upload with retry + +For every source asset (image, PDF, video): + +1. Check state file: already uploaded? Reuse the recorded `asset_uid`. +2. Not uploaded: download from source (if URL) or read from disk, POST to Contentstack CMA `/v3/assets`. Attach a `source_url` metadata field on the asset so re-runs match by that key. +3. Record `asset_uid` in the state file immediately after successful upload, before proceeding to the next asset. Never batch state writes. A crash mid-batch strands assets in an unrecorded state. + +Rate limit: CMA `/v3/assets` accepts ~10 uploads/sec. Concurrency of 2-3 is safe. Anything higher trips rate-limit-retry loops. Retry-after honoured on 429. + +### Step 5: Entry POST with idempotency + +For every source record: + +1. Check state file: already imported? **Skip.** +2. Transform source fields per the mapping. Apply source-format-specific converters: + - **HTML** (from any source that stores rich content as HTML strings) becomes JSON RTE via [`@contentstack/json-rte-serializer`](https://www.npmjs.com/package/@contentstack/json-rte-serializer) (v3.1.0 as of writing). Contentstack also ships a CLI plugin ([`@contentstack/cli-cm-migrate-rte`](https://www.npmjs.com/package/@contentstack/cli-cm-migrate-rte)) for bulk migration of HTML into JSON RTE on existing entries. Preserve heading levels, links, embedded images. + - **CSV**: text fields as-is, boolean coercion for known columns, date parsing. + - **Markdown**: parse via `remark` into JSON RTE. Preserve frontmatter as top-level fields. +3. Resolve references. For an `author` string, look up the existing `author` entry by name. If not found and the plan allows, POST a new one and stash its UID. +4. Substitute asset UIDs from Step 4 into image-referencing fields. +5. POST to CMA `/v3/content_types/<ct_uid>/entries`. Include `source_uid` in the entry payload for idempotency. +6. Record `entry_uid` in state file. Rate-limit: 15 entries/sec safe. Concurrency 2-3. + +### Step 6: Publish + +By default, imported entries are drafts. To go live: + +- Batch-publish all imported entries via CMA `/v3/content_types/<ct>/entries/publish` with the entry UIDs from the state file. +- Publish to the target environments (typically `preview` first, then `production` after human review). + +Ask before publishing to production: the confirmation step catches wrong-target-CT bugs before they hit customers. + +### Step 7: Emit a report + +At the end (or on cancel), print: + +``` +IMPORT COMPLETE +Source: blog-export.json — 342 records +Target: blog_post on blt3f2a9d7... + ✓ 338 imported (335 new, 3 updated from prior runs) + ✗ 4 failed: + - post-87: asset download 404 (source URL broken) + - post-134: json_rte conversion failed (malformed HTML) + - post-201: reference to unknown author "Guest Contributor" + - post-289: exceeded content-length limit + + → Failures preserved in docs/_import-state/blog_post-import-state.json. + → Re-run this skill to retry only failed records. +``` + +## Inputs needed from the user + +1. **Source**: exactly one of: + - **File path** (`.json`, `.csv`, or a folder of `.md`). **Preferred**: no copy-paste, full fidelity, handles binary refs. + - **Inline content pasted into chat**: for small imports (≤50 rows CSV, one Markdown file). Cannot handle binary assets: you can't paste an image. + - **API endpoint + auth**: for JSON APIs or an existing Contentstack stack (URL + bearer token / API key). + - **`preview` mode**: no file yet. Run against the bundled sample JSON to verify the transform + POST + idempotency chain works before you commit to a real import. +2. Target CT UID + target stack management token (from env or explicit). +3. Field mapping (skill proposes from the source. User confirms before any CMA write). +4. Any transformation rules the source needs (e.g. `strip HTML → keep only text`, `only import posts published after 2020-01-01`). +5. Whether to publish immediately or leave as drafts. Publishing to production requires an explicit confirm. + +**If none of the source options in #1 is supplied, ask again explicitly. Do NOT invent a source or silently proceed.** Silent invention of source content is the single most common failure mode of import scripts. + +## Acceptance + +- [ ] Source was supplied by the user (file path / inline content / API endpoint / `preview` mode). Skill did NOT invent a source silently. +- [ ] If `preview` mode: no CMA writes happened. Skill printed the parsed sample + proposed field mapping + asset list + idempotency preview. Verified before continuing to real data. +- [ ] An import plan is emitted + approved before any CMA writes. +- [ ] State file exists under `docs/_import-state/<ct_uid>-import-state.json` and is updated **before** each record's next step (asset then entry then publish). +- [ ] Every source record ends in one of four states: `imported`, `updated`, `skipped-existing`, `failed-with-reason`. No records disappear. +- [ ] Re-running the skill with the same inputs is idempotent: no duplicates created, no already-imported records touched, only pending / failed records attempted. +- [ ] Rate-limit retries honour `Retry-After` headers. +- [ ] Publishing to production requires an explicit confirmation prompt. +- [ ] Final report accounts for every source record. + +## Common pitfalls + +| Pitfall | Why it bites | Fix | +|---|---|---| +| No state file, no idempotency | A crash halfway through 500 entries creates 250 real entries + 250 orphans. Re-run creates 500 duplicates. | State file mandatory. Update after every step, not at end. | +| Batching state writes | Crash between batches strands writes in unrecorded state. | Serial state writes, one per record. Concurrency in CMA calls, not in state persistence. | +| Converting HTML into JSON RTE with naive string parsing | Loses formatting, breaks embedded images, mangles nested lists | Use `@contentstack/json-rte-serializer`. Never write your own. | +| Not attaching `source_uid` to imported entries | Second run can't detect duplicates, so it creates copies | Every CT that receives imports must have a `source_uid` (single-line text) field. Add it to the CT during `provision-studio-stack` if it isn't already. | +| Skipping the "publish to production" confirmation | Draft entries silently land on the live site on the wrong environment | Explicit prompt required before ProdPublish. | +| Ignoring rate limits | CMA 429s produce an auto-retry storm, which produces a longer 429, which cascades into failure | Concurrency 2-3, honour `Retry-After`, exponential backoff on unresolved 429. | + +## Handling per-source specifics + +| Source | Key considerations | +|---|---| +| **JSON export / API** | Paginate carefully. Some APIs limit to 100 records/page. Auth: bearer token or API key. Rate limits are source-specific. Convert HTML string fields into JSON RTE via `@contentstack/json-rte-serializer`. | +| **CSV** | Every column becomes one field. Type coercion needed (dates, booleans, references). No native references. Use ID columns + a two-pass import (entries first, then references). | +| **Existing Contentstack stack** | Bulk-fetch entries via Delivery API (fast) or CMA (respects draft state). If CTs mismatch between source and target, this becomes a schema migration, see [`migrate-ct-schema`](migrate-ct-schema.md). | +| **Markdown folder** | Frontmatter as top-level fields. Body via `remark` into JSON RTE. Filename becomes the slug. Handle image references (`![alt](./path.png)`): download local paths, upload as assets. | +| **Any other CMS (WordPress, Contentful, Sitecore, AEM, Drupal…)** | Out of scope. Use Contentstack's dedicated CLI: [`@contentstack/cli-cm-import`](https://www.npmjs.com/package/@contentstack/cli-cm-import) with the appropriate connector. | + +## See also + +- [`provision-studio-stack`](provision-studio-stack.md): creates the target CTs this skill populates. Run first. +- [`decompose-design`](decompose-design.md) / [`decompose-site`](decompose-site.md): plan the schema this skill fills. +- [`migrate-ct-schema`](migrate-ct-schema.md): if source and target schemas diverge, run this alongside to align. +- [`agent-idempotency`](../../docs/50-advanced/agent-idempotency.md): the state-file + resume pattern this skill uses is documented as the canonical convention for every skill making CMA writes. diff --git a/skills/studio/import-design-tokens.md b/skills/studio/import-design-tokens.md new file mode 100644 index 0000000..0d404cb --- /dev/null +++ b/skills/studio/import-design-tokens.md @@ -0,0 +1,161 @@ +--- +name: import-design-tokens +--- + +## When to use + +Read a project's design tokens from Tailwind, CSS variables, or JSON and register them with Studio so the Design Panel offers them for spacing, colors, typography, radius, shadow. + +Use when the user wants Studio's Design Panel to show THEIR tokens instead of Studio's defaults, e.g. "use my Tailwind colors in Studio", "register my CSS variables", "I have a tokens.json from Style Dictionary". Run AFTER `register-component`. Do NOT use to register breakpoints (use `register-breakpoints`). Do NOT use to register fonts as static assets, tokens only. + +> **Mandatory auth preflight: settle the credential before the first API call.** Resolve it OAuth-first per [`authenticate-cma`](authenticate-cma.md): `CS_OAUTH_ACCESS_TOKEN`, else the Contentstack MCP's stored session: Read that skill for where it is, even with no MCP connected. **Never ask the user for a session `authtoken`.** If nothing resolves, or a refresh fails with `400 invalid_refresh_token`, hand them `! CONTENTSTACK_REGION=<code> npx @contentstack/mcp --auth` (it needs a TTY and a browser, so it cannot be run for them) and wait. `403 error_code 316` is a valid credential aimed at another org: fix the org or the `api_key`, do **not** re-authenticate. + +# Import design tokens into Studio + +## Context + +Studio's right-panel **Design Panel** lets authors style component instances: spacing, color, typography. By default it shows Studio's built-in tokens (generic sans-serif, neutral grays). For a brand-consistent site, replace those with the customer's design system tokens. + +The mechanism is `registerDesignTokens(tokens, options)` from `@contentstack/studio-react`. **The payload is sectioned, not flat**: colors and spacing are top-level globals. Everything else nests under a section that maps to a Design-panel control. The full surface is in `docs/20-bring-your-own-components/studio-design-tokens-overview.md`. + +| Source concept | Goes in | Value shape | +| ------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | +| Colors | `colorTokens` (global) | any CSS color string: `"#0052ff"`, `"rgb()"`, `"hsl()"`, `"var(--…)"` | +| Spacing scale | `spaceTokens` (global) | a length **with a unit**: `` `${number}${unit}` `` (e.g. `"16px"`, `"1.5rem"`), a bare number is rejected | +| Font sizes / weights / line-heights | `typography.fontSize` (string), `typography.fontWeight` (**number**), `typography.lineHeight` (number or length), `typography.letterSpacing` (length) | per-field `Record<string, …>` | +| Named text styles (incl. font family) | `typography.style[name]` holds `{ fontFamily, fontWeight, fontSize, lineHeight, letterSpacing }` | `fontFamily` exists **only here**, not as a top-level token | +| Radius | `border.radius` | length with unit | +| Sizes (max-width, width…) | `size.maxWidth` (also `width`, `height`, `minWidth`, `minHeight`, `maxHeight`) | length with unit | +| Shadow | `shadow.style[name]` holds `{ inset?, x, y, blur?, spread?, color? }` (a structured object, **not** a CSS box-shadow string), plus `shadow.color` | one entry = one layer. A multi-layer shadow can't be expressed as a single token | + +> **Do not use the old flat shape** (`color`, `spacing`, `radius`, a typography object with a top-level `fontFamily`, or `shadow` as a box-shadow string). It does **not** compile against the installed SDK. `tsc` rejects it with "Object literal may only specify known properties". The section names above are the real ones. + +Tokens are read once at registration time. They don't hot-reload. If the user changes their Tailwind config, re-run this skill (or restart the app). + +Reference: `docs/20-bring-your-own-components/studio-design-tokens-overview.md`, `docs/30-composition/style-components-with-the-design-panel.md`. + +## Prerequisites: without these the panel stays empty or locked + +Registering tokens cleanly can still produce **zero visible effect**. Three conditions gate whether the author ever sees or can use them. Check all three: registering tokens against a project that fails any of them looks like a silent no-op. + +1. **`allowedValuesLevel` must be set to `"tokens"` (or `"arbitrary"`).** This is the decisive one. `registerDesignTokens(tokens)` called with **no options** leaves `allowedValuesLevel` at the SDK default `"dynamic"`, which permits **only data-binding**. Registered tokens never appear as selectable values, so every design control looks disabled. Always pass the options arg (Task step 4). The three levels (from `@contentstack/studio-registry` `AllowedDesignValues`): + - `"dynamic"` (SDK default): only data can be bound. **Tokens are not pickable** + - `"tokens"`: authors pick from your registered tokens (the design-system default, use this) + - `"arbitrary"`: tokens, data binding, **and** free-form CSS values +2. **"Enable Freeform Feature" must be ON** at the project level (in Studio, open Settings, then Configuration). The Design tab only renders with Freeform on. With it off the right panel collapses to **Settings only** and no component (not even Studio's built-ins) can be styled. (See `docs/30-composition/style-components-with-the-design-panel.md`. Toggle via `configure-studio`.) +3. **Custom components need a `styles` group** in their `registerComponent` config, or they show no Design controls even with Freeform on. Built-ins have styles implicitly. Your own components don't. (See `register-component` and `docs/20-bring-your-own-components/component-schema-prop-types.md`.) + +If the user reports "every field is disabled" or "there's no Design tab," walk these three in order: #1 explains disabled controls, #2 explains a missing tab on every component (built-ins included), #3 explains a missing tab on only their own component. + +## Task + +1. **Parse tokens from `tokenSource`.** + + **`tokenSource=tailwind`**. Import the config: + ```ts + import config from "../tailwind.config"; // or .ts + const theme = config.theme?.extend ?? config.theme ?? {}; + ``` + Pull `theme.colors`, `theme.spacing`, `theme.fontFamily`, `theme.fontSize`, `theme.borderRadius`, `theme.boxShadow`, `theme.maxWidth`. Tailwind uses nested objects for color shades (`{ primary: { 500: '#…' } }`). Flatten to a token name like `primary-500`. + + **`tokenSource=css`**. Parse `:root { --primary: …; }` rules from the CSS file. Map each `--*` var to a token name under the right section (user must annotate which `--*` vars are which scope, ask if unclear). + + **`tokenSource=json`**. Parse Style Dictionary / Theo / DTCG JSON. For DTCG `$value` references, resolve them to literals before registration (Studio doesn't follow `$value` chains). + + **`tokenSource=manual`**. Ask the user to paste the token list with explicit scope annotations. + +2. **Filter to the requested `scopes`** and **map each scope to its real section** (the user-facing scope names are friendly aliases): + + | `scopes` value | Target section | + | -------------- | ----------------------------------------------------------------------------------------------------------------------------------- | + | `color` | `colorTokens` | + | `spacing` | `spaceTokens` | + | `typography` | `typography.fontSize` / `.fontWeight` / `.lineHeight` / `.letterSpacing`, and `typography.style[name].fontFamily` for font families | + | `radius` | `border.radius` | + | `shadow` | `shadow.style[name]` (decompose each box-shadow into `{x,y,blur,spread,color}`) | + + Drop scopes the user didn't request. **Normalize lengths**: `spaceTokens` and `border.radius` values must carry a unit. A bare `8` is invalid, use `"8px"`. + +3. **Build the `registerDesignTokens` payload.** Use the sectioned shape: + ```ts + const tokens = { + colorTokens: { + primary: "#0052ff", + "primary-500":"#0052ff", + "neutral-50": "#f9fafb", + }, + spaceTokens: { xs: "0.25rem", sm: "0.5rem", lg: "1.5rem" }, + typography: { + fontSize: { body: "1rem", "heading-1": "3rem" }, + fontWeight: { regular: 400, bold: 700 }, // numbers, not strings + lineHeight: { tight: 1.1, normal: 1.5 }, + style: { + "heading-1": { + fontFamily: '"Inter", system-ui, sans-serif', // fontFamily lives ONLY here + fontWeight: "700", // string inside style + fontSize: "3rem", + lineHeight: 1.1, + }, + }, + }, + border: { radius: { md: "0.5rem", full: "9999px" } }, + shadow: { + style: { + card: { x: "0px", y: "1px", blur: "3px", color: "rgba(0,0,0,0.1)" }, + }, + }, + }; + ``` + +4. **Call it with an explicit `allowedValuesLevel`.** This is two-arg. The options arg is what makes the tokens usable, not just registered: + ```ts + registerDesignTokens(tokens, { allowedValuesLevel: "tokens" }); + ``` + Use `"tokens"` so authors pick from the registered set (the design-system default). Use `"arbitrary"` only if the user explicitly wants authors to also type raw CSS values. **Never** omit the options arg: the default `"dynamic"` leaves the controls unusable (see Prerequisites). To add your tokens on top of Studio's defaults instead of replacing them, also pass `allowDefaultDesignTokens: true`. Most enterprise installs want the default (replace) behavior. + +5. **Wire the call.** Add it to the same file as `registerComponent` calls (`src/register-components.tsx` or `src/register-studio.ts`). Call it **once**, before any component registrations OR before `<StudioCanvas />` mounts, whichever comes first. + +6. **Verify in Studio.** Confirm the three Prerequisites first (Freeform ON, the component has `styles`, `allowedValuesLevel` set). Then open a composition with a registered component selected, go to the **Design** tab, and the controls should offer the customer's tokens by name (e.g. "primary-500", "lg") and let you select them. If the tab is missing or every control is disabled, re-check the Prerequisites: that's the cause, not the token payload. + +## Inputs needed from the user + +1. `tokenSource`: required. Auto-detect a default if there's an obvious match (if `tailwind.config.{js,ts}` exists, suggest `tailwind`, and if `tokens.json` exists, suggest `json`). +2. `tokenSourcePath`: required for tailwind/css/json modes, skip for manual. +3. `scopes`: default to all five. + +If the user has tokens spread across multiple sources (e.g. Tailwind colors + a separate `typography.json`), invoke this skill once per source. + +## Acceptance + +- [ ] `registerDesignTokens(tokens, { allowedValuesLevel: "tokens" })` exists in the registration file: **two args**, options never omitted. +- [ ] Payload uses the sectioned shape (`colorTokens` / `spaceTokens` / `typography` / `border` / `shadow`), NOT the flat `color` / `spacing` / `radius` shape. +- [ ] `tsc` (or the project's build) compiles the payload with no "unknown property" errors. +- [ ] Color values are valid CSS color strings. `spaceTokens` and `border.radius` values carry a unit. `typography.fontWeight` values are numbers. +- [ ] Shadows are expressed as `{x,y,blur,spread,color}` objects, not box-shadow strings. +- [ ] No `$value` reference strings remain unresolved in JSON-source mode. +- [ ] Prerequisites confirmed: Freeform ON, the target component has a `styles` group, `allowedValuesLevel` set, and tokens are actually selectable in the Design panel when a registered component is selected. + +## Common pitfalls + +| Pitfall | Why it bites | Fix | +| ------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | +| Calling `registerDesignTokens(tokens)` with no options | `allowedValuesLevel` defaults to `"dynamic"`: only data-binding is allowed, so registered tokens aren't selectable and every control looks disabled | Always pass `{ allowedValuesLevel: "tokens" }` | +| Using the flat shape (`color`, `spacing`, `radius`, a box-shadow string) | Does not compile against the installed SDK. `tsc` errors with "Object literal may only specify known properties" | Use the sectioned shape: `colorTokens`, `spaceTokens`, `typography`, `border.radius`, `shadow.style` | +| Putting `fontFamily` at the typography top level | `fontFamily` only exists under `typography.style[name]` | Nest it: `typography.style.heading.fontFamily` | +| Bare numbers for spacing/radius (`8`, `0.5`) | `spaceTokens` / `border.radius` require `` `${number}${unit}` `` | Add the unit: `"8px"`, `"0.5rem"` | +| `"tokens"` level with no `size` tokens | Width and Max-Width inputs lock; the picker offers only Studio's built-in XS–XL, so a Box can't be sized (a Section Slot's Box, for one) | Register `size.maxWidth` from `theme.maxWidth` | +| Expecting one shadow token to hold a multi-layer box-shadow | `shadow.style[name]` is a single `{x,y,blur,spread,color}` layer | Register one entry per layer, or pick the dominant layer | +| Tokens registered but no Design tab appears at all | Freeform is off (right panel = Settings only) | Enable "Enable Freeform Feature" in Studio: open Settings, then Configuration (`configure-studio`) | +| Design tab appears for built-ins but not your component | Your `registerComponent` has no `styles` group | Add a `styles` group to the component (`register-component`) | +| Registering a `var(--…)` color not defined in the canvas-app stylesheet | Studio shows the token name but the canvas renders unstyled | Resolve the `var()` to a literal at import time, or load the same stylesheet in the canvas app | +| Importing Tailwind's full theme (defaults + extend) | Duplicate keys. First registration wins silently | Read `theme.extend`, or use `resolveConfig` and the merged result | +| Forgetting to re-run after editing the source file | Tokens are read once at startup | Add a comment in the registration file: "Re-run import-design-tokens after editing tailwind.config" | + +## See also + +- `docs/20-bring-your-own-components/studio-design-tokens-overview.md`: full token-surface reference (sectioned schema, CSS-variable emission) +- `docs/30-composition/style-components-with-the-design-panel.md`: what the Design Panel shows authors, the Freeform requirement +- `register-component`: register components FIRST, with a `styles` group, so the Design Panel has something to apply tokens to +- `configure-studio`: enable "Enable Freeform Feature" (gates the Design tab) +- `register-breakpoints`: separate skill for responsive breakpoints +- `register-json-rte`: separate skill for JSON RTE renderer (also a registry call but unrelated to tokens) diff --git a/skills/studio/install-contentstack-mcp.md b/skills/studio/install-contentstack-mcp.md new file mode 100644 index 0000000..92eea14 --- /dev/null +++ b/skills/studio/install-contentstack-mcp.md @@ -0,0 +1,329 @@ +--- +name: install-contentstack-mcp +--- + +## When to use + +Discover and verify the Contentstack MCP server before setup or provisioning API work. Prefer callable MCP operations. Install/configure the server when needed within the user's authorized scope. OAuth credentials and MCP execution are separate capabilities; `--auth` alone does not register tools in the current agent. See [`authenticate-cma`](authenticate-cma.md) for HTTP fallback credentials. + +Run discovery on whole-project setup and API-provisioning flows, including when OAuth is already authenticated. Respect a prior decline. If tools are unavailable, record why and use supported OAuth HTTP operations where possible; do not claim MCP was used. + +> **Auth preflight: settle the credential before the first API call.** Resolve it OAuth-first per [`authenticate-cma`](authenticate-cma.md): `CS_OAUTH_ACCESS_TOKEN`, else the Contentstack MCP's stored session: Read that skill for where it is, even with no MCP connected. **Never ask the user for a session `authtoken`.** If nothing resolves, or a refresh fails with `400 invalid_refresh_token`, hand them `! CONTENTSTACK_REGION=<code> npx @contentstack/mcp --auth` (it needs a TTY and a browser, so it cannot be run for them) and wait. `403 error_code 316` is a valid credential aimed at another org: fix the org or the `api_key`, do **not** re-authenticate. + +# Install Contentstack MCP: preferred API execution layer + +> **MCP-first applies to supported callable operations.** For operations absent from the discovered tool schemas, a declined installation, an unsupported region, or a connection that cannot be recovered in this session, record the limitation and use the OAuth HTTP path. Never invent an MCP tool or assume a capability table from an older release is current. + +## Discovery in the active host + +1. Identify the host from the active tool surface. In Codex, search the available/deferred tool catalog for Contentstack tools. In Claude Code, use its MCP inventory. Do not use `claude mcp list` as evidence about Codex. +2. Inspect discovered tool schemas and run a read operation against the intended stack/region. Only a successful read establishes readiness. A saved OAuth file, CLI login success, or `CONTENTSTACK_MCP_READY` note alone does not. +3. If no tools are callable, inspect the current host's supported MCP configuration mechanism before registering the server. Resolve the final stack and region first. Use the host's documented restart/reconnect behavior; do not invent commands or edit another IDE's configuration. +4. After login or restart, repeat discovery and the read probe. Record supported operations and any remaining HTTP fallback reasons, without credentials. + +The `claude mcp` commands below are Claude Code examples only. In other hosts, use their own configuration interface. CLI login commands shown with leading `!` use Claude's shell shortcut; omit `!` in an ordinary terminal. + +## MCP-first credential boundary + +For a whole-project Studio setup, run this discovery before asking the user to populate the app's `.env`: + +1. Check the active host for callable Contentstack MCP tools. +2. If tools are unavailable, offer to register/configure the server using the host's documented mechanism, then ask the user to complete the interactive OAuth login if needed. +3. Run a read probe against the intended stack before claiming MCP readiness. +4. Use the discovered tool schemas to determine which values and operations MCP can supply. + +MCP authentication and app runtime configuration are different. A successful MCP login does not write the app's `CONTENTSTACK_*` variables and does not prove that a Studio project is selected. The app still needs the runtime values required by [`install-studio`](install-studio.md), including the stack API key, delivery token, preview token, environment, locale mapping, and the selected Studio project's exact composition `contentTypeUid`. + +Do not request every runtime value up front if MCP can discover or create it. Ask only for a stack API key when the MCP's registration has none ([`understand-contentstack-mcp`](understand-contentstack-mcp.md)), a Base URL/origin that belongs to the user's app, an explicit choice when multiple Studio projects exist, or any other value absent from the discovered tool/API responses. Never infer a Studio project, environment, locale mapping, or composition content type UID from the repository name or the default `compositions` convention. + +The MCP server does not provide every Studio operation. At minimum, expect the Studio API project list/configuration (`/v1/projects`) and some token/Live Preview operations to require OAuth HTTP or the Studio UI. State that limitation and continue with the supported path; do not claim an MCP operation ran when no callable tool was discovered. + +## What it does + +Scope, and the things it deliberately does not cover, are in [`understand-contentstack-mcp`](understand-contentstack-mcp.md). Read that first if you are still deciding whether to install. + + +## Task + +### Step 0: Region gate (run BEFORE anything else) + +Do not offer the install, and do not run `--auth`, until the data center is settled. `DEFAULT_REGION` is `NA`, so "not set" silently means prod. + +1. **Determine the DC**: [`analyze-project-fit`](analyze-project-fit.md) § 2b: Determine the data center. If that already ran this session, reuse its answer. Don't ask twice. + +2. **Branch on the result:** + + | DC | What to set | Then | + |---|---|---| + | **Prod**: one of the 7 | `--region <CODE>` or `CONTENTSTACK_REGION=<CODE>` (`NA`/`EU`/`AU`/`AZURE_NA`/`AZURE_EU`/`GCP_NA`/`GCP_EU`. `US`, `AWS_NA`, `AWS_EU`, `AWS_AU` also accepted) | Continue to Step 1 | + | **Non-prod**: `<env>.<non-prod-domain>` | A valid region code **plus** base-URL overrides `CONTENTSTACK_CMA_BASE_URL=https://<env>-api.<non-prod-domain>`, `CONTENTSTACK_CDA_BASE_URL=https://<env>-cdn.<non-prod-domain>` (add the other `*_BASE_URL` vars for tool groups you use) | **Not Step 1.** `--auth` offers prod regions only and derives its authorize host from that pick, so it cannot mint a session here. Use raw `curl` with `$CS_AUTH`, or `csdx` with this DC's OAuth app credentials. See [`understand-contentstack-mcp`](understand-contentstack-mcp.md) § Non-prod data centers | + | **Unknown / user unsure** | - | **STOP.** Print the block below. Do not install, do not run `--auth` | + + **`<env>` is whatever the user says it is.** The prefix your team uses, `stag`, `eu-dev`, or any internal name their org runs are all equally valid. Substitute it verbatim into every `<env>-<service>.<non-prod-domain>` host. Never validate the name against a list, never "correct" it to one you've seen before, and never assume `<env>` because it appeared in a previous session. The only fixed vocabulary is the 7 **region codes**. The env prefix is free-form. + +3. **If the region can't be established, say this verbatim and stop:** + + > I can't set up the Contentstack MCP yet. I don't know which data center your stack is on, and the MCP defaults to **NA prod** when it isn't told. Authenticating now could point an OAuth session and every CMA write at the wrong data center, and the calls would succeed silently. + > + > Tell me one of these and I'll continue: + > - the URL you open Contentstack at: `app.contentstack.com`, a regional prod host, or `<env>-app.<non-prod-domain>`, or + > - the region code directly: `NA`, `EU`, `AU`, `AZURE_NA`, `AZURE_EU`, `GCP_NA`, `GCP_EU`. + > + > For a non-prod stack, tell me the env prefix exactly as it appears in your URL and I'll set `CONTENTSTACK_CMA_BASE_URL` / `CONTENTSTACK_CDA_BASE_URL` to those hosts and check they resolve before logging in. Note the env name is not a region value: `--region <env>` makes the CLI exit with `Unknown region`. + +3b. **Region vocabularies differ between tools. Don't copy one into the other blindly.** Runtime-verified: + + | Tool | Accepts | Rejects | + |---|---|---| + | **Contentstack MCP** | `NA`, `US`, `AWS_NA`, `AWS-NA`. `normalizeRegion` uppercases and converts `-` to `_`, so all four resolve to `NA` | a non-prod environment name becomes `undefined` and the CLI exits | + | **`csdx` Studio plugin** | **`AWS-NA` only**: its map is keyed with the hyphenated prefix | `NA`, which fails with "Region not configured" | + | **`csdx` core** | both `NA` and `AWS-NA` (identical hosts) | - | + + So **`AWS-NA` is the one value that satisfies all three.** Set that and neither tool complains. See [`configure-studio` § Select the Studio project](configure-studio.md#select-the-studio-project) for the plugin side. + + **`--auth` is interactive.** `login()` calls `selectRegion()` unconditionally (an inquirer prompt with no flag or env bypass) then opens a browser. No flag skips the menus: drive them through a PTY, or hand the user the command (Step 3). The browser login always needs the user. + +4. **Verify the host BEFORE triggering the login.** A confirmed answer is not a correct answer. A prefix from another environment, or a typo, produces a hostname that doesn't exist. Probe it unauthenticated. You are testing that the host resolves and a Contentstack API answers, not that you have access: + + ```bash + # Non-prod: substitute the user's own <env>. Prod: use the region's CMA host. + curl -sS -o /dev/null -w "%{http_code}\n" --max-time 10 \ + "https://<env>-api.<non-prod-domain>/v3/environments" + ``` + + | Result | Meaning | Action | + |---|---|---| + | `401` / `412` | Host exists and Contentstack is answering, it's rejecting you for missing auth, which is exactly right at this stage | Host is good; resume the Step 0 branch you picked | + | `000` / DNS or connection error | **That host does not exist.** Almost always a wrong or mistyped env prefix | Do NOT log in. Tell the user the exact hostname you tried, say it didn't resolve, and ask them to confirm the env prefix from their Contentstack URL | + | `200` | Unexpected without auth; note and continue | Same | + + Only after this probe passes do you install, and on prod run `--auth`. Confirming the region then authenticating against a nonexistent host wastes an OAuth round-trip and, worse, silently falls back to prod for anything you forgot to override. + +5. **Record the resolved region + any overrides** and reuse them in the `claude mcp add` config (Step 4). The same values must be present there, not only in your shell. An override that lives only in your shell means the MCP server process starts with prod defaults. + +### Step 1: Check if already installed — in THIS host, not by reflex + +Run § Discovery in the active host first. The command below is the **Claude Code** form only: + +```bash +claude mcp list | grep -i contentstack +``` + +In Codex or any other host, that command is not the inventory and its absence is not evidence: search the active tool catalog for Contentstack tools instead. Treating an empty `claude mcp list` as "not installed" on a non-Claude host is how an already-connected server gets re-offered and reconfigured. + +If Contentstack tools are present in whichever inventory applies, perform the read probe from the discovery section. Skip installation only after readiness is established, and skip **Step 2 entirely** — its offer is written for a host where nothing is installed. + +### Step 2: Offer (don't push) — only when discovery found nothing callable + +Tell the user verbatim: + +> The Contentstack MCP isn't installed. It's optional: provisioning works via curl, and `csdx auth:login --oauth` gives the same OAuth credential either way, so declining doesn't mean pasting a token. If you do install it (~2 min), you log in once via browser OAuth, and CMA steps (create CTs, entries, assets, publish) run as tool calls instead of curl. One heads-up: your login captures the org automatically, but you still supply the **stack API key** once. (This offer is for prod; non-prod logins go through `csdx`.) Registering a Studio project (`/v1/projects`) has no MCP tool. I'll try your OAuth token there first and only ask for anything else if the API rejects it. Want me to install it? <!-- style-lint: allow --> + +- **Yes**: go to Step 3. +- **No / not now**: reply `Got it — I'll use curl for provisioning this session, authenticating with your OAuth token (csdx auth:login --oauth if it isn't set up yet).` Stop. Do not re-prompt this session. +- **Tell me more**: expand the "What it does" section, then re-ask. + +### Step 3: Authenticate (OAuth) + +`--auth` walks **three arrow-key TTY menus in sequence** (`Select an action → Authorization`, then `Authorization actions → Login`, then `Select your Contentstack region → <region>`) and THEN prints the OAuth URL + calls `open()` to launch the browser. There is **no org-picker menu**: the org comes from your Contentstack login's default org. To target a different org, **switch your active org in the Contentstack web UI before logging in**. This is the ONLY way. (`CONTENTSTACK_ORGANIZATION_UID` does NOT help: the MCP takes the org from the OAuth token, not that env var, so it has no effect on CMA auth.) + +**Recommended: agent drives the menus via a PTY (the agent CAN open the browser for you).** The menus need a TTY, but `expect` allocates one, so the agent auto-selects the first two defaults, **navigates to the user's region** on the third menu, and lets the CLI open the browser. You only do the in-browser login. The region menu is 0-indexed (`NA=0, EU=1, AU=2, AZURE_NA=3, AZURE_EU=4, GCP_NA=5, GCP_EU=6`), so set `downs` to your region's index (do NOT assume NA): + +```expect +# cs-auth.exp — run: expect cs-auth.exp (background it) +set downs 0 ;# <-- SET to region index: NA=0, EU=1, AU=2, AZURE_NA=3, AZURE_EU=4, GCP_NA=5, GCP_EU=6 +set timeout 60 ;# short — menus render fast +spawn npx -y @contentstack/mcp --auth +expect "Select an action" ; send "\r" ;# Authorization (default) +expect "Authorization actions" ; send "\r" ;# Login (default) +expect "Select your Contentstack region" +for {set i 0} {$i < $downs} {incr i} { send "\033\[B" } ;# arrow-down to the chosen region +send "\r" +set timeout 1800 ;# 30 min — generous window for the human browser login (must exceed how long login takes) +expect eof ;# CLI prints URL, opens browser, waits for the localhost:8184 callback +``` + +Run it backgrounded, then read the log for `Opening browser…` + the `https://app.contentstack.com/…/authorize` URL (hand that URL to the user as a fallback if the auto-open is blocked). The user completes the login in the browser. The CLI's `localhost:8184` callback finishes it (`Authentication completed successfully`). If the login window may exceed 30 min (MFA/SSO enrollment), raise the second `set timeout` or use `set timeout -1` so the callback server isn't killed mid-login. + +**Fallback: user runs it in their own terminal** (no `expect`, or the agent can't spawn a PTY): the `!` prefix gives a real TTY: + +``` +! npx @contentstack/mcp --auth +``` +Then they arrow through Authorization, then Login, then their region, and log in. + +Either way, tokens are saved locally and auto-reused. The region + org are stored in the session. + +> **After authentication: select the Studio project before doing project-scoped work.** An org usually has several, and the wrong one fails silently (empty compositions list, empty Sections panel). See [`configure-studio` § Select the Studio project](configure-studio.md#select-the-studio-project). List them, confirm which when there's one, ask when there are several, and check the project's connected stack matches the app's. + +### Step 3c: Tokens last ONE HOUR. Check expiry before diagnosing any auth failure. + +**Runtime-verified.** `--auth` stores `expires_in: 3600`. The access token is valid for **one hour**. A token from an earlier session is therefore almost always dead, and the failure does not say so: + +| What you see | What it actually means | +|---|---| +| **401** `error_code 105`, "The provided access token is invalid or expired or revoked" | Usually just **expired**. Reads exactly like a wrong credential or wrong header, which is the trap | +| **403** on the CMA with a fresh token | Token is fine. That **stack is outside the OAuth'd org**. Different problem, different fix: switch the active org and re-auth, or use a stack in this org | +| **200** | Working. OAuth `Bearer` **is** valid for the CMA, verified with a fresh token | + +**Check expiry FIRST, before concluding anything about headers, schemes or scopes:** + +```bash +node -e ' +const os=require("os"),p=require("path"),fs=require("fs"); +const f=p.join(os.homedir(),"Library/Application Support/ContentstackMCP/oauth-config.json"); // macOS +const c=JSON.parse(fs.readFileSync(f,"utf8")); +const expiresAt=c.token_issued_at+c.expires_in*1000; +console.log(Date.now()>expiresAt + ? "EXPIRED "+Math.round((Date.now()-expiresAt)/60000)+" min ago — re-run --auth" + : "valid for "+Math.round((expiresAt-Date.now())/60000)+" more min");' +``` + +Config location: `~/Library/Application Support/ContentstackMCP/oauth-config.json` (macOS), `~/.config/ContentstackMCP/` (Linux), `%LOCALAPPDATA%\ContentstackMCP\` (Windows). + +**On expiry, say so plainly and re-authenticate. Don't debug the wrong layer.** Tell the user verbatim: + +> Your Contentstack session expired. The MCP's access token only lasts an hour, and yours was issued earlier. Nothing is misconfigured. I'll re-run the login. You'll just need to complete the browser step. + +Then re-run Step 3 (the `expect` PTY path drives the menus. Only the browser step needs the user). A `refresh_token` is stored alongside the access token, but a raw `curl` won't use it. Re-running `--auth` is the reliable path. + +**This is the single most misleading failure in the whole flow.** An expired token produced a 401 that looked like "OAuth doesn't work for the CMA", a wrong conclusion that survived until the expiry was checked. **Any 401 on a Contentstack API: check the clock before you touch the credential.** + +### Step 4: Register the MCP server + +**claude-code CLI:** +```bash +claude mcp add contentstack -e GROUPS=cma,cma-extended -e CONTENTSTACK_REGION=<resolved-region> -- npx -y @contentstack/mcp +``` + +`<resolved-region>` is the Step 0 region (`NA` for prod NA, etc.). **Pin it explicitly, see the callout below.** Omitting it lets a stray ambient value crash the server. + +**Claude Desktop / Cursor / VS Code (JSON config):** +```json +{ + "mcpServers": { + "contentstack": { + "command": "npx", + "args": ["-y", "@contentstack/mcp"], + "env": { "GROUPS": "cma,cma-extended", "CONTENTSTACK_REGION": "<resolved-region>" } + } + } +} +``` +Config locations: Claude Desktop (macOS) `~/Library/Application Support/Claude/claude_desktop_config.json` · Cursor `~/.cursor/mcp.json` · VS Code extension MCP panel. Restart the IDE / `claude` session so the server registers. + +> `GROUPS=cma,cma-extended` enables the write tools the provisioning skills need. Add `cda` only if a skill needs published-content reads. Do NOT set a `CONTENTSTACK_MANAGEMENT_TOKEN` when using OAuth. The stored session is the credential. + +> **Pin `CONTENTSTACK_REGION` in the add config: the MCP inherits your shell environment.** The stdio server inherits the ambient env. If you're in a repo whose `.env` (or your shell) sets `CONTENTSTACK_REGION` to a non-standard value (a DC prefix like `<env>`), the server reads THAT instead of your OAuth region and **throws `Unknown region "<x>"` at startup**, so `claude mcp list` shows `✘ Failed to connect — Connection closed` even though your OAuth session is valid and you never passed a bad region. This is the single most common "the MCP won't connect" cause on a machine that also runs a non-prod Contentstack app. `-e CONTENTSTACK_REGION=<resolved-region>` (CLI) or the `env` block (JSON) overrides the stray value. Diagnose via the connection log: `~/Library/Caches/claude-cli-nodejs/<project-path>/mcp-logs-contentstack/`. The newest `.jsonl` prints the exact `Server stderr: Error: Unknown region …`. Pin `CONTENTSTACK_API_KEY` the same way for the same reason once the target stack is resolved (Step 5). + +### Step 5: Choose the target stack (a loop of new-or-existing, then permission, then recover) + +The MCP operates on ONE stack (`CONTENTSTACK_API_KEY`), which **MUST belong to the OAuth'd org** (Step 3's captured `organization_uid`). A key from another org fails the smoke test (Step 6). Run this loop. It exits only when an existing key is supplied OR a new stack is created successfully. + +**Q1, ask after EVERY org selection** (first login AND after any "new org" re-auth below): "Create a new stack, or choose an existing stack?" + +- **Choose existing stack**: the user supplies an `api_key` (from [`install-playwright-mcp`](install-playwright-mcp.md) Step 5b `CS_RECENT_STACK_API_KEY`, or pasted). Confirm it's in the OAuth'd org, then **go to "Set the key" (loop exit).** + +- **Create a new stack**: the MCP has **no stack-create tool**, so use raw CMA `POST /v3/stacks` (header `organization_uid: <org>`, auth = OAuth access_token as `Bearer` (default) or, last resort, a session authtoken. Body `{ stack: { name, master_locale: "en-us" } }`). Confirm name + org first (real mutation). **Always attempt it: the create IS the permission check.** Branch on the response: + - **HTTP 201**: capture `api_key`, then **go to "Set the key" (loop exit).** + - **HTTP 403, `error_code 316`, `"You don't have the permission to do this operation."`** (verified): the user lacks stack-create rights in this org. Do NOT crash. **Q2 (ask: "No permission to create here) choose an existing stack, or a different org?"** (only these two. Admin-rights is an out-of-band footnote): + - **Choose existing stack**: the user pastes `api_key`, then **go to "Set the key" (loop exit).** + - **Choose different org**: **re-run Step 3, the browser re-opens.** Before logging in, the user switches their **active org** (Contentstack top-left switcher) to one where they can create (there's no in-flow org-picker, the login captures the active org). New org captured, so **loop back to Q1** (re-ask new-vs-existing for the new org). Repeat until exit. + +**Set the key** (loop exit): the server was registered keyless in Step 4, and **`claude mcp add` errors if the name already exists**, so **remove, then re-add** with the key (CLI), or edit the `env` block directly (JSON config), then restart and go to Step 6: + +```bash +claude mcp remove contentstack +claude mcp add contentstack -e GROUPS=cma,cma-extended -e CONTENTSTACK_REGION=<resolved-region> -e CONTENTSTACK_API_KEY=<blt...> -- npx -y @contentstack/mcp +``` + +For multi-stack work in one session, repeat this remove-then-re-add when switching stacks. + +> **Why a restart is needed, and how to have only ONE.** The stack is bound to `CONTENTSTACK_API_KEY` **at server-process start**. There is **no hot-reload** (changing the env on a running process has no effect), **no per-call stack override** (the tools take `branch`, not an `api_key`, verified), and **no `claude mcp reconnect/restart`** subcommand (only add/remove/list/get/login/logout). So changing the target stack ALWAYS requires the server to respawn = one session restart. This is a Claude Code + MCP limitation, not something this skill can auto-fix. **To keep it to exactly one restart: finish the Step 5 loop and resolve the FINAL stack BEFORE the restart, then register + restart once, never re-register mid-build.** Switching to a different stack later is one restart each. If you genuinely cannot restart (long continuous flow), **provision via raw `curl` + the OAuth Bearer token instead**: no MCP, no restart, at the cost of not using the MCP tools. + +### Step 6: Smoke test + +After a **session restart** (so the MCP's tools load into the agent's toolset), call a read tool: `get_all_content_types` (or `get_all_environments`) against the configured stack. Pass = returns real CT/env data. + +**Fail mode: `MCP error -32603: … Cannot read properties of undefined (reading 'data')`.** The server crashed on an undefined CMA response (no clean 401/403). Most often a **stale OAuth token, so re-auth (Step 3) for a fresh one** (the actual fix in testing). Otherwise the stack is in the wrong org/region, or is a non-prod stack. See the `-32603` and wrong-org rows in Common pitfalls for the full triage. + +Note `claude mcp list` showing `✔ Connected` only means the server process started. It does NOT confirm CMA calls work. The tool call is the real test. + +### Step 7: Write the session flag + +Record an in-conversation note downstream skills read. Get the org by reading `organization_uid` from the stored token (`~/Library/Application Support/ContentstackMCP/oauth-config.json`). **Use the exact flag names the provisioning skills + the Playwright hook already consume** (`CS_RECENT_STACK_API_KEY`, `CS_ACTIVE_ORG`). A differently-named key is never read: +``` +CONTENTSTACK_MCP_READY=true +CS_MCP_REGION=<region picked in --auth> +CS_ACTIVE_ORG=<organization_uid from the OAuth token> +CS_RECENT_STACK_API_KEY=<blt...> +``` +Downstream skills check `CONTENTSTACK_MCP_READY` and read `CS_RECENT_STACK_API_KEY`. If absent they fall back to curl authenticated with the **same OAuth token**, not to a pasted session token. + +### Step 8: Tell the user what's now automated + +Print: +``` +✅ Contentstack MCP installed + authenticated. + +Runs as tool calls now (no curl, no pasted authtoken): + • provision-studio-stack — global fields, CTs, entries, asset dedupe + publish + • provision-studio-project — compositions CT create + environment create + • author-composition-via-api — entry create/update + publish + +STILL raw HTTP (not in MCP scope) — same OAuth credential, sent as `Authorization: Bearer`: + • provision-studio-project — /v1/projects (Studio API), delivery+preview token, enable Live Preview, stack-create + • provision-studio-stack — asset binary UPLOAD (no MCP tool) + the CDA verify (delivery-host read) + • stack API key — you supply once (org is auto-captured by the login; MCP can't enumerate stacks) + • anything on a non-prod data center — raw HTTP with host overrides; MCP OAuth login is unavailable +``` + +## Inputs needed from the user + +1. `ide`: picks the config path. +2. `region`: the prod DC the stack lives in (drives the Step 3 region-menu selection). Must be one of the 7 prod regions. +3. `stackApiKey`: the target stack (in the OAuth'd org). From Playwright MCP's `CS_RECENT_STACK_API_KEY` or pasted once. Org is NOT an input. It's auto-captured by the login. +4. `existing vs new stack`: Step 5 branch. + +## Acceptance + +- [ ] Target stack confirmed **prod** (not a non-prod data center) BEFORE offering. +- [ ] User offered the recommendation in non-pushy framing, with the prod-only + "you supply the stack key" caveats stated. +- [ ] If declined: no further prompts this session. +- [ ] If accepted: `claude mcp list` shows `contentstack`. `--auth` completed (browser OAuth, region selected, org auto-captured). +- [ ] Stack key resolved (existing pasted, or new stack created) AND it belongs to the OAuth'd org. +- [ ] Smoke test (`get_all_content_types` / `get_all_environments`) returns real data, NOT just `✔ Connected`. On the `-32603 …'data'` crash, re-auth for a fresh token and retry. +- [ ] Session note written: `CONTENTSTACK_MCP_READY=true`, region, org, stack API key. +- [ ] User told which provisioning steps run as tools vs stay raw HTTP. + +## Common pitfalls + +| Pitfall | Why it bites | Fix | +|---|---|---| +| Passing `--region <env>` (or any non-prod name as a region) | Not a region value. `normalizeRegion` returns undefined and the CLI exits with `Unknown region` | Pass a **valid** region code and override the hosts instead: `CONTENTSTACK_CMA_BASE_URL` / `CONTENTSTACK_CDA_BASE_URL` -> `https://<env>-api.<non-prod-domain>` / `-cdn.<non-prod-domain>` | +| Diagnosing a 401 as a wrong credential when the token has simply **expired** | Access tokens live **1 hour**. The 401 text (`error_code 105`, "invalid or expired or revoked") gives no hint which. Sends you off testing headers and schemes that were never wrong | Check `token_issued_at + expires_in` first (Step 3c). If expired, tell the user the session expired and re-run `--auth` | +| Reading a **403** as an expired token | A fresh token returning 403 means the stack is outside the OAuth'd org, not an auth-lifetime problem | Switch the active org in the web UI and re-auth, or target a stack in the current org | +| Running `--auth` before the DC is settled | `DEFAULT_REGION` is `NA`, so an unset region silently authenticates against **NA prod**. The OAuth session and every CMA write land in the wrong data center and succeed | Run Step 0 first. Never `--auth` on an unknown DC | +| Region right, hosts left at defaults on non-prod | Region alone cannot reach non-prod: the map only produces prod endpoints | Set the `*_BASE_URL` override for every tool group you use, not just CMA | +| Smoke test crashes: `-32603 … Cannot read properties of undefined (reading 'data')` | Server got an undefined CMA response and didn't surface the real 401/403 | **Re-auth for a fresh token first** (the actual fix in testing). Else: stack not in the OAuth'd org, wrong region, or non-prod stack | +| Expecting to paste `org_id` | The OAuth token auto-captures `organization_uid`, asking for it is redundant | Read it from the token. Only the **stack** key is a manual input | +| Stack `api_key` from a different org than the login | CMA can't read it, which produces the `-32603` crash above | Use a key in the OAuth'd org. Or re-auth into the owning org (switch default org first, there's no org-picker in `--auth`) | +| `POST /v3/stacks` (new-stack) returns **HTTP 403, `error_code 316`** `"You don't have the permission to do this operation."` | The user lacks stack-create rights in that org (verified against a real no-permission org) | Show the clear message (Step 5): get rights / switch org (re-auth) / use existing. Don't crash or retry blindly | +| Assuming it provisions the Studio project | `/v1/projects` (Studio API) is out of scope. Only CMA + related | Keep `provision-studio-project` steps 3/5/9 (LP-enable, tokens, project register) on raw HTTP | +| Asking for a session authtoken **before** trying OAuth on `/v1/projects` | A session authtoken is the whole user session, every org and stack, and often isn't needed: OAuth `Bearer` works for `/v1/projects` where the Studio API accepts app tokens | Try `Authorization: Bearer <oauth-token>` first. Only on **401 `error_code 105`** (as `authtoken:`) or **422 `error_code 21`** (as `Bearer`) fall back, and prefer `csdx --oauth` or the Studio UI over pasting a session token | +| Sending the OAuth token as an `authtoken:` header | Wrong header for that credential class: 401 `error_code 105` | OAuth goes in `Authorization: Bearer`. `authtoken:` is only for session tokens | +| Expecting a delivery/preview-token tool | None exists ("requires manual setup") | Create tokens via raw CMA `POST /v3/stacks/delivery_tokens` (both env + branch scope) | +| Setting a management token alongside OAuth | Ambiguous credential. May target the wrong stack/region | With OAuth, omit `CONTENTSTACK_MANAGEMENT_TOKEN`. The stored session is the credential | +| `GROUPS` too narrow (`cma` only) | Some skills need CMA-extended (workflows/versions) | Register with `GROUPS=cma,cma-extended` | +| `--auth` dies instantly: `Error: User force closed the prompt with 0 null` | Run in a shell with **no TTY**. The arrow-key menus can't read input | Agent path: drive it via `expect` (Step 3: allocates a PTY, auto-selects the 3 menu defaults, `open()` launches the browser). User path: `! npx @contentstack/mcp --auth` for a real TTY | +| MCP installed but not restarted | Tools don't appear. Skills silently fall back to curl | Restart the `claude` session / IDE after `claude mcp add`. MCP tool schemas load at session start | +| Assuming CMA works because `claude mcp list` shows `✔ Connected` | "Connected" only means the server process started, not that CMA calls succeed | Confirm with an actual read tool (Step 6), not the connection status | +| Session flag lost to context compaction | Downstream skill re-prompts for authtoken mid-flow | Re-run the Step 6 smoke test when a provisioning skill starts rather than trusting the flag indefinitely | + +## See also + +- [`install-playwright-mcp`](install-playwright-mcp.md): discovers `org_id` + stack API key from the UI. The complement this MCP can't do. +- [`provision-studio-project`](provision-studio-project.md): CT-create + env-create route through MCP. `/v1/projects` + tokens stay raw HTTP. +- [`provision-studio-stack`](provision-studio-stack.md): CTs, entries, assets, publish all route through MCP tools. +- [`author-composition-via-api`](author-composition-via-api.md): entry create + publish via MCP. The `ui`/`data_sources` shape is unchanged. +- Reference: `@contentstack/mcp` on npm · `contentstack.com/docs/agent-os/contentstack-mcp-server`. diff --git a/skills/studio/install-live-preview.md b/skills/studio/install-live-preview.md new file mode 100644 index 0000000..70c6613 --- /dev/null +++ b/skills/studio/install-live-preview.md @@ -0,0 +1,197 @@ +--- +name: install-live-preview +--- + +## When to use + +Install and wire `@contentstack/live-preview-utils` standalone (without the full Studio SDK) so an existing app can receive real-time edits from Contentstack into Visual Editor. + +Use when the user wants ONLY Live Preview added to an existing Contentstack app, typically rolling out Visual Editor before adopting Studio. E.g. "I just want Live Preview, not Studio yet". Do NOT use for full Studio install (use `install-studio`, it includes LP). Do NOT use to fix LP in an already-installed app (use `troubleshoot-canvas`). + +> **Mandatory auth preflight: settle the credential before the first API call.** Resolve it OAuth-first per [`authenticate-cma`](authenticate-cma.md): `CS_OAUTH_ACCESS_TOKEN`, else the Contentstack MCP's stored session: Read that skill for where it is, even with no MCP connected. **Never ask the user for a session `authtoken`.** If nothing resolves, or a refresh fails with `400 invalid_refresh_token`, hand them `! CONTENTSTACK_REGION=<code> npx @contentstack/mcp --auth` (it needs a TTY and a browser, so it cannot be run for them) and wait. `403 error_code 316` is a valid credential aimed at another org: fix the org or the `api_key`, do **not** re-authenticate. + +# Install Live Preview standalone + +## Context + +Live Preview is the post-message channel between Contentstack and a running site. When an editor changes a field, the site re-fetches and re-renders within ~1 second. + +**Two separate things must be true** for Live Preview to work: + +1. **Stack-level flag**: to enable Live Preview for the stack, open **Settings**, go to **Visual Experience**, and open the **General** tab. This is a single checkbox. It's stack-wide, not per-CT. +2. **App-level install**: `@contentstack/live-preview-utils` is installed, `ContentstackLivePreview.init({…})` is called once at app shell, and the Delivery SDK is wired with `live_preview: { enable, preview_token, host }`. + +Most "Live Preview broken" issues are one of those two missing. This skill ensures both. + +**Preview Token vs Delivery Token**: the Preview Token is **paired** with a Delivery Token (same screen, same record). It's NOT a separate token type. When you create a Delivery Token, Contentstack generates the Preview Token at the same time. They share an environment scope. + +Reference: `docs/10-setup/app-prerequisites/install-live-preview.md`. + +## Task + +1. **HARD GATE: stack-level Live Preview must be ON.** Without the stack-level flag no `preview_token` resolves content, because the channel does not exist. + + **Check it yourself instead of asking**, the state is readable: + + ```bash + curl -s -H "api_key: <stack>" -H "$CS_AUTH" \ + "https://<cma-host>/v3/stacks/settings" | jq .stack_settings.live_preview + # {"enabled": true, "default-env": "blt…", …} → gate satisfied + # {} or enabled:false → not enabled yet + ``` + + If it is off, **enable it by API**: `POST /v3/stacks/settings`, and mint the preview token with `POST /v3/stacks/delivery_tokens?create_with_preview_token=true`. Both are scriptable. See [`enable-visual-experience` § Do it by API](enable-visual-experience.md#api-path) for the payloads and the three traps (the `PUT /v3/stacks` no-op, the query-param-not-body rule, and the required branch scope). + + Only fall back to walking the user through the UI when the write 401s with a **freshly refreshed** credential: an expired OAuth access token 401s identically, and raw `curl` never refreshes ([`authenticate-cma`](authenticate-cma.md)). Never assume the user has a session `authtoken` to paste. Never accept "I think it's on" as evidence. Read it. + +2. **Install the package.** Detect package manager from lockfiles: + - `pnpm-lock.yaml`: run `pnpm add @contentstack/live-preview-utils` + - `yarn.lock`: run `yarn add @contentstack/live-preview-utils` + - default: run `npm install @contentstack/live-preview-utils` + +3. **Write Preview Token to `.env.local`** (or whatever local-only env file the framework uses, which is `.env.local` for Vite, Next and CRA). Use the prefix the framework requires: + - Vite: `VITE_CS_PREVIEW_TOKEN=…` + - Next: `NEXT_PUBLIC_CS_PREVIEW_TOKEN=…` (only if used client-side. Otherwise no prefix and server-only) + - CRA: `REACT_APP_CS_PREVIEW_TOKEN=…` + + Confirm `.env.local` is in `.gitignore` before writing. + +4. **Update the existing Delivery SDK init** to include the `live_preview` block. Find the file that currently calls `contentstack.stack({ apiKey, deliveryToken, environment })`. **Derive the preview host from the region, do not hardcode `rest-preview.contentstack.com`.** It's the US host only. Every other region 401s against it. The map below covers **prod regions only**. **Non-prod (the non-prod domain your team uses) has no `region` shortcut**: every host is `<env>-<service>.<non-prod-domain>` and must be set explicitly. The full table is [`install-studio`](install-studio.md) § Non-prod hosts. Establish which data center you're on first via [`analyze-project-fit`](analyze-project-fit.md) § 2b: Determine the data center. + + ```ts + // Region → preview host. The US default is the ONLY region that uses the bare + // `rest-preview.contentstack.com` host; every other region prefixes it. + const PREVIEW_HOSTS = { + us: "rest-preview.contentstack.com", + eu: "eu-rest-preview.contentstack.com", + "azure-na": "azure-na-rest-preview.contentstack.com", + "azure-eu": "azure-eu-rest-preview.contentstack.com", + "gcp-na": "gcp-na-rest-preview.contentstack.com", + "gcp-eu": "gcp-eu-rest-preview.contentstack.com", + au: "au-rest-preview.contentstack.com", + } as const; + + const region = (import.meta.env.VITE_CS_REGION ?? "us") as keyof typeof PREVIEW_HOSTS; + // Non-prod wins over the map, which is prod-only: set VITE_CS_PREVIEW_HOST to + // <env>-rest-preview.<non-prod-domain>, and VITE_CS_CDA_HOST alongside it. + const previewHost = import.meta.env.VITE_CS_PREVIEW_HOST || PREVIEW_HOSTS[region]; + if (!previewHost) throw new Error(`Unknown region ${region}; on non-prod set VITE_CS_PREVIEW_HOST`); + + const stack = contentstack.stack({ + apiKey: import.meta.env.VITE_CS_API_KEY, + deliveryToken: import.meta.env.VITE_CS_DELIVERY_TOKEN, + environment: "preview", + ...(import.meta.env.VITE_CS_CDA_HOST + ? { host: import.meta.env.VITE_CS_CDA_HOST } // non-prod + : { region }), // prod + live_preview: { + enable: true, + preview_token: import.meta.env.VITE_CS_PREVIEW_TOKEN, + host: previewHost, + }, + }); + ``` + + Add `VITE_CS_REGION=<region>` (or `NEXT_PUBLIC_CS_REGION` / `REACT_APP_CS_REGION`) to `.env.local` so the host is derived at build/runtime, not hardcoded. Default to `us` when unset. On non-prod, set `VITE_CS_CDA_HOST` and `VITE_CS_PREVIEW_HOST` instead: `region` has no value that reaches those data centers, and leaving it to default sends every read to US prod. + +5. **Wire `ContentstackLivePreview.init({…})` at app shell.** This is the post-message listener that picks up Contentstack's edit events. Call it **once**, before any route renders: + + ```ts + // Import only with a preview token: the package draws its Edit button on import, even without init(). + if (previewToken) { + import("@contentstack/live-preview-utils").then(({ default: ContentstackLivePreview }) => + ContentstackLivePreview.init({ + stackSdk: stack, + enable: true, + ssr: false, // set true for Next.js / Remix / Astro server-side rendering + mode: "builder", + })); + } + ``` + + For Next.js / Remix / Astro / any SSR framework, set `ssr: true`. For Vite/CRA/single-page apps, leave `ssr: false`. + +6. **Verify `data-cslp` tags are emitted.** Open any page with a content binding, then open the **Elements** tab in DevTools. Look for `data-cslp="<ct_uid>.<entry_uid>.<locale>.<field>"` on every bound DOM element. If absent, the binding isn't actually using the Delivery SDK output: the app is hardcoding strings instead of reading from CS. + +7. **Wire `onEntryChange` so the running page refetches on edit.** `ContentstackLivePreview.init` opens the channel. `onEntryChange(cb)` subscribes a callback that fires every time an author saves an edit. Without this, the channel is open but nothing tells your app to re-render: visitors see stale content until a manual reload. **Wire it once, in an app-shell client component, using the loop-safe pattern below.** + + The callback body depends on how you render Studio compositions: + + - **CSR** (`useCompositionData` hook): call the hook's `refetchData` — the bound data is all a live edit changes. + - **SSR / RSC (Next.js App Router)**: call `router.refresh()`. + - **SSR (Next.js Pages Router)**: call `router.replace(router.asPath, undefined, { scroll: false })`. + - **SSR (Remix)**: call the route's `revalidator.revalidate()`. + - **Non-Studio pages that just read Delivery-SDK content**: call your own refetch / cache-invalidate. + + **Loop-safe pattern (mandatory).** `onEntryChange` invokes the callback ONCE at register time in addition to on every edit. If you subscribe inside a `useEffect(..., [router])` and the callback calls `router.replace` / `router.refresh`, the router identity changes, so the effect cleans up and re-subscribes, the register-time fire runs again, and the result is an infinite refetch loop. Always: **subscribe once with `[]` deps, keep the router in a `useRef`, skip the first (register-time) fire, unsubscribe in cleanup.** + + ```tsx + "use client"; + import { useEffect, useRef } from "react"; + import { useRouter } from "next/navigation"; // or next/router for Pages Router + + export function LivePreviewBridge() { // render it only when a preview token is set + const router = useRouter(); + const routerRef = useRef(router); + routerRef.current = router; // latest router without re-subscribing + useEffect(() => { + let unsub: any, firstFire = true, LP: any; + import("@contentstack/live-preview-utils").then(({ default: m }) => { LP = m; unsub = m.onEntryChange(() => { + if (firstFire) { firstFire = false; return; } // skip register-time fire + routerRef.current.refresh(); // App Router + // Pages Router: routerRef.current.replace(routerRef.current.asPath, undefined, { scroll: false }); + // CSR: refetchRef.current?.(); + }); }); + return () => { + if (unsub != null) LP?.unsubscribeOnEntryChange?.(unsub); + }; + }, []); // SUBSCRIBE ONCE — never depend on `router` + return null; + } + ``` + + Reference implementation: the SDK's own `composable-studio-sdk/test-resources/ssr-react/hooks/use-studio-spec-options.ts` uses the same `skipFirstClientFetchRef` pattern. + +8. **Live-test the channel.** Open the page in a browser. In another tab, open Contentstack and edit a field on the entry the page renders. The page should update within ~1s without a manual reload. If the Network tab shows the same route data endpoint (`_next/data/<id>/<route>.json` or the RSC payload) firing repeatedly for a single edit, your `onEntryChange` wiring isn't loop-safe. See step 7. + +## Inputs needed from the user + +1. `stackApiKey`: used in `stack({ apiKey })`. +2. `previewToken`: written to `.env.local`, used in `live_preview.preview_token`. +3. `environment`: must match a stack environment. +4. `region`: selects the host string. + +Don't ask for Delivery Token here: it's already configured (Live Preview is being added to an existing app that has the Delivery SDK). + +## Acceptance + +- [ ] `@contentstack/live-preview-utils` appears in `package.json` dependencies. +- [ ] `.env.local` has the Preview Token under the correct framework prefix. +- [ ] The existing Delivery SDK init now includes `live_preview: { enable: true, preview_token, host }`. +- [ ] `ContentstackLivePreview.init({…})` is called exactly once at app shell. +- [ ] Stack-level Live Preview is confirmed ON, **read back** from `GET /v3/stacks/settings` (`live_preview.enabled === true`), not taken on trust. +- [ ] **The Studio SDK init sets `cslp: { appendTags: true }`**: the renderer gates both attribute bags on it, so without it no element carries a tag however the components are written. See [`install-studio`](install-studio.md). +- [ ] At least one bound DOM element shows a `data-cslp` attribute in DevTools. +- [ ] `ContentstackLivePreview.onEntryChange(...)` is wired loop-safely: subscribe-once (`useEffect(..., [])`), router held in a `useRef`, register-time fire skipped, cleanup calls `unsubscribeOnEntryChange`. **Never** `[router]` deps. +- [ ] Editing a field in Contentstack updates the running app within ~1s. +- [ ] Network tab shows exactly ONE refetch per edit (not a hundreds-of-requests loop). + +## Common pitfalls + +| Pitfall | Why it bites | Fix | +| --- | --- | --- | +| Treating Preview Token as a separate token type | It doesn't exist independently: it's always paired with a Delivery Token | On the Delivery Token screen, look for "Preview Token" field below the Delivery Token. They share a record. | +| Forgetting the stack-level Live Preview flag | App is wired correctly but nothing flows because the stack-side channel is off | In the stack, open Settings, go to Visual Experience, open General, and toggle it ON before anything else | +| Using the wrong host string | Token and region mismatch produces 401 or empty responses | Use the region map above. Do NOT default to `rest-preview.contentstack.com` blindly | +| Calling `init()` per-route instead of at app shell | Listeners stack, messages process N times, performance degrades | Single `init()` call before first render, then never again | +| `ssr: false` in Next.js / Remix | Initial render misses preview data. Only hydration fixes it | Set `ssr: true` for any SSR framework | +| **`onEntryChange` wired in a `[router]`-dep effect** | Infinite `_next/data/<id>/<route>.json` (Pages) or RSC payload (App) refetch, hundreds of requests per edit | Subscribe ONCE with empty deps, hold router in a `useRef`, skip the first (register-time) fire, unsubscribe in cleanup. Full pattern in step 7. | +| **`onEntryChange` never wired** | Channel opens but page never updates on edit. Author sees stale content | Wire it in an app-shell client component (step 7). Applies to CSR AND SSR: SSR only "requires" it if Live Preview is enabled on that route. If a route has no Live Preview at all, skip both `ContentstackLivePreview.init` and `onEntryChange`. | +| Committing Preview Token to git | Token leak | Always to `.env.local` (in `.gitignore`). Never in source | + +## See also + +- `docs/10-setup/app-prerequisites/install-live-preview.md`: full reference +- `install-studio`: full Studio install which includes Live Preview (use this if the user wants Studio too) +- `verify-setup`: Layer 2 of the smoke test checks the Live Preview channel +- `troubleshoot-canvas`: "Live Preview not configured" symptom diff --git a/skills/studio/install-playwright-mcp.md b/skills/studio/install-playwright-mcp.md new file mode 100644 index 0000000..16c9006 --- /dev/null +++ b/skills/studio/install-playwright-mcp.md @@ -0,0 +1,295 @@ +--- +name: install-playwright-mcp +--- + +## When to use + +Optionally install the Playwright MCP server so downstream skills can drive the Contentstack web app, canvas app, and Studio directly, turning manual click-through gates into one-shot automated verification. + +Offer the FIRST time a user runs any setup/verification flow (`enable-visual-experience`, `install-studio`, `verify-setup`, `troubleshoot-canvas`) whose steps include "click X and confirm Y". If already installed (`claude mcp list`), skip silently. If user declines, skip for the session, never re-prompt. NOT a hard gate. + +> **Mandatory auth preflight: settle the credential before the first API call.** Resolve it OAuth-first per [`authenticate-cma`](authenticate-cma.md): `CS_OAUTH_ACCESS_TOKEN`, else the Contentstack MCP's stored session: Read that skill for where it is, even with no MCP connected. **Never ask the user for a session `authtoken`.** If nothing resolves, or a refresh fails with `400 invalid_refresh_token`, hand them `! CONTENTSTACK_REGION=<code> npx @contentstack/mcp --auth` (it needs a TTY and a browser, so it cannot be run for them), and wait. `403 error_code 316` is a valid credential aimed at another org: fix the org or the `api_key`, do **not** re-authenticate. + +# Install Playwright MCP: optional acceleration layer + +> **Recommendation, not a requirement.** Every downstream skill works without Playwright MCP via manual click-through. With it, the skill drives the web app and reports what it observed. We still ask before destructive toggles. + +## Why offer this + +Most Studio setup friction is tab-switching. With Playwright MCP installed: + +- `enable-visual-experience` can verify the live UI state directly (is the checkbox checked? did Save persist? did the top-nav link appear?) instead of trusting "done" +- `verify-setup` Layer 2 (Live Preview iframe channel) can be tested end-to-end without the user opening DevTools +- `troubleshoot-canvas` can read the actual canvas iframe DOM instead of asking the user to describe what they see +- `enable-visual-experience` Step 1 sanity-check ("top-nav `Visual Experience` link appears") becomes an assertion, not a prompt +- Stack provisioning skills can confirm tokens were actually created via the UI (CMA can't always see Preview Tokens) + +> **Confirmation policy:** any toggle that modifies stack settings (Enable Live Preview, Create Preview Token, Save buttons) goes through an explicit confirmation prompt before the MCP call fires. Playwright MCP reads the UI to confirm state, and asks before changing it. + +## Task + +### Step 1: Check if it's already installed + +Run one of: + +```bash +# claude-code CLI +claude mcp list | grep -i playwright + +# In VS Code extension or Claude Desktop — check the MCP servers section of the IDE settings +``` + +If `playwright` (or `@playwright/mcp`) shows up, skip the rest of this skill and print: `Playwright MCP already installed — downstream skills will use it automatically.` <!-- style-lint: allow --> + +### Step 2: Offer the recommendation (don't push) + +Tell the user verbatim: + +> I noticed Playwright MCP isn't installed. It's optional: every Studio skill works without it. But if you install it (~2 minutes), the next few setup steps go from "click here, type done" to "I clicked here, here's what I saw, ready for the next step." You stay in control: I still ask before changing any settings. Want me to install it now? <!-- style-lint: allow --> + +Three valid user responses: + +- **Yes / sure / install it**: proceed to Step 3. +- **No / skip / not now**: reply `Got it — I'll use the manual click-through paths for the rest of this session. You can install Playwright MCP later with this skill anytime.` Stop. Do not re-prompt this session. +- **What does it do? / tell me more**: expand on the "Why offer this at all" section above, then re-ask. + +### Step 3: Install + +Detect `{{ide}}` and run the right command: + +**claude-code CLI:** +```bash +claude mcp add playwright npx @playwright/mcp@latest +``` + +**Claude Desktop / VS Code extension:** +Open the MCP config file and add: +```json +{ + "mcpServers": { + "playwright": { + "command": "npx", + "args": ["@playwright/mcp@latest"] + } + } +} +``` +Config locations: +- Claude Desktop (macOS): `~/Library/Application Support/Claude/claude_desktop_config.json` +- Claude Code VS Code extension: `.vscode/settings.json` under `claude.mcpServers` (or the extension's MCP panel) +- Cursor: `~/.cursor/mcp.json` +- Windsurf: similar, check the IDE's MCP settings panel + +Restart the IDE / `claude` CLI session so the new MCP server registers. + +### Step 4: Configure browser mode + +Ask the user verbatim, presenting all three options with their honest tradeoffs: + +> Playwright MCP can run in three modes. **Pick based on how much you want MCP isolated from the rest of your browsing.** +> +> **Option A: Reuse your existing Chrome (default profile)** (fastest, lowest isolation) +> - **Upside:** no separate window, no extra Contentstack login. Your current session just works. +> - **Tradeoff:** while Chrome runs on the debug port, MCP can technically see every tab and logged-in site in that window (Gmail, GitHub, banking, etc.) and any local process on your machine can also attach to `localhost:9222`. In practice the skills only drive `app.contentstack.com`, but the capability is exposed for as long as Chrome runs with the debug port. +> - Best for: trusted dev machines, throwaway Contentstacks, when you'll close Chrome after the session. +> +> **Option B: Dedicated Chrome profile** (recommended balance) +> - A second Chrome window with its own empty profile (`~/.chrome-mcp`). Contentstack is the only site logged in there, so MCP literally can't see anything else. +> - **Tradeoff:** one extra login the first time. A second Chrome icon in your dock while the session is active. +> - Best for: production stacks, shared machines, when you want capability isolation without losing the "real browser" feel. +> +> **Option C: Isolated headless Chromium** (strongest separation) +> - Playwright MCP launches its own Chromium with no profile at all. +> - **Tradeoff:** slowest first-time setup. You log in fresh inside the headless browser every session (SSO can be fiddly). Visible UI debugging is harder. +> - Best for: CI-like flows, when you never want a visible MCP browser, when other developers run the same skill on the same machine. +> +> Which mode? (A / B / C) + +Record the answer as `{{chromeProfileMode}}`: +- A becomes `existing` +- B becomes `dedicated` +- C becomes `isolated` + +> **Chrome v136+ on org-managed devices: `existing` mode is often broken** +> +> Starting in Chrome 136 (April 2025), Chrome on MDM/policy-enrolled devices (Jamf, Intune, Workspace, etc.) refuses to start the `--remote-debugging-port` listener on the **default profile**. Symptoms: launch succeeds, window opens, but `lsof -i :9222` shows nothing listening. On a corporate laptop, assume `existing` will fail and pick **`dedicated`**: a dedicated `--user-data-dir` isn't subject to the policy. <!-- style-lint: allow --> + +For **existing** (`{{chromeProfileMode}}` = `existing`): **reuse the user's existing Chrome window and default profile** (no separate window, no extra login, your existing Contentstack session is reused as-is): + +- Fully quit Chrome (`Cmd+Q` on macOS, not just close the window, the background process must exit), then relaunch with the debug port on your default profile: + + | OS | One-liner | + |---|---| + | macOS | `open -a "Google Chrome" --args --remote-debugging-port=9222 --disable-features=LocalNetworkAccessChecks` | + | Linux | `google-chrome --remote-debugging-port=9222 --disable-features=LocalNetworkAccessChecks &` | + | Windows (PowerShell) | `& "C:\Program Files\Google\Chrome\Application\chrome.exe" --remote-debugging-port=9222 --disable-features=LocalNetworkAccessChecks` | + + > **Why `--disable-features=LocalNetworkAccessChecks`:** Chrome v136+ blocks iframes that load `localhost:<port>` from a non-local origin (e.g. Studio's canvas iframe loading `localhost:6846` from `app.contentstack.com`). Without this flag the canvas iframe shows up blank with `net::ERR_BLOCKED_BY_PRIVATE_NETWORK_ACCESS_CHECKS` in DevTools. The flag is required for any Studio canvas-iframe workflow, regardless of MCP. + + With no `--user-data-dir` flag, Chrome uses your normal profile, so every site you're already logged into (including Contentstack) stays logged in. + +- Assert the port is listening: + ```bash + lsof -i :9222 # macOS / Linux + netstat -ano | findstr :9222 # Windows + ``` + Must show Chrome bound to `127.0.0.1:9222`. If not, Chrome was still running in the background when you launched. Fully quit and retry. + +- Update the MCP config to attach to that endpoint: + ```json + "args": ["@playwright/mcp@latest", "--cdp-endpoint", "http://127.0.0.1:9222"] + ``` + + > **Use `127.0.0.1`, NOT `localhost`, in `--cdp-endpoint`.** Chrome's `--remote-debugging-port` binds **IPv4 `127.0.0.1`** only. Node/Playwright resolves `localhost` to **IPv6 `::1` first**, so `--cdp-endpoint http://localhost:9222` fails with `connect ECONNREFUSED ::1:9222` even though `lsof -i :9222` shows Chrome listening. The endpoint literal must be `http://127.0.0.1:9222`. (Changing it needs a session restart: the MCP server binds the endpoint at process start.) + +> **Tradeoff (be transparent with the user):** sharing the default profile means MCP can technically see all your open tabs and logged-in sessions in Chrome. In practice every Playwright action in this setup is scoped to a tab MCP opens itself (`app.contentstack.com`), and we never click on or read other tabs, but the capability is there. Don't browse banking / private email in this same Chrome window while a Studio session is active if that bothers you. Quit Chrome and skip MCP attach when you're done. + +For **dedicated** (`{{chromeProfileMode}}` = `dedicated`): a second Chrome window with its own empty profile, only Contentstack logged in: + +- Launch a second Chrome instance with the debug port AND a dedicated `--user-data-dir`. Your normal Chrome can keep running undisturbed. + + | OS | One-liner | + |---|---| + | macOS | `open -na "Google Chrome" --args --remote-debugging-port=9222 --disable-features=LocalNetworkAccessChecks --user-data-dir="$HOME/.chrome-mcp"` | + | Linux | `google-chrome --remote-debugging-port=9222 --disable-features=LocalNetworkAccessChecks --user-data-dir="$HOME/.chrome-mcp" &` | + | Windows (PowerShell) | `& "C:\Program Files\Google\Chrome\Application\chrome.exe" --remote-debugging-port=9222 --disable-features=LocalNetworkAccessChecks --user-data-dir="$env:USERPROFILE\.chrome-mcp"` | + + > Both flags matter: `--remote-debugging-port` for MCP to attach, `--disable-features=LocalNetworkAccessChecks` for Chrome v136+ to load the Studio canvas iframe served from `localhost`. + +- Assert the port is listening (same `lsof -i :9222` / `netstat` check as above). + +- Update the MCP config to attach: + ```json + "args": ["@playwright/mcp@latest", "--cdp-endpoint", "http://127.0.0.1:9222"] + ``` + > Use `127.0.0.1`, not `localhost`, see the IPv6 note in the `existing`-mode block above. + +- **First-time only**: the dedicated profile is empty. Step 5b prompts you to log into Contentstack once in that window. After that the profile keeps the session cookies, so subsequent sessions skip the login. + +For **isolated** (`{{chromeProfileMode}}` = `isolated`): +- No extra config. Default Playwright MCP launches its own Chromium. Skip the launch one-liner and the `lsof` assertion. The session-bridge step below still applies: the isolated Chromium has no Contentstack cookies, so you'll log in once inside it. + +### Step 5: Smoke test + +Run a 3-line smoke test from a fresh Claude turn: + +``` +Browser: navigate to https://app.contentstack.com +Then: snapshot the page +Then: report the page title +``` + +Pass: Claude returns a snapshot with the Contentstack login or stacks page title. + +Fail: see "Common pitfalls" below. + +### Step 5b: Session bridge (verify Contentstack is reachable as a logged-in user) + +This is the step that flips MCP from "installed" to "ready to auto-drive." Without it, the first downstream skill that tries to drive the UI hits the login screen and stalls. + +1. **Navigate to the Contentstack editor via Playwright MCP** and take a snapshot. Prod is `https://app.contentstack.com`. **On a non-prod DC it's `https://<env>-app.<non-prod-domain>`** (e.g. `<env>-app.<non-prod-domain>`). Driving the prod URL logs you into the wrong instance and every later step reads the wrong stack. Confirm the DC first via [`analyze-project-fit`](analyze-project-fit.md) § 2b: Determine the data center. + +2. **Assert which page we landed on.** Branch on the snapshot: + + | Snapshot URL | State | Action | + |---|---|---| + | `/#!/stacks` or contains a "Stacks" heading | Logged in | Proceed to step 3. | + | `/#!/login` or contains an SSO/login form | Not logged in | See step 2a below. | + | Region picker / `app.contentstack.com/login/saml` / org switcher | Partly logged in | See step 2a below. | + + 2a. **If not logged in**, tell the user verbatim: + > Playwright MCP opened Contentstack but landed on the login page. **Log in once in the Chrome window MCP is using** (the one with the debug banner). I'll wait: reply `logged in` when you're on the stacks list. + + After user replies `logged in`, re-snapshot and re-assert. If still on login, stop, report failure, and do NOT mark MCP ready. + +3. **Capture active org + most-recent stack.** With MCP, read the DOM: + - Active org: on the org dashboard (`/#!/dashboard`), read `[data-test-id="cs-dashboard-org-title"]`. The switcher is under the top-right profile icon. + - Most-recent stack: click into the stacks list, grab the **first row** (order isn't reliably recent-first; confirm it). Capture: + - Stack display name (visible row text) + - Stack API key (in the stack's Settings, under Stack, open API Credentials and copy the `Api Key` field. Or read it from the URL after entering: `/#!/stack/<apiKey>/...`) + +4. **Write to session note** (an in-conversation memory record visible to downstream skills in this session): + ``` + PLAYWRIGHT_MCP_READY=true + PLAYWRIGHT_MCP_MODE={{chromeProfileMode}} + CS_ACTIVE_ORG=<org name> + CS_RECENT_STACK_NAME=<stack name> + CS_RECENT_STACK_API_KEY=<blt...> + ``` + Downstream skills check `PLAYWRIGHT_MCP_READY` before deciding whether to use auto-drive paths or fall back to manual click-through, and pre-fill the `stackApiKey` input from `CS_RECENT_STACK_API_KEY` so the user isn't asked again. + +5. **Confirm the captured context with the user** verbatim: + > MCP is bridged to your Contentstack session. + > + > - Active org: **<org name>** + > - Most-recent stack: **<stack name>** (`<api key>`) + > + > Downstream skills will default to this stack. Reply `yes` to confirm, or paste a different stack API key. + + If user picks a different stack, overwrite `CS_RECENT_STACK_API_KEY` in the session note. + +### Step 6: Tell the user what's now automated + +Print: + +``` +✅ Playwright MCP installed and verified. + +These skills will now run with reduced click-through tax: + • enable-visual-experience — verifies checkboxes/saves/top-nav directly + • install-studio — confirms Studio's "Add Canvas URL" persists + • verify-setup — Layer 2 + Layer 3 + Layer 4 tested end-to-end + • troubleshoot-canvas — reads the canvas iframe DOM directly + • enable-personalize — verifies the Personalize app install path + +These still require your hands on the keyboard (CLI / file edits): + • install-studio (npm install + env files) + • setup-template-preview-routes (route file edits) + • author-without-code / build-section / build-connected-template (authoring decisions) + +I'll always ask before toggling any setting in the Contentstack web app — +Playwright MCP does NOT mean autonomous changes. +``` + +## Inputs needed from the user + +1. `ide`: to pick the right config path. +2. `chromeProfileMode`: existing (faster, shared session) vs isolated (cleaner, requires re-login). + +## Acceptance + +- [ ] User was offered the recommendation in non-pushy framing. +- [ ] If declined: no further prompts this session. +- [ ] If accepted: Playwright MCP appears in the IDE's MCP server list. +- [ ] Smoke test (navigate + snapshot + title) returns a real result. +- [ ] Session bridge verified: MCP-driven `app.contentstack.com` snapshot lands on stacks list (not /login). If user logged in mid-flow, re-snapshot confirms success. +- [ ] Session note written with `PLAYWRIGHT_MCP_READY=true`, active org, recent stack name + API key. +- [ ] User confirmed (or overrode) the captured default stack. +- [ ] User informed which skills accelerate and which don't. + +## Common pitfalls + +| Pitfall | Why it bites | Fix | +| --- | --- | --- | +| Treating this skill as a hard prerequisite | It's an accelerator. If we gate setup on it, users without admin rights to install MCP are blocked. | This is a SOFT recommendation. Decline = use manual click-through. Re-read the "This is a recommendation" header. | +| Re-prompting after the user declined | Annoying, trains the user to say no faster next time | Track decline in the session, never re-ask in the same conversation. | +| Installing without telling the user about session sharing (existing mode) | User assumes Playwright is sandboxed. Surprised when Claude sees their other tabs | Step 4 explicitly explains the tradeoff. Default to recommending `existing` but with eyes open. | +| Claude clicks Save without asking | Violates the "verify before mutating" contract | Every state-mutating click goes through an explicit `confirm before clicking` prompt in the calling skill. Playwright MCP is for READING state and EXECUTING confirmed actions, not autonomous mutation. | +| Playwright MCP installed but not verified | Skills assume it works, then fail mid-flow with cryptic MCP errors | Always run Step 5's smoke test. If it fails, don't tell downstream skills the MCP is ready. | +| `--cdp-endpoint` set but Chrome not launched with `--remote-debugging-port=9222` | MCP server can't attach. Falls back to isolated launch silently | Confirm Chrome is running with the debug port before declaring success. `lsof -i :9222` should show Chrome listening. | +| `--cdp-endpoint http://localhost:9222` fails with `connect ECONNREFUSED ::1:9222` even though `lsof -i :9222` shows Chrome listening | Chrome binds the debug port on **IPv4 `127.0.0.1`**. Node/Playwright resolves `localhost` to **IPv6 `::1`** first and connects to the wrong address | Use `http://127.0.0.1:9222` in `--cdp-endpoint` (never `localhost`). Re-add the MCP server with the corrected endpoint and restart the session: the endpoint binds at MCP-process start. | +| Reused-existing-Chrome (`existing` mode) silently never binds the port on a corp laptop | Chrome v136+ under MDM refuses `--remote-debugging-port` on the **default profile**: `lsof -i :9222` stays empty after launch | Switch to a **dedicated** `--user-data-dir="$HOME/.chrome-mcp"` profile (launched with `open -na`). The policy applies only to the default profile. One-time Contentstack login in that window. | +| Mac App Store Chrome | Sandboxed, can't use `--remote-debugging-port` reliably | Use the standalone Chrome download from google.com/chrome, not the App Store build. | +| Chrome v136+ refuses to debug the default profile on org-managed devices | MDM/policy blocks `--remote-debugging-port` on the default profile (silent failure: `lsof -i :9222` empty even though Chrome launched fine) | Use `dedicated` profile mode (`--user-data-dir="$HOME/.chrome-mcp"`). The policy applies to the default profile only. | +| Forgetting `--disable-features=LocalNetworkAccessChecks` on Chrome v136+ | Studio's canvas iframe (served from `localhost:<port>`) is blocked when loaded from `app.contentstack.com`. Canvas appears blank. `net::ERR_BLOCKED_BY_PRIVATE_NETWORK_ACCESS_CHECKS` in DevTools. | Add the flag to your Chrome launch one-liner (both `existing` and `dedicated` modes need it). Re-quit Chrome and relaunch with the flag. | +| Chrome didn't actually quit before relaunch | macOS keeps Chrome's background process alive after closing the window. The new launch silently ignores `--remote-debugging-port` because the existing process owns the profile | `Cmd+Q` to fully quit (or `pkill -x "Google Chrome"`), confirm `lsof -i :9222` is empty BEFORE launching, then run the one-liner. | +| Step 5b skipped because smoke test passed | Smoke test only confirms MCP can drive a browser. It doesn't confirm the browser has a Contentstack session | 5b is the gate, not 5. Don't mark `PLAYWRIGHT_MCP_READY=true` without 5b. | +| Captured stack from recently-accessed list is wrong | User most-recently opened a different stack than the one they're configuring now | Step 5b explicitly asks the user to confirm or override the captured API key. Never assume. | +| Session note lost across context compaction | Long sessions may compact older messages. The `PLAYWRIGHT_MCP_READY` flag and captured stack key can drop out | Re-run a lightweight version of Step 5b's check when a downstream skill starts (re-snapshot Contentstack, re-confirm session) rather than trusting flag indefinitely. | + +## See also + +- `enable-visual-experience`: biggest beneficiary, turns 6 manual steps into 6 verified observations. +- `verify-setup`: Layer 2/3/4 can be fully automated when this is installed. +- `troubleshoot-canvas`: reads canvas iframe DOM directly instead of asking user to describe. +- Reference: Playwright MCP project, `@playwright/mcp` on npm. diff --git a/skills/studio/install-studio.md b/skills/studio/install-studio.md new file mode 100644 index 0000000..09e7706 --- /dev/null +++ b/skills/studio/install-studio.md @@ -0,0 +1,598 @@ +--- +name: install-studio +--- + +## When to use + +**Compatibility question?** "Can I install Studio", "is my project compatible", "check before I install": Read [`analyze-project-fit`](analyze-project-fit.md) instead, not this skill. + +Install Contentstack Studio + Live Preview + Delivery SDK in this project, configure them with stack credentials, and wire studioSdk.init at the app shell. + +Use when the user wants to add Contentstack Studio to an existing React app (Next.js, Vite, CRA, Remix, Astro): "install Studio in my app", "add Studio SDK". Installs the three SDKs (delivery-sdk, live-preview-utils, studio-react), validates credentials, wires side-effect init. Does NOT add the canvas route. Pair with `setup-section-preview`. + +> **Mandatory auth preflight: settle the credential before the first API call.** Resolve it OAuth-first per [`authenticate-cma`](authenticate-cma.md): `CS_OAUTH_ACCESS_TOKEN`, else the Contentstack MCP's stored session: Read that skill for where it is, even with no MCP connected. **Never ask the user for a session `authtoken`.** If nothing resolves, or a refresh fails with `400 invalid_refresh_token`, hand them `! CONTENTSTACK_REGION=<code> npx @contentstack/mcp --auth` (it needs a TTY and a browser, so it cannot be run for them) and wait. `403 error_code 316` is a valid credential aimed at another org: fix the org or the `api_key`, do **not** re-authenticate. + +# Install Studio in this project + +## Context + +Install three SDKs together, they're effectively one install: + +- **`@contentstack/delivery-sdk`**: reads published content +- **`@contentstack/live-preview-utils`**: real-time updates (uses **Preview Token**, not Delivery Token) +- **`@contentstack/studio-react`**: Studio bridge (`studioSdk`, `<StudioCanvas />`, `<StudioComponent />`) + +Each Delivery Token record on the stack carries three credentials: **Stack API Key**, **Delivery Token**, and an automatically-paired **Preview Token**. Don't ask the user to "create a preview token" separately. + +Two Studio React components, NOT interchangeable: + +- **`<StudioCanvas />`** mounts on the **Canvas route** (e.g. `/studio-canvas`). Previews Sections in Studio's iframe. +- **`<StudioComponent />`** mounts on **ONE catch-all route** (`app/[[...slug]]/page.tsx` for Next.js App Router, `<Route path="*">` for React Router). Studio resolves every URL via `sdk.fetchCompositionData({ url, searchQuery })`. No per-template routes needed. + +This skill installs SDKs + wires global init. Adding the `<StudioCanvas />` route is a separate skill (`setup-section-preview`). + +## Regional host map: fill in BOTH CDN host and Live Preview host + +The Delivery SDK takes a CDN host. Live Preview takes a different, region-specific preview host. The `us` defaults you get if you omit them work **only** for North America stacks. Non-US stacks need both filled in. + +| Region | CDN `host` (Contentstack.stack) | `live_preview.host` (preview channel) | +|---|---|---| +| `us` | (omit, default) | `rest-preview.contentstack.com` | +| `eu` | `eu-cdn.contentstack.com` | `eu-rest-preview.contentstack.com` | +| `azure-na` | `azure-na-cdn.contentstack.com` | `azure-na-rest-preview.contentstack.com` | +| `azure-eu` | `azure-eu-cdn.contentstack.com` | `azure-eu-rest-preview.contentstack.com` | +| `gcp-na` | `gcp-na-cdn.contentstack.com` | `gcp-na-rest-preview.contentstack.com` | +| `gcp-eu` | `gcp-eu-cdn.contentstack.com` | `gcp-eu-rest-preview.contentstack.com` | +| `au` | `au-cdn.contentstack.com` | `au-rest-preview.contentstack.com` | + +For projects that already use `@timbenniks/contentstack-endpoints` or a similar helper, prefer that. It returns both hosts from a single `region` and stays current as Contentstack adds regions. + +## Non-prod environments + +QA, staging and internal dev stacks sit on their own domain with no `region` shortcut, so every host needs an explicit override. + +**Ask for both halves; never guess.** The pattern is `<env>-<service>.<non-prod-domain>`, both halves organization-specific. Read them off the user's Contentstack URL, or ask: + +> Which environment is this stack on, and what host do you open Contentstack at? I need the prefix and domain exactly as they appear in your URL. + +| Service | Non-prod host pattern | Used by | +|---|---|---| +| CDA (Delivery) | `<env>-cdn.<non-prod-domain>` | `Contentstack.stack({ host })` | +| Live Preview | `<env>-rest-preview.<non-prod-domain>` | `live_preview.host` | +| CMA | `<env>-api.<non-prod-domain>` | Management scripts, provisioning | +| Studio API | `<env>-composable-studio-api.<non-prod-domain>` | Studio project creation, project listing | +| Editor (browser) | `<env>-app.<non-prod-domain>` | The Studio UI the user opens | +| Images CDN | `<env>-images.<non-prod-domain>` | Asset URLs in entry responses | + +**Required `host:` override.** The Studio React SDK derives its Studio API host from whatever Delivery stack you pass it. **If you don't set `host:` on `Contentstack.stack({...})`, it defaults to the US prod CDA and the Studio SDK then points at prod Studio API too. All your `<env>` work goes to prod.** Set the CDA host explicitly: + +```ts +const stack = Contentstack.stack({ + apiKey, deliveryToken, environment, + host: `${csEnv}-cdn.${csHostSuffix}`, // <-- required; e.g. <env>-cdn.<non-prod-domain> + live_preview: { + enable: true, preview_token, + host: `${csEnv}-rest-preview.${csHostSuffix}`, + }, +}); +``` + +Read `csEnv` and `csHostSuffix` from env vars (`VITE_CS_NON_PROD_ENV` / `VITE_CS_NON_PROD_DOMAIN`). `csHostSuffix` is the domain the user gave you. The env Base URL (Settings, then Environments) must point at the running app, same as prod. + +## Existing Contentstack app? Take the minimal-add branch + +Before doing anything destructive, check whether the user already has `@contentstack/delivery-sdk` AND `@contentstack/live-preview-utils` installed and a stack init somewhere in the codebase. If yes, **take the minimal-add branch** instead of emitting a new `lib/contentstack.ts`: + +- Search for `Contentstack.stack({` or `contentstack.stack({`. The existing stack factory is what to reuse. +- Search for `ContentstackLivePreview.init(`. If present, do NOT call `init()` again. Doing so double-initializes the channel. +- Add `@contentstack/studio-react` only. +- Create a SEPARATE module (e.g. `lib/studio.ts`) that imports the existing `stack` and calls `studioSdk.init({ stackSdk: stack, contentTypeUid: "<studioContentTypeUid>" })`, using the uid resolved in § 0c. Export `sdk` from there. This branch needs § 0c exactly as much as the greenfield one does. +- Do not modify the existing `lib/contentstack.ts` (or equivalent module). + +The full emit would clobber a custom stack factory. If NEITHER package is installed, proceed with the greenfield emit below. + +> **Hazard: reusing the existing stack instance means Studio shares its Live Preview state with everything else that touches it.** `stack.livePreviewQuery(...)` doesn't scope to one call: it mutates a single shared object (`_client.stackConfig.live_preview` in `@contentstack/delivery-sdk`, the equivalent on the legacy `contentstack` v3 SDK) that lives on the stack instance itself, not on the request. If the app's OWN code calls `.livePreviewQuery(...)` on that SAME stack anywhere else — even with a legitimate, unrelated reason, e.g. a parent layout's own data fetch doing `stack.livePreviewQuery(live_preview ?? {})` with no override — it resets that shared state to empty. Next.js (and most SSR frameworks) run a page's layout(s) and the page itself concurrently on one request, so that reset can land in between two of Studio's OWN internal composition-resolution queries for the SAME lookup, flipping some of them back to the Delivery API mid-resolution with no code change and no error. Symptom: Live Preview intermittently doesn't apply — see [`troubleshoot-canvas`](troubleshoot-canvas.md) § Live Preview state resets mid-request for the full diagnosis. **If the app makes its own `.livePreviewQuery(` calls elsewhere on the same stack, give Studio's `stackSdk` its own independent stack instance instead of reusing the app's.** Reuse the app's Delivery Token/credentials to construct it, just not the same object. + +## Task + +### 0. FIRST: run `analyze-project-fit` + +Before this skill changes anything, Read `analyze-project-fit` and print its summary block. A hand inspection doesn't count. It picks the path: + +- **Path A (Greenfield-friendly)**: continue with this skill from §0a +- **Path D (Existing-app minimal-add)**: continue with this skill from §0b minimal-add branch +- **Path B / C / G (blockers: stale SDK pin on React 19, React 17, duplicate React)**: STOP. Resolve the blocker, re-run `analyze-project-fit`, then return here. + +Skip only if it already ran this session. + +### 0a. STACK-LEVEL PRE-FLIGHT: Visual Experience must be enabled (do NOT skip) + +Confirm **Live Preview is enabled at the stack level**: read `stack_settings.live_preview.enabled` from `GET /v3/stacks/settings` rather than asking, and enable it by API if it is off ([`enable-visual-experience` § Do it by API](enable-visual-experience.md#api-path)). Otherwise every step succeeds locally and **silently fails at runtime** (blank canvas, no `data-cslp` tags). + +If the user has never run `enable-visual-experience` for this stack, call it now and wait for its acceptance. If they insist it's already set up, ask them to confirm explicitly: "In Settings, under Visual Experience, on the General tab, `Enable Live Preview` is checked + Saved (yes/no? Delivery Token has a Preview Token) yes/no?" If unsure, run `enable-visual-experience`. + +### 0b. PRE-FLIGHT GATE: non-greenfield detection (do NOT skip) + +Scan for an existing Contentstack integration. If found, **switch to the minimal-add branch**. The full emit will clobber a custom stack factory. + +Check ALL signals in parallel: + +```bash +# Existing stack factory +grep -rEn "Contentstack\.stack\(|contentstack\.stack\(" --include='*.ts' --include='*.tsx' --include='*.js' --include='*.mjs' src/ app/ lib/ pages/ 2>/dev/null | head -5 + +# Existing Live Preview init (NEVER call init() twice) +grep -rEn "ContentstackLivePreview\.init\(" --include='*.ts' --include='*.tsx' --include='*.js' --include='*.mjs' . 2>/dev/null | head -5 + +# Existing Contentstack-config module (common names) +ls src/lib/contentstack.* lib/contentstack.* src/contentstack.* contentstack.config.* 2>/dev/null + +# package.json — Delivery SDK + Live Preview already installed? +grep -E "@contentstack/delivery-sdk|@contentstack/live-preview-utils" package.json | head -3 +``` + +**Branching rule:** + +| Signal | Branch to take | +|---|---| +| Both `@contentstack/delivery-sdk` AND `@contentstack/live-preview-utils` already in `package.json` | **Minimal-add**: `npm install @contentstack/studio-react` only, create separate `lib/studio.ts` (see § Existing Contentstack app above) | +| Only one of the two installed | Ask user, likely partial install in progress. Do NOT auto-install the other | +| **Existing `lib/contentstack.ts`** (or similar) with a stack factory | **Minimal-add**: even if package.json looks "clean," the project may have a custom stack module. Don't overwrite. | +| **Existing `ContentstackLivePreview.init(...)` call** anywhere in the codebase | **Minimal-add**: adding a second `init()` double-initializes the channel and breaks Live Preview. | +| NEITHER package installed AND no existing stack module | **Greenfield**: proceed with the full emit below | + +When in doubt, STOP and ask: "I found `<file>` with `<signal>`, looks like an existing Contentstack integration. Add Studio as a minimal addition (separate `lib/studio.ts` importing your existing stack)?" Default to YES. + +### 0c. PRE-FLIGHT GATE: resolve the Studio project, then read its content type (do NOT skip) + +`studioSdk.init({ contentTypeUid })` has to name the **same compositions content type the Studio project points at**. That uid belongs to the project, not to the stack: one stack can host several Studio projects, each with its own `contentTypeUid` (`compositions_first`, `compositions_second`, `<user>_compositions`, …). Defaulting to `"compositions"` is therefore a guess, and it fails late and confusingly. Studio raises **"Content Types Don't Match"** over the canvas — *Expected* is the project's uid, *Actual* is whatever the SDK asked for — and loads nothing at all. + +**If the user named a Studio project, use it. If they didn't, ASK — never default.** List the projects rather than making them find and paste a uid: + +```bash +curl -s "https://<studio-api-host>/v1/projects" \ + -H "Authorization: Bearer <oauth-access-token>" \ + -H "organization_uid: <org-uid>" | jq -r \ + '.projects[] | "\(.uid) \(.name) stack=\(.connectedStackApiKey) ct=\(.contentTypeUid)"' +``` + +`<studio-api-host>` is `composable-studio-api.contentstack.com` on prod NA (`eu-`, `azure-na-`, `azure-eu-`, `gcp-na-`, `gcp-eu-` prefixes for the other regions), and `<env>-composable-studio-api.<non-prod-domain>` on a non-prod DC (§ Non-prod environments). The auth ladder, plus two token-free ways to enumerate projects (`csdx studio:project:set` with a deliberately invalid id, and Studio's own project switcher), are in [`configure-studio`](configure-studio.md#select-the-studio-project). Don't re-derive them here. + +**Branch on the count:** + +| Projects found | What to do | +|---|---| +| **0** | There is nothing to point the SDK at. Run [`provision-studio-project`](provision-studio-project.md) first, then come back | +| **1** | Use it, and **state which** ("Using project `<name>` (`<uid>`), content type `<contentTypeUid>`"). Never adopt it silently | +| **2 or more** | **STOP and ask.** Print a numbered list with name, uid, connected stack and `contentTypeUid`, and let the user pick **by number**. You already hold every uid, so never make them paste one. Never guess from a name that resembles the repo | + +**Record two values for the rest of the run**, and don't re-ask for either: + +- `studioProjectId` — the chosen project's `uid`. Hand it to [`configure-studio`](configure-studio.md), [`setup-section-preview`](setup-section-preview.md) and every later skill. +- `studioContentTypeUid` — the chosen project's **`contentTypeUid`, verbatim**. Only when the listing shows that field empty or absent does the project genuinely fall back to `compositions`, and only then is `compositions` the right value to write. + +**Cross-check the project against the stack before writing any file.** Its `connectedStackApiKey` must equal the `<stackApiKey>` you were given. If they differ, stop and say so: installing against a project bound to a different stack yields an empty compositions list and **HTTP 412 "We can't find that Stack"** at runtime ([`troubleshoot-canvas`](troubleshoot-canvas.md)), and no SDK-side config fixes it. + +### 1. Greenfield path: only after § 0 confirms no existing integration + +1. **Detect the framework.** Look in `package.json` `dependencies` for one of: + - `next` means Next.js (App Router or Pages Router. Detect via `app/` vs `pages/`) + - `vite` means Vite (probably with React) + - `react-scripts` means CRA + - `@remix-run/*` means Remix + - `astro` means Astro + - anything else: ask the user which framework / report unsupported and stop. + + **Next.js advisory check.** If `next@14.2.x` is present, ensure the patch version is `>= 14.2.21`. Earlier `14.2.x` patches (including the `create-next-app` default `14.2.18`) carry a known security advisory. Bump to the latest patched `14.2.x` (stays React-18 + App-Router compatible, no migration needed). Print the recommendation. Let the user run the bump. + +2. **Detect the package manager.** `pnpm-lock.yaml` means pnpm, `yarn.lock` means yarn, and neither means npm. + +3. **Pre-flight: validate credentials before writing any code.** Make a CMA call to confirm `stackApiKey` + `deliveryToken` work: + ``` + GET https://cdn.contentstack.io/v3/content_types?include_count=true + Headers: + api_key: <stackApiKey> + access_token: <deliveryToken> + ``` + (Use the regional CDN host if `region != us`: `eu-cdn.contentstack.com`, `azure-na-cdn.contentstack.com`, `azure-eu-cdn.contentstack.com`, `gcp-na-cdn.contentstack.com`, `au-cdn.contentstack.com`.) + + If non-200, fail fast with a clear message: "Your Stack API Key or Delivery Token didn't validate. In your Stack's Settings, under Tokens, open Delivery and verify the values." + + Validate the Preview Token separately with [`verify-setup`](verify-setup.md) Layer 6: the regional preview host and a content-type entries endpoint. Do not substitute a preview token into this delivery-host schema request. A failure there does not establish that the preview token is invalid and must not trigger token replacement. + + **Then assert the target environment has a Base URL for the locale**, same call, `environments` endpoint, no extra credentials needed: + ``` + GET https://cdn.contentstack.io/v3/environments + Headers: + api_key: <stackApiKey> + access_token: <deliveryToken> + ``` + (Same regional host rule as above.) In the response, find the environment named `<environment>` and check its per-locale URL for `<defaultLocale>`: + ```jsonc + { "environments": [ + { "name": "preview", "urls": [ { "locale": "en-us", "url": "" } ] } // ← empty url is the failure + ] } + ``` + On a localized app (the `i18n` row from `analyze-project-fit` is not `none`), check `urls[]` for **every** locale the app serves, not only `<defaultLocale>`. A locale-prefixed app needs the prefix in each URL (`http://localhost:3000/en`, `http://localhost:3000/fr`). [`convert-project-to-studio`](convert-project-to-studio.md) § Step 3 has the map and the write. **Fail on any locale the app serves, not only the default.** If `urls[locale == L].url` is **empty or missing for ANY app locale `L`** (that is just `<defaultLocale>` on an un-localized app, and every mapped locale on a localized one), fail fast, do NOT proceed to install, and name the failing locale(s) rather than reporting the default: + > Environment `<environment>` has no Base URL for locale(s) `<failing locales>`. Studio composes the canvas iframe address as **Base URL (this value) + Canvas URL (path)**, so an empty Base URL silently blocks the canvas later — for that locale only, which is why a passing default locale proves nothing about the rest. Studio's Settings, under Configuration, will refuse to save the Canvas URL with a "no base URL found" error that points at the wrong layer. Set it now in the Stack's **Settings**, under **Environments**: select **`<environment>`** and fill in the **URL for each failing locale** (e.g. `http://localhost:5173` for local dev, `http://localhost:5173/fr` on a locale-prefixed app), then re-run. + + This is the single most common cause of a blank canvas after a "successful" install. The data is right here in the pre-flight, so catch it now rather than three skills later. See `setup-section-preview` and `configure-studio`, which also guard this. + +4. **Confirm a single React major.** The SDK peer-deps `react ^18.0.0 || ^19.0.0`, so React 18 and React 19 both work. Do not downgrade a React 19 app. What matters is one copy. + + ```bash + <pm> ls react # must show exactly one entry, on 18.x or 19.x + ``` + + More than one entry: find the dep pulling the second copy, add `overrides` / `resolutions` pinning the app's version, reinstall, re-check. Pin exactly (`-E`) only when duplicates keep coming back. `@types/react` and `@types/react-dom` must match the installed major. + +5. **Install Studio dependencies from public npm.** + ```bash + <pm> add @contentstack/delivery-sdk @contentstack/live-preview-utils @contentstack/studio-react + ``` + Use `npm install` / `yarn add` / `pnpm add` as detected. Studio ships on public npm, no custom registry, `.npmrc`, or auth token needed. If you see instructions pointing at `npm.pkg.github.com` or a GitHub Personal Access Token, that's an internal-only registry mirror. Install from public npm instead. + +6. **Create `src/lib/contentstack.ts`** (or `lib/contentstack.ts` for Next.js App Router, pick the right place based on the project's source layout). + + **IMPORTANT: pick ONE env-access syntax based on the framework detected in step 1. Do NOT mix `import.meta.env` and `process.env` in the same file: `import.meta.env` does not type-check or compile under Next.js (and `process.env` is undefined in Vite browser bundles).** + + Use this decision table: + + | Framework detected | Env prefix | Env access syntax | + | --- | --- | --- | + | `next` in deps | `NEXT_PUBLIC_` | `process.env.NEXT_PUBLIC_*` | + | `vite` in deps | `VITE_` | `import.meta.env.VITE_*` | + | `react-scripts` (CRA) | `REACT_APP_` | `process.env.REACT_APP_*` | + | `@remix-run/*` | (none, server env) | `process.env.*` | + | `astro` | `PUBLIC_` | `import.meta.env.PUBLIC_*` | + + **Next.js variant** (emit this exact file body when `next` is in `package.json` dependencies): + ```ts + import Contentstack from "@contentstack/delivery-sdk"; + import { studioSdk } from "@contentstack/studio-react"; + + const previewToken = process.env.NEXT_PUBLIC_CONTENTSTACK_PREVIEW_TOKEN; + + // Region → host derivation. NEVER hardcode `rest-preview.contentstack.com` — + // that's US only; every other region 401s against it. Source: Regional host map above. + const CDN_HOSTS = { + us: undefined, + eu: "eu-cdn.contentstack.com", + "azure-na": "azure-na-cdn.contentstack.com", + "azure-eu": "azure-eu-cdn.contentstack.com", + "gcp-na": "gcp-na-cdn.contentstack.com", + "gcp-eu": "gcp-eu-cdn.contentstack.com", + au: "au-cdn.contentstack.com", + } as const; + const PREVIEW_HOSTS = { + us: "rest-preview.contentstack.com", + eu: "eu-rest-preview.contentstack.com", + "azure-na": "azure-na-rest-preview.contentstack.com", + "azure-eu": "azure-eu-rest-preview.contentstack.com", + "gcp-na": "gcp-na-rest-preview.contentstack.com", + "gcp-eu": "gcp-eu-rest-preview.contentstack.com", + au: "au-rest-preview.contentstack.com", + } as const; + const region = (process.env.NEXT_PUBLIC_CONTENTSTACK_REGION ?? "us") as keyof typeof PREVIEW_HOSTS; + const cdnHost = CDN_HOSTS[region]; + const previewHost = PREVIEW_HOSTS[region]; + + export const stack = Contentstack.stack({ + apiKey: process.env.NEXT_PUBLIC_CONTENTSTACK_API_KEY!, + deliveryToken: process.env.NEXT_PUBLIC_CONTENTSTACK_DELIVERY_TOKEN!, + environment: process.env.NEXT_PUBLIC_CONTENTSTACK_ENVIRONMENT!, + region, + ...(cdnHost ? { host: cdnHost } : {}), + // Live Preview block ONLY when a preview token is present. With an empty token the + // Delivery SDK init fails — and we don't want LP failure to break the SDK either. + ...(previewToken ? { + live_preview: { + enable: true, + preview_token: previewToken, + host: previewHost, + }, + } : {}), + }); + + // Live Preview init is async and caught, so an LP failure (bad token, LP not enabled + // on the stack, network) NEVER blocks studioSdk.init below. A common failure mode: + // ContentstackLivePreview.init() throws → studioSdk.init() is unreachable → Studio + // canvas shows "SDK Not Initialized" even though the SDK itself was fine. + // Import it ONLY with a preview token: live-preview-utils (4.5.3) draws its Edit + // button on import, even without init(), so visitors would see it in production. + if (previewToken && typeof window !== "undefined") { + import("@contentstack/live-preview-utils") + .then(({ default: ContentstackLivePreview }) => + ContentstackLivePreview.init({ + enable: true, + mode: "builder", // default "preview" never starts Visual Editor's editing layer + ssr: true, // false for CSR / static apps + stackDetails: { + apiKey: process.env.NEXT_PUBLIC_CONTENTSTACK_API_KEY!, + environment: process.env.NEXT_PUBLIC_CONTENTSTACK_ENVIRONMENT!, + }, + clientUrlParams: { host: "app.contentstack.com" }, + editButton: { enable: true, exclude: ["outsideLivePreviewPortal"] }, + editInVisualBuilderButton: { enable: false }, // else a "Start Editing" link on every page + }), + ) + .catch((err) => console.warn("[studio] Live Preview init failed; continuing without LP.", err)); + } + + export const sdk = studioSdk.init({ + stackSdk: stack, + // The literal is `studioContentTypeUid` from § 0c — the chosen project's own + // contentTypeUid. Write `"compositions"` here ONLY if that is what the project + // actually reported; a guess surfaces as "Content Types Don't Match" in Studio. + contentTypeUid: process.env.NEXT_PUBLIC_CONTENTSTACK_STUDIO_CONTENT_TYPE ?? "<studioContentTypeUid>", + // Emits the data-cslp attributes Visual Editor needs to map a DOM node back + // to its field. Off by default: the renderer checks `config.cslp.appendTags` + // before attaching either attribute bag, so with this absent NO component + // gets a tag no matter how correctly it spreads them, and the page opens in + // VB with nothing editable. Set it at install time, once, for every project. + cslp: { appendTags: true }, + // Add `fetchTemplateEntry` here so a new entry is editable on the page before anyone + // types into it: see `make-visual-editor-editable` § 4. + }); + + export { studioSdk }; + ``` + + **Vite variant** (emit this exact file body when `vite` is in `package.json` dependencies / devDependencies): + ```ts + import Contentstack from "@contentstack/delivery-sdk"; + import { studioSdk } from "@contentstack/studio-react"; + + const previewToken = import.meta.env.VITE_CONTENTSTACK_PREVIEW_TOKEN; + + // Region → host derivation. NEVER hardcode `rest-preview.contentstack.com` — + // that's US only; every other region 401s against it. Source: Regional host map above. + const CDN_HOSTS = { + us: undefined, + eu: "eu-cdn.contentstack.com", + "azure-na": "azure-na-cdn.contentstack.com", + "azure-eu": "azure-eu-cdn.contentstack.com", + "gcp-na": "gcp-na-cdn.contentstack.com", + "gcp-eu": "gcp-eu-cdn.contentstack.com", + au: "au-cdn.contentstack.com", + } as const; + const PREVIEW_HOSTS = { + us: "rest-preview.contentstack.com", + eu: "eu-rest-preview.contentstack.com", + "azure-na": "azure-na-rest-preview.contentstack.com", + "azure-eu": "azure-eu-rest-preview.contentstack.com", + "gcp-na": "gcp-na-rest-preview.contentstack.com", + "gcp-eu": "gcp-eu-rest-preview.contentstack.com", + au: "au-rest-preview.contentstack.com", + } as const; + const region = (import.meta.env.VITE_CONTENTSTACK_REGION ?? "us") as keyof typeof PREVIEW_HOSTS; + const cdnHost = CDN_HOSTS[region]; + const previewHost = PREVIEW_HOSTS[region]; + + export const stack = Contentstack.stack({ + apiKey: import.meta.env.VITE_CONTENTSTACK_API_KEY!, + deliveryToken: import.meta.env.VITE_CONTENTSTACK_DELIVERY_TOKEN!, + environment: import.meta.env.VITE_CONTENTSTACK_ENVIRONMENT!, + region, + ...(cdnHost ? { host: cdnHost } : {}), + // Conditional LP block — see Next variant above for rationale + ...(previewToken ? { + live_preview: { + enable: true, + preview_token: previewToken, + host: previewHost, + }, + } : {}), + }); + + // LP init failure must never block studioSdk.init below + // Only with a preview token (see the Next variant above). + if (previewToken) { + import("@contentstack/live-preview-utils") + .then(({ default: ContentstackLivePreview }) => + ContentstackLivePreview.init({ + enable: true, + mode: "builder", // see the Next variant above + ssr: false, + stackDetails: { + apiKey: import.meta.env.VITE_CONTENTSTACK_API_KEY!, + environment: import.meta.env.VITE_CONTENTSTACK_ENVIRONMENT!, + }, + clientUrlParams: { host: "app.contentstack.com" }, + editButton: { enable: true, exclude: ["outsideLivePreviewPortal"] }, + editInVisualBuilderButton: { enable: false }, + }), + ) + .catch((err) => console.warn("[studio] Live Preview init failed; continuing without LP.", err)); + } + + export const sdk = studioSdk.init({ + stackSdk: stack, + // `studioContentTypeUid` from § 0c — see the Next variant above. Not a guess. + contentTypeUid: import.meta.env.VITE_CONTENTSTACK_STUDIO_CONTENT_TYPE ?? "<studioContentTypeUid>", + // ⛔ Do NOT omit — see the Next variant above. Without `appendTags` no + // bound value carries `data-cslp` and nothing is editable in Visual Editor. + cslp: { appendTags: true }, + }); + + export { studioSdk }; + ``` + + For CRA / Remix / Astro, follow the same pattern using the table above, emit a single env-access syntax matching the detected framework. Never emit both `import.meta.env` and `process.env` references for the same variable. + + `export const sdk = studioSdk.init(...)`: the return value is the live SDK that downstream skills call (`sdk.fetchCompositionData(...)`). The `studioSdk` namespace export is un-initialized. + +7. **Create `.env.local`** (gitignored) with the collected values: + ``` + <PREFIX>_CONTENTSTACK_API_KEY=<stackApiKey> + <PREFIX>_CONTENTSTACK_DELIVERY_TOKEN=<deliveryToken> + <PREFIX>_CONTENTSTACK_PREVIEW_TOKEN=<previewToken> + <PREFIX>_CONTENTSTACK_ENVIRONMENT=<environment> + <PREFIX>_CONTENTSTACK_DEFAULT_LOCALE=<defaultLocale> + <PREFIX>_CONTENTSTACK_STUDIO_CONTENT_TYPE=<studioContentTypeUid> + ``` + + The last one is the `contentTypeUid` from § 0c. The init file reads it, so omitting it silently falls through to the literal in `studioSdk.init` — which is exactly how a project on `<user>_compositions` ends up asking for `compositions`. + Add `.env.local` to `.gitignore` if not already there. + + **`.env.local` shadowing trap.** Vite (and CRA, Next) load env files in this order: `.env`, then `.env.local`, then `.env.[mode]`, then `.env.[mode].local`, with later files **overriding earlier ones for the same key**. `.env.local` is loaded in every mode (dev AND build) and silently wins over `.env`. The trap pattern: a project re-provisions and writes fresh values to `.env`, but a stale `.env.local` (left from a previous run, perhaps with `<PREFIX>_CONTENTSTACK_PREVIEW_TOKEN=undefined` literally as a string) still exists and overrides them. Two distinct failure signatures for the same root cause: + + - **Stale bad preview token**: canvas iframe throws **HTTP 401** from `rest-preview.contentstack.*` with no CORS headers, which produces "Component Loading Error" on a project that worked yesterday. + - **Stale stack api_key from a copied project**: the app initializes the OLD stack while Studio's project points at the NEW one, which produces **HTTP 412 "We can't find that Stack. Please try again."** on the CMA and **"No composition for /…"** at runtime. Especially common when scaffolding a new project by copying a working app's directory. <!-- style-lint: allow --> + + Mitigation: + - When provisioning writes `.env`, **also delete** any stale `.env.local` (or rewrite it from `.env.example`). + - Never let `.env.local` carry `<key>=undefined` or `<key>=` (empty). The SDK init can't distinguish a literal `"undefined"` string from a real token and the CDA rejects it. + - After writing env files, log the resolved value via `console.log(import.meta.env.<PREFIX>_CONTENTSTACK_PREVIEW_TOKEN)` once in the host app and verify it matches the intended token before claiming setup succeeded. + +8. **Wire the import** at the app shell so init runs once. The right file depends on framework: + - **Next.js App Router**: **DO NOT import `@/lib/contentstack` directly in `app/layout.tsx`.** `layout.tsx` is a Server Component. A side-effect import there runs `studioSdk.init` / `ContentstackLivePreview.init` on the server, where the canvas iframe never sees them. Studio loads with no SDK in the client realm and `<StudioCanvas />` errors. Instead, create a tiny client boundary: + ```tsx + // app/studio-init.tsx + "use client"; + import "@/lib/contentstack"; // side-effect import — runs init in the client bundle + export function StudioInit() { return null; } + ``` + ```tsx + // app/layout.tsx (Server Component — no "use client") + import { StudioInit } from "./studio-init"; + export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + <html><body> + <StudioInit /> + {children} + </body></html> + ); + } + ``` + `StudioInit` renders `null`. Its only job is to carry the side-effect import into the client module graph so the SDK initializes where the canvas runs. + - **Next.js Pages Router**: the rule is **`studioSdk.init()` must complete before `<StudioCanvas>` mounts**. Any top-level side-effect import in a module that evaluates first satisfies it. `pages/_app.tsx` is the natural home because it's the client entry. What breaks the rule: + - Init inside a `useEffect` fires **after** render commits. Canvas has already mounted and posted `ready`, which leaves **"Canvas Component Did Not Load"** with no other symptom. + - Init inside a dynamic `import()` awaited before render, WHEN the canvas is also `dynamic({ ssr:false })`. Both are async, they race, canvas usually wins. + + ```tsx + // pages/_app.tsx + import "@/lib/contentstack"; // top-level, synchronous — NOT in useEffect, NOT dynamic import + import type { AppProps } from "next/app"; + export default function App({ Component, pageProps }: AppProps) { + return <Component {...pageProps} />; + } + ``` + + If you can't reason about module-graph ordering (or the canvas is very deeply lazy-loaded), a defensive move is to duplicate the top-level static import in the file that renders `<StudioCanvas>`. Same-module init is the strongest guarantee, though usually not required. + - Vite / CRA: import in `src/main.tsx` or `src/index.tsx`. + - Remix: import in `app/root.tsx`. + + Add `import "@/lib/contentstack";` near the top of that file (no destructuring. The side-effects matter). + +9. **For Vite projects: serve a PROD BUILD to the canvas (not the dev server).** + + Vite's dev optimizer can pre-bundle the SDK with a separately-loaded React (different `?v=hash`) and break hook calls. Studio's canvas throws "Invalid hook call". `resolve.dedupe` / `optimizeDeps.include` only partially help. The reliable fix is `vite preview`. + + - Add to `vite.config.*` as defense-in-depth (does NOT replace the prod-build rule): + ```ts + resolve: { dedupe: ['react', 'react-dom'] }, + optimizeDeps: { + include: [ + 'react', 'react-dom', + '@contentstack/delivery-sdk', + '@contentstack/live-preview-utils', + '@contentstack/studio-react', + ], + }, + ``` + - **Serve via `vite preview`:** `<pm> build && <pm> exec vite preview --port <port>`. Verify in DevTools, on the Network tab: files look like `assets/<chunk>-<hash>.js` with NO `?v=<hash>` query strings. + +10. **Continue the setup workflow.** When called for whole-project setup, return to `convert-project-to-studio` and execute canvas routing, template routing, configuration, and verification with the choices already resolved there. Do not stop after installing packages. For an explicit SDK-only request, report the SDK installation and list these remaining capabilities: + ``` + Studio + Live Preview + Delivery SDK installed. + + Next: + 1. Open Studio at <studio URL — usually https://app.contentstack.com/#!/studio> + 2. Select project <studioProject name> (<studioProjectId>) — the one this install points at + 3. REQUIRED — run `setup-section-preview` to add the /canvas route (mounts <StudioCanvas />). + Not optional: Section authoring needs it, and a later catch-all route will claim /canvas + if nothing owns it. + 4. (Optional) Run `setup-local-https-canvas` ONLY if your app needs HTTPS-on-localhost for service workers, secure cookies, or strict policy. Plain `http://localhost:<port>` works for Studio's iframe — browsers treat localhost as a trusted origin. + 5. Wire Studio into routing — run `setup-template-preview-routes`. It asks wildcard (one + catch-all owns every URL) vs dedicated per-pattern routes, and in wildcard mode also + wires header/footer preservation and 404 handling. + ``` + +## Inputs needed from the user + +In this order. Stop and ask the user if any is missing. DO NOT proceed without them. + +1. `stackApiKey`: Stack API Key +2. `deliveryToken`: Delivery Token (sensitive) +3. `previewToken` (Preview Token (sensitive)) paired with the same Delivery Token record +4. `environment`: Environment name +5. `defaultLocale`: default to `en-us` if unsure +6. `region`: default to `us` +7. `studioProject`: which Studio project this app renders. **Ask if it wasn't supplied** — § 0c lists them and branches on the count. There is no safe default + +Tokens (2 and 3): reuse the delivery token `enable-visual-experience` created, or let it create one by API (§ 0a). Ask the user only if both fail. + +`studioContentTypeUid` is NOT an input: it is **derived** from the chosen project's `contentTypeUid` in § 0c. Never ask the user for it, and never assume `compositions`. + +If the user doesn't know where to find them, point them at: +- Stack credentials: on `app.contentstack.com`, go to the org dashboard, open Headless CMS and pick the stack. Then use **More**, **Settings**, **Tokens** and click an existing Delivery Token to see all three values in one form +- Environment list: the same Settings sidebar, under **Environments** +- Studio projects: the project switcher in Studio's top bar, or the `GET /v1/projects` listing in § 0c + +## Acceptance + +This skill succeeds only when ALL of the following are true. If any fails, do not claim success. Surface the failure and stop. + +- [ ] `analyze-project-fit` was run and chose Path A or D (otherwise install would have been blocked) +- [ ] `<pm> ls react` shows exactly one React entry (18.x or 19.x), and `@types/react` + `@types/react-dom` match that major +- [ ] `<pm> ls react` shows a single tree entry (18.x or 19.x, no duplicates) +- [ ] `package.json` lists all three Contentstack packages in `dependencies` +- [ ] Delivery credentials passed the delivery read; preview credentials passed `verify-setup` Layer 6 on the preview host. These are separate checks. +- [ ] The target environment has a non-empty Base URL for **every locale the app serves** (asserted from the `environments` pre-flight — the default locale alone is not enough on a localized app), or the user was told which locales are missing and stopped +- [ ] `src/lib/contentstack.ts` (or framework equivalent) exists with the three SDK inits +- [ ] **`cslp: { appendTags: true }` is set in `studioSdk.init`**. Every Studio project is a Visual Editor project. With this off or absent, `studioAttributes` and every `$`-twin arrive empty across the whole app and no component-level fix helps. See [`complete-the-build`](complete-the-build.md) item 1. +- [ ] Live Preview init is wrapped in **try/catch** and the `live_preview` block on the stack is **conditional on a non-empty preview token** +- [ ] **A Studio project was resolved from an actual `GET /v1/projects` listing, not assumed.** With 2+ projects the user picked; with exactly 1 it was named back to them. Its `connectedStackApiKey` equals `<stackApiKey>` +- [ ] **`contentTypeUid` in the init file equals the chosen project's `contentTypeUid`** (compare the emitted file against the listing — `grep contentTypeUid src/lib/contentstack.ts`). If it reads `compositions`, that is correct ONLY when the project reported `compositions` +- [ ] `.env.local` exists with the six `<PREFIX>_CONTENTSTACK_*` vars, including `_STUDIO_CONTENT_TYPE` +- [ ] `.gitignore` includes `.env.local` +- [ ] The app shell imports `@/lib/contentstack` for side-effect init. For Next.js App Router specifically, the import lives in a `"use client" StudioInit` component rendered from `app/layout.tsx`, NOT a direct side-effect import in the layout itself. +- [ ] For Vite: `resolve.dedupe` + `optimizeDeps.include` are set. The user has been told to serve a prod build (`vite preview`) to the Studio canvas +- [ ] `<pm> run dev` (or `vite preview` for the canvas) starts without runtime errors +- [ ] **Asserted from the rendered page, not from the source:** once a composition renders at a URL, `curl -s <url> | grep -c 'data-cslp'` returns a number greater than zero. Zero means bound values are reaching the DOM with no edit anchor. Visual Editor shows the page and refuses to edit it. Check `appendTags` above first, then the component's `$`-prefixed props ([`register-component`](register-component.md) § CSLP tags). If no composition exists yet, defer this check to [`verify-setup`](verify-setup.md) and say so. + +If acceptance fails, do not silently move on. Report exactly which step broke and stop so the user can intervene. + +## After install: if Studio says "Content Types Don't Match" + +A dialog over the canvas: *"Studio is set up for the content type `<expected>`, but the code on your site is asking for `<actual>`."* **Expected** is the Studio project's `contentTypeUid`; **Actual** is what `studioSdk.init` passed. Nothing renders until they agree. + +Fix it on the SDK side, not by renaming the content type: set `contentTypeUid` (and `<PREFIX>_CONTENTSTACK_STUDIO_CONTENT_TYPE` in `.env.local`, which overrides it) to the **Expected** value the dialog names, then restart the dev server — env files bake in at start time. If both look right in the source, check for a stale `.env.local` shadowing `.env` (§ step 7). + +Two causes worth telling apart, because the fix differs: + +- **The install guessed.** § 0c was skipped, so the init fell through to a literal `"compositions"` while the project was provisioned as `<user>_compositions`. Re-run § 0c and rewrite the value. +- **The app points at the wrong project.** The uid it asks for is real, just owned by a different Studio project on the same stack. Re-select the project ([`configure-studio`](configure-studio.md#select-the-studio-project)) before touching the SDK config, or you will bind the app to the wrong compositions. + +## After install: if you see "SDK Not Initialized" in Studio + +**Do NOT re-run this skill.** The popup is misleading. It usually means a Template URL match failed, not a broken SDK install. See `troubleshoot-canvas` § Run this FIRST: the section-test pre-flight. If Sections render, jump to `troubleshoot-composition-resolution`. Only if Sections also fail should you re-check this skill's acceptance steps. + +## After install (minimal-add branch): if Live Preview intermittently doesn't apply + +Same fetch, same hash, sometimes hits `rest-preview.contentstack.com` and sometimes falls back to `cdn.contentstack.io`, with no code change in between. This is the shared-stack hazard flagged in § Existing Contentstack app? Take the minimal-add branch above: something else in the app calls `.livePreviewQuery(...)` on the same stack instance Studio's SDK uses, resetting the shared preview state mid-request. Do NOT chase this as a token or Studio-config problem. See `troubleshoot-canvas` § Live Preview state resets mid-request for the diagnostic and the fix (give Studio's `stackSdk` its own stack instance). + +## After install: if the Design tab is missing / disabled + +Not a broken install, three separate gates control the Design tab: + +1. **Design tab missing entirely** (right panel is Settings only) means **Enable Freeform Feature** is off on the Studio project. Toggle it in Studio's Settings, under Configuration, or PUT `settings.configuration.isFreeformEnabled: true` via the Studio API. +2. **Design tab present but every control looks disabled / read-only** means `registerDesignTokens(tokens)` was called **without** the options arg. `allowedValuesLevel` defaults to `"dynamic"` which permits data-binding only, not token selection. **Fix:** always pass `{ allowedValuesLevel: "tokens" }` as the second arg. See [`import-design-tokens`](import-design-tokens.md) for the full token payload shape. +3. **Design tab appears but is empty for one specific component** means that component's `registerComponent` schema didn't declare a `styles` block. See [`register-component`](register-component.md) § Design panel: component must declare `styles`. + +## See also + +- Pair with `setup-section-preview` to add the Canvas route next. +- Use `verify-setup` to run a layered end-to-end smoke test after install. +- Use `troubleshoot-canvas` if the canvas iframe doesn't render after both skills are run. diff --git a/skills/studio/make-visual-editor-editable.md b/skills/studio/make-visual-editor-editable.md new file mode 100644 index 0000000..7cdf03a --- /dev/null +++ b/skills/studio/make-visual-editor-editable.md @@ -0,0 +1,91 @@ +--- +name: make-visual-editor-editable +--- + +## When to use + +Make a Studio page fully editable in Contentstack's Visual Editor: every field clickable on the page, lists that can be added to, reordered and deleted from, an empty list that offers "+", and a brand-new entry that is editable before anyone types into the form. Each rule here fails silently: the page renders correctly and Visual Editor simply offers nothing. + +Phrases: "nothing is clickable in Visual Editor", "Visual Builder opens but I can't edit", "can't add a block / reorder / delete in Visual Editor", "no + on an empty list", "new entry shows Your text here", "can't click an empty field", "image under an overlay can't be selected". Do NOT use for the per-component `$` props and `studioAttributes` contract (that's [`register-component-cslp`](register-component-cslp.md)), or for the stack-level Live Preview switch (that's [`enable-visual-experience`](enable-visual-experience.md)). + +# Make every field and list editable in Visual Editor + +Checked against `@contentstack/studio-react` 1.9.0 and `@contentstack/live-preview-utils` 4.5.3, in their code and on a rendered page (2026-10-07). Visual Editor works from the `data-cslp` edit tags in the page's HTML, so each check below is a count of tags, not a look at the page. + +## 1. Live Preview must run in builder mode + +`live-preview-utils` defaults to `mode: "preview"`, and its editing layer returns early unless the mode is `builder`. Visual Editor then loads the page, sits on "Initializing the editor…", and nothing can be clicked. The init options live in [`install-studio`](install-studio.md) § the `lib/contentstack` snippets: `mode: "builder"`, an explicit `ssr`, `editButton.exclude: ["outsideLivePreviewPortal"]` and `editInVisualBuilderButton: { enable: false }` (builder mode otherwise adds a floating "Start Editing" link to every page it runs on). + +Check: open the page directly (not in Visual Editor) with a preview token set. With builder mode and the "Start Editing" button enabled, `[data-testid="vcms-start-editing-btn"]` appears, which proves the editing layer started. Then turn that button off again. + +## 2. Lists need two kinds of tag + +Visual Editor's list controls read two tags: the **list tag** `<…>.<field>` on an element around the whole list, and an **item tag** `<…>.<field>.<N>` on an element around each item. Move and delete need the item tags. Add needs both: it finds the list element from the item's tag. + +| Trap | What happens | Fix | +|---|---|---| +| No wrapper marked as the list | No list tag at all, so no add buttons | The node holding the Repeater carries `metadata.repeaterWrapper: true`. Studio's UI sets it on the Repeater's parent; by API, set it yourself ([`author-composition-practice`](author-composition-practice.md)) | +| The Repeater's child is a Condition Block holding a Section, or a Section Slot | The item tag never reaches an element: a Section or Section Slot renders no element of its own, so every item tag is dropped | Make a **Box** the Repeater's only child and put the Condition Blocks inside it: Repeater → Box → Condition Blocks → Sections | +| A Section scoped to the list field (`selected_field: "blocks"`) with its Repeater bound to the Section root | The list renders, but the tags are looked up on the bare array, which has none: no list tag and no item tags | Scope the Section to the whole entry and bind the Repeater to `<field>` | + +**Worked example (measured):** a page-builder template built in Studio's UI (Repeater over a modular-blocks field → Condition Block → Section per block type) rendered the list tag `page_sections` and the fields inside each block (`page_sections.0.cta_block.label`), but **no** `page_sections.0` / `.1` / `.2`. With a Box between the Repeater and the Condition Blocks, all three item tags appeared and nothing else changed. + +Check, on the rendered HTML of an entry with at least one item: + +```sh +curl -s "<page URL>" | grep -o 'data-cslp="[^"]*\.<field>\(\.[0-9]*\)\?"' | sort -u +``` + +Expect the `<field>` line and one `<field>.<N>` line per item. + +## 3. An empty list should offer "+" + +Visual Editor shows its "This page doesn't have any … added" panel, with a "+", inside an element that has the list tag **and** the class `visual-builder__empty-block-parent`, for a field that exists in the content type schema. It looks for that class only after a change to the page's children (a `childList` mutation), never at start-up, and never after a class change alone. So add the class in the browser after mount, inside the editor only, then append an empty comment node to trigger the scan: + +```tsx +useEffect(() => { + const el = ref.current; // the list wrapper, carrying the list tag + if (!el || window.self === window.top || el.children.length) return; + el.classList.add("visual-builder__empty-block-parent"); + el.appendChild(document.createComment("")); // a childList change, so Visual Editor rescans +}, [itemCount]); +``` + +Checked in the `live-preview-utils` code, not yet clicked through in Visual Editor. + +## 4. A new entry must be editable before anyone types + +A new entry holds only the fields someone has filled. A missing or `null` field gets no edit tag (tags are built from the entry's own keys) and its component shows the registered default ("Your text here"). An empty image is the worst case: a binding to `image.url` has nothing to tag. Two changes fix it, and both are needed: + +1. **Complete the entry.** `studioSdk.init({ fetchTemplateEntry })` (studio-react 1.9.0) receives `defaultFetch()`. Give every missing or `null` field an empty value from the content type schema: `""` for text, `{ url: "" }` for a file, `[]` for anything multiple, recursing into groups and blocks. Never touch existing values. The SDK tags the entry after this hook, so every field gets a tag. + ```ts + fetchTemplateEntry: async (ctx) => { + const entry = await ctx.defaultFetch(); + return entry ? completeEntry(entry, await ctSchema(ctx.connectedContentType)) : entry; + }, + ``` + Fetch the schema with a plain Delivery API call (`GET /v3/content_types/<uid>?environment=…`, cached per uid). Don't use `stack.contentType(uid).fetch()`: with Live Preview configured, `delivery-sdk` calls `getData` without a params object and throws "Cannot set properties of undefined (setting 'live_preview')". A change to `studioSdk.init` options needs a dev-server restart; hot reload keeps the old config. +2. **Render the empty element.** A component that returns nothing for an empty value has no element to tag. Render the element whenever its `$` prop is present (`{(headline || $headline) && <h1 {...$headline}>{headline}</h1>}`), and give empty tagged elements a minimum size inside the editor so there is something to click. + +**Measured:** with `hero` missing from Entry MB, the heading had no tag and showed "Your headline here". With the completer alone, the default text was gone but there was still no tagged element. With both changes, the page rendered `<h1 data-cslp="test_mb.<uid>.en-us.hero.heading">`. + +## 5. Decorative overlays block selection + +Visual Editor selects the nearest tagged ancestor of the clicked element. A gradient or scrim laid over an image (an absolutely positioned sibling, or a `::after` on the wrapper) takes the click, so the image can never be selected. Give every decorative overlay `pointer-events: none`. + +## Acceptance + +- [ ] Live Preview runs with `mode: "builder"` and `editInVisualBuilderButton: { enable: false }`. +- [ ] Every list renders a `<field>` tag and one `<field>.<N>` tag per item. +- [ ] No Section or Section Slot is the Repeater's direct child: a Box is. +- [ ] An empty list's wrapper gets `visual-builder__empty-block-parent` after mount, inside the editor only. +- [ ] `fetchTemplateEntry` completes the entry, and a new entry's empty fields, the image included, render tagged elements. +- [ ] Decorative overlays have `pointer-events: none`. + +## Common pitfalls + +| Pitfall | Why it bites | Fix | +|---|---|---| +| Judging Visual Editor by an existing, fully filled entry | Every trap in § 4 shows only on a new entry | Test with a new entry whose fields are all empty | +| Rendering the empty-list class on the server | The class is present before Visual Editor starts watching, and nothing changes after | Add it after mount, then append the comment node | +| Completing the entry on the visitor side only | The editor fetch is the one that needs the tags | Pass `fetchTemplateEntry` to the `studioSdk.init` the editor's page uses (server and client) | diff --git a/skills/studio/match-existing-pattern.md b/skills/studio/match-existing-pattern.md new file mode 100644 index 0000000..8bfe660 --- /dev/null +++ b/skills/studio/match-existing-pattern.md @@ -0,0 +1,180 @@ +--- +name: match-existing-pattern +--- + +## When to use + +Read the Templates and Sections already in a Studio project, work out **how they are built** (repeater-driven, block-wise, flat section list, single component) and build anything new the same way. When the project holds more than one pattern, show the user each one as a layer tree and let them pick before building. + +**Mandatory before authoring any Template or Section in a project that already has compositions.** Run it from `plan-studio-architecture` § Step 0a, and from `build-section`, `build-connected-template`, `build-freeform-template`, `build-page-builder-template` and `compose-marketing-section` before their first write. Phrases: "match the existing design", "build it like the other templates", "follow the existing pattern", "same as the current sections". An empty project needs nothing from this skill: report "no existing compositions, building fresh" and continue. + +> **Auth preflight: settle the credential before the first API call.** Resolve it OAuth-first per [`authenticate-cma`](authenticate-cma.md): `CS_OAUTH_ACCESS_TOKEN`, else the Contentstack MCP's stored session: Read that skill for where it is, even with no MCP connected. **Never ask the user for a session `authtoken`.** If nothing resolves, or a refresh fails with `400 invalid_refresh_token`, hand them `! CONTENTSTACK_REGION=<code> npx @contentstack/mcp --auth` (it needs a TTY and a browser, so it cannot be run for them) and wait. `403 error_code 316` is a valid credential aimed at another org: fix the org or the `api_key`, do **not** re-authenticate. + +# Match the pattern the project already uses + +## Why this matters + +A composition's `ui` is zlib-compressed, so a project's build conventions are invisible to every other inspection skill. `discover-sections` reads route files, `discover-sections-from-ct` reads a schema, `verify-visual-parity` compares screenshots. None of them can see that the existing Templates iterate their blocks through a Repeater with one Condition Block per type, or that Sections declare `linked_schemas` so a dropped Section binds itself. + +Build without looking and the new work is structurally foreign to what is there: it may render correctly and still be wrong, because authors get no Repeater, no Condition Blocks and nowhere to drop anything. That is rework, and it is only visible once someone opens the Layers panel. + +## Task + +### Step 1: Inventory, then read the trees + +`place_composition_as` splits Templates from Sections. The listing endpoint does **not** return `ui`. Fetch each composition individually, then inflate. + +```bash +# List (paginate: the endpoint caps at 100) +curl -s "$STUDIO_API/projects/$PROJECT/compositions?limit=100&skip=0" -H "$CS_AUTH" +# Then per composition +curl -s "$STUDIO_API/projects/$PROJECT/compositions/$UID" -H "$CS_AUTH" +``` + +`ui` arrives as `zlib:<base64>`. Inflate it before walking: + +```python +import zlib, base64, json +ui = json.loads(zlib.decompress(base64.b64decode(raw[5:]))) +``` + +Fetch concurrently. A few hundred compositions at ~1.5s each is ten minutes sequentially, which is long enough that the run gets abandoned half-read. + +### Step 1b. Reuse inventory: what already exists + +Shape is only half the answer. The same scan also answers "what can I drop instead of building?", and skipping it is how a project ends up with two Sections doing one job. + +Three lists come out of the trees you already inflated: + +**Sections keyed by the schema they bind to.** Group every Section by its `linked_schemas` entry (`<content_type>.<block_type>`). Before building for a target CT, look it up: + +``` +== TARGET: platform_details == + platform_details_section.banner_section (1) + Alpha platform_details — Banner Section [single component] preview:n blt21e341ba… + platform_details_section.benefits_section (1) + … +``` + +Six Sections already cover this CT. The build is reuse or extend, not create. Say which, and name the uid. + +**The component palette, ranked by real usage.** Count each `type` across all trees, split atoms + layout primitives from composites: + +| Component type | Compositions using it | +|---|---| +| `alpha-atom-heading` | 88 | +| `alpha-atom-body-string` | 82 | +| `alpha-atom-cta-button` | 41 | + +Usage count is what makes this trustworthy. A registered component nothing uses is not a convention, and building on it means inventing one alone. + +**Unlinked Sections.** A Section with no `linked_schemas` cannot auto-bind when dropped. The author wires every prop by hand. Report the count. It is the project's largest hidden authoring cost. + +Report reuse before the pattern trees. A user who learns the Section already exists does not need the pattern at all. + +### Step 2: Derive each composition's shape + +Walk the tree and count what decides how you build one: + +| Signal | What it tells you | +|---|---| +| `repeater` nodes | blocks are iterated in the tree, not inside React | +| `condition-block` nodes | one branch per block type, `metadata.condition.value` names it | +| `section-composition` nodes | Sections are referenced, not inlined | +| `alpha-atom-*` nodes | content is built from atoms rather than a section component | +| tree depth | flat list vs nested composition | +| binding `type` mix | `template` / `repeater` / `static_value` / literal | + +Classify each into a shape: + +- **`repeater + condition + sections`**: the fully composable pattern +- **`repeater + condition + atoms`**: composable, content built from atoms +- **`section list`**: flat list of Section references, no iteration +- **`single component`**: one registered component holds the whole page +- **`flat` / `atoms`**: everything else + +### Step 3: Report the patterns as layer trees, not prose + +The user recognizes a pattern by its **Layers panel**, so print the chain: + +``` +Box + Slot + Repeater iterates <blocks field> + Contents + Condition Block when <block type> + Contents + Box + Slot + Section <name> +``` + +State the count behind each: "14 of 18 Templates are built this way." + +### Step 4: One pattern, or a choice + +**Exactly one pattern**: say so, name a representative composition, and build the new work that way without asking. + +> Found 18 existing Templates, all built as Repeater + Condition Block + Section +> (e.g. `Latest - Homepage`). Building the new Template the same way. + +**More than one**: do not guess, and do not pick the most common by default. Show each with its layer tree and its count, then ask which to follow. Use `AskUserQuestion` so the trees render side by side as previews. + +Only offer patterns that actually fit the target. A Repeater pattern needs the page's body in **one** modular-blocks field. A page whose content is split across named fields and two blocks fields cannot use it, and offering it wastes a build. + +### Step 5: Copy the wiring, not just the silhouette + +Matching the shape is not enough. The bindings have to match too. Read them off a working composition rather than inferring: + +- **Repeater**: `props.items` bound to the blocks field, `metadata.repeaterBindingFieldType: "modular_block"`, `metadata.mode: "preview"` +- **Condition Block**: `metadata.condition` with `operator: "eq"`, `value: "<block type>"`, and both `conditionBinding` and `dataBinding` pointing at `{ repeaterUID, path: { <block type>: {} } }` +- **Section auto-bind**: `linked_schemas: [{ content_type_uid, selected_field: "<field>.<block type>" }]`. This is what makes a dropped Section bind itself +- **Scope-relative paths**: see the pitfall table + +### Step 6: Record the choice, then enforce it + +Re-asking on every build is noise, and it lets two answers coexist in one project. Write the decision to +`.studio/pattern.json` in the consuming repo the first time it is made, and read it before Step 1: + +```json +{ + "project": "<studio project uid>", + "template_pattern": "repeater + condition + sections", + "section_pattern": "shell + stack + inner repeater", + "chosen_by": "user", + "example_template": "Latest - Glossary Pages", + "example_section": "Benefits" +} +``` + +With a record present, skip the prompt and say which pattern is being followed and where it came from. Re-prompt only +when the user asks to change it, or when the scan finds a pattern the record does not mention. + +**Enforce it.** The pattern is not a suggestion: build to it, and if the target genuinely cannot take it (a page whose +body is split across named fields and two blocks fields cannot take the Repeater pattern), say so plainly, name the +constraint, and build the nearest fit. Do not silently pick a different shape and leave the user to find it in the +Layers panel. + +## Pitfalls + +| Pitfall | Why it bites | Fix | +|---|---|---| +| Binding a Section inside a Condition Block with an entry-root path | The Section is scoped to **the block**, so `template.<blocks_field>` resolves to nothing and it silently renders its unbound placeholder, a page that returns 200 with no content | Bind relative to the block: `path: { heading: {} }`, never `path: { hero: { heading: {} } }` | +| Reading the shape from the listing endpoint | `ui` is not returned there. Every composition looks empty | Fetch each composition individually, then inflate | +| Trusting `static_value` bindings | They do not resolve on this render path. The component receives the **key**, not the value | Write literals as `{ type, value }` | +| Offering the Repeater pattern for every page | Needs the body in one modular-blocks field. A page mixing named fields with two blocks fields renders nothing | Check the schema shape first. Leave those flat and say why | +| Judging "it renders" as "it matches" | A 404 page inside the canvas still has headings and characters | Compare the layer tree, and check the first heading is real content | +| Building a Section that already exists | The scan classified shape but never grouped Sections by `linked_schemas`, so a duplicate ships and two Sections drift apart | Run the reuse inventory (Step 1b) before any write. A hit means reuse or extend | +| A newly registered component type in the canvas | The canvas serves a stale client bundle and reports the type unregistered even though the page route renders it | Restart the dev server after registering a new type | + +## Acceptance + +- Every existing composition read and classified, count reported, none skipped +- Reuse checked before shape: existing Sections for the target schema named with uids, or "none, new build" stated explicitly +- Atoms and composites reported with usage counts, so the new work is built from what the project actually uses +- Patterns reported as layer trees with counts +- With one pattern, it was matched without asking. With several, the user chose from previews +- New composition's tree matches the chosen pattern node for node +- Bindings copied from a working example, and the new page renders real content, not just a 200 +- Choice recorded in `.studio/pattern.json`, so the next build does not re-ask +- New composition re-scanned after building: it classifies into the chosen pattern, not a neighbouring one diff --git a/skills/studio/migrate-ct-schema.md b/skills/studio/migrate-ct-schema.md new file mode 100644 index 0000000..41fe450 --- /dev/null +++ b/skills/studio/migrate-ct-schema.md @@ -0,0 +1,160 @@ +--- +name: migrate-ct-schema +--- + +## When to use + +Change a live Content Type's schema (add fields, rename, retype, remove, extract to Global Field) safely. Every change classified by risk. Safe changes (additive) applied directly, unsafe changes (rename, retype, remove) planned as dual-write + backfill + cutover. Dry-run mandatory before any live change. State file records every intended mutation so the run resumes on failure or reverts on demand. + +Use when a live CT needs to evolve: field added post-launch, prop renamed to match new component signature, a group extracted into a reusable Global Field, a Reference retargeted from one CT to another. Phrases: "add a field to `blog_post`", "rename `title` to `headline`", "extract `hero` to a Global Field", "change `read_time` from string to number", "migrate CT schema". Do NOT use for initial CT creation (that's [`provision-studio-stack`](provision-studio-stack.md)) or for content-only migrations (that's [`import-content`](import-content.md)). + +> **Auth preflight: settle the credential before the first API call.** Resolve it OAuth-first per [`authenticate-cma`](authenticate-cma.md): `CS_OAUTH_ACCESS_TOKEN`, else the Contentstack MCP's stored session: Read that skill for where it is, even with no MCP connected. **Never ask the user for a session `authtoken`.** If nothing resolves, or a refresh fails with `400 invalid_refresh_token`, hand them `! CONTENTSTACK_REGION=<code> npx @contentstack/mcp --auth` (it needs a TTY and a browser, so it cannot be run for them) and wait. `403 error_code 316` is a valid credential aimed at another org: fix the org or the `api_key`, do **not** re-authenticate. + +# Migrate a Content Type schema: dry-run planned, dual-write executed, rollback documented + +> ## Verification status +> +> **Core mechanics runtime-verified against a live stack.** Some risk-table rows (destructive rename, incompatible retype) remain pattern-verified only. Always dry-run on a staging stack before production. +> +> - CMA endpoints (`/v3/content_types/<uid>`, entry backfill endpoints) match existing verified skills. +> - Retry + state-file pattern matches [`agent-idempotency`](../../docs/50-advanced/agent-idempotency.md) reference implementation. +> - **Safe additive (add optional field), dual-write pattern (add parallel field for rename), bulk entry backfill via PUT, and rollback via schema PUT are all runtime-verified end-to-end** against a live Contentstack stack. 17/17 structural claims pass: schema shape changes, entries survive rollback with core field data intact. Reproduce with `npx ts-node scripts/verify-migrate-ct-schema.ts` (requires `CS_API_KEY` + `CS_AUTH_TOKEN` in `.env`). +> +> **Best practice: always dry-run on a staging stack** with the exact CT + entries first. Never run a high-risk migration against production the first time. Incompatible-retype and in-place destructive renames are explicitly discouraged in the risk-classification table below in favour of the verified dual-write path. The 14-day cool-off is a suggested default. Tune to your release cadence. + +## Context + +The three CT-schema failure modes an agent must not commit blind: + +1. **Add a required field to a CT with entries.** Live entries have no value, so Contentstack rejects the CT update. Silent failure or partial rollout. +2. **Rename a field.** Entries still carry the old field name. Existing bindings on Sections point at the old name. The new name is empty. Live site breaks on first render. +3. **Retype a field.** Existing values don't match the new type, so Contentstack drops them silently or rejects the update. Content vanishes. + +Safe changes (additive-optional, help-text edits, choice-option additions) can commit directly. Unsafe changes need a pattern of **plan, dual-write, backfill, then cutover**, documented per change class. + +## Risk classification + +| Change | Risk | Requires | +|---|---|---| +| Add optional field (any type) | Safe | Direct commit + dry-run smoke test | +| Add required field | Medium | Add as optional first, backfill all entries, then flip to required | +| Add option to a `choice` field | Safe | Direct commit | +| Remove option from a `choice` field | Medium | Backfill entries using the removed value first | +| Rename a field | High | Dual-write (new name plus old alias), backfill, update every binding, cut over, then remove the alias | +| Retype a field (compatible: `single_line` to `multi_line`) | Medium | Backfill probably-fine values. Validate no truncation | +| Retype a field (incompatible: `text` to `number`) | High | Add new field + backfill via transform + update bindings + remove old field | +| Remove a field | High | Backfill any downstream dependency + update bindings + soft-delete first (rename to `<field>_deprecated`), then remove after N days | +| Extract a group to a Global Field | Medium | Create the GF, dual-write to both the CT group and the GF for one release, update bindings, then remove the CT group | +| Retarget a Reference field | High | Backfill references (bulk update entries) + update bindings. May involve `import-content` if the target CT itself needs seeding | + +## Task + +### Step 1: Read the intended change + +The user says one of: + +- "Add a field `read_time` (number, optional) to `blog_post`." +- "Rename `blog_post.title` to `blog_post.headline`." +- "Extract `blog_post.hero` and `product.hero` into a Global Field." + +Parse: source CT + field(s) + intended mutation. Reject vague inputs: "clean up the schema" is not actionable. Ask for a specific change. + +### Step 2: Fetch the current CT schema + +Use CMA `/v3/content_types/<uid>` (stack management token from env). Store the current schema locally as `docs/_migration-state/<ct_uid>-before.json`. This is the rollback artifact. Never proceed without a snapshot. + +### Step 3: Classify + emit the migration plan + +Match the requested change against the risk table. Emit a plan: + +``` +MIGRATION PLAN · blog_post.title → blog_post.headline +Risk: 🔴 HIGH (field rename) + +Current entries in blog_post: 342 (fetched via CMA /entries?count=true). + +Plan: + 1. Add new field `headline` (single_line, optional) alongside `title` — safe, no downtime. + 2. Backfill: for every entry, copy `title` value → `headline`. Idempotent (skip entries where `headline` already populated). + 3. Update Section bindings: enumerate every Section currently binding `title`. Report before applying — user confirms. + 4. Cutover: switch bound Sections to bind `headline`. Republish affected compositions. + 5. Cool-off: leave `title` in place for N days (default 14) — rollback window. + 6. Remove `title` after cool-off + verify no remaining bindings. + +Rollback: docs/_migration-state/blog_post-before.json restores pre-change schema. Compositions with bindings to `title` restored via git revert of the composition changes committed in Step 4. + +Dry-run: no changes applied yet. Approve to proceed? +``` + +Wait for explicit approval. Never mutate the CT without user go-ahead. + +### Step 4: Execute the plan step-by-step, with checkpoints + +Update `docs/_migration-state/<ct_uid>-migration.json` after **every** completed step: + +```json +{ + "ct_uid": "blog_post", + "migration": "rename-title-to-headline", + "steps": [ + { "id": "1-add-headline", "status": "completed", "at": "2026-..." }, + { "id": "2-backfill-headline", "status": "in-progress", "records_completed": 214, "records_total": 342 }, + { "id": "3-list-bindings", "status": "pending" }, + { "id": "4-cutover-bindings", "status": "pending" }, + { "id": "5-cooloff", "status": "pending", "cooloff_ends": "2026-..." }, + { "id": "6-remove-title", "status": "pending" } + ] +} +``` + +Between destructive steps, **pause and require explicit user go-ahead**. Never remove a field automatically after the cool-off ends. Wait for the user to invoke the skill again with `--continue --step=6-remove-title` (or equivalent). This protects against accidental destruction if a rollback is silently in progress. + +### Step 5: Rollback support + +At any point, the user can invoke: + +- "Roll back the `title → headline` migration." + +Skill reads the state file + the `before.json` snapshot, walks back the completed steps in reverse: + +- Step 6 completed: not applicable (already destroyed). +- Steps 1-5 completed: revert bindings, remove the new field, unbackfill (if user wants, usually leaves headline values populated but bindings point back to title). + +**If Step 6 (remove old field) already ran, rollback is destructive. The data is gone.** Report clearly + ask for confirmation before continuing. + +## Inputs needed from the user + +1. The change (specific: field name, source CT, target state). +2. Target stack UID + management token (from env or explicit). +3. Explicit approval after the migration plan is emitted (dry-run before commit). +4. Confirmation between destructive steps (backfill, cutover, then remove). +5. Optional: cool-off period override (default 14 days). + +## Acceptance + +- [ ] Migration plan is emitted + explicitly approved before any CMA writes. +- [ ] `<ct>-before.json` snapshot captured before any mutation. +- [ ] `<ct>-migration.json` state file updated after every step. +- [ ] Between destructive steps (cutover, remove), user is prompted. The skill does not auto-continue. +- [ ] Every Section binding affected by the change is enumerated + updated. None left dangling. +- [ ] Rollback path documented in the plan + executable via a follow-up skill invocation. +- [ ] For medium/high-risk changes: cool-off period between backfill and destructive step. + +## Common pitfalls + +| Pitfall | Why it bites | Fix | +|---|---|---| +| Direct field rename via CMA `/v3/content_types/<uid>` PUT | CMA does rename the schema, but existing entries retain the old field name in `_data`, so new bindings return null and the live site breaks | Never rename directly. Always dual-write via new field + backfill + cutover. | +| Skipping the `<ct>-before.json` snapshot | Rollback becomes hand-crafted. May miss field metadata (mandatory flags, unique constraints, display names) | Snapshot mandatory. Never proceed without one. | +| Backfilling without idempotency check | Re-run overwrites in-progress author edits | Skip records where the target field is already populated by an author. | +| Auto-continuing after cool-off | Old field removed silently, so downstream systems still expecting it break | Never auto-continue destructive steps. Manual re-invocation required. | +| Not updating Section bindings | Field renamed, Sections still bind the old name, so the live site shows nothing | Enumerate every affected binding + update in the same run. Use `discover-sections` or grep composition JSON. | +| Treating "add required field" as safe | Live entries have no value, so the CT update rejects | Add as optional, backfill all entries, then flip to required. Three-step, not one. | + +## See also + +- [`provision-studio-stack`](provision-studio-stack.md): for initial CT creation. This skill picks up after that. +- [`import-content`](import-content.md): used inside this skill for bulk entry backfills. +- [`agent-idempotency`](../../docs/50-advanced/agent-idempotency.md): canonical state-file + resume pattern this skill follows. +- [`discover-sections`](discover-sections.md): enumerate Sections affected by a schema change, used inside Step 3. +- [`troubleshoot-data-binding`](troubleshoot-data-binding.md): if the site breaks post-migration and bindings return empty, this troubleshoots the "bound to renamed field" failure mode. diff --git a/skills/studio/migrate-page-to-studio.md b/skills/studio/migrate-page-to-studio.md new file mode 100644 index 0000000..4c56471 --- /dev/null +++ b/skills/studio/migrate-page-to-studio.md @@ -0,0 +1,214 @@ +--- +name: migrate-page-to-studio +--- + +## When to use + +FINAL step of a section-first migration: swap one route from hand-coded JSX to `<StudioComponent />` after components, sections, and template exist in Studio. + +Use ONLY after section-first setup is done: "migrate my blog route to Studio", "swap this page to <StudioComponent />". Pre-requisites: components registered, sections built, template built, Studio installed and project configured. Per-route by design, never invoke across multiple routes. Does NOT build sections/templates. If missing, stop and run `build-section` first. + +> **Auth preflight: settle the credential before the first API call.** Resolve it OAuth-first per [`authenticate-cma`](authenticate-cma.md): `CS_OAUTH_ACCESS_TOKEN`, else the Contentstack MCP's stored session: Read that skill for where it is, even with no MCP connected. **Never ask the user for a session `authtoken`.** If nothing resolves, or a refresh fails with `400 invalid_refresh_token`, hand them `! CONTENTSTACK_REGION=<code> npx @contentstack/mcp --auth` (it needs a TTY and a browser, so it cannot be run for them) and wait. `403 error_code 316` is a valid credential aimed at another org: fix the org or the `api_key`, do **not** re-authenticate. + +# Migrate one hand-coded page to a Studio template + +> **Approved plan required.** This skill builds. It does not decide. If there is no plan the user has explicitly approved (Sections with their linked schemas, Section Slots, per-Section components, registered vs built-in, bindings, and the template kind), **stop and run [`plan-studio-architecture`](plan-studio-architecture.md) first**, then come back with the approved block for this piece. Building without it is how a project ends up as one monolithic template with no Sections, no slots and no reuse. Exception: the user is explicitly iterating on a piece already in an approved plan. + +## Context + +If the ask is the whole project rather than one route ("convert my project", "make it Studio-compatible"), stop and run [`convert-project-to-studio`](convert-project-to-studio.md) first. Its minimal conversion wires Studio behind the existing routes without touching them, and this skill is the option-2 follow-on. + +This skill is the **final step** of a section-first migration. The order is: + +1. **Register components** (`register-component`), one-off, per component. +2. **Build sections** (`build-section`), Studio sections that mirror recurring compositions across your routes. +3. **Build templates**: almost always `build-connected-template` (the default. Migrate INTO Connected even for one-off pages, via a single-entry CT). +4. **Swap the route file** (this skill). Only NOW does the route file change. + +If sections or templates don't exist yet, STOP. Run the relevant skills first. See [`docs/40-recipes/migrating-hand-coded-pages-to-studio.md`](../../docs/40-recipes/migrating-hand-coded-pages-to-studio.md) for the full section-first migration recipe. This skill is its Step 5. + +**Catch-all is the destination.** This skill swaps ONE route file at a time, useful for incremental migration. Once you've migrated more than a handful of routes, consolidate to a single catch-all (`app/[[...slug]]/page.tsx` for Next.js, `<Route path="*">` for React Router) via [`setup-template-preview-routes`](setup-template-preview-routes.md). The catch-all lets Studio resolve every URL via its CDA query. You stop maintaining one route file per URL. Per-route swapping is the path. The catch-all is the destination. + +**The consolidation step is not just a route swap.** A catch-all takes over every URL the app didn't explicitly claim, so the header/footer that the replaced pages rendered, and the app's own 404 page, both need re-wiring. `setup-template-preview-routes` step 4a handles this and will ask you to choose wildcard vs dedicated first. Per-route swapping (this skill) leaves chrome and 404 untouched. That's exactly why it's the safe incremental path. + +The route-swap itself is a four-step transform: + +1. **Inventory**: read the route, list the components it uses, list the entry-field-to-prop bindings. +2. **Decide**: for each component: is it registered with Studio? For the route as a whole: does it do anything Studio can't represent (custom fetching, route-level state, A/B in tree)? +3. **Scaffold**: create a Studio Connected template + section structure with the bindings pre-populated. +4. **Swap**: replace the route's JSX with `<StudioComponent specOptions={…} />`. Preserve the original as a commented block above the new render so the user can diff. + +> **Design-fidelity contract: the migrated render MUST be visually identical to the original page.** Migration re-homes the SAME UI into Studio. It never redraws it. This is not aspirational. It is the definition of done, and the user should never have to point out that the design drifted. For every block: +> - **1:1 reuse is the DEFAULT. Decomposition is opt-in.** Each component the original route renders maps to the SAME registered component in Studio, bound to the whole object/array, so the layout is identical by construction, right on the first render, with no correction round-trip. Do NOT split a block into a Repeater + per-item components or child Sections **unless the user explicitly asked to make items author-editable.** Decomposition re-lays-out the block (a uniform sequence instead of the original's bespoke arrangement) and is the #1 cause of first-render drift, the exact failure that makes a user re-prompt "the design doesn't match." When in doubt, reuse whole. +> - **Reuse the EXACT production component**: register it and bind the whole object/array (see [`register-component`](register-component.md)). Never hand-author a lookalike, a "close enough" reimplementation, or a built-in stand-in for a component the app already ships. Registering the real component is the only way brand styling + layout survive. +> - **Never substitute a uniform Repeater for a bespoke/asymmetric layout** (a bento mosaic, a 1-big-plus-small grid, a carousel). A Repeater renders items uniformly in sequence. Different design. Bind the whole array/object to the real component and let its own markup + CSS run (see [`register-component`](register-component.md) § Array & object props: binding rules). +> - **Slots and per-field bindings reproduce the original's structure and order**, not an approximation. +> +> "It renders" is not the bar. "It renders identically to the current site" is. + +The skill is conservative. It stops on anything it can't represent cleanly. Better to surface a blocker than silently strip behavior. + +Reference: `docs/40-recipes/migrating-hand-coded-pages-to-studio.md` (the multi-route playbook), `docs/31-templates/overview.md`, `docs/32-sections/overview.md`. + +## Task + +1. **Read `routeFilePath`.** Parse the JSX. Build an inventory: + - **Components used.** Every JSX element that's a capital-letter identifier (`<Hero>`, `<Body>`, `<RelatedPosts>`, not `<div>`, `<a>`). + - **Prop bindings.** For each component, every prop and the expression assigned to it. Two patterns to recognize: + - Direct field reads: `headline={entry.title}` becomes the binding `headline ← template.title` + - Nested object reads: `cover={entry.featured_image.url}` becomes the binding `cover ← template.featured_image.url` + - **Data fetching.** The entry lookup at the top of the route (`stack.entry(...)`, `useCompositionData(...)`, `getStaticProps`, etc.). Confirm it's a single-entry fetch by uid/slug. Flag anything else. + - **Blockers.** Anything that isn't "render an entry's fields through components": + - `useState`, `useEffect`, `useReducer` inside the route + - Conditional component switches based on runtime state (`{flag ? <A /> : <B />}`) + - Calls to external APIs in the route body + - Custom middleware imports + - A/B / experiment / feature-flag checks affecting the component tree + +2. **Print the convertibility report.** Three sections: + + ``` + ROUTE: app/blog/[slug]/page.tsx + Content type: blog_post + + COMPONENTS USED: + ✅ Hero (registered as `site-hero`) + ⚠ Body (not registered — will run register-component before scaffolding) <!-- style-lint: allow --> + ✅ RelatedPosts (registered as `related-posts`) + + BINDINGS DETECTED: + Hero.headline ← template.title + Hero.subhead ← template.tagline + Hero.cover ← template.featured_image.url + Body.markdown ← template.body + RelatedPosts.ids ← template.related + + BLOCKERS: none + ``` + + If the **BLOCKERS** section is non-empty, **STOP**. Print the blockers, explain that this route isn't a clean migration candidate, and suggest manual conversion or skipping it. Do not attempt to scaffold. + +3. **Register any unregistered components.** For each `⚠ not registered` line, invoke `register-component` (chain the skill) with the component's source file. If a component's source can't be located, stop and ask the user where it lives. + +4. **Scaffold the Studio composition.** With every component now registered, create the Connected template via Studio's web UI flow OR by chaining `build-connected-template`: + - Connected content type: `contentTypeUid` + - URL pattern: `urlPattern` + - Template root: one section (or a sequence of sections) matching the route's render order + - **For each component in the inventory:** drop the registered component onto the section canvas and apply the binding that was detected in step 1 (so the user does not re-author bindings) + - Save the template + +5. **Generate the swap.** Rewrite `routeFilePath`: + + ```tsx + // Before — preserved as a reference comment so you can diff: + // export default async function BlogPost({ params }) { + // const entry = await stack.entry('blog_post', params.slug); + // return ( + // <> + // <Hero headline={entry.title} subhead={entry.tagline} cover={entry.featured_image.url} /> + // <Body markdown={entry.body} /> + // <RelatedPosts ids={entry.related} /> + // </> + // ); + // } + + "use client"; + import { use } from "react"; + import { StudioComponent, useCompositionData } from "@contentstack/studio-react"; + + export default function BlogPost({ params }) { + const { slug } = use(params); // params is a Promise on Next 15+ + const { specOptions, isLoading, error } = useCompositionData({ + url: `/blog/${slug}`, // or the pathname for this route + templateContentTypeUid: "blog_post", // narrows resolution to this CT + }); + if (isLoading) return null; + if (error) throw error; + if (!specOptions) return null; // hook may return null before resolving + return <StudioComponent specOptions={specOptions} />; + } + ``` + + Print the diff to chat for review before writing the file. + +6. **Verify: visual parity against the original, not just "it renders."** BEFORE the swap, capture the current render (Playwright MCP screenshot if available, plus a note of each section's order, key text, and images. Otherwise have the user capture it). AFTER the swap, capture the Studio render the same way and **compare the two**. The migration succeeds only when they match: same sections in the same order, same images, same text, same layout. Then invoke `verify-setup` (Layer 4 canvas). If the renders differ, that is a binding / component / layout defect, not a pass. Common causes: a prop typed `any` collapsing structured data ([`register-component`](register-component.md) § The `any` flatten trap), an unwired container `props.children` ([`author-composition-via-api`](author-composition-via-api.md) § Node anatomy), or a Repeater swapped in for a bespoke layout. Fix it and re-compare before declaring success. **Do NOT hand the user a "done" they have to visually reject.** If a layer fails outright, restore the original JSX from the preserved comment and report which layer broke. + + For a rigorous section-by-section comparison against a still-shipping production URL, each drift classified by cause and fixed at the source (unbound prop, prop-routing, or **layout-wrapper CSS transcribed line-by-line**), looping until no new drift appears, chain [`verify-visual-parity`](verify-visual-parity.md). That skill reads the real design and closes the gap. This step is the gate, `verify-visual-parity` is the tool it hands off to. + +7. **Output the next-step checklist**: a short list of: + - Other routes in the same file tree that look like migration candidates (same content type, similar shape) + - The estimated complexity for each + - The order the user should tackle them in + +## Inputs needed from the user + +In order: + +1. `routeFilePath`: required. Must be a real file the skill can read. +2. `contentTypeUid`: required. Skill does not infer this. The user knows the right CT. +3. `urlPattern`: required. Default to the route's filesystem path with Next.js-style `[slug]` segments converted to `{{entry.slug}}` if obvious. Ask if not. +4. `studioProjectId`: required. + +Do NOT proceed past step 1 (inventory) if the route file cannot be read or parsed. + +## Acceptance + +This skill succeeds only when ALL of the following are true. + +- [ ] The convertibility report listed every JSX component the route uses, no component dropped silently. +- [ ] **Every Section created by this skill has a `ui_preview`**, a blank tile in the Sections accordion otherwise. UI-created sections get one from Studio on Save. API-created ones need `tsx scripts/make-ui-preview.ts --entry <uid>`. See [`author-composition-via-api` § Section thumbnails](author-composition-thumbnails.md#section-thumbnails-ui-preview). +- [ ] Every prop assignment in the original JSX was either represented as a Studio binding OR explicitly listed as a blocker. +- [ ] If any blocker was reported, the skill stopped and did NOT proceed to scaffold. +- [ ] If the skill proceeded - a new Connected template exists in the Studio project with bindings populated. The user did NOT re-author bindings in the canvas. +- [ ] The route file is rewritten - new render is `<StudioComponent specOptions={…} />`. Original JSX is preserved as a commented block above so the user can diff. +- [ ] `verify-setup` ran and Layer 4 (Studio canvas) renders the converted template successfully. +- [ ] **Visual parity confirmed**: the Studio render was compared against the pre-migration render and matches (same sections, order, images, text, layout), not merely "renders." Any drift was traced to a binding / component / layout defect and fixed BEFORE declaring success. +- [ ] A next-step checklist of further route candidates was printed. +- [ ] **The skill operated on exactly ONE route file. Never attempted to migrate multiple routes in one invocation.** + +## Common pitfalls + +| Pitfall | Why it bites | Fix | +| --- | --- | --- | +| Trying to migrate multiple routes in one call | Skill is per-route by design. Bulk migration burns the team out and hides per-route blockers | Invoke once per route. Use the next-step checklist to schedule the next one. | +| Silently dropping JSX behavior that doesn't fit (a `useEffect`, a conditional, an A/B check) | The migrated route renders differently than the original. Bugs land in prod | Stop on blockers. Surface them. Migrate manually, or skip this route. | +| Inferring `defaultValue` or static text from the route's literal strings | Hard-coded strings in JSX are often placeholders for entry fields the dev forgot to bind | Mark hard-coded strings as TODO bindings in the report. Ask the user before scaffolding | +| Skipping `register-component` for already-registered components but using a different `type` UID | Registration silently overrides the existing one | Match the registered component's existing `type` UID exactly. Don't invent a new one | +| Scaffolding the template without first running `register-component` for unregistered ones | The template's bindings reference unregistered types and render as "Component Loading Error" | Always chain `register-component` first. Never assume registration | +| Discarding the original JSX without preserving it as a comment | Rollback requires git history. User can't easily diff visually | Always keep the original as a commented block in the rewritten file | +| Failing to invoke `verify-setup` after the swap | The route may render blank in prod and you wouldn't know until a visitor hits it | Verify before declaring success | +| Declaring success on "it renders" without comparing to the ORIGINAL design | Design drift ships (a crude lookalike, a bespoke layout flattened to a uniform Repeater, a section rendering blank because a prop collapsed) and the USER catches it, re-prompting "the design doesn't match" | Run the before/after visual-parity gate (step 6). Capture the original render first, compare after, fix any drift before declaring done. Reuse the real production component. Never hand-roll or decompose a bespoke layout | +| Rebuilding a block as a hand-authored lookalike instead of registering the real component | Loses the exact styling/layout. The user sees a "close but wrong" render and has to correct it | Register the app's actual component and bind the whole object/array. The design comes for free. See the Design-fidelity contract above | +| Inferring `templateEntryUid` wrong (e.g. passing `params.slug` when the entry is looked up by a different field) | The route fetches the wrong entry or 404s | Re-read the original entry-fetch code carefully. Use the same lookup key Studio's spec resolver expects | + +## Where does call-site display logic go? + +Hand-coded pages routinely do data massage at the call site (before passing to the wrapper) that Studio bindings (bare `field → prop` paths) cannot express. If that logic is silently dropped on migration, the composed page renders with wrong or missing values. Route every piece of call-site logic to one of these five homes, in order: + +| Call-site pattern | Where it goes in Studio | +|---|---| +| **Prop reroute**: `props.copy = entry.short_description ?? entry.description` | Inside the leaf adapter: accept `copy` as prop, adapter selects the source field. | +| **String composition**: `subtitle = "Related to " + entry.title`, `title = "How to " + method` | Bind the raw entry field (`entry.title`, `entry.url`) directly to a component's scalar prop and let the component do the composition (`"Related to " + props.title`). If the composition happens inside a nested section-scope context, expose the composed value as a `string` prop on the section and set it per-composition. | +| **Per-CT / per-branch fallbacks**: `image = entry.tile_data?.thumb ?? entry.image` where the shape differs per content type | Per-branch bindings inside a Condition Block: each branch points at the right path for that CT. See `use-condition-block`. | +| **Boolean flags / literal props**: `<Card isInteractive={false} variant="compact" />` | Registered scalar prop with the call-site literal as `defaultValue`. Missed literals are the #1 cause of "looks similar but off." See `register-component` § Call-site literal sweep. | +| **Computed values**: anything requiring runtime data massage the picker can't do (URL builders, formatters, external lookups) | Flag as a **product gap**, don't hack. Options: (a) do the massage in the host app before passing via `<StudioComponent data={…} />`, (b) do it in the leaf adapter body, (c) file it against the picker's missing coercion (Part 1 #2 in improvements). Do not re-implement complex logic inside the wrapper adapter. That's how drift accumulates unbounded. | + +Rule of thumb: **if the call site is doing anything the Data Picker can't express in a bare path, the migration must decide where that logic lives: adapter / static / branch / product-gap.** Silently dropping it is not an option. + +## Recommended migration shape: top-down + +If the route you're migrating is rendered by a **compound component** (an outer wrapper that iterates and dispatches to inner components), start by authoring ONE Section that wraps the whole compound. Studio renders the existing page inside its canvas in ~5 minutes with no source-code changes. Marketing gains a real win (change bound content, publish without a deploy) at that first checkpoint. + +Only decompose further when a specific inner level needs to become author-editable: reorder body blocks, swap a variant, add a new block type. At each drill-down step: author the next Section, preview it in Studio's canvas, verify the inner sub-tree still renders, then decide whether to go deeper. Every step is opt-in. Every step is reversible. + +For each Section you author along the way, use [`build-section`](build-section.md) or [`build-repeating-section`](build-repeating-section.md). Full worked example with a 4-level nested schema: [From components to Studio compositions](../../docs/00-overview/standard-studio-page-anatomy.md). + +## See also + +- `docs/40-recipes/migrating-hand-coded-pages-to-studio.md`: the multi-route migration playbook (this skill is per-route. That recipe is the program around it) +- `docs/40-recipes/add-studio-to-a-visual-editor-app.md`: Visual Editor projects don't migrate. They layer Studio on top of VE. This skill is optional on VE projects (use it only for routes where you want to switch from hand-coded JSX to a Studio-authored composition). +- `register-component`: chained by this skill for unregistered components +- `build-section` / `build-repeating-section`: chained per Section written during the migration +- `build-connected-template`: chained by this skill for the template scaffold +- `verify-setup`: chained by this skill at the end +- `docs/31-templates/connected-content-type.md`: the model this skill produces diff --git a/skills/studio/pin-entry-to-freeform.md b/skills/studio/pin-entry-to-freeform.md new file mode 100644 index 0000000..4df44e0 --- /dev/null +++ b/skills/studio/pin-entry-to-freeform.md @@ -0,0 +1,111 @@ +--- +name: pin-entry-to-freeform +--- + +## When to use + +Pin a Contentstack entry to a Freeform template, or to any template or Section, via the Pinned Entries section so components there can bind to the entry's fields. Use Studio UI, driven through Playwright per [`drive-studio-canvas-ui`](drive-studio-canvas-ui.md). Don't hand the clicks to the user or switch to the API unless they ask; that's [`author-composition-via-api`](author-composition-via-api.md). + +Use on Freeform templates needing to bind components to a specific entry — "pin this entry", "I can't find my entry on this template", "Pinned Entry missing from Data Picker" — and on a Connected template or Section that needs one fixed entry beside the page's own, such as a header or footer reading a shared site-settings entry. A Connected template's own entry binds through its content type, not a pin; for dynamic lists use `pin-query-to-freeform`. + +> **⛔ Auth preflight — settle the credential before the first API call.** Resolve it OAuth-first per [`authenticate-cma`](authenticate-cma.md): `CS_OAUTH_ACCESS_TOKEN`, else the Contentstack MCP's stored session: Read that skill for where it is, even with no MCP connected. **Never ask the user for a session `authtoken`.** If nothing resolves, or a refresh fails with `400 invalid_refresh_token`, hand them `! CONTENTSTACK_REGION=<code> npx @contentstack/mcp --auth` — it needs a TTY and a browser, so it cannot be run for them — and wait. `403 error_code 316` is a valid credential aimed at another org: fix the org or the `api_key`, do **not** re-authenticate. + +# Pin Entry to Freeform Template + +> **Prerequisite:** this skill assumes you've already chosen Freeform via `choose-connected-vs-freeform`. If you haven't, run that first — Connected is the recommended default in Studio and most pages should use it (including one-off pages, via a single-entry CT). Freeform is the documented exception, not a parallel option. This skill remains valid for the legitimate Freeform cases; it just shouldn't be your starting point. + +## Context + +Freeform templates have no inherent connection to a content type, so by default their components have nothing to bind to beyond `template.*` exposed props and static values. The **Pinned Entries** section on the template's Data tab lets an author "pin" one or more Contentstack entries to the template; once pinned, every component on the template can bind props to that entry's fields through the Data Picker under the **`Pinned Entries` → <title>** root. + +Pinned Entries is on every composition type: Freeform and Connected templates and Sections (checked 2026-10-07 in a project with Enable Freeform Feature on). A Connected template binds **its own** entry through `Linked Template Entry`; pin an entry only for data that is not the page's own, such as a shared site-settings entry. **For a header or footer**, pin the settings entry on the header or footer **Section itself** (open the Section, Data tab), not on each template: every placement is then filled with no per-placement setup ([`plan-studio-architecture`](plan-studio-architecture.md) § 0c). + +**A placed section is rooted, not bound field by field.** The pinned-entry root above is for components authored directly on the template. A *section* dropped on the template resolves through its own per-placement **Section Data** row (right panel → **Properties**): root the placement at the pinned entry via the **Entire Entry** row, or at a field inside it, and the section's internal bindings resolve against that document. The Section Data picker offers only targets whose structure matches what the section declared, so an incompatible entry simply doesn't appear. Reference: `docs/32-sections/binding-to-cms.md` § *Part 3 — Section Data*. + +Reference: `docs/33-freeform/pinned-entries.md`. + +Mental model: + +- Pinned entries are a **template-level** resource. They live on the template, not on any single component. +- They show up in the Data Picker as a binding root, **alongside** (not replacing) `template.*` exposed props, `Component Default Data`, and any `Pinned Queries`. +- The Data tab is **context-sensitive**: with nothing selected it shows template-level config (Pinned Entries, Pinned Queries); with a component selected it shows that component's bindings. You must deselect to reach the pin UI. + +## Task + +1. **Confirm the template is Freeform.** Open `templateName` in Studio and look for the **FREEFORM MODE** badge on the canvas. If you see a Connected template indicator instead, stop — pinning does not apply; bind via `Linked Template Entry`. + +2. **Verify Freeform is enabled at the project level.** In project settings, confirm **Enable Freeform Feature** is on. Without it, the Data tab does not render at all and you'll incorrectly conclude pinning is broken. + +3. **Deselect any component on the canvas.** Click an empty area of the canvas so no component is selected. If a component is selected, the Data tab swaps to component-level bindings and Pinned Entries is not visible. + +4. **Open the Data tab and expand Pinned Entries.** Right panel → **Data** tab → expand the **Pinned Entries** accordion → click **Link Entry**. + +5. **Pick the entry.** In the entry picker modal: + - Filter by content type using `entryContentTypeUid`. + - Search by title using `entrySearchTerm`. + - Select the matching entry. + - Confirm. + +6. **Verify the pin landed.** The Pinned Entries section should now list the pinned entry with its title and content type label. It will remain visible whenever the template is loaded with no component selected. + +7. **Bind a component prop to a pinned-entry field.** Select any component on the canvas, click the binding chip next to a prop in the right panel, and in the Data Picker navigate **`Pinned Entries` → <entry title> → <field>**. Confirm. The canvas should re-render with the live field value. + +8. **(Optional) Bindings inside a Repeater.** If you bind a Repeater's source to a reference field on the pinned entry, every reference inside that Repeater — single-CT or multi-CT — needs a **Condition Block** before its fields are reachable. Iteration-scoped bindings use `repeater.<field>`; template-level bindings continue to use `template.<field>`. Pinned-entry bindings appear as their own root and are neither. + +## Inputs needed from the user + +Collect in this order. If any is missing, ask before clicking anything in Studio. + +1. `templateName` — the Freeform template to pin onto. +2. `entryContentTypeUid` — the content type UID to filter by in the picker. +3. `entrySearchTerm` — title or UID to locate the specific entry. + +## Acceptance + +This skill succeeds only when ALL of the following are true. If any fails, surface the failure and stop. + +- [ ] The template displays the **FREEFORM MODE** badge. +- [ ] **Enable Freeform Feature** is on at the project level. +- [ ] With nothing selected on the canvas, the right panel's **Data** tab shows **Pinned Entries** with the pinned entry listed (title + content type). +- [ ] With a component selected, the Data Picker shows **`Pinned Entries` → <title>** as a binding root. +- [ ] A prop bound via `Pinned Entries → <entry title> → <field>` renders the entry's live value on the canvas — verified by inspecting a PNG screenshot of the canvas iframe (a11y snapshots are opaque to iframe contents). +- [ ] Unpinning the entry removes the **Pinned Entry** root from every component's Data Picker on that template. + +## Verifying the runtime render + +If you want to confirm the pinned entry flows through at runtime (not just in Studio), the canvas-app side uses: + +```tsx +// SSR — fetchCompositionData THROWS on a miss (it does not resolve with +// hasSpec:false), so an unwrapped call 500s instead of 404ing. A compositionUid +// lookup throws COMPOSITION_UID_NOT_FOUND (not NO_COMPOSITION_FOR_URL). Wrap it: +// configure-csr-vs-ssr § "fetchCompositionData does not return not-found". +const { specOptions, notFound: miss } = await resolveComposition(sdk, { + compositionUid: "<your-freeform-uid>", +}); +if (miss) return notFound(); +return <StudioComponent specOptions={specOptions} />; +``` + +The pinned entry data is embedded inside `specOptions.spec` — you do NOT pass it via the `data` prop. `data` is a separate `Record<string, any>` for [runtime Component Default Data](../../docs/20-bring-your-own-components/set-component-default-data.md#part-2-runtime-component-default-data-the-data-prop-on-ltstudiocomponent-gt) (external data outside Contentstack — live pricing, geo, etc.). + +The Freeform composition URL only carries `{{composition_uid}}` plus `{{environment}}` and `{{branch}}` — it does **not** carry `{{entry.*}}` or `{{content_type_uid}}`. The pin is resolved server-side from the composition itself. (`{{locale}}` is accepted by the pattern engine but not recommended in URL patterns; carry locale via your routing layer + the SDK `locale` query option.) + +## Common pitfalls + +| Pitfall | Symptom | Fix | +| --- | --- | --- | +| Pinning the page's own entry on a Connected template | It already binds through `Linked Template Entry`; a pin duplicates it | Pin only other, fixed entries (shared site settings); bind the page's own fields through `Linked Template Entry` | +| A component is selected while opening the Data tab | Data tab shows component bindings; no Pinned Entries section | Click empty canvas to deselect, then reopen the Data tab. | +| Enable Freeform Feature is off at the project level | Data tab does not render at all on Freeform templates | Toggle Enable Freeform Feature on in project settings. | +| Picker filter not set to `entryContentTypeUid` | Wrong / too many entries in the search list | Apply the content-type filter before searching by title. | +| Reference field inside a Repeater bound without a Condition Block | Reference fields don't appear in the Data Picker under the repeater iteration | Wrap the reference in a Condition Block (required for single-CT and multi-CT alike). | +| Section dropped on the template renders component defaults | Pinning an entry does not bind a placed section — a section resolves through its own root, not the template's binding roots | Select the placement → **Properties → Section Data** → pick the pinned entry (**Entire Entry**) or a matching field inside it | +| Mixing iteration vs template scope | `repeater.<field>` used outside a Repeater, or `template.<field>` used for per-iteration data | Use `repeater.<field>` only inside a Repeater iteration; `template.<field>` only for template-level exposed props; `Pinned Entries` → …` is its own root. | + +## See also + +- [`docs/33-freeform/pin-specific-entries-to-a-component.md`](../../docs/33-freeform/pin-specific-entries-to-a-component.md) — full reference. +- Pair with [`build-freeform-template`](build-freeform-template.md) when introducing the template for the first time. +- Pair with [`expose-section-props`](expose-section-props.md) when the same template also needs author-editable `template.*` props alongside pinned-entry bindings. +- [`docs/32-sections/binding-to-cms.md`](../../docs/32-sections/binding-to-cms.md) § *Part 3 — Section Data* — rooting a placed section at a pinned entry, a field inside it, or a query. diff --git a/skills/studio/pin-query-to-freeform.md b/skills/studio/pin-query-to-freeform.md new file mode 100644 index 0000000..7209714 --- /dev/null +++ b/skills/studio/pin-query-to-freeform.md @@ -0,0 +1,61 @@ +--- +name: pin-query-to-freeform +--- + +## When to use + +Pin a CDA query (filter + sort + limit) to a Freeform template so Repeater sources iterate dynamic content like top 3 featured products. Use Studio UI, driven through Playwright per [`drive-studio-canvas-ui`](drive-studio-canvas-ui.md). Don't hand the clicks to the user or switch to the API unless they ask; that's [`author-composition-via-api`](author-composition-via-api.md). + +Use when a Freeform template needs to iterate a dynamic list of entries (top 3 featured products, latest 5 articles, sorted catalog). Phrases: "pin a query", "iterate latest entries", "filtered list on freeform". Re-runs every render so content stays fresh. For single-entry pinning use `pin-entry-to-freeform`. + +> **Mandatory auth preflight: settle the credential before the first API call.** Resolve it OAuth-first per [`authenticate-cma`](authenticate-cma.md): `CS_OAUTH_ACCESS_TOKEN`, else the Contentstack MCP's stored session: Read that skill for where it is, even with no MCP connected. **Never ask the user for a session `authtoken`.** If nothing resolves, or a refresh fails with `400 invalid_refresh_token`, hand them `! CONTENTSTACK_REGION=<code> npx @contentstack/mcp --auth` (it needs a TTY and a browser, so it cannot be run for them) and wait. `403 error_code 316` is a valid credential aimed at another org: fix the org or the `api_key`, do **not** re-authenticate. + +# Pin a CDA query to a Freeform template + +> **Prerequisite:** this skill assumes you've already chosen Freeform via `choose-connected-vs-freeform`. If you haven't, run that first. Connected is the recommended default in Studio and most pages should use it (including one-off pages, via a single-entry CT). Freeform is the documented exception, not a parallel option. This skill remains valid for the legitimate Freeform cases. It just shouldn't be your starting point. + +## Context + +Freeform templates can pin a CDA query so a Repeater iterates a dynamic list of entries, for example the top 3 featured products or the latest 5 articles. The query (filter + sort + limit) is stored on the template and re-runs on every render, so content stays fresh as authors publish new entries. Reference doc: `docs/33-freeform/pinned-queries.md`. Pinned queries differ from pinned entries: a pinned entry returns a single record (bind directly to `template.*`), while a pinned query returns a list and must be consumed by a Repeater whose items resolve against `repeater.*`. + +## Task + +1. Confirm you are on a Freeform template: the canvas header shows the `FREEFORM MODE` badge. +2. Deselect any component on canvas (click empty canvas area) so the right-panel Data tab shows template-level pins, not component-scoped ones. +3. In the right panel, open the **Data** tab, find the **Pinned Queries** section, and click **Create Query**. +4. In the New Query modal, fill the required **Name** and **Content Type** fields. +5. Configure the query one of two ways: + - **By hand**: set the **filter**, **order**, and **limit** fields directly. + - **Natural language**: type a prompt in the Tell me what data you need box. Studio converts the prompt to a stored CDA query at save time. This conversion is one-shot: it is NOT re-evaluated at render. To tune the query later, re-open the modal. +6. **Save** the query. It appears in the Pinned Queries list. +7. Use the query on canvas: drop a **Repeater** component, click **Bind items** in Properties, and pick **Pinned Queries**, then `<name>`. +8. Drop a card or item component inside the Repeater body. Its bindings resolve against `repeater.*`: each iteration represents one entry from the query result. +9. Every item is an entry of the one content type you picked in step 4, so the Repeater itself needs no **Condition Block**, and neither does a reference field that points at a single CT. Add a Condition Block only where an item renders a **multi-CT** reference field and needs a different layout per referenced content type (a content-type check on that reference). See `use-condition-block`. + +## Inputs needed from the user + +| Input | Prompt | +| --- | --- | +| `templateName` | Freeform template name | +| `contentTypeUid` | Content type the query returns | +| `filterExpression` | Filter (e.g. `featured = true`) or natural-language prompt | +| `sortBy` | Sort spec (e.g. `price asc`) or blank | +| `limit` | Result limit (e.g. 3, 5, 10) | + +## Acceptance + +- In the target Freeform template's right panel, the Data tab's Pinned Queries list shows the new query by name. +- Dropping a Repeater on canvas and opening **Bind items** shows the query under **Pinned Queries**. +- After binding the Repeater and adding a child card with `repeater.*` field bindings, the canvas (with Repeater Preview Mode on) renders one card per returned entry, respecting filter, sort, and limit. +- Publishing a new entry that matches the filter causes it to appear in the rendered list on the next render, no template edit required. + +## Common pitfalls + +| Pitfall | Why it bites | Fix | +| --- | --- | --- | +| Leaving filter and sort blank | Returns an unbounded, arbitrarily ordered result set | Always set at least a filter or a sort plus a sensible limit | +| Expecting the natural-language prompt to re-evaluate at render | The prompt is converted to a stored CDA query once at save time. Render uses the stored query only | To change query behavior, re-open the Pinned Query modal and edit (or re-prompt) | +| Binding a Repeater to a single-result query | A Repeater expects a list. One entry is wasted scaffolding | Use `pin-entry-to-freeform` for single-entry pinning and bind directly to `template.*` | +| Selecting a component before opening the Data tab | Tab shows component-scoped pins, not template-level | Click empty canvas to deselect, then open Data tab | +| Forgetting a Condition Block for a multi-CT reference inside the Repeater | Each referenced entry renders with the same layout whatever its content type | Wrap only multi-CT reference fields that need a layout per content type in a Condition Block (step 9). See `use-condition-block` | +| Binding child fields to `template.*` instead of `repeater.*` | `template.*` resolves to template-level pins, not the current iteration item | Inside a Repeater body, child bindings must use `repeater.<field>` | diff --git a/skills/studio/plan-studio-architecture.md b/skills/studio/plan-studio-architecture.md new file mode 100644 index 0000000..d673de3 --- /dev/null +++ b/skills/studio/plan-studio-architecture.md @@ -0,0 +1,684 @@ +--- +name: plan-studio-architecture +--- + +## When to use + +Analyze the starting point (existing codebase, part-built Studio project, a design, or nothing) and print an architecture plan for approval: Sections + linked schemas, Section Slots, registered vs built-in components, CTs + Global Fields, Connected vs Freeform per page, build order. + +Use when the user describes a project and asks "what should I build", "how should I structure this", "Section or Template", "what CTs do I need", "build order". **Also run FIRST for any build request that isn't one already-scoped piece** ("build me a rewards page", "implement this design in Studio", "add this page to Studio", "build the site") because those go straight to a monolithic template otherwise. Also defensively when `build-section`/`build-connected-template` is invoked without decomposing first. Do NOT use to actually build. Do NOT use before concept ladder is crisp (run `start-here-zero-knowledge`). + +> **Auth preflight: settle the credential before the first API call.** Resolve it OAuth-first per [`authenticate-cma`](authenticate-cma.md): `CS_OAUTH_ACCESS_TOKEN`, else the Contentstack MCP's stored session: Read that skill for where it is, even with no MCP connected. **Never ask the user for a session `authtoken`.** If nothing resolves, or a refresh fails with `400 invalid_refresh_token`, hand them `! CONTENTSTACK_REGION=<code> npx @contentstack/mcp --auth` (it needs a TTY and a browser, so it cannot be run for them) and wait. `403 error_code 316` is a valid credential aimed at another org: fix the org or the `api_key`, do **not** re-authenticate. + +# Plan the Studio architecture from project requirements + +## Purpose + +Given requirements, classify Sections, Connected templates, the content model, and build order. Planning-only. Produces a printed plan and hands off to `byoc-end-to-end`. + +## The four-question decision framework: applied to the plan's component inventory + +Every component in the emitted plan carries a Q1 to Q4 verdict from the [decision framework](../../docs/00-getting-started/composable-primitives.md#four-questions-to-answer-when-building-any-component). The plan produces the sequence: "here's what to register, in what order, with what shape". The framework's four questions decide the shape. + +- **Q1. Classify: Atom, Layout, or Compound?** For each component in the plan's inventory, classify what shape it is. Atom means a single content unit. Layout means a slot-carrying arrangement. Compound means refuse. Decompose into atoms + layouts, add each fragment to the inventory, and reclassify. The plan's component list has zero compounds. +- **Q2. Does the registry already have it?** Check the existing project registry AND earlier steps of THIS plan's build order. If a primitive matching the classified shape exists, the plan marks it as reused. If not, it adds a new-registration step to the build order before any Section that depends on it. +- **Q3. Which props?** Each primitive's proposed schema is emitted with a per-prop check against the varies-per-instance-AND-functional-intent rule. Refuse polish props at plan time, cheaper than discovering them in code review. +- **Q4. Which are Exposed?** Each schema prop's exposure status is emitted at plan time. This lets the design review sign off on the exposure surface BEFORE registration ships. + +**The plan halts** if any component's Q1 to Q4 verdict is ambiguous. Ask the user to disambiguate. Add the resolution to the plan. Only then proceed to the build order. + +## Task + +### Step 0: Analyze the real inputs first. A plan is ALWAYS produced. + +**Every path ends in a plan**: existing codebase, partially-built Studio project, a design, or nothing but a sentence. What changes is where the facts come from. Never plan from prose when code exists: you'll invent Sections for components that are already there and miss the ones that matter. + +Detect the scenario, then gather: + +```bash +# Is there an app to read? +ls package.json 2>/dev/null && node -e "console.log(Object.keys(require('./package.json').dependencies||{}).filter(d=>/contentstack|next|react/.test(d)).join(' '))" + +# Routes / pages that already exist — include src/app, and expect locale segments +find app src/app pages src/pages src/routes \( -name 'page.*' -o -name '*.astro' -o -name '*Route.*' \) 2>/dev/null | head -40 + +# Components that already exist, and which are already registered with Studio +ls src/components components 2>/dev/null | head -40 +grep -rn "registerComponent" src app lib 2>/dev/null | head -20 + +# Studio already wired? (compositions, canvas route, init) — grep by CONTENT; +# a fixed path list produces false negatives on src/app and locale-prefixed routes +grep -rln "StudioComponent\|StudioCanvas\|studioSdk.init" src app pages 2>/dev/null | head +``` + +#### 0a: Ask the project what already exists. Code cannot tell you. + +The greps above inventory the **codebase**. They cannot see the Sections and Templates already authored in the Studio project, so a plan built from code alone proposes rebuilding pieces that are already there, and misses the reuse the user is paying for. + +List them, and score reuse from the live tree rather than by eye: + +```bash +# Every composition in the project, split into Sections and Templates, with the +# number of Templates each Section is actually placed on. +curl -s "$STUDIO_API/projects/$PROJECT/compositions?limit=100" -H "$CS_AUTH" \ +| python3 -c ' +import sys, json, zlib, base64, collections +cs = json.load(sys.stdin).get("compositions", []) +use = collections.Counter() +for c in cs: + ls = c.get("linked_sections") + ls = json.loads(ls) if isinstance(ls, str) else (ls or []) + for r in ls: use[r.get("uid")] += 1 +secs = [c for c in cs if c.get("place_composition_as") == "section"] +pages = [c for c in cs if c.get("place_composition_as") in ("page", "template")] +print(f"templates: {len(pages)} sections: {len(secs)}") +for c in sorted(secs, key=lambda x: -use[x["uid"]]): + n = use[c["uid"]] + slug = c.get("composable_uid") or c["uid"] + print(" %2d template(s) %s" % (n, slug)) +' +``` + +The listing caps at 100. A larger project needs `&skip=100`, `&skip=200` … merged before counting, or the tail +of the inventory silently reads as zero-use. + +Read the output as a reuse map: + +- **Used by 2+ Templates**: proven reusable. Reuse it. Never author a second copy. +- **Used by 1**: reusable in principle. Before adding a near-identical Section, check whether one exposed prop covers both (§ Reuse across Templates). +- **Used by 0**: orphaned. Either the plan places it, or it is dead weight worth reporting. + +**Reuse tells you WHAT exists. It does not tell you HOW it is built.** A plan that proposes new Templates without +matching the project's existing build pattern produces work that renders but is structurally foreign, no Repeater, no +Condition Blocks, nowhere for an author to drop a Section. Run [`match-existing-pattern`](match-existing-pattern.md) +now, before the plan names a single new composition, and state the chosen pattern in the plan. It is mandatory +whenever the listing above returns any composition at all. + +**A Section binds `template.<field>`, so two Templates can share it only if their page content types name that field identically.** Sections whose binding fields differ are not reuse candidates however alike they look, see [`decompose-blocks-page`](decompose-blocks-page.md) § Step 5. State that limit in the plan rather than promising reuse the schema cannot deliver. + +#### 0b: Classify EVERY component. No component goes unexamined. + +Routing "decompose the coarse ones" isn't coverage: coarse is a judgment call, and the component that quietly holds four atomics is exactly the one that gets waved through. **Walk the whole component inventory and record a verdict for each**, using one test: + +> **Does it render more than one heading / image / body / list?** If yes it is **coarse**: read its render tree via [`decompose-jsx-to-atomics`](decompose-jsx-to-atomics.md). If no, it is genuinely atomic: register it as-is. + +```bash +# Every component file, with a rough leaf count to triage by +for f in $(find src app components -name '*.tsx' -o -name '*.jsx' -o -name '*.js' 2>/dev/null | grep -iE 'component|section|block|card|hero|header|footer|nav|menu'); do + n=$(grep -oE '<(h[1-6]|p|img|ul|ol)\b' "$f" 2>/dev/null | wc -l | tr -d ' ') + m=$(grep -c '\.map(' "$f" 2>/dev/null) + echo "$n leaves, $m maps $f" +done | sort -rn | head -40 +``` + +Print the verdict table in the plan. **An unclassified component is an incomplete plan**: + +| Component | Leaves | Verdict | Action | +|---|---|---|---| +| `<RewardsPage>` | 9 | coarse | decompose into 3 Sections | +| `<Marquee>` | 1 | atomic | register as-is | +| `<Header>` | 6 | coarse **+ shared** | decompose into one shared Section (see 0b) | + +#### 0c. Shared shells first: header, footer, nav, menus + +These are the highest-value reusable Sections in any project and the ones most often missed, because **route-based scanning under-counts them**. They usually live in a layout (`app/layout.tsx`, `_app.tsx`, a `<Shell>`), so a scan that counts route files sees them once and scores them low, even though they render on **every page**. + +```bash +# Shared shells: check the LAYOUT, not the routes +grep -rn "Header\|Footer\|Nav\|Menu\|Breadcrumb" \ + app/layout.* src/app/layout.* app/**/layout.* pages/_app.* src/App.* 2>/dev/null | head +``` + +Treat anything rendered from a layout as **shared by construction**. Reuse count is effectively every page, whatever the route scan says. For each: + +| Shell | Decompose into | Binds to | The list inside is | +|---|---|---|---| +| **Header** | logo (`imageurl`) · brand/site name (`string`) · nav (list) · CTA (`href` + label) · optional search slot | a Global Field reused by every page CT, or a singleton `site_config` entry | **nav links in a Repeater** over a multi-Group / multi-Reference, never hardcoded `<li>`s | +| **Footer** | logo · legal/copyright (`string`) · link columns (nested list) · social icons (list) | same Global Field / `site_config` | **columns in a Repeater**, and **links inside each column in a nested Repeater**, see `use-repeater` § nested | +| **Nav / menu** | one link atomic per item: label (`string`) + href (`href`) | the multi-valued field the Repeater iterates | the Repeater's child Section: build once, reuse in header, footer, sidebar, mobile drawer | +| **Breadcrumb** | label + href per crumb | derived or a multi-Group | Repeater | + +**How a shell reaches a singleton entry:** pin the `site_config` entry on the header and footer **Sections themselves** (Data tab → Pinned Entries, see [`pin-entry-to-freeform`](pin-entry-to-freeform.md)), with no `linked_schemas`, and place them on Templates with no selected field. A reference field on every page type instead leaves header and footer blank on each new entry until an editor picks the settings entry. + +**Two failure modes to head off explicitly:** + +- **Hardcoded nav.** A `<ul>` of literal `<li><a href="/deals">Deals</a></li>` means an author cannot add a menu item without a developer. Every menu item is a **Repeater iteration bound to a field**. That is the whole point of putting the header in Studio. +- **A shared shell rebuilt per page.** If the plan lists "Homepage Header" and "Deals Header", that's one Section with the same binding twice. Name it once, reuse it, and use an **exposed prop** for any per-page variation (a different CTA label, a transparent variant). + +| Scenario | Signals | Gather from | Notes | +|---|---|---|---| +| **A. Existing codebase** | `package.json` + route/component files present | **[`decompose-jsx-to-atomics`](decompose-jsx-to-atomics.md) FIRST** on any page-level or coarse component: it reads render trees and produces the atomics/Sections this plan then assembles. Then [`discover-sections`](discover-sections.md) for what recurs across routes, [`analyze-project-fit`](analyze-project-fit.md) for install state + data center, and [`discover-sections-from-ct`](discover-sections-from-ct.md) if the stack already has populated content types | The scan is the source of truth. The user's description is context. Plan against what's on disk. Skipping decomposition here is what yields one Section per page. `discover-sections` counts component names and cannot see inside a monolith | +| **B. Partially-built Studio project** | `registerComponent` calls, existing compositions, `/canvas` route | Everything in A, plus list existing Sections/Templates in Studio | **Plan the delta, not a rebuild.** Anything already correct is `[exists]` and gets left alone | +| **C. Design / Figma / screenshot** | User supplies a design, no code | [`decompose-design`](decompose-design.md) (one page) or [`decompose-site`](decompose-site.md) (several) | Feed its component/Section inventory into Step 2 | +| **D. From scratch, prose only** | No code, no design | The user's `requirements` text | Ask for detail if it's a one-liner. Don't plan off a single sentence | +| **E. Whole-project conversion, scope not chosen** | Existing codebase plus a "convert / make Studio-compatible" ask with no page named | [`convert-project-to-studio`](convert-project-to-studio.md) **first**: its gate offers minimal (recommended), one page, or full site | Plan only for what the user scoped there (option 2: one page, option 3: every page type). Option 1 needs no plan from this skill | + +**Print an inventory of what already exists before planning anything new**: registered components, Sections, Templates, content types, Global Fields. Every plan item in Step 9 then carries a status so the user sees the delta: + +- `[exists]`: already correct, no work +- `[modify]`: exists but needs a change (a prop added, a binding fixed, a slot carved) +- `[create]`: new + +A plan that lists everything as `[create]` on an existing codebase means Step 0 was skipped. + +### Step 1: Parse the requirements into a list of pages and features + +Combine the Step 0 analysis with the user's `requirements` and extract (**where they disagree, the code wins**: the user's description is often about intent, the code is about reality): + +- **Pages**: distinct URLs / routes the visitor sees (homepage, product detail, blog post, about, contact, category landing, etc.). +- **Reusable structural blocks**: the parts that appear on multiple pages (header, hero, feature grid, testimonial strip, CTA, footer, related-items, etc.). +- **Data sources**: content types the user already has (or wants), external data hints (Shopify, third-party API), pinned-query language ("show all products tagged X"). + +Print the parsed list back. Ask the user to confirm or correct. + +### Step 2: Classify reusable structural blocks as Sections (NOT pages) + +**Golden rule: when a piece qualifies as a Section (build BEFORE touching a Template).** Skills sometimes jump straight to Template authoring and drop components + bindings directly on the Template. That's wrong whenever either of the two conditions below fires. The piece must be built as a Section first, then dropped on the Template. + +**Sections are the default. Reuse is a prioritization signal, NOT a qualification gate.** A page is decomposed into Sections that a Template assembles. That's the idiomatic Studio shape, and it holds at reuse count 1. Do not produce a plan whose Template is one monolithic `ui` tree of directly-dropped components because "this page is a one-off." A single-use page still gets its hero, its form, its content band as separate Sections. + +Why, beyond reuse: + +- **It's the authoring surface.** Sections are what a template author sees, reorders, swaps, and drops into slots. Components dropped straight onto a Template give authors a flat tree with no meaningful units. They can re-bind props and nothing else. +- **Independent preview.** A Section renders on its own `/canvas` and can be verified in isolation. A monolithic Template can only be checked by loading the whole page. +- **Scoped data.** `selectedField` scoping exists at the Section layer. Inline everything on the Template and every binding is an absolute path off the page entry. +- **Reuse arrives later.** "One-off" pages routinely grow a second instance. Retrofitting a Section after the fact means re-authoring bindings. Starting with one costs two extra compositions today. + +Three conditions make a Section **mandatory**, but the default already is a Section, so these are the cases where skipping it is a defect, not a judgment call: + +1. **Reuse across Templates.** A bound compound component appearing on more than one Template MUST become a Section. Otherwise every Template repeats the drop-and-bind work and any binding tweak is applied N times. **Reuse count 1 does not exempt a piece from being a Section**. It only lowers its build priority. +2. **Schema iteration with bindings.** Any Modular Block field, multi-Reference field, group with `multiple: true`, or otherwise-iterated collection whose items need bound rendering MUST become a **List Section**, not authored inline on the Template. The iteration wiring belongs at the Section layer where it's reusable and testable. Putting it at Template level fragments it across every Template that needs the same iteration. + + **A List Section has TWO build paths: pick by layout fidelity, don't default to Repeater.** If the collection has a **bespoke or asymmetric layout** (a mosaic, a feature strip, a carousel), or you are **migrating and must preserve an existing design**, build it by binding the whole array to the real production component (`type: "array"`, see [`register-component`](register-component.md) § Array & object props), so the component's own layout renders pixel-identically on the first pass. Use a **Repeater + Condition Block** only when the list is a **uniform** grid/row, OR items are **polymorphic** and need per-item authoring. A Repeater renders items uniformly in sequence. Choosing it for a bespoke layout is the #1 cause of first-render design drift (the "why is it a plain sequence?" re-prompt). +3. **Any bound region an author should be able to move, swap, or remove.** If it's a recognizable page part (hero, form band, feature grid, CTA) it's a Section, reuse count irrelevant. + +Build the Section first (`build-section` for Simple, `build-repeating-section` for List), THEN drop it on the Template. Direct-drop of registered components on a Template is reserved for genuine one-offs that are not a page part (a stray spacer, a single decorative element) and should be a rare line in the plan, called out as an exception with a reason. + +For each reusable structural block (header, hero, footer, feature grid, etc.) mark it as a **Section**. Sections aren't pages. They're units that pages assemble. + +For each Section, identify what it should bind to: + +- **Bound to a Global Field** if the same field set is reused across multiple CTs (e.g. a `card_meta` GF with title + image + cta, used by ProductCard, FeaturedCategoryCard, RelatedItemCard, etc.) +- **Bound to a CT** if the Section is specific to one CT's shape (e.g. ProductCard bound to `product` CT) +- **Bound to a Group / Modular Block / Reference field** on a parent CT if the Section renders the nested shape (these are usually child Sections that drop into a parent's Slot, see Path B in `build-section`) +- **Atomic / static** if the Section is layout-only with no CMS bindings (rare. Usually still has at least a brand-config reference) + +For any Section whose linked schema has Global Field / Modular Block / Group / Reference fields, flag those as **Slot candidates** (per the heuristic in `understand-section-slots`). Default to exposing a Slot. Only inline when certain the nested shape is single-use. + +Also flag each Section's **`selectedField` decision**. Studio scopes Section data via `getScopedData(pageEntry, selectedField)`, and the set-vs-unset choice depends on the Section's data shape: + +- **One Repeater over one field** (e.g. Card Grid over `related_posts`): set `selectedField = <that field>`. The Section reads `template` as the field's value directly. +- **Multiple fields on the page entry** (e.g. Header reading `brand` + `nav_links` + `signin_label`): leave `selectedField` unset. `template` becomes the whole page entry. Keep original `template.<field>` paths. + +Setting `selectedField` on a multi-field Section loses access to everything outside the scoped path. See [`build-section`](build-section.md) § How a Section gets its data for the mechanism and [`author-composition-via-api`](author-composition-via-api.md) § Decision rule for the wire shapes. + +### Step 2b: Pick the RIGHT primitive per piece. `registerComponent` is not the default answer. + +**The failure mode this prevents:** everything becomes a registered component, because registering is the easiest thing to do. The plan then looks complete while the author ends up with a page they can only re-bind (no reordering, no swapping, no restyling, no reuse) and never discovers that Studio had a primitive for what they wanted. Registering is one tool out of many, and it is the least author-editable of them. + +**Migrating an existing design? Fidelity outranks editability on the first pass.** Pick the primitive that reproduces the current render exactly, usually reuse the real production component whole (`object` / `array` binding), because its own markup + CSS already are the design. Introduce built-in decomposition and Repeaters as a SECOND, opt-in phase, once the user has confirmed the render matches and explicitly asks for per-field / per-item authoring. Decomposing on the first pass is what forces the user to re-prompt "the design doesn't match". Get parity first, add editability second. + +**This governs a Section's internals. It never governs the page.** Keeping a carousel or a card grid whole is fidelity. Binding one component to the page's entire body is a monolith. The two are easy to confuse when the body happens to be a single modular-blocks field. Fidelity-first means this Section keeps the production component inside it. It is not permission for the Template to hold one node. A Template still gets one Section per schema scope on the first pass ([`decompose-blocks-page`](decompose-blocks-page.md)), each of which may keep its production component whole. Parity and decomposition are not in tension at that level. The measured proof is that splitting a blocks page into one Section per block type leaves the rendered output byte-identical. + +Work through each piece from Steps 1 to 2 and pick from **evidence**, not convenience: + +| Evidence in the code / design / content model | Primitive | Skill | +|---|---|---| +| Plain copy (headline, body, caption, CTA label) mapping 1:1 to a field | **Built-in field component** (`Heading` / `Paragraph` / `Text` / `Image` / `Link` / `RichText`) | `build-section` § Basic vs custom | +| Interactive: local state, handlers, cookies, modals, carousel logic | **Registered component** (mandatory, built-ins can't express behavior) | `register-component` | +| Pixel-sensitive: bespoke CSS, animation, layering | **Registered component** | `register-component` | +| Static presentational layout (image + heading + body + link) | **Decompose into built-ins**: authors get full editing. This is the case most often wrongly registered | `build-section` § Register whole or decompose | +| A styled shell wrapping editable content | **Registered component with a `slot` prop** + built-ins inside | `register-component`, `use-section-slot` | +| A recognizable page part (hero, form band, feature grid, CTA) | **Section**: regardless of reuse count | `build-section` | +| Multi-valued field with a **uniform** grid/row, OR polymorphic items needing per-item authoring | **List Section via Repeater** (+ **Condition Block** per block or CT for refs/MBs) | `build-repeating-section`, `use-repeater`, `use-condition-block` | +| Multi-valued field with a **bespoke layout**, or **migrating an existing design** | **List Section via array-prop reuse**: bind the whole array to the real component. Its own layout renders identically, first pass | `register-component` § Array & object props, `build-repeating-section` § array-prop alternative | +| A region a template author must fill differently per instance | **Section Slot** (filled by a Section, not a raw component) | `use-section-slot` | +| One prop an author should override per template instance | **Exposed section prop**: cheaper than forking the Section | `expose-section-props` | +| Same field set across several CTs | **Global Field** (+ a Slot on the Section that renders it) | `plan-studio-architecture` § Step 4 | +| **Any rich text**: JSON RTE, HTML RTE or Markdown | **`RichText` component** (never `Heading`/`Paragraph`, which show markup as literal characters) | `register-json-rte` § Which component for which field | +| Rich text that **truncates behind "read more" / an accordion** | **`CollapsibleText`**: same content model, plus expand/collapse | `register-json-rte` | +| Rich text that **embeds entries or assets** | `RichText` **+ `registerRTERenderer` with `embeddedEntry` / `embeddedAsset` handlers**. Unregistered, an embedded entry renders as a bare delivery-API span (title only) | `register-json-rte` § Embedded entries and embedded assets | +| One-off page owning its own copy, no CT worth creating | **Freeform template** + **Pinned Entries** | `build-freeform-template`, `pin-entry-to-freeform` | +| "Show the latest / top N / filtered X" on a Freeform page | **Pinned Query** as a Repeater source | `pin-query-to-freeform` | +| Data outside Contentstack (pricing, geo, inventory) | **`data` prop on `<StudioComponent>`**: not a fake CT | `wire-external-data` | +| Two or more components must agree on a value (cart count, active filter, theme) | **Declared state variable**: author-bindable shared state. **Not in any published SDK release yet. Run that skill's export preflight before planning it in** | `wire-studio-state` | +| The app already owns that state (Redux / Zustand / signals) | State variable + **`storage: "custom"`** (`getState`/`setState`/`subscribe`) | `wire-studio-state` | +| An author should choose what a button does | **Registered studio function + `action` prop**: not a hardcoded handler | `wire-studio-state`, `register-component` | +| Route-level branch on user state / segment | **Variant alias** (Personalize), not an `if` in the tree | `wire-variant-alias` | +| Brand spacing / color / type scale the author should reuse | **Design tokens** registered with Studio | `import-design-tokens` | +| Author needs to see tablet/mobile | **Breakpoints** registered at boot | `register-breakpoints` | + +**Bias check before printing the plan.** Count the primitives you selected: + +- **Every interactive component's behavior hardcoded, no `action` props?** Authors can place it but not wire it. If the page has buttons that should do something the author picks, that's a function + `action` prop. +- **One Section covering a whole page?** A defect, not a shortcut. Nothing to reorder, nothing reusable, and the author never meets slots or repeaters. Run [`decompose-jsx-to-atomics`](decompose-jsx-to-atomics.md) and re-plan. A single-Section page needs a stated reason, not silence. +- **Any Section whose binding is one coarse prop taking a whole object?** Decomposition was skipped. Atomic props bind one CT field each. A single prop swallowing a group renders blank when typed `any` (the binder recursively unwraps single-key objects for every type except `object`/`array`). +- **Only registered components?** Almost certainly wrong. Re-walk the table. Plain copy belongs in built-ins, lists belong in a List Section, per-instance variation belongs in a Slot or an exposed prop. +- **No Section Slots anywhere?** Legitimate for a small, fully-fixed page, but state that you checked and why none is needed. Silence reads as "not considered". +- **A list rendered without a Repeater**, or a Modular Block / multi-Reference Repeater without a Condition Block, means the plan is wrong, not merely terse. +- **A one-off page modelled as a Connected CT with a single entry** is fine and often right. A campaign page with no owned copy is the Freeform case. Decide, don't drift. + +**Name the features you ruled out.** The plan prints a "Studio features considered" line listing what you rejected and why (e.g. "Pinned Query: not needed, the list is a Modular Block on the page entry"). This is how the user learns the primitive exists at all. A plan that silently omits half of Studio teaches them Studio is just component registration. + +### Step 3: Classify each page as a Connected template + +For every page (not a Section), pick the CT backing: + +``` +Q1. Does the page repeat per content item? + ("blog post detail", "product detail", "recipe page", "author profile") + YES → Connected template + multi-entry CT, URL pattern with {{entry.slug}} or similar + NO → continue to Q2 + +Q2. Does the page have a stable shape? + ("homepage", "about", "contact", "pricing", "category landing", any campaign with hero copy) + YES → Connected template + single-entry CT (or generic shape CT with one entry). + Even short-lived / campaign pages get a single-entry CT for their owned copy. +``` + +For each page, print one line: + +``` +<page name> → Connected + Backing: <CT name + multi/single-entry> + URL pattern: <e.g. "/blog/{{entry.slug}}" for multi-entry, "/" for single-entry, etc.> + Reasoning: <Q1 hit / Q2 hit> +``` + +If a page seems not to fit either Q1 or Q2, it almost certainly has some copy (hero title, CTA) that wasn't called out in the requirements. That copy IS content, and modeling it puts the page back on Q2 (single-entry CT). + +### Step 4: Derive the content model + +From Steps 2 to 3, list every CT and Global Field needed: + +``` +Content model: + CT <name> <multi-entry | single-entry> fields: <field (type), one line — types required> + CT ... + GF <name> fields: <field (type)> + reused by: <CTs / Sections> +``` + +**Name a type for every field, and do not default long copy to plain text.** The field type decides what an author can ever do with it, and it is expensive to change once entries exist: + +| The copy is… | Field type | Renders through | +|---|---|---| +| A heading, label, name, short value | `text` (single-line) | `Heading` / `Text` | +| Body copy, description, prose an author may want to **bold, link, list, or embed into** | **`json_rte`** (or multiline + rich-text / markdown to match the team's editing convention) | **`RichText`** | +| Long prose the design **truncates**: "read more", accordion, "show details" | same rich-text type | **`CollapsibleText`** | + +**Propose the rich-text type by default for body copy**, and say so in the plan: "`body` is `json_rte` so authors can format and embed. If it will only ever be a flat paragraph, tell me and I'll make it plain text." Choosing plain text silently is the costlier mistake. An author who later needs a link in a sentence needs a schema migration, whereas an over-provisioned rich-text field is merely unused. + +If any rich-text field should accept **embedded entries or assets**, decide that here too, it drives the `RTE renderer` block in the build sheet and one `registerRTERenderer` call. Mapping: [`register-json-rte` § Which component for which field](register-json-rte.md#rte-field-to-component). + +For each CT, check for Slot-candidate fields (Global Field / Modular Block / Group / Reference) and mark them. + +For each Global Field, name which CTs/Sections reuse it. They exist because a field set is shared. + +If 2+ CTs have identical or near-identical sub-shapes, propose extracting them as a Global Field. + +### Step 5: Section inventory in build order + +**Match every planned Section against the Step 0a listing before adding it as new.** Components get this from Q2. Sections had no equivalent, so a plan would silently propose authoring a second Hero beside one that already existed. For each planned Section, mark it: + +| Mark | When | What the plan says | +|---|---|---| +| `[reuse]` | an existing Section already binds the same field and renders the same scope | name the existing `composable_uid`. Add **no** build step | +| `[extend]` | an existing Section is close, and one exposed prop would cover both cases | name it, and add the prop as the build step, never a near-duplicate Section | +| `[new]` | nothing existing binds that scope | full build step | + +**Say the reuse out loud in the plan**, per Section, before the user approves it. "Hero: `[reuse]` `sec_<field>_hero`, already on 2 Templates". A plan that lists twelve `[new]` Sections when four already exist reads as twelve units of work, and the user only discovers the duplication after it is built. + +Reuse is capped by the binding field: two Sections are the same Section only if their page content types name the field identically ([`decompose-blocks-page`](decompose-blocks-page.md) § Step 5). Alike-looking Sections on differently-named fields are `[new]`, and the plan should say why. + + +List every Section from Step 2, ordered by **dependency**: + +- Child Sections (those that drop into a parent's Slot) come BEFORE the parent Sections that expose Slots for them. +- Atomic / standalone Sections (no Slot interactions) can go in any order, group them at the start for quick wins. +- Mark each Section's exposed Slots and which child Sections fill them. +- **Shared shells (header / footer / nav) are Sections like any other**: bound to a Global Field or a singleton `site_config` entry, with their menus as Repeaters. They are NOT "atomic, no binding": that classification is what leaves an author unable to edit the nav. See Step 0c. +- A link/menu-item child Section comes **before** the header and footer that iterate it. One child Section usually serves both, plus any sidebar or mobile drawer. + +``` +Sections (build in this order): + 1. ProductCard bound to product CT → standalone; no Slot + 2. FeaturedCategoryCard bound to card_meta GF → standalone; no Slot + 3. ProductGrid bound to category.products → exposes Slot, drop ProductCard + 4. FeaturedCategories bound to homepage.featured_categories → exposes Slot, drop FeaturedCategoryCard + 5. Hero bound to homepage.hero group → standalone + 6. SiteNavLink bound to site_config.nav_links item → child Section for the Repeater + 7. Header bound to site_config GF → logo + brand + nav Repeater (drops SiteNavLink) + 8. Footer bound to site_config GF → nested Repeater: columns → links +``` + +### Step 6: Template inventory + +For each page (from Step 3): + +``` +Templates: + Connected / homepage CT (single) → Hero + FeaturedCategories + Connected /category/{{slug}} category CT → Hero + ProductGrid + Connected /product/{{slug}} product CT → ProductDetailHero + ProductSpecsTable + RelatedProducts + Connected /blog/{{slug}} blog_post CT → BlogHeader + RichText + RelatedPosts +``` + +### Step 7: Build order + +Print the full executable order: + +``` +Build order: + 0. Setup, if not already done (analyze-project-fit -> install-studio -> + enable-visual-experience — stack Live Preview + preview token -> + install-live-preview with cslp: { appendTags: true } -> + setup-section-preview for the COMPULSORY /canvas route; + skip any step Step 0's inventory shows is already in place) + 1. Create / update CTs and Global Fields (in the Contentstack web app) + 2. Register atomic React components (lazy by default; layout-agnostic — see register-component) + every registration ships thumbnailUrl, every component spreads + studioAttributes + its $-twins — same step, never a follow-up + 3. Import design tokens (before any Section is authored — see import-design-tokens) + 4. Build Sections in the order from Step 5 + each section's ui_preview is set immediately after it is written + 5. Build Templates in the order from Step 6 (one per Connected template) + 6. Set up visitor render routes (configure-csr-vs-ssr + setup-template-preview-routes) + 7. Verify + hand over (complete-the-build -> verify-setup + deploy-studio-site) + +Run `byoc-end-to-end` to walk this in execution mode. +``` + +The inline notes on steps 0, 2, 4 and 7 are the six deliverables from [`complete-the-build`](complete-the-build.md): CSLP tags, component thumbnails, section thumbnails, Live Preview, the `/canvas` route. **Print them in the build order, don't leave them implied.** Each is invisible to a developer loop (the page renders fine without all six) and each breaks the editor for the non-developer this plan exists to serve, so a plan that omits them gets approved as complete and delivered incomplete. + +### Step 8: Sanity checks before handoff + +Before printing the final plan, flag any of these at the top: + +- Anything with a URL listed as a Section (it's a Template). +- Reusable block (header/hero/footer/grid) listed as a Template (it's a Section). +- CT with GF/MB/Group/Reference field not exposed as a Slot. +- Global Field shared by ≥2 CTs without its own Section. +- Connected page without an identified backing CT. +- Child Section whose linked schema doesn't match its parent Repeater's iterated CT. +- Any of the six in [`complete-the-build`](complete-the-build.md) missing from the plan, or an `[exists]` component carrying no `thumbnailUrl` / no `studioAttributes` (it's `[modify]`). + +### Step 9. Print the plan in TWO layers: plain language first, build sheet second + +**The person approving this is often not a developer.** A plan written in Sections, linked schemas and `url_source` cannot be approved by someone who can't read it. They either say yes without understanding, or stall. So print a plain-language summary first, and keep the technical detail below it for the build steps. + +Both layers, every time. The summary is not a teaser. It must cover everything the build will do. + +#### Layer 1: What you'll get (plain language, no jargon) + +``` +WHAT YOU'LL GET — <project name> + +In short: + <2–3 sentences. What exists at the end, which pages, and who can change what + without a developer. No product terms.> + +Your pages: + • <Page name> — <one page at /rewards | every product gets its own page> + What's on it : <hero banner, signup form> + You can edit : <headline, image, body text, button label> + Fixed for now : <the form's behavior — needs a developer> + +Reusable blocks (built once, usable on any future page): + <one entry per block — if a whole page is a single entry here, the plan is wrong; + go back and break it up> + • <Hero banner> — <used on 1 page today> + You can edit : <image, headline, body> + Swap spots : <you can drop a different offer box here per page | none> + • <Signup form> — interactive, so it stays a developer-built block + You can edit : <labels, category list> + +Lists that grow by themselves: + • <The offers list repeats automatically for every offer you add — no developer needed> + <or "None on this page"> + +After this you'll be able to, on your own: + ✓ <change any wording or image on these pages> + ✓ <reorder the blocks on a page> + ✓ <reuse the hero on a new page> + ✗ <change how the form submits — that needs a developer> + +How you'll edit it: + • Click any text or image on a preview of the real page and change it right there. + • Every building block and reusable block shows a picture of itself, so you can + tell them apart when adding one — no blank tiles to guess at. + <these two lines are ALWAYS included and always true — they are the six + deliverables in complete-the-build, which the build order commits to> + +Size of the job: + <2 reusable blocks · 1 page · 2 of your components hooked up · ~N new content fields> + +Already in place / being reused: + <from the Step 0 inventory — or "nothing yet, this is a fresh build"> +``` + +**Rules for Layer 1:** + +- **Ban the vocabulary, keep the meaning.** Never print Section, linked schema, `selectedField`, Connected/Freeform, Repeater, Condition Block, Section Slot, `url_source`, `resolvedReferences`, binding, prop, CT. Translate: + + | Don't write | Write | + |---|---| + | Section | reusable block | + | Section Slot | swap spot: you can drop something different here on each page | + | Connected template | one design every `<thing>` page uses automatically | + | Freeform template | a one-off page | + | Repeater (+ Condition Block) | the list repeats automatically for each item you add | + | Registered component | your existing designed block, used as-is | + | Built-in component | plain text or image you can type into and restyle yourself | + | Exposed prop | one setting you can change per page | + | Global Field | the same group of fields reused in several places | + | linked schema / binding | where it gets its content from | + | URL pattern / `url_source` | the page address | + | Pinned query | it shows the latest ones automatically | + | Design tokens | your brand colors and spacing, available in the editor | + | CSLP tags / Visual Editor / Live Preview | you can click any text or image on a preview of the real page and edit it there | + | `thumbnailUrl` / `ui_preview` | every block shows a picture of itself so you can tell them apart | + +- **"You can edit" and "Fixed for now" are the point.** That's the line a non-technical approver actually cares about, and it's what makes the A-vs-B choice in Step 10 meaningful rather than abstract. +- **No counts without meaning.** "2 reusable blocks" is fine. "4 `linked_schemas`" is not. +- **Say what's reused.** If Step 0 found existing pieces, name them here. Otherwise the reader assumes everything is new and the size looks wrong. +- **"How you'll edit it" is not optional and is not a promise you can drop.** It is the plan's commitment to the six deliverables in [`complete-the-build`](complete-the-build.md). Say it in the words in that skill's plain-language table, never as `data-cslp`, `thumbnailUrl`, `ui_preview` or "Live Preview". +- **Keep it short.** A wall of plain text is no more approvable than a wall of jargon. If Layer 1 runs past roughly a screen, the plan is probably too big to approve in one go. Split the work. + +#### Layer 2: Build sheet (technical. The build steps read this, not the user) + +Print it under a clear heading so the reader knows they can stop at Layer 1: + +``` +── BUILD SHEET (technical — for the build steps; skip if you just want the summary) ── + +ARCHITECTURE PLAN — <project name> + +Sanity flags (reconsider before proceeding): + - <flag>: <one-line reason and pointer to the affected piece> + ... (or "None" if Steps 1–8 all clean) + +Content model: + <CT and GF listing from Step 4> + +Sections (build in this order): + <for EACH Section from Step 5, print EVERY line below — never just the name. + `atomics` is what build-section and register-component consume: one atomic, + one prop, one CT field. A Section with no atomics line was not decomposed.> + - <Section name> [exists|modify|create] shared: <yes — rendered from layout | no> + linked schema : <CT / Global Field / Group / Modular Block / Block / Reference> (+ selectedField set|unset) + components : <registered: Hero, ProductCard | built-in: Heading, Paragraph, Image> + atomics : <one line per atomic — atomic -> prop -> CT field (type)> + <Image -> image -> entry.hero.image (imageurl)> + <Heading -> headline -> entry.hero.headline (string)> + rich text : <every RTE field on this Section — field (json_rte|html rte|markdown) + -> RichText|CollapsibleText -> embeds: entries <CT list>|assets|none + | none. NEVER map rich text to Heading/Paragraph.> + repeaters : <field iterated -> child Section -> Condition Blocks needed | none> + section slots : <slot name -> what a template author drops there | none, and why> + bindings : <any binding NOT covered by the atomics lines above | none> + +Templates: + <Template inventory from Step 6 — for EACH: kind (Connected|Freeform), connected CT, + URL pattern + url_source, and the ordered list of Sections placed on it> + +Registered components needed: + <component -> props to register, and which Section consumes it, each [exists|modify|create]> + <and per component: thumbnailUrl [has|to add] · CSLP tags [has|to add] — an [exists] component + with no thumbnail or no studioAttributes is [modify], not [exists]> + +Built-in (field) components used: + <Heading / Paragraph / Text / Image / Link / RichText -> which Section, bound to which field> + <if this list is empty, say why — a plan where every visible element is a custom + registered component is usually over-registered; plain copy belongs in built-ins> + +Suggested upgrades (recommendations — the build proceeds without them if declined): + <existing rich-text field rendered through a plain text component + -> switch to RichText — no schema change, fixes markup showing as literal text> + <plain multiline text field holding prose + -> consider json_rte so authors can format and embed — schema change on N live + entries via migrate-ct-schema> + <long prose the design truncates -> CollapsibleText> + <or "None — the existing model already types its rich text correctly"> + +RTE renderer (omit ONLY if the model has no rich-text field anywhere): + fields : <every json_rte / HTML RTE / Markdown field in the model -> the Section using it> + embedded CTs : <content types authors can embed -> the markup each renders as, per displayType + (block = card, inline = span, link = anchor) | none> + embedded assets: <custom markup needed? | none — the MIME default covers it> + registration : <single registerRTERenderer call at bootstrap | not needed, and why> + +Studio features considered: + used : <primitives this plan uses — built-ins, Sections, Slots, Repeater+Condition Block, + exposed props, Global Fields, pinned entry/query, external data, variants, tokens> + ruled out: <primitive -> one-line reason it isn't needed here> + +Editing experience (mandatory — the six from complete-the-build; never listed as optional): + preflight : Live Preview + preview token <on | to enable> · cslp.appendTags <on | to enable> + · /canvas route <exists | to add> + per component: thumbnailUrl + studioAttributes/$-twins on every registration above + per section : ui_preview set at write time on every Section above + verify : complete-the-build's verification block at handover, counts printed + +Build order: + <Step 7 listing> +``` + +#### Worked example: the same plan, both layers + +Layer 1, as the approver sees it: + +``` +WHAT YOU'LL GET — Rewards page + +In short: + A rewards signup page at /rewards. Once it's built you can change every + headline, image and piece of copy on it yourself, and reuse the hero banner + on other pages. The signup form's behavior stays with the developers. + +Your pages: + • Rewards — one page at /rewards + What's on it : hero banner, signup form + You can edit : headline, sub-heading, hero image, form labels, category list + Fixed for now : what happens when someone submits the form + +Reusable blocks (built once, usable on any future page): + • Hero banner — used on 1 page today + You can edit : image, headline, body text + Swap spots : none — it's the same shape everywhere it's used + • Signup form — interactive, so it stays a developer-built block + You can edit : field labels, the category list + +Lists that grow by themselves: + None on this page. + +After this you'll be able to, on your own: + ✓ change any wording or image on this page + ✓ reorder the two blocks, or remove one + ✓ put the hero banner on a new page without a developer + ✗ change how the form submits, or add a new form field + +Size of the job: + 2 reusable blocks · 1 page · 2 of your components hooked up · 6 new content fields + +Already in place / being reused: + Nothing yet — this is a fresh build. +``` + +Layer 2 for the same plan carries the detail Layer 1 deliberately hides: the hero's linked schema and `selectedField`, `type: "action"` on the form's submit prop, the `url` + `url_source` pair, which components are `[create]` vs `[exists]`, and the build order. Same plan, two audiences, never two different plans. + +**Every Section line must be filled in.** A plan that names Sections without their linked schema, components, slots and bindings isn't a plan. It's a wish list, and the build step will improvise the missing parts. Same for Templates: kind + URL + which Sections go on it, or it's not decided. + +### Step 10: STOP. Get explicit approval before anything is built. + +**Nothing is created until the user approves this plan.** Print it, then present the architecture decision as an explicit choice and wait. Do not chain into a build skill, do not "start with the easy part", do not treat silence or a topic change as approval. + +**Ask in the same plain language as Layer 1.** The approval question is the worst place to reintroduce jargon. A choice nobody understands gets answered "sounds good", which is not a decision. Ask verbatim: + +> Two ways to build this. The difference is what you can change later without a developer: +> +> **A. Build it as reusable blocks (recommended).** Each part of the page becomes its own block. Afterwards you can reorder them, drop one onto a different page, restyle the text, and edit all the wording and images yourself. It's a bit more setup now. +> +> **B. Build it as one fixed page.** Faster to stand up. You'll be able to change wording and images, but **not** reorder anything, reuse a part on another page, or restyle it, those would need a developer. Splitting it up later means redoing the wiring, so this is easier to choose than to undo. +> +> I've planned for **A**. Reply **"go"** to build it, tell me what to change, or say **"B"** and I'll note the trade-off in the plan. +> +> One more: should **every `<thing>` get this design automatically** as you add more of them (recommended), or is this a **one-off page** you'll only ever have one of? + +The last question is Connected vs Freeform. Ask it that way, never by those names. "Every product page looks like this" versus "this is a single campaign page" is a distinction any approver can make. "Connected or Freeform" is not. + +**Rules for this gate:** + +- **A is the default and stays the default.** Reuse count 1 does not downgrade a piece to "just drop a component", see § Golden rule. If the user picks B, record it in the plan as an explicit decision with its cost, so the next agent doesn't read a monolith as the intended shape. +- **One round of edits is normal.** Apply the user's corrections, reprint the changed sections, re-ask. Don't rebuild the whole plan from scratch. +- **Plain language in, plain language out.** If the user asks a question about the plan, answer in Layer 1's vocabulary. Dropping into "the Section's `selectedField` scopes it to…" mid-approval loses the approver, and an approval you talked someone into isn't one. +- **Partial approval is not approval.** "The sections look right" is not permission to create templates. +- **Carry the approved plan forward.** The build skills expect it: `build-section` per Section in order, then `build-connected-template` / `build-freeform-template`, then `register-component` for anything not yet registered. Hand each one the matching block from the plan rather than re-deciding. + +## Inputs needed from the user + +1. `source`. One of: an existing codebase (the default when `package.json` is present), a partially-built Studio project, a design/Figma link, or prose requirements. Determined in Step 0, not asked for. +2. `requirements`: a paragraph or bullet list of pages, features, reusable blocks, data sources. **Required only for scenario D** (from scratch, no code and no design). Ask if the user gives only a one-liner. For scenarios A to C the code or design is the primary input and this is supporting context. + +## Acceptance + +- [ ] **Step 0 ran and its inventory was printed**: existing routes, components, registered components, Sections, Templates, CTs. On an existing codebase the plan was built from the scan, not from the user's description alone, and items carry `[exists]` / `[modify]` / `[create]`. +- [ ] **Each piece's primitive was chosen from the Step 2b evidence table, and the "Studio features considered" block lists what was ruled out and why.** A plan built only from `registerComponent` fails this: it hides the rest of Studio from the user and leaves authors with a page they can only re-bind. +- [ ] **Every Section row names its Section Slots and its built-in (field) components, not just registered components.** A plan listing only `registerComponent` work is incomplete. Say explicitly where plain copy uses built-ins and where a template author needs a swap point. +- [ ] **Each page has an explicit Connected-vs-Freeform decision** with the connected CT (or the reason there isn't one), never left implicit. +- [ ] **The plan was printed in full and explicitly approved before any build skill ran.** Every Section line carries linked schema + components + slots + bindings. Every Template carries kind + URL + its Section list. No piece was created on an unapproved or half-filled plan. +- [ ] **The plan commits to the six deliverables in [`complete-the-build`](complete-the-build.md)**: Layer 1 carries the "How you'll edit it" lines, Layer 2 carries the Editing experience block, and the build order names them on steps 0, 2, 4 and 7. A plan that covers only Sections, bindings and templates describes something that renders but that a marketer cannot use. +- [ ] **The A-vs-B architecture choice was put to the user** (decomposed Sections vs a single template with components dropped in) and their answer recorded. If B was chosen, its cost is written into the plan. + +The skill succeeds when: + +- [ ] Every page in the requirements has been classified as Connected (multi-entry CT or single-entry CT), with the deciding Q1 / Q2 step printed. +- [ ] The content model lists every CT and Global Field needed, with reuse notes. +- [ ] The Section inventory is ordered by dependency (child Sections before parent Sections that use them). +- [ ] Each Section's Slot-candidate fields are flagged. +- [ ] The Template inventory names each page with its URL pattern and the Sections it stacks. +- [ ] The build order is printed as a numbered, executable list. +- [ ] Sanity flags are surfaced at the top of the plan (or "None"). +- [ ] The plan ends with the handoff to `byoc-end-to-end`. +- [ ] No code was written and nothing was created in Studio. This skill is planning-only. + +## Common pitfalls + +| Pitfall | Why it bites | Right move | +|---|---|---| +| Calling a reusable block a "page" | Headers / heroes / footers aren't pages. They're Sections that pages assemble | Anything that doesn't have its own URL is a Section, not a Template | +| Calling a per-content-item page (blog post, product detail) a separate Template per entry | Templates iterate via URL pattern: one Template renders N entries. Building N templates means N times the maintenance | Multi-entry CT + one Connected template with a `{{entry.slug}}`-style URL pattern. The Template renders unlimited entries. | +| Skipping the content model step | Building Sections without knowing which CT backs them, so the missing field surfaces mid-build and the model has to be reworked | Model the CT first. The Section authoring depends on it. | +| Inlining a Modular Block / Group / Reference / Global Field instead of exposing a Slot | Locks the nested shape into the parent Section, throws away the reusability the field shape was set up to give you | Default to Slot. Override only when certain the nested shape is single-use. Global Field always deserves a Slot (it exists because of cross-CT reuse). | +| Building parent Sections before their child Sections | Parent's Slot has nothing to drop in when you try to verify it | Order Section build by dependency in Step 5: children before parents. | +| Producing a plan without sanity flags | The user proceeds with a flawed model. Rework cost is high | Step 8 is mandatory. Print "None" if clean. Otherwise list flags at the top of the plan in red. | + +## See also + +- [`composable-primitives`](../../docs/00-getting-started/composable-primitives.md): foundational pattern doc. Before this skill's build order sequences the work, that doc decides what to register: atomic + layout primitives instead of monolithic Sections. Read alongside `from-designs-to-sections` when the plan will produce a fresh component library. +- `start-here-zero-knowledge`: the conceptual prerequisite (run BEFORE this skill if the user is new) +- `understand-templates`, `understand-sections`, `understand-section-slots`, `understand-linked-schemas`, `understand-auto-binding`: the concept ladder this skill assumes is internalised +- `byoc-end-to-end`: the procedural macro that walks the plan in execution mode +- `complete-the-build`: the ten always-mandatory deliverables this plan commits to and the handover gate that verifies them +- `enable-visual-experience`, `install-live-preview`: the preflight half of those six +- `build-section`, `build-connected-template`, `build-repeating-section`, `use-section-slot`: the implementation skills the build order references diff --git a/skills/studio/provision-studio-project.md b/skills/studio/provision-studio-project.md new file mode 100644 index 0000000..7782727 --- /dev/null +++ b/skills/studio/provision-studio-project.md @@ -0,0 +1,269 @@ +--- +name: provision-studio-project +--- + +## When to use + +Bootstrap a Studio project via API: create the compositions CT with the schema Studio's editor + runtime require, plus the publish + delivery-token setup. + +Use when standing up a brand-new Studio project headlessly (no UI), seeding an environment for tests/demos, or onboarding a new stack with no compositions CT yet. Signals: "create compositions CT via API", "seed a Studio project", "bootstrap a stack for Studio". Do NOT use for composition entries (`author-composition-via-api`) or stack assets/CTs outside compositions (`provision-studio-stack`). + +> **Mandatory auth preflight: settle the credential before the first API call.** Resolve it OAuth-first per [`authenticate-cma`](authenticate-cma.md): `CS_OAUTH_ACCESS_TOKEN`, else the Contentstack MCP's stored session: Read that skill for where it is, even with no MCP connected. **Never ask the user for a session `authtoken`.** If nothing resolves, or a refresh fails with `400 invalid_refresh_token`, hand them `! CONTENTSTACK_REGION=<code> npx @contentstack/mcp --auth` (it needs a TTY and a browser, so it cannot be run for them) and wait. `403 error_code 316` is a valid credential aimed at another org: fix the org or the `api_key`, do **not** re-authenticate. + +# Provision Studio Project (API-first) + +## Context + +Provisioning a Studio project touches **two API surfaces**: + +1. **Contentstack CMA** (`api.contentstack.io` and regional variants). Creates the stack, the compositions content type, the environment, the delivery + preview token, and enables Live Preview on the stack. +2. **Studio API** (`composable-studio-api.contentstack.com` and regional variants). Creates the Studio project itself via `/v1/projects`, binds it to the stack (`connectedStackApiKey`) and the compositions CT (`contentTypeUid`), and configures environment + locale + Freeform. + +Skip either surface and Studio cannot resolve compositions: + +- Without a compositions CT there is nothing to author against. +- Without a Studio project the canvas iframe has no project to load. +- Without Live Preview enabled on the stack the canvas iframe hangs forever on "Loading composition…" because the canvas-app's `rest-preview.contentstack.*` calls fail CORS preflight. + +The CT must carry an exact set of fields. If any are missing, mistyped, or wrong cardinality, the editor's `?include[]=linked_sections` fails, the runtime's `composable_uid` lookup misses, or the canvas cannot persist the composition. + +Once the project exists, composition entries are created via [`author-composition-via-api`](author-composition-via-api.md). Stack content outside the compositions CT (assets, page CTs the compositions reference) belongs in [`provision-studio-stack`](provision-studio-stack.md). + +Reference: [`reference_studio_provisioning_api`](../../docs/90-reference/studio-provisioning-api.md) for the host map across regions. + +## Task + +1. **Confirm prerequisites.** You have a resolved CMA credential, **OAuth Bearer by default**, session `authtoken` only as the last rung ([`authenticate-cma`](authenticate-cma.md)), AND `api_key` for the target stack. Both Studio API and CMA expect these two headers together. Know the region: regional host map in [`reference_studio_provisioning_api`](../../docs/90-reference/studio-provisioning-api.md). NA defaults: CMA `api.contentstack.io`, Studio API `composable-studio-api.contentstack.com`. **Non-prod (the non-prod domain your team uses) has no `region` shortcut**. Every host is `<env>-<service>.<non-prod-domain>` and must be set explicitly. The full table is [`install-studio`](install-studio.md) § Non-prod hosts. Establish which data center you're on first via [`analyze-project-fit`](analyze-project-fit.md) § 2b: Determine the data center. Getting this wrong here provisions a CT, tokens and a Studio project **in the wrong data center**, with no error. The calls succeed against prod. + + > **MCP-first execution.** Discover tools and verify the target with a read call per [`install-contentstack-mcp`](install-contentstack-mcp.md). When supported tools are callable, the **CMA** steps here run as MCP tool calls with no pasted `authtoken`: step 8 CT-create (POST then PUT) becomes `create_a_content_type` plus `update_content_type`, step 4 environment-create becomes `create_an_environment`, and verify becomes `get_a_single_content_type`. The MCP does **not** cover the Studio API or tokens: steps **5 (enable Live Preview), 6 (delivery+preview token), 10 (`/v1/projects` register)**, and stack-create stay raw HTTP, authenticated with the same OAuth credential (`-H "$CS_AUTH"`), not a pasted session token. **Step 10 (`/v1/projects`) has no MCP tool, but try the OAuth token there first (`Authorization: Bearer`). A session authtoken is the fallback only if the API rejects it. See step 10's callout.** Org comes from the login. Read the stack `api_key` from the MCP registration ([`understand-contentstack-mcp`](understand-contentstack-mcp.md)) and confirm before asking. A missing readiness flag requires discovery; use HTTP only for unavailable operations or a recorded connection limitation. + +2. **FIRST: does a Studio project already exist? List, then ASK. Never create blind.** + + This skill creates a project. Run it after an OAuth login without looking and you get a **duplicate project against the same stack**. Nothing errors, and from then on half the tooling points at one project and half at the other. The stack step below already reuses-or-creates. The project needs the same treatment, plus an explicit question, because "reuse" vs "new" is the user's call and not inferable. + + ```bash + curl -s "https://<studio-api-host>/v1/projects" \ + -H "Authorization: Bearer <oauth-access-token>" \ + -H "organization_uid: <org-uid>" | jq -r \ + '.projects[] | "\(.uid) \(.name) stack=\(.connectedStackApiKey) ct=\(.contentTypeUid)"' + ``` + + (Auth ladder + host map: step 10's callout. `organization_uid`, **not** a stack `api_key`. Projects are org-scoped.) + + | Result | What to do | + |---|---| + | **A project's `connectedStackApiKey` matches the target stack** | **Almost certainly the one they want.** Say so and default to reusing it: "Project `<name>` (`<uid>`) is already connected to this stack with `contentTypeUid: <ct>`. Reuse it, or create a second project against the same stack?" A second project on one stack is legal but rarely intended. It splits compositions across two projects | + | **Projects exist, none on this stack** | Present the numbered list below and **ask**: reuse one (repointing it, see the warning at the end) or create new. Don't assume new just because the stack differs | + | **No projects at all** | Proceed to create, and say that's what you're doing | + | **`GET` fails** (401/422) | **Stop.** An auth failure is not evidence that no project exists. Resolve auth per step 10's ladder, then re-check | + + ### Present the list as a numbered choice: never ask for a project id + + **You already fetched every project in the org, uid included. Do not ask the user to find or paste a project id.** Show a numbered list and accept a number (or the name). You map the choice back to the `uid` yourself: + + ``` + Found 3 Studio projects in your organization: + + 1. Visual Experience Showcase ← already connected to this stack (compositions) + 2. Marketing Site stack=blt9f3… ct=compositions + 3. Docs Playground stack=blt21c… ct=doc_compositions + + 4. Create a NEW project instead + + Reply with a number (1–4). + ``` + + Rules for the prompt: mark the one already connected to the target stack, always offer **"create new"** as an explicit numbered option so reuse is never the only path, and show `stack=` / `ct=` so the choice is informed. Accept a name if they type one instead of a number. Only ask for a raw uid if the `GET` itself failed and they're supplying it from the UI. + + ### If the user picks an existing project: what actually happens next + + Reusing changes the rest of this skill. It does not simply skip to the end. + + | Step | On reuse | + |---|---| + | **10: register the project** | **Skipped entirely.** The project exists. Do not POST `/v1/projects` | + | **7: pick the compositions CT uid** | **No longer a free choice.** Adopt the project's existing `contentTypeUid` from the listing. Inventing a new uid here creates a second compositions CT that the project doesn't point at. The canvas then loads nothing while the CT sits there looking correct | + | **8 / 9: create + verify the CT** | Verify first (step 9's `GET /v3/content_types/<uid>`). If it exists with the right field shape, skip the create. If it's missing or mis-shaped, fix it against the **adopted** uid | + | **3 to 6: stack, Live Preview, environment, tokens** | Still run, still reuse-or-create. An existing project does **not** imply Live Preview is enabled or that a preview token exists. Those are stack-level and are the most common thing missing on a project someone created in the UI | + | **11 to 12: publish + record metadata** | Unchanged | + + **Then hand the chosen `uid` + `contentTypeUid` to [`configure-studio`](configure-studio.md)**, which reads back Environment / Language / Canvas URL rather than assuming a UI-created project has them set. Record the decision so a later agent doesn't re-ask. + + **Don't repoint an existing project at a different stack casually.** If the user picks a project whose `connectedStackApiKey` is some other stack, say so and confirm before changing it. Every composition already authored there resolves against the old stack's content. + +3. **Ensure the stack exists** (CMA). Either reuse via `GET /v3/stacks?organization_uid=<org>&query={"name":"…"}` or create via `POST /v3/stacks` (body: `{ stack: { name, description, master_locale: "en-us" } }`, plus header `organization_uid`). Capture `api_key`. + +4. **Create the environment** (CMA) if it doesn't exist: `POST /v3/environments` with `{ environment: { name: "preview", urls: [{ locale: "en-us", url: "<canvas-app-url>" }] } }`. The environment URL must match where the host app renders (the canvas-app or visitor app). Save `environment.uid`. Studio's `/v1/projects` configuration takes the **uid, not the name**. + +5. **Enable Live Preview on the stack** (CMA, critical, easy to miss). Read-modify-write: siblings like `visual_builder` must survive the write. + ``` + GET /v3/stacks/settings (headers: api_key, $CS_AUTH) + POST /v3/stacks/settings (headers: api_key, $CS_AUTH) + { "stack_settings": { ...existing, + "live_preview": { ...existing.live_preview, + "enabled": true, "default-env": "<environment uid>", "default-url": "<canvas-app-url>" } } } + ``` + **Not `PUT /v3/stacks`.** It answers `200 Stack updated successfully` and silently discards the payload: wrong endpoint, wrong envelope, `enable` not `enabled`. Live Preview stays off, silently. Saving Visual Experience in the Contentstack UI issues this same `POST /v3/stacks/settings`, measured at `201`. + + `enabled: true` alone is rejected `422 error_code 150`, asking for a default URL and environment. That is why step 4 comes first: its uid goes in `default-env`. + + A `200` is not proof. Re-read and assert `live_preview.enabled`. Without it the canvas iframe hangs on "Loading composition…" because the canvas-app's calls to `rest-preview.contentstack.*` fail CORS preflight. + +6. **Create the delivery token, then the preview token** (CMA). Two calls. `scripts/provision.ts` is the reference implementation. `?create_with_preview_token=true` returns `token.preview_token` on the same call, measured at `201` with a 26-char `cs…` value. Query param only: in the body it is accepted and the token is silently absent. Either route works; assert the token is non-empty, because an empty one 401s every Preview call silently. Use the dedicated endpoint below: + ``` + POST /v3/stacks/delivery_tokens + { "token": { "name": "...", "scope": [ + { "module": "environment", "environments": ["<env-name>"], "acl": { "read": true } }, + { "module": "branch", "branches": ["main"], "acl": { "read": true } } + ] } } + ``` + Then create the preview token from its own endpoint. Live Preview must already be enabled on the + stack, or this call fails: + ``` + POST /v3/stacks/delivery_tokens/<uid>/preview_token + ``` + It returns `token.preview_token` (a `cs…` string). Capture both. If it comes back empty, stop rather + than continue: an empty preview token 401s every Preview API call and the canvas fails with + "Component Loading Error / API error (0)". + +7. **Pick the compositions CT uid.** Convention: `<project>_compositions` (lowercase, underscored). Studio resolves the compositions CT by this uid. + +8. **Create the compositions CT: two calls, not one.** CMA validates reference targets at creation time, so **`linked_sections`** (a self-reference) must be added on a follow-up PUT. In the initial POST it returns `error_code 115: linked_sections.reference_to: content type does not exist`. The CT can't reference itself before it exists. + + ``` + POST /v3/content_types (body = every field in the table below EXCEPT linked_sections) + PUT /v3/content_types/<uid> (body = every field including linked_sections) + ``` + + Every field is **required by the editor or runtime**. Do not drop, rename, or change cardinality. + +### Compositions CT: required fields + +| uid | data_type | mandatory | multiple | Required by | Purpose | +|---|---|---|---|---|---| +| `title` | text | yes | no | CMS UX | Author-visible title. Unique recommended. | +| `url` | text | no | no | Runtime | Composition URL pattern (e.g. `/products/{{entry.url}}`). Empty for freeform. | +| `composable_uid` | text | yes | no | Runtime + editor | Contract key: runtime queries compositions by `composable_uid`, NOT entry uid. Must be unique. | +| `ui_preview` | file | no | no | Editor | Thumbnail shown in Studio's composition list. | +| `connected_content_type` | text | no | no | Editor + runtime | The page CT uid this composition is connected to (Connected Template). Empty for Freeform. | +| `ui` | text (multiline) | no | no | Editor + runtime | `zlib:<base64>`-encoded composition node tree. Written by Studio canvas. Written by [`author-composition-via-api`](author-composition-via-api.md) for headless authoring. | +| `data_sources` | text (multiline) | no | no | Runtime | JSON object holding `resolvedReferences` etc. for reference resolution at render. | +| `static_value` | group | no | no | Editor | Static-value sub-bindings. See § static_value sub-schema below. Required as-is for the picker to enumerate static-value types. | +| `schema_version` | text | no | no | Editor | Composition schema version stamp written by Studio, a semver-shaped string like `"1.0.0"` (also seen: `""`, `"1"`, `"2"`). | +| `place_composition_as` | text | no | no | Editor | `"page"` for top-level template compositions (both Connected and Freeform). `"section"` for section compositions. `"template"` is legal but not what Studio writes for the top-level entry. | +| `linked_schemas` | **group, multiple=true** | no | yes | Editor | Per-section template-binding override. One entry per content type the section accepts. **Must be `group` with `multiple: true`**. The editor expects an array. Sub-fields on the group: `content_type_uid` (text, **required**), `selected_field` (text, **optional**, set when the section restricts to one field), `display_name` (text, **optional**) and `cardinality` (text, **optional**, `single` or `collection`). Runtime stored shape: `[{ "content_type_uid": "blog_post", "_metadata": { "uid": "<system-generated>" } }, …]`. If this is a single `group` (multiple=false) or a flat text field, editor crashes during section-context build. | +| `url_metadata` | group | no | no | Editor | `url_source` (text), `url_queries` (text). Drives the URL pattern editor. **Both sub-fields must exist.** If the group (or either sub-field) is missing, the CMA silently drops `url_metadata` on every composition write: 201 returned, field reads back absent, and every template composition falls back to `legacy_url` semantics with no error anywhere. See [`author-composition-via-api` § `url` + `url_metadata`, a matched pair](author-composition-entry.md#url-metadata-matched-pair). | +| `linked_sections` | **reference, multiple=true, reference_to: <same CT uid>** | no | yes | Editor | Self-reference to other composition entries that are templates this composition links. **Must be a reference field**. The editor's CDA call uses `?include[]=linked_sections`, which only works on reference fields. If modeled as text/group, the editor's Template Did Not Load error appears. | + +### `static_value` sub-schema + +Add a `group` field named `static_value` (multiple=false) with **13 sub-groups (each multiple=true)**, one per scalar binding type the picker offers. Each sub-group has a uniform `{ key: text, value: text }` shape, EXCEPT `boolean` (`value: boolean`) and `choice` (`value: text` but **`multiple: true`**: exposed choice props write an array like `["center"]`. A single-value `choice.value` rejects the write with "should be a single value instead of multiple"): + +``` +static_value (group) +├─ text (group[]) { key: text, value: text (multiline) } +├─ html_rte (group[]) { key: text, value: text (multiline) } +├─ array (group[]) { key: text, value: text (multiline) } +├─ object (group[]) { key: text, value: text (multiline) } +├─ number (group[]) { key: text, value: text (multiline) } +├─ href (group[]) { key: text, value: text (multiline) } +├─ textarea (group[]) { key: text, value: text (multiline) } +├─ any (group[]) { key: text, value: text (multiline) } +├─ json_rte (group[]) { key: text, value: text (multiline) } +├─ datestring (group[]) { key: text, value: text (multiline) } +├─ boolean (group[]) { key: text, value: boolean } +├─ imageurl (group[]) { key: text, value: text (multiline) } +└─ choice (group[]) { key: text, value: text (multiline, **multiple: true**) } +``` + +The picker's "Static Value" branch enumerates these sub-groups by uid. Missing sub-groups produce missing options in the picker. + +9. **Verify the CT exists.** `GET /v3/content_types/<uid>` and confirm the field list matches the table above. Catch shape errors early. Fixing a wrong cardinality after entries exist requires deleting and re-creating the CT. + +10. **Register the Studio project** (Studio API). Two calls. + + > **Auth for `/v1/projects`: try OAuth FIRST. A session authtoken is the last resort.** Work down this ladder and stop at the first that succeeds: + > + > 1. **`Authorization: Bearer <oauth-access-token>`**, from `install-contentstack-mcp`'s `--auth`, or `csdx --oauth`. **Runtime-confirmed on prod NA:** `GET /v1/projects` with this token plus an `organization_uid` header returns **200** and the project list. Also reported working on a non-prod data center after COMS-1710. **Always attempt this before asking for anything else**. Pair it with `organization_uid`, not a stack `api_key`, because projects are org-scoped. + > 2. **Create the project in the Studio UI**, no token at all, and it onboards the stack in the same action. + > 3. **Session authtoken**, only after OAuth is observed to fail with one of: `authtoken: <oauth>` returns **401 `error_code 105`** "authtoken is not valid". `Authorization: Bearer <oauth>` returns **422 `error_code 21`** "Stack not found". (A management token as `authtoken:` returns **401 "not logged in"**, never the right credential here.) + > + > **A session authtoken is a full user-session credential**: every org, every stack, every permission that user holds, valid until the session ends. Pasting it leaves copies in shell history, logs and process lists. Don't request one speculatively: attempt OAuth, read the response, and only then escalate. If you do use one, scope it to this single call and discard it. Don't write it to `.env`. + > + > Sending the OAuth token in the wrong header is itself a 401 (`error_code 105`): OAuth goes in `Authorization: Bearer`, `authtoken:` is for session tokens only. Capability is still rolling out per environment, so branch on the response rather than assuming. See `install-contentstack-mcp` § What it does, and does NOT do. + + ``` + POST https://<studio-api-host>/v1/projects (headers: organization_uid, $CS_AUTH — OAuth Bearer resolves this on prod) + { + "name": "<Project Name>", + "description": "...", + "connectedStackApiKey": "<stack api_key from step 3>", + "contentTypeUid": "<compositions CT uid from step 7>", + "canvasUrl": "/canvas" // route on the canvas-app that mounts <StudioCanvas/> + } + ``` + + Then configure it (this is where environment + locale + Freeform attach): + + ``` + PUT https://<studio-api-host>/v1/projects/<projectUid> + { + "name": "<Project Name>", + "canvasUrl": "/canvas", + "connectedStackApiKey": "<api_key>", + "contentTypeUid": "<compositions CT uid>", + "settings": { + "configuration": { "environment": "<env UID from step 4>", "locale": "en-us" }, + "isFreeformEnabled": true + } + } + ``` + + The `environment` value MUST be the env **uid** (looked up via `GET /v3/environments`), not the name. The Studio API regional hosts mirror the CMA: `eu-composable-studio-api.contentstack.com`, `azure-na-composable-studio-api.contentstack.com`, `azure-eu-composable-studio-api.contentstack.com`. + +11. **Publish at least one composition entry** to the chosen environment before testing rendering. Unpublished compositions return 404 from the CDA, presenting as a blank canvas or "Composition Not Found." See [`author-composition-via-api`](author-composition-via-api.md) § Publish for the publish call. + +12. **Record the project metadata** (CT uid, environment, delivery token, preview token, Studio project uid, branch) in the project's `.env.example` so [`install-studio`](install-studio.md) can wire the host app consistently. + +## Acceptance + +- [ ] Live Preview enabled: `GET /v3/stacks/settings` returns `stack_settings.live_preview.enabled === true`. +- [ ] Environment exists. Delivery token + preview token captured. +- [ ] `GET /v1/projects` lists the project with `connectedStackApiKey === <stack api_key>` and the right `contentTypeUid`. +- [ ] `GET /v3/content_types/<uid>` returns all 13 fields from the table with exact `data_type`, `mandatory`, `multiple`. +- [ ] `linked_schemas` is `group` with `multiple: true`. (Single-group is the most common provisioning bug, re-check.) +- [ ] `linked_sections` is `reference` with `multiple: true` and `reference_to: [<this CT uid>]`. +- [ ] `composable_uid` is mandatory + unique. +- [ ] `static_value` group contains all 13 sub-groups with correct shape. +- [ ] One composition entry exists and is published. CDA `GET /v3/content_types/<uid>/entries?query={"composable_uid":"<your-uid>"}&environment=<env>` returns it. +- [ ] Preview token is set on the environment. `?live_preview=<hash>` flows through `sdk.fetchCompositionData`. + +## Common pitfalls + +| Pitfall | Why it bites | Fix | +|---|---|---| +| Naming a group sub-field `uid` | The CMA rejects the whole `PUT` with `422 schema.N.schema.0.uid: "has a restricted value 'uid', which is not allowed"`. Easy to hit on `linked_schemas`, where the natural guess for the content-type key is `uid` | The sub-field is **`content_type_uid`**, never `uid`. See the `linked_schemas` row above. `uid` is reserved by the CMA inside groups | +| `linked_schemas` modelled as `group` with `multiple: false` | Editor expects an array. Section-context build crashes on `.map` of a single object | Re-create CT with `multiple: true`. Or PATCH the CT field if no entries exist yet | +| `linked_sections` modelled as text/group instead of reference | Editor's `?include[]=linked_sections` returns nothing. Template Did Not Load on canvas open | Must be `reference` field with `reference_to: [<same CT uid>]`. See [`troubleshoot-canvas`](troubleshoot-canvas.md) Template-Did-Not-Load row | +| `composable_uid` left non-unique | Runtime lookup picks an arbitrary entry. One composition renders for many URLs | Mark mandatory + unique at CT creation time | +| Forgetting `static_value` sub-groups | Picker's Static Value root shows fewer (or zero) type options | Add the 13 sub-groups exactly as schema'd above | +| Composition entry not published | CDA returns 404. Canvas blank | Publish to the environment after entry create (P5) | +| Preview token missing / set to literal `"undefined"` | Canvas iframe gets HTTP 401 with no CORS headers, "Component Loading Error" | See [`troubleshoot-canvas`](troubleshoot-canvas.md) preview-token row | +| JSON-RTE static values fail to embed correctly | JSON-RTE embeds carry `attrs.locale`, missing in API-authored payloads | Set `attrs.locale` on every embedded entry/asset in `static_value.json_rte[*].value` (P6) | +| Wrong region host | CT creation 404s | Use region-specific CMA host. See [`reference_studio_provisioning_api`](../../docs/90-reference/studio-provisioning-api.md) | +| Live Preview not enabled on stack | Canvas iframe hangs on "Loading composition…". Rest-preview CORS preflight fails | `POST /v3/stacks/settings` with `live_preview.enabled`, `default-env` and `default-url` (step 5). `PUT /v3/stacks` answers `200` and changes nothing | +| Studio project configured with environment **name** instead of **uid** | Studio API accepts the PUT but environment binding silently misses. Compositions never resolve | Look up env uid via `GET /v3/environments`, pass `settings.configuration.environment = <uid>` | +| Re-using a Studio project across stacks (name match, stale `connectedStackApiKey`) | Project surfaces last stack's compositions. Current stack invisible in canvas | On every provision, PUT the project with the current `connectedStackApiKey` to re-bind | +| Mixed CMA + Studio API hosts (e.g. NA Studio API with EU CMA) | 401/404s with no clear error | Pick a region and use BOTH region-matched hosts | +| Including `linked_sections` in the initial `POST /v3/content_types` | `error_code 115: linked_sections.reference_to: content type does not exist`. CMA validates reference targets at create time. A CT can't self-reference before it exists | Two-step: POST without `linked_sections`, then PUT with the full schema (step 8) | +| Delivery-token scope trimmed to environment-only (branch scope dropped) | `"Delivery Token creation failed. Please try again."` (no HTTP error code, no field diagnostic) a natural "simplification" of the step 6 body silently fails | Both scope entries are REQUIRED: `{ module: "environment", ... }` AND `{ module: "branch", branches: ["main"], acl: { read: true } }`. Keep the two-entry body from step 6 verbatim | +| `PUT /v3/environments/<uid>` to update an environment (using the UID in the path) | `error_code 248: could not find environment`. This CMA endpoint keys on env **name** in the path, not UID | Use the env name: `PUT /v3/environments/preview`. Studio `/v1/projects` config (step 10) instead takes the env UID from step 4 | +| Creating the stack (`POST /v3/stacks`) in an org where the user lacks stack-create rights | **HTTP 403, `error_code 316`, `"You don't have the permission to do this operation."`** (verified), a bare 403, no field diagnostic | Don't crash. Tell the user: get stack-create rights in the org, OR switch to an org where they can (re-auth if using the MCP: there's no org-picker, switch the active org first), OR reuse an existing stack. `--auth` captures the active org, so switching active org before login targets the right one | + +## See also + +- [`author-composition-via-api`](author-composition-via-api.md): create composition entries against this CT. +- [`provision-studio-stack`](provision-studio-stack.md): assets + page CTs the compositions reference. +- [`install-studio`](install-studio.md): host-app wiring + env vars. +- [`troubleshoot-canvas`](troubleshoot-canvas.md): preview token, linked_sections, blank canvas symptoms. +- [`reference_studio_provisioning_api`](../../docs/90-reference/studio-provisioning-api.md): host map and `/v1/projects` shape. diff --git a/skills/studio/provision-studio-stack.md b/skills/studio/provision-studio-stack.md new file mode 100644 index 0000000..39c6fc4 --- /dev/null +++ b/skills/studio/provision-studio-stack.md @@ -0,0 +1,161 @@ +--- +name: provision-studio-stack +--- + +## When to use + +Seed a stack with page CTs, global fields, entries, and assets, the prerequisites a Studio project needs OUTSIDE the compositions CT. Covers asset upload + dedupe + publish. + +Use when standing up a brand-new stack headlessly that a Studio project will consume (templates connect to these CTs, entries provide preview data, assets back image / file fields). Signals: "seed the stack", "create CTs + entries via API", "upload assets". Do NOT use for the compositions CT (`provision-studio-project`) or composition entries (`author-composition-via-api`). + +> **Mandatory auth preflight: settle the credential before the first API call.** Resolve it OAuth-first per [`authenticate-cma`](authenticate-cma.md): `CS_OAUTH_ACCESS_TOKEN`, else the Contentstack MCP's stored session: Read that skill for where it is, even with no MCP connected. **Never ask the user for a session `authtoken`.** If nothing resolves, or a refresh fails with `400 invalid_refresh_token`, hand them `! CONTENTSTACK_REGION=<code> npx @contentstack/mcp --auth` (it needs a TTY and a browser, so it cannot be run for them), and wait. `403 error_code 316` is a valid credential aimed at another org: fix the org or the `api_key`, do **not** re-authenticate. + +# Provision Studio Stack (CTs, entries, assets) + +## Context + +A Studio project references content that lives in the stack: page CTs (e.g. `blog_post`, `product`), global fields they reuse, entries that satisfy URL patterns, and assets bound to file fields. [`provision-studio-project`](provision-studio-project.md) handles the stack-wide setup (Live Preview, environment, delivery + preview tokens) and creates the compositions CT. This skill handles everything else: the **content types the compositions connect to**, the **entries** that preview them, and the **assets** that fill their file fields. + +All operations are CMA-only (`api.contentstack.io` and regional variants). Auth = `api_key` + `$CS_AUTH`, resolved OAuth-first: **assume no session `authtoken` exists**. See [`authenticate-cma`](authenticate-cma.md). **Non-prod (the non-prod domain your team uses) has no `region` shortcut**: every host is `<env>-<service>.<non-prod-domain>` and must be set explicitly. The full table is [`install-studio`](install-studio.md) § Non-prod hosts. Establish which data center you're on first via [`analyze-project-fit`](analyze-project-fit.md) § 2b: Determine the data center. + +> **MCP-first execution.** Discover tools and verify the target with a read call per [`install-contentstack-mcp`](install-contentstack-mcp.md). When supported tools are callable, **most** CMA steps run as MCP tool calls, no pasted `authtoken`: global fields use `create_a_global_field` / `update_a_global_field`, page CTs use `create_a_content_type` / `update_content_type`, asset **dedupe** uses `get_all_assets`, asset **publish** uses `publish_an_asset`, entries use `create_an_entry` / `update_an_entry` (idempotent PUT branch) / `publish_an_entry`. **Two steps stay raw HTTP even with the MCP:** (1) the asset **binary upload** (Step 4, `POST /v3/assets` multipart, there is NO create/upload-asset MCP tool, only get/publish/update/delete), (2) the Step 7 **CDA verify** (a delivery-host read, `get_single_entry` is a CMA read and can't confirm publish state or file-field asset-URL resolution. `cda` isn't in the default `GROUPS` either). Read the stack `api_key` from the MCP registration and confirm it before asking ([`understand-contentstack-mcp`](understand-contentstack-mcp.md)). A missing readiness flag requires discovery; use HTTP only for unavailable operations or a recorded connection limitation. + +## Task + +1. **Prerequisites.** Stack exists with delivery + preview tokens. The compositions CT has been created via [`provision-studio-project`](provision-studio-project.md). You have `CS_AUTH_TOKEN`, `CS_API_KEY`, the target environment name, and the region's CMA host. + +2. **Create / update global fields first.** Page CTs that reference global fields (e.g. `gf_hero`, `gf_card_list`) must have those GFs in place at CT create time. Contentstack rejects CT POSTs that reference an unknown global field uid. + + ``` + POST /v3/global_fields (idempotent — check first via GET /v3/global_fields/<uid>) + { "global_field": { "title": "...", "uid": "<uid>", "schema": [ ... ] } } + ``` + + Use `PUT /v3/global_fields/<uid>` to update an existing one. Same auth headers. + +3. **Create page content types.** For each page CT (`blog_post`, `product`, `case_study`, etc.): + + ``` + POST /v3/content_types + { "content_type": { + "title": "Blog Post", + "uid": "blog_post", + "schema": [ /* field definitions */ ], + "options": { + "is_page": true, // makes it a page CT (URL-aware) + "url_pattern": "/blog/:title", // optional — drives content_type_url_pattern URL source + "singleton": false + } + } } + ``` + + Idempotency: check `GET /v3/content_types/<uid>` first. PUT on exists, POST otherwise. + + **Every CT MUST include a `source_uid` field for downstream chain compatibility.** This is a contract: [`import-content`](import-content.md) uses `source_uid` as its idempotency key when re-running imports. Skip it and the import silently creates duplicates on every re-run. + + ```json + { + "display_name": "Source UID", + "uid": "source_uid", + "data_type": "text", + "field_metadata": { "description": "Idempotency key for imports from external sources", "default_value": "", "multiline": false, "version": 3 }, + "mandatory": false, + "unique": false, + "multiple": false, + "indexed": true + } + ``` + + **Important:** field UIDs MUST NOT start with an underscore. Contentstack rejects CT creation with error 115 (`The UID must begin with a letter, and can contain only lowercase letters, numbers, or underscores.`). Use `source_uid`, NOT `_source_uid`. + + **Page CT vs non-page CT.** Set `options.is_page: true` ONLY if entries of this CT should be addressable by URL (Connected Templates resolve against these). Non-page CTs (e.g. `author`, `testimonial`) are referenced from page entries but don't get their own URL. + +4. **Upload assets.** Each file referenced by an entry must exist as a stack asset first. Use multipart upload to `POST /v3/assets`: + + ``` + POST /v3/assets (multipart/form-data) + Fields: + asset[upload] = <file stream> (required) + asset[title] = <human-readable name> + asset[parent_uid] = <folder uid> (optional — omit for stack root) + asset[tags] = <comma-separated tags> (optional) + ``` + + Response: `data.asset.{uid, url, content_type, file_size, ...}`. + + **Dedupe before upload.** A naive seed re-uploads the same image on every run, leaving orphans. Before posting, list existing assets and match by title (or by SHA-256 of bytes for true dedupe): + + ``` + GET /v3/assets?query={"title":"<name>","parent_uid":"<folder>"}&limit=1 + ``` + + If a match exists, reuse `data.assets[0].uid`. Cache the mapping locally (e.g. `.asset-map.json` keyed by source filename, with the asset uid as the value) so cross-runs are idempotent. + + **Publish each asset** to the target environment before publishing entries that reference it. Unpublished assets surface as broken images in canvas and on the live site: + + ``` + POST /v3/assets/<uid>/publish + { "asset": { "locales": ["en-us"], "environments": ["<env name>"] } } + ``` + +5. **Create entries.** For each entry payload: + + - **Resolve asset references** before POSTing. File-field shape on CMA entries: + - Single-file field: `"featured_image": "<asset_uid>"` (string) + - Multiple-file field: `"images": ["<uid1>", "<uid2>"]` (array of strings) + + The CDA later resolves these to full asset objects (`{ uid, url, filename, ... }`), but on the WRITE side (CMA), pass uids. + + - **Resolve cross-entry references** by uid. Reference fields hold arrays of `{ uid: "<entry_uid>", _content_type_uid: "<ref_ct>" }` objects (a common pattern is a seed-uid map persisted at `.entry-map.json` to translate stable identifiers to server-assigned uids). + + - **JSON-RTE embeds** carry `attrs.locale` on every embedded entry / asset node. Omitting `attrs.locale` makes the embed render blank. Studio's RTE renderer matches embed locale to the page locale (P6). + + - **Idempotency.** Find by title (or another stable field) via `GET /v3/content_types/<ct>/entries?query={"title":"..."}&limit=1`. PUT on exists, POST otherwise. + + ``` + POST /v3/content_types/<ct>/entries + { "entry": { "title": "...", /* all schema fields */ } } + ``` + +6. **Publish each entry** to the target environment. Unpublished entries return 404 from the CDA. Connected Template canvas shows blank. Connected Template URL resolution also relies on the published entry existing for the connected CT (see [`build-connected-template`](build-connected-template.md) step 1). + + ``` + POST /v3/content_types/<ct>/entries/<entry_uid>/publish + { "entry": { "locales": ["en-us"], "environments": ["<env>"] } } + ``` + +7. **Verify.** For each seeded entry: `GET https://<cdn-host>/v3/content_types/<ct>/entries/<uid>?environment=<env>` returns 200 with all file-field references resolved to asset URLs. + +## Acceptance + +- [ ] All required global fields exist (`GET /v3/global_fields/<uid>` returns 200). +- [ ] All required page CTs exist with the expected schema, `options.is_page` set correctly per CT. +- [ ] Every CT includes a `source_uid` (text, non-mandatory, indexed) field so downstream `import-content` runs are idempotent. Field UID must NOT begin with `_`. +- [ ] Asset dedupe map (`.asset-map.json` or equivalent) persists across runs: re-running provision does NOT upload duplicates. +- [ ] Each asset is published to the target environment. **Always request with `?include_publish_details=true`** (`GET /v3/assets/<uid>?include_publish_details=true`) and require an explicit array entry for the env. The CMA omits `publish_details` unless asked (verified for entries, where the Studio SDK itself opts in with `include_publish_details: true`), and an omitted field is indistinguishable from an unpublished asset. Passing the flag is harmless if an endpoint returns the field anyway. **Absent ≠ unpublished, only `[]` is**. +- [ ] Each entry's file fields hold asset uid(s), not URLs or filenames. +- [ ] Each entry's reference fields hold `{ uid, _content_type_uid }` objects, with uids resolved from the seed-uid map. +- [ ] Each entry is published. CDA `GET /v3/content_types/<ct>/entries/<uid>?environment=<env>` returns 200. +- [ ] JSON-RTE embeds carry `attrs.locale` matching the entry's locale. + +## Common pitfalls + +| Pitfall | Why it bites | Fix | +|---|---|---| +| Creating a CT that references a GF before the GF exists | CMA rejects POST `/v3/content_types` with "global field not found" | Create / update all global fields BEFORE page CTs | +| Re-uploading the same asset every provision run | Stack accumulates duplicate assets. Entry references go stale on re-create | Dedupe via `GET /v3/assets?query={"title":"…"}` + persist `.asset-map.json` | +| File-field value set to the asset URL or filename instead of uid | CMA accepts the POST but the field reads as broken in CDA | Pass `<asset_uid>` (string) for single-file, `[<uid>, ...]` for multi-file | +| Forgetting `_content_type_uid` on reference field entries | CDA can't resolve the reference. Field reads as empty | Each reference entry is `{ uid, _content_type_uid }` | +| JSON-RTE embed missing `attrs.locale` | Embed renders as a placeholder / blank | Set `attrs.locale` on every embedded entry / asset node (matches entry locale) | +| Asset published, entry referencing it NOT published | Entry 404s on CDA. Canvas blank | Publish entries AFTER assets, BEFORE testing canvas | +| Entry published, asset NOT published | Image fields surface broken URLs on the live site | Publish assets first | +| `options.is_page: true` set on a non-URL CT (e.g. `author`) | Studio surfaces the CT in URL-resolution probes. Spurious template matches | Set `is_page` only on URL-addressable CTs | +| Seed-uid map (`.entry-map.json`) deleted between runs | Cross-entry references break on subsequent runs (seed-uids no longer translate to server-uids) | Commit the map OR rebuild it from `GET /entries?query={"title":"..."}` lookups | +| Wrong region CMA host | 401 / 404 on every call | Use region-specific host (`eu-api.contentstack.com`, `azure-na-api.contentstack.com`, etc.) | + +## See also + +- [`provision-studio-project`](provision-studio-project.md): stack-wide setup, compositions CT, Studio project registration. +- [`author-composition-via-api`](author-composition-via-api.md): composition entries that bind to the CTs created here. +- [`build-connected-template`](build-connected-template.md): Template authoring against these page CTs. +- [`reference_studio_provisioning_api`](../../docs/90-reference/studio-provisioning-api.md): host map and auth headers. diff --git a/skills/studio/register-breakpoints.md b/skills/studio/register-breakpoints.md new file mode 100644 index 0000000..c6460bb --- /dev/null +++ b/skills/studio/register-breakpoints.md @@ -0,0 +1,119 @@ +--- +name: register-breakpoints +--- + +## When to use + +Register default/tablet/mobile breakpoints at SDK boot and verify the canvas breakpoint switcher renders. + +Use when wiring responsive viewports into a Studio host app, e.g. "add tablet/mobile breakpoints", "the breakpoint switcher is missing from my canvas toolbar", or validating a breakpoint config against `BreakpointInput`. Run AFTER `install-studio` and `setup-section-preview`. The switcher only appears once the canvas mounts and the registry has at least two breakpoints. + +> **Auth preflight: settle the credential before the first API call.** Resolve it OAuth-first per [`authenticate-cma`](authenticate-cma.md): `CS_OAUTH_ACCESS_TOKEN`, else the Contentstack MCP's stored session: Read that skill for where it is, even with no MCP connected. **Never ask the user for a session `authtoken`.** If nothing resolves, or a refresh fails with `400 invalid_refresh_token`, hand them `! CONTENTSTACK_REGION=<code> npx @contentstack/mcp --auth` (it needs a TTY and a browser, so it cannot be run for them) and wait. `403 error_code 316` is a valid credential aimed at another org: fix the org or the `api_key`, do **not** re-authenticate. + +# Register Breakpoints (default + tablet + mobile) + +## Context + +Studio's canvas can preview a composition at multiple viewport sizes. The host app declares those viewports once at bootstrap by calling `registerBreakpoints` from `@contentstack/studio-react`. The first entry is always the `default` breakpoint (no `query` key). Subsequent entries carry a raw CSS media query string plus a `previewSize` used to resize the canvas iframe. + +The shape comes from `@contentstack/studio-registry` (`breakpoint-registry.type.ts`): + +```ts +interface Breakpoint { + id: string; + displayName: string; + query: string; // raw CSS media query + previewSize: { width: number; height: number }; +} +// Input tuple: first entry MUST be the default and MUST NOT carry `query`. +type BreakpointInput = [DefaultBreakpoint, ...PreProcessedBreakpoint[]]; +``` + +The registry tracks unique `id`s and unique `displayName`s. Duplicates throw. The call must run **before** any `<StudioCanvas />` mounts, so it belongs in the same bootstrap module as `registerComponent` / `registerDesignTokens` (commonly `src/studio/bootstrap.ts` or wherever your app shell already imports `@/lib/contentstack`). + +Reference doc: `docs/20-bring-your-own-components/configure-custom-breakpoints.md`. + +## Task + +1. **Locate the bootstrap module: by content, not by path.** `grep -rln "registerComponent" src app lib 2>/dev/null` (path lists produce false negatives on `src/app`, route groups, and locale segments). Typical homes are ( `src/studio/bootstrap.ts`, `src/studio/registerComponents.ts`, or whatever the project pulls in from `@/lib/contentstack`). If none exists, create `src/studio/bootstrap.ts` and import it from the app shell (`app/layout.tsx`, `src/main.tsx`, `app/root.tsx`, etc.) for its side effects. + +2. **Add the import** at the top of that file: + + ```ts + import { registerBreakpoints } from "@contentstack/studio-react"; + ``` + +3. **Call `registerBreakpoints` before any canvas surface renders.** Use the user-provided values. The structure must be: + + ```ts + registerBreakpoints([ + { + id: "default", // required literal + displayName: "<defaultDisplayName>", + // NO `query` key on default — registry throws if present + previewSize: { width: <defaultWidth>, height: <defaultHeight> }, + }, + { + id: "tablet", + displayName: "Tablet", + query: "<tabletQuery>", + previewSize: { width: <tabletWidth>, height: <tabletHeight> }, + }, + { + id: "mobile", + displayName: "Mobile", + query: "<mobileQuery>", + previewSize: { width: <mobileWidth>, height: <mobileHeight> }, + }, + ]); + ``` + +4. **Confirm uniqueness.** Every `id` and every `displayName` in the array must be unique. The registry maintains a `uniqueNames` set and throws `Duplicate breakpoint name '<id>'` on conflict. + +5. **Restart the dev server.** Studio reads the registry once at boot. HMR updates may not re-register. A clean restart is the only reliable way to pick up a new array. + +6. **Verify in the canvas.** Open a composition in Studio canvas and walk through the verification checks below. + +## Inputs needed from the user + +In this order. Defaults above are sensible if the user is unsure. Keep going with them unless they explicitly want different values. + +1. `defaultDisplayName`: label shown on the desktop switcher button +2. `defaultWidth` / `defaultHeight`: canvas iframe size when "default" is active +3. `tabletQuery`: CSS media query string (e.g. `(max-width: 1024px)`) +4. `tabletWidth` / `tabletHeight`: preview iframe size at tablet +5. `mobileQuery`: CSS media query string (e.g. `(max-width: 640px)`) +6. `mobileWidth` / `mobileHeight`: preview iframe size at mobile + +Pick representative device widths inside each media-query range. A `(max-width: 640px)` rule with a 1024-wide `previewSize` will mislead the author. + +## Acceptance + +This skill succeeds only when ALL of the following hold. If any fails, surface the failure and stop. + +- [ ] `registerBreakpoints` is imported from `@contentstack/studio-react` in the bootstrap module +- [ ] The call runs at module load (no lazy wrapping in a component effect) +- [ ] First entry has `id: "default"` and NO `query` key (the doc example showing `query: ""` is wrong. Omit the key entirely: the registry rewrites it to `"*"` internally) +- [ ] Every `id` and `displayName` is unique +- [ ] Each non-default entry has both `query` and `previewSize` +- [ ] Canvas toolbar renders three buttons with the configured `displayName`s +- [ ] Clicking each switcher resizes the canvas iframe to the matching `previewSize` +- [ ] Opening the Design panel on any component shows breakpoint-scoped overrides. Switching the breakpoint changes the active slot +- [ ] A canvas iframe screenshot at each breakpoint (not just an a11y snapshot: iframe contents are opaque to it) shows the composition reflowing as expected + +## Common pitfalls + +| Pitfall | Why it bites | Fix | +| --- | --- | --- | +| `query` set on default | Throws `Default breakpoint should not have a query` | Omit the key entirely | +| First entry not `id: "default"` | Throws `First breakpoint must be default` | Put the default entry first | +| Missing `previewSize` or `query` on non-default entries | Throws `Breakpoint '<field>' is required` | Provide both for every non-default entry | +| Switcher not visible | `registerBreakpoints` ran after canvas mount, or never ran | Move it into the same boot path as `registerComponent`, before rendering | +| `previewSize` mismatched to query range | Canvas frame misrepresents the device | Pick a representative width inside the media-query range | +| Mobile styles bleeding into desktop | Base styles were set while on the Mobile breakpoint | Design `default` first, then override downward | + +## See also + +- `docs/20-bring-your-own-components/configure-custom-breakpoints.md` for the authoring model and override semantics. +- `install-studio` and `setup-section-preview` for the prerequisite setup. +- `troubleshoot-canvas` if the switcher never appears even after a clean restart. diff --git a/skills/studio/register-component-cslp.md b/skills/studio/register-component-cslp.md new file mode 100644 index 0000000..2072786 --- /dev/null +++ b/skills/studio/register-component-cslp.md @@ -0,0 +1,196 @@ +--- +name: register-component-cslp +--- + +## When to use + +The two halves of edit-ability: `$` CSLP props that make bound values editable in Visual Editor, and the `studioAttributes` contract that carries the node's own tag and identity. Emit both or neither. + +Part of the component-registration set. Start at [`register-component`](register-component.md), which routes here. + +## CSLP tags: the `$` props that make bound values editable + +Also searchable as: `data-cslp`, `Cslptag`, `$title` / `$image`, `$`-twin, click-to-edit, inline editing, "Visual Builder renders but won't edit". + +Full reference: [CSLP tags: the `$` props that make bound values editable](../../docs/20-bring-your-own-components/component-schema-prop-types.md#cslp-tags-the--props-that-make-bound-values-editable). + +**Silent-failure #1 in hand-written registered components:** a bindable prop is registered, the component renders the value fine, and **click-to-edit never attaches**. No error, no warning. The page looks finished and authors cannot touch it. + +Every bindable prop arrives **twice**: the resolved value under its own name, and the CSLP tag for the field it came from under the same name with a `$` prefix. The `$` prop holds one attribute, `data-cslp`, which is the field's address in the entry. It is the anchor Visual Editer attaches the edit affordance to. Its type is exported as `Cslptag`. You never construct one by hand. + +### Two things must both be true + +**1. The SDK must be told to emit the tags.** The switch is `cslp.appendTags` on `studioSdk.init`: + +```ts +studioSdk.init({ stackSdk: stack, contentTypeUid: "compositions", cslp: { appendTags: true } }); +``` + +Without it **every `$` prop is `undefined`** no matter how correctly the components are written, and no element on the page carries a tag. [`install-studio`](install-studio.md) emits this. If you are registering components into a project someone else wired, grep for `appendTags` before debugging anything else. + +**2. Each `$` prop must be spread on the element that renders its value.** + +```tsx +import type { Cslptag } from "@contentstack/studio-react"; + +interface HeroProps { + eyebrow?: string; + headline?: string; + backgroundImage?: string; + // One `$` prop per bindable prop above. + $eyebrow?: Cslptag; + $headline?: Cslptag; + $backgroundImage?: Cslptag; +} + +// ❌ Renders every value, and nothing is editable. +export function Hero({ eyebrow, headline, backgroundImage }: HeroProps) { + return ( + <section style={{ backgroundImage: `url(${backgroundImage})` }}> + {eyebrow && <p className="eyebrow">{eyebrow}</p>} + <h1>{headline}</h1> + </section> + ); +} + +// ✅ Each `$` prop spread on the element that renders its value. +export function Hero({ + eyebrow, $eyebrow, + headline, $headline, + backgroundImage, $backgroundImage, +}: HeroProps) { + return ( + <section {...$backgroundImage} style={{ backgroundImage: `url(${backgroundImage})` }}> + {eyebrow && <p className="eyebrow" {...$eyebrow}>{eyebrow}</p>} + <h1 {...$headline}>{headline}</h1> + </section> + ); +} +``` + +The spread has to land on the element rendering the value, not on a parent. A tag on the wrapper makes the whole block one edit target instead of the field. + +**Checklist for every component you register:** + +- [ ] `studioSdk.init` passes `cslp: { appendTags: true }`. +- [ ] For each bindable prop that renders **visible text or an image**: `$prop` is declared on the props interface as `Cslptag` and destructured. +- [ ] For each `$prop`: `{...$prop}` is spread on the DOM element that renders that prop's content, not on an ancestor. +- [ ] Slot-typed props (`type: "slot"`) hold child nodes, not values, and have no `$` prop. +- [ ] Non-visible props (analytics IDs, aria labels not tied to visible text) don't strictly need one, but adding it costs nothing. + +**Verify from the rendered page, not from the source.** `curl -s <url> | grep -c 'data-cslp'`, or DevTools on the composition. Zero tags means `appendTags`. Some fields tagged and others not means those others aren't spreading their `$` prop. A tag reads `<ct_uid>.<entry_uid>.<locale>.<field_path>`, so you can confirm it points at the field you meant. + +## The `studioAttributes` contract: the node's own CSLP tag and identity + +The `$` props above (also called `$`-twins) cover **fields**. There is a second, separate attribute bag covering the **node**, and missing it is silent in the same way. + +For every registered component the renderer passes `studioAttributes`: the node's `data-cslp` plus the `data-composable-studio-*` identifiers Studio uses to locate the node on the canvas. Spread it on the component's **root** element: + +```tsx +// ✅ The pattern every built-in SDK component uses (41 of them do this). +export function Number(props: NumberProps) { + const { studioAttributes, number, $number, ...rest } = props; + return ( + <p {...rest} {...studioAttributes} {...$number}> + {number} + </p> + ); +} +``` + +Both bags, one component, different jobs: + +| Bag | Scope | Spread on | Enables | +| --- | --- | --- | --- | +| `studioAttributes` | the node | the **root** element | node-level `data-cslp`, canvas hit-testing, selection | +| `$<propName>` | one field | the element rendering **that** prop | inline click-to-edit for that field | + +### The two halves are one change: emit both or neither + +**Every registered component takes `studioAttributes` and is registered `wrap: false`.** + +Why this matters. Studio renders a registered component inside a wrapper `<div>` unless its entry says otherwise. That div sits between the styled container the component is placed in and the component's own root, so the CSS around it never reaches the component. A grid or flex parent styles the wrapper, and the component inside it goes unstyled. + +Under `wrap: false` Studio hands the component the editor's selection ref and node ids as `studioAttributes` **instead of** putting them on a wrapper. So the two halves are a single change: + +1. The component declares `studioAttributes` and spreads it on its root element. +2. Its register entry carries `wrap: false` at the top level, right after `component:`. + +Emit both or neither. A component registered `wrap: false` that does not spread them **cannot be selected on the canvas**, worse than the div it was avoiding. + +```tsx +import type { StudioAttributes } from '@contentstack/studio-react'; + +export interface HeroProps { title?: string } + +const Hero = ({ title, studioAttributes }: HeroProps & StudioAttributes) => ( + <section className="hero" {...studioAttributes}> + <h1>{title}</h1> + </section> +); +``` + +```ts +{ + type: 'hero', + component: Hero, + wrap: false, + displayName: 'Hero', + props: { title: { type: 'string', displayName: 'Title', defaultValue: '' } } +} +``` + +**Where the spread goes.** On the **outermost** element, and **last** (after any `{...props}`) so it wins. + +**The root must reach a real DOM node:** + +- a plain HTML tag (`<section>`, `<div>`, `<article>`), always correct, prefer this. +- a forwarding component you already pass DOM attributes to (`<Link className={…}>`), acceptable. +- **never** a Fragment (`<>…</>`), and **never** a component you pass only semantic props to (`<Card title={…} />`). The ref would land nowhere. + +If the design needs sibling roots, wrap them in one plain element and put the spread there. + +**TypeScript.** Intersect the props type with `StudioAttributes` and import it with `import type`. It is a type-only export, and a value import breaks projects on `verbatimModuleSyntax`. A JavaScript component just destructures the name. + +**Variants of the same rule:** + +| Shape | How the spread is reached | +| --- | --- | +| Props not destructured | `(props: HeroProps & StudioAttributes)`, spread as `<section {...props.studioAttributes}>` | +| Component takes no props | `({ studioAttributes }: StudioAttributes)`, it still takes this one | + +`studioAttributes` is **not content**. Never register it as a prop, never give it a CSLP tag, never put it in a `<Slot data={…}>`. + +If a later edit rewrites a component's root, carry the spread onto the new root. Never leave the prop declared but unspread, and never leave `wrap: false` on a component that no longer spreads it. + +**An existing `wrap` that is not `false`**: `wrap: true`, `wrap: 'section'`, is a deliberate choice about that component's markup. Leave it as written. + +**Prerequisite: CSLP must be switched on.** `cslp: { appendTags: true }` gates **both** bags, not just the `$` props: with it absent, `studioAttributes` arrives empty too and no amount of correct spreading produces a `data-cslp`. Check this first when tags are missing everywhere at once, rather than auditing components one by one. See § Two things must both be true above, and [`install-studio`](install-studio.md). + +**Retrofitting a component library?** Both halves have to line up, so count them separately, a mismatch is the bug: + +```bash +grep -c "registerComponent" <file> # registrations +grep -c "wrap: false" <file> # halves in the registry +grep -rl "studioAttributes" <components-dir> | wc -l # halves in the components +``` + +All three should agree. `wrap: false` outnumbering the spreads is the dangerous direction. Those components are unselectable, where the ones merely missing `wrap: false` still render inside a wrapper. + +A real audit of 19 atomic components found **0** spreading `studioAttributes`, so every one of them rendered without a node-level `data-cslp`, and nothing anywhere reported it. + +| Pitfall | Why it bites | Fix | +| --- | --- | --- | +| Spreading `studioAttributes` but not `$prop` | The node is selectable on canvas, but no individual field is inline-editable, looks like Visual Editor "half works" | Spread both. They are different bags with different scopes | +| Spreading `{...rest}` and assuming it carries the tags | `studioAttributes` is its own named prop, not part of `rest`. Destructuring `rest` alone silently drops it | Destructure `studioAttributes` explicitly, as the built-ins do | +| Spreading `studioAttributes` on an inner element | Studio's hit-testing measures the node's root box. On an inner element, selection and drop targeting land on the wrong rectangle | Root element only | +| Component renders a fragment (`<>…</>`) | There is no root DOM node, so the ref lands nowhere and the node is unselectable | Give it one plain root element and spread there | +| `wrap: false` set, `studioAttributes` never spread | Studio put nothing on a wrapper and the component dropped it. The node cannot be selected at all, worse than the wrapper div | Emit both halves, or neither | +| Prop declared but unspread after a root rewrite | An edit replaced the root element and the spread stayed on the old one | Carry the spread onto the new root in the same edit | +| Spread placed before `{...props}` | A later spread overwrites the attributes | Spread `studioAttributes` **last**, on the outermost element | +| Spread onto a semantic-props component (`<Card title={…} />`) | It never reaches a DOM node, so there is no ref for hit-testing | Use a plain tag, or a component that forwards DOM attributes | +| `import { StudioAttributes }` as a value | It is a type-only export, breaks builds on `verbatimModuleSyntax` | `import type { StudioAttributes }` | +| Registering `studioAttributes` as a prop | It is identity, not content. A CSLP tag or `<Slot data>` entry on it corrupts the node | Never register, tag, or bind it | +| Tags missing on every component at once | Almost always `cslp.appendTags` not enabled, not a component bug | Fix the SDK config first | + +**Verify in the browser** (both bags at once): open the component in Studio, inspect the rendered DOM, and confirm the component's root element carries `data-cslp` **and** each bound text/image element carries its own. A root with no tag means `studioAttributes` was dropped. A root with a tag but bare fields means the `$`-twins were. diff --git a/skills/studio/register-component-pitfalls.md b/skills/studio/register-component-pitfalls.md new file mode 100644 index 0000000..903ea04 --- /dev/null +++ b/skills/studio/register-component-pitfalls.md @@ -0,0 +1,37 @@ +--- +name: register-component-pitfalls +--- + +## When to use + +The pitfall table for component registration: what fails silently, why, and the fix. + +Part of the component-registration set. Start at [`register-component`](register-component.md), which routes here. + +## Common pitfalls + +| Pitfall | Why it bites | Fix | +| --- | --- | --- | +| Using `default:` instead of `defaultValue:` | Studio silently ignores `default:`, palette preview renders blank | Use `defaultValue:` exactly | +| `registerComponent` without `thumbnailUrl` | Registry defaults it to `""`, so Studio renders a **text placeholder**, not a preview. No error, no warning. A twenty-component palette becomes an unbrowsable text wall and authors can't find anything | Add a `thumbnailUrl` inline-SVG data URI to every registration. Sweep the file: `registerComponent` count must equal `thumbnailUrl` count | +| `thumbnailUrl` pointing at a CDN / Contentstack asset / `/public` URL | The palette renders inside Studio's iframe. External requests hit CORS or fail silently, so the tile is blank or broken-image. Looks identical to "no thumbnail set" | Inline SVG data URI via `encodeURIComponent`. Zero network fetches. See `palette-conventions` | +| Registering components from a Server Component graph on Next App Router | Next throws "Attempted to call the default export … from the server but it's on the client", or the palette is empty because the server-side registry got populated instead of the client one | Import the registration module from the `"use client"` boundary that runs `studioSdk.init` (`app/studio-init.tsx`), never from `layout.tsx` or a server `page.tsx`. See step 5b | +| `type: "any"` on a prop bound to a Group / Global Field / block object, or to a list | The binder recursively unwraps single-key objects for every type except `object`/`array`, so a single-field block collapses to its inner value. Lookups return `undefined`, the array's indices show as `{0,1,2,3}`, and the component renders **blank with no error** | Use `object` for a group/block, `array` for a list, the only two types that skip the unwrap. Reserve `any` for genuine primitives. See § The `any` flatten trap | +| Using `text`, `link`, `color`, `image` as prop types | These don't exist. Registration is rejected at runtime | Use the 14 allowed types only (`register-component` step 3) | +| Hardcoding an interactive component's behavior instead of exposing an `action` prop | Authors can drop the component but can't decide what it does. Every new behavior needs a code change. The palette looks complete while Studio only decorates the app | Declare `type: "action"` for the callback and register the function it can call. See [`wire-studio-state`](wire-studio-state.md) | +| A design prop typed `string` instead of `choice` | A free string re-opens the drift the library exists to prevent: `color: '#6c5ce7'`, `gap: '24px'`, one value per author. `choice` with a closed `options` list means the author picks from a menu and cannot type a raw value | Type every design prop (`color`, `size`, `align`, `variant`, `gap`, `tag`) as `choice` with `options` + `defaultValue`. Reserve `string` for content | +| Copying a prop type off a built-in node into a `registerComponent` schema | Built-ins use a separate prop-type vocabulary. A type outside the 14 is rejected | Map to one of the 14 registered types. Read built-in defs for structure, never for prop-type strings | +| Registering a prop that's not actually on the component | The Data Picker offers a binding that crashes at render time | Read the source. Register only props you see | +| Duplicate `type:` UID across two components | **First registration wins. Duplicates are silently skipped** (the SDK's default is idempotent so HMR / RSC-plus-client / build-worker re-evaluation doesn't break). A real bug stays hidden because the second definition never lands. | Search the registration file for the UID before writing. Pass `{ strict: true }` as the second arg to `registerComponents([...], { strict: true })` to throw on duplicates instead. | +| Forgetting to import the registration file at startup | Components never appear in the palette | Add `import "./register-components"` to `main.tsx` / `_app.tsx` | +| Registering the unwrapped primitive (e.g. `Box` from a UI lib) instead of the customer's component | Loses brand styling. Studio composes pages out of unstyled primitives | Register the customer's wrapped component (e.g. `<Hero>`), not the lib primitive | +| Inferring a `string` prop as `choice` because TS uses a union of two literals | Authors can't type freeform text into a free-form field | Use `choice` only when the union is a closed set the user MUST pick from | +| Arity-0 component (`function Header() {…}` as `component: Header`) | SDK treats as lazy loader and calls it outside render, which throws React #321 "Invalid hook call". | Use lazy shape `() => import(...)` or give it a `props` param. | +| Registering eagerly when lazy would do | Eager ships in entry bundle. Many components balloon TTI. | Default to lazy `() => import("./Foo")`. | +| Component depends on layout ancestor (e.g. `.fs-card` only sized by `.fs-grid`) | Renders full-bleed in a Slot/Repeater that doesn't provide that ancestor. | Decouple. See Layout contract above. Layout lives in parent Section. See `use-section-slot` § Layout container. | +| Setting `defaultValue` on a `slot` prop | Slots start empty until authors fill them. A static default doesn't fit the model and is silently ignored. | Omit `defaultValue` on `slot` props. The slot renders as a dashed drop target until an author drops content. | +| Naming a slot prop something the component doesn't render | The slot becomes a drop target in Studio but its contents never appear at render time because the component ignores the prop. | The prop name must match what the React component's signature uses, typically `children` (for `<Card>{...}</Card>` style), but any name works as long as the component renders `{propName}` somewhere in its JSX. | +| Trying to constrain a slot to "only accept Sections of type X" | Slots accept any registered component or Section. No built-in type filter exists. Adding a constraint isn't supported. | If you need a typed value override (one specific kind of content), use an exposed Section Prop instead. If you need a typed component constraint, surface the expectation in the slot's `displayName` ("Drop a CTA Section here") so authors self-route. | +| Component throws on `undefined` / `null` / empty-array props during canvas preview | Bindings resolve at runtime. An unfilled entry field, empty multi-file field, or unbound prop surfaces `undefined` to the component. One thrown access kills the whole canvas render. Author can't recover without re-binding. | See § Null-safe rendering contract above. Optional-chain every nested access (`props.image?.url`). Return `null` or a skeleton when essential data is missing. | +| Confusing `slot` (component prop type) with Section Slot (drop region inside a Section) | They are different mechanisms at different levels, both surface drop targets, both can hold the other. | A `slot` PROP is part of a registered component's schema. A Section Slot is a Smart Container carved into a Section. The recommended composition runs in three steps: declare a `slot` prop on the component, carve a Section Slot inside that prop via `use-section-slot` § component-slot-prop placement, then let authors fill that Section Slot at template time. | +| Design tab is empty for this component when selected in the canvas | The registration didn't declare a `styles` block. The Design tab surfaces the sections listed in `styles`, so with no `styles` the tab is empty. | Add `styles: [...]` to the registration listing the categories authors should be able to edit (`size`, `spacing`, `typography`, `background`, etc.). See [`docs/20-bring-your-own-components/component-schema-prop-types.md`](../../docs/20-bring-your-own-components/component-schema-prop-types.md) § styles. If the Design tab itself is missing (not just empty), the project's Freeform Feature is off (see [`install-studio`](install-studio.md) § After install) if the Design tab is missing / disabled. | diff --git a/skills/studio/register-component-prop-contracts.md b/skills/studio/register-component-prop-contracts.md new file mode 100644 index 0000000..a71bf25 --- /dev/null +++ b/skills/studio/register-component-prop-contracts.md @@ -0,0 +1,119 @@ +--- +name: register-component-prop-contracts +--- + +## When to use + +The prop-shape contracts a registered component must honour: array and object binding rules, tolerant `imageurl` and link signatures, and why `{{entry.title}}` does not resolve outside a Connected template URL pattern. + +Part of the component-registration set. Start at [`register-component`](register-component.md), which routes here. + +## Array & object props: binding rules + +`array` and `object` **do** bind to CMS fields, but under specific conditions that are easy to miss. Registering a `type: "array"` prop and expecting the picker to bind it to any Reference field will silently degrade to a manual-entry sub-field in cases the SDK doesn't cover. Two paths: + +**Bindable: `type: "array"` bound to a multi-value field.** The SDK reads the bound array directly, the array arrives at the component populated, and the component renders items with its own `.map()`. No Repeater, no Condition Block. Valid sources: + +- **Reference multi (or single)**: needs `data_sources.resolvedReferences` on the composition so CDA returns `?include[]=<field>`. Otherwise the array arrives as `{ uid }` stubs. This is the case that catches migrators. It's easy to bind and see stub UIDs render, missing the resolvedReferences step. +- **Group multi (`multiple: true` on a Group field)**: the group values are stored on the entry itself. No resolution needed. Array arrives populated as-is. +- **Modular Block**: the block payload is on the entry. No resolution needed. Note that Modular Blocks are polymorphic (each item can be a different block type). If your `.map()` renders every item the same, the array-prop path works. If it needs per-block-type rendering, use a Repeater + Condition Block instead (see below). +- **Scalar multi-values**: a `multiple: true` single-line-text field arrives as an array of strings. No resolution needed. + +Common requirements across all four: + +- The prop is `type: "array"` on the registration. +- The wrapper's per-item render works against the shape delivered by the CMS for that field type. +- The list is single-shape (no per-iteration variant authoring or template-author swap). + +This is the **preferred path** for wrapping an existing production component whose interface already takes an array of items. See `build-repeating-section` § When to skip the Repeater entirely: the array-prop alternative. + +**Layout fidelity (not just data shape) decides Repeater vs array-prop.** A Repeater renders items **uniformly in sequence**. It cannot reproduce a bespoke or asymmetric layout (a bento mosaic, a 1-big-plus-3-small grid, a carousel). When the design is fixed and non-uniform, or you simply want the production component's own layout verbatim, bind the whole array/object to the real component (`array` / `object`) and let its own markup + CSS run. The render is pixel-identical to the live site. Reach for a Repeater only when the layout genuinely IS a uniform list, or when items are polymorphic and need per-type rendering (Repeater + Condition Block). + +**Bindable: `type: "object"` bound to a matching entry Group / Global Field.** Same idea for a single-entry-object shape (a nested `cta: { label, href }` binding into an entry Group with matching sub-fields). Groups live on the entry, no `resolvedReferences` step. + +**When array/object won't fit: reach for the Repeater or the adapter pattern.** If the list iterates a **Modular Block** (polymorphic, each item can be a different block type), template authors need to **swap a different child Section per template instance**, or the production leaf's shape doesn't reduce cleanly to array-item scalars, use one of: + +- **`build-repeating-section`**: greenfield parent+child+Repeater pattern with a Section Slot for template-author swaps. +- **`adapt-collection-component`**: compatibility-adapter pattern for wrapping a legacy production component whose leaf can't be simplified. + +Both surface author variability the array-prop path can't. Choose by the constraints above, not by default. + +The narrow non-CMS use for `type: "array"` / `type: "object"` remains: authoring-time-only configuration where a marketer should type values in by hand (e.g. a "tags" prop not stored in CMS). + +**Before you register a whole-array/object component (a `Card Grid` / `Article Grid` that `.map()`s internally), confirm a matching multi-valued field exists on the CT it will bind against.** A single array prop collapses the entire list to **one binding point**. Studio never exposes the per-item fields, so binding correctness rests entirely on that one array field resolving. If the target CT has **no** multi-Reference / Modular Block / `multiple:true` Group / scalar-multi to feed it (e.g. an `Article List` page CT that holds only `Title`/`URL`/`SEO` and references its articles nowhere), the prop has nothing to bind to and the component renders its `defaultValue` array forever, placeholder cards that read as real content in Design Mode. Two correct moves: +- **The source lives in a different CT** (articles, products): add a multi-Reference on the page CT ([`migrate-ct-schema`](migrate-ct-schema.md)) or pin a query ([`pin-query-to-freeform`](pin-query-to-freeform.md)) **before** binding, or +- **Prefer atomic + Repeater**: register the leaf card's fields as atomic components and iterate with a Repeater + child Section ([`build-repeating-section`](build-repeating-section.md)), so per-item binding is visible and verifiable in Studio rather than hidden inside one array prop. + +`build-section` Step 5e enforces this at bind time. Catching it at registration saves a round trip. + +## Tolerant image signatures: accept `string | { url }` on every `imageurl` prop + +Every `imageurl`-typed prop must accept both a plain URL string AND a Contentstack asset object with a `.url` field. Contentstack stores assets as objects (`{ url, filename, uid, … }`). The picker sometimes binds the object, sometimes the `.url` sub-field, depending on the depth of the picked path. + +If the component's TypeScript signature demands one shape, the other silently fails: icons "map correctly" but render invisible, or the object gets `.toString()`'d and the URL area shows `[object Object]`. Both bugs ship to production without a warning. + +Contract for every `imageurl` prop: + +```tsx +type ImageProp = string | { url?: string } | null | undefined; + +function coerce(img: ImageProp): string | undefined { + if (!img) return undefined; + return typeof img === "string" ? img : img.url; +} +``` + +Every registered component's `imageurl` prop declares its TS type as `string | { url?: string } | null | undefined`, and the component internally calls a coerce helper (or inlines `typeof img === "string" ? img : img?.url`. Never `img?.url ?? img`: an asset object with no `url` falls through to the object itself and renders `[object Object]`). This is a first-class contract, peer of the null-safety and `$`-twin contracts, not a pitfall row. + +Rationale: Studio's picker doesn't ship type-aware binding coercion today (Part 1 #2 in the product improvements backlog). Until it does, the component compensates. + +## Tolerant link signatures: bind the leaves, not the whole link object + +Contentstack's `link` field is a two-property object: `{ title: string; href: string }`. Studio's picker binds each leaf separately. A component with an `href`-typed prop expects a URL string, not the whole link object. Binding the object silently ships `[object Object]` into the DOM. + +Contract for every navigable component: + +- **`href`-typed prop**: TS type: `string | null | undefined`. Never accept `{ href: string }`. Bind against `linkField → href`. +- **Label**: separate `string` prop bound against `linkField → title`. +- **A single object prop for the whole link is an anti-pattern.** If the design conceptually has one "link" object, still register the leaves as separate props (`ctaLabel: string`, `ctaHref: href`) and let the picker bind each. Two clicks in the picker, zero mystery renders. + +If you can't split (a shared design-system component takes `link: {title, href}`), coerce inside: + +```tsx +type LinkProp = string | { title?: string; href?: string } | null | undefined; + +function href(link: LinkProp): string | undefined { + if (!link) return undefined; + return typeof link === "string" ? link : link.href; +} +function label(link: LinkProp, fallback: string): string { + if (!link || typeof link === "string") return fallback; + return link.title ?? fallback; +} +``` + +Same shape as the image tolerance contract above. + +## `{{entry.title}}` and other unresolved placeholders: what a Connected template URL pattern resolves depends on `url_metadata.url_source` + +Which placeholders a Template URL Pattern resolves depends on the composition's `url_metadata.url_source`: + +- **`content_type_url_pattern` or `legacy_url`**: Studio matches the request path against the CT's `url` field only. **`{{entry.url}}`** is the one placeholder that resolves. +- **Pattern sources (`user_specified_pattern`, `default_url_pattern`)**: the SDK matches the request path against the pattern and queries on each variable: entry fields (`{{entry.slug}}`), a field of an included reference, `{{taxonomy:<uid>}}`, and `{{entry_created_date:year}}`-style dates. + +A variable the SDK doesn't recognise (`{{author.name}}`) is never processed, and a placeholder anywhere other than the URL pattern is left **literal in the response**. The SDK does not template-expand it. + +Where this bites: + +- Composition **canonical URL** field with `{{entry.title}}` renders literally in the `<link rel="canonical">` tag. Google indexes the literal string. SEO ranking dies silently. +- Template preview iframe URL with a non-`{{entry.url}}` placeholder never resolves. Iframe stays on the placeholder URL. +- OG image / social preview URLs with placeholders produce broken share cards. + +``` +URL Pattern: /blog/{{entry.url}} ✓ resolves to entry.url (any url_source) +URL Pattern: {{entry.url}} ✓ site root only (entry url "/"). Every other template needs a prefix +URL Pattern: /blog/{{entry.slug}} ✓ only with a pattern source (user_specified_pattern / default_url_pattern); ✗ with content_type_url_pattern / legacy_url +URL Pattern: /author/{{author.name}} ✗ not a recognised variable, never resolves +``` + +If you need a title-based slug, populate the CT's `url` field with the slug at entry time (Contentstack workflows / hook / manual), do not template-expand at render time. diff --git a/skills/studio/register-component-render-contracts.md b/skills/studio/register-component-render-contracts.md new file mode 100644 index 0000000..f7316e3 --- /dev/null +++ b/skills/studio/register-component-render-contracts.md @@ -0,0 +1,60 @@ +--- +name: register-component-render-contracts +--- + +## When to use + +Two rules a registered component must satisfy to survive the canvas: stay layout-agnostic, and render null-safe when a binding resolves to nothing. + +Part of the component-registration set. Start at [`register-component`](register-component.md), which routes here. + +## Layout contract: registered components must be layout-agnostic + +A registered component is dropped by template authors into Sections, Section Slots, Repeaters, and other contexts the component author never anticipated. The component must render correctly across those contexts without depending on a specific ancestor. + +The rule, from standard CSS architecture (BEM, Every Layout, Atomic Design, separation of concerns between layout and content): + +- **The component renders at `width: 100%` of whatever container it's placed in.** It fills its cell. It doesn't decide how big its cell should be. +- **The component does NOT hard-code a `max-width` to "protect itself"** from being placed in a too-wide container. Hard-coded sizes spread layout decisions into content components and break reuse (a card capped at 300px looks fine in a 4-up grid, ridiculous in a single-product hero, crammed in a 6-up grid). +- **Container queries** (CSS `@container`) are the right tool for size-dependent internal layout inside the component. They let the component adapt to its container without knowing the ancestor. +- **Layout responsibility lives in the parent Section**, not in the component. The Section that owns the Slot / Repeater provides the layout container (grid tracks, flex with `gap` + `flex-basis`, or a `max-width`-constrained Box). The component fills the cell that container defines. + +If you find yourself adding `.fs-grid > .fs-card { ... }` overrides to make a card behave inside a grid, the coupling is backwards. The card knows about the grid. Decouple: card stays `width: 100%`, grid (in the parent Section) provides the track. + +## Null-safe rendering contract + +Bindings resolve at runtime, and may resolve to `undefined`, `null`, an empty string, an empty array, or a placeholder value. Every registered component MUST render without throwing under those inputs. + +The SDK's resolution chain: `boundValue ?? staticValue ?? defaultValue ?? placeholder`. If you set `defaultValue` on every prop, MOST cases resolve to a real value, but four paths still surface `undefined`/`null`/empty to your component: + +1. **No `defaultValue` and no binding**: picker emits an unbound prop, no static value, no default, so the resolved value is `undefined`. +2. **Binding to a deep optional path**: e.g. `featured_image.0.url` when `featured_image` is an empty array (multi-file field with no upload yet), which resolves to `undefined`. +3. **Binding to an optional reference**: entry exists but the reference field is empty, which resolves to `undefined`. +4. **Empty-string field**: Contentstack stores `""` for cleared text fields. Truthy checks (`if (props.title)`) treat it as missing but JSX renders nothing, safe but easy to confuse with a render bug. + +The component contract: + +```tsx +// ❌ Throws when featured_image.0 is undefined +export function Card({ image }) { + return <img src={image.url} alt={image.alt} />; +} + +// ✅ Optional-chains and short-circuits cleanly +export function Card({ image, title }) { + if (!image?.url) return null; // render nothing when essential prop missing + return ( + <article> + <img src={image.url} alt={image.alt ?? ""} /> + {title && <h3>{title}</h3>} + </article> + ); +} +``` + +Rules: + +- **Optional-chain every nested access**: `props.image?.url`, `props.cta?.[0]?.href`. +- **Nullish-coalesce non-binding renders**: `alt={image.alt ?? ""}`, `count={items?.length ?? 0}`. +- **Decide what "missing" means**: render `null`, render a skeleton, or render a labeled empty state. **Never crash.** Compositions get authored against half-filled entries during preview. One throw kills the whole canvas. +- **Don't rely on `defaultValue` alone.** It's a safety net for unbound props, not for empty entry data. diff --git a/skills/studio/register-component.md b/skills/studio/register-component.md new file mode 100644 index 0000000..b3e051d --- /dev/null +++ b/skills/studio/register-component.md @@ -0,0 +1,279 @@ +--- +name: register-component +--- + +## When to use + +Read a React component, infer a prop schema, and write a `registerComponent` call so Studio's palette uses the customer's component instead of built-in defaults. Lazy by default. + +Use when the user wants Studio's palette to surface their components: "make my Hero available in Studio", "Studio shows defaults instead of mine", "register components in src/components". The BYOC moment. Do NOT use to register design tokens (use `import-design-tokens`). Do NOT invent prop schemas. Only register props visible in source. + +> **Auth preflight: settle the credential before the first API call.** Resolve it OAuth-first per [`authenticate-cma`](authenticate-cma.md): `CS_OAUTH_ACCESS_TOKEN`, else the Contentstack MCP's stored session: Read that skill for where it is, even with no MCP connected. **Never ask the user for a session `authtoken`.** If nothing resolves, or a refresh fails with `400 invalid_refresh_token`, hand them `! CONTENTSTACK_REGION=<code> npx @contentstack/mcp --auth` (it needs a TTY and a browser, so it cannot be run for them) and wait. `403 error_code 316` is a valid credential aimed at another org: fix the org or the `api_key`, do **not** re-authenticate. + +> **Reuse preflight: mandatory when the project already has compositions.** Before naming a single new atomic, component or Section, run [`match-existing-pattern`](match-existing-pattern.md) § Step 1b: it groups existing Sections by the schema they bind and ranks the component palette by real usage. Decomposition is where reuse has to bite. Once this step names `Heading` instead of the project's `alpha-atom-heading`, the duplicate survives into the plan and into the build. Mark every row reuse, extend, or new, and never new without having looked. + +# Register a component with Studio + +> **Atomic check before you register anything.** Registration is where a monolith becomes permanent. If the component renders **more than one heading / image / body / list**, stop and run [`decompose-jsx-to-atomics`](decompose-jsx-to-atomics.md) first. Register its atomics and containers, not the whole thing. Registering a page-level component is how a project ends up with one Section, nothing reusable, and a coarse prop that renders blank. Exception: it is genuinely interactive (form, carousel, modal) and cannot be expressed with built-ins, then register it whole and expose an `action` prop so authors can still wire behavior. +> +> **And check reuse first:** header, footer, nav and menus are shared across every page. Register the atomics once and bind their lists through a Repeater, rather than registering a per-page variant. + +## Before you start: walk Q2, Q3, Q4 of the framework + +This skill runs the last three questions of the [four-question decision framework](../../docs/00-getting-started/composable-primitives.md#four-questions-to-answer-when-building-any-component). Q1 (classify: atom, layout, or compound?) is answered before you land here. If the input is a compound, `decompose-jsx-to-atomics` or `decompose-design` decomposes it into atoms + layouts before registration. If the input is already a valid atom or layout, proceed. + +- **Q2: Does the registry already have it?** Before writing a `registerComponent` call, check whether an existing primitive covers the shape. If yes, reuse (don't re-register). If not, proceed to Q3. +- **Q3: Which props?** For each proposed prop: does it vary per-instance AND represent functional/content intent (not visual polish)? If either is no, refuse the prop. Let a DS token decide. +- **Q4: Which are Exposed?** For each schema prop: is it author-understandable AND DS-portable? If either is no, keep static per-Section. Never expose atom props. + +If any answer is ambiguous, halt and ask the user to disambiguate. Never silently register a prop that fails Q3 or expose a prop that fails Q4. + +## Before you start: pick the layer + +Every component you register is one of two shapes. Decide which before writing the registration: + +- **Layer 1: Atomic** (renders one CMS field). All scalar props. `<Heading>`, `<Text>`, `<Image>`, `<Button>`. This skill's default path. +- **Layer 2: Container / Skeleton / Layout** (holds other components). Has at least one `slot`-typed prop as a drop zone. `<Card>` with a body slot, `<Split>` with left+right slots. Same skill. § "Exposing extensible regions with slot props" covers it. + +If the thing you want to register is neither: a pure layout primitive (Grid, Stack, Container, Box), a whole page-shaped composition, or a component with an array-of-objects prop. **Stop.** Read [From designs to Sections: the three-layer mental model](../../docs/00-overview/from-designs-to-sections.md) first. It's a 10-minute page that saves hours of "why doesn't my Grid work in Studio." + +## Context + +Studio composes pages from React components. By default it uses its **own** built-ins (Hero, Card, Button) so authors can play immediately, but for production, every component should come from the customer's library. Registering tells Studio's palette "use THIS component for type 'site-hero'" so authors drop branded components, not defaults. + +The registration is a `registerComponent` call that names the component, gives it a unique `type`, and declares the **prop schema** Studio's right-panel will offer for binding. Prop types are strict: only the 14 in step 3. There is no `text`, `link`, `color`, or `image` (false friends from other systems). + +Reference: `docs/20-bring-your-own-components/register-components.md`, `docs/20-bring-your-own-components/component-schema-prop-types.md`. + +## Task + +1. **Locate the registration entry point.** Look for `src/register-components.tsx`, `src/register-studio.ts`, or `src/lib/contentstack.ts` (whichever file imports `registerComponent` from `@contentstack/studio-react`). If none exists, create `src/register-components.tsx` and ensure it's imported once at app startup (before any `<StudioCanvas />` or `<StudioComponent />` mounts). + +2. **Read the component at `componentPath`.** Extract the prop names and types. Source of truth ranked by reliability: + - Explicit TypeScript interface (`interface HeroProps { … }`), most reliable + - `propTypes` block, second-best + - Destructured args + JSDoc comments, fallback only + +3. **Map each prop to a Studio prop type.** Use the **exact** strings below (14). Anything else is rejected: + + | Source shape | Studio prop type | + |---|---| + | `string` / `string \| undefined` | `string` | + | plain-text-only `string` | `plaintext` (Single / Multi Line only) | + | `number` | `number` | + | `boolean` | `boolean` | + | `string` constrained to known values (union literal, enum) | `choice` (set `options:` to the literal values. `defaultValue` is an **array** (`["centered"]`), single- or multi-select. See `wire-component-default-data`) | + | URL string used as `href` | `href` | + | URL string used as image `src` | `imageurl` | + | ISO date string | `datestring` | + | array | `array`, binds to a **multi-value field** (Reference multi, Group multi, Modular Block, scalar multi). Reference sources need `data_sources.resolvedReferences`. Groups / Modular Blocks / scalar multi-values live on the entry and need no resolution. See § Array & object props: binding rules | + | object | `object`, binds when the shape matches an entry Group / Global Field. See § Array & object props: binding rules | + | React `children` / placeholder for extensible region | `slot`, fillable at template time via `use-section-slot` § component-slot-prop placement. The prop turns that region of the component into an author-controlled drop target. No `defaultValue` (slots start empty). | + | rich text (JSON RTE field, HTML payload) | **`json_rte`**, not `string`. RTE renders markup, and `string` would show it as literal `<p>` text. | + | unknown / can't infer, and the value is a **primitive** | `any`, **never for object- or array-shaped data.** See the flatten trap below | + | A callback an **author** should be able to point at your app's logic (`onClick`, `onSelect`, `onSubmit`) | **`action`**: the author picks a registered studio function. Your component just calls the prop. Needs `registerStudioFunctions`. See [`wire-studio-state`](wire-studio-state.md). **Omit this and interactivity stays hardcoded**, so the component is a black box authors can't wire | + + ### The `any` flatten trap: why structured data renders blank + + `any` is not a harmless catch-all. Verified in `studio-registry/src/data-binder/retrieve-data.ts`, the binder runs a **recursive single-key unwrap** on every prop type except `object` and `array`: + + ```js + // Unwrap single-key objects (common in Contentstack Modular Blocks) + // We only do this if we are not explicitly expecting an object or array. + if (type !== "object" && type !== "array" && !handledRepeaterContext) { + resolvedData = getFlattenedData(resolvedData); + ``` + + and `getFlattenedData` descends **as long as the object has exactly one key**, ignoring `$` and `_metadata`: + + ```js + const keys = Object.keys(data).filter((k) => k !== "$" && k !== "_metadata"); + if (keys.length === 1) return getFlattenedData(data[keys[0]]); // recurses + ``` + + **So a single-field block bound to an `any` prop is silently replaced by its inner value.** A block like `image_grid → { image: [...] }` or `tabs → { tabs: [...] }` has one real key, so the object you expected never arrives. The component receives the bare inner array. Read it as an object and every lookup is `undefined`. The array's indices surface as `{0,1,2,3}`. **Blank render, no error, nothing in the console.** The `_metadata` exclusion makes it worse: a block carrying one field plus `_metadata` still counts as single-key and still unwraps. + + **The fix is the prop type, not the data.** `object` and `array` are the only two types that skip the unwrap: + + | The bound field is | Use | Why | + |---|---|---| + | A Group / Global Field / whole block object | **`object`** | Skips the unwrap. The shape arrives intact | + | A list: Modular Blocks, multi-Reference, multi-Group, scalar list | **`array`** | Skips the unwrap. Render with `.map()` | + | A genuine primitive of unknown type | `any` | Flattening a primitive is a no-op, so it's safe here only | + + Match the type to the **real** shape. "The shape varies per block" is not a reason to reach for `any`. It's a reason to use `object`. + + This is a **resolver-level** behavior (`retrievePropValue`), so it bites UI / picker-authored bindings exactly as it bites API-authored ones, not an API-only footgun. It is also **not specific to Modular Blocks**: any single-key object collapses: a Group with one sub-field, a single-item wrapper, a block carrying one field plus `_metadata`. Rule of thumb: whenever the value you want is an object or a list, type the prop `object` / `array`. `any` is safe only for a genuine primitive. + + **These 14 strings govern YOUR registered components only.** Studio's built-in nodes (`header`, `plain-text`, `rich-text`, `image`, …) carry their own prop-type vocabulary. Reading an existing composition and copying a built-in's prop type into your registration is a silent rejection. The two vocabularies overlap in places and diverge in others. Never transplant between them. Built-in node types are cataloged in [`author-composition-via-api`](author-composition-via-api.md) § Built-in node `type` values. + +4. **Set `defaultValue` for every prop** where the component has a default value in its source. The default appears as the palette tile preview. Without it, the tile renders blank and authors don't know what the component looks like. Use `defaultValue:` exactly, NOT `default:` (the latter is silently ignored). + +5. **Ship a `thumbnailUrl`: every registration, no exceptions.** (This and the `studioAttributes` contract below are two of the six in [`complete-the-build`](complete-the-build.md). Both ship in the same commit as the registration, never as a follow-up.) `thumbnailUrl` defaults to `""` in the registry (`studio-registry`, `registry-options-processor`), and Studio then renders a **text placeholder** instead of a preview. A palette of twenty text placeholders is unusable for the marketers this whole exercise exists to serve, and nothing errors, so it ships unnoticed. + + Rules that matter (full guidance + the `thumb()` helper and tier glyph library: [`palette-conventions`](../../docs/20-bring-your-own-components/component-palette-conventions.md)): + + - **Inline SVG data URI**, built with `encodeURIComponent`. **Never a CDN/asset URL**. The palette renders inside Studio's iframe, where external requests hit CORS or fail silently, leaving you back at a blank tile. + - **Brand tokens for colors**, not invented hex. + - **Preview the shape, not the pixels**: the thumbnail says what kind of thing this is (atomic / slot-shell / pattern). + + ```ts + registerComponent({ + type: "site-hero", + displayName: "Hero", + component: () => import("./components/Hero"), + sections: ["Acme · Patterns"], + thumbnailUrl: thumb(HeroGlyph, "Hero Block"), // data:image/svg+xml,... + props: { /* … */ }, + }); + ``` + + **One argument, not two.** `registerComponent(Hero, { name, … })` — component first, config second — **throws `Component type is required.`** Verified against the published `@contentstack/studio-registry@1.9.0`: `registerComponent` is typed as *one element* of the array `registerComponents` takes, so a component passed first is read as the config and `type` comes back undefined. Pass one config object; **`type` and `component` are the only required fields**. `registerComponents([{ … }], options?)` takes an array of the same shape. + + **Retrofitting an existing registration file?** Every `registerComponent` call without `thumbnailUrl` is a blank tile today. Sweep them all: `grep -c "registerComponent" <file>` vs `grep -c "thumbnailUrl" <file>`. The counts must match. + +5b. **Next.js App Router: registration must run in the CLIENT realm.** A registered component that is (or renders) a `"use client"` component cannot be registered from a Server Component module graph. On the server the SDK sees a client reference, and Next throws at render time ("Attempted to call the default export of \<module\> from the server but it's on the client") or the palette silently comes up empty because the registry that got populated was the server-side one. + + The rule: **`registerComponents()` runs from the same client boundary that runs `studioSdk.init`.** `install-studio` already creates one for init (`app/studio-init.tsx`, `"use client"`, side-effect import of `@/lib/contentstack`). Put the registration import there too, so both land in the client bundle in the right order: + + ```tsx + // app/studio-init.tsx + "use client"; + import "@/lib/contentstack"; // studioSdk.init + import "@/lib/studio-components"; // registerComponent calls — SAME client boundary + export function StudioInit() { return null; } + ``` + + Do **not** import the registration module from `app/layout.tsx`, a `page.tsx` Server Component, or any non-`"use client"` module. Pages Router / Vite / Remix don't have this split. A module-scope import in `_app.tsx` or `main.tsx` is fine. Related: [`install-studio`](install-studio.md) § App Router init boundary, and [`troubleshoot-ssr-rendering`](troubleshoot-ssr-rendering.md) for the registry-singleton symptoms. + +6. **Write the registration: prefer LAZY by default.** Append (or update) the registration file. The recommended shape is **lazy**: the component is downloaded only when it first renders, keeping the initial bundle small and code-splitting each registration automatically. The SDK wraps the dynamic `import()` with `React.lazy` + `Suspense` for you. + + ```ts + import { registerComponent } from "@contentstack/studio-react"; + + registerComponent({ + type: "site-hero", // componentType — unique UID + displayName: "Hero", // componentName — palette label + component: () => // ← LAZY: arity-0 thunk that returns a dynamic import + import("./components/Hero").then(m => ({ default: m.Hero })), + props: { + headline: { type: "string", displayName: "Headline", defaultValue: "Welcome" }, + description: { type: "string", displayName: "Description", defaultValue: "Lorem ipsum" }, + imageUrl: { type: "imageurl", displayName: "Image", defaultValue: "https://…" }, + ctaHref: { type: "href", displayName: "CTA Link", defaultValue: "/get-started" }, + layout: { type: "choice", displayName: "Layout", defaultValue: ["centered"], options: ["centered", "split"] }, + }, + }); + ``` + + If the component is the **default** export: `component: () => import("./components/Hero")` is enough. An explicit form `registerLazyComponent(config, loader)` exists with identical runtime behavior. + + **5a. Exposing extensible regions with `slot` props.** To let template authors drop content into a region of the component (a card body, a sidebar, a CTA area), declare a `slot`-typed prop. The component renders the prop wherever the extensible region should appear. Studio surfaces it as a drop target for any component or Section. + + ```ts + import { registerComponent } from "@contentstack/studio-react"; + + registerComponent({ + type: "site-card", + displayName: "Card", + component: () => import("./components/Card"), + props: { + title: { type: "string", displayName: "Title", defaultValue: "Card title" }, + body: { type: "slot", displayName: "Body" }, // ← extensible region, no defaultValue + }, + }); + ``` + + The React component receives the slot as a prop (or via `children`, depending on what its TypeScript interface declares) and renders it inside its tree: + + ```tsx + function Card({ title, body }: { title: string; body: React.ReactNode }) { + return <div className="card"><h3>{title}</h3>{body}</div>; + } + ``` + + At template-authoring time, the slot region shows as a dashed drop target. Authors drop a component or Section into it (see `use-section-slot` § component-slot-prop placement for the canonical filling pattern). Slots take ANY component or Section. There is no type constraint. Slot fills are stored per-template, so the same Card can carry different filled content on different templates. + + For a richer pattern (slot count driven by another prop, slot template factories), see `docs/20-bring-your-own-components/component-schema-prop-types.md` § slot. + + **5b. The two valid `component:` shapes**, decided by arity: + + | Shape | `component:` value | When | + |---|---|---| + | **Lazy (default)** | arity-0 function returning a `Promise` (dynamic `import()`) | Every BYOC registration unless tiny+hot. | + | **Eager** | function with ≥1 parameter (ordinary React component) | Tiny components on hot paths. | + + ```ts + component: () => import("./components/Hero") // ✅ LAZY + component: Hero // ✅ EAGER — function Hero(props) {…} + component: (props) => createElement(Hero, props) // ✅ EAGER — wrap arity-0 + ``` + + **Arity-0 trap.** A parameter-less function that does NOT return a Promise (e.g. `function Header() {…}`) is treated as a lazy loader, called outside render, and throws React #321 "Invalid hook call". Rule: **return a `Promise` (lazy), OR give it a `props` parameter (eager).** + +7. **Confirm Studio palette shows the new tile.** Open the canvas, switch the palette accordion to **Registered Components**, find `Hero`. **The tile must show the thumbnail image**. A text placeholder means `thumbnailUrl` is missing or the data URI is malformed. A broken-image icon means you used an external URL and the iframe blocked it. The tile preview itself renders from the `defaultValue`s. If that area is blank, a prop is missing a default or the prop type was wrong. + +## Inputs needed from the user + +1. `componentPath`: file path to read. +2. `componentName`: display label. +3. `componentType`: unique kebab-case UID (reject duplicates. Check the registration file before writing). + +Do NOT invent component paths. If the user just says "register my Hero" without a path, ask which file. + +## The registration contracts: read the one you need + +Registration has four contracts a component must honour. They are reference material, +split out so a task loads only what it touches. + +| Read this | For | +|---|---| +| [`register-component-prop-contracts`](register-component-prop-contracts.md) | Array and object binding rules, tolerant image and link signatures, `{{entry.*}}` resolution | +| [`register-component-render-contracts`](register-component-render-contracts.md) | Layout-agnostic rule, null-safe rendering | +| [`register-component-cslp`](register-component-cslp.md) | `$` CSLP props and the `studioAttributes` contract. **Both or neither.** | +| [`register-component-pitfalls`](register-component-pitfalls.md) | What fails silently, and why. **Read before reporting done.** | + +## Acceptance + +This skill succeeds only when ALL of the following are true. + +- [ ] The registration file contains a `registerComponent` call for the supplied `componentType` that did not exist before this skill ran. +- [ ] **The component spreads `studioAttributes` on its root element** (or the registration sets `wrap`), verified in the DOM by a `data-cslp` on the root. Without it the node has no CSLP tag and Visual Editor cannot select it. +- [ ] **Every bindable prop that renders visible content spreads its `$`-twin** on the element rendering it, verified by a `data-cslp` on each of those elements. +- [ ] **An empty bound value still renders its element** when its `$`-twin is present (not `null`), so a new entry's field can be clicked, and decorative overlays over images have `pointer-events: none` ([`make-visual-editor-editable`](make-visual-editor-editable.md) § 4–5). +- [ ] Every prop on the source component's TypeScript interface (or propTypes) appears in the `props:` object, no prop dropped silently. +- [ ] Every `type:` value is one of the 13 allowed strings (`string`, `boolean`, `number`, `choice`, `href`, `imageurl`, `datestring`, `array`, `object`, `slot`, `json_rte`, `any`, `action`), not `text`, `link`, `color`, or `image`. +- [ ] **Any callback an author should control is an `action` prop**, not a hardcoded handler. Otherwise the component's behavior is fixed at build time. See [`wire-studio-state`](wire-studio-state.md). +- [ ] A `choice` prop's `defaultValue` is an array (`["centered"]`). Multi-select requires it, and single-select accepts it on every SDK version (newer versions also convert a bare value). +- [ ] `defaultValue:` (not `default:`) is set on every prop where the source component has a default. **Exception: `slot` props never carry a `defaultValue`**, they start empty until template authors fill them. +- [ ] Choice props include an `options:` array of the allowed literal values. +- [ ] The registration file is imported once at app startup, verified by grep for the import in `main.tsx` / `_app.tsx` / `App.tsx`. +- [ ] Studio's **Registered Components** palette accordion shows the new tile with the supplied `displayName`. +- [ ] The tile preview renders (not blank) because the defaults are populated. +- [ ] `component:` follows one of the two valid shapes: **lazy** (arity-0 function that returns a `Promise` of the component, typically `() => import("./Foo")`) OR **eager** (function with at least one parameter, i.e. an ordinary React component). The default recommendation is lazy. A zero-parameter function that does NOT return a Promise triggers React #321 / "Invalid hook call" at render. +- [ ] The component is **layout-agnostic**, `width: 100%` of its container, no hard-coded `max-width` to "protect" against wrong contexts, no styles that rely on a specific ancestor selector (`.fs-grid > .fs-card { ... }`). Layout sizing is delegated to the parent Section, not baked into the component. +- [ ] **Call-site literal sweep**. If this component is already used in production code, grep every JSX use of it. Every literal prop set at a call site (`isInteractive={false}`, `variant="compact"`, `columns={3}`, `isLogoBgWhite={false}`) becomes a `defaultValue` on the corresponding registered prop, or a named preset. Skipping this is the single most common cause of "composed page looks 80% right but wrong on hover/motion/spacing." Sample grep: `rg -tn tsx -o "<ComponentName[^>]*/>" | head`. If the component is new (no production call sites yet), acceptance is trivially satisfied, but state that explicitly. +- [ ] **Tolerant image signatures**: every `imageurl` prop's TS type accepts `string | { url?: string } | null | undefined`, and the component internally coerces. See § Tolerant image signatures above. This is a first-class contract, not an optional nicety. Studio's picker binds sometimes the object, sometimes `.url`, and either shape must render correctly. +- [ ] **Tolerant link signatures**: every `href`-typed prop binds against a link field's `href` leaf (never the whole link object). Where the design mandates a single link prop, the component coerces `string | {title, href}` inside via `href()` / `label()` helpers. See § Tolerant link signatures above. +- [ ] **Palette group + thumbnail**: the registration declares `sections: ["<Brand> · Elements | Patterns | Layouts"]` (never `"Template"` or `"Section"` in the name) and a `thumbnailUrl` data URI that visually previews the tier. See [`palette-conventions`](../../docs/20-bring-your-own-components/component-palette-conventions.md). + +## After registering: plan your Section shape + +Register the component first. Then decide which Section(s) to author in Studio for it. + +- **Compound component that iterates internally** (say `<BlogArticle>` doing `.map(sections)`, `<CardList>` doing `.map(related_posts)`): **build top-down**. Author the wrapping Simple Section first, one Section that wraps the whole compound. Studio renders your existing page inside its canvas in ~5 minutes with no code changes. Then decompose one Section per iteration level as you need each to become author-editable. +- **Atomic component that renders one shape** (`<Card>` bound to card_ref, `<Hero>` bound to hero_group): author its Simple Section directly. Compose it later inside a List Section when a parent iteration needs to drop it as a slot's default content. + +Both directions produce the same Section chain in the end. Top-down is faster for existing compound-heavy apps. Bottom-up has a cleaner mental model for greenfield. + +Use [`build-section`](build-section.md) (or [`build-repeating-section`](build-repeating-section.md) for List Sections) to author each Section. The skill walks the Studio-UI flow. Full worked example with a 4-level nested schema: [From components to Studio compositions](../../docs/00-overview/standard-studio-page-anatomy.md). + +## See also + +- `docs/20-bring-your-own-components/register-components.md`: full reference, including `registerComponents` (batch) and `registerLazyComponent` (code-split) +- `docs/20-bring-your-own-components/component-schema-prop-types.md`: every prop type with all options +- `docs/20-bring-your-own-components/set-component-default-data.md`: separate `wire-component-default-data` skill for advanced default data +- `wire-component-default-data`: for components whose defaults aren't representable as static `defaultValue:` literals +- `use-section-slot`: fill a `slot`-typed prop on a registered component (the `component-slot-prop` placement mode), or carve a Section Slot inside one +- `wire-slot-data`: carry data the component holds into whatever an author drops into its `slot` prop +- `import-design-tokens`: register your design system after registering components diff --git a/skills/studio/register-json-rte.md b/skills/studio/register-json-rte.md new file mode 100644 index 0000000..28827f9 --- /dev/null +++ b/skills/studio/register-json-rte.md @@ -0,0 +1,273 @@ +--- +name: register-json-rte +--- + +## When to use + +Register the unified RTE renderer via `registerRTERenderer` so Studio can render rich text (**JSON RTE, HTML RTE and Markdown alike**) plus embedded entries, embedded assets and custom element types. + +Use for **any** rich-text field bound to a component prop, whatever its storage format. Phrases: "JSON RTE", "HTML RTE", "rich text field", "markdown field", "RichText component", "rich text renders as raw HTML", "RTE shows `<p>` tags as literal text", "rich text renders blank", "embed a card inside rich text", "embedded entries/assets in RTE", "custom element type in RTE", "registerRTERenderer", "registerJSONRTE is deprecated". Also when the JSON tree contains element types the default serializer doesn't know, or you want to override the default HTML for a standard tag. Register once at app bootstrap. + +> **Auth preflight: settle the credential before the first API call.** Resolve it OAuth-first per [`authenticate-cma`](authenticate-cma.md): `CS_OAUTH_ACCESS_TOKEN`, else the Contentstack MCP's stored session: Read that skill for where it is, even with no MCP connected. **Never ask the user for a session `authtoken`.** If nothing resolves, or a refresh fails with `400 invalid_refresh_token`, hand them `! CONTENTSTACK_REGION=<code> npx @contentstack/mcp --auth` (it needs a TTY and a browser, so it cannot be run for them), and wait. `403 error_code 316` is a valid credential aimed at another org: fix the org or the `api_key`, do **not** re-authenticate. + +# Register an RTE renderer + +> **The name says `json-rte` for historical reasons. The API is not JSON-RTE-only.** `registerRTERenderer` is a single call covering **JSON RTE, HTML RTE and Markdown**. If you arrived here with an HTML RTE or a Markdown field, you are in the right place: everything below applies unchanged. + +## Context + +Contentstack stores rich text in more than one shape, and Studio renders all of them through one renderer: + +| Field | Stored as | Notes | +|---|---|---| +| **JSON RTE** | a structured JSON tree (nodes with `type` + `attrs`) | serialized to HTML for the prop | +| **HTML RTE** | an HTML string | embedded refs extracted from the markup | +| **Markdown** | a Markdown string | converted, then rendered | + +The built-in **`RichText`** component is typed `any` and **auto-detects** which of these it received, so a JSON tree, an HTML string, Markdown, or a plain string all render without the author choosing a variant. + +When Studio resolves a component prop bound to a rich-text field, it serializes the value to HTML and passes it to your component. + +The default serializer already handles paragraphs, headings, lists, links, marks, images, and tables. Use **`registerRTERenderer`** (the unified, canonical API) when any of these apply: + +- The JSON tree contains **custom element types** (anything outside the default tag map) +- The RTE field **embeds entries** that should render as components/cards (e.g. a blog body that embeds a Product card) +- The RTE field **embeds assets** that need custom markup (override the default `<img>` / `<video>` / `<a download>` fallback) +- You want to **override** the default rendering of a standard tag + +`registerRTERenderer` covers all four in a single call. + +Registration is a side-effect on the SDK singleton. It must happen **once, at app bootstrap, before any `<StudioComposition />` (or `<StudioComponent />`) mounts**. Late registration won't retroactively re-render props that have already resolved. + +**`registerJSONRTE` is deprecated.** It still works (preserves previously-registered `embeddedEntry`/`embeddedAsset`) but the SDK marks it `@deprecated`. New code uses `registerRTERenderer`. Migrate existing calls when you next touch them. `registerEmbeddedEntryRenderer` is also deprecated for the same reason. + +See: `docs/20-bring-your-own-components/json-rte-custom-element-rendering.md`. + +## Task + +1. **Import `registerRTERenderer`** from `@contentstack/studio-react` (re-exported from `@contentstack/studio-client`): + + ```ts + import { registerRTERenderer } from "@contentstack/studio-react"; + ``` + +2. **Call it at app bootstrap, once**, in the same module where you call `studioSdk.init(...)` (typically `src/lib/contentstack.ts`). Place it alongside any `registerComponent(...)` calls. + +3. **Pass an `RTEConfig`**: extends `IJsonToHtmlOptions` with `embeddedEntry` + `embeddedAsset`. Each call **REPLACES** the full config. Merge yourself if you call it more than once. + + ```ts + registerRTERenderer({ + // Render embedded entries as components/cards. The SDK auto-resolves + // the embedded entry and passes the full entry object — no extra fetch. + embeddedEntry: ({ entry, contentTypeUid, displayType }) => { + // displayType is one of "block", "link", or other editor-defined kinds + if (displayType === "block") { + return `<article class="card"> + <h3>${entry.title}</h3> + <p>${entry.summary ?? ""}</p> + </article>`; + } + if (displayType === "link") { + return `<a href="${entry.url ?? "#"}">${entry.title}</a>`; + } + return `<span>${entry.title}</span>`; + }, + + // Render embedded assets. Omit to use the built-in default + // (img for image/*, video/audio for those MIMEs, <a download> otherwise). + embeddedAsset: ({ asset }) => { + if (asset.content_type?.startsWith("image/")) { + return `<img src="${asset.url}" alt="${asset.title ?? ""}" loading="lazy" />`; + } + return `<a href="${asset.url}" download>${asset.filename}</a>`; + }, + + // Custom JSON RTE element types (handlers return HTML strings) + customElementTypes: { + callout: (attrs, child, jsonBlock) => { + const variant = jsonBlock?.attrs?.variant ?? "info"; + return `<aside class="callout callout--${variant}"${attrs}>${child}</aside>`; + }, + // Override default heading rendering: + h2: (attrs, child) => `<h2 class="prose-h2"${attrs}>${child}</h2>`, + }, + + // Other IJsonToHtmlOptions keys passed through: + allowNonStandardTypes: true, + }); + ``` + +4. **`embeddedEntry` args**, full signature: `{ entry, contentTypeUid, displayType, ...metadata }`. The `entry` is the **complete resolved entry object** (Studio fetches it). Read any field on it directly. `displayType` carries the editor's chosen display variant ("block", "link", or any custom string the editor exposes). Branch on it to render the same entry differently. + +5. **`embeddedAsset` args**: `{ asset, ...metadata }`. The `asset` is the complete asset object including `url`, `content_type`, `filename`, `title`, `dimension` etc. Both JSON RTE (`attrs.type: "asset"`) and HTML RTE (`<figure class="embedded-asset" data-sys-asset-uid="…">`) paths invoke this handler. + +6. **`customElementTypes` handler signature**: `(attrs: string, child: string, jsonBlock: IAnyObject, extraProps?: object) => string` + - `attrs`: pre-serialized HTML attribute string + - `child`: pre-serialized inner HTML of children + - `jsonBlock`: raw node, read `jsonBlock.attrs`, `jsonBlock.type`, custom metadata + - **Return an HTML string**, not a React element + +7. **Match keys to node `type` exactly.** Keys in `customElementTypes` must equal the `type` string on the JSON RTE node emitted by Contentstack, byte-for-byte. `callout` will not match `"Callout"` or `"custom-callout"`. Use a standard tag name (`h2`, `a`, `ul`) to override the default rendering for that tag. + +8. **Declare the prop in your component schema as `json_rte`.** Studio creates a `JsonRTEProp` and binds it to the field like any other prop: your component code stays JSON-RTE-agnostic. + +9. **Render it: check which path the installed SDK supports.** Two options, and the newer one removes most of this work: + + ```bash + grep -rq "useStudioRichText" node_modules/@contentstack/studio-react/dist \ + && echo "hook available" || echo "hook ABSENT — use the manual path" + ``` + + **`hook available` means one call.** `useStudioRichText` runs the same pipeline as the built-in `RichText`: it detects JSON RTE / HTML RTE / Markdown, resolves embedded entries and assets, and sanitizes before rendering. No `dangerouslySetInnerHTML`, no DOMPurify, no format branching. + + ```tsx + import { useStudioRichText } from "@contentstack/studio-react"; + + export function Article({ body, $body, entry }) { + return <div className="prose">{useStudioRichText(body, $body, entry)}</div>; + } + ``` + + Pass `entry` when you have it: embedded items are then read from `entry._embedded_items` instead of being refetched, free if the caller already used `.includeEmbeddedItems()`. + + **`hook ABSENT` means the manual path.** The prop hands over the raw value, so the component serializes and sanitizes it itself, and embedded entries/assets will NOT render: + + ```tsx + import { jsonToHtml } from "@contentstack/json-rte-serializer"; + import DOMPurify from "isomorphic-dompurify"; + + export function Article({ body }: { body: object }) { + const html = DOMPurify.sanitize(jsonToHtml(body)); + return <div className="prose" dangerouslySetInnerHTML={{ __html: html }} />; + } + ``` + + **Sanitize on this path.** RTE content is author-supplied. Rendering it unsanitized is an XSS vector. The hook does this for you. The manual path does not. + + On an SDK without the hook, `json_rte` also accepts **only** JSON RTE (an object). HTML RTE and Markdown are strings and cannot bind to it. Bind those to an `any` prop, or use the built-in `RichText`, until the SDK is upgraded. + +10. **(Optional) Inspect current config**: three internal readers used by hooks: + - `getJSONRTE()` returns `IJsonToHtmlOptions` (without `embeddedEntry`/`embeddedAsset`) + - `getEmbeddedEntryRenderer()` returns the registered entry renderer or `null` + - `getEmbeddedAssetRenderer()` returns the registered asset renderer or `null` (falls back to the SDK default if not registered) + + All three import from `@contentstack/studio-client` (not `@contentstack/studio-react`, which only re-exports the registration functions). + +## Inputs needed from the user + +1. `embeddedContentTypes`: list of CTs the RTE field can embed (so the `embeddedEntry` callback can branch on `contentTypeUid` per CT). +2. `customElementTypes`: list of custom node `type` values you need to render. Pull these from the actual entry JSON, not from guesses. +3. `overrideTags`: optional list of standard tags whose default HTML you want to replace. + +If none of the three is provided, ask the user whether they actually need this skill: the default serializer + default asset renderer may already cover their JSON RTE. + +## Acceptance + +This skill succeeds only when ALL of the following are true: + +- [ ] `registerRTERenderer({...})` is called once, in the bootstrap module, before any `<StudioComposition />` or `<StudioComponent />` mounts. +- [ ] If embedded entries are in scope: the `embeddedEntry` handler branches on `contentTypeUid` and/or `displayType` and returns an HTML string per case. +- [ ] If embedded assets need custom markup: the `embeddedAsset` handler returns an HTML string. Otherwise the default is acceptable. +- [ ] Every declared custom element type has a handler returning an HTML string (not React). +- [ ] Handler keys match the raw `type` values in the entry JSON exactly. +- [ ] Components consuming the prop render it via `useStudioRichText` where the installed SDK exports it, or via sanitized `dangerouslySetInnerHTML` where it does not (no escaped HTML visible on the page). +- [ ] `getJSONRTE()` returns the serializer options. `getEmbeddedEntryRenderer()` returns the entry renderer. +- [ ] Rendering a composition bound to the `json_rte` field shows custom node markup AND embedded-entry markup AND custom-asset markup in the DOM. Standard nodes still render via their defaults. + +## Which component for which field {#rte-field-to-component} + +Rich text is never a `Heading`/`Paragraph` job. Map the CT field to the right component, then register what that component needs: + +| Contentstack field | Component | Prop type | Must register | +|---|---|---|---| +| **JSON RTE** | `RichText` | `json_rte` | `registerRTERenderer`: always, if it embeds entries/assets or custom nodes | +| **HTML RTE** (multiline + rich text editor) | `RichText` | `any` | same call: embedded refs are extracted from the markup | +| **Markdown** (multiline + markdown) | `RichText` | `any` | same call | +| Rich text that must **collapse / "read more"** | `CollapsibleText` | same as above | same call: it accepts the full RTE matrix | +| Single-line / multiline **plain** text | built-in `Text` / `Header` | `plaintext` | nothing | + +**The line between them:** if an author can apply any formatting (bold, a link, a list, an embed) it is rich text and needs `RichText`. Only genuinely unformatted strings belong on `Text`/`Header`, which render `String(text)` and show markup as literal characters. + +`CollapsibleText` is not a different content model: it is `RichText` plus expand/collapse. Choose it whenever the design truncates long copy behind "read more", an accordion, or a "show details" toggle. + +## Embedded entries and embedded assets: the reason this API exists + +These two are what `registerRTERenderer` is for. A rich-text field that only holds paragraphs needs no registration at all. The moment an author embeds an entry or an asset, unregistered rendering degrades badly: + +| | Without registration | With a handler | +|---|---|---| +| **Embedded entry** | renders as a raw delivery-API span: title only, no field access, no markup | your card / component markup, with full entry fields | +| **Embedded asset** | `defaultAssetRender` fallback by MIME | your markup (captions, lazy-loading, art direction, CDN transforms) | + +**Embedded entries** are stored as references, so their content must be resolved (see the next section). The handler receives the **full delivery-API entry**, plus `contentTypeUid` and `displayType`. Branch on `contentTypeUid` first, never on entry shape, because different content types often share field names. + +`displayType` tells you how the author placed it, and each wants different markup: + +| `displayType` | Author intent | Typical markup | +|---|---|---| +| `block` | a standalone card in the flow of text | full card: image, heading, summary, link | +| `inline` | a mention inside a sentence | a short inline span. Never a block-level element | +| `link` | text linking to the entry | an `<a>` with the entry's resolved URL | + +Returning block markup for an `inline` embed breaks the paragraph it sits in, the most common visual bug here. + +**Embedded assets** need no fetch: their metadata travels inline in the RTE markup. Register `embeddedAsset` only when the default markup can't express what you need. + +Both handlers return **HTML strings**, not React: a returned JSX object stringifies to `[object Object]`. + +## Embedded items: let the fetch you already did do the work + +Embedded **entries** are stored in the RTE as references, so their content must be fetched. Embedded **assets** are not: their metadata travels inline in the markup and never costs a request. + +Two ways the entries get resolved, and the cheaper one is easy to miss: + +| Route | What happens | Cost | +|---|---|---| +| Pass the **`entry`** prop to `RichText` | Resolves `entry._embedded_items[<field>]` via the `$text` CSLP tag | **no extra request** | +| Pass only the RTE value | Refs are extracted, grouped by content type, and fetched in parallel | one query per content type | + +So if the caller already fetched with **`.includeEmbeddedItems()`**, pass `entry` through: the embedded items are in the payload and re-fetching them is pure waste. Reach for the fetching route only when the RTE value arrives without its parent entry. + +## Embedded assets: the default is usually enough, and it is guarded + +`defaultAssetRender` branches on the asset's MIME type, so a custom `embeddedAsset` handler is only needed for markup the default cannot express: + +| MIME | Rendered as | +|---|---| +| `image/*` | `<img>` | +| `video/*` | `<video controls>` | +| `audio/*` | `<audio controls>` | +| anything else | `<a download>` | + +**If you replace it, keep the URL guard.** The default blocks `javascript:`, `vbscript:` and non-image `data:` URLs and falls back to a plain-text placeholder. An asset URL is author-supplied content, so rendering one unfiltered into an `href`/`src` is an XSS vector. A hand-written `embeddedAsset` handler that interpolates the URL straight into markup silently drops that protection. + +## Deprecated built-ins: do not reach for these + +`Number`, `JSONRTE` and `Section` are deprecated and no longer appear in `getBasicComponents` / `BASIC_COMPONENTS`. For rich text use **`RichText`** (or `CollapsibleText`, which accepts the same RTE matrix). The built-in `Text` and `Header` now type their text prop as `plaintext` rather than `string`. `Text` renders `String(text)` and no longer parses HTML, so markup passed to it shows as literal characters: that is what `RichText` is for. + +## Common pitfalls + +| Pitfall | Why it bites | Fix | +| --- | --- | --- | +| Assuming this is JSON-RTE-only because of the name | `registerRTERenderer` covers JSON RTE, HTML RTE **and** Markdown in one call. Treating HTML RTE as unsupported leads to hand-rolled parsing | One registration serves every rich-text field, whatever its storage format | +| Putting rich text through the built-in `Text` | `Text` renders `String(text)` and no longer parses HTML, so tags appear as literal `<p>` characters | Use the `RichText` component (typed `any`, auto-detects the payload) | +| Re-fetching embedded entries the caller already loaded | Without the `entry` prop the refs are fetched again, one query per content type, on every render path | Pass `entry` so `_embedded_items` is used. Pair with `.includeEmbeddedItems()` at fetch time | +| Custom `embeddedAsset` handler that interpolates the URL directly | Drops the default's scheme guard: `javascript:` / `vbscript:` / non-image `data:` URLs become an XSS vector | Keep the scheme check, or extend `defaultAssetRender` rather than replacing it | +| Using the deprecated `Number` / `JSONRTE` / `Section` basics | Removed from `BASIC_COMPONENTS`. The `@deprecated` marker sits on the declarations so IDEs warn | Use `RichText` for rich text. `Number`/`Section` have no replacement in the basics palette | +| Using `registerJSONRTE` for new code | Deprecated. `registerRTERenderer` is the unified canonical API | Use `registerRTERenderer`. Existing `registerJSONRTE` calls still work, migrate when you next touch the file. | +| Embedded entry renders blank or as a placeholder | No `embeddedEntry` handler is registered (default renders nothing for entry embeds) | Register `embeddedEntry: ({ entry, contentTypeUid, displayType }) => html` | +| `embeddedEntry` returns a React element / JSX | Serializer is HTML-string based. React objects stringify to `[object Object]` | Return a plain HTML string from the handler | +| Calling `registerRTERenderer` more than once | Each call REPLACES the full config. Later calls drop earlier `customElementTypes` / `embeddedEntry` | Build a single merged config and call once at bootstrap. Or use `registerJSONRTE` for serializer-only updates (it preserves existing `embeddedEntry`/`embeddedAsset`), but the right answer is "register everything in one call." | +| Registering too late | After props resolve, re-registering does not retroactively re-render | Always register at bootstrap, before any composition mount | +| Wrong key name in `customElementTypes` | Keys must match the raw `type` in entry JSON, not the editor's display label | Verify against the actual entry JSON | +| Forgetting `dangerouslySetInnerHTML` on the manual path | The page renders escaped HTML as visible text | Use `useStudioRichText` if the SDK exports it. Otherwise render via `dangerouslySetInnerHTML` | +| Rendering the manual path without sanitizing | RTE content is author-supplied, so unsanitized HTML is an XSS vector. `useStudioRichText` sanitizes, hand-rolled rendering does not | Run DOMPurify before `dangerouslySetInnerHTML`, or upgrade to an SDK exporting the hook | +| Expecting embedded entries/assets to appear on the manual path | Only the built-in components and `useStudioRichText` resolve embeds: a hand-serialized `jsonToHtml` call drops them silently | Use the hook, or accept that embedded cards/media will not render | +| Registering standard types unnecessarily | Paragraphs, headings, lists, links, marks, images, tables already render | Only register to override or add | +| Branching `embeddedEntry` on entry shape instead of `contentTypeUid` | Fragile: entries may share field names across CTs | Use `contentTypeUid` as the primary branch key | +| API-authoring an entry with a `json_rte` embed reference node whose `attrs` omits `locale` | CMA rejects the write with `"Reference must contain content-type-uid, entry-uid, locale and display-type."`. The SDK's read path never surfaces this because it's a write-time validation | Every embedded-entry `reference` node under `json_rte` needs the full attr set: `{ type: "entry", "entry-uid": "…", "content-type-uid": "…", "display-type": "block"\|"inline"\|"link", "locale": "en-us" }`. Applies both to node-level RTE fields and `static_value.json_rte[*].value` embeds (mirrors `provision-studio-project` § `static_value` sub-schema JSON-RTE row) | + +## See also + +- Pair with `install-studio` (must run first so `studioSdk.init` exists). +- `docs/20-bring-your-own-components/json-rte-custom-element-rendering.md` for the full prop / serializer reference. diff --git a/skills/studio/render-with-own-data.md b/skills/studio/render-with-own-data.md new file mode 100644 index 0000000..df61b1a --- /dev/null +++ b/skills/studio/render-with-own-data.md @@ -0,0 +1,137 @@ +--- +name: render-with-own-data +--- + +## When to use + +Render a Studio composition (page or standalone section) against data you already hold, via `<StudioComposition context={...} />` plus the spec-only `sdk.fetchComposition`. + +Use when you already hold the data (a group-array element, a commerce record, a request-time value) and must render a composition against it with no SDK data fetch. Phrases: "render this section per array item", "section per product", "render a composition with my data". Do NOT use for route rendering: that's `setup-template-preview-routes` + `wire-external-data`. + +> **Mandatory auth preflight: settle the credential before the first API call.** Resolve it OAuth-first per [`authenticate-cma`](authenticate-cma.md): `CS_OAUTH_ACCESS_TOKEN`, else the Contentstack MCP's stored session: Read that skill for where it is, even with no MCP connected. **Never ask the user for a session `authtoken`.** If nothing resolves, or a refresh fails with `400 invalid_refresh_token`, hand them `! CONTENTSTACK_REGION=<code> npx @contentstack/mcp --auth` (it needs a TTY and a browser, so it cannot be run for them) and wait. `403 error_code 316` is a valid credential aimed at another org: fix the org or the `api_key`, do **not** re-authenticate. + +# Render a Composition With Your Own Data + +## Context + +`<StudioComposition />` renders a composition (a full page, or a section as the root) against a `context` object you pass in. No SDK data fetch, no hook, no route ownership. It runs in CSR and SSR. + +You give it two things: which composition, and the data. + +- **Which composition**: a pre-fetched `spec` from `sdk.fetchComposition`, or a `compositionUid` it fetches on the client. +- **The data**: `context`, placed internally at `dataSources.template`, so the composition's existing template bindings resolve against it. Embedded sections are auto-scoped from `context` (pure, no network). You never build `section_scoped_data`. + +Reference: `docs/25-bring-your-own-data/studio-composition.md`, `docs/25-bring-your-own-data/studio-composition-props-reference.md`. + +This is not `<StudioComponent />`. That one owns a route and resolves composition **and** CMS data through `sdk.fetchCompositionData`. `<StudioComposition />` owns a subtree and takes the data from you. If the choice of surface is still open, run `understand-bring-your-own-data` first. + +## Task + +1. **Prereq: `studioSdk.init(...)` runs before render, and every component the composition uses is registered.** Without init the `compositionUid` path logs `StudioComposition: SDK not initialized. Call studioSdk.init(config) before rendering with a compositionUid.` and renders `errorFallback`. `sdk.fetchComposition` throws when no `stackSdk` is configured. Run `install-studio` and `register-component` if either is missing. + +2. **Identify the composition.** Get `compositionUid` from the composition's URL in Studio. `sdk.fetchComposition` also accepts `url` or `templateContentTypeUid` (same identifier shapes as `sdk.fetchCompositionData`) plus `searchQuery`. Pass `searchQuery: ""` when rendering on your own site. It exists to forward Studio's iframe overrides (locale, variant, preview entry, edit mode). + +3. **Shape `context` to the composition's bindings.** A binding to `title` reads `context.title`. A binding that iterates `sub_items` reads `context.sub_items`. Pass the raw element you hold (one item of a multiple-group field, one API record) and the SDK wraps it. If the composition was authored against a `product` template, `context` is one product-shaped object with the same field names. + +4. **Pick the fetch path by render strategy:** + + | Situation | Path | + |---|---| + | SSR / RSC / Remix loader / `getServerSideProps` | Pre-fetch `spec`: a `"use client"` component cannot fetch during a server render | + | A loop rendering N instances of the same composition | Pre-fetch `spec` once above the loop | + | A single client-rendered mount | `compositionUid` + `loadingFallback` + `errorFallback` | + +5. **Pre-fetched spec (SSR, the default).** Resolve the spec and your own data in parallel, render immediately, no client loading state: + + ```tsx + // app/products/page.tsx — Next App Router Server Component (no "use client") + import { sdk } from "@/lib/contentstack"; + import { StudioComposition } from "@contentstack/studio-react"; + + export default async function ProductsPage() { + const [products, spec] = await Promise.all([ + getProducts(), // your data source + sdk.fetchComposition({ compositionUid: "product_tile", searchQuery: "" }), + ]); + + return ( + <div className="grid"> + {products.map((element, i) => ( + <StudioComposition key={i} spec={spec} context={element} /> + ))} + </div> + ); + } + ``` + + `sdk.fetchComposition(query, options?)` returns `Promise<StudioSpec>` whose `data` is intentionally empty: that's the bring-your-own-data path. `options` is the same `CompositionQueryOptions` as `sdk.fetchCompositionData` (`variantAlias`, `locale`, `templateEntryUid`, `extendQuery`, custom fetchers). + + Pages Router / Remix: call `sdk.fetchComposition` in `getServerSideProps` / `loader` and pass `spec` through props. + +6. **`compositionUid` path (client only).** Always pair it with both fallbacks: the defaults are `null`, so a failed fetch renders nothing: + + ```tsx + "use client"; + import { StudioComposition } from "@contentstack/studio-react"; + + <StudioComposition + compositionUid="hero_section" + context={element} + loadingFallback={<SectionSkeleton />} + errorFallback={<NotFound />} + />; + ``` + + In a CSR app you can also pre-fetch in `useEffect` and hold the `StudioSpec` in state, worth it when several mounts share one composition. + +7. **Fetch the spec once per composition, never per item.** Each `<StudioComposition compositionUid={…} />` inside a loop fetches independently. Hoist the fetch, pass the same `spec` object to every item. + +8. **Keep `spec` and `context` JSON-serializable.** `<StudioComposition />` is a `"use client"` component, so in RSC both props cross the server to client boundary. Class instances, functions, `Map`/`Set`, and `Date` objects break serialization: pass plain objects, arrays, strings, numbers, booleans, `null`. + +9. **Verify in a normal browser tab** (not the Studio iframe): each mount renders the composition's own layout with your element's values, and item N shows item N's data. + +## Inputs needed from the user + +In this order. If any is missing, ask before editing code. + +1. `compositionUid`: which composition to render (or the `url` / `templateContentTypeUid` that resolves it). +2. `dataShape`: the object you hand to `context`, so we can check it matches the composition's binding field names. +3. `renderPath`: CSR or SSR. Picks step 5 vs step 6. +4. `mountFile`: the file where `<StudioComposition />` mounts. + +## Acceptance + +This skill succeeds only when ALL of the following are true. If any fails, surface the failure and stop. + +- [ ] `mountFile` renders `<StudioComposition />` with `context` plus exactly one of `spec` / `compositionUid`. +- [ ] In SSR the spec is resolved with `sdk.fetchComposition` on the server and passed as `spec`, no `compositionUid` on a server-rendered mount. +- [ ] `context` field names match the bindings the composition was authored against. +- [ ] A loop fetches the spec once above it and reuses the same `spec` for every item. +- [ ] The `compositionUid` path passes both `loadingFallback` and `errorFallback`. +- [ ] `spec` and `context` are JSON-serializable end-to-end (no functions, class instances, `Date`/`Map`/`Set`). +- [ ] Loading the route in a normal browser tab renders every mount with its own data, verified by screenshot or by reading the rendered values, not by the absence of console errors. + +## Common pitfalls + +| Pitfall | Why it bites | Fix | +| --- | --- | --- | +| Neither `spec` nor `compositionUid` passed | The component throws `StudioComposition requires either a 'spec' or a 'compositionUid'.` | Pass one: `spec` for SSR, `compositionUid` for client-only | +| `compositionUid` on a server-rendered mount | The fetch is a client effect. The server renders nothing and content pops in late | Pre-fetch with `sdk.fetchComposition`, pass `spec` | +| Passing `data={...}` instead of `context={...}` | `data` is the `<StudioComponent />` prop. `<StudioComposition />` ignores it and renders with no template data | Use `context`, see `understand-bring-your-own-data` § Why `context` is not `data` | +| `context` shape doesn't match the bindings | Bound nodes render blank with no error | Pass the element in the shape the composition was authored against (same field names) | +| Expecting `spec.data` to be populated | `fetchComposition` skips the data fetch by design | Supply the data through `context` | +| `sdk.public.fetchComposition(...)` | `studioSdk.init(...)` already returns the public surface | Call `sdk.fetchComposition(...)` | +| Spec fetched inside the loop body | One network call per item | Hoist the fetch above the loop | +| No `loadingFallback` / `errorFallback` | Both default to `null`: a failed or slow fetch renders an empty gap | Pass both on the `compositionUid` path | +| Non-serializable `context` in RSC | Next.js rejects the prop crossing the client boundary | Map to a plain object before passing | +| `compositionUid` path with no `studioSdk.init` | Console error `SDK not initialized`, then `errorFallback` | Initialize the SDK at app startup (`install-studio`) | +| Composition renders unstyled | Design tokens come from `spec.config` via the composition itself | Confirm the composition resolves (a real `spec`), and that breakpoints/tokens are registered (`import-design-tokens`) | + +## See also + +- `docs/25-bring-your-own-data/studio-composition.md`: CSR/SSR/loop walkthrough + per-framework fetch locations +- `docs/25-bring-your-own-data/studio-composition-props-reference.md`: every prop, the `fetchComposition` query shape, and `options` +- `understand-bring-your-own-data`: which of the three data surfaces fits +- `wire-slot-data`: the other bring-your-own-data surface (per-slot) +- `embed-composition`: mount a composition inside a code-owned page when the SDK should fetch its data too +- `configure-csr-vs-ssr`: pick the render path before wiring step 5 vs 6 diff --git a/skills/studio/resolve-reference-depth-pitfalls.md b/skills/studio/resolve-reference-depth-pitfalls.md new file mode 100644 index 0000000..5c1cb65 --- /dev/null +++ b/skills/studio/resolve-reference-depth-pitfalls.md @@ -0,0 +1,47 @@ +--- +name: resolve-reference-depth-pitfalls +--- + +## When to use + +Diagnostics for reference resolution: `error_code 141` and what it always means, plus the pitfall table for walking depth. Read when a fetch fails or a stub will not resolve. + +Part of the reference-depth set. Start at [`resolve-reference-depth`](resolve-reference-depth.md). + +## `error_code 141`: always the same cause, never "go deeper" + +`141` means the request asked for more reference paths than it is allowed to carry. + +**It is a request-size limit, not a path count. Do not plan around "100 paths".** Measured on one stack, the same entry accepted **27** declared paths and returned `141` at **28**, with the request URL at ~2.5KB. Paths on that entry ranged from 23 to 372 characters, so the count at which it breaks moves with how long your paths are: a few deeply-nested paths exhaust it as fast as many shallow ones. Past that, an even longer request stops returning `141` and starts returning **`414`**, a transport failure with an empty body, which is easy to misread as "resolved nothing". + +**Always check `r.ok` before reading a response.** A `414` and a genuinely empty result look identical if you only count stubs. + +The fix is the same in every case: **ask for fewer paths.** + +- **Split**: run two or more fetches, each carrying part of the path set, and merge. +- **Narrow**: declare only the chains the binding actually needs, not the schema's full closure. +- **Re-root**: fetch the deepest resolved entry as a new root so the request carries only the sub-chain. Unbounded, so it needs the user (Step 3). + +What is never the fix is raising a depth number. A `141` does not become a success one level deeper. + +## Common pitfalls + +| Pitfall | Why it bites | Fix | +|---|---|---| +| Treating `[]` as "not deep enough" | No depth fills genuinely empty content. The user approves passes that cannot succeed | Classify per Step 0 first. Report absent content as absent | +| Tuning `include_all_depth` to fix a page | The bound-entry fetch carries `include[]` and `only[]` and no depth parameter, the number being raised is not in that request | Use depth to diagnose. Fix by declaring the path, which becomes `include[]` | +| Trusting `include_all_depth` to resolve anything | Measured inert on a real stack: identical bytes at 1 to 6 and 10, no error, and no effect on top of declared paths | Declare a path per hop. Re-measure before relying on the parameter | +| Answering a `141` by raising the depth | `141` means too many reference paths. One more level asks for strictly more | Narrow the declared set, or ask which paths actually matter | +| Reading a falling stub count as progress | Resolving one level exposes the stubs one hop further out, and `_metadata` objects inflate the count, measured 17 of 28 | Exclude `_metadata`. Track the paths the binding needs, by name | +| Deepening the fetch but not declaring the path | The entry is fetched and the field still renders blank, so the fix looks like it failed | Advance both mechanisms in the same pass | +| Prompting per stub, or per section | Spends the user's attention on decisions the schema already answers | Declare every derivable hop, then ask once, for everything still stuck | +| Raising depth because the value is deeply nested in groups or blocks | Same-entry nesting costs no depth. The number climbs until `141` while the real fault is an undeclared or mis-terminated path | Count reference hops from the schema. Fix the path, not the depth | +| Reading an unpublished target as "not deep enough" | An unresolvable stub and a too-shallow stub are byte-identical. Digging and re-rooting both fail forever | Probe the stub's uid directly. No entry returned means publish state, not depth | +| Declaring hops past a stub instead of the stub's own path | The stub is resolved by its own path. Declaring the target CT's fields leaves it untouched and the pass looks like a no-op | Declare `P` first, then anything beyond `P` | +| Reading an empty `entries` array as a resolved page | Zero stubs and zero bytes look identical to full success | Confirm an entry came back before counting anything | +| Reading a `414` as "nothing to resolve" | The response has no body, so stub and byte counts are both zero, indistinguishable from success unless `r.ok` is checked | Check `r.ok` first. On `414` split the path set and retry | +| Declaring the schema's full closure | Expansion overruns the request long before the schema runs out, measured 153 candidate paths against a 27-path ceiling | Expand toward paths that carry a real stub. Split when the budget bites | +| Expanding only the CT this entry happens to hold | A multi-target reference holds a different CT per entry, so the next hop is under-declared for every other entry | Union the next hop over every `reference_to` candidate | +| Asking the user about a stub that is merely unpublished | The survivor of every derivable path looks like a missing path, but probes `422` with `publish_details: (NONE)`. Measured, the only stub reaching Step 3 was this | Re-run the stub probe before prompting. Report `422` as absent content | +| Guessing a path, or re-rooting, instead of asking | A guessed path silently declares nothing, and a re-root has no natural end, both look like progress and produce a blank field | At an underivable path, stop and ask for path / entry / depth (Step 3) | +| Digging silently in automation | The job passes, the page is blank, and nothing recorded which path was missing | Declare derivable hops, then stop and report in the Step 3 format | diff --git a/skills/studio/resolve-reference-depth.md b/skills/studio/resolve-reference-depth.md new file mode 100644 index 0000000..eac1ac4 --- /dev/null +++ b/skills/studio/resolve-reference-depth.md @@ -0,0 +1,190 @@ +--- +name: resolve-reference-depth +--- + +## When to use + +Resolve a reference chain one hop at a time by declaring each hop's path, and stop at a human checkpoint the moment a path cannot be derived. Covers both mechanisms that must line up (the fetch's `include[]` and Studio's `data_sources.resolvedReferences`), why `include_all_depth` measured as inert, and what to ask for when the schema runs out of answers. + +Use when a field behind a reference renders blank and the reference chain is more than one hop: a card that references an author who references an organization, a block whose reference points at an entry that itself references an asset group. Phrases: "reference inside a reference is empty", "nested reference not resolving", "the logo is inside a group inside a reference", "reference inside a modular block inside a group", "the field is buried four groups deep", "how deep can references go", "how many levels of reference does Contentstack support", "include_all_depth", "error_code 141", "include reference paths processing limit", "the data is two references deep". Do NOT use for a single-hop reference (bind it per [`build-section`](build-section.md)), for a reference that resolves to `[]` (that is absent content, not depth, see § Step 0), or to diagnose why one declared path is wrong (that is [`troubleshoot-data-binding`](troubleshoot-data-binding.md) rows on mis-terminated paths). + +# Resolve a reference chain hop by hop, then ask + +## Context + +Three things decide whether a reference arrives, and they are not the same knob: + +| Mechanism | Wire parameter | Ceiling | +|---|---|---| +| `data_sources.resolvedReferences` | becomes **`include[]`** on the bound-entry fetch | no depth cap. Every hop's path must be declared and correctly terminated | +| Field projection | **`only[<ref>][]`** | dropped automatically when the request URL would exceed ~15 000 chars | +| `include_all_depth` | one number, resolves everything to that depth | **5** org-wide, and lower per entry in practice | +| `include_reference` | explicit paths on the fetch | **3 levels** for a multi-content-type reference field | + +**Which one the runtime actually uses matters more than the ceilings.** The SDK fetches bound page content with `include[]` + `only[]` and **no depth parameter at all**. References arrive because their path was declared, not because a number was raised. `include_all_depth` appears only on the fetch for composition/section entries. + +`include_all_depth` was therefore **not** used as this skill's ladder, and when measured it turned out not to work at all (see § The ladder). **The fix is a declared path**, never a bigger number: the number is not in the production request to raise, and on the stack measured it changed nothing anywhere. + +A chain fails if either the fetch never retrieved the entry or the composition never declared the path. Both present as the same thing downstream: a blank field. + +## Why this matters + +Digging deeper is neither free nor always correct. Each declared path multiplies the payload and consumes the per-request reference-path budget, and no amount of depth fixes an entry that is genuinely empty in the environment being served. Left to itself an agent will keep raising a number until the API rejects it (or, worse, until it does not) and report "resolved" when the request merely got wider and the field is still blank. + +So when the schema stops answering, the next move is a question, not a retry. **Ask for the path or the entry. Bind exactly what comes back.** The checkpoint is the point of this skill. + +## Reference for this skill + +| Read this | For | +|---|---| +| [`understand-reference-depth`](understand-reference-depth.md) | What depth counts, and the two signals that it is finished | +| [`resolve-reference-depth-pitfalls`](resolve-reference-depth-pitfalls.md) | `error_code 141`, and what goes wrong walking depth. **Read before reporting done.** | + + +## Task + +### Step 0: Classify before digging. Only the first of these five states is a depth problem + +Read the raw value at the deepest point that did resolve: + +| What you see | What it means | Response | +|---|---|---| +| `{ "uid": "…" }` and nothing else | Either the fetch never went deep enough, its path was never declared, **or the target entry is not published in this environment**, all three look identical | Run the **stub probe** below before spending a pass | +| **Every** reference in the response is a stub, all at once | The request URL overflowed. `only[]` is dropped first (a `[studio-sdk] Skipping only[] projections` warning). If `include[]` alone still overflows, the fetch 414s and falls back to no includes, so nothing resolves | Not a depth problem. Reduce the request: fewer declared paths, re-root, or split the fetch | +| `{ "uid": "…" }`, and fetching that uid directly returns no entry | The reference points at an entry that is not published (or not accessible) in this environment | **Not** a depth problem. No depth and no re-root will fill it. Report it unpublished; publishing it is the user's call, so never publish it yourself | +| `[]` | The reference field is genuinely empty for this entry in this environment | **Not** a depth problem. No depth will fill it. Say so and stop. Check the entry, the environment, and whether the target is published | +| Field absent entirely | The path was never requested, a declaration gap, not a depth gap | Declare the path (see [`author-composition-via-api`](author-composition-via-api.md) § Where a reference path must terminate), then re-run pass 1 | + +**The stub probe.** A stub carries `_content_type_uid` and `uid`, so it costs one cheap request to find out which kind it is: + +``` +GET /v3/content_types/<stub._content_type_uid>/entries + ?environment=<env>&query={"uid":"<stub.uid>"} +``` + +If an entry comes back, the target exists and the stub is a depth or path problem. Continue the ladder. **If nothing comes back, the target is not published in this environment, and no amount of depth will ever resolve it.** Measured: an entry that plateaued at three unresolved paths across depths 2, 3 and 4 turned out to have all three pointing at unpublished targets, depth was never the fault, and each further pass would have cost payload for nothing. + +Run the probe on one representative stub per distinct target content type, not per occurrence. + +Never open a checkpoint for any row but the first. Offering to dig deeper for absent content sends the user to approve work that cannot succeed. + +### Step 1: Derive every path you can, then fetch once + +Walk the content type. Each segment whose field is `data_type: "reference"` is a hop. Group, blocks and block-uid segments are free. Declare **every** derivable hop in one request rather than one per pass, each costs a path, not a round trip. + +Declare the same paths in the composition's `resolvedReferences`. A fetch that includes a path the composition never declared still renders blank, and the two failures are indistinguishable from outside. + +**Use schema paths, not payload paths.** The payload carries array indices (`sections.1.resources.cards.0.reference_card.card_category.0.author.0`) and `include[]` takes the schema shape with the indices dropped: `sections.resources.cards.reference_card.card_category.author`. Copying the path out of the response is the most common way this silently declares nothing. + +### Step 2: Walk the remaining stubs outward + +For each stub still present, read its `_content_type_uid`, look that CT up, and declare the next hop. Repeat while the next hop is derivable. Stop when the stub set stops shrinking. + +**There is no depth limit to stop at: the walk runs until the schema has nothing left to give.** It goes exactly as deep as the data does, which is why it needs no ceiling: measured across three entries it converged in a single round at 2, 2 and 3 hops, each time because nothing further was derivable rather than because a cap was hit. The only thing that can cut it short is the request budget, and that is answered by splitting (Step 2b.3), not by stopping. + +**`_metadata` objects are not stubs.** They are uid-only and match every naive stub detector. Measured on one entry: 28 apparent stubs, of which **17 were `_metadata`**, a detector that counts them reports a chain as unresolved forever. Exclude them before deciding anything. + +**Stub count is not a progress bar. It can rise while you are succeeding.** Declaring hop 1 above took the count from 11 to 12 while adding 52KB of resolved content, because resolving one hop exposes the stubs one hop further out. + +### Step 2b: Exhaust the automated options before you ask + +**Asking is the last resort, not the second one.** Three things are still free to try, and all three are bounded. Do them before Step 3. + +**0. Declare the stub's own path before anything beyond it.** A stub at payload path `P` is resolved by declaring `P` itself, not by declaring the fields of the content type it points at. Declaring only the hops past a stub leaves the stub exactly as it was, and the pass looks like it accomplished nothing. Measured: on one entry this mistake held the count at 8 stubs and pushed 7 of them to the user as questions. Declaring each stub's own path first took the same entry to 1, and that survivor was unpublished: **0 questions**. + +**1. Union over every candidate CT.** A reference field with several `reference_to` targets holds a different content type per entry, so the next hop differs per entry too. Walking only the CT this entry happens to hold under-declares for every other one. Declare the next hop for **every** candidate. + +**2. Probe `include_all_depth` once, on this stack.** One A/B fetch, same paths, with and without it. Identical bytes and identical stub count means inert here. Spend nothing more on it. Anything different means it helps on this stack, so keep it. Measure. Never assume either way. + +**3. Split the request when the budget bites.** Exhaustive expansion overruns the request long before it runs out of schema: measured, a full schema closure produced **153** candidate paths where the entry accepted **27**. So expand toward the paths that actually carry a stub, and when a set returns `141` or `414`, **split it across two fetches and merge** rather than giving up or asking. + +**No entry is not zero stubs.** A fetch that returns `200` with an empty `entries` array reports zero stubs and zero bytes, identical to a fully resolved page. It means the content type has no entry in this environment, which is a Step 0 classification, not a success. Check an entry came back before reading any count. + +**A failed request is not an empty result.** A `414` returns no body: zero stubs, zero bytes. Counting stubs alone reports it as total success. Check `r.ok` first. This is the single easiest way to conclude "nothing left to resolve" from a request that never ran. + +Only when all three are exhausted, and stubs remain, continue to Step 3. + +### Step 3: Re-probe, then ask. Do not guess, do not re-root unasked + +When a stub's path cannot be derived, **ask the user**. This is the checkpoint, and it replaces the old "raise the depth / re-root" behavior. + +**Re-run the Step 0 stub probe on each survivor first.** A stub that outlived every derivable path is more often unpublished than unreachable, and the two are byte-identical in the response. Fetch the stub's uid directly on the **delivery** API for the target environment: + +- **`422`** (or `entry not found`) means the target is **not published in this environment**. That is absent content, not a path. Report it as such, and leave publishing to the user. Asking for an include path cannot fix it, and spends the user's attention on a wrong question. +- **`200`** means genuinely reachable but undeclared. This one is worth asking about. + +Measured on one real chain: Steps 1 to 2 took 8 stubs down to 1, and that survivor probed `422` with `publish_details: (NONE)`. Every stub that reached Step 3 in that run was absent content, asking about any of them would have been wrong. + +Cross-check on the management API if you need certainty: it ignores publish state, so a `200` there plus a `422` on delivery is conclusive. + +Only for the survivors that probe `200`, ask as a numbered choice over **reference fields**. A +binding targets a CT field, never an entry, so the question is which reference field to declare +next. List the reference fields on the stub's CT and let the user pick: + +``` +Can't resolve this binding. Which reference field do I declare next? + + Prop : <the prop that is blank> + Payload path : <blocks>.1.<block>.<list>.0.<ref_field>.0 + Stub CT : <target_content_type> + Declared : <ref_field> (toggled on, still a stub) + + 1. <target_content_type>.<ref_a> -> <ref_a's target CT> + 2. <target_content_type>.<ref_b> -> <ref_b's target CT> + 3. None of these — give me the include path, or a depth + 4. Leave this prop unbound noted, I move on +``` + +**Every option declares the reference field itself before anything past it**, per 2b.0: picking +`1` declares `<ref_field>.<ref_a>`, the hop and its parent, not `<ref_a>` alone. Show each +field's target CT so the user can spot which holds the value, and mark declared ones so a pick +cannot repeat a pass. + +Number choices across the whole report so several props are answered `1, 3, 4` in one line. +Keep option 4 — a prop with no content behind it is a real answer, and the only other exit is +to invent a path. + +Then act on the choice and **resume the walk from there**. A picked field is declared with its +parent hop, then Step 2 runs again from the level it opens — a new level often makes the next +hops derivable, and re-asking for what the walk could derive wastes the checkpoint. A supplied +path goes straight into `include[]` and `resolvedReferences`. Speculate past nothing: resume, +and return to the user only if it stubs again. + +### Step 4: Non-interactive runs + +With no human to ask (CI, a seed pipeline, a scripted provisioner) do not guess and do not dig. Silent digging in automation is how a job passes while the page is still blank. + +- declare every **derivable** hop (Steps 1 to 2). That part needs no human +- perform **zero** guesses and **zero** re-roots +- stop at the first stub that would need Step 3, and report it in the Step 3 format so a human can answer it later + +A caller may configure more. What automation must never do is treat "no human present" as permission to keep going. + +## Acceptance + +- [ ] Every unresolved path was classified per Step 0 (including the stub probe) before anything deeper was proposed. +- [ ] Required hops were counted from the schema, not from path segments. Group and block nesting adds none. +- [ ] `include[]` used **schema** paths with array indices stripped, not paths copied out of the payload. +- [ ] `_metadata` objects were excluded before any stub was counted or reported. +- [ ] Every derivable hop was declared without prompting. The first prompt came only at a path that could not be derived. +- [ ] Each stub's own path was declared before any hop beyond it. +- [ ] An empty `entries` array was classified as "no entry in this environment", never counted as zero stubs. +- [ ] Every candidate CT of a multi-target reference was expanded, not only the one this entry held. +- [ ] `include_all_depth` was A/B probed once on this stack rather than assumed inert or assumed working. +- [ ] A `141`/`414` was answered by splitting or narrowing the path set, never by asking the user or by raising a depth. +- [ ] `r.ok` was checked before any response was counted. A `414` was never read as "nothing left to resolve". +- [ ] Each surviving stub was re-probed on the delivery API before any prompt, `422` reported as unpublished/absent, only `200` escalated to the user. +- [ ] That prompt named the field, the payload path, the target CT, the stub uid and what was already tried, and offered path / entry / depth as the three ways to answer. +- [ ] What the user supplied was bound exactly as given: no extra levels, no unrequested re-root. +- [ ] One report covering every unresolved path, not one per section or per stub. +- [ ] A non-interactive run declared derivable hops, then stopped at the first path needing a human, zero guesses, zero re-roots. +- [ ] The fix landed as a declared `resolvedReferences` path (`include[]`), never as a raised depth number. +- [ ] `include_all_depth` was not credited with a fix unless re-measured on that stack. It measured inert on the one tested. +- [ ] The final report names each path as resolved, absent (`[]`), or still unresolved, never "resolved" for a path that merely stopped erroring. + +## See also + +- [`troubleshoot-data-binding`](troubleshoot-data-binding.md): why one declared path resolves empty (mis-terminated paths, block-uid segments) +- [`author-composition-via-api`](author-composition-via-api.md) § Where a reference path must terminate: the four path shapes and where each one ends +- [`build-section`](build-section.md): binding a single-hop reference +- [`understand-linked-schemas`](understand-linked-schemas.md): how a Section's scope decides which references are addressable at all diff --git a/skills/studio/setup-local-https-canvas.md b/skills/studio/setup-local-https-canvas.md new file mode 100644 index 0000000..3d06dd3 --- /dev/null +++ b/skills/studio/setup-local-https-canvas.md @@ -0,0 +1,201 @@ +--- +name: setup-local-https-canvas +--- + +## When to use + +Set up a locally-trusted HTTPS cert (via mkcert) for the canvas-app. NOT required for most local-dev: browsers treat `localhost` as trusted and allow Studio to iframe `http://localhost:<port>`. + +Use ONLY when HTTPS-on-localhost is actually required: service workers, secure cookies, web-share/WebAuthn, strict corporate policies that block mixed content even for localhost, or production-parity smoke tests. Phrases: "I need https locally", "mixed-content block on localhost". Do NOT use for production (real HTTPS) or tunnels (Cloudflare/ngrok already ship certs). + +> **Auth preflight: settle the credential before the first API call.** Resolve it OAuth-first per [`authenticate-cma`](authenticate-cma.md): `CS_OAUTH_ACCESS_TOKEN`, else the Contentstack MCP's stored session: Read that skill for where it is, even with no MCP connected. **Never ask the user for a session `authtoken`.** If nothing resolves, or a refresh fails with `400 invalid_refresh_token`, hand them `! CONTENTSTACK_REGION=<code> npx @contentstack/mcp --auth` (it needs a TTY and a browser, so it cannot be run for them) and wait. `403 error_code 316` is a valid credential aimed at another org: fix the org or the `api_key`, do **not** re-authenticate. + +# Set up locally-trusted HTTPS for the canvas-app + +## Context: when this is actually needed + +Studio is served from `https://app.contentstack.com/#!/studio`. To preview your canvas-app, Studio loads it inside an iframe. + +**For plain `http://localhost:<port>` the iframe just works** in modern browsers: Chrome, Firefox, and Safari all treat `localhost` and `127.0.0.1` as "potentially trustworthy" origins per the [W3C Secure Contexts spec](https://www.w3.org/TR/secure-contexts/), so mixed-content blocking does NOT apply. **You do not need mkcert for a basic Studio canvas iframe.** Current Chrome asks once for local network access: the user must click Allow, or the canvas stays blank. HTTPS doesn't avoid this prompt. + +HTTPS-on-localhost via mkcert is only needed when something else in your app requires HTTPS even at dev time: + +- **Service workers**: require secure context. An HTTP localhost dev server can't register one +- **Secure cookies / SameSite=None+Secure**: set only over HTTPS, even on localhost +- **WebAuthn / WebShare / Payment Request**: gated on secure context +- **Strict corporate browser policy**: some MDM policies override the localhost exemption +- **Production-parity local testing**: to catch HTTPS-only bugs before deploy +- **Tunnel URLs (cloudflared / ngrok / lt)**: NOT localhost, so the secure-context exemption doesn't apply. The tunnel already gives you a trusted HTTPS URL, so mkcert wouldn't help anyway + +If none of the above apply, **skip this skill** and use plain `http://localhost:<port>` as your Canvas URL origin / env base URL. Add mkcert later if a real need emerges. + +## Task + +### 1. Install mkcert (one-time per machine) + +Detect the OS and use the right command: + +```bash +# macOS +brew install mkcert nss +# nss is needed if you use Firefox; harmless otherwise + +# Linux (Debian/Ubuntu) +sudo apt install libnss3-tools +# Then grab mkcert: https://github.com/FiloSottile/mkcert/releases (or via Homebrew on Linux) + +# Windows (PowerShell, as admin) +choco install mkcert +# or: scoop install mkcert +``` + +Verify: + +```bash +mkcert -version +``` + +### 2. Install the local CA into the system trust store + +```bash +mkcert -install +``` + +This adds mkcert's root CA to: +- macOS: Keychain (System trust) +- Linux: NSS-using browsers (Chromium, Firefox). Also writes to `/etc/ssl/certs` if `update-ca-certificates` is available +- Windows: Windows Certificate Store + +You may be prompted for sudo / admin once. After this, every cert mkcert generates is automatically trusted by every browser on this machine (including the Playwright-MCP-driven one, if you're using that). + +### 3. Generate the cert for the canvas-app + +Inside `{{projectRoot}}`: + +```bash +mkdir -p certs +mkcert -cert-file certs/localhost.pem -key-file certs/localhost-key.pem localhost 127.0.0.1 ::1 +``` + +This emits a cert valid for `localhost`, IPv4 loopback, and IPv6 loopback. The cert is locally-trusted (signed by the mkcert root CA you installed in step 2). + +### 4. Wire the cert into your framework's dev server + +**Vite** (`vite.config.ts` / `.js`): + +```ts +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import { readFileSync } from 'node:fs'; + +export default defineConfig({ + plugins: [react()], + server: { + https: { + key: readFileSync('certs/localhost-key.pem'), + cert: readFileSync('certs/localhost.pem'), + }, + port: {{devPort}}, + // Studio iframes from app.contentstack.com — allow it + allowedHosts: true, + headers: { + 'Access-Control-Allow-Origin': '*', + 'Content-Security-Policy': "frame-ancestors *;", + }, + }, + preview: { + https: { + key: readFileSync('certs/localhost-key.pem'), + cert: readFileSync('certs/localhost.pem'), + }, + port: {{devPort}}, + allowedHosts: true, + headers: { + 'Access-Control-Allow-Origin': '*', + 'Content-Security-Policy': "frame-ancestors *;", + }, + }, +}); +``` + +> The `allowedHosts: true` + CSP `frame-ancestors *` headers are required for Studio to iframe the app. Without them Vite returns "host not allowed" and the browser blocks the frame. They're part of this same setup. Don't split them into a separate step. + +**Next.js**: pass your mkcert files to `next dev` via the `--experimental-https-key` + `--experimental-https-cert` flags: + +```bash +next dev \ + --experimental-https \ + --experimental-https-key ./certs/localhost-key.pem \ + --experimental-https-cert ./certs/localhost.pem +``` + +Bare `next dev --experimental-https` generates its OWN self-signed cert (not signed by mkcert's root CA). Studio's iframe silently blocks it. Passing the key/cert flags points Next at the trusted mkcert pair. Fallback if the flags aren't available on your Next version: a custom `server.js` booting Node's https module with the mkcert files, or a prod build (`next build && next start`) behind a reverse proxy that terminates HTTPS with the mkcert cert. + +**Remix / Astro**: follow the same pattern as Vite (both expose `server.https` config). + +### 5. Update `.gitignore` + +The private key is sensitive, never commit it: + +``` +# .gitignore +certs/ +``` + +Confirm with: + +```bash +git check-ignore -v certs/localhost-key.pem +``` + +### 6. Verify + +```bash +# Start the dev (or preview) server +npm run dev # or pnpm dev / yarn dev / bun dev + +# In another terminal — confirm HTTPS works +curl -I https://localhost:{{devPort}}/ +# Expect: HTTP/2 200 (or similar). No `(self signed certificate)` warning. + +# Open in a normal browser tab — expect green lock, no cert warning. +``` + +Then test the Studio iframe: +1. Wherever Studio asks for your canvas-app's origin (the env-level setting that holds the canvas-app's host:port, set per Contentstack environment), make sure it's now `https://localhost:{{devPort}}`. The Studio Project's Canvas URL field stays `/canvas`. Canvas URL is a separate setting and it's just a path, never an origin. +2. Open any composition. The canvas iframe should load cleanly (no blank frame, no console error). + +## Inputs needed from the user + +1. `projectRoot`: the canvas-app directory. +2. `devPort`: the port your dev/preview server listens on. Vite default is 5173. Next is 3000. + +## Acceptance + +- [ ] `mkcert -version` returns a version string. +- [ ] `mkcert -install` completed (root CA in system trust store). +- [ ] `certs/localhost.pem` + `certs/localhost-key.pem` exist in the project root. +- [ ] Framework config has `server.https` + `preview.https` pointing at the cert pair, AND `allowedHosts: true` + `frame-ancestors *` header set. +- [ ] `.gitignore` contains `certs/`. +- [ ] `curl -I https://localhost:<port>/` returns a 200 with no untrusted-cert warning. +- [ ] Browser loads `https://localhost:<port>` with a green lock. +- [ ] Studio canvas (with the matching Canvas URL configured) shows the app inside its iframe, not a blank frame. + +## Common pitfalls + +| Pitfall | Why it bites | Fix | +| --- | --- | --- | +| Skipping `mkcert -install` | Cert is generated but not trusted by the browser, silent iframe block | Run `mkcert -install` first. Re-open browsers after (Firefox especially caches trust). | +| Bare `next dev --experimental-https` (no key/cert flags) | Next generates its own self-signed cert not signed by mkcert's root CA, so the Studio iframe blocks silently | Pass the mkcert pair: `next dev --experimental-https --experimental-https-key ./certs/localhost-key.pem --experimental-https-cert ./certs/localhost.pem` | +| Cert files committed to git | Private key leaked | Always add `certs/` to `.gitignore` before generating | +| Generating cert for a different hostname than the dev URL | Cert mismatch, which produces a browser warning, which blocks the iframe | `mkcert ... localhost 127.0.0.1 ::1` covers all common localhost forms. Add other names if needed | +| `allowedHosts` set but headers missing | Vite serves the page but Studio can't frame it | Both `allowedHosts: true` AND the `frame-ancestors *` CSP header must be set. They solve different layers | +| Cert expired after ~825 days | mkcert certs are short-lived for security | Regenerate the cert pair. The root CA stays trusted | +| Re-installing mkcert after a system upgrade and re-running `mkcert -install` | Old cert pair was signed by the previous mkcert root CA which is no longer trusted | Regenerate the cert pair after re-installing mkcert | +| Using Mac App Store Chrome | Sandboxed. Sometimes ignores Keychain trust for system-installed CAs | Use the standalone Chrome download from google.com/chrome | + +## See also + +- `setup-section-preview`: wires Studio's preview channel. This skill is its HTTPS prerequisite +- `install-playwright-mcp`: its smoke test depends on a trusted Studio canvas, which depends on this cert +- `troubleshoot-canvas`: blank-iframe symptom row maps back here diff --git a/skills/studio/setup-section-preview.md b/skills/studio/setup-section-preview.md new file mode 100644 index 0000000..d3f33ee --- /dev/null +++ b/skills/studio/setup-section-preview.md @@ -0,0 +1,201 @@ +--- +name: setup-section-preview +--- + +## When to use + +Add the canvas route that mounts `<StudioCanvas />`, then print the Canvas URL to set in Studio (browser-only, step 4). **Required on every Studio project, not an optional extra.** **STOP before writing any file:** read the project's environment and its per-locale URL by API (§ 0), show them, confirm the path (default `/canvas`), and wait for a yes. + +Use right after `install-studio` to wire the section-authoring route. Also invoked by [`setup-template-preview-routes`](setup-template-preview-routes.md) step 2 when it finds no canvas route: a catch-all matches `/canvas` like any other URL, so with nothing owning that path Studio's section iframe renders a composition lookup (blank frame, or the site's 404) instead of `<StudioCanvas />`. Phrases: "set up canvas route", "MISSING_CANVAS_URL", "where does StudioCanvas mount", "canvas iframe is blank in Studio". Do NOT use to add template/preview routes (use `setup-template-preview-routes`) or to fix a misbehaving canvas after setup (use `troubleshoot-canvas`). + +> **Auth preflight: settle the credential before the first API call.** Resolve it OAuth-first per [`authenticate-cma`](authenticate-cma.md): `CS_OAUTH_ACCESS_TOKEN`, else the Contentstack MCP's stored session: Read that skill for where it is, even with no MCP connected. **Never ask the user for a session `authtoken`.** If nothing resolves, or a refresh fails with `400 invalid_refresh_token`, hand them `! CONTENTSTACK_REGION=<code> npx @contentstack/mcp --auth` (it needs a TTY and a browser, so it cannot be run for them), and wait. `403 error_code 316` is a valid credential aimed at another org: fix the org or the `api_key`, do **not** re-authenticate. + +# Set up the section preview route + +## Context + +The "canvas route" is the page in the user's app that Studio loads inside its iframe to preview sections. Two things have to line up: + +1. A route in the app at a known path (e.g. `/canvas`) that mounts `<StudioCanvas />` +2. The same path set as **Canvas URL** in the Studio project's Settings, under Configuration + +Without both, opening a section in Studio shows the `MISSING_CANVAS_URL` error. + +**Two unrelated "Base URLs": don't confuse them.** (1) The "Base URL" under Visual Experience, then Custom Preview URL (set by `enable-visual-experience`) drives **template** preview URLs, not relevant here. (2) The **canvas** Base URL is the per-locale URL on the environment your Studio project targets (in Contentstack, open the stack's Settings, go to Environments, open `<env>`, and read the URL for `<locale>`). Studio composes the canvas iframe address as **canvas Base URL (origin) + Canvas URL (path)**. This skill sets the path. The origin must already exist on the targeted environment, or the canvas stays blank. + +Mounting `<StudioCanvas />` is simple: it takes no required props. Studio drives it via URL parameters. The harder part is making sure the framework's routing wires it correctly (App Router vs Pages Router vs React Router vs Remix). + +Prerequisite: `install-studio` has run, so `@contentstack/studio-react` is in the project. + +## Task + +### 0. CONFIRM-AND-GO GATE: the canvas origin chain (do this BEFORE creating the route) + +The canvas iframe address is **canvas Base URL (origin) + Canvas URL (path)**. This skill sets the path (step 4). The **origin comes from the per-locale URL on the environment your Studio project targets**. If that URL is empty, Studio can't build the iframe address and the canvas stays blank, with an error that points at the wrong layer. Most "canvas won't load" reports at this stage are this, not the route. Do NOT create the canvas route until all three are confirmed.: + +1. **Identify the dev origin** the canvas runs at, e.g. `http://localhost:51723`. If a dev server is running, it's that server's origin (the same one step 3 hits for its 200 check). If not, ask the user which origin they'll serve the app at locally. +2. **Confirm an environment carries that origin** as its per-locale URL: in Contentstack, open the stack's Settings, go to Environments, open `<env>`, and the URL for `<locale>` must equal the dev origin, with no path: Studio appends the Canvas URL as plain text, so `http://localhost:3000/en` loads `/en/canvas`. (This is the **canvas** Base URL, NOT the Visual Experience Custom Preview URL.) If it's empty or wrong, guide the user to set it now before continuing. +3. **Confirm that same environment is the one the project targets**: in the Studio project, open Settings, then Configuration, and read the Environment field. If the project points at a different environment (e.g. an empty `preview`), that mismatch **is** the trap: either set that environment's per-locale URL to the dev origin, or repoint the project's Environment (via `configure-studio`) at the environment that already has it. + +**Recommended topology (avoids clobbering deployed envs):** an environment record holds ONE URL per locale, so pointing `preview` at `localhost` breaks your deployed preview later. Prefer a dedicated **`development`** environment whose per-locale URL is `localhost`, with `preview`/`production` reserved for real origins, and point the Studio project's Environment at `development` for local authoring. Switch the project to `preview`/`production` when you deploy (see `deploy-studio-site`). This is a recommendation, not a requirement: if you deliberately use one shared environment, just make sure its per-locale URL matches wherever you're currently serving the canvas. + +Only once 1 to 3 hold, continue. + +1. **Detect the framework.** Look at `package.json` and the project's directory structure: + - `next` in deps plus an `app/` folder means Next.js App Router + - `next` in deps plus a `pages/` folder means Next.js Pages Router + - `vite` in deps means Vite (look for `src/routes/` or check for react-router) + - `@remix-run/*` in deps means Remix + - `react-scripts` means CRA (probably with react-router) + - otherwise, ask the user which framework + +2. **Create the canvas route file** at the framework-appropriate path. + + **The canvas route MUST be isolated from your app's global chrome.** The route that mounts `<StudioCanvas />` should render ONLY that component: no site header/footer, no analytics tags (Lytics / Segment / GA), no third-party embeds, no global `<Script>`s (FontAwesome kits, commerce widgets, chat popups), no auth-gated wrappers. Every one of those runs inside Studio's canvas iframe when the user opens Studio, and any script that `throw`s on failure surfaces as a runtime overlay INSIDE Studio's canvas (looks like Studio is broken, isn't). + + **Framework-specific isolation patterns:** + + - **Next.js App Router:** put the canvas at a route group that has its own minimal `layout.tsx` returning `{children}` only, e.g. `app/(studio)/canvas/page.tsx` with `app/(studio)/layout.tsx` doing `export default function StudioLayout({children}) { return <>{children}</>; }`. Route groups let you opt out of the app's parent layouts (analytics, header, etc.) without changing URLs. + - **Alternative, layout guard:** if you can't use a route group, guard the existing `RootLayout` with a pathname check that skips analytics / third-party scripts when the pathname matches `*/canvas`. + - **Next.js Pages Router:** use `_app.tsx` with a pathname guard (`router.pathname === "/canvas" ? <Component/> : <ChromeWrapper><Component/></ChromeWrapper>`). + - **Vite / React Router / Remix:** the CanvasRoute component sits at the router level, above your app chrome: the chrome only wraps the visitor routes. + + **Verify isolation before proceeding.** Open `http://localhost:<port><canvasPath>` in a browser, open DevTools, then the Network tab. You should see **only** the framework's assets + `@contentstack/*`, NO calls to `kit.fontawesome.com`, `c.lytics.io`, GA, chat providers, commerce embeds. If you see any of those, follow the isolation pattern above and re-verify. Skipping this step is fine short-term but every wrong-network-request shows up as a scary overlay to whoever opens Studio next. + + **Next.js App Router** (`app/<canvasPath>/page.tsx`), `<StudioCanvas/>` must load client-only: + ```tsx + "use client"; + import dynamic from "next/dynamic"; + import { getSSRStyles } from "@contentstack/studio-react"; + + // StudioCanvas is client-only: it reads browser-only context (window.location, + // post-message channel to Studio's parent frame). Server-rendering it produces + // null on the server and a populated tree on the client — a guaranteed + // hydration mismatch. Load it client-only. + const StudioCanvas = dynamic( + () => import("@contentstack/studio-react").then((m) => m.StudioCanvas), + { ssr: false }, + ); + + export default function CanvasRoute() { + return (<><style>{getSSRStyles(null)}</style><StudioCanvas /></>); + } + ``` + + **Next.js Pages Router** (`pages/<canvasPath>.tsx`), same SSR-mismatch reason, wrap in `dynamic({ ssr: false })`: + ```tsx + import dynamic from "next/dynamic"; + const StudioCanvas = dynamic( + () => import("@contentstack/studio-react").then((m) => m.StudioCanvas), + { ssr: false }, + ); + export default function CanvasRoute() { + return <StudioCanvas />; + } + ``` + + **Vite + React Router** (`src/routes/CanvasRoute.tsx` + router config update), Vite is SPA-only, so a plain import is safe: + ```tsx + import { StudioCanvas } from "@contentstack/studio-react"; + export default function CanvasRoute() { + return <StudioCanvas />; + } + ``` + Plus add `{ path: "<canvasPath>", element: <CanvasRoute /> }` to the existing router config. + + **Remix** (`app/routes/<canvasPath>.tsx`), Remix server-renders by default. Use `ClientOnly` or a `useEffect`-mounted boundary: + ```tsx + import { ClientOnly } from "remix-utils/client-only"; + import { StudioCanvas } from "@contentstack/studio-react"; + export default function CanvasRoute() { + return <ClientOnly fallback={null}>{() => <StudioCanvas />}</ClientOnly>; + } + ``` + + **Every framework: render `<style>{getSSRStyles(null)}</style>` beside `<StudioCanvas />`.** In studio-react 1.9.0 `StudioCanvas` writes no design-token `:root` variables, so token styles (padding, max-width, colors) resolve to nothing on the canvas. + + Convert `<canvasPath>` to the framework's expected filename (e.g. `/canvas` becomes `canvas/page.tsx` for App Router, or `canvas.tsx` for Pages Router). + +3. **Verify the route locally** if a dev server is running. Hit `http://localhost:<port><canvasPath>` and confirm it returns 200. If no dev server, print the URL the user should test once they start it. + +4. **Set Canvas URL in Studio.** This is the second half: the route file is useless without the matching Studio setting. Print clear manual instructions for the user to apply in Studio: + ``` + Open: <studioHost>/projects/<studioProjectId>/settings/configuration + Set "Canvas URL" to: <canvasPath> + Click Save. + ``` + +5. **iframe-allowed config (REQUIRED on every dev server).** Studio iframes the canvas-app from `https://app.contentstack.com/#!/studio`. The browser will silently block the iframe unless the canvas-app's dev server allows being iframed from a different origin. + + For local dev, `http://localhost:<port>` works as the Canvas URL origin: browsers treat localhost as a trusted origin (W3C Secure Contexts spec), so no HTTPS / mkcert is needed for the basic case. Run `setup-local-https-canvas` only if your app needs HTTPS-on-localhost for unrelated reasons (service workers, secure cookies, strict browser policy). + + For Vite-based apps, add to `vite.config.*`. **Configure BOTH `server` and `preview`: the canvas should be served from `vite preview` (a prod build), not the dev server.** Vite's dev optimizer can pre-bundle the SDK with a separately-loaded React (different `?v=` hash) and break hook calls in the canvas, even with one React in `node_modules`. Use the dev server for non-Studio iteration. The moment Studio iframes the app, switch to `vite preview`. See `install-studio` step 9 for the full reasoning. + + ```ts + server: { + // ...existing config... + allowedHosts: true, // Vite refuses unknown hosts by default; Studio iframe needs this + headers: { + 'Access-Control-Allow-Origin': '*', + 'Content-Security-Policy': "frame-ancestors *;", + }, + }, + preview: { + // RECOMMENDED canvas target — `<pm> build && <pm> exec vite preview` serves a deduped prod bundle + // (no runtime optimizer to split React across passes) + allowedHosts: true, + headers: { + 'Access-Control-Allow-Origin': '*', + 'Content-Security-Policy': "frame-ancestors *;", + }, + }, + ``` + + For Next.js, set the headers in `next.config.js`: + ```js + module.exports = { + async headers() { + return [{ + source: '/:path*', + headers: [ + { key: 'Content-Security-Policy', value: "frame-ancestors 'self' https://*.contentstack.com;" }, + { key: 'Access-Control-Allow-Origin', value: '*' }, + ], + }]; + }, + }; + ``` + + **Why both:** `allowedHosts: true` (Vite) prevents the "host not allowed" 403 from the dev server. `frame-ancestors` says who may embed the page: `*` locally, only `https://*.contentstack.com` (every region) when deployed. Without either, Studio's iframe stays blank with no console error. + +6. **Sanity check.** Tell the user to: + 1. Open Studio at the project's Compositions list + 2. Click any section (Sections tab) + 3. Confirm the canvas iframe loads with `<StudioCanvas />` content, no MISSING_CANVAS_URL banner + +## Inputs needed from the user + +1. `canvasPath` (defaults to `/canvas`): the path to use. Confirm with user before creating the file in case they have a routing convention. +2. `studioProjectId` (required): to set Canvas URL via API or to print the right manual link. **Resolve it by listing the org's projects ([`configure-studio` § Select the Studio project](configure-studio.md#select-the-studio-project)), not by guessing from a name.** Setting Canvas URL on the wrong project leaves the real one unconfigured and the canvas blank. +3. `studioHost` (defaults to `https://app.contentstack.com/#!/studio`): to construct the right URL. Adjust for non-US regions. On a non-prod DC it's `https://<env>-app.<non-prod-domain>/#!/studio` (see [`analyze-project-fit`](analyze-project-fit.md) § 2b). + +## Acceptance + +- [ ] BEFORE creating the route: confirmed the targeted environment has a non-empty per-locale URL equal to the dev origin, AND the Studio project targets that environment +- [ ] Canvas route file exists in the right location for the detected framework +- [ ] The file mounts `<StudioCanvas />` and the token `<style>` (nothing else, no required props) +- [ ] If a dev server was running, the route returns 200 +- [ ] Canvas URL is set in Studio Project Settings, under Configuration, to match the path +- [ ] For local dev: canvas-app serves over trusted HTTPS (via `setup-local-https-canvas`), AND `allowedHosts: true` + `frame-ancestors *` header are set in the framework config +- [ ] Opening a section in Studio loads the canvas iframe without errors (no blank frame, no "host not allowed", no mixed-content block) + +If the Studio API call wasn't possible (no auth), the manual step is printed and the skill explicitly tells the user to complete it before claiming success. + +## See also + +- `understand-canvas-url`: concept explainer: what Canvas URL is, why a relative path, why `/canvas` is the standard, when Studio uses it vs the env base URL. Run this first if the user is unfamiliar with the concept. +- `setup-local-https-canvas`: optional, mkcert for local HTTPS +- `install-studio`: runs first, the SDK has to be installed +- `configure-studio`: covers Environment + Language alongside Canvas URL +- `verify-setup`: full layered smoke test +- `troubleshoot-canvas`: when the iframe doesn't render after setup diff --git a/skills/studio/setup-template-preview-routes.md b/skills/studio/setup-template-preview-routes.md new file mode 100644 index 0000000..de58905 --- /dev/null +++ b/skills/studio/setup-template-preview-routes.md @@ -0,0 +1,617 @@ +--- +name: setup-template-preview-routes +--- + +## When to use + +Wire Studio into the app's routing: a CATCH-ALL (wildcard) route so every visitor URL resolves through `<StudioComponent />`, or dedicated per-pattern routes. Asks which. Wildcard mode also wires site chrome (header/footer) preservation and 404 handling. + +Use when adding Studio-rendered template routes (Vite/React Router or Next.js App Router), seeing "Template did not load" / "NO_COMPOSITION_FOR_URL" on a template URL, or finishing the Studio setup chapter (after `install-studio` + `setup-section-preview`). Phrases: "make every URL go through Studio", "wire catch-all template route", "wildcard or single page", "template URL 404s", "header/footer disappeared after adding Studio", "my 404 page stopped working", "duplicate header on Studio pages". Skip if only the section-authoring `/canvas` route is needed (that's `setup-section-preview`). + +> **STOP before writing any file.** Ask: (1) one catch-all route for every URL, or dedicated routes such as `/blog/*`; (2) which paths to keep out; (3) whether the header and footer stay in code or become Studio Sections. Wait for the answers. Skip what `convert-project-to-studio` already settled. + +> **Auth preflight: settle the credential before the first API call.** Resolve it OAuth-first per [`authenticate-cma`](authenticate-cma.md): `CS_OAUTH_ACCESS_TOKEN`, else the Contentstack MCP's stored session: Read that skill for where it is, even with no MCP connected. **Never ask the user for a session `authtoken`.** If nothing resolves, or a refresh fails with `400 invalid_refresh_token`, hand them `! CONTENTSTACK_REGION=<code> npx @contentstack/mcp --auth` (it needs a TTY and a browser, so it cannot be run for them) and wait. `403 error_code 316` is a valid credential aimed at another org: fix the org or the `api_key`, do **not** re-authenticate. + +# Wire template preview routes + +**Caller context:** when `convert-project-to-studio` has resolved minimal setup, inherit its wildcard, code-owned chrome, and locale choices. Do not repeat the route-mode question. Ask only for choices not already settled by the user or caller. + +**Editor rendering correction:** the simplified no-spec examples below are not sufficient evidence of working preview. A plain `<div />` does not mount `StudioComponent` or start its editor bridge. Preserve valid `specOptions` (including `fetchOptions`) and let the renderer handle its supported editor mode. If resolution throws before producing those options, use only a fallback verified against the installed SDK; do not fabricate options or mark an empty 200 response successful. The unsaved-template browser check in `verify-setup` must pass before claiming preview is complete. + +## Context + +The user has Studio installed (`install-studio`). The `/canvas` section-authoring route (`setup-section-preview`) is a hard requirement of this skill. Step 2 creates it if it's missing rather than bailing, because a catch-all silently claims `/canvas` when no route owns it. They now need to wire Studio as the **default renderer for every URL on the site**, so any published composition resolves to its template automatically, and any opened template in Studio's iframe renders live. + +**Catch-all is the recommended default, not mandatory, and on an existing app it's a question, not an assumption (step 3).** A catch-all doesn't just add Studio. It takes over every URL the app didn't explicitly claim, which is why wildcard mode carries the extra chrome + 404 work in step 5a. This skill registers ONE route matching every URL. Studio resolves the composition by URL at render time via `sdk.fetchCompositionData({ url, searchQuery })`. With no match, **the call rejects** (it does not resolve null), which the catch-all must translate into a 404, see step 5a (iii). Per-template route registration is **not needed** (Studio's URL patterns handle matching internally), but it's valid if you have a reason (see below). + +**When dedicated routes are a fit instead of the catch-all:** +- You're adding Studio to an existing app where you want explicit control over which URL patterns route through Studio (e.g. `app/blog/[slug]/page.tsx` mounting `<StudioComponent />` for blog posts only, while `/products/*` stays on hand-coded pages). +- Your routing layer has constraints catch-all can't satisfy (route-level middleware, per-route auth, framework-specific data fetching that varies by URL shape). +- You're gradually migrating from hand-coded pages, each new dedicated `<StudioComponent />` route replaces one set of hand-coded pages. + +For dedicated routes: mount `<StudioComponent />` (with the request URL passed to `sdk.fetchCompositionData({ url, searchQuery })` or `useCompositionData`). The renderer handles iframe preview and visitor render the same way. A dedicated per-pattern route is functionally identical to Studio. + +**Opt-outs are explicit (catch-all mode only).** If the user has paths that must NOT go through Studio (`/api/*`, `/admin/*`, custom static pages), this skill registers those routes ahead of the catch-all so they take precedence. Dedicated-route mode doesn't need opt-outs, only the URLs you explicitly route through `<StudioComponent />` go through Studio. + +The skill emits route code that mounts `<StudioComponent />` for the URL. The same component handles both direct visitors (rendering the deployed composition from CDA) and in-Studio iframe preview (rendering the unsaved working spec). Mode-switching happens inside the renderer. You do not branch on iframe context in your route code. The separate `/canvas` route (from `setup-section-preview`) is the only place `<StudioCanvas />` appears. + +There are TWO Studio React components in play: + +- **`<StudioComponent />`**: the renderer for visitor routes (`/blog/:slug`, the catch-all, etc.). Reads a published composition spec from CDA via `sdk.fetchCompositionData({ url, searchQuery })` (or `useCompositionData`) and renders it. When Studio loads the same URL in its iframe, it switches modes internally to show the unsaved spec. Your code does nothing different. +- **`<StudioCanvas />`**: the dedicated section-preview iframe target. Mount it once at `/canvas` (per `setup-section-preview`). Studio's Canvas URL config points here. You do NOT mount `<StudioCanvas />` on visitor routes. + +Reference doc: `docs/10-setup/studio-project/template-preview-routes.md`. + +## Failure signatures: all mean "no `<StudioComponent>` at the template's URL" + +Before running the Task steps, know the failure modes. These three symptoms have ONE root cause: a Connected template's URL isn't served by a route mounting `<StudioComponent>`. + +| Symptom in Studio's canvas / on the deployed site | Root cause | +|---|---| +| Studio previews **the existing hand-coded page** (looks correct but authoring changes don't reflect) | The template's URL is served by a legacy hand-coded route the catch-all/dedicated Studio route hasn't replaced. Studio's iframe renders the legacy route as-is. | +| Studio shows **"Template Did Not Load"** or a **404** in the iframe on a template URL you just created | No route in the app matches that URL, or the matching route returns empty because `<StudioComponent>` never mounts. | +| Studio shows **"SDK is not initialized"** on the template URL | A route matches the URL but doesn't import the module where `studioSdk.init()` runs (`lib/studio.ts` in the standard install). `<StudioComponent>` requires init to have happened by mount time. Init only runs when the module is imported. | + +Fix, in order: (1) a route in the app matches the template's URL, (2) that route mounts `<StudioComponent>` (directly or via a wrapper that does), (3) that route's module tree imports the module that runs `studioSdk.init()`. All three must hold before authoring a Connected template. + +## Task + +1. **Confirm prerequisites.** Bail with a clear message if any of these are missing: + - `@contentstack/studio-react` is in `package.json` dependencies. + - `studioSdk.init(...)` runs at the app shell (created by `install-studio`). + - Components are registered before Studio mounts (project-specific `registerComponents()` call). + - Env vars are set: stack API key, delivery token, preview token, environment, region, branch (using the framework's prefix: `VITE_`, `NEXT_PUBLIC_`, etc.). + - **The environment base URL matches the served canvas-app's scheme.** Studio iframes connected-template previews at the env base URL (the `urls[].url` value from `GET /v3/environments/<env>`), NOT at `canvasUrl`. If the env is `http://localhost:5173` but your dev server is HTTPS-only (mkcert), the iframe gets nothing back and Studio shows "SDK Not Initialized" on connected templates while sections still render fine. **Fix:** `PUT /v3/environments/<env>` so `urls[].url` matches the canvas-app's scheme (HTTPS for local mkcert dev). + - **One env Base URL serves all Studio projects on the stack.** The env URL is a **stack-level** resource. Multiple Studio projects on one stack share the single `urls[].url` per locale. If you're running two or more Studio projects against different local apps (e.g. project A on `:5192`, project B on `:3000`), they CANNOT both preview at once on the same environment. Pick separate environments per project (recommended: create `<project>_preview` for each) or separate stacks. See [`configure-studio`](configure-studio.md) § Common pitfalls for the full rationale + three options. + +2. **COMPULSORY: the `/canvas` route must exist. Create it if it doesn't. Never skip, never bail, never leave it for later.** + + This runs in **both** route modes and applies to **every** project, migration or greenfield. A Studio project without a canvas route cannot author Sections at all, and in wildcard mode its absence actively breaks things (below). Detect: + + ```bash + # Search by CONTENT, not by guessed path — the mount is what matters. + grep -rln "StudioCanvas" app src pages 2>/dev/null + # Then locate the route file, wherever it lives: + find app src pages -path "*canvas*" \( -name "page.*" -o -name "*Canvas*" -o -name "canvas.*" \) 2>/dev/null | head + ``` + + **Do not conclude "no canvas route" from a fixed path list.** Real projects put it at `src/app/canvas/`, `app/(studio)/canvas/`, `app/[locale]/canvas/` (locale-prefixed), `pages/canvas.tsx`, or a custom `canvasPath` entirely. **Runtime-verified false negative:** checking only `app/canvas/page.tsx` reported "no canvas route" on a project whose route was at `src/app/[locale]/canvas/page.js`, and acting on that would have created a duplicate. The `grep` for `StudioCanvas` is authoritative. The `find` only tells you where it is. + + | Result | Action | + |---|---| + | Route found + Studio's Canvas URL set to its path | Continue to step 3. | + | Route found, Canvas URL not set in Studio | Run [`setup-section-preview`](setup-section-preview.md) step 4. The file alone is half the wiring. | + | **Nothing found** | **Run [`setup-section-preview`](setup-section-preview.md) now, in full, then come back.** Do not emit any template route first. | + + `setup-section-preview` owns this route and everything around it: framework-correct file path, the client-only `dynamic({ ssr: false })` wrapper, chrome isolation, iframe-allowed headers, and the Canvas URL setting in Studio. Don't hand-roll a `/canvas` file here. Delegate. + + **Why this is compulsory in wildcard mode specifically.** A catch-all matches `/canvas` like any other URL. With no canvas route registered, `<Route path="*">` / `app/[[...slug]]/page.tsx` claims it, Studio's section iframe loads the catch-all, `fetchCompositionData({ url: "/canvas" })` finds no composition, and the author gets a blank frame, or (once step 5a's 404 handling lands) **your 404 page rendered inside Studio's section-authoring canvas**. Every Section in the project looks broken, and no error message points at routing. In dedicated mode nothing claims `/canvas`, so the shadowing failure doesn't occur. The route is still mandatory, just for the ordinary reason that Section authoring needs it. + +3. **Ask which route mode: wildcard or single-page. Do not assume.** + + **This is a required gate on any app that already has pages.** Detect first, then ask, never wire a catch-all into an existing app without the user picking it, because the catch-all takes over every unmatched URL including the app's own 404. + + ```bash + # How many pages does the app already own? Cover src/app and every extension — + # Next projects use .js/.jsx as often as .tsx, and route groups / [locale] + # segments mean the depth is unpredictable. + find app src/app pages src/pages src/routes \ + \( -name 'page.*' -o -name 'index.*' -o -name '*Route.*' \) 2>/dev/null | head -30 + grep -rn "<Route" src app 2>/dev/null | head -20 + ``` + + **A zero result here does NOT mean greenfield.** Verify before believing it: `ls src/app app pages 2>/dev/null`. Treating a false negative as greenfield skips the ask entirely and wires a catch-all into an app full of pages, which is the exact outcome this gate exists to prevent. + + Ask verbatim: + + > How should Studio be wired into your routing? + > + > **A. Wildcard (catch-all): RECOMMENDED.** One route matches every URL your code does not already claim. Your existing routes are untouched, because a more specific route wins over the catch-all. Studio resolves the matching template at render time via `sdk.fetchCompositionData({ url, searchQuery })`. You never register a template URL again, and new templates go live without a code change. Because it owns every unmatched URL, I'll also wire your site chrome (header/footer) and keep your existing 404 page (step 5a). + > + > **B. Single-page / dedicated routes: when you want pattern-level control.** Only the URL patterns you name mount `<StudioComponent />` (e.g. `/blog/*` through Studio, `/products/*` stays hand-coded). Nothing else can reach Studio, not even a new URL an author creates. You add a route per pattern as you migrate. + > + > Which one? + + **Default when the user has no preference:** **A**, on greenfield and on existing apps alike. On an existing app, A with `chromeOwner = code` and the existing 404 is the minimal conversion ([`convert-project-to-studio`](convert-project-to-studio.md)): code routes keep winning, Studio serves only the URLs nobody claimed. Take **B** when the user wants explicit control over which URL patterns may ever reach Studio, or a routing constraint (per-route middleware, auth) rules out a catch-all. B still consolidates to A later (see [`migrate-page-to-studio`](migrate-page-to-studio.md)). + + **If A (wildcard):** collect opt-outs, then run steps 4, 5, **5a (chrome + 404: mandatory)**, 6, 7. + + > Are there any paths that should NOT go through Studio? Examples: `/api/*` (API routes), `/admin/*` (admin pages), or any custom non-Studio pages. + > + > Leave empty (default) to keep the catch-all unrestricted. RECOMMENDED. + + Register any opt-outs ahead of the catch-all (step 5). + + **If B (single-page / dedicated):** collect the URL patterns to route through Studio. Run step 4, then register ONE route per pattern mounting the same shared component, skip the catch-all in step 5 and skip step 5a entirely (existing chrome and 404 keep working untouched. Nothing is being taken over). Opt-outs are meaningless in this mode, only the patterns you register go through Studio. Everything in steps 6 and 7 still applies. + +4. **Create one shared route component.** Do NOT generate a separate page per template. Every template route uses identical wiring. One generic component handles all of them. + + **React Router (Vite/CRA) variant**: emit at the path the user chose (default `src/routes/LinkedRoute.tsx`): + + ```tsx + import { useLocation } from "react-router-dom"; + import { StudioComponent, useCompositionData } from "@contentstack/studio-react"; + + export function LinkedRoute() { + const { pathname } = useLocation(); + const { specOptions, isLoading, error } = useCompositionData({ url: pathname }); + if (isLoading) return <div>Loading…</div>; + if (error) return <div>No composition for {pathname}</div>; // wildcard mode: replace with the classified 404 branch in step 5a (iii) + if (!specOptions?.spec) return <div>Composition found but empty.</div>; + return <StudioComponent specOptions={specOptions} />; + } + ``` + + **Next.js App Router variant (SSR (recommended for SEO sites)**) fetch in a Server Component, branch in a `"use client"` wrapper. The CSR `useCompositionData` snippet further below is for **SPAs without SEO needs only**. App Router apps that care about SEO must use this server-side path. + + ```tsx + // app/[[...slug]]/page.tsx — Server Component + import { sdk } from "@/lib/contentstack"; + import { StudioRender } from "./StudioRender"; + + export default async function Page({ params, searchParams }) { + // Same fetch for visitor + in-Studio iframe — StudioComponent picks the + // right render path internally. Forward the full request searchQuery so + // Studio's iframe overrides (locale / variant / preview entry) reach the SDK. + const { slug } = await params; // a Promise on Next 15+ + const url = "/" + (slug ?? []).join("/"); + const searchQuery = new URLSearchParams((await searchParams) as Record<string, string>).toString(); + // Unwrapped for clarity — this call THROWS when no composition matches, so + // shipping it as-is returns 500 on every unclaimed URL. Step 5a (iii) has + // the production form using resolveComposition. + const specOptions = await sdk.fetchCompositionData({ url, searchQuery }); + return <StudioRender specOptions={specOptions} />; + } + ``` + + ```tsx + // app/[[...slug]]/StudioRender.tsx — Client Component + "use client"; + import { StudioComponent } from "@contentstack/studio-react"; + + export function StudioRender({ specOptions, editorMode = false }: { specOptions?: any; editorMode?: boolean }) { + // Missing options on a VISITOR render is a real bug — fail loudly rather than + // serving an empty 200. Inside Studio's iframe the same state is legitimate: + // an unsaved template has no published spec yet, so the editor path passes + // `editorMode` and the guard stands down. Throwing there would turn the + // editor exemption below (§ 5a) into a render-time error on every unsaved + // template — the opposite of what that path exists to do. + if (!editorMode && !specOptions?.fetchOptions) throw new Error("Studio render options are missing; verify editor bootstrap for the installed SDK."); + // StudioComponent handles iframe context internally — same component on + // visitor routes and inside Studio's preview iframe. Mount it even with no + // spec: a bare `<div />` returns 200 without starting the editor bridge. + // Confirm the no-spec form against the installed SDK (§ Editor-mode + // detection) instead of fabricating options. + return <StudioComponent specOptions={specOptions} />; + } + ``` + + **App Router CSR variant (SPAs only (no SEO requirement)**) emit at `app/_components/LinkedRoute.tsx`, marked `"use client"`. Skip this for any visitor-facing site. The SSR pattern above is the default: + + ```tsx + "use client"; + import { usePathname, useSearchParams } from "next/navigation"; + import { StudioComponent, useCompositionData } from "@contentstack/studio-react"; + + export function LinkedRoute() { + const pathname = usePathname(); + const searchParams = useSearchParams(); + return <VisitorComposition url={pathname} searchParams={searchParams} />; + } + + function VisitorComposition({ url, searchParams }: { + url: string; + searchParams: URLSearchParams; + }) { + // Pass `searchQuery` as the raw URLSearchParams OBJECT — NOT a stringified + // form and NOT omitted. Studio's editor-mode detection reads editor params + // (see § Editor-mode detection below) directly off this object. The 1.5.x + // .d.ts type says `URLSearchParamsString` but the object is what actually + // works; passing the string form or omitting the arg breaks editing of + // unsaved compositions. + // + // `extendQuery` merges into the CDA fetch. Keeping locale-only avoids + // hydration mismatches from server-included fields. + const { specOptions, isLoading, error } = useCompositionData({ + url, + searchQuery: searchParams, + extendQuery: { blog_post: { only: ["locale"] } }, // one entry per connected CT + }); + if (isLoading) return <div>Loading…</div>; + if (error) return <div />; // empty div, NOT null + if (!specOptions?.spec) return <div />; // canvas host needs a mount target + return <StudioComponent specOptions={specOptions} />; + } + ``` + + ### Why the `searchQuery` object + empty `<div>` matter + + - **`searchQuery` = raw URLSearchParams object.** When Studio's iframe loads the template URL, it appends editor-mode params. The SDK reads them off `searchQuery` to switch from "render deployed composition" to "render unsaved working spec." Omit `searchQuery` and the SDK falls through to the deployed path, which returns `null` on a not-yet-saved composition, so you get **"Template Did Not Load."** + - **Return an empty `<div />` (not `null`) when there's no spec.** Studio's canvas host queries the mount target after route render. If there's nothing in the DOM it can't drive it, and Studio shows "SDK Not Initialized" or a blank error. An empty div gives it something to attach to. **In wildcard mode this applies to the editor iframe only**, for real visitors the no-spec branch must render the 404 page instead, or the site never 404s. Step 4a (iii) has the gated version. Use that one. + - **`extendQuery` per connected CT.** Present in the working `cs-website-studio` reference. Absent in older docs snippets. Restrict to `only: ["locale"]` to avoid over-fetching. + +5. **Register the catch-all route: pick `[...slug]` vs `[[...slug]]` based on existing app pages.** + + #### Pre-step: detect existing root + specific routes (Next.js only) + + Before emitting the catch-all file, grep for existing routes so Next.js's root-collision rule (cited from [nextjs.org/docs/app/api-reference/file-conventions/dynamic-routes](https://nextjs.org/docs/app/api-reference/file-conventions/dynamic-routes): "with optional, the route without the parameter is also matched") is handled correctly: + + ```bash + # Does a ROOT page already exist? Any extension, app/ or src/app/, and note that + # a locale-prefixed app's root lives at app/[locale]/page.* — which still owns "/". + find app src/app -maxdepth 2 -name 'page.*' 2>/dev/null + ls pages/index.* 2>/dev/null + + # What other specific routes exist? (informational — they win by Next precedence) + find app src/app pages -name 'page.*' 2>/dev/null | head -20 + ``` + + **Getting this wrong causes the exact collision the rule below prevents.** `ls app/page.tsx` returns nothing on a project whose root is `src/app/[locale]/page.js`, you conclude greenfield, emit the optional `[[...slug]]`, and it collides with the root page that was there all along. **Runtime-verified false negative.** If the `find` shows any root-level `page.*`, treat the app as having a root page. + + **Branching rule:** + + | Signal | Catch-all file to emit | + |---|---| + | `app/page.tsx` (or `pages/index.tsx`) **exists** | `app/[...slug]/page.tsx`: **non-optional** catch-all. Does NOT match `/`. The existing root page keeps owning `/`. Coexists cleanly. | + | No root page found, so greenfield-style | `app/[[...slug]]/page.tsx`: **optional** catch-all. Matches `/` AND all nested URLs. | + | **Locale-prefixed app** (`app/[locale]/page.*` owns `/en`, `/fr`) | `app/[locale]/[...slug]/page.tsx`, **inside** the segment. Strip the prefix before building `url`, pass the mapped stack locale as `{ locale }`, forward `searchQuery` untouched. See [`convert-project-to-studio`](convert-project-to-studio.md) § Step 3 for the map and the Base URL rule. | + + Tell the user verbatim what you found: + + > Detected `app/page.tsx` (or whichever). Using **non-optional** `[...slug]` so it doesn't collide with your root page. Existing specific routes win by Next.js's standard precedence (more-specific route beats catch-all). + + #### Emit the route files + + **The canvas route must exist — its position does not matter.** React Router v6+ ranks routes by specificity, so `/canvas` beats `path="*"` wherever it sits in the table; Next.js does the same by its own precedence rules. (This repo's fixture runs `react-router-dom@7`.) What actually breaks is a **missing** route: with no `/canvas`, the catch-all owns it and Studio's section iframe renders a composition lookup (blank frame, or your 404) instead of `<StudioCanvas />`. Confirm it's there (step 1). + + **React Router**: in `src/main.tsx` (or wherever the route table lives), register the canvas route, any opt-outs, and the catch-all — order is not significant: + + ```tsx + <Routes> + {/* Section authoring. Must EXIST (position is irrelevant — routes are + ranked); without it `path="*"` claims /canvas and Studio's section + iframe renders a composition lookup instead of <StudioCanvas />. */} + <Route path="/canvas" element={<CanvasRoute />} /> + + {/* Opt-outs (only if user provided them; otherwise omit this block) */} + <Route path="/api/*" element={<NotFoundOrApiHandler />} /> + <Route path="/admin/*" element={<AdminApp />} /> + + {/* DEFAULT — catch-all: every other URL flows through Studio */} + <Route path="*" element={<LinkedRoute />} /> + </Routes> + ``` + + **Next.js App Router**: based on the detection above, create ONE catch-all file. Left = existing root page (use `[...slug]`). Right = greenfield (use `[[...slug]]`): + + ![Next.js App Router catch-all layouts: existing root page uses [...slug]. Greenfield uses [[...slug]]](../../docs/assets/diagrams/template-route-layouts-nextjs.png) + + The catch-all itself must 404 unclaimed paths, but **only for real visitors**. See step 5a for the editor-mode gate. A bare `if (!specOptions?.spec) notFound();` 404s unsaved templates inside Studio's own iframe. + + Do NOT create `app/blog/[slug]/page.tsx`, `app/products/[sku]/page.tsx`, etc. The catch-all handles all of them. Studio's URL-pattern matching runs inside `sdk.fetchCompositionData({ url, searchQuery })`. The router doesn't need to know about each template. + +5a. **Wildcard mode ONLY: preserve site chrome and 404. Mandatory. Do not skip.** + + A catch-all takes over every URL that isn't explicitly claimed. Two things silently disappear when it lands in an existing app: **the header/footer that used to be rendered by the pages it replaced**, and **the app's own 404 page** (the catch-all now matches the URLs that used to fall through to it). Neither throws an error. The site just renders bare compositions on every route and never 404s. + + #### (i) Decide who owns the chrome: code or Studio + + Ask verbatim: + + > Your header and footer: should they stay in code, or become Studio Sections that authors can edit? + > + > **Code-owned: RECOMMENDED when migrating an existing app.** Header/footer stay in your layout and wrap every Studio-rendered page. Zero authoring risk, nothing to rebuild. Authors edit page bodies only. + > + > **Studio-owned.** Header/footer become Sections placed on each template. Authors edit everything, including nav. Requires building those Sections first, and the code layout must stop rendering them or you'll get two of each. + + | Owner | Layout renders chrome? | Templates contain Header/Footer sections? | Cost | + |---|---|---|---| + | **Code** (default) | Yes | No | None, chrome keeps working as-is | + | **Studio** | No, strip it | Yes, on every template | Build Header + Footer Sections first ([`build-section`](build-section.md)). Place them on each template | + + **Both owners at once produce a duplicated header and footer on every page.** Pick exactly one owner. If the user wants Studio-owned, do NOT strip the layout chrome until the Sections exist and are placed, otherwise the site ships headerless. + + #### (ii) Wire code-owned chrome so the catch-all inherits it + + First, find out whether the chrome is in a layout or repeated per page: + + ```bash + # Next.js — is chrome in the root layout, or in individual pages? + grep -n "Header\|Footer\|Nav" app/layout.tsx 2>/dev/null + grep -rln "Header\|Footer" app/**/page.tsx src/pages/ 2>/dev/null | head + # React Router — is chrome around <Routes>, or inside each page component? + grep -n "Header\|Footer\|Outlet" src/App.tsx src/main.tsx 2>/dev/null + ``` + + | What you find | What to do | + |---|---| + | **Next.js, chrome in `app/layout.tsx`** | Nothing. The catch-all is nested under the root layout and inherits it automatically. Verify visually in step 7. | + | **Next.js, chrome imported per `page.tsx`** | Lift it into `app/layout.tsx` (or a route-group layout), remove it from the individual pages. The catch-all replaces those pages. Per-page chrome dies with them. | + | **React Router, chrome wraps `<Routes>` in `App.tsx`** | Nothing. Every route including the catch-all renders inside it. | + | **React Router, chrome inside each page component** | Add a layout route (below) and nest the catch-all inside it. | + + React Router layout route, note `/canvas` stays **outside** it: + + ```tsx + function SiteChrome() { + return ( + <> + <Header /> + <Outlet /> {/* Studio-rendered page goes here */} + <Footer /> + </> + ); + } + + <Routes> + {/* /canvas must NOT be wrapped in site chrome — Studio's section-authoring + iframe would render your header + footer around every section. */} + <Route path="/canvas" element={<CanvasRoute />} /> + + <Route element={<SiteChrome />}> + <Route path="*" element={<LinkedRoute />} /> + </Route> + </Routes> + ``` + + **`/canvas` outside the chrome is not cosmetic.** `<StudioCanvas />` renders one Section at a time inside Studio's authoring iframe. Wrapped in site chrome, every section is authored inside a full page header and footer. The section's own spacing, sticky behavior, and viewport measurements all read wrong. Same rule for Next.js: if `/canvas` sits under a layout that renders chrome, move it into a route group with a bare layout. + + #### (iii) Wire the 404, and exempt Studio's editor iframe + + The catch-all now owns unmatched URLs, so "no composition for this URL" is the site's 404 path. **Reuse the project's existing not-found page** (`app/not-found.tsx`, `pages/404.tsx`, the app's `NotFound` component, or the per-locale one under `app/[locale]/`). Create a new file only when none exists. **But the same no-spec condition happens legitimately inside Studio's iframe** for a template that hasn't been saved yet. 404 unconditionally and every unsaved template shows your 404 page in the canvas ("Template Did Not Load"). Gate on editor mode: reuse `isStudioEditorMode` from § Editor-mode detection below. + + **`fetchCompositionData` throws on a miss. It does not resolve with an empty spec.** A guard like `if (!specOptions?.spec) notFound()` never runs on the path it was written for. The `await` rejects first and the visitor gets a **500 instead of your 404 page**. Use the canonical [`resolveComposition`](configure-csr-vs-ssr.md#resolve-composition-helper) wrapper (it classifies known not-found ids as 404 and rethrows real failures), don't hand-roll a second one, and never blanket-catch. + + **Next.js App Router**: 404 for visitors, empty mount target for the editor: + + ```tsx + // app/[...slug]/page.tsx (or [[...slug]]) + import { notFound } from "next/navigation"; + import { sdk } from "@/lib/contentstack"; + import { resolveComposition } from "@/lib/resolve-composition"; + import { StudioRender } from "./StudioRender"; + + export default async function Page({ params, searchParams }) { + const { slug } = await params; + const url = "/" + (slug ?? []).join("/"); + const query = new URLSearchParams((await searchParams) as Record<string, string>); + + // Throws on CDA/token/network failure (correctly → 500); returns notFound + // only for the known "no composition here" error ids. + const { specOptions, notFound: miss } = await resolveComposition(sdk, { + url, + searchQuery: query.toString(), + }); + + if (miss) { + // In Studio's iframe an unsaved template legitimately has no published spec — + // 404ing here is what produces "Template Did Not Load" in the canvas. + // A null spec (not undefined: that crashes) asks Studio for the draft. + if (isStudioEditorMode(query)) return <StudioRender editorMode specOptions={{ + spec: null, hasSpec: false, hasTemplate: false, seo: null, + fetchOptions: { config: sdk.getConfig(), compositionQuery: { url }, + searchQuery: query.toString(), options: {}, mode: "edit" } }} />; + notFound(); // real visitor → app/not-found.tsx + } + return <StudioRender specOptions={specOptions} />; + } + ``` + + **Test the 404 with two URLs, not one.** `/definitely-not-a-page` fails at pattern match (`NO_COMPOSITION_FOR_URL`). A bad slug under a connected template (`/blog/zzz-not-an-entry`) matches the pattern and fails later at entry lookup (`URL_MATCHED_NO_ENTRY`). `isCompositionNotFound` covers both; a hand-rolled list that knows only the first still 500s on every mistyped slug. Full taxonomy: [`troubleshoot-composition-resolution` § Runtime error taxonomy](troubleshoot-composition-resolution.md). + + If `app/not-found.tsx` doesn't exist, create one: `notFound()` falls back to Next's unstyled default otherwise, which won't have the site chrome: + + ```tsx + // app/not-found.tsx + export default function NotFound() { + return ( + <main> + <h1>Page not found</h1> + <p>The page you’re looking for doesn’t exist.</p> + <a href="/">Back to home</a> + </main> + ); + } + ``` + + **React Router**: the catch-all and the old 404 route are BOTH `path="*"`. The first match wins, so registering the Studio catch-all silently retires the app's `<NotFound />`. Fold it into the no-spec branch instead of leaving it as a dead route: + + ```tsx + import NotFound from "./pages/NotFound"; // the app's EXISTING 404 page — reuse it + import { isCompositionNotFound } from "@contentstack/studio-client"; + + export function LinkedRoute() { + const { pathname, search } = useLocation(); + const searchParams = new URLSearchParams(search); + const { specOptions, isLoading, error } = useCompositionData({ + url: pathname, + searchQuery: searchParams, + }); + + if (isLoading) return <div>Loading…</div>; + + // The hook stores the SDK's rejection verbatim in `error`, so a not-found + // and a CDA outage arrive on the same branch. Ask the SDK which it is. + const isMiss = !error || isCompositionNotFound(error); + if (error && !isMiss) throw error; // real failure — don't turn it into a 404 + + if (error || !specOptions?.spec) { + // Editor iframe, unsaved template: null-spec options as in App Router (not 404, not <div />). + if (isStudioEditorMode(searchParams)) return <StudioComponent specOptions={{ spec: null, + fetchOptions: { compositionQuery: { url: pathname }, searchQuery: searchParams.toString(), options: {}, mode: "edit" } } as any} />; + return <NotFound />; + } + return <StudioComponent specOptions={specOptions} />; + } + ``` + + Delete the now-unreachable `<Route path="*" element={<NotFound />} />`. It can never match once the Studio catch-all is registered. + + **Optional: a Studio-authored 404.** If authors should own the 404 page too, build a Freeform template at `/404` ([`build-freeform-template`](build-freeform-template.md)) and, in the no-spec visitor branch, fetch that composition instead of rendering the hard-coded page. Keep the hard-coded fallback for when the `/404` composition itself fails to resolve, never leave the branch with nothing to render. + + #### (iv) Tell the user what changed + + > Wildcard route wired. Also changed: + > - Header/footer: **\<code-owned via `app/layout.tsx` / `SiteChrome` layout route\>**, every Studio page renders inside them. + > - `/canvas` deliberately excluded from site chrome so section authoring isn't wrapped in your header and footer. + > - 404: unmatched URLs now render **\<`app/not-found.tsx` / your existing `NotFound` page\>**. Studio's editor iframe is exempt, so unsaved templates still preview. + +6. **Verify Canvas URL in Studio.** In Studio, open Project Settings. Canvas URL should be the **path** of the canvas route (`/canvas` in dev), NOT a template route and NOT a full origin. Studio prepends the origin itself (the targeted environment's per-locale Base URL), so the composed iframe address resolves to e.g. `http://localhost:5173/canvas`. Studio reaches template routes by rewriting the iframe URL to the template's pattern. The SDK handles iframe context internally. Your code only mounts `<StudioComponent />` and the rest happens behind the scenes. (See `understand-canvas-url` for the full concept.) + +7. **Smoke-test both code paths.** + 1. `npm run dev` (or equivalent): app boots, no console errors. + 1b. **`/canvas` first.** Open `http://localhost:<port>/canvas`. `<StudioCanvas />` renders. "No composition for /canvas", a blank frame, or the 404 page means the catch-all is claiming it (see step 2). Check this before anything else. Every Section in the project depends on it. + 2. Visit `http://localhost:5173/blog/<published-entry-slug>` directly. The published composition should render. If it doesn't: either the template isn't deployed, no entry with that slug exists, or the URL pattern in Studio doesn't match `/blog/:slug`. + 3. In Studio, open a template, click Preview Entry, and pick a real entry. Studio loads the visitor URL inside its iframe. The canvas should render the template bound to that entry's data. `<StudioComponent />` detects iframe context internally and shows the live in-canvas runtime. + 4. Edit the template in Studio (drop a component) WITHOUT saving. The canvas should update live, proving the renderer routed through the in-iframe live path (CDA has nothing yet). + + **Wildcard mode: three more checks. A pass on 1-4 says nothing about these. On a localized app repeat 5 to 7 once per locale, at each locale's Base URL:** + 5. **Chrome.** The rendered template URL shows the site header and footer, exactly once each. + 6. **404: two URLs, both must return 404 and neither may 500.** (a) `http://localhost:5173/definitely-not-a-page`, fails at pattern match. (b) A valid prefix with a bad slug under a connected template, e.g. `/blog/zzz-not-an-entry`, matches the pattern and fails later at entry lookup, with a different error id. A 500 on either means the fetch isn't wrapped ([`resolveComposition`](configure-csr-vs-ssr.md#resolve-composition-helper)). A blank page or empty div means the no-spec branch never got the 404 wiring from step 5a (iii). + 7. **Editor exemption.** Create a template and open it in Studio WITHOUT saving. The canvas renders it. If the 404 page appears there instead, the `isStudioEditorMode` gate is missing or reading the wrong params. + +8. **Print next steps verbatim:** + ``` + Template preview routes wired. + + You should now be able to: + - Visit any template URL directly in a browser and see the published composition. + - Open a template in Studio with Preview Entry and see live, unsaved edits. + + Common gotchas if something looks off: + - Studio's project-level Canvas URL must point at /canvas, NOT a template route. + - Templates with URL pattern /blog/{{entry.url}} only resolve for entries with a non-empty `url` field. + - For iteration bindings inside a template, bind to repeater.<field> — the visitor render uses the same spec. + ``` + +## Inputs needed from the user + +1. `routeMode`: **`wildcard`** (one catch-all owns every URL) or **`dedicated`** (only named patterns go through Studio). **Always ask on an existing app** (step 3). Default `wildcard` for greenfield, `dedicated` for an app with existing hand-coded pages. +2. `studioPatterns`: dedicated mode only. The URL patterns to route through Studio (e.g. `/blog/*`, `/landing/*`). One route each. Everything else untouched. +3. `chromeOwner` (wildcard mode only. **`code`** (default) header/footer stay in the layout) or **`studio`** (header/footer become Sections on each template). Never both. See step 5a (i). +4. `optOutPaths`: wildcard mode only. Comma-separated route patterns that should NOT go through Studio. **Leave empty (default) to keep the catch-all unrestricted. RECOMMENDED.** Provide only for explicit non-Studio routes (`/api/*`, `/admin/*`). +5. `sharedRoutePath`: where the shared `LinkedRoute` component should live. Default `src/routes/LinkedRoute.tsx` for Vite, `app/_components/LinkedRoute.tsx` for Next.js App Router. + +## Acceptance + +This skill succeeds only when ALL of the following are true. If any fails, do not claim success. Surface the failure and stop. + +- [ ] **The user was asked wildcard vs dedicated and picked one** (step 3). On an app with existing pages, a catch-all was NOT wired without that choice. +- [ ] **A `/canvas` route mounting `<StudioCanvas />` exists**, created by `setup-section-preview` if it was missing, not skipped and not bailed on. Studio's project-level Canvas URL matches its path. +- [ ] **`/canvas` is NOT shadowed by the catch-all.** Load `http://localhost:<port>/canvas` directly: `<StudioCanvas />` renders. If it shows "No composition for /canvas", a blank frame, or the 404 page, the catch-all is claiming it. React Router: move the canvas `<Route>` above `path="*"`. Next.js: the static canvas page file is missing. +- [ ] `LinkedRoute` exists at the chosen path and mounts `<StudioComponent />` for visitor URLs (no iframe-detection branching). + +**Wildcard mode adds these, all mandatory:** + +- [ ] **Site chrome survives.** Load any Studio-rendered URL: the header and footer that the app rendered before are still there. Chrome lives in exactly ONE place: the code layout OR Studio Sections on the template, never both (no duplicated header/footer anywhere). +- [ ] **`/canvas` renders WITHOUT site chrome.** Open a Section in Studio: no site header or footer around it. +- [ ] **Unmatched URLs 404: verified on BOTH shapes.** `/definitely-not-a-page` (pattern-match miss) **and** a bad slug under a connected template like `/blog/zzz-not-an-entry` (entry-lookup miss) each render the app's 404 page. **Neither returns 500**. A 500 means `fetchCompositionData` is unwrapped and its rejection is escaping. Not a blank page, not an empty div, not a Studio error. +- [ ] **The editor iframe is exempt from the 404.** Create a template, do NOT save it, open it in Studio: the canvas renders live edits. If it shows the 404 page or "Template Did Not Load", the editor-mode gate in step 5a (iii) is missing or checking the wrong params. +- [ ] React Router only: the old `<Route path="*" element={<NotFound />} />` was removed (unreachable once the Studio catch-all registers) and `NotFound` is reachable via `LinkedRoute`'s no-spec branch. + +**Dedicated mode:** every pattern in `studioPatterns` mounts `<StudioComponent />`. Every other route, the existing chrome, and the existing 404 page are unchanged (verify by loading one non-Studio page and one bad URL). + +- [ ] **ONE catch-all route** is registered (wildcard mode). For Next.js: `app/[...slug]/page.tsx` (non-optional) WHEN the app has an existing `app/page.tsx`, OR `app/[[...slug]]/page.tsx` (optional) WHEN greenfield with no root page. For React Router: `<Route path="*">` registered LAST. The catch-all points at `LinkedRoute`. +- [ ] If the app has an existing `app/page.tsx`: confirm the chosen catch-all does NOT collide (greenfield-style `[[...slug]]` is wrong here, must be `[...slug]`). +- [ ] If the user provided opt-outs, those routes are registered ahead of the catch-all and bypass Studio correctly. +- [ ] Studio's project-level Canvas URL still points at `/canvas` (not a template route). +- [ ] Direct visit to ANY published template URL renders the matching composition (URL pattern resolved by Studio's CDA query, not by per-route registration). +- [ ] Opening any template in Studio's Preview Entry renders the live canvas (including unsaved edits). +- [ ] Opt-out routes (if any) are NOT served by Studio. +- [ ] `npm run dev` (or equivalent) starts without runtime errors. + +## Editor-mode detection: for custom renderers + +If you're shipping a custom `StudioRenderer` (rather than importing `<StudioComponent />` directly, e.g. because you're doing per-route swaps in a partial-adoption app and want to gate on editor mode explicitly), detect editor mode by checking Studio's iframe search params: + +```ts +function isStudioEditorMode(searchParams: URLSearchParams): boolean { + return ( + searchParams.get("cs-composable-studio") === "true" || + searchParams.has("cs-composable-uid") || + searchParams.has("hash") + ); +} +``` + +**If you copied a `StudioRenderer` from an internal reference app** and editor mode never activates (Studio always shows "Template Did Not Load" for unsaved templates), the reference is likely gated on a different param set (`content_type_uid` / `entry_uid` / `live_preview`) than the public SDK's iframe sends. Replace the check with the one above. Always verify against `document.location.search` inside the live Studio iframe (`window.parent === window ? "standalone" : "iframe"`, then log the search params) if you're unsure which scheme applies to the SDK version you installed. + +## Environment Base URL: must be reachable + include locale segment + +Studio previews Connected templates by having its canvas iframe fetch `<env base URL> + <template URL>`. Two things trip this up on real projects: + +1. **Base URL must point at the LOCAL dev server for local authoring.** If you set the preview environment's base URL to a deployed hostname (`https://<slug>.contentstackapps.com`), Studio previews templates against the deployed build, which won't have any newly-added `<StudioComponent>` routes yet. Symptom: "Template Did Not Load" / 404 in the iframe. **Fix:** `PUT /v3/environments/<env>` so `urls[].url` points at `http://localhost:3000` (or the dev port). Re-flip to the deployed URL when you're done authoring locally. + +2. **Locale-prefixed apps: the per-locale base URL carries the app's prefix, and nothing else does.** If your app uses `next-intl` or similar and routes are `/en/*` / `/fr/*`, the env base URL for `en-us` is `http://localhost:3000/en` and for `fr-fr` is `http://localhost:3000/fr`, NOT the bare origin. Otherwise Studio composes `<base URL> + <template URL>` = `/` + `/` = `//` (unresolvable). One env has one base URL per locale. Set every one the app serves, not only the default. The other half of the rule: composition URLs never carry the prefix, and the catch-all strips it from the request path before calling Studio. A doubled `/en/en` means one of those two carried it (see [`troubleshoot-canvas`](troubleshoot-canvas.md)). + +## Locale-prefixed apps: the middleware 307 breaks the iframe + +Distinct from the trailing-slash 308 below, same outcome: **any redirect breaks the preview iframe**. It never lands on the destination, and Studio reports the generic "Template Did Not Load" / blank frame. + +**The shape.** Composition URLs are authored without a locale prefix (`/rewards`), because locale is a Studio context variable (`{{locale}}`), not part of the stored path. Studio then iframes `<env base URL> + <composition url>`. With `next-intl` on `localePrefix: "always"` (the default), a request to `/rewards` has no locale segment, so the middleware **307-redirects** it to `/en/rewards`, and the iframe dies on the redirect. + +**Three fixes: pick one, don't stack them:** + +| Fix | How | Trade-off | +|---|---|---| +| **Locale in the env base URL** (simplest) | Set the environment's per-locale URL to `http://localhost:3000/en` so the composed address is already `/en/rewards` | One base URL per locale per environment, which is exactly the model, but it means one environment previews one locale | +| **Exempt Studio paths from the locale middleware** | Narrow the middleware `matcher` so `/canvas` (and any preview path) bypasses locale rewriting | Keeps one base URL. Needs care that the exemption doesn't leak to visitor routes | +| **`localePrefix: "as-needed"`** | Default locale serves unprefixed, so `/rewards` resolves 200 with no redirect | Changes public URL shape for the whole site, only if that's acceptable independently | + +**Verify with `curl`, not the browser**: a browser follows the redirect and looks fine while the iframe does not: + +```bash +curl -sS -o /dev/null -w "%{http_code} -> %{redirect_url}\n" "http://localhost:3000/rewards" +# 200 (empty redirect_url) = good. 307/308 + a redirect_url = the iframe will fail. +``` + +Run this against the **exact composed address** Studio uses (env base URL + composition url). Any 3xx is a failure, whatever its cause: locale prefix, trailing slash, auth wall, or a rewrite. + +<a id="bare-entry-url-hazard"></a> + +## Bare `{{entry.url}}` catch-all: hazard once a second template exists + +A composition `url` of just `{{entry.url}}` (no literal prefix) matches **every** path. With one template that's convenient. Add a second and the bare pattern usually wins or ties on specificity, so URLs you meant for the new template resolve to the old one, and ties break deterministically-but-arbitrarily by `composable_uid`, so it can look random across machines. + +Give every template at least one literal segment (`/blog/{{entry.url}}`, `/rewards`). If a genuine site-wide fallback is wanted, keep exactly one bare pattern and make every other template more specific. Diagnosis + ranking mechanics: [`troubleshoot-composition-resolution`](troubleshoot-composition-resolution.md) § Symptom matrix (rows on specificity ties and bare placeholders). + +## Trailing-slash 308 breaks the Studio iframe on locale-prefixed apps + +Studio composes the preview URL as `<base URL> + <template URL>`. On locale-prefixed Next apps, `<base URL>` ends `/en` and a template `url = "/"` produces `/en/`, which Next 308-redirects to `/en`. The redirect breaks the iframe: it never lands on the destination. + +**Fix in `next.config.js`:** + +```js +module.exports = { + skipTrailingSlashRedirect: true, // Studio-authored URLs must resolve 200 directly + // ... +}; +``` + +Or normalize the template URL patterns to never end in `/`. This is Next-specific but every locale-prefixed framework has an equivalent behavior. Check the framework's docs if you see the iframe stuck on a redirect. + +## Third-party global scripts on Studio routes surface as runtime overlays + +Analytics / commerce embeds that `throw` on failure show up as scary-but-harmless runtime overlays inside the Studio iframe. Common offenders: Lytics (`throw new Error("Load error!")` in its script.onload), FontAwesome kit (403 when missing), commerce widget script loaders. Studio didn't cause them, but they render on the routes Studio previews and confuse the "is my setup working?" check. + +**Recommended:** audit the app's global `<Script>`s / analytics tags on the routes Studio renders. Third-party loaders that `throw` on failure should be either (a) removed from Studio's routes via a route-group / layout guard, or (b) patched to `console.warn` + `return` instead of `throw`. See [`troubleshoot-canvas`](troubleshoot-canvas.md) § Third-party global-script overlays for the full playbook. + +## Common pitfalls + +| Pitfall | Why it bites | Fix | +| --- | --- | --- | +| Wiring the catch-all without a `/canvas` route in place | `path="*"` / `[[...slug]]` matches `/canvas` too. Studio's section iframe hits the catch-all, `fetchCompositionData({ url: "/canvas" })` returns nothing, and every Section in the project renders as a blank frame, or as your 404 page once step 5a lands. Nothing in the error mentions routing | Create it via `setup-section-preview` **before** emitting the catch-all (step 2, compulsory in both modes). React Router: `/canvas` above `path="*"`. Next.js: the static `app/canvas/page.tsx` (or `app/(studio)/canvas/page.tsx`) file must exist, precedence only helps if there's a file to prefer | +| Wiring a catch-all into an existing app without asking | It silently claims every URL the app didn't explicitly route, including the ones that used to reach the 404 page. The user expected Studio added, not Studio in front of everything | Ask (step 3). Wildcard with code precedence, `chromeOwner = code` and the existing 404 is the minimal-conversion default. Dedicated when the user wants pattern-level control | +| Header/footer vanish on Studio-rendered pages | They were imported per `page.tsx` / per page component, and the catch-all replaced those files | Lift chrome into the layout (Next `app/layout.tsx`) or a React Router layout route wrapping the catch-all, step 5a (ii) | +| Header and footer render twice | Code layout renders them AND the template has Header/Footer Sections on it | One owner only. Strip whichever side isn't the chosen `chromeOwner` | +| Site chrome wraps the `/canvas` section-authoring iframe | `/canvas` was nested inside the chrome layout, so every Section is authored inside a full page header + footer: spacing, sticky positioning, and viewport measurements all read wrong | Register `/canvas` outside the chrome layout (React Router) or in a bare-layout route group (Next.js) | +| Unmatched URLs render blank instead of 404ing | The no-spec branch returns an empty `<div />`, correct for the editor iframe, wrong for visitors, and the catch-all now owns every unmatched URL | Gate on `isStudioEditorMode`: empty div in the editor, 404 page for visitors, step 5a (iii) | +| Unsaved templates show the 404 page inside Studio's canvas ("Template Did Not Load") | The 404 fires unconditionally on no-spec. An unsaved template legitimately has no published spec | Same gate, other direction. Verify `isStudioEditorMode` checks the params the installed SDK actually sends (§ Editor-mode detection) | +| React Router: the app's `<Route path="*" element={<NotFound />} />` left in place | Both it and the Studio catch-all are `path="*"`. First match wins, so one of them is dead code, usually the 404 | Remove the old route. Render `NotFound` from `LinkedRoute`'s visitor no-spec branch | + +## See also + +- `install-studio`: installs the SDKs this skill depends on. +- `setup-section-preview`: adds the `/canvas` section-authoring route. +- Docs: `docs/10-setup/studio-project/template-preview-routes.md`. +- Reference: `docs/90-reference/composition-rendering-reference.md`: every prop + fetcher option `<StudioComponent />` accepts (the routes this skill wires consume the resolved `specOptions`). diff --git a/skills/studio/skills-index.md b/skills/studio/skills-index.md new file mode 100644 index 0000000..f8064c4 --- /dev/null +++ b/skills/studio/skills-index.md @@ -0,0 +1,341 @@ +# Studio skills: index + +The `studio` skill is the only one loaded always. It reads this file to route the rest. + +Two lookups below. The **Trigger table** is exact-match against phrases in the user's message. The **Topic map** is a fallback when no phrase matches but the topic is Studio-shaped. + +**Resolving a skill file: two layouts, try both, in this order:** + +1. `../<name>/SKILL.md`, the **installed** layout (`~/.claude/skills/<name>/SKILL.md`). Claude Code gives every skill its own directory. This is what a customer has. +2. `./<name>.md`, the **repo** layout (`skills/src/<name>.md`, `docs/prompts/<name>.md`). + +Every name below is written `<name>` for readability. Apply this rule to each. Read them from disk. Never rely on remembered summaries. **If neither path resolves, say the pack looks mis-installed and name the skill. Never fall back silently to general knowledge**, because a silent fallback is indistinguishable from the pack working. + +--- + +## Trigger table + +If the user's message contains any phrase in the left column, Read the skill in the right column and follow it. + +> **Mandatory atomic-and-reusable rule: applies to EVERY row below.** Never register or author a component that renders more than one piece of content. Decompose it first (`decompose-jsx-to-atomics`). Check for an existing atomic or Section before creating one. Header / footer / nav / menus are shared Sections with their menus as **Repeaters over a field**, never hardcoded lists. One atomic prop binds one CT field. See the `studio` router § Standing rule. +> +> **Mandatory plan-first rule: applies before every build row below.** If the request is to build/create/implement anything larger than one already-scoped piece ("build me a rewards page", "implement this design", "add this page to Studio", "build the site", "migrate this route"), Read [`plan-studio-architecture`](plan-studio-architecture.md) **first** and get the plan explicitly approved. Only then run the build skills it names. Skipping this is what produces a single monolithic template with no Sections, no Section Slots and no reuse, forcing the user to re-prompt. Straight to a build skill only when the user is iterating on a piece from an already-approved plan. **Exception: a whole-project ask** ("convert my project to Studio", "make it Studio-compatible") routes to [`convert-project-to-studio`](convert-project-to-studio.md) and its scoping gate first. `plan-studio-architecture` runs only if the user picks the one-page or full-site option there. + +> **Mandatory auth preflight: applies before every row below.** Settle the credential before the first API call, OAuth-first per `authenticate-cma`. Never ask for a session `authtoken`. If nothing resolves, hand the user `! CONTENTSTACK_REGION=<code> npx @contentstack/mcp --auth` (interactive, so it cannot be run for them). `403 error_code 316` means wrong org, not missing auth. + +> **Mandatory done-rule: applies after every build row below.** Eight deliverables are mandatory on every build and all eight fail silently: `cslp.appendTags`, `studioAttributes` + `$`-twins per component, `thumbnailUrl` per registration, `ui_preview` per Section, stack Live Preview + preview token, the `/canvas` route, **publishing every Section and Template** (an edit creates an unpublished version. Delivery keeps serving the old one), and **a template actually composed of Sections**. A single component bound to the whole page body renders perfectly and fails no other check, so count `section-composition` nodes before reporting done. Read `complete-the-build`. It is the handover gate, and `plan-studio-architecture` commits to it up front. Never report a build finished with any row outstanding. + +| If the user says… | Read | +|---|---| +| convert my project to Studio, make my project Studio compatible, Studio-compatible project, add Studio to my existing site without touching pages, minimal Studio setup, Studio as a fallback route, works with my locales in Studio | `convert-project-to-studio` | +| add Studio to my app, is my project compatible, before I install | `analyze-project-fit` | +| enable Studio, make this project Studio ready, make this project Studio enable, Studio setup, set up Studio, install Studio in my app | `convert-project-to-studio` | +| install only the Studio SDK, add Studio SDK only, package installation only | `install-studio` | +| install Live Preview, Live Preview standalone, receive edits from Contentstack | `install-live-preview` | +| install Playwright MCP, automate Studio verification, drive Studio in tests | `install-playwright-mcp` | +| install Contentstack MCP, authenticate CMA without pasting authtoken, provision via MCP tools, OAuth for provisioning | `install-contentstack-mcp` | +| which token do I use, OAuth instead of authtoken, 401 error_code 105, 422 Stack not found, authtoken is not valid, management token vs authtoken, do I have to paste a token, we're logged in with OAuth | `authenticate-cma` | +| I need https locally, mixed-content block on localhost, mkcert canvas | `setup-local-https-canvas` | +| configure Studio project, set canvas URL, set Base URL, change environment, Settings Configuration page, canvas config disabled/blank | `configure-studio` | +| enable Live Preview, set up Visual Experience, canvas is blank, preview events not arriving | `enable-visual-experience` | +| provision Studio project, create compositions CT, delivery token setup | `provision-studio-project` | +| seed the stack, create page content types, seed entries | `provision-studio-stack` | +| register breakpoints, tablet/mobile switcher, canvas breakpoints | `register-breakpoints` | +| which components should I register, design our Studio library, what should the component library look like, atomic + layout primitives for Studio, we're starting fresh: what do we register, map our design system to Studio | `design-component-library` | +| is this atom or layout, classify this component, what shape is this, audit my registered components, which of my registrations are compounds, sanity-check my palette, review this component before I register it | `classify-existing-component` | +| match the existing design, build it like the other templates, follow the existing pattern, same as the current sections, what pattern do the existing templates use, how are the existing templates built, don't rebuild it differently | `match-existing-pattern` | +| compose a hero from primitives, build a feature grid in Studio, which primitives for this testimonial, translate this section into a primitive tree, compose a pricing tile | `compose-marketing-section` | +| register component, add my React component to Studio, palette missing my components, no thumbnail on my components, palette shows text placeholder instead of image | `register-component` | +| array prop binding rules, object prop shape, imageurl accepts string or object, link prop leaves, entry.title not resolving | `register-component-prop-contracts` | +| component must be layout-agnostic, null-safe rendering, component breaks when binding is empty | `register-component-render-contracts` | +| cslp tags, dollar props, studioAttributes contract, values not editable in Visual Editor | `register-component-cslp` | +| nothing clickable in Visual Editor, Visual Builder stuck on "Initializing the editor", can't add / reorder / delete list items in Visual Editor, no "+" on an empty list, new entry shows "Your text here" or its fields can't be clicked, image under an overlay can't be selected | `make-visual-editor-editable` | +| component registered but not in palette, registration fails silently, registration pitfalls | `register-component-pitfalls` | +| register JSON RTE, render json_rte, **HTML RTE, rich text field, markdown field, RichText component, rich text renders as raw HTML, RTE shows `<p>` tags as text, rich text renders blank, embedded entries in RTE, embedded assets in RTE, embed a card inside rich text, custom element type in RTE, registerRTERenderer, registerJSONRTE deprecated** | `register-json-rte` | +| BYOC, use my components in Studio, Studio with my design system | `byoc-end-to-end` | +| generate components from Figma, Figma to React, import my design system | `figma-generate-components` | +| import design tokens, use my Tailwind colors in Studio, register CSS variables | `import-design-tokens` | +| set default value, blank on drop, palette preview empty | `wire-component-default-data` | +| show data from X here, external API in composition, use query params, feature flag value | `wire-external-data` | +| render a section with my own data, section per array item, render a composition with my data | `render-with-own-data` | +| pass data into a slot, slot children can't see props, dropped component needs the parent's value | `wire-slot-data` | +| which prop do I use for my own data, data prop vs context, bring your own data | `understand-bring-your-own-data` | +| variant alias, Personalize-driven render, cookie/header variant | `wire-variant-alias` | +| share state between sections, cart count in Studio, use my Redux store, Bring Your Own State, BYOS, stateVariable, getState/setState, action prop, author should pick which function the button calls | `wire-studio-state` | +| set up canvas route, MISSING_CANVAS_URL, where does StudioCanvas mount, canvas iframe is blank in Studio | `setup-section-preview` | +| make every URL go through Studio, wire catch-all template route, template URL 404s, wildcard or single page, header/footer disappeared after adding Studio, duplicate header on Studio pages, my 404 page stopped working | `setup-template-preview-routes` | +| CSR or SSR, Next App Router setup, hydration mismatch, SEO meta missing, Live Preview broken after switching render mode | `configure-csr-vs-ssr` | +| here's the design, here's a Figma link, here's a screenshot, map this design to Studio, decompose this design, how would I build this in Studio, what components do I need for this design | `decompose-design` | +| here are all the page designs, decompose this whole site, multi-page decomposition, site-wide plan, build the marketing site, N templates | `decompose-site` | +| here's the whole Figma board, build every section in this file, all the hero variants, turn this design file into sections, decompose the whole design file | `decompose-site` | +| build the skeleton first, no data yet, connect the data later, structure without bindings, placeholder section | `author-composition-via-api` | +| populate entries from CSV, seed content from JSON, import content from our API, ingest content into this CT, backfill CMS content, import Markdown blog posts | `import-content` | +| add a field to blog_post, rename title to headline, extract hero to a Global Field, change field type, migrate CT schema, evolve content type | `migrate-ct-schema` | +| reference inside a reference is empty, nested reference not resolving, how deep can references go, how many levels of reference does Contentstack support, the data is two references deep | `resolve-reference-depth` | +| what does reference depth count, how deep should I fetch, depth stopped helping, stub count | `understand-reference-depth` | +| error_code 141, stub will not resolve, reference fetch fails, depth pitfalls | `resolve-reference-depth-pitfalls` | +| include_all_depth, error_code 141, include reference paths processing limit, reference chain too deep, deeper reference scan | `resolve-reference-depth` | +| the field is buried four groups deep, reference inside a modular block inside a group, the logo is inside a group inside a reference, group and blocks nested references | `resolve-reference-depth` | +| make this component reusable, break this page into sections, my whole page is one component, decompose this React component, this section is too big, why can't I reuse this, break down my component | `decompose-jsx-to-atomics` | +| which components should be sections, migration inventory | `discover-sections` | +| we have blog_post entries already, propose Sections from my existing CT schema, map existing CT to Sections, adding Studio on top of existing content | `discover-sections-from-ct` | +| one component renders all my blocks, the whole page is one modular blocks field, split this blocks page into Sections, split the sections field into sections, how do I decompose a page that is one array | `decompose-blocks-page` | +| my template has only one Section, the template has a single node, I built a monolith, the page renders but there are no Sections, why is there only one section | `decompose-blocks-page` | +| design schema for my Hero, what fields does this section need, migrate this component to a section | `design-section-from-jsx` | +| build a hero section, create card grid, section has no thumbnail, blank tile in Sections accordion | `build-section` | +| drag and drop does not commit on the canvas, playwright dragTo does nothing in Studio, mousedown mousemove sequence | `drive-studio-canvas-ui` | +| basic field component or my own component, simple vs styled section, which component type for a section | `build-section-component-choice` | +| section renders blank on canvas, section authoring pitfalls, section saved but wrong | `build-section-pitfalls` | +| what does the Contentstack MCP do, is MCP the right tool, MCP scope and limits | `understand-contentstack-mcp` | +| card grid, list of N items, iterate related posts | `build-repeating-section` | +| Connected or Freeform?, what kind of template, should this be a landing page | `choose-connected-vs-freeform` | +| build a blog template, PDP template, connected to a content type | `build-connected-template` | +| build a freeform template, static page in Studio, one-off landing page | `build-freeform-template` | +| page builder, section picker, authors choose the sections, reorder sections per page, modular page, one template many layouts, let marketing add sections | `build-page-builder-template` | +| pin an entry to freeform, additional entry data | `pin-entry-to-freeform` | +| pin a query, iterate latest entries, filtered list on freeform | `pin-query-to-freeform` | +| expose this prop, let authors override the headline, per-instance override | `expose-section-props` | +| repeat per item, list of blocks, iterate references | `use-repeater` | +| modular block repeater, reference repeater, blocks render empty | `use-condition-block` | +| carve a section slot, drop zone in a section, swap point inside a section | `use-section-slot` | +| default section for a slot, pre-fill empty slot, only allow these sections, restrict what drops into a slot | `configure-slot-defaults` | +| embed a Studio band, marketer-editable PDP region, promo strip, one editable section | `embed-composition` | +| build pages in Studio, I just want to compose, no code | `author-without-code` | +| compositions from JSON, API author composition, seed compositions, headless author, bulk import | `author-composition-via-api` | +| compositions REST API, Studio API service, create composition endpoint, list compositions, composition references API, managed composition API | `use-compositions-api` | +| composition entry body, url_metadata pair, what to POST to the CMA | `author-composition-entry` | +| static_value binding by API, static value renders the default, where the literal goes | `author-composition-static-values` | +| binding types list, node anatomy, where bindings live in the ui tree, repeater binding shape | `author-composition-bindings` | +| compress the ui spec, zlib encode composition, preflight validate the ui tree | `author-composition-encoding` | +| repeat a card over a multi-reference, repeat over group-multiple, repeater recipe by API | `author-composition-repeater-recipes` | +| nested repeaters by API, list inside each iterated item, repeaterUID chain | `author-composition-nested-repeaters` | +| author a section by API, selectedField priority, section scoping P22, section-slot node shape | `author-composition-sections` | +| Pattern B by API, fill a section slot by API, wrapper section with a slot | `author-composition-section-slot-fill` | +| exposed section props by API, per-instance override headlessly | `author-composition-exposed-props` | +| section thumbnail missing after API create, API never sets thumbnail | `author-composition-thumbnails` | +| linked_sections by API, author a template headlessly, complete freeform composition body | `author-composition-templates` | +| composition returns 200 but does not resolve, API authoring pitfalls, verify the write | `author-composition-pitfalls` | +| skeleton first then bind, read component source before authoring, free-form primitive | `author-composition-practice` | +| move compositions between stacks, remap uids on migration, composition schema remap | `author-composition-migration` | +| production wrapper needs template-instance swap, per-CT rendering inside a list, adapter for legacy carousel, wrap a production band into a List Section | `adapt-collection-component` (if native array-prop path doesn't fit) | +| visual parity, compare with live URL, drift between composed and hand-coded, screenshot diff, migration QA, make it look exactly like my existing design, keep the same design, the section doesn't match my design, preserve the existing look, match my CSS/styling | `verify-visual-parity` | +| migrate this page to Studio, swap route to StudioComponent | `migrate-page-to-studio` | +| deploy Studio site, deploy to Launch, Contentstack Launch, production env vars, blank page in prod | `deploy-studio-site` | +| upgrade Studio SDK, bump studio-react version, breaking change in Studio | `upgrade-studio-sdk` | +| verify Studio setup, smoke test, is Studio working | `verify-setup` | +| is this finished, anything missed, ready to hand over, why is the palette all blank tiles, no thumbnail on my sections, why can't I click the text to edit it, Visual Builder does nothing, definition of done | `complete-the-build` | +| Studio broken, help debug Studio, canvas blank, composition not rendering, binding wrong | `troubleshoot` | +| canvas won't load, canvas 404, SDK not initialized in canvas, Composition Loading Error, Component Loading Error, all/every component not registered in canvas | `troubleshoot-canvas` | +| cookie banner in canvas, chat widget over the panel, hide analytics in Studio, gate GTM, isStudioCanvas | `gate-third-party-scripts` | +| URL renders the homepage, two templates match the same pattern, /blog/post-1 404s, wildcard stopped matching | `troubleshoot-composition-resolution` | +| binding shows nothing, wrong value rendering, literal {{ | `troubleshoot-data-binding` | +| SSR render failure, use client mismatch, hydration Studio, registry undefined in server | `troubleshoot-ssr-rendering` | +| what's a slot, swap point, drop zone | `understand-section-slots` | +| what's a Section, Section vs Template, reusable page parts | `understand-sections` | +| what's a Template, template vs section, one template N pages | `understand-templates` | +| Canvas URL, /canvas vs full origin | `understand-canvas-url` | +| StudioCanvas vs StudioComponent, when to use which | `understand-canvas-vs-component` | +| what does installing Studio add, three SDKs, how Studio pieces fit | `understand-installation` | +| linked schema, why does a section auto-bind, section data shape | `understand-linked-schemas` | +| auto-binding algorithm, how does the section pick up template data | `understand-auto-binding` | +| headless composition authoring, ui zlib tree, six binding types | `understand-authoring-headlessly` | +| first time with Studio, brand new, zero knowledge | `start-here-zero-knowledge` | +| give me a Studio tour, walk me through Studio | `studio-tour` | +| plan a Studio project, architecture plan, sections vs templates for my app, **build me a page, implement this design in Studio, add this page to Studio, build the site, create a page in Studio** | `plan-studio-architecture` (**first, then the build skills it names**) | + +If several rows match, follow the ordering the sequence implies: `analyze-project-fit` first, then provisioning, install, configure, register, author, verify, and troubleshoot. `enable-visual-experience` gates install. `install-studio` gates everything after. + +--- + +## Topic map + +Grouped by intent. Use when the user's message is Studio-shaped but no trigger phrase matches. Pick the closest by name and Read the file. + +### 1. Start here — route before any state change + +- **start-here-zero-knowledge**: zero-knowledge onboarding orchestrator. +- **studio-tour**: intent-driven walkthrough that routes to specialist skills. +- **analyze-project-fit**: read-only diagnostic of the user's project. Run before any install. +- **convert-project-to-studio**: whole-project orchestrator. Asks minimal / one page / full site (minimal recommended), then wires Studio as a fallback renderer behind the app's existing routes with the app's own 404 kept, every locale included. Nothing migrated or decomposed. +- **plan-studio-architecture**: analyze the starting point (existing code, part-built project, design, or prose), then print a plan for approval: Sections + linked schemas, Section Slots, registered **and** built-in components, CTs, Connected-vs-Freeform per page, build order. **Nothing is built until it's approved.** Run first for any non-trivial build request. +- **match-existing-pattern**: **MANDATORY before authoring a Template or Section in a project that already has compositions.** Inflates every composition's `ui`, derives how the project builds (repeater + condition + sections, section list, single component, atoms), and reports the patterns as layer trees. With one pattern, build that way. With several, the user picks from previews. Prevents the rework of authoring something structurally foreign to what is already there. +- **choose-connected-vs-freeform**: Connected default. Freeform is last resort. Emits the next skill. + +### 2. Install and configure the app + +- **install-studio**: install + wire studio-react + Live Preview + Delivery SDK. +- **install-live-preview**: Live Preview alone, without the full Studio SDK. +- **configure-studio**: set Environment, Language, Canvas URL on the Studio project. +- **enable-visual-experience**: stack-level Live Preview + Visual Experience gate. Do this first. +- **setup-local-https-canvas**: mkcert cert for the canvas app (mixed-content block on localhost). +- **upgrade-studio-sdk**: bump the seven studio-* packages together, read CHANGELOGs, smoke-check. +- **install-playwright-mcp**: optional MCP to automate the click-through verification skills. +- **install-contentstack-mcp**: discover and verify MCP execution first; configure the current host when needed. OAuth login and callable tools are separate checks. Record limitations before using HTTP fallback. + +### 3. Provision the Contentstack side + +- **authenticate-cma**: how every API call is authenticated: the OAuth-first credential ladder (verified from the CLI's own resolver), scope headers, token refresh, and the 401/422 signature table. Read before writing any `curl`. +- **provision-studio-project**: bootstrap the compositions CT + publish + delivery-token via API. +- **provision-studio-stack**: seed page CTs, global fields, entries, assets outside the compositions CT. +- **migrate-ct-schema**: evolve a live CT schema safely (add / rename / retype / remove fields. Extract to Global Field). Risk-classified, dry-run-first, dual-write for high-risk changes, rollback via before-snapshot. +- **import-content**: bulk-ingest entries into CTs from a customer-owned source (JSON / CSV / Markdown / existing Contentstack stack). Idempotent + resumable. Competitor-CMS migrations are out of scope. Use `@contentstack/cli-cm-import` for those. See `docs/50-advanced/agent-idempotency.md`. + +### 4. Components — plan and register + +- **design-component-library**: meta-planning skill. Apply the atom + layout taxonomy from `docs/00-getting-started/composable-primitives.md` to the user's design system BEFORE any registration is written. Emits a decomposition sheet with the 10-primitive proposal and DS token gaps flagged. Run first when a project is starting fresh with a component library or when the user asks "what should I register". +- **classify-existing-component**: diagnostic. Runs Q1 of the four-question framework on **one** component and stops: Atom, Layout, or Compound. Routes to the right next skill (`register-component` for atoms/layouts, `decompose-jsx-to-atomics` for JSX compounds, `decompose-design` for design-input compounds). Use when auditing an existing registry, sanity-checking before registration, or triaging "authors can't rearrange this" reports. +- **decompose-jsx-to-atomics**: break an existing React component (often a whole page in one file) into Layer-1 atomics, Layer-2 containers and Layer-3 Sections so its parts become reusable. Run before `discover-sections` on an existing codebase. The code-input twin of `decompose-design`. +- **register-component**: read a React component, infer prop schema, emit a `registerComponent` call. +- **register-json-rte**: register the unified RTE renderer (`registerRTERenderer`) covering **JSON RTE, HTML RTE and Markdown** in one call, plus embedded entries/assets and custom element types. The skill name says json-rte for history. The API is not JSON-RTE-only. +- **register-breakpoints**: register default/tablet/mobile at SDK boot. +- **import-design-tokens**: Tailwind / CSS vars / JSON tokens into Studio's Design Panel. +- **figma-generate-components**: turn Figma frames into React components + registry entries via the Studio Figma plugin. +- **wire-component-default-data**: per-prop `defaultValue` so a component renders on drop before any binding. + +### 5. Component contracts — read when registering + +- **register-component-prop-contracts**: array and object binding rules, tolerant image and link signatures, `{{entry.*}}` resolution. +- **register-component-render-contracts**: the layout-agnostic rule and the null-safe rendering contract. +- **register-component-cslp**: `$` CSLP props plus the `studioAttributes` contract. Emit both or neither. +- **register-component-pitfalls**: what fails silently when registering, and the fix. + +### 6. Designs to a plan + +- **decompose-design**: take one design (Figma / screenshot / mock) and emit a build sheet + three-layer inventory + proposed CT shape. **THE entry point for greenfield single-page builds starting from a design.** Drives the whole flow end-to-end: the build sheet, then an offer to scaffold code, then an offer to author via CMA API or Playwright MCP, then verification. +- **decompose-site**: multi-page orchestrator. Wraps `decompose-design` per template, deduplicates atomics + Layer-2 components across pages, identifies Global Field candidates, emits a consolidated site plan + per-template build sheets in dependency order. **Use for greenfield multi-page marketing sites**, and for a whole Figma board of section variants: § Step 1b collapses variant frames into `choice` props instead of one Section per frame. +- **decompose-blocks-page**: execute the split when a page body is one modular-blocks field and one component renders every block type: the scope-filter prop, occurrence pinning when a type repeats, rebuilding the root slot from the pre-decomposition version so sibling nodes survive, and the field-name ceiling on Section reuse. The authoring half of `discover-sections-from-ct`. +- **discover-sections**: inventory a codebase for section candidates (components that recur across routes). +- **discover-sections-from-ct**: inventory an EXISTING Content Type for Section candidates. Walks the schema tree (root + Groups + References + Modular Blocks) and proposes one Section per scope with the correct linkedSchemaKind. The counterpart to `discover-sections` for teams starting with populated CMS content rather than existing route files. +- **design-section-from-jsx**: from a JSX component, propose the section's linked-schema shape. + +### 7. Sections + +- **build-section**: link a section to a structural schema (CT / GF / Group / Modular Block / Block / Reference). +- **build-repeating-section**: greenfield List Section: parent + child Section pattern iterating a multi-valued field. +- **compose-marketing-section**: given a design comp and already-registered primitives, pick the primitive tree that composes the section on the canvas. Points at the four canonical recipes (Hero, Feature Grid, Testimonial, Pricing) or drafts a new composition when the shape diverges. +- **adapt-collection-component**: compatibility adapter for wrapping a legacy production wrapper into a Studio List Section (wrapper-with-slot + Repeater + leaf adapter). Reach for this only when the native array-prop path (`build-repeating-section` § array-prop alternative) won't fit: Modular Block polymorphism, template-author child-swap, or non-reducible leaf shapes. +- **expose-section-props**: expose specific component props so template authors override per instance. +- **build-section-component-choice**: basic field components vs your own registered components: the simple-vs-styled tradeoff. +- **build-section-pitfalls**: what fails silently on the canvas when authoring a Section. +- **drive-studio-canvas-ui**: the drag-and-drop event sequence that actually commits on the canvas. Read before automating a drop, from any build skill. + +### 8. Section Slots and smart containers + +- **use-section-slot**: carve a slot into an already-designed section for per-instance swaps. +- **configure-slot-defaults**: give a Section Slot a default section for its empty state, and an allowed-sections whitelist gating what drops in. +- **wire-slot-data**: carry an owning component's data into whatever an author drops into its `slot` prop, bindable under Component Default Data. +- **use-repeater**: Repeater bound to a multi-valued field on a section or template canvas. +- **use-condition-block**: inside a Repeater over modular blocks / references, condition per item shape. + +### 9. Templates + +- **build-connected-template**: content-type-bound template. Every entry renders at a derived URL. +- **build-freeform-template**: static template with Pinned Entries and Pinned Queries. +- **build-page-builder-template**: the author-composes-the-page pattern: a Modular Block of section types, one Section per block, one template with a Repeater + a Condition Block per block. One template renders every layout. +- **pin-entry-to-freeform**: pin one entry to a freeform template as a Pinned Entry. +- **pin-query-to-freeform**: pin a CDA query so a Repeater source iterates dynamic content. + +### 10. Routes and template URLs + +- **setup-section-preview**: mount `<StudioCanvas />` in the app + set Canvas URL on the project. +- **setup-template-preview-routes**: wildcard (catch-all) vs dedicated routes. Wildcard also wires site-chrome preservation + 404 handling. +- **configure-csr-vs-ssr**: choose CSR (`useCompositionData`) vs SSR (`sdk.fetchCompositionData`). + +### 11. Data binding + +- **wire-external-data**: non-Contentstack data via the `data` prop on `<StudioComponent />`. +- **render-with-own-data**: render a composition (page or standalone section) against a `context` object you already hold, via `<StudioComposition />` + spec-only `sdk.fetchComposition`. +- **wire-studio-state**: declared state variables authors can bind, a custom store via `getState`/`setState`/`subscribe`, and named functions wired to buttons through `action` props (BYOS). +- **wire-variant-alias**: Personalize variant alias from cookie/header into `<StudioComponent />`. +- **embed-composition**: embed ONE editable region in a code-owned page via `useCompositionData({ compositionUid })`. +- **resolve-reference-depth**: walk a reference chain one hop at a time by declaring each hop's path, then stop and ask the user for a path, an entry uid or a depth as soon as a path cannot be derived. Why `include_all_depth` measured inert, why `include[]` takes schema paths rather than payload paths, and telling an unresolved stub apart from genuinely empty content. +- **resolve-reference-depth-pitfalls**: `error_code 141` and the pitfall table for walking reference depth. +- **make-visual-editor-editable**: builder mode, list tags (`<field>` and `<field>.<N>`) so items can be added, moved and deleted, the empty-list "+", new entries editable before anyone types (`fetchTemplateEntry`), and overlays that block selection. + +### 12. Author by API — start here and the contract + +- **author-composition-via-api**: write composition JSON directly via the CMA (headless / seed / migration). +- **author-without-code**: end-to-end authoring path for content teams on an already-installed project. +- **use-compositions-api**: create/read/list/update/delete compositions via the managed Studio API service (validated, `ui` compressed for you, your own credential forwarded: OAuth or session). The higher-level counterpart to `author-composition-via-api`. +- **author-composition-practice**: skeleton-first authoring, read component source, free-form primitives. +- **author-composition-entry**: the CMA body and the `url` + `url_metadata` pair. Read first when authoring by API. +- **author-composition-static-values**: how a `static_value` binding's key resolves to the literal in the entry. +- **author-composition-bindings**: every binding type, node anatomy in the `ui` tree, Repeater binding shapes. Read second. +- **author-composition-encoding**: compress the `ui` tree safely and preflight-validate it before publishing. + +### 13. Author by API — recipes and reference + +- **author-composition-repeater-recipes**: repeat a card over a multi-reference or GROUP-MULTIPLE field, with full node trees. +- **author-composition-nested-repeaters**: a list inside each iterated item, and the `repeaterUID` chain that resolves it. +- **author-composition-sections**: Section scoping rules (P22-P25), `selectedField` priority, `section-slot` node shape. +- **author-composition-section-slot-fill**: Pattern B: a wrapper Section's slots filled by another Section, with full node trees. +- **author-composition-exposed-props**: per-instance overrides on a placed Section, by API. +- **author-composition-thumbnails**: why the API never sets a Section thumbnail, and what to do instead. +- **author-composition-templates**: populate `linked_sections` (P27), plus a complete Freeform composition body. +- **author-composition-pitfalls**: verify a `200` actually resolved, and the full API-authoring pitfall table. +- **author-composition-migration**: move compositions between stacks: uid, index and schema remaps. + +### 14. Ship and verify + +- **complete-the-build**: the ten always-mandatory deliverables (CSLP tags, `studioAttributes`, component + section thumbnails, Live Preview, `/canvas`, publishing, a template actually composed of Sections, builder mode, and new entries editable in Visual Editor) and the verification block that proves them. Run at the end of every build, and cite it in the plan at the start. +- **verify-setup**: layered smoke test (Delivery / Live Preview / Visual Editor / Studio). +- **verify-visual-parity**: brownfield migration: drive a browser to compare composed vs production Section-by-Section, classify drift by cause class, fix at the source (adapter / registration / wrapper CSS). +- **deploy-studio-site**: Contentstack Launch project, environments and env vars, composition Deploy, Live Preview gating, region config. +- **migrate-page-to-studio**: final swap of a route from hand-coded JSX to `<StudioComponent />`. +- **gate-third-party-scripts**: keep cookie banners / chat widgets / GTM / pixels out of the canvas via the SDK's editor-mode contract (`isStudioCanvas`). +- **byoc-end-to-end**: orchestrator for the whole BYOC arc (register, section, template, publish, in that order). + +### 15. Troubleshoot — route by symptom + +- **troubleshoot**: symptom router. Classify the failure and hand off to the right specialist. +- **troubleshoot-canvas**: canvas won't load, blank iframe, SDK not initialized. +- **troubleshoot-composition-resolution**: wrong URL renders, template pattern collisions, 404s. +- **troubleshoot-data-binding**: bound fields render nothing / wrong value / literal `{{`. +- **troubleshoot-ssr-rendering**: SSR/RSC failures, `use client` boundary, registry singleton. + +### 16. Concepts — what is X, not how do I X + +- **understand-sections**: a section is a reusable page part. +- **understand-templates**: templates compose sections. One template renders N live pages. +- **understand-section-slots**: designated swap points inside a section. +- **understand-linked-schemas**: the section's declared data shape. Why auto-binding works. +- **understand-auto-binding**: the algorithm matching section schema to template CT. +- **understand-canvas-url**: why it's a relative path, not a full URL. +- **understand-canvas-vs-component**: `<StudioCanvas />` (Studio iframe) vs `<StudioComponent />` (visitor routes). +- **understand-authoring-headlessly**: the entry `ui` zlib tree + `data_sources` shape, six binding types. +- **understand-bring-your-own-data**: pick between the three render-time surfaces: the `data` prop, `<StudioComposition context>`, and `<Slot data>`. +- **understand-installation**: what installing Studio actually adds to a project. +- **understand-contentstack-mcp**: what the Contentstack MCP does and does not do, before you install it. +- **understand-reference-depth**: what depth counts (reference hops, not groups or blocks) and when it is finished. + +## Ordering hints + +Some skills gate others. When multiple are relevant, run in this order: + +``` +enable-visual-experience → provision-studio-{stack,project} → analyze-project-fit + ↓ + convert-project-to-studio (existing app: drives the next four) + ↓ + install-studio + ↓ + configure-studio → register-breakpoints + ↓ + register-component (+ register-json-rte if json_rte fields) + ↓ + setup-section-preview → build-section + ↓ + setup-template-preview-routes → choose-connected-vs-freeform + ↓ + build-{connected,freeform}-template + ↓ + verify-setup → (troubleshoot-* only if verify fails) + ↓ + deploy-studio-site +``` + +Concepts (`understand-*`) are pulled on-demand, not part of the sequence. + +## What to do if nothing matches + +Answer from general Studio knowledge and say so explicitly. If the question keeps recurring across sessions, that's a missing skill. Flag it. diff --git a/skills/studio/start-here-zero-knowledge.md b/skills/studio/start-here-zero-knowledge.md new file mode 100644 index 0000000..8365dd2 --- /dev/null +++ b/skills/studio/start-here-zero-knowledge.md @@ -0,0 +1,143 @@ +--- +name: start-here-zero-knowledge +--- + +## When to use + +Zero-knowledge onboarding orchestrator. Walks new users through the conceptual ladder and nudges them to build their first real Section + Template. + +Use as the VERY FIRST skill when a user says "I'm new to Studio", "what is Studio?", "I want to add Studio to my site", "how do I start with Studio", "show me Studio from scratch". Also defensively when `build-section` / `build-connected-template` is invoked by someone unfamiliar with Sections. Do NOT use as reference for someone who's shipped a Section. + +> **Auth preflight: settle the credential before the first API call.** Resolve it OAuth-first per [`authenticate-cma`](authenticate-cma.md): `CS_OAUTH_ACCESS_TOKEN`, else the Contentstack MCP's stored session: Read that skill for where it is, even with no MCP connected. **Never ask the user for a session `authtoken`.** If nothing resolves, or a refresh fails with `400 invalid_refresh_token`, hand them `! CONTENTSTACK_REGION=<code> npx @contentstack/mcp --auth` (it needs a TTY and a browser, so it cannot be run for them) and wait. `403 error_code 316` is a valid credential aimed at another org: fix the org or the `api_key`, do **not** re-authenticate. + +# Start here: Studio from zero + +## Why this skill exists + +Studio's value depends on a mental model the user must internalise BEFORE they start building. Skip it and they'll: + +- Drop atomic components straight onto routes (one-off pages, no reuse) +- Hand-wire bindings per page (slow. Defeats the point) +- Build "Sections" as new React components in their repo (misunderstanding: Sections are composition data, not new code) +- Skip linked schemas (forfeits auto-binding. Back to manual wiring) +- Build N templates as N separate page routes (forfeits the catch-all + URL pattern reuse) + +This skill is the on-ramp. It walks five concepts in 10 to 15 minutes, then routes to the right first action. + +## Task + +### Step 1: Frame the problem (no jargon yet) + +Tell the user verbatim: + +> Before any code, here's the problem Studio solves and the new vocabulary you'll need. +> +> **Today's problem.** Every new page on your site is engineering work. A new blog post, a new product, a campaign landing: each one is a route file + a component arrangement + data wiring. Marketing waits on engineering. Engineering rewrites the same hero-grid-CTA pattern for every layout. Adding a page = a deploy. +> +> **Studio's premise.** Engineers register your atomic React components ONCE. Authors then compose **Sections** (reusable parts of a page like a hero or a CTA strip, bound to CMS data once) and arrange those Sections into **Templates** (whole-page recipes). New pages = new entries in your CMS, not new code. +> +> The whole game is **bind once, reuse everywhere**. Sections and Templates are how Studio expresses that. + +### Step 2: Walk the concept ladder (run each in order) + +These seven concept skills explain the whole mental model, ~12 minutes total. Run in order. Each builds on the last: + +1. **`understand-sections`**: what a Section is. The "part of a page" unit. +2. **`understand-linked-schemas`**: how a Section declares the data shape it needs. +3. **`understand-auto-binding`**: what happens when a Section drops on a Template. Why dropping "just works." +4. **`understand-section-slots`**: how to make ONE Section serve multiple Templates by leaving swap points open. +5. **`understand-templates`**: what a Template is and how Sections compose into one. Key mental-model rung: **Templates compose Sections.** A Template is a page-level container. Sections are the units that fill it. Build Sections first, then assemble them into a Template. Templates never model fields directly. +6. **`understand-canvas-vs-component`**: the two SDK mounts split by what they render: `<StudioCanvas />` is **Section-only** (one `/canvas` route, used inside Studio's iframe for Section building / authoring / previewing, Templates never render through it). `<StudioComponent />` renders **Templates**, on your real visitor routes AND inside Studio's template-preview iframe (Studio iframes the env Base URL + Template URL, which is the same route a visitor hits). Sections never render standalone. They only appear inside a Template. +7. **`understand-canvas-url`**: the Canvas URL field that comes up during setup (it points Studio at the `<StudioCanvas />` route used for Sections). + +After these the user can answer in plain language: "A Section is a reusable part of a page with its CMS binding declared. A Template is a stack of Sections. When I drop a Section on a Template, Studio matches its linked schema to the Template's content type and auto-binds every prop. Section Slots let one Section serve many Templates with a child-component swap point. Sections author through `<StudioCanvas />` at the `/canvas` route inside Studio's iframe. Templates render through `<StudioComponent />` on my real routes: visitors hit those routes directly, and Studio iframes the same routes when previewing a Template." + +### Step 3: Branch by project shape + +Ask: **"Are you adding Studio to an existing website, or starting a brand-new one?"** Branch: + +#### Path A: Existing website (most common) + +The user already has a React app (Next/Vite/CRA/Remix), a Contentstack, and live content. Adding Studio is incremental, and the first phase changes none of their pages. + +**Phase 1, minimal conversion.** `convert-project-to-studio` drives steps 1 to 6 with the answers pre-filled, so run it rather than the steps by hand: + +1. `analyze-project-fit`: read-only diagnostic. Confirms a single React (18 or 19), detects framework and locales, picks the install branch. +2. `enable-visual-experience`: turn on Live Preview at the stack level (UI-only step). +3. `install-studio`: adds the three SDKs and wires the canonical init. Minimal-add branch if Contentstack is already wired. +4. (Optional) `setup-local-https-canvas`: only if you need HTTPS-on-localhost for service workers / secure cookies / strict policy. Plain `http://localhost:<port>` works for the basic Studio iframe. +5. `setup-section-preview`: adds the `/canvas` route mounting `<StudioCanvas />`, sets Canvas URL. +6. `setup-template-preview-routes`: adds the catch-all that mounts `<StudioComponent />` behind the existing routes. Code routes win, the app's own 404 stays, every locale gets a Base URL. + +Studio is live at this point. Nothing the user had before renders differently, and authors can build a first Studio page at any URL no code route claims. + +**Phase 2, once Studio is live:** + +7. `register-component`: register the user's first 3 to 5 atomic components (the ones they'll use in the first Section). +8. `build-section` (author the first Section. **Stop here and celebrate**) the first Section authored against real components is the moment Studio "clicks." +9. `build-connected-template`: author the first Template against an existing content type, drop the Section on it. +10. `verify-setup`: run the layered smoke test. + +After step 8 the user has proof the loop works (register, then Section, then render in canvas). After step 9 they have a live URL rendering a Template against their existing CMS data. + +#### Path B: Brand-new website (greenfield) + +The user is starting from scratch. They still need React + a CMS first. + +Sequence: + +1. Scaffold a React app (Vite recommended, `npm create vite@latest -- --template react-ts`. It defaults to React 19, which the SDK supports alongside React 18. No pin needed). +2. Set up a Contentstack with at least one content type that has fields you'll bind a Section to (e.g. `blog_post` with `title`, `subtitle`, `cover`, `body`). +3. Then run the same chain as Path A from `analyze-project-fit` onwards. + +For Path B specifically: build 2 to 3 atomic components first (Hero, FeatureCard, CTA) so there's something to register in step 6. A `register-component` skill on a half-built component is wasted effort. + +### Step 4: Set expectations + +Tell the user verbatim: + +> Two things you'll feel in the first hour: +> +> 1. **The first Section feels like overhead.** You register a component, declare a linked schema, then drop it once. Why didn't I just import the component on a page? Because the second time you use it on a different Template, you'll feel the payoff: zero re-wiring. By the fifth use it's a 10× return. +> +> 2. **You'll be tempted to skip Sections and bind components directly on Templates.** Don't. Template-level direct binding works for the Template you just authored, but the binding doesn't travel: when you make a similar Template tomorrow, you'll wire it again. Sections + linked schemas are what make today's binding usable tomorrow. +> +> If a part of a page appears on more than one Template, make it a Section. If you're not sure: make it a Section anyway. Downside is small. + +### Step 5: Hand off to the first procedural skill + +Print the user's tailored plan as a numbered list (from Step 3's appropriate path), and offer to invoke the first skill (`analyze-project-fit`). Wait for explicit user confirmation before any state-changing skill runs. + +## Inputs needed from the user + +1. `projectShape`: `existing` or `new`. Determines the branch in Step 3. + +## Acceptance + +This skill succeeds only when ALL of the following are true. + +- [ ] The frame in Step 1 was printed (the user got the "problem and premise" narrative before any jargon). +- [ ] The user was guided through (or directly read) all six concept skills in Step 2, order matters: Sections, linked schemas, auto-binding, slots, Templates, then Canvas URL. +- [ ] The user confirmed their `projectShape`. +- [ ] The user received their branched, numbered plan (Path A or Path B). +- [ ] Step 4's expectation-setting was printed verbatim, both warnings (first-Section-feels-like-overhead, don't-skip-Sections). +- [ ] The user was nudged toward Sections + Templates rather than left to invent their own pattern. +- [ ] The user explicitly confirmed before any state-changing skill was invoked. + +## Common pitfalls + +| Pitfall | Why it bites | Fix | +| --- | --- | --- | +| Skipping the concept ladder and going straight to `install-studio` | User ends up wiring page-by-page, never realizes Sections exist, ships a one-off integration that doesn't compound | Always run Step 2 first, even if the user is impatient. The 10-minute investment prevents weeks of rework. | +| Letting the user write Section-shaped React components in their repo | Sections are composition data, not new components. New React code "to be a Section" is wasted work. | Run `understand-sections`, make sure they see "a Section is composed from atomic components you already have." | +| Treating Path B (greenfield) as "easier" | It isn't: greenfield users still need a CMS stack with real content types and entries before Studio is meaningful. Empty stack = nothing to bind. | Step 3 Path B explicitly sequences CMS setup before Studio install. | +| Recommending `build-section` before `register-component` | A Section can't exist without registered atomic components to compose. | Path A step 6 (`register-component`) comes BEFORE step 7 (`build-section`). | +| Pushing the user toward complex setups (Section Slots, exposed props) on day one | These are advanced: premature complexity confuses new users | Keep the first chain to: register components, one Section, then one Connected Template. Section Slots and exposed props are post-success additions. | + +## See also + +- `understand-sections`, `understand-linked-schemas`, `understand-auto-binding`, `understand-section-slots`, `understand-templates`, `understand-canvas-vs-component`, `understand-canvas-url`: the seven concept skills this orchestrator runs +- `plan-studio-architecture`: the architecture planner that follows the concept ladder. Takes the user's project requirements (paragraph or bullets) and produces a printed plan: which pieces are Sections, which pages are Connected templates, the content model, the build order. Run BEFORE `byoc-end-to-end`. +- `byoc-end-to-end`: the procedural macro that walks the plan in execution mode. After concepts are crisp AND the architecture plan exists, run `byoc-end-to-end` to see the full BYOC arc (register components, build Sections, expose Slots, assemble into Templates, wire SSR or CSR, verify, then ship) named on one page. +- `analyze-project-fit`: first procedural skill on either path +- `studio-tour`: broader walkthrough. This skill is the zero-knowledge entry that precedes the tour diff --git a/skills/studio/studio-tour.md b/skills/studio/studio-tour.md new file mode 100644 index 0000000..f6bbda9 --- /dev/null +++ b/skills/studio/studio-tour.md @@ -0,0 +1,169 @@ +--- +name: studio-tour +--- + +## When to use + +Intent-driven end-to-end walkthrough that meets the user wherever they are and routes them through setup, registration, sections, templates and ship via specialist skills. + +Use when the user wants Studio working end-to-end but has not picked a starting point ("just walk me through", "I want to ship a page", "I have components, what next"). This skill orchestrates. It delegates to specialists at each step. Do NOT use when the user has a specific named task. Invoke that skill directly. + +> **Auth preflight: settle the credential before the first API call.** Resolve it OAuth-first per [`authenticate-cma`](authenticate-cma.md): `CS_OAUTH_ACCESS_TOKEN`, else the Contentstack MCP's stored session: Read that skill for where it is, even with no MCP connected. **Never ask the user for a session `authtoken`.** If nothing resolves, or a refresh fails with `400 invalid_refresh_token`, hand them `! CONTENTSTACK_REGION=<code> npx @contentstack/mcp --auth` (it needs a TTY and a browser, so it cannot be run for them) and wait. `403 error_code 316` is a valid credential aimed at another org: fix the org or the `api_key`, do **not** re-authenticate. + +# Studio Tour: start here + +## Context + +Studio sits between three things a user already has (a React component library, a Contentstack, an app routing layer) and one thing they want (visually composed pages with real CMS data, editable post-publish without code changes). The journey from "I want to use Studio" to "page rendering" is six stages. Many users don't realize some are optional. + +This skill identifies the user's current stage, names what they're really trying to do, and routes to the right specialist. + +Studio is three things in one (surface this early if the user hasn't seen it): + +1. **A visual page builder**: drag-drop composition for content authors +2. **A real-data preview surface for composed components**: the missing rung between Storybook (isolated, fake data) and the live URL (composed, real data, post-deploy) +3. **An on-the-fly editor for layouts / components / look-and-feel**: when pages are built through templates, swap sections / reorder / re-bind without a redeploy + +Reference: `docs/00-overview/contentstack-studio-overview.md`, `docs/00-overview/the-development-flow-with-and-without-studio.md`. + +## The six stages + +``` +1. Stack → API key, Delivery Token, Preview Token, Environment, Language (Contentstack) + + Live Preview on (enable-visual-experience) +2. App SDKs → install-studio + install-live-preview into the user's React app +3. Studio project → Playground Canvas (no app) OR Website Canvas (with canvas route) + + Environment + Language +4. Registered → register-component for each custom component the user already has + components (skip on Playground; required for Website Canvas to use brand components) +5. Sections → build-section: connect a structural schema (CT / GF / Group / MB / Block / + Reference; never a scalar field) + drop registered components + bind props +6. Templates → build-connected-template (CT-bound; covers both repeatable pages AND + one-off pages backed by a single-entry CT). Drops sections; saves. + → Deploy when ready (deploy-studio-site) +``` + +Stages 3-6 happen inside Studio's web UI (no API tokens needed: the user's browser session is the auth). Stages 1-2 + 4 touch the user's app code or Contentstack and may need credentials. + +## Task + +This skill is a **discovery + routing flow**, not a doing-flow. Walk it through 4 phases. + +### Phase 1: Discover the user's intent + +Map the supplied `userGoal` to one of these named intents. If unclear, ask one clarifying question, never guess. + +| If the user said something like… | Intent | +|---|---| +| "try it without installing anything", "explore", "see what it looks like" | **try** | +| "understand what Studio does", "decide if it fits my project", "evaluate" | **understand** | +| "build my first page", "ship a real page" | **build** | +| "bring my React components into Studio" | **byoc** (bring your own components) | +| "I have a page idea but don't know how to model it" | **model** | +| "swap a section / move things around / change the layout" | **edit** (existing Studio user) | +| "troubleshoot a broken canvas" | redirect to `troubleshoot-canvas` (NOT this skill) | +| "verify my setup" | redirect to `verify-setup` | + +State the named intent back before proceeding ("Sounds like you're trying to **build** a real page. Let me walk you through the path."). + +### Phase 2: Map their current state to the six stages + +Take `existingState` and translate it onto the stage list. Identify the lowest unfinished stage. That's the current step. + +| `existingState` value | Stages completed | Lowest unfinished | +|---|---|---| +| `none` | (none) | Stage 1 / 2 | +| `studio-project-only` | 1, 3 (partial: Playground) | Stage 2 if they want Website Canvas, else go to 5 | +| `sdks-installed` | 1, 2 | Stage 3 | +| `canvas-route-mounted` | 1, 2, 3 (Website Canvas) | Stage 4 | +| `components-registered` | 1-4 | Stage 5 | +| `sections-exist` | 1-5 | Stage 6 | +| `templates-exist` | 1-6 | Done, go to deploy / edit | + +If the user picked combinations that don't make sense (e.g. `templates-exist` without `sections-exist`), ask one clarifying question. Don't assume. + +### Phase 3: Route based on intent + state + +Pick the next specialist skill (or doc page) from this matrix. + +| Intent | Current stage | Next concrete step | +|---|---|---| +| **try** | none | Run `install-studio` only IF user wants real components. Otherwise: read `docs/10-setup/studio-project/try-studio-in-the-playground-canvas-without-an-app.md`, create the project in Studio, then start authoring directly in Playground (no app setup needed). | +| **try** | studio-project-only | Author a small section in Playground using Studio's defaults. Skip steps 2 + 4. Use `build-section` with `linkedSchemaUid` blank (static-only). | +| **understand** | any | Reading path, in order: `docs/00-overview/contentstack-studio-overview.md`, `docs/00-overview/the-development-flow-with-and-without-studio.md`, `docs/00-overview/choosing-between-templates-and-sections.md`, `docs/40-recipes/marketing-site-walkthrough-with-four-end-to-end-scenarios.md`. No skill needed. | +| **build** | none | Sequence, in order: `analyze-project-fit`, `enable-visual-experience`, `install-studio`, `setup-section-preview` (or stay on Playground if exploring), `register-component` (one per component), `build-section`, `build-connected-template`, `setup-template-preview-routes`, `verify-setup`, then optionally `deploy-studio-site`. | +| **build** | sdks-installed | Resume at `setup-section-preview`, then continue the build sequence. | +| **build** | components-registered | Resume at `build-section`, then `build-connected-template`. | +| **byoc** | any | `register-component` per component, then `wire-component-default-data` for sensible defaults. Optionally `figma-generate-components` if Figma is the source of truth, or `import-design-tokens` to pull tokens. | +| **model** | any | Walk content modeling first (CT / Global Field / Modular Block / Reference). Then `build-section`. If sections need iteration over a list, `use-repeater` + `use-condition-block`. If a single value should be overridable per template, `expose-section-props`. If a region should be template-author-defined, `use-section-slot`. | +| **edit** | templates-exist | Direct to the relevant in-Studio action: re-bind a prop, swap a section, re-order. No skill needed. This is what Studio's runtime editor is for. If an entire layout change is needed, route to `build-connected-template` or rebuild a section with `build-section`. | + +### Phase 4: Map the path end-to-end + +Before they start the next skill, **describe the full path** so they know what they're getting into. Example for `build` intent from `none`: + +``` +Path: from zero → a Blog Post page rendering at /blog/<slug> + +1. analyze-project-fit → read-only check: React version, existing SDKs, data center +2. enable-visual-experience → Live Preview on the stack, delivery + preview tokens +3. install-studio → SDKs in your app, 3 packages, env vars +4. setup-section-preview → canvas route mounted, Canvas URL set on the project +5. register-component (×N) → your Hero, Card, Button etc. appear in Studio's palette +6. build-section → Hero Strip section linked to a gf_hero Global Field +7. build-connected-template → Blog Post template, drops Hero Strip, connected to blog_post CT +8. setup-template-preview-routes → /blog/:slug route in your app mounting <StudioComponent /> +9. verify-setup → smoke test: open the URL, see a real entry render + +Estimated wall-clock for a fresh setup: 30–60 min the first time, 10 min for the second blog-post template. + +Skills you can skip: +- setup-section-preview → if you're fine on Playground Canvas (no deploy though) +- register-component → if you only want Studio's defaults (less interesting for production) +``` + +Then ask: "Ready to start with step 1?", and on confirmation, invoke the first specialist skill. + +## Inputs needed from the user + +In this order: + +1. `userGoal`: free-form sentence describing what they want. Don't accept anything generic like "use Studio". Ask for specifics until one of the named intents lands. +2. `existingState`: comma-separated state markers. If user doesn't know, ask one clarifying yes/no per item: "Do you have an app with @contentstack/delivery-sdk installed already? Do you have a Studio project in your Contentstack account?" +3. `frameworkOrTarget`: required for `install-studio` / `setup-section-preview` / `configure-csr-vs-ssr` downstream. Ask now to avoid asking later. + +## Acceptance + +- [ ] User's intent named explicitly (one of: try / understand / build / byoc / model / edit), confirmed back to them +- [ ] Current state mapped to the six stages, with the lowest-unfinished stage identified +- [ ] Next specialist skill (or doc page for understand intent) named and the reason for picking it stated +- [ ] Full path through remaining stages described before the user starts work +- [ ] On confirmation, the next specialist skill is invoked OR the doc page is opened +- [ ] User does NOT leave the tour without a clear next action + +## Common pitfalls + +| Pitfall | Why it bites | Fix | +| --- | --- | --- | +| Guessing the intent from vague input | Wrong path. User wastes time on the wrong skill | Ask one clarifying question. Never assume between try / build / model | +| Skipping the Playground vs Website Canvas distinction | User installs SDKs they didn't need (try intent) OR is stuck without deploy (build intent) | Surface Playground Canvas early for try intent. Surface Canvas URL setup for build intent | +| Inviting the user to "build a section" without first checking they have registered components | Section composes only Studio defaults. Brand wrong | Confirm step 4 (registered-components) before step 5 (build-section) for Website Canvas users | +| Treating troubleshoot / verify intents as "tour" cases | The user wanted a focused diagnostic, got a journey overview | Recognize troubleshoot-canvas / verify-setup keywords and redirect immediately | +| Forgetting the 3-in-1 framing for understand intent | User leaves still thinking Studio is "just a page builder" | Open with the three-things-in-one explanation from `what-is-studio.md` | + + + +## Driving the canvas + +Moving a component on the Studio canvas through browser automation has a required event sequence, and the high-level drag helpers do not satisfy it. See [`drive-studio-canvas-ui`](drive-studio-canvas-ui.md) before attempting a drop. + +## See also + +- `docs/00-overview/contentstack-studio-overview.md`: the 3-in-1 framing +- `docs/00-overview/the-development-flow-with-and-without-studio.md`: without/with Studio comparison +- `docs/00-overview/choosing-between-templates-and-sections.md`: Templates vs Sections +- `docs/00-overview/choosing-your-studio-setup-path.md`: onboarding paths +- `docs/10-setup/studio-project/try-studio-in-the-playground-canvas-without-an-app.md`: try-without-installing path +- `docs/00-getting-started/quickstart-with-skills.md`: evaluator quickstart +- `docs/40-recipes/marketing-site-walkthrough-with-four-end-to-end-scenarios.md`: full real-world example +- Every specialist skill referenced in the Phase 3 routing matrix diff --git a/skills/studio/troubleshoot-canvas.md b/skills/studio/troubleshoot-canvas.md new file mode 100644 index 0000000..d302ef4 --- /dev/null +++ b/skills/studio/troubleshoot-canvas.md @@ -0,0 +1,243 @@ +--- +name: troubleshoot-canvas +--- + +## When to use + +Symptom-mapped diagnostic for a non-working Studio canvas. Given a user-visible failure, narrow it to the underlying cause and propose the fix. Run a Section-test pre-flight first. + +Use when Studio's canvas misbehaves with a specific symptom: "canvas is blank", "Component Loading Error", "canvas shows wrong composition", "preview entry doesn't update", "iframe never finishes loading". If the symptom is ambiguous, route via `troubleshoot` first. Do NOT use to validate a fresh install (use `verify-setup`). Do NOT use for authoring-quality issues: those are skill-correctness. + +> **Auth preflight: settle the credential before the first API call.** Resolve it OAuth-first per [`authenticate-cma`](authenticate-cma.md): `CS_OAUTH_ACCESS_TOKEN`, else the Contentstack MCP's stored session: Read that skill for where it is, even with no MCP connected. **Never ask the user for a session `authtoken`.** If nothing resolves, or a refresh fails with `400 invalid_refresh_token`, hand them `! CONTENTSTACK_REGION=<code> npx @contentstack/mcp --auth` (it needs a TTY and a browser, so it cannot be run for them) and wait. `403 error_code 316` is a valid credential aimed at another org: fix the org or the `api_key`, do **not** re-authenticate. + +# Troubleshoot Studio canvas issues + +## Context + +Studio's canvas iframe is downstream of four moving parts: Delivery SDK auth, Live Preview's post-message channel, the canvas-app's React rendering, and the saved composition spec. A symptom can come from any of them. This skill maps each symptom to its root cause. + +Pair with `verify-setup` if the user is freshly set up: verify-setup walks all four layers, troubleshoot-canvas assumes you already know the symptom. + +Reference: `docs/10-setup/troubleshoot-common-studio-issues.md` (the canonical table of symptoms and their fixes). + +## Step 0: is the app actually being served? + +**Do this before the section-test, before anything.** Studio iframes your app. If nothing answers on that origin, every downstream symptom appears: blank canvas, "SDK Not Initialized", "Template Did Not Load", "localhost didn't send any data". None of them says "your dev server isn't running", and it is a routine first cause: the server was never started, it crashed, it took a different port on restart, or you're serving `vite preview` on `:4173` while Studio points at `:5173`. + +```bash +# 1. Is anything listening on the port Studio targets? +curl -sS -o /dev/null -w "%{http_code}\n" http://localhost:<port>/canvas + +# 2. What origin does Studio actually iframe? (must match, scheme included) +# Sections -> env base URL + Canvas URL +# Templates -> env base URL + the template path +# GET /v3/environments/<env> -> urls[].url +``` + +A connection error or a non-2xx/3xx on step 1 means **stop**. Start the server (or fix the port/scheme mismatch) and re-test. Only when the URL Studio targets returns content does the rest of this skill apply. + +Cheap, and it costs nothing when it passes. Also re-run it after any crash, port change, or switch between `npm run dev` and `vite preview`. + +## Run this SECOND: the section-test pre-flight + +Before reading the symptom table (especially for **"SDK Not Initialized"**, blank canvas, or any popup that names the SDK as broken), run this one diagnostic. It cuts the longest debugging path from hours to seconds. + +**The section-test:** open any Section (not a Template) in Studio. Does it render with real data? + +- **If YES**, the SDK, the credentials, the editing handshake, the Live Preview channel, and the Delivery SDK auth are ALL working. Whatever symptom you're seeing on a Template is downstream of the SDK. Skip the SDK / init / token rows in the table. Jump straight to `troubleshoot-composition-resolution`. The most common cause is the connected template's URL match (literal URL, missing leading slash, or API-set `user_specified_pattern` that reverted to default, see #9 in the missing-skills audit). +- **If NO** (sections also fail), THEN the SDK / init / token rows below are honest. Continue to the symptom table. + +**Why this matters:** Studio's "SDK Not Initialized" popup is a **red herring** in the most common case. When a Template's `useCompositionData` never resolves (URL match fails), `<StudioComponent />` never mounts, Studio's editing handshake times out, and Studio surfaces that as "SDK Not Initialized", blaming a layer that isn't broken. Re-running `install-studio` won't help. The install is fine. The section-test rules SDK-broken in or out before you spend hours in the wrong layer. + +## Diagnostic tooling: inspecting a stored composition's `ui` + +When a row below points at the stored composition (node tree, bindings, data sources, URL metadata), fetch via the Studio API. The **`ui`** field is **`zlib:<base64>`**: a literal `zlib:` prefix + base64-encoded zlib-deflated JSON. Decode for read-only inspection: + +```js +// Node 18+. Save as inspect-ui.js +import zlib from "node:zlib"; + +function decodeUi(ui) { + if (!ui.startsWith("zlib:")) return JSON.parse(ui); // legacy plain-JSON fallback + const bytes = Buffer.from(ui.slice("zlib:".length), "base64"); + return JSON.parse(zlib.inflateSync(bytes).toString("utf8")); +} + +// Usage: pipe the composition JSON in on stdin, get the decoded ui on stdout +const composition = JSON.parse(await new Response(process.stdin).text()); +console.log(JSON.stringify(decodeUi(composition.ui), null, 2)); +``` + +```bash +# Quick CLI: fetch a composition and pipe through the decoder +curl -s "<studio-api-host>/v1/projects/<projectId>/compositions/<compositionId>" \ + -H "$CS_AUTH" -H "organization_uid: <org>" | node inspect-ui.js +# $CS_AUTH is either credential: 'authorization: Bearer <oauth>' (preferred) or +# 'authtoken: <session>'. Both work since 2026-08-25 — see authenticate-cma. +``` + +**Read-only (for debugging).** Do not author or modify compositions through the API. You'd lose UI-generated metadata (`url_queries`, etc.) that the Edit-URL modal sets behind the scenes. If you want to write a composition by hand, step back into Studio's UI. + +## Task + +1. **Match the symptom to the canonical category** using the table below. Pick the closest match: exact wording matters less than category. + +2. **Run the "Check first" diagnostic** for that category. If it confirms the diagnosis, propose the fix. If not, run the secondary check. + +3. **Report** the symptom you matched, then the likely cause, then the exact thing to check, then the fix. Do NOT propose more than two checks. If both come back negative, escalate to reading `docs/10-setup/troubleshoot-common-studio-issues.md` or running `verify-setup`. + +### Symptom and cause matrix + +| Symptom (user-visible) | Most likely cause | Check first | Fix | +| --- | --- | --- | --- | +| **Visual Editor / Visual Builder (not Studio) stuck on "Initializing the editor…"**, nothing clickable | Live Preview runs in its default `mode: "preview"`, which never starts the editing layer | `grep -rn "mode:" <the ContentstackLivePreview.init file>` | `mode: "builder"`: [`make-visual-editor-editable`](make-visual-editor-editable.md) § 1 | +| **Canvas iframe is completely blank** (no Studio chrome inside) | The composed iframe address is wrong. Studio builds it as **Base URL (origin) + Canvas URL (path)**: the origin is the per-locale URL on the environment the project targets, the path is the Canvas URL. Either half can be broken. An **empty env Base URL** is the most common cause (and surfaces as a "no base URL"-style error blaming the Studio project, not the environment). | (a) In Contentstack, open the stack's Settings, go to Environments, open `<env>`, and check the URL for `<locale>`: is it set to where the canvas-app serves (`http://localhost:5173` for local dev, or the deploy origin)? (b) In Studio, open the project's Settings, then Configuration: is the project's **Environment** that same env, and is **Canvas URL** the route path (`/canvas`, not a full origin)? | Set the env's per-locale Base URL to the canvas-app origin. Set Canvas URL to the route path. Ensure the project targets that env. Re-open the composition. See `setup-section-preview`. | +| **Canvas loads but shows the host app's home page** instead of the composition | The host app doesn't have a `/canvas` route mounting `<StudioCanvas />` | `grep -r "StudioCanvas" src/` in the canvas-app | Add a `/canvas` route that mounts `<StudioCanvas />`. See `docs/10-setup/studio-project/section-preview-route.md`. | +| **"Component Loading Error: Component with type 'X' is not registered"** where `X` is `repeater` / `condition-block` / `section-slot` / `box` / `text` / another **built-in** | The SDK auto-registers built-ins on the **authoring canvas** path. Seeing this on the canvas means the registry instance was bypassed (e.g. importing `getComponentRegistryInstance` from a different worker/realm than the one rendering the canvas), or a bundler quirk shipped an older SDK build. | Confirm all `@contentstack/studio-*` packages are on the same major and run in the same JS realm. | Track the SDK issue for system-component auto-registration on the render paths. | +| **Visitor page falls back to "No page here yet" / a `<NotFound/>` branch** even though the composition is published and `useCompositionData` returns a non-thrown error with `ComposableStudioError: [Composable Studio SDK] Internal components missing: page` (also seen for `box`, `text`) | **Pre-patch SDK (1.3.1 / 1.5.1 and earlier, or any pre-merge build of `fix/builtin-ssr-registration`).** The visitor render path didn't auto-register built-in/system components (`page`, `box`, `text`, …). Only the authoring canvas did. Compositions whose root node is type `page` couldn't render on the public route. | In DevTools, log the return of `useCompositionData` (or whatever hook drives the route), then look for `state: "error"` with `Internal components missing: page` in the body. Compositions with `page` (or another system type) as the root node are affected. | Upgrade `@contentstack/studio-react` to the next 1.3.x / 1.5.x patch. PR #851 (`fix/builtin-ssr-registration`) restores auto-registration of built-ins on the visitor path. | +| **"Component Loading Error: Component with type 'X' is not registered"** where `X` is a section composition UID OR one of the user's component types | Section ref resolves to a section that isn't preloaded OR a registered component for `type: 'X'` doesn't exist | Open the composition in Studio, then check Layers for a node with `type: "X"`. Is it a section ref (X is another composition's UID) or a registered component (X is a registered type)? | If section ref: confirm the SDK auto-registers sections, see F-018 fix in `docs-verification-findings.md`. If registered component: run `register-component` for that type. | +| **"Component Loading Error / Composition Loading Error" for MANY or ALL of your components at once** (e.g. every `home-hero` / `cs-*` section errors, not just one), often the whole composition fails to load | **NOT a per-component registration gap**: the canvas is iframing an app that has NONE of your `registerComponent` calls, because the project's **Configuration is unset or points at the wrong app**. A blank/disabled **Base URL**, a missing **Canvas URL**, or an **Environment** that resolves to a deployed build without your registrations all produce this. The registrations exist in your running app. The canvas just isn't loading it. | In Studio, open the project's **Settings**, then **Configuration**: is **Environment** set (to the env whose Base URL points at your running app), is **Base URL** set to that app's origin (e.g. `http://localhost:3000`, `http://localhost` needs no HTTPS), and is **Canvas URL** set (`/canvas`, required)? Any one blank or disabled produces this error. | In Settings, under Configuration, set **Environment**, ensure the env's **Base URL** points at your running app, and set **Canvas URL** to `/canvas`. **Save**, then re-open the composition. This is the single most common "everything's not registered" cause. See [`configure-studio`](configure-studio.md). | +| **"Template Did Not Load"** + the iframe shows your app's **404** (the requested URL has a **doubled locale**, e.g. `/en/en`) | The locale prefix is carried twice. Studio previews at **per-locale Base URL + the composition's own URL**. On a locale-prefixed app the Base URL for `en-us` is `http://localhost:3000/en` by design, so the doubling comes from the other side: the composition URL was authored as `/en` (or `/en/...`) instead of `/`, or the catch-all passes the prefixed request path to Studio instead of stripping it. | Read the exact requested URL in the dev-server log / Network tab. A doubled `/en/en` confirms it. Then check the composition's `url` in Studio and the `url` your catch-all passes to `fetchCompositionData`. | Composition URLs never carry a locale prefix: fix the authored URL to `/`. The catch-all strips the app prefix before calling Studio and passes the stack locale as `{ locale }`. Keep the prefix in the per-locale Base URL. On an app with **no** locale prefix the Base URL is the bare origin. The single rule is in [`convert-project-to-studio`](convert-project-to-studio.md) § Step 3. | +| Images render on the **published/visitor** page but are **broken in the canvas / Live Preview** (broken URLs look like `assets.contentstack.io/spaces/…` and return 404) | The image field is backed by a **DAM / Assets-Manager** asset (`am…` uid, `parent_uid`). Live-Preview (draft) resolves it to the DAM **preview** delivery URL (`/spaces/…`), which 404s in a plain browser. The published CDA resolves the same asset to `images.contentstack.io/v3/assets/…` (HTTP 200). | Compare the draft (preview token) vs published (delivery token) URL for the same field. | Link the asset so it delivers the `images.contentstack.io` form: re-pick it via the DAM/asset picker, or set the asset uid with a **session authtoken** (OAuth Bearer CMA writes reject DAM assets, see `author-composition-via-api` pitfalls). Then re-publish + re-open. | +| **"Authentication failed" / 401 / 403** in the canvas | Delivery Token or Preview Token in `.env` is wrong or has been revoked | In Contentstack, open the stack's Settings, go to Tokens, open the relevant Delivery Token, and copy the current Delivery Token plus its paired Preview Token into `.env` | Restart the dev server. | +| **"Component Loading Error: HTTP status 0" / CORS error on the CDA preview host** in the canvas iframe ("Access to XMLHttpRequest at `https://<cdn>-preview.contentstack.com/…` blocked by CORS policy: No 'Access-Control-Allow-Origin' header"). Looks like a CORS issue but **almost always isn't**. | The **Preview Token** the canvas-app passes to the Delivery SDK / Live Preview SDK is invalid, missing, "expired" (its paired Delivery Token was deleted/regenerated), literally `"undefined"`, empty, or scoped to a different stack. Contentstack's preview API returns **401** with a clear body (`{"error_message":"You're not allowed in here unless you're logged in.","error_code":105,"errors":{"preview_token":["is not valid."]}}`) but **without CORS headers** (error responses don't carry them). The browser hides the body and surfaces a CORS error instead. Common forms: token left as the literal string `"undefined"` by a provisioning script that couldn't mint it via CMA. The paired Delivery Token was deleted/regenerated (Preview Tokens can't be revoked individually, they die with their Delivery Token). Copy-paste truncation / trailing whitespace. Token belongs to a different stack than the configured API key. | **Bypass the CORS masking with `curl`**. CORS doesn't apply to `curl`, so you see the real status code + body. Plug in the values your canvas-app uses (wherever it loads them from: `.env.local`, `.env`, runtime config, secrets manager, hardcoded):<br><br>`curl -i "https://<preview-host>/v3/content_types/<CT>/entries?environment=<env>&locale=<locale>" -H "api_key: <stack-api-key>" -H "preview_token: <preview-token>"`<br><br>For the NA prod region the preview host is `rest-preview.contentstack.com`. Non-prod / non-NA regions use a different host (see `install-studio` § Regional host map). Outcomes:<br>• **HTTP 200** + JSON entries means the token is fine, cause is elsewhere<br>• **HTTP 401** + `"errors":{"preview_token":["is not valid."]}` means the token is bad (revoked, `"undefined"`, empty, wrong stack), most likely<br>• **HTTP 403** means the token is valid but lacks scope for this CT / environment<br>• **HTTP 422** means an environment / locale name typo<br>• **HTTP 400** + `"Please provide live preview hash in request"` means the token IS valid. The 400 is just because `curl` didn't send the live-preview hash header your canvas-app's SDK adds automatically. Treat this as a pass. | (a) Open the file/config where your canvas-app loads its Preview Token (project-specific: Vite `.env.local`, Next `.env.local`, CRA `.env`, Remix env, runtime fetch, secrets manager, whatever your project uses) and verify the value is NOT the literal string `undefined` or empty. (b) In Contentstack, open Settings, go to Tokens, then Delivery Tokens, open the Delivery Token your app uses, and confirm a **Preview Token (read only)** field is visible. If only a **Create Preview Token** button shows, click it to mint one (see `enable-visual-experience` Step 4). If the Delivery Token doesn't exist or looks unfamiliar, the original was deleted. Create a fresh Delivery Token, mint a new Preview Token on it. (c) Copy the current Preview Token (starts with `cs`) into wherever your canvas-app loads it from. (d) **Restart the canvas-app server**: env vars and bundle-time config bake in at build/start time. Hot-reload typically does NOT pick up env-file changes for `import.meta.env.*` / `process.env.*` reads. (e) Hard-reload the Studio canvas. The CORS-shaped error disappears once the API can authenticate. | +| **Preview token looks right in `.env` but the canvas still 401s**: fresh provision, you re-checked the token character-by-character against the Delivery Token page, it matches, the canvas still fails | **`.env.local` is shadowing `.env`.** Vite / CRA / Next load env files in this order: `.env` first, then `.env.local`, then `.env.[mode]`, then `.env.[mode].local`. Later files **override earlier ones for the same key** and `.env.local` is loaded in every mode. A stale `.env.local` left from a previous provisioning run (often carrying `<PREFIX>_CONTENTSTACK_PREVIEW_TOKEN=undefined` as a literal string) silently wins. Symptom is indistinguishable from a bad token in `.env`. | `ls -la .env.local .env`: does `.env.local` exist? Print its preview-token value: `grep PREVIEW_TOKEN .env.local`. Is it `undefined`, empty, or different from `.env`? | Either (a) delete the stale `.env.local`, (b) rewrite it from `.env.example` with the current values, or (c) `unset` the offending key from `.env.local`. **Restart the dev server**: env files bake in at start time. Then re-run the preview-token `curl` from the row above to confirm 200/400 (pass), not 401. See also `install-studio` step 7 § `.env.local` shadowing trap. | +| **HTTP 412 "We can't find that Stack. Please try again."** on CMA/CDA calls + **"No composition for /…"** at runtime, most commonly after scaffolding a new project by copying a working app's directory | **Stale `.env.local` carrying the previous project's `api_key`.** Same `.env.local` shadowing trap as the 401 row, but the failing field is the stack api_key (not the preview token). The app initializes the OLD stack while Studio's project points at the NEW one, and that mismatch produces a 412 from the CDA. | `grep -E "API_KEY\|api_key" .env.local .env`: compare the values. If `.env.local` carries a stack key from a different project, that's the cause. Also verify with a CDA probe: `curl -i "https://<cdn>/v3/content_types" -H "api_key: <what-your-app-actually-loads>" -H "access_token: <delivery-token>"`. A 412 confirms wrong stack. | Delete or rewrite the stale `.env.local` from `.env.example`. **Restart the dev server**. Re-probe with the CDA `curl`: 200 means the stack init now matches Studio's project. | <!-- style-lint: allow --> +| **Canvas loads but preview entry is the wrong one** | The preview-entry override isn't reaching the SDK fetch | Studio navbar shows the entry picker. Did the user pick a different entry but the iframe doesn't update? | Hard-reload the canvas iframe. If still wrong: verify the canvas route mounts `<StudioCanvas />` (which reads Studio's iframe params itself). Do NOT have your own code parse Studio's internal `cs-*` URL params: that contract is internal and may change. If you fetch via `sdk.fetchCompositionData` on the server, ensure the full request `searchQuery` is forwarded. | +| **Canvas iframe never finishes loading (spinner forever)** | The canvas-app is starting and failing silently | Open the canvas URL directly (not through Studio). What does the host app show? | If host app errors: fix the host app first. If host app loads fine: clear the iframe's `beforeunload` handler (run `window.onbeforeunload = null` in DevTools console). | +| **"Live Preview not configured"** banner in the canvas | App is missing `@contentstack/live-preview-utils` install OR `ContentstackLivePreview.init()` was never called | `grep -r "live-preview-utils" package.json src/` | Install the package and call `init()` at app shell. See `install-live-preview` skill. | +| **Bindings render the literal path** (e.g. "{{entry.title}}") | The Delivery SDK call inside the canvas-app didn't fetch the entry. Bindings can't resolve | Open Network tab in the canvas-app's DevTools. Was a CDA call made? | If no CDA call: the host app's `<StudioCanvas />` mount is missing the SDK setup. If CDA call returned 200 but body is empty: preview entry UID is wrong. | +| **Compositions list is empty** | Stack or project mismatch | In the Studio top bar, which project is selected? Is the stack key in `.env` the one that owns this project? Compare the `connectedStackApiKey` from `GET /v1/projects` against the api_key the app initializes. | Select the right project. [`configure-studio` § Select the Studio project](configure-studio.md#select-the-studio-project) lists them and checks the stack match. Otherwise re-run provision against the right stack. | +| **"Beforeunload" dialog blocks every navigation** | Studio's leave-without-save guard is firing | Save any unsaved edits, OR `window.onbeforeunload = null` in DevTools to force-clear | Save state first. The guard is intentional. | +| **Section drops on the canvas show "Add a condition for X schema"** | Repeater is bound to a Reference or Modular Block but no Condition Block wraps the iteration | In Layers, does the Repeater have a Condition Block as a direct child? | Accept the inline prompt to wrap, OR invoke `use-condition-block` skill. | +| **Whole composition renders blank or partially blank** (only some sections paint, the rest of the page is empty) AND console shows `TypeError: Cannot read properties of undefined (reading 'slots')` together with `Cannot find a descendant at path [...] in node` | **An empty Section Slot is fatal page-wide.** The SDK renderer walks the composition tree. When it hits a Section Slot whose `children` array is empty (component removed but slot kept, or slot never filled), it throws, aborting the ENTIRE composition's render, not just that slot. Every section that would have painted after the throw goes blank. (This is a Studio renderer bug: an empty slot should render nothing, not throw. Report upstream.) | (a) Decode the path in the error (`[<page-slot-uid>, <index>, <repeater-slot-uid>, <index>, …]`) into the composition tree. (b) Inflate the published `ui` via the `zlib:<base64>` decoder snippet in [`author-composition-via-api`](author-composition-via-api.md) § Diagnostic tooling. Walk the same path. (c) The terminal node is the empty Section Slot. Properties panel for that node also confirms with "The slot is empty, with no component added yet." | Drop a component back into the child slot, or delete that slot. Never delete a `section-slot` node (its empty `slots` entry is required). If authoring via API, ensure every `slot: { <uid>: [...] }` value is a non-empty array AND no node references a removed descendant. Validate before publishing. `adv-post-message` ACK / `highlight-node` console lines are benign hover noise. | +| **Live site (or CDA fetch) shows a different tree than the canvas Layers panel**: fewer sections, missing recent edits, "this works in Studio but not in production" | The canvas **Layers panel is the DRAFT**. The published composition (what CDA serves and the live site renders) is whatever was last **Saved + Deployed**. Unsaved or undeployed canvas edits exist only in the draft session. A broken draft can also mask what's actually published. | (a) Fetch the published composition via CDA: `curl -i "https://<cdn-host>/v3/content_types/<compositions-CT>/entries/<entry-uid>?environment=<env>" -H "api_key: <key>" -H "access_token: <delivery-token>"`. (b) Inflate the `ui` field (it's `zlib:<base64>`, use the decoder in [`author-composition-via-api`](author-composition-via-api.md) § Diagnostic tooling). (c) Diff the inflated tree against what the canvas Layers panel shows. Match = same composition, mismatch = there are unsaved or undeployed edits. | **Save** (Cmd/Ctrl+S in canvas) then **Deploy** (top-right button). Re-fetch via CDA. The inflated tree should now match Layers. Pair with [`deploy-studio-site`](deploy-studio-site.md) for the full Save and Deploy pipeline rationale. | +| **Canvas iframe is blank in Chrome v136+** AND DevTools console shows `net::ERR_BLOCKED_BY_PRIVATE_NETWORK_ACCESS_CHECKS` (or you see no console errors at all but the iframe never paints) | Chrome's Local Network Access protection blocks `localhost:<port>` iframes loaded from a non-local origin (e.g. `app.contentstack.com` loading `localhost:6846`). Affects all Chrome v136+ installs. | In DevTools, open the Network tab and find the canvas iframe request: does it show as blocked, or with a `(failed)` status and `LocalNetworkAccess` in the reason? | Relaunch Chrome with `--disable-features=LocalNetworkAccessChecks`. macOS: `open -a "Google Chrome" --args --disable-features=LocalNetworkAccessChecks`. Add the same flag to your Playwright MCP launch one-liner if applicable. | +| **Canvas iframe is blank AND you're using Chrome's default profile in Studio MCP/automation setup** | Chrome v136+ on org-managed devices (MDM/policy-enrolled) refuses to start the `--remote-debugging-port` listener on the default user profile. Silent failure: `lsof -i :9222` is empty even though `open -a "Google Chrome"` returned 0. | `lsof -i :9222`: empty? `chrome://policy` lists `DeveloperToolsAvailability` or similar enterprise policy? | Use a dedicated `--user-data-dir="$HOME/.chrome-mcp"` profile (the default in `install-playwright-mcp`). The policy applies to the default profile only. An empty user-data-dir is treated as a personal profile. | +| **"CT not found" / 404 from Delivery SDK** when querying compositions, e.g. `stack.ctQuery('compositions')` returns 404 | The compositions CT UID the app reads doesn't match what Studio's provisioning created. Agent-provisioned projects often use `agent_compositions`, not `compositions`. Value is per-project. | In Studio, open the project's **Settings**, then **General**, then **Integration code** (URL: `/studio/projects/<projectId>/settings/general`). Copy the CT UID literal from the snippet. Then grep the canvas-app for where the UID actually lives: could be `.env*`, `src/lib/contentstack.ts`, a config file, or a hard-coded `ctQuery("…")` call: `grep -rn "compositions\|contentTypeUid\|COMPOSITIONS_CT" src/ lib/ app/ config/ .env*`. | Update **every** location the UID appears (don't assume it's in `.env` only) to Studio's integration-code value. Restart the dev server so env-derived reads pick up the change. | +| **Studio canvas iframe loads but Studio's hover/edit overlays don't appear. `studio-renderer.tsx` console.log of URL params shows the wrong key** | Studio sends the composition UID as `cs-composable-uid` in its iframe URL, but older `studio-renderer.tsx` templates parse `cs-composition-uid`. A mismatched key makes the renderer read `undefined`, so there are no overlays and no composition mount. **Likely a real bug worth upstreaming.** | `grep -n "cs-composition-uid\|cs-composable-uid" src/` in the canvas-app | Patch `studio-renderer.tsx` to accept BOTH keys: `const uid = params.get("cs-composable-uid") ?? params.get("cs-composition-uid");`. File an issue against the renderer template repo so the official template gets fixed too. | +| **"Invalid hook call" / minified React #321** when the canvas tries to render a registered component | The SDK peer-deps `react ^18.0.0 \|\| ^19.0.0`, so React 19 itself is fine. The error means either (a) two React instances in the bundle (a peer override or a nested dep pulls a second copy, or a stale `@contentstack/studio-react` 1.5.0 pin was forced through on React 19), (b) Vite's dev-optimizer pre-bundled the SDK with a separate React even though `node_modules` is deduped, (c) the registered component has arity 0 (`function Header() { … }`): the SDK treats parameterless functions in `registerComponent({ component })` as lazy-loader thunks (`() => import("...")`), wraps them with `React.lazy`, and ends up calling the function outside React's render path. | (a) `npm ls react` must show exactly one entry, 18.x or 19.x. Two entries means a dep or an override pulls a second copy. `npm ls @contentstack/studio-react` showing 1.5.0 on React 19 means a stale pin. (b) Stack trace shows two optimized React bundles with different `?v=` hashes. (c) Find the `component:` value in the registration that has zero parameters. | (a) One React major in `ls react`, `@types/*` matching it, and `@contentstack/studio-react` 1.5.1 or later. Drop any `overrides`/`resolutions` that force a second copy. (b) Serve a **prod build** to the canvas (`vite build && vite preview`). Rollup dedupes, the dev optimizer can split. (c) Wrap the component with `(props) => createElement(Comp, props)` or add a `props` arg to the source. See `register-component` arity rule. | +| **"SDK Not Initialized" popup** | **The popup is misleading: this is a red herring in the common case.** Studio surfaces "SDK Not Initialized" whenever the canvas-app's editing handshake times out, regardless of why it timed out. The most common cause is a connected template whose `useCompositionData` never resolves (URL match failed: literal `url`, missing leading slash, API-set `user_specified_pattern` that reverted). `<StudioComponent />` never mounts, handshake times out, popup appears blaming the SDK. The SDK itself is usually fine. | **Confirm the dev server answers (Step 0), then run the section-test pre-flight.** Open any Section (not a Template). If it renders, the SDK is fine. Jump to `troubleshoot-composition-resolution`. If sections ALSO fail, suspect SDK / init / token (see install rows below). Do NOT re-run `install-studio` before doing this test. | If sections render: resolve the underlying URL match per `troubleshoot-composition-resolution`. Typically: ensure the entry's `url` field starts with `/`, the composition `url_metadata.url_source` is `content_type_url_pattern` for a page-type CT, and the composition `url` contains a `{{entry.x}}` pattern (a literal URL never resolves). If sections also fail: re-check SDK init order, credentials, and that `studioSdk.init(...)` was actually called before the canvas mounts. | +| **Connected-template canvas shows "localhost didn't send any data" / "SDK Not Initialized"** while sections render fine | Connected templates iframe at the **environment's base URL** + the template path, NOT at `canvasUrl` (only sections use that). If the env base URL is HTTP and the dev server is HTTPS-only, the iframe gets nothing back. | `GET /v3/environments/<env>`: check `urls[].url`. Compare to the served app's scheme (HTTPS for local mkcert dev). | `PUT /v3/environments/<env>` to set `urls[].url` to the HTTPS URL of the served app (must match the scheme `canvasUrl` uses). | +| **"Template Did Not Load"** in the editor, but the deployed/SSR site renders the template correctly | The template has `section-composition` nodes in its `ui` but the template entry's **`linked_sections` reference field is empty/null** (P27). The Studio editor builds `spec.sectionCompositions` from `?include[]=linked_sections` on load: an empty field yields no section specs, so it cannot resolve the nodes and shows "Template Did Not Load". SSR walks the `ui` nodes directly via `compositionUID` and works regardless. **API-authored templates** are the common culprit: the Studio UI auto-populates `linked_sections` when an author drags a section onto the canvas. API authoring must do it explicitly. | (a) Confirm SSR works: `await sdk.fetchCompositionData({ url })` from a one-off server route returns a usable `spec.data.section_scoped_data` (or just renders the template). (b) Inspect the template entry's `linked_sections` directly: `curl -s "<cdn>/v3/content_types/<comp-CT>/entries/<template-entry-uid>?include[]=linked_sections" -H "api_key:…" -H "access_token:…" \| jq '.entry.linked_sections \| length'`. If 0, that's the cause. | Populate the template entry's `linked_sections` with `[{ uid: "<section-comp-entry-uid>", _content_type_uid: "<compositions-CT-uid>" }, …]`. One entry per distinct placed section, deduped. Publish. See `author-composition-via-api` § Authoring a Template: populate linked_sections for the recipe. | +| **An empty render that was working a minute ago, with no code changes** (empty grid in a section that rendered products, blank Compositions list, blank Templates tab, and the thing rendered correctly seconds earlier) | A **transient 5xx from the Studio API**: `/composable-studio-api/v1/projects/...` returns 503 / 502 / 504 intermittently. Studio gets no data, renders empty. Not your binding, not your CT, not your config. | In DevTools, open the Network tab, filter to `composable-studio-api`, and look for a 503 / 502 / 504 around the time the render went empty. If you find one, that's the cause. If everything in Network shows 200s, the empty render is something else, keep debugging. | **Reload.** That's the fix when it's transient. Do NOT chase a transient 5xx as a binding bug: empty renders look identical to real binding failures and you'll burn an hour walking through the wrong cause space. If the 5xx reproduces consistently (not just one-off), it's no longer transient. Escalate with the API path, project ID, and timestamp. | +| **Repeater (or any data-driven container) still empty AFTER toggling Preview Mode on** | Preview Mode controls whether bindings resolve, not whether they resolve correctly. Once on, flipping it again does nothing: the binding shape is wrong. | Confirm Preview Mode is on (in the right panel, under Configuration). If on AND empty, the binding is wrong. | Diagnose the binding: re-check Bind items source, confirm items exist in payload (DevTools Network), verify scope (see `use-repeater` § The scope rule, in user terms), confirm a Condition Block wraps Reference / Modular Block iteration. See `troubleshoot-data-binding`, `build-repeating-section`. | +| **A registered component (or child Section) dropped into a Section Slot renders full-bleed / full-screen**, looks correct on the live storefront, broken in Studio | The parent Section is missing a layout container around the Slot. See `use-section-slot.md` § Layout container for the canonical rule. | Open the parent Section, go to Layers, and confirm the Slot sits inside a sized Box. If it's a direct child of the Section root, that's the bug. | Wrap the Slot in a layout container (grid / sized Box). Do NOT add `max-width` on the component. See `use-section-slot.md` § Layout container. | +| **"No Studio Code on This Route"** ("The front-end code for this URL doesn't use Studio yet") for a route that DOES mount `<StudioComponent>` / `<StudioCanvas>` | This banner reflects what the **iframe's response** looked like, not your source code. A local build/syntax error (a broken import, an unterminated statement, a stray character from mid-edit) makes the dev server return a compile-error overlay instead of your app's real output. Studio can't recognize that as Studio-integrated markup and falls back to the generic "custom code" message. | Open the exact URL directly in a plain browser tab (not through Studio's iframe). Do you see your app, or a Next.js / Vite / Turbopack error overlay? Also check the dev-server terminal for a parse/compile error timestamped around the request. | Fix the actual build error, reload. The banner clears once the route serves real content again — don't chase the Studio integration, chase the build error. | +| **Live Preview intermittently doesn't apply**: the same composition/entry resolves against `rest-preview.contentstack.com` (drafts visible) on one internal fetch and falls back to `cdn.contentstack.io` (published-only) on the next, within the same resolution, with no code change in between | Shared, mutable Live Preview state on a stack instance reused elsewhere in the app. See § Live Preview state resets mid-request below. | Log the `live_preview` request header across consecutive outgoing calls to the same stack (see § below for the exact snippet). Does it go from populated to empty partway through one resolution? | Give Studio's `stackSdk` its own independent stack instance instead of reusing one shared with the rest of the app. Full mechanism and fix in § Live Preview state resets mid-request. | + +## Inputs needed from the user + +1. `symptom`: one sentence. If the symptom doesn't clearly match a row, ask the user to describe what they see (canvas-iframe content vs surrounding UI) before guessing. + +## Acceptance + +This skill succeeds only when ALL of the following are true. + +- [ ] The reported symptom was matched to one row in the table above. +- [ ] The "Check first" diagnostic was run (or the user was directed to run it). +- [ ] If the diagnostic confirmed the cause, the fix was applied or the user was given the exact action to take. +- [ ] If the diagnostic failed to confirm, the second-most-likely cause was offered (or escalation to `verify-setup` / `docs/10-setup/troubleshoot-common-studio-issues.md` was suggested). +- [ ] The user was NOT given more than two diagnostic guesses in a row. If both miss, escalate to systematic verification. + +## Third-party global-script overlays inside the canvas: how to spot + fix + +> Widget/banner overlap (OneTrust, chat bubbles) or analytics pollution is NOT this section: that's `gate-third-party-scripts`, which applies the SDK's `isStudioCanvas()` editor-mode contract. This section is for third-party loaders that crash with error overlays. + +**Symptom:** the Studio canvas renders fine, but a runtime error overlay pops in the iframe: **`pageerror: Load error!`** / `Failed to load lytics` / **`net::ERR_ABORTED`** from `kit.fontawesome.com` / a `next/script` "Load error!" from a commerce embed. The composition renders correctly BELOW the overlay: the error isn't from Studio. + +**Root cause:** the app's canvas route inherits parent layouts, and those layouts inject analytics tags / third-party embeds / global `<Script>`s. Many of those loaders `throw new Error(...)` in their `onload`/`onerror` handler when their remote asset 403s or their init misconfigures. The throw surfaces as a runtime-error overlay inside Studio's canvas iframe. Nothing to do with Studio, everything to do with the loader running on the canvas route. + +**Diagnose in one minute:** + +1. Open the canvas route directly in a normal browser tab: `http://localhost:<port><canvasPath>`. +2. In DevTools, open Network and filter by "Failed" or by hostname (`lytics`, `fontawesome`, third-party CDN of your embed). +3. In DevTools, open Console: any `pageerror` line points at a script URL. That script is your culprit. + +**Fix in order of preference:** + +1. **Isolate the canvas route from app chrome** (best). Route-group / layout guard so analytics + embeds + global scripts do NOT run when the path matches `*/canvas`. Full recipe in [`setup-section-preview`](setup-section-preview.md) § Canvas route isolation. This is the correct long-term shape: the canvas route should be minimal by design. +2. **Patch loaders that `throw`.** Some SDKs let you swap the `throw` for `console.warn`, e.g. Lytics `jstag`'s tag-load handler can be modified to `warn` + `return` instead of `throw`. Keeps the tag stub on other pages that use it, kills the overlay. +3. **Remove vestigial loaders.** FontAwesome kits often persist in `<Script>` tags after the app moved to a different icon system. Grep the app for `fa-` class usages. If there are none, delete the kit script. +4. **Prefer plain `<script async>` over `next/script`.** `next/script` (Next.js) throws its own "Load error!" when a network error occurs. A plain async `<script>` fails silently to the console. Only relevant when the script can legitimately fail (marketing tags, optional embeds). + +**Rule of thumb:** every request the canvas iframe makes that is not to the Studio SDK or your app's own bundles is a candidate for elimination. If it doesn't need to run under Studio's iframe, don't run it. + +## Live Preview state resets mid-request: shared stack instance hazard + +**Symptom:** resolving one composition/URL makes several internal CDA/CMA calls (a literal-URL search, a candidate-ranking search, an entry lookup), and only the FIRST of them actually uses Live Preview (`rest-preview.contentstack.com`, populated `live_preview` header, drafts visible). The rest silently fall back to the plain Delivery API (`cdn.contentstack.io`, empty `live_preview` header, published-only), with no error and no code difference between the calls. Net effect: an unpublished/draft-only composition resolves on the lucky first attempt and 404s ("Composition not found for URL: …") the moment resolution needs a second internal query — which is the common case for anything but an exact literal-URL match. + +**Root cause:** `stack.livePreviewQuery(...)` does not scope to one call. It mutates a single shared object living on the stack instance itself — `_client.stackConfig.live_preview` in `@contentstack/delivery-sdk` (`node_modules/@contentstack/delivery-sdk/dist/modern/lib/stack.js`), the equivalent field on the legacy `contentstack` v3 SDK. The actual host-switch happens centrally, in `@contentstack/core`'s request layer: + +```js +// @contentstack/core/dist/esm/src/lib/request.js +if (instance.stackConfig && instance.stackConfig.live_preview) { + const livePreviewParams = instance.stackConfig.live_preview; + if (livePreviewParams.preview_token) { + instance.defaults.headers.live_preview = livePreviewParams.live_preview; + } + if (livePreviewParams.enable && livePreviewParams.live_preview !== 'init') { + url = 'https://' + livePreviewParams.host + url; // only switches host here + } +} +``` + +This reads whatever the shared `stackConfig.live_preview` object currently holds, at the moment each individual HTTP call fires — not a value scoped to the higher-level operation that started the fetch. + +**The collision:** this is only a problem when TWO code paths share the SAME stack instance and have DIFFERENT live-preview intentions on the SAME request. This is exactly the shape of `install-studio`'s minimal-add pattern (Studio's `stackSdk` reusing an existing app's stack). Concretely, in a Next.js App Router app: + +1. Studio's SDK (`@contentstack/studio-client`) reads the `hash` param from the incoming request's `searchQuery` and calls `stackSdk.livePreviewQuery(hash)` before its first internal query — correctly switches that ONE call to the preview host. +2. A parent layout on the SAME request does its own unrelated data fetch (e.g. homepage data for SEO metadata) via a helper that calls `stack.livePreviewQuery(live_preview ?? {})` with no override — a perfectly reasonable thing for THAT call to want (metadata generation arguably should reflect published state, not an in-progress draft). But since `query.live_preview` is falsy, the delivery-sdk's own `livePreviewQuery` takes its reset branch and explicitly sets `live_preview: ""` on the SHARED object. +3. Next.js runs a page's layout(s) and the page itself concurrently on one request. That reset can land in the gap between Studio's first and second internal queries, wiping the hash Studio just set before its next call goes out. + +Neither call site is "wrong" in isolation — each wants the correct behavior for its own purpose. The bug is architectural: the API conflates "no live preview for my call" with "no live preview for anyone sharing this object right now," because state lives on a mutable singleton instead of being scoped per-call. + +**Check first:** confirm it empirically rather than reasoning from source alone. Patch Node's `http`/`https` layer to log the `live_preview` header on every outgoing Contentstack request (must be added before any Contentstack package's module graph loads, so restart the dev server after adding it, not hot-reload): + +```js +// lib/debug-fetch-interceptor.js — diagnostic only, delete when done +import http from "node:http"; +import https from "node:https"; +const CS_HOST_PATTERN = /contentstack\.(io|com)/; +function patch(mod) { + if (mod.__csPatched) return; + mod.__csPatched = true; + const originalRequest = mod.request; + mod.request = function (...args) { + const req = originalRequest.apply(this, args); + const host = req.getHeader?.("host") || req.host; + if (host && CS_HOST_PATTERN.test(host)) { + console.log(`[cs-http] -> ${req.method} https://${host}${req.path}`); + console.log("[cs-http] live_preview header:", req.getHeader?.("live_preview")); + } + return req; + }; +} +if (!globalThis.__csHttpPatched) { + globalThis.__csHttpPatched = true; + patch(http); + patch(https); +} +``` + +Import it for side effects at the top of the route under test, restart the dev server (not hot-reload — module-load order matters), and trigger the failing resolution. If the logged `live_preview` header is populated on the first call and empty on later ones for the SAME request, this is the cause. Then grep for every other call site sharing the same stack instance: `grep -rn "livePreviewQuery(" src/ app/ lib/`. + +**Fix:** give Studio's `stackSdk` its own independent stack instance, separate from whatever stack the rest of the app reuses for its own content fetching. Reuse the same credentials/env vars to construct it — just not the same object. This removes the collision entirely: the rest of the app can keep calling `.livePreviewQuery({})` for its own reasons on its own stack, and Studio's hash stays untouched because it's a different instance. See [`install-studio`](install-studio.md) § Existing Contentstack app? Take the minimal-add branch. + +## Common pitfalls + +| Pitfall | Why it bites | Fix | +| --- | --- | --- | +| Guessing the cause from prior probability instead of running the diagnostic | Wastes user time. The same symptom has different causes in different setups | Always run the "Check first" diagnostic before proposing a fix | +| Treating "canvas blank" and "canvas shows host home page" as the same symptom | Blank = wrong URL. Home page = missing route. Opposite fixes. | Look at the actual iframe contents before classifying | +| Suggesting `npm install` as a generic fix | Hides the real issue when the user has a stale `.env` or wrong Canvas URL | Only suggest npm-level fixes when the diagnostic actually finds a missing package | +| Recommending the user re-paste credentials when the Canvas URL is the real problem | Touching `.env` is no-op if credentials weren't the issue. Wastes the user's time | Check the Canvas URL config FIRST before recommending any credential change | +| Diagnosing "No Studio Code on This Route" as a routing/registration gap | The banner reflects a broken iframe response, which a local build/syntax error also produces. Chasing Studio wiring wastes time when the real cause is a compile error | Load the URL directly in a plain tab first — see it your app, or a build-error overlay? | +| Treating an intermittent Live-Preview failure as a bad token or Studio misconfiguration | Same token, same hash, same request — it's a shared-stack race, not a credentials problem. Re-pasting tokens or reconfiguring Studio does nothing | Check whether Studio's `stackSdk` shares a stack instance with the rest of the app; see § Live Preview state resets mid-request | + +## See also + +- `docs/10-setup/troubleshoot-common-studio-issues.md`: the canonical reference of symptoms and their fixes (read this if the matrix above doesn't fit) +- `verify-setup`: layered smoke test, run this if you're not sure WHICH layer is failing +- `install-studio`, `install-live-preview`: for actual reinstall steps +- `register-component`: for "Component Loading Error" cases involving registered components diff --git a/skills/studio/troubleshoot-composition-resolution.md b/skills/studio/troubleshoot-composition-resolution.md new file mode 100644 index 0000000..1829021 --- /dev/null +++ b/skills/studio/troubleshoot-composition-resolution.md @@ -0,0 +1,171 @@ +--- +name: troubleshoot-composition-resolution +--- + +## When to use + +Symptom-mapped diagnostic for "the URL I expected to render a composition renders the wrong one, or none." Covers URL pattern matching, `{{entry.url}}` lookup, specificity ties, and unified-URL-resolution semantics. + +Use when a LIVE-site URL resolves to the wrong composition, none, or non-deterministically. Phrases: "URL renders the homepage", "two templates match the same pattern", "/blog/post-1 404s", "wildcard stopped matching". If the symptom is ambiguous, route via `troubleshoot` first. Do NOT use for canvas-only issues (use `troubleshoot-canvas`) or when the host app lacks a catch-all route. That's setup. + +> **Mandatory auth preflight: settle the credential before the first API call.** Resolve it OAuth-first per [`authenticate-cma`](authenticate-cma.md): `CS_OAUTH_ACCESS_TOKEN`, else the Contentstack MCP's stored session: Read that skill for where it is, even with no MCP connected. **Never ask the user for a session `authtoken`.** If nothing resolves, or a refresh fails with `400 invalid_refresh_token`, hand them `! CONTENTSTACK_REGION=<code> npx @contentstack/mcp --auth` (it needs a TTY and a browser, so it cannot be run for them) and wait. `403 error_code 316` is a valid credential aimed at another org: fix the org or the `api_key`, do **not** re-authenticate. + +# Troubleshoot composition URL resolution + +## Arrived here from "SDK Not Initialized"? + +If Sections render but the Template shows "SDK Not Initialized," you're in the right skill. The SDK is fine. The connected template's URL match failed, so `useCompositionData` never resolves, `<StudioComponent />` never mounts, and Studio's handshake times out. See `troubleshoot-canvas` § Run this FIRST: the section-test pre-flight to confirm Sections pass before continuing here. + +## Context + +When a request URL lands on a route that mounts `<StudioComponent>`, the SDK resolves it to a composition in three stages: + +1. **Pattern match**: every composition's `url` pattern is compiled into a regex. The request URL is matched against all of them. Wildcards / placeholders (`{{entry.url}}`, `*`) capture segments. Literals must appear verbatim. +2. **Candidate ranking**: surviving patterns are ranked by **literal-character specificity** (how many non-placeholder characters they have). The most specific tier wins. Ties go to the next stage. +3. **Entry lookup**: for patterns containing `{{entry.<field>}}` or `*`, the SDK queries the linked content type for an entry whose field matches the captured segment. If only one tied candidate has a matching entry, it wins. If multiple do, the first by `composable_uid` order wins (deterministic but arbitrary). + +Most production bugs in this area are caused by: +- **Authoring confusion** between "literal prefix in the pattern" vs "prefix stored on the entry's `url` field". When an entry's `url` field stores the full path (e.g. `/blog/post-1`) but the pattern's captured segment is just the slug (`post-1`), the entry lookup misses. Recent SDK versions handle this via a full-path `$or` branch in the entry query (see § Symptom matrix below). +- **Specificity ties** between `/{{entry.url}}` and `/{{entry.slug}}` patterns linked to different content types. +- **Wildcard `*` handling** in older SDK versions where `*` was regex-escaped and only matched the literal star. + +The SDK addresses several of these via a full-path `$or` branch in the template query. When the pattern contains `{{entry.url}}`, the CDA query becomes `{ $or: [<per-field>, { url: <full request path> }] }`, resolving whether the stored `url` is the captured slug OR the full path. + +This skill maps the user-visible symptom to the failing stage and proposes the fix. + +## Diagnostic tooling: inspecting a stored composition's `ui` + +URL-resolution debugging needs the actual `url_metadata` (e.g. `url_source`, `url_queries`) on the stored composition. Fetch via the Studio API. The **`ui`** field is **`zlib:<base64>`**, literal `zlib:` prefix + base64-encoded zlib-deflated JSON. Decode for read-only inspection: + +```js +// Node 18+. Save as inspect-ui.js +import zlib from "node:zlib"; + +function decodeUi(ui) { + if (!ui.startsWith("zlib:")) return JSON.parse(ui); // legacy plain-JSON fallback + const bytes = Buffer.from(ui.slice("zlib:".length), "base64"); + return JSON.parse(zlib.inflateSync(bytes).toString("utf8")); +} + +const composition = JSON.parse(await new Response(process.stdin).text()); +console.log(JSON.stringify(decodeUi(composition.ui), null, 2)); +``` + +**Read-only, for debugging.** Do not modify a composition via the API. The UI-only `url_queries` metadata the Edit-URL modal generates is exactly what gets lost when you try, which is the root cause of finding #9 (API-set `user_specified_pattern` reverts). Use Studio's Edit-URL modal to author URL patterns. + +## Task + +1. **Capture the SDK version** in the host app: `grep '"@contentstack/studio-client"' package.json` (or whichever SDK package the app uses). Note the version. Fixes referenced below land in SDK alpha after the unified-URL-resolution ADR. Confirm via the SDK CHANGELOG before promising a fix is available. +2. **Open DevTools, then the Network tab** in the host app and reload the misbehaving URL. Filter to `cdn.contentstack.io` (or the configured CDA host). You should see at least one composition-fetch query and (usually) an entry-fetch query. +3. **Match the symptom** to the matrix below, pick the closest one. +4. **Run "Check first."** If positive, apply the fix. If negative, run the secondary check. +5. **Report:** the symptom, which stage failed, the check you ran, and the fix. Cap at two checks. Escalate to engineering if both come back negative (paste the request URL, the candidate patterns from Studio, and the CDA call from DevTools). + +### Symptom-to-stage matrix + +| Symptom | Likely stage that failed | Check first | Fix | +|---|---|---|---| +| **404 / "No composition matched"** for a URL you DO have a pattern for | Pattern match | In Studio, open Compositions and confirm the pattern. Is it `/blog/{{entry.url}}` or `/blog/*`? Does it have an unintended trailing slash? Is there a Stack-level URL prefix the dev app should strip? | If the pattern uses `*` (e.g. `/blog/*`): confirm the SDK version contains the wildcard restoration fix. Older SDKs escape `*` and the pattern only matches the literal URL `/blog/*`. Upgrade the SDK or rewrite as `/blog/{{entry.url}}`. If the pattern uses `{{entry.url}}` and the entry's `url` field is empty / not the slug you expect: fix the entry's `url` field. | +| **Wrong composition wins** when two patterns tie on specificity (e.g. `/blog/{{entry.url}}` and `/article/{{entry.url}}` both exist, request is `/blog/post-1` and the article template renders) | Candidate ranking: specificity tie | Inspect the candidates via `rank-url-matches.ts` logic: each match carries a `literalCharCount`. Ties at the top tier all enter entry lookup. In a host app, log `ranking.topTierMatches.length` from the SDK if exposed. Otherwise reason about it from the patterns. | When the request URL belongs unambiguously to one CT, the entry-lookup stage should drop tied candidates whose CT has no matching entry. Confirm SDK version contains the unified-URL-resolution ADR work. Otherwise: disambiguate by adding more literal prefix (e.g. `/blog/post/{{entry.url}}` vs `/article/{{entry.url}}`). | +| **Right composition matches but wrong entry renders** (e.g. `/blog/post-1` matches `/blog/{{entry.url}}` but renders post-2) | Entry lookup: `entry.url` field mismatch | Check DevTools Network: the CDA entry query body. Does it have `{ $or: [<per-field>, { url: <full path> }] }`? If yes, the full-path branch should resolve entries stored with the prefix. | If the captured segment is `post-1` but the entry's `url` field stores `/blog/post-1`: the full-path `$or` branch handles this. Confirm your SDK version includes that fix. Otherwise: pick ONE convention across the CT's entries (slug-only OR full path) and apply consistently. | +| **The same URL renders different compositions on different reloads / different machines** | Candidate ranking: tied with no disambiguator | Reload 10× with cache disabled. If the result varies: multiple top-tier candidates tied and the SDK is returning the first hit non-deterministically. | The entry-existence drop in the unified-URL-resolution work makes ties deterministic when only one tied CT owns a matching entry. Confirm SDK version. Backstop: rewrite patterns so only one is top-tier for any given URL (add literal prefix). | +| **`*` wildcard pattern (e.g. `/blog/*`) doesn't match anything but `/blog/*` literally** | Pattern match: legacy wildcard | Inspect `url-pattern-matcher.ts` in your SDK version: does `compileUrlPattern` treat `*` as a placeholder, or does `escapeRegExp` escape it? If the latter, the pattern only matches the literal star. | Upgrade SDK to a version where `*` is compiled as a placeholder (check `url-pattern-matcher.spec.ts` for wildcard test cases), OR rewrite the pattern as `/blog/{{entry.url}}`. | +| **Bare `/{{entry.url}}` pattern catches everything, including URLs you wanted other compositions to handle** | Specificity (working as designed, but unintended) | In Studio, open Compositions: which compositions have bare placeholders at the root (`/{{entry.url}}`, `/{{entry.slug}}`)? | Add literal prefixes to disambiguate (e.g. `/article/{{entry.url}}`). The entry-lookup stage's CT filter only helps when patterns tie on specificity, not when one bare pattern is uniquely the most specific match. | +| **`/blog/post-1` resolves to the homepage** (`/` or `/home`) | Catch-all routing in the host app, NOT SDK resolution | `grep -r "StudioComponent" src/` in the host app. Does the catch-all route exist? Or does a specific `/blog/*` route mount something else? | Add the catch-all route. See `embed-composition` skill. This isn't a Studio bug. | +| **An API-authored template renders fine on the live route, but Studio's Edit-URL panel shows the pattern as hand-typed**: no sign it derives from the connected CT. Editing anything in the modal loses the CT link | Not a resolution failure. The composition was written with `url` but **no `url_metadata`**, so Studio has no derivation to read and treats it as `legacy_url`. Delivery still resolves off the `url` pattern, which is why it looks healthy | Fetch the composition entry via the CMA. Is `url_metadata` absent, or is `url_metadata.url_source` unset? Then: does the compositions CT even model `url_metadata` as a group with `url_source` + `url_queries`? (A missing CT field makes the CMA drop the object silently on write.) | Fix the CT field first if missing (`provision-studio-project`), then re-write the composition with the matched pair: `url_source: "content_type_url_pattern"` for Connected on a page-type CT, `default_url_pattern` / `user_specified_pattern` for Freeform, `url_queries` as a JSON string. Read the entry back and confirm it persisted. See [`author-composition-via-api` § `url` + `url_metadata`, a matched pair](author-composition-entry.md#url-metadata-matched-pair). | +| **Some compositions never appear in the candidate set even though they should** | Workspace scale: SDK fetches `url_metadata.$exists: true` and ranks client-side. Very large composition counts can exhaust the response payload | In Studio, open Compositions: how many compositions total? If >500, scale is plausible. Inspect the CDA composition-fetch response size and compare to the count. | Engineering escalation. There is no shipped client-side workaround. Reduce composition count or use more specific patterns as an interim measure. | +| **The pattern looks right but evaluates with `{{entry.title}}` rendering capitalized + `%20`-encoded** | The pattern uses `{{entry.title}}` instead of a slug field | In Studio, open the composition, then URL pattern. Does it reference `entry.title`? | Replace with `{{entry.url}}` or `{{entry.slug}}`. `title` is human-readable text and breaks on rename. | +| **CT `url_pattern: "/:slug"` saves but entries' `url` comes back literally `/:slug`** (not substituted) and the canvas URL stays `/:slug`, every entry of this CT looks like it has the same URL | Contentstack's **CMS-side** CT URL-pattern compiler (separate from the Studio SDK pattern compiler) recognizes `:title` as a substitution token. `:slug` is treated as a **literal**. The pattern stores correctly but nothing gets slugified into `entry.url` on save, so the SDK's lookup (which queries by `entry.url`) finds no per-entry URL to match against. | In Studio, open the content type, then URL pattern. Is the placeholder `:slug` or `:title`? | Switch the CT `url_pattern` to `:title` (CMS slugifies the title field: that's the working substitution token). Re-save + republish every entry so `entry.url` recomputes. In the composition, use `{{entry.title}}` for the placeholder. `:slug` is a footgun. Remove it from CT patterns. | +| **A multi-entry connected template 404s for every URL** (`/<route>/<slug>` returns "No composition for /…"), but a single-entry connected template (literal URL like `/home`) resolves fine on the same project | The multi-entry template's composition `url` is a literal pattern (e.g. `"/blog/:slug"`). The SDK can't match a literal `:slug` against any entry URL. Single-entry templates are fine with literal URLs because they only resolve one path. Multi-entry templates need a placeholder pattern + CT `url_pattern` that produces real per-entry URLs. | In Studio, open Compositions, select the multi-entry template, and read its URL pattern. Is it literal (`/blog/:slug`) or placeholder (`/blog/{{entry.title}}`)? Then check the CT's URL pattern. Is it `:slug` (broken) or `:title` (working)? Then pick any entry: does its `url` field show a real slug like `/blog/welcome-to-studio` or the literal `/:slug`? | The proven shape: CT `url_pattern: "/:title"` + `url_prefix: "/<route>/"` (e.g. `/blog/`). Composition `url: "/<route>/{{entry.title}}"` with `url_metadata.url_source: "content_type_url_pattern"` and `url_queries: ""`. Re-save + republish entries after the CT pattern change. CT pattern and composition URL must be **consistent by construction**. See [`build-connected-template § Single-entry vs multi-entry URL pattern rule`](build-connected-template.md#single-entry-vs-multi-entry-url-pattern-rule). | + +### Runtime error taxonomy: what the SDK actually throws + +The three-stage model above describes where resolution fails. This is what that failure looks like from the calling code, and it's the part most route wiring gets wrong. + +**`sdk.fetchCompositionData` throws on a miss. It does not resolve with `hasSpec: false`.** So the standard guard `if (!specOptions.hasSpec) notFound()` is unreachable on exactly the path it was written for: the `await` throws, the framework's error boundary catches it, and the visitor gets a **500 where a 404 belongs**. A "my 404 page never shows / unmatched URLs return 500" report is this, not a routing bug. + +It throws a `ComposableStudioError` carrying a **`code`** and an **`errorClass`**. For the 404 decision read neither: call `isCompositionNotFound(err)`, true for exactly the codes below and for any added after your build shipped. + +| `code` (class `NOT_FOUND`) | Stage that failed | Typical cause | +|---|---|---| +| `COMPOSITION_UID_NOT_FOUND` | Lookup by identifier | Wrong or deleted uid: what embeds and Freeform fetches get | +| `NO_COMPOSITION_FOR_URL` | 1: pattern match | Unclaimed URL, or a literal pattern where a placeholder belongs | +| `URL_MATCHED_NO_ENTRY` | 3: entry lookup | Bad slug under a connected template, or the entry's `url` is empty or unpublished | +| `CONNECTED_ENTRY_NOT_FOUND` | 3: entry lookup | No entry authored, or none published to this environment | + +Every other class is real breakage and **never a 404**: `SETUP_INVALID` (a wrong connection value), `SERVER_ERROR`, `NETWORK_ERROR`. That split is the point. All three used to arrive under one name reading "composition not found", so a route branching on it returned **404 on pages that exist**. + +**Read `err.causes` first.** The SDK attaches `{ cause, fix }` pairs generated for that specific failure, ordered most likely first. It's the fastest diagnosis available and most people never look at it. + +**`content_type_url_pattern` relocates the failure. This is the trap.** With `url_metadata.url_source: "content_type_url_pattern"`, the composition's pattern covers a whole URL family (`/blog/{{entry.title}}` matches any `/blog/*`). A mistyped slug therefore **passes** stage 1 and dies at stage 3: `URL_MATCHED_NO_ENTRY`, not `NO_COMPOSITION_FOR_URL`. Both are `NOT_FOUND`, so the predicate covers both — but route code that hand-rolls a list of names and only knows the first 404s correctly for `/nonsense` and 500s for `/blog/nonsense`. Test both. + +Classify with the predicate: + +```ts +import { isCompositionNotFound } from "@contentstack/studio-client"; + +if (isCompositionNotFound(err)) { /* 404 */ } else { throw err; /* 500 */ } +``` + +`useCompositionData` doesn't throw, it stores the same error object in `error`, so `isCompositionNotFound(error)` classifies identically. + +The wrapper that turns this into correct 404-vs-500 behavior lives in [`configure-csr-vs-ssr` § `fetchCompositionData` does not return "not found"](configure-csr-vs-ssr.md#resolve-composition-helper), one canonical `resolveComposition` helper. Don't hand-roll a second one, and never blanket-catch: a bare `catch { notFound() }` converts CDA outages and expired tokens into silent 404s. + +<a id="editor-vs-ssr-url-derivation"></a> + +### Editor vs SSR: URL derivation is not the same code path + +The symptom matrix above is about **live-site** resolution. The Studio **editor** resolves URLs differently, and a composition can pass one while failing the other. Reported as "the page works but the template won't open in Studio" (or the reverse). + +| | Live site / SSR | Studio editor | +|---|---|---| +| What it resolves | The request path becomes a composition, via the compiled `url` pattern + a CDA entry query | A **preview entry** for the open template, via `prepareResolution` (`iframe-url/resolvePreviewEntry.ts`) | +| Needs `url_metadata`? | No, resolves off `url` alone | **Yes.** With no `url_metadata` you get `no-fetch` with reason `no-composition`, so there is nothing to derive a preview URL from and the template won't open | +| Honours `url_source`? | For query construction | **Decisively**: see the table below | + +Editor query shape by `url_source`, and the error each raises when nothing matches: + +| `url_source` | Editor query | Substitutes `{{entry.x}}`? | Error | +|---|---|---|---| +| `content_type_url_pattern` | Fixed `url`-field existence (`requiredVariables: ["url"]`) | **No** | `MISSING_CT_PATTERN_URL_FIELD`, shown as "No entry has a URL field populated yet" | +| `legacy_url` | Same fixed `url`-existence query | **No** | `MISSING_LINKED_URL_FIELD` | +| `user_specified_pattern` | Matches on the fields extracted from the pattern | **Yes** | `NO_ENTRY_WITH_ALL_VARIABLES` | + +**The mismatch that produces this most often:** `url_source: "content_type_url_pattern"` on a CT where **no entry has a non-empty `url` field**. That source ignores your pattern and queries for `url` existence, so it doesn't matter what `{{entry.x}}` you wrote. Typical on a **singleton** CT (`options.singleton: true`, no `url_pattern`). Nothing auto-generates `url`, and if nobody set it by hand the editor shows "No entry has a URL field populated yet" while SSR renders fine off the literal `url`. + +**Two valid fixes, pick by where the URL comes from:** +- If the URL is a fixed path (`/rewards`), keep `content_type_url_pattern` and **set `url` on the entry by hand**. Cheapest, and it's what a singleton expects. +- If the URL derives from some other field (`{{entry.slug}}`, a reference), switch to `user_specified_pattern`, the only source that substitutes pattern variables. + +See [`author-composition-via-api` § Which `url_source` to write](author-composition-entry.md#url-metadata-matched-pair). + +**Check, in order:** (1) does the composition have `url_metadata` at all? (2) does `url_source` match how the URL is really derived? (3) for `content_type_url_pattern` / `legacy_url`, does at least one entry of the CT have a non-empty `url` starting with `/`? A "yes, yes, no" is the classic case. + +### When to escalate + +If both checks for a symptom come back negative, gather and send to engineering: + +1. The request URL (exact, with query string). +2. Every candidate composition's `url` pattern from Studio. +3. The CDA calls from DevTools Network (request URL + response). +4. The SDK package version. +5. Whether `<StudioComponent>` is mounted on a catch-all route, and the route file path. +6. If available, output from any composition-resolution diagnostic surfaced in your app (the SDK exposes `CompositionResolutionDiagnosticEventData` and Studio's diagnostic-inspector module instruments this. Whether it's user-visible depends on the host app and Studio version). + +Without those six pieces, engineering can't reproduce. Don't open a ticket until you have all six. + +## Why this is a separate skill from `troubleshoot-canvas` + +`troubleshoot-canvas` covers the **authoring iframe** (Studio's canvas, what the author sees while building). This skill covers the **live site** (what the visitor sees at the public URL). They share zero failure modes: + +- Canvas issues are about communication between Studio and the canvas app (Live Preview, Delivery Token, route mounting). +- Resolution issues are about the matching logic between the SDK and the CDA on the live site. + +If unsure which surface is failing: is the issue visible to authors only (inside Studio's iframe), or to visitors only (on the live URL)? That tells you which skill to use. + +## What this skill is NOT + +- Not a guide to authoring URL patterns from scratch. Use `build-connected-template` for that. +- Not a guide to debugging Studio's editor (use `troubleshoot-canvas`). +- Not a replacement for reading the SDK CHANGELOG. Version-specific fixes referenced here may not exist in older SDKs. +- Not a routing-setup guide. If the catch-all route is missing, use `embed-composition`. diff --git a/skills/studio/troubleshoot-data-binding.md b/skills/studio/troubleshoot-data-binding.md new file mode 100644 index 0000000..e995639 --- /dev/null +++ b/skills/studio/troubleshoot-data-binding.md @@ -0,0 +1,98 @@ +--- +name: troubleshoot-data-binding +--- + +## When to use + +Symptom-mapped diagnostic for bound fields rendering incorrectly: covers binding paths, type mismatches, repeater item context, and condition-block parent dependency. + +Use when a binding is wired but renders the wrong value, an empty value, the literal path, or breaks at runtime. Phrases: "binding shows nothing", "wrong value rendering", "literal {{...}}". If the symptom is ambiguous, route via `troubleshoot` first. Do NOT use for canvas iframe failures (use `troubleshoot-canvas`), SSR errors (`troubleshoot-ssr-rendering`), or URL resolution (`troubleshoot-composition-resolution`). + +> **Mandatory auth preflight: settle the credential before the first API call.** Resolve it OAuth-first per [`authenticate-cma`](authenticate-cma.md): `CS_OAUTH_ACCESS_TOKEN`, else the Contentstack MCP's stored session: Read that skill for where it is, even with no MCP connected. **Never ask the user for a session `authtoken`.** If nothing resolves, or a refresh fails with `400 invalid_refresh_token`, hand them `! CONTENTSTACK_REGION=<code> npx @contentstack/mcp --auth` (it needs a TTY and a browser, so it cannot be run for them) and wait. `403 error_code 316` is a valid credential aimed at another org: fix the org or the `api_key`, do **not** re-authenticate. + +# Troubleshoot Studio data binding + +## Context + +Data binding in Studio flows through three layers, each with distinct failure modes: + +1. **Authoring (Studio editor)**: the user picks a field via the Data Picker. The picker enforces type / shape rules at selection time. Violations surface as inline modal errors. +2. **Spec validation (registry)**: when a component is registered, the SDK checks each prop's declared type against incoming bindings. Mismatches log in editor mode. +3. **Runtime resolution (SDK data-binder)**: at render time, the SDK walks the binding path against actual data, falls back to static value or type-default if missing, and `console.warn`s on type mismatches **in production only** (editor mode suppresses them). + +Almost every "field renders wrong" report maps to one of these three layers. This skill walks each symptom to its failing layer, then to the UI control that fixes it. + +## Diagnostic tooling: inspecting a stored composition's `ui` + +Binding-shape debugging often needs the actual stored shape, what the Data Picker emitted into the composition. Fetch via the Studio API. The **`ui`** field comes back as **`zlib:<base64>`**, a literal `zlib:` prefix + base64-encoded zlib-deflated JSON. Decode for read-only inspection: + +```js +// Node 18+. Save as inspect-ui.js +import zlib from "node:zlib"; + +function decodeUi(ui) { + if (!ui.startsWith("zlib:")) return JSON.parse(ui); // legacy plain-JSON fallback + const bytes = Buffer.from(ui.slice("zlib:".length), "base64"); + return JSON.parse(zlib.inflateSync(bytes).toString("utf8")); +} + +const composition = JSON.parse(await new Response(process.stdin).text()); +console.log(JSON.stringify(decodeUi(composition.ui), null, 2)); +``` + +The reliable debugging move when bindings misbehave is: capture a working composition that was hand-authored through the UI, decode its `ui`, and diff your broken composition's tree against it. Bindings, data-source resolution, repeater metadata: they're all visible in the decoded tree. + +**Read-only, for debugging.** Don't modify bindings by writing back to the API. Use the Data Picker. Hand-edited bindings drift from the shape Studio expects and reproduce exactly the failure mode you're trying to debug. + +## Task + +1. **Note the surface** the user is on. Some symptoms only appear in production (the type-mismatch console warning is silenced in editor mode). Live Preview also has different fallback behavior than a raw production page. +2. **Match the symptom** to the table below: exact wording matters less than category. +3. **Run the "Check first" step.** If it confirms the cause, propose the fix. If not, run the secondary check. +4. **Report:** the symptom, the cause, the check you ran, and the fix. Cap at two checks. Escalate to engineering if both come back negative, sharing the page URL, the binding path from Properties, and the data payload (in DevTools, open the Network tab and read the composition or entry fetch responses). + +### Symptom-to-cause matrix + +| Symptom (verbatim or paraphrased) | Likely cause | Check first | Fix | +|---|---|---|---| +| **"Invalid binding"** marker in the Layers panel | Condition Block's condition binding is null/undefined, or the binding resolves to no data. | In Properties, open Conditions: is the picker actually populated? | Re-select the field via the picker. If parent (section/repeater/template) is itself unbound, bind it FIRST (Condition Block depends on parent context). | +| **"No source configured"** in Layers | Binding was set, then the source field was deleted from the schema or the data source was disconnected. Static value is still rendering as fallback. | Open Properties, then Data: does the binding chip look empty or stale? | Click the picker, re-bind the field. Static value persists as a fallback when the binding goes stale. Both can coexist. | +| **`"Direct binding isn't supported because this field has no data—use a repeater to bind it to the schema"`** | The chosen data path resolved to an empty array. The picker blocks expanding empty arrays at selection time. | Confirm the upstream data has items (Live Preview / production payload). | If iterating, wrap with a Repeater bound to the array field, then bind nested props inside the iteration context. | +| **"Primitive fields cannot be selected when binding nested repeaters. Only array fields are allowed."** | Tried to bind a nested Repeater's `items` to a primitive (string / number / boolean) field. | Check the picked field's type in the schema. | The field must be `type: "array"`. Pick a different field, or restructure upstream data so the iteration source is an array. | +| **"Non-array object fields (like JSON editor) cannot be selected when binding nested repeaters."** | Same picker, tried to bind a nested Repeater's `items` to an object/JSON-editor field. | Check the picked field's type. | The binding must point to an array. Transform the object into an array of entries upstream if you genuinely need to iterate object keys. | +| **Browser console warning:** `Data binding type mismatch in component "...": Expected prop "..." to be ... but received ...` | At runtime, the SDK saw a type that doesn't match the registered prop's declared `type`. **Only logged in production-style render**. Editor mode suppresses it. | Open DevTools console on the published page. Filter `Data binding type mismatch`. | Reconcile the schema. Either change the prop's declared type, or change the bound field, or insert a transformation in code. Don't silence the warning: it always indicates a real type drift. | +| **Field renders a hardcoded default (or empty string / 0 / false) instead of CMS data** | Binding path didn't resolve in the actual data, so the SDK used the static value / default value (then a type-default if both are absent). | In DevTools, open the Network tab, find the composition fetch, and look for the binding's path in the response. Does the path exist? | (a) If path is wrong: re-bind via picker. (b) If path is correct but value is `null`/missing: fix upstream content. (c) Set a non-empty static value as visible fallback if the field is legitimately optional. | +| **A list/grid component shows generic placeholder cards (`ARTICLE ONE / TWO / THREE`, "short teaser", gray image icons) even in Preview Mode** | A whole-array component prop (a `Card Grid` / `Article Grid` that `.map()`s internally) is bound to a field that **doesn't exist on the CT** (or isn't bound at all), so the array resolves to nothing and the component renders its registration `defaultValue`. Classic cause: the page CT (e.g. `Article List`) has no multi-Reference / Modular Block / Pinned Query feeding the list. The items live in a **separate CT** the page never references. | Open the Data Picker on the array prop: is there actually a multi-valued field to bind to? Then in DevTools, open the Network tab and check the composition fetch: is the array field present and populated in the payload? | Add the missing multi-valued source: multi-Reference on the page CT (`migrate-ct-schema`), or a Pinned Query (`pin-query-to-freeform`), then bind. If you want per-item binding visible in Studio instead of one array blob, decompose to Repeater + atomic child Section (`build-repeating-section`). This is the `build-section` Step 5e / `register-component` whole-array trap, caught after the fact. | +| **Console:** `[Composable Studio SDK] useData() was called outside a DataCtxProvider` (dev only, client only) | A basic component (Repeater / ConditionBlock / SectionComposition) rendered without its parent `DataCtxProvider`. Hook returns empty data and warns. | Confirm the component tree wraps the basic component with `<StudioComponent>`. | Always render compositions inside `<StudioComponent>` (it provides `DataCtxProvider` with the spec). If you're using a basic component directly outside that wrapper, wrap it explicitly. This is a render-tree shape issue, not a binding-path issue. | +| **Repeater renders one iteration on the Studio canvas, production renders the correct N** | Repeater's authoring default is to render a single iteration with placeholder/default values so authors can lay out the structure without depending on bound data. Production iterates the real array. | Repeater Preview Mode toggle in Properties. | Select the Repeater node in Layers, toggle **Preview Mode** in Properties: canvas re-renders with real iteration data. (If production also renders one or zero iterations, the binding itself is wrong. Re-check the bound array field in the data payload.) | +| **Repeater renders no iterations in production / SSR** | During SSR, `useData()` returns empty data (no items), so the Repeater iterates 0× until hydration attaches real context. | View page source (no JS): Repeater HTML is minimal. | This is expected for SSR. If the items don't appear AFTER hydration either, the issue is upstream: `specOptions.data.dataSources` doesn't contain the items, or the binding path is wrong. Check the Network payload. See `troubleshoot-ssr-rendering` for further SSR-specific failure modes. | +| **Condition Block's field picker shows no options** | Parent node (section / repeater / template) has no binding set, or its binding hasn't resolved. Condition is a child of the parent's data context. | Select the parent in Layers. Check its binding in Properties, under Data. | Select the parent in Layers FIRST. In Properties, under Data, verify a binding is set. Then return to the Condition Block: its picker now lists fields from the parent context. | +| **Component prop's declared type doesn't match what the binding supplies (editor-side warning)** | Component registry validates declared types against incoming bindings. Mismatch surfaces in the editor (not as a thrown error, as a warning). | Look for the warning in the editor's console alongside the component name. | Open the component's registration (where `registerComponent` was called). Reconcile the `type` declaration with what the binding actually returns. If the mismatch is intentional (e.g. a number rendered as a string), clear the binding and use a static value, or transform upstream. | +| **Empty render that worked seconds ago, no code changes** (grid suddenly empty, list blank, etc.) | A **transient 5xx from the Studio API**: `/composable-studio-api/v1/projects/...` returns 503 / 502 / 504 intermittently and Studio renders nothing. Not your binding. | In DevTools, open the Network tab and filter `composable-studio-api`: any 503 / 502 / 504 around the time it went empty? If yes, that's the cause. | **Reload.** Don't chase a transient 5xx through the binding-debug path above: empty renders from upstream blips look identical to real binding failures and will cost an hour. Only if the 5xx reproduces consistently is it no longer transient. Escalate with the API path + project ID + timestamp. See `troubleshoot-canvas` for the canonical row on this symptom. | +| **Section renders blank in production AND in the editor canvas**, the section is dropped on a template but shows no content / repeats nothing / shows one empty placeholder | The section's scope (`selectedField`) didn't resolve. Sections are data-less standalone (`author-composition-via-api` § How a Section gets data). They only fill out when placed on a template. If the resolved `selectedField` is wrong / missing, the section's `dataSources.template` is undefined or empty, so repeaters render nothing and the section comes out blank. Common causes: `linked_schemas` dropped by the compositions CT (P23 trap: modeled as reference instead of group-multiple), `selectedField` points at a field that doesn't exist on the page CT, the page entry's field is genuinely empty, missing `data_sources.resolvedReferences` for a reference path. | Dump `section_scoped_data` via the SSR cold-load. See [`verify-setup`](verify-setup.md) § Layer 7. For every section instance, that map shows the resolved `selectedField` + the actual scoped `template`. If `template` is undefined / empty / wrong-shape, the section's scope is broken. | Fix per the failure class. For the `linked_schemas` trap, re-model the compositions CT field as group-multiple AND populate `sectionBindingOverride` on each `section-composition` node (see `author-composition-via-api` § The linked_schemas-as-reference trap). For a wrong `selectedField`, fix the section's `linked_schemas` to point at a field that exists on the page CT. For missing `resolvedReferences`, see `author-composition-via-api` § Repeat a card over a multi-reference field. | + +| **Modular Block renders, but the reference inside each block is empty**, blocks appear, their own fields render, only the referenced entry's fields are blank. Binding shows the right path in the picker | The `resolvedReferences` path for a reference nested in a Modular Block omitted the **block uid**. A MB item is `{ <block_uid>: { …fields } }`, so the path must be `blocks.<block_uid>.<ref_field>`. `blocks.<ref_field>` (the shape that works for a group-multiple) is not a fetchable CDA include, so Studio filters it out to avoid a 422, silently. The reference stays a stub and the binding resolves against the stub. | Dump the template composition's `data_sources`, find the `"template"` entry's `resolvedReferences`, and check each path has three segments: the MB field, the block uid, then the reference field. Also confirm it's on the **parent template** composition, not the Section. | Rewrite the path with the block uid: `"blocks.feature_block.related_products"`. One entry per block uid that holds a reference. Numeric prefixes (`2_feature_block`) are stripped by both Studio and the SDK, so write the plain uid. See `author-composition-via-api` § Modular Block to reference: the block-uid segment. **Same silent failure applies to any mis-terminated reference path**: a reference inside a reference (drop the target CT uid), or a nested asset (list the reference AND the nested path). All four shapes: `author-composition-via-api` § Where a reference path must terminate. | +| **A whole section renders as nothing, though the field it binds is populated** | The block declares **two** collection fields for the same idea and leaves one empty, `<collection_a>: []` beside a populated `<collection_b>`. Component code written as `body.<collection_a> ?? body.<collection_b>` takes the **empty array**, because `[]` is neither `null` nor `undefined`, so the section renders zero items with no error. | Dump the block object and look for sibling array fields where one is `[]`. | Select the first array that actually has items, not the first that is non-nullish. `??` and `||` are both wrong: `??` keeps `[]`, and `||` would also discard a legitimate `0` or `""` if the same helper is reused for scalars. | +| **A boolean-looking flag never turns a section off (or on)** | Entry booleans can arrive over the CDA as the **strings** `"True"` / `"False"` rather than booleans. `"False"` is a non-empty string and therefore truthy, so `{flag && <Section/>}` renders unconditionally and a gate meant to hide a section silently stops working. Affects any authored toggle: visibility flags, "open in new tab", feature-present markers. | Log `typeof` on the flag, not just its value. | Compare explicitly: `v === true \|\| v === 'true' \|\| v === 'True'`. Put it in one shared helper rather than at each call site. | +| **Cards render but their titles/copy are blank, and the array length is right** | The collection nests deeper than it looks. Each item is a single-key wrapper naming its block type (`{ <block_type>: { <field>: [ {…} ] } }`), so reading `item.<prop>` finds nothing while `item` still counts as an object and the length looks correct. | Print `Object.keys(items[0])`. A single key that is not `_metadata` means you are holding a wrapper, not the record. | Unwrap iteratively: while the object has exactly one non-`_` key whose value is an object or a single-element array, descend into it. One level is not always enough: a wrapper around a reference array needs two. | +| **A reference path is declared correctly and still resolves to nothing** | Two different failures look identical at the render. An **unresolved** reference is `{uid}` and nothing else. An **empty** one is `[]`. Declaring more `resolvedReferences` paths fixes only the first. Seen where part of a card collection resolved and the rest held `[]` in that environment, capping what the page could ever render. | Fetch the entry with the path in `include[]` and inspect each item: a bare `{uid}` is a declaration gap, `[]` is a content gap. | Only the declaration gap is yours to fix. For the content gap the data is absent in that environment, report it as such rather than adding paths that change nothing. Re-check in an environment where the content is published before concluding the build is wrong. | + +### When the symptom maps to none of the above + +These failure modes have **no diagnostic surface** in the codebase as of the current SDK alpha: + +- **Manually typed binding path with a typo**: silently falls through to fallback. Picker-only authoring avoids this. If you find a hand-edited binding string, re-set it through the picker. +- **Repeater item context lost inside a deeply nested Section Slot**: propagation is implicit via `DataCtxProvider`. No warning if the context doesn't reach the slot's subtree. +- **Schema mismatch between Studio's composition and runtime `ComponentData` you pass in code**: the SDK uses fallback values, no validation error. +- **CSLP-append behavior when `appendTags: false`**: content renders un-tagged with no UI indication. +- **Data source fetch timeout / network failure**: picker may stall without a banner unless the adapter explicitly surfaces it. + +If the symptom fits none of the rows above and is not in this silent-failure list, escalate to engineering with: the page URL, the bound field's path from Properties, the actual data payload from DevTools Network, and the SDK version (`grep '"@contentstack/studio-client"' package.json`). + +## What this skill is NOT + +- Not a guide to authoring bindings from scratch. Use `build-connected-template` / `build-section`. +- Not a guide to wiring external data. Use `wire-external-data` / `wire-component-default-data`. +- Not for live-site URL resolution (use `troubleshoot-composition-resolution`). +- Not for canvas iframe / Studio editor failures (use `troubleshoot-canvas`). +- Not for SSR / RSC boundary errors (use `troubleshoot-ssr-rendering`). diff --git a/skills/studio/troubleshoot-ssr-rendering.md b/skills/studio/troubleshoot-ssr-rendering.md new file mode 100644 index 0000000..1a8a6f4 --- /dev/null +++ b/skills/studio/troubleshoot-ssr-rendering.md @@ -0,0 +1,68 @@ +--- +name: troubleshoot-ssr-rendering +--- + +## When to use + +Symptom-mapped diagnostic for SSR / RSC render failures with `<StudioComponent>`. Covers the "use client" boundary, registry singleton, lazy-component loading, and `useData` fallback. + +Use when the app fails to render compositions on the server. Signals: `"Attempted to call Page() from the server"`, `"Element type is invalid"`, `useData()` warning, `"is registered as lazy"`, `"is not registered"` from the server bundle, hydration mismatches. If the symptom is ambiguous, route via `troubleshoot` first. Do NOT use for canvas iframe issues (`troubleshoot-canvas`) or binding issues (`troubleshoot-data-binding`). + +> **Mandatory auth preflight: settle the credential before the first API call.** Resolve it OAuth-first per [`authenticate-cma`](authenticate-cma.md): `CS_OAUTH_ACCESS_TOKEN`, else the Contentstack MCP's stored session: Read that skill for where it is, even with no MCP connected. **Never ask the user for a session `authtoken`.** If nothing resolves, or a refresh fails with `400 invalid_refresh_token`, hand them `! CONTENTSTACK_REGION=<code> npx @contentstack/mcp --auth` (it needs a TTY and a browser, so it cannot be run for them) and wait. `403 error_code 316` is a valid credential aimed at another org: fix the org or the `api_key`, do **not** re-authenticate. + +# Troubleshoot Studio SSR / RSC rendering failures + +## Context + +`<StudioComponent>` and the SDK's built-in components (`Page`, `Section`, `Text`, `Repeater`, `ConditionBlock`, `SectionComposition`, etc.) are **`"use client"`** modules. When integrated into a server-rendered tree (especially Next.js App Router with React Server Components), three boundaries must be correctly drawn: + +1. **The renderer entry must be `"use client"`**: `<StudioComponent>` uses React hooks and CSS-in-JS that don't survive in a Server Component. +2. **The built-in basics must register on the client side only**: importing them from the server-safe entry would place them in the consumer's server module graph as client-references, which Next then fails to call at runtime. +3. **The registry must be shared across server and client module graphs**: RSC server and client are separate bundles. A module-scoped `let instance` lives in only one graph. + +The SDK ships fixes for each boundary internally (client-only built-ins isolation, shared cross-bundle registry, tolerant data-context fallback). This skill maps each symptom to its failing boundary so you can upgrade or fix the consumer-side wrapping. + +## Task + +1. **Capture the SDK version** the host app uses: `grep '"@contentstack/studio-react"\\|"@contentstack/studio-client"\\|"@contentstack/studio-registry"' package.json`. Several fixes referenced below are recent. Confirm the installed version against the package CHANGELOG. +2. **Identify the surface**: server build error (during compile / first SSR pass) vs. runtime browser error vs. runtime server error in production. The matrix below distinguishes them. +3. **Match the symptom** to a row. +4. **Run "Check first."** If positive, propose the fix. Most fixes are consumer-side wrapping. A few require an SDK upgrade. +5. **Report** four things: the symptom, the SDK boundary that is failing, the minimum SDK version (if an upgrade) or the consumer-side fix, and what to verify afterwards. + +### Symptom to mechanism matrix + +| Symptom | Failing boundary | Check first | Fix | +|---|---|---|---| +| **"Attempted to call Page() from the server"** (or `Section()`, `Text()`, `Repeater()`, any basic) | The basics package landed in the consumer's server module graph as client-references. The renderer's registry lookup then tries to call them via JSX. Server can't. | Trace the import. Does anything in the server-rendered tree transitively import `@contentstack/studio-react-components` (the basics package) outside a `"use client"` boundary? | The SDK isolates the basics behind a `"use client"` boundary internally. Recent `@contentstack/studio-react` versions ship this fix. Upgrade and check the CHANGELOG for the basics-isolation entry. On the consumer side: do not import the basics package from server code, and wrap `<StudioComponent>` in a `"use client"` child component. | +| **Console warning:** `[Composable Studio SDK] useData() was called outside a DataCtxProvider.` (dev only, client only) | A basic component (Repeater / ConditionBlock / SectionComposition) hit the SSR pass before `DataCtxProvider` attached. The hook returns `EMPTY_DATA` and warns. | Confirm: is the warning in dev only? In prod is the page rendering correctly post-hydration? | If post-hydration render is correct, the warning is expected and benign: SSR placeholder, then hydration populates real data. If post-hydration is still wrong, see `troubleshoot-data-binding` (the binding itself is broken). If the warning appears in production console, that's an SDK assertion bug. Escalate. | +| **Hydration mismatch around the composition tree** (React error about HTML mismatching) | `<StudioComponent>` (or its containing component) is being rendered as a Server Component. The renderer uses hooks + client-only CSS-in-JS and is a `"use client"` module. Rendering it server-side produces HTML the client can't reconcile. | `grep -n '"use client"' app/<your-route>/page.tsx`. If absent and you're calling `<StudioComponent>` directly: that's the bug. | Wrap `<StudioComponent>` in a tiny `"use client"` child (per `configure-csr-vs-ssr` [skill](configure-csr-vs-ssr.md)). The parent Server Component does the data fetch (`sdk.fetchCompositionData`) and passes `specOptions` to the wrapper. | +| **Error:** `Component '<name>' is registered as lazy but hasn't been loaded yet. This usually happens when: 1. fetchSpec() was not awaited before rendering` | The component was registered LAZILY. Both lazy entry points hit this same code path: (a) `registerComponent({ component: () => import("./Foo") })`, any arity-0 function in `component:` that returns a Promise is treated as lazy. (b) `registerLazyComponent({...config}, () => import("./Foo"))`, the explicit-signature form. The SDK does **not** auto-load on access. The loader runs explicitly during `fetchSpec`. If `<StudioComponent>` mounts before `fetchSpec` (or `fetchCompositionData`) has been awaited, the registry knows the component exists but the React.lazy bundle isn't downloaded yet, and this throws. | Confirm: is `fetchSpec` (or its wrapper `fetchCompositionData`) awaited before `<StudioComponent>` mounts? Note this affects both lazy-registration shapes. Fix is the same for both. | Always `await sdk.fetchCompositionData(...)` before passing `specOptions` to `<StudioComponent>`. If using `useCompositionData`, render a loading state until `isLoading === false`. The contract is the same whether you registered via the arity-0-thunk form of `registerComponent` or the explicit `registerLazyComponent` form. | +| **Component with type 'X' is not registered** (emitted from the server bundle / RSC fetch) | Pre-fix, the component registry was module-scoped. RSC server and client bundles had separate instances, so server-fetched specs couldn't find user-registered components. The SDK's fix shares the registry across both module graphs at runtime. | Confirm the installed `@contentstack/studio-registry` version is recent (check its CHANGELOG for "shared registry" / "globalThis-backed singleton" entries). | Upgrade `studio-registry` and the packages that depend on it to a version that ships the shared-registry fix. After upgrade, registration done at module-init time is visible to both server and client bundles within the same JS runtime. | +| **Component with type 'X' is not registered** where X is a built-in (`page`, `section`, `text`, `repeater`, `condition-block`, `section-slot`, `box`), on the **visitor render** path (`<StudioComponent>` in SSR/RSC, or any non-canvas public route) | Pre-patch SDK (1.3.1 / 1.5.1 and earlier) didn't auto-register built-ins on the visitor render path. Only the authoring canvas did. PR #851 (`fix/builtin-ssr-registration`) restores auto-registration. Ships in the next 1.3.x / 1.5.x patch. | `npm ls @contentstack/studio-react`. Is the installed version older than the patch that contains PR #851? | Upgrade `@contentstack/studio-react` to the patch release containing PR #851. Don't hand-register internals as a workaround. Auto-register is back on the visitor path. | +| **Component with type 'X' is not registered** where X is a built-in, only on routes that mount `<StudioCanvas>` and not `<StudioComponent>` | Distinct from the visitor-path issue above: older SDK versions also had a shared-registry tree-shake gap where canvas-only routes could load with an empty built-in registry. A separate fix in recent `@contentstack/studio-react` versions covers this case. | Open a canvas-only route in a production build and check console for "is not registered" errors on built-in types. If present on a canvas route (not a visitor route), this is the canvas-side fix you need. | Upgrade `@contentstack/studio-react` to a version that includes the canvas-route built-in fix (check its CHANGELOG). | +| **`<StudioCanvas>` import explodes during server build** | `<StudioCanvas>` is the edit-mode iframe overlay, client-only by design. Importing it into a Server Component pulls the visual-editor SDK into the server bundle. | Was the canvas route file marked `"use client"`? Check at the top of the file. | Mark the canvas route `"use client"` (per `setup-template-preview-routes` [skill](setup-template-preview-routes.md) / `setup-section-preview` [skill](setup-section-preview.md)). Or use Next's `dynamic(() => import('...'), { ssr: false })` form. | +| **In SSR, fetched composition resolves to the wrong locale / variant / preview entry** | On the server there is no `window.location.search`. `sdk.fetchCompositionData` REQUIRES the consumer to pass `searchQuery` explicitly so iframe/preview parameters flow into the CDA call. Skip it and you get the default fetch. | Inspect the server-side call site. Is `searchQuery` being passed? `await sdk.fetchCompositionData({ url, searchQuery }, { locale })`. | Extract `searchQuery` from the request and pass it. Pattern documented in [`configure-csr-vs-ssr.md`](configure-csr-vs-ssr.md). Not an SDK bug: a consumer integration step. | +| **Next.js App Router route 500s with `Cannot read properties of null (reading 'useContext')` at `useData → RepeaterPreview`** | An SDK basic executed during the RSC server prerender against a React instance with a **null dispatcher** (Next's app-page runtime). Trigger: `transpilePackages` includes `@contentstack/studio-*`, which pulls the basics in as **real components** (defeating the renderer's `isClientReference` deferral), so they run on the server and hit a null React dispatcher. | `grep -n "transpilePackages" next.config.{js,mjs,ts}`. Does it list any `@contentstack/studio-*` package? Is the route under `app/`? | Remove `@contentstack/studio-*` from `transpilePackages` on App Router (restores deferral: no crash, but the composition is then client-rendered, not SSR'd). For SSR, use a `"use client"` wrapper (`configure-csr-vs-ssr` step 3). See `configure-csr-vs-ssr` for the corrected decision table. | +| **App Router route renders in the browser but `view-source` / `<body>` (after stripping `<script>`) is empty of composition DOM, bound content only in `self.__next_f`** | By SDK design: the renderer detects every built-in as `Symbol.for("react.client.reference")` during the RSC server pass and emits `<data-cs-defer-builtin>` placeholders. Real render happens on client hydration. This is **CSR-equivalent**, not true SSR. | View page source. Look for `data-cs-defer-builtin` attributes or the absence of repeater iteration values. | Two options: (a) accept App Router = client-rendered for the composition (fine for non-SEO surfaces). (b) for SSR, use a `"use client"` wrapper, Pages Router, Vite custom-server (`renderToString` + `hydrateRoot`, see `composable-studio-sdk/test-resources/ssr-react`), or Remix, all of which run the basics through `renderToString` server-side. | +| **Pages Router `next dev` only: Repeater items render field defaults ("Your text here") on some boots, real data on others** | Per-boot dev race: recompile + module-init order + React StrictMode double-invoke can serialize before registrations land. Prod build doesn't have this race. | Reproduce on `next build && next start`. Does the issue persist? | Don't judge SSR from `next dev`. Verify with a prod build. Deploy from prod artifacts. (Not an SDK bug: dev-server timing.) | +| **Infinite `_next/data/<buildId>/<route>.json` (Pages Router) or RSC payload (App Router) refetch loop**: Network tab fires hundreds of times per edit | `ContentstackLivePreview.onEntryChange(cb)` invokes `cb` once at register-time. If `cb = () => router.replace(asPath)` / `router.refresh()` is wired in a `useEffect(..., [router])`, the router-identity change re-runs the effect, which unsubscribes and resubscribes, which fires the register-time callback, which calls `router.replace`, and the loop never ends. This is a Live Preview wiring bug, not an SSR bug per se. | Inspect the `onEntryChange` wiring. What are the effect's deps? Is `firstFire` skipped? Is unsubscribe wired? | Replace with the loop-safe pattern from [`install-live-preview`](install-live-preview.md) (step 7): `useEffect(..., [])` (subscribe ONCE), hold `router` in a `useRef`, skip the first (register-time) callback, `unsubscribeOnEntryChange` in cleanup. Reference: SDK's `test-resources/ssr-react/hooks/use-studio-spec-options.ts` (`skipFirstClientFetchRef`). | +| **In SSR / RSC, design tokens or fonts not applied on first paint** | The renderer applies design tokens via `goober` CSS-in-JS at runtime, and loads Google Fonts via `useEffect`. SSR pass produces HTML without the runtime-applied styles. First paint can flash unstyled content. | View page source on the SSR'd route. Are the `:root` CSS variables present? | This is a known runtime-style limitation. Pre-rendering critical tokens via static CSS at the host-app level is the consumer-side mitigation. Not an SDK bug. | +| **Section composition shows as unregistered** | Section-composition UIDs are resolved at render time. If a section composition isn't registered before its first paint, the lookup throws. The SDK auto-registers sections during `fetchSpec`. Failure here means `fetchSpec` ran but registration didn't complete before render. | Confirm `fetchSpec` was awaited. Check the spec response for the section composition's UID. | Await `fetchSpec` / `fetchCompositionData` before render. If the spec doesn't contain the section composition's UID, it wasn't included in the composition's fetched dependencies. Escalate to engineering with the parent composition UID + the missing section composition UID. | + +### When the symptom maps to none of the above + +Likely-but-unverified causes (escalate rather than guess): + +- **Tree-shake gotchas in non-Next bundlers** (Vite SSR, Astro, custom Webpack with `sideEffects: false`): no general fix path documented. The bootstrap pattern depends on the renderer being eagerly evaluated, which some bundlers won't do for side-effect-only imports. +- **`use server` directives wrongly applied to SDK code**: never add `use server` to SDK files. The SDK does not export Server Actions. +- **Mixed SDK package versions**: `studio-registry@1.X` + `studio-react@1.Y` where the versions disagree on the singleton API. Check that the versions installed satisfy the SDK's `peerDependencies`. + +When escalating, share: framework + version, every `@contentstack/studio-*` package's exact installed version (`npm ls @contentstack/studio-react @contentstack/studio-registry @contentstack/studio-client`), the route file with full `"use client"` boundary structure, and the error stack trace. + +## What this skill is NOT + +- Not a guide to setting up SSR from scratch. Use [`configure-csr-vs-ssr`](configure-csr-vs-ssr.md). +- Not a guide to live-site URL resolution failures (use [`troubleshoot-composition-resolution`](troubleshoot-composition-resolution.md)). +- Not a guide to canvas iframe failures (use [`troubleshoot-canvas`](troubleshoot-canvas.md)). +- Not a guide to per-binding data issues (use [`troubleshoot-data-binding`](troubleshoot-data-binding.md)). diff --git a/skills/studio/troubleshoot.md b/skills/studio/troubleshoot.md new file mode 100644 index 0000000..c7fea02 --- /dev/null +++ b/skills/studio/troubleshoot.md @@ -0,0 +1,50 @@ +--- +name: troubleshoot +--- + +## When to use + +Symptom router: classify a Studio failure (canvas, binding, URL resolution, SSR, fresh-setup) and route to the ONE specialist diagnostic skill. Don't load every diagnostic. Route first. + +Use as the FIRST step whenever Studio isn't working. Phrases: "Studio broken", "help debug Studio", "canvas blank", "composition not rendering", "binding wrong". Routes to the right specialist. Do NOT use to verify a fresh install (use `verify-setup`). Do NOT skip and invoke specialists directly when the symptom is ambiguous. Let the router classify first. + +> **Mandatory auth preflight: settle the credential before the first API call.** Resolve it OAuth-first per [`authenticate-cma`](authenticate-cma.md): `CS_OAUTH_ACCESS_TOKEN`, else the Contentstack MCP's stored session: Read that skill for where it is, even with no MCP connected. **Never ask the user for a session `authtoken`.** If nothing resolves, or a refresh fails with `400 invalid_refresh_token`, hand them `! CONTENTSTACK_REGION=<code> npx @contentstack/mcp --auth` (it needs a TTY and a browser, so it cannot be run for them) and wait. `403 error_code 316` is a valid credential aimed at another org: fix the org or the `api_key`, do **not** re-authenticate. + +# Troubleshoot Studio: symptom router + +## Why route first + +Studio has five distinct failure surfaces, each with its own specialist diagnostic. Loading all five wastes ~10K tokens and lets the wrong specialist mis-diagnose. This skill is the lean classifier: read it, match the symptom to ONE specialist, load that one. + +## Classify the symptom + +Match the user's complaint against the most specific row. **Load only the matching specialist.** Symptoms ordered by frequency. + +| Symptom (verbatim or close paraphrase) | Failure surface | Load this specialist | +|---|---|---| +| Canvas is **blank** / shows the wrong page / "Component Loading Error" / iframe never finishes loading / "SDK Not Initialized" popup / preview entry won't update / authentication failed | **Canvas iframe**: the editor's render surface inside Studio | [`troubleshoot-canvas`](troubleshoot-canvas.md) | +| Component **renders but the bound value is wrong, empty, or shows the literal path** (`{{entry.title}}`) / binding chip looks correct but rendered output is stale / Repeater iterates wrong field / Condition Block doesn't disambiguate | **Data binding**: the value resolution path | [`troubleshoot-data-binding`](troubleshoot-data-binding.md) | +| **Live-site URL** resolves to the wrong composition / two templates match the same URL non-deterministically / `/blog/post-1` 404s on the visitor site / `*` wildcard pattern stopped matching / `:slug` comes back literal | **URL resolution**: pattern matching at the SDK level | [`troubleshoot-composition-resolution`](troubleshoot-composition-resolution.md) | +| **Server-side render fails**: "Attempted to call Page() from the server" / "Element type is invalid" / `useData()` warning / "is registered as lazy but hasn't been loaded yet" / "Component with type 'X' is not registered" from the server bundle / hydration mismatch around composition trees | **SSR / RSC**: server render path | [`troubleshoot-ssr-rendering`](troubleshoot-ssr-rendering.md) | +| **Fresh install just done, does it actually work end-to-end?** ("I just ran install-studio, how do I verify it's wired?") / regression check ("worked yesterday, what broke?") | **Layered smoke test** (not symptom diagnosis) | [`verify-setup`](verify-setup.md) | + +## When the symptom doesn't clearly match a row + +Two paths: + +1. **Ask the user one clarifying question**: "Is the failure on the canvas inside Studio, on the live site, or during a build / SSR step?" That maps to canvas / URL resolution / SSR respectively. If the answer is "the value renders but is wrong", route to data binding. + +2. **Default to canvas-test pre-flight.** If the user is unsure where the failure is, [`troubleshoot-canvas`](troubleshoot-canvas.md) leads with a section-test pre-flight that rules out 80% of misdirections before reading any symptom row. Run that, then route based on what the pre-flight finds. + +## What this skill does NOT do + +- Does NOT execute the diagnostic. That's the specialist's job. This skill ends after routing. +- Does NOT cover authoring-quality issues ("the layout looks wrong", "this Section needs different styling"). Those are design / build issues. Route to [`build-section`](build-section.md) or [`design-section-from-jsx`](design-section-from-jsx.md). +- Does NOT replace [`verify-setup`](verify-setup.md) for fresh-install smoke testing. That's its own intent, not a symptom. + +## Acceptance + +Skill succeeds when: +- [ ] The user's symptom was matched to exactly ONE specialist row (no "load all five"). +- [ ] The specialist was invoked / linked. +- [ ] If the symptom didn't match, a clarifying question was asked OR the canvas-test pre-flight was run. diff --git a/skills/studio/understand-authoring-headlessly.md b/skills/studio/understand-authoring-headlessly.md new file mode 100644 index 0000000..54314e1 --- /dev/null +++ b/skills/studio/understand-authoring-headlessly.md @@ -0,0 +1,86 @@ +--- +name: understand-authoring-headlessly +--- + +## When to use + +Explain API-authoring a composition vs UI authoring: the entry's `ui` (zlib node tree) + `data_sources` shape, the six binding types, when this path fits. On-ramp before `author-composition-via-api`. + +Use when the user asks "how do I create a composition via API", "can I bulk-import compositions", "what does Studio store in the entry", "what's the JSON shape of a composition", or before invoking `author-composition-via-api` without context. Concept only: for the actual API authoring run `author-composition-via-api`. Do NOT use for UI authoring (use `build-section` / `build-connected-template`). + +> **Auth preflight: settle the credential before the first API call.** Resolve it OAuth-first per [`authenticate-cma`](authenticate-cma.md): `CS_OAUTH_ACCESS_TOKEN`, else the Contentstack MCP's stored session: Read that skill for where it is, even with no MCP connected. **Never ask the user for a session `authtoken`.** If nothing resolves, or a refresh fails with `400 invalid_refresh_token`, hand them `! CONTENTSTACK_REGION=<code> npx @contentstack/mcp --auth` (it needs a TTY and a browser, so it cannot be run for them) and wait. `403 error_code 316` is a valid credential aimed at another org: fix the org or the `api_key`, do **not** re-authenticate. + +# What API-authoring a composition means + +## Two ways to author a composition + +Compositions live as **entries** in a Contentstack content type (the project's compositions CT, typically `<project>_compositions`). There are two paths to create those entries: + +| Path | Tool | When to use | +|---|---|---| +| **UI authoring** (default) | Studio canvas: drag, drop, bind via Data Picker, Save, Deploy | Every author-driven workflow. Authors use it. Engineers use it for ad hoc composition. | +| **API authoring** (headless) | CMA `POST /v3/.../entries` with a hand-built `ui` payload | Seed pipelines, scripted provisioning, automated migration, debugging via diff, bulk import | + +API authoring is NOT a replacement for the UI. The Data Picker handles every shape the API path describes. Reach for the API only when scripting at scale or seeding fixtures. + +## What's in the composition entry + +The compositions CT has 13 fields (full schema in [`provision-studio-project`](provision-studio-project.md)). The two that carry the composition itself: + +- **`ui`**: `zlib:<base64>`-encoded composition node tree. The whole layout (page, sections, components, repeaters, condition blocks, section slots) lives here as one inflated JSON tree. +- **`data_sources`**: JSON object holding `resolvedReferences` (which fields to populate via CDA `?include[]=`), the static-value map, and other resolution metadata. + +Other fields (`composable_uid`, `url`, `linked_schemas`, `linked_sections`, `url_metadata`, etc.) are CT-level routing/metadata. The actual composition is in `ui` + `data_sources`. + +## The six binding types + +Studio's Data Picker emits one of six binding shapes per bound prop, and these are the six an API author writes by hand: + +| Type | Where the value comes from | Common use | +|---|---|---| +| `template` | The template's preview entry (the page-level CT) | `entry.title`, `entry.hero.headline` | +| `repeater` | The current Repeater iteration item | inside a Repeater iterating `entry.related_posts` | +| `contentstack` | A Pinned Entry on the template (Freeform must be on) | `dataSources.contentstack.<uid>.title` | +| `contentstack_queries` | A Pinned Query on the template (Freeform must be on) | a Repeater sourced from `dataSources.contentstack_queries.<uid>` | +| `static_value` | A literal value baked into the composition | Labels, button text the author types once | +| `component_props` | A Section's exposed prop (passed in from a Template) | Section's "Card Title" override | + +API-authored bindings live at `props.<propName>.binding` on each node. + +## The node anatomy + +Each node in the `ui` tree has: + +- `type`: registered component UID (`page`, `section`, `repeater`, `condition-block`, `section-slot`, or a custom registered component) +- `props`: bindings + static values + exposed-prop metadata +- `slots`: `{ <slot_uid>: [<child_node>, ...] }` for children. **Every slot must have a non-empty array**: empty slots crash the renderer (see [`troubleshoot-canvas`](troubleshoot-canvas.md)). Two exceptions: a `section-slot` node keeps `slots: { "<its-own-uid>": [] }` until a template fills it. The other is the page-side `section-composition` node, which keeps `slots: { "<section-slot-uid>": [] }` for each slot the template hasn't filled yet. +- `metadata`: `mode` (preview/design), `condition`, `sectionBindingOverride`, etc. + +## When API-authoring is the right choice + +| Task | Right path | +|---|---| +| Seed compositions for a test environment | API authoring (`author-composition-via-api`) | +| Migrate an existing hand-coded marketing site | UI authoring (Studio canvas + `build-section` / `build-connected-template`) | +| Bulk-import 200 product detail compositions from a CSV | API authoring | +| Author a Hero Strip section that authors will tweak | UI authoring | +| Diff "what Studio writes" against "what my SDK reads" | API authoring (decode + inspect) | +| Auto-provision a Studio project for a new test stack | API authoring + [`provision-studio-project`](provision-studio-project.md) | + +The rule of thumb: API path for **machines doing it at scale or in pipelines**, UI path for **humans composing pages**. + +## What you'll need to know before API authoring + +1. The six binding types and their shapes (above). +2. The node anatomy: `props.<prop>.binding` for bindings, `slots: { <uid>: [...] }` for children, `metadata` for mode/condition. +3. `data_sources.resolvedReferences`: what to populate via CDA `?include[]=` so reference fields come back as full entries, not stubs. +4. The `zlib:<base64>` encoding: both for writing and for debugging (decode + walk to diagnose). +5. The `composable_uid` contract: unique across the compositions CT. Studio's UI fills it with a readable name from the title. It never places a section: a template's `section-composition` node references the section by its entry uid (`metadata.compositionUID: "blt…"`). +6. The pre-publish preflight: every slot's children array non-empty, no orphan descendant references, Repeater `metadata.mode: "preview"`, Condition Block sibling on reference/modular-block iteration. + +## What this skill is NOT + +- Not the recipe. For that, run [`author-composition-via-api`](author-composition-via-api.md). +- Not for UI authoring. Use [`build-section`](build-section.md), [`build-connected-template`](build-connected-template.md), [`build-repeating-section`](build-repeating-section.md). +- Not for provisioning the compositions CT itself. That's [`provision-studio-project`](provision-studio-project.md). +- Not for diagnosing a misbehaving composition. That's [`troubleshoot`](troubleshoot.md) router. diff --git a/skills/studio/understand-auto-binding.md b/skills/studio/understand-auto-binding.md new file mode 100644 index 0000000..4af9a10 --- /dev/null +++ b/skills/studio/understand-auto-binding.md @@ -0,0 +1,130 @@ +--- +name: understand-auto-binding +--- + +## When to use + +Explain how Studio auto-renders data when a Section drops on a template, the algorithm matching the Section's linked schema to the template's content type. + +Use after `understand-linked-schemas` when the user asks "how does data appear?", "do I have to wire bindings by hand?", "why did my Section render empty when dropped?". Also before `build-connected-template` so the user knows what happens at the drop. Do NOT use to wire bindings: for that, see `wire-external-data` / `build-connected-template`. + +> **Mandatory auth preflight: settle the credential before the first API call.** Resolve it OAuth-first per [`authenticate-cma`](authenticate-cma.md): `CS_OAUTH_ACCESS_TOKEN`, else the Contentstack MCP's stored session: Read that skill for where it is, even with no MCP connected. **Never ask the user for a session `authtoken`.** If nothing resolves, or a refresh fails with `400 invalid_refresh_token`, hand them `! CONTENTSTACK_REGION=<code> npx @contentstack/mcp --auth` (it needs a TTY and a browser, so it cannot be run for them) and wait. `403 error_code 316` is a valid credential aimed at another org: fix the org or the `api_key`, do **not** re-authenticate. + +# How auto-binding works + +## The one-line answer + +**When you drop a Section onto a template, Studio reads the Section's linked schema, looks at the template's connected content type, and pairs every Section prop with the matching CT field automatically, no manual wiring per drop.** The Section then renders against a real entry as soon as it lands. + +This is what makes Sections worth using. Without it, reuse would be theoretical: you'd re-wire every drop. + +## What "auto" actually means + +Three things happen the instant you release the drag: + +1. **Schema match**: Studio compares the Section's linked schema to the connected content type's fields, by **field UID**, **field type**, and **required-ness** (see `understand-linked-schemas`). +2. **Binding**: for every linked-schema entry that finds a CT field match, Studio writes the binding (`prop ← field.uid`) into the template instance. +3. **Render**: the Section's atomic React components receive the bound data from the **preview entry** Studio fetches (top-bar PREVIEW ENTRY in the canvas) and render immediately against that real content. + +No save, no refresh, no manual binding click. The canvas redraws with real CMS data the second the drop completes. + +## A concrete walkthrough + +You have a `blog_post` content type with fields: `title`, `subtitle`, `body`, `cover`, `author`, `publish_date`. + +You have a Section called `BlogArticleHeader` whose linked schema is: + +``` +title: Single Line, required +subtitle: Multi Line, optional +cover: File, required +``` + +You create a connected template against `blog_post` and drag `BlogArticleHeader` onto the empty canvas. At drop time Studio: + +| Section prop | Matches CT field? | Binding written | +|---|---|---| +| `title` | yes (`blog_post.title`) | `props.title ← entry.title` | +| `subtitle` | yes (`blog_post.subtitle`) | `props.subtitle ← entry.subtitle` | +| `cover` | yes (`blog_post.cover`) | `props.cover ← entry.cover.url` (see Multiple-file note below) | + +The PREVIEW ENTRY in the canvas top bar is auto-set to the most recently updated `blog_post`. The Section renders its hero card with the entry's actual title, subtitle, and cover image. **Zero clicks beyond the drop.** + +## What "scope" means and why it changes the match + +The matching scope is whichever content level you're inside when you drop the Section. There are three common scopes: + +| Drop scope | What the Section binds against | Example | +|---|---|---| +| **Page root** | The template's connected content type, top-level fields on the preview entry | Drop `BlogArticleHeader` straight onto a blog-post template, so it binds to top-level `blog_post` fields | +| **Inside a Repeater** bound to a reference / Modular Block | The repeated item's shape (each loop iteration is one referenced entry / one block) | Drop `RelatedPostCard` inside a Repeater bound to `blog_post.related_posts`, so it binds to each `related_post` entry's fields, not the parent post's | +| **Inside a Section Slot** | The slot's host scope (usually the same as the Section the slot lives in) | Drop a `Button` into a `cta` slot inside a hero Section, so it binds against the hero Section's host scope | + +Drop scope matters because the same Section dropped at page root vs inside a Repeater binds to different fields. The Repeater changes the field reference frame to the iteration item. Studio handles this automatically. You don't choose the scope: it's wherever your drag-and-drop lands on the canvas. + +## What happens when auto-binding can't find a match + +| Situation | What Studio does | Author's next step | +|---|---|---| +| Required prop has no matching CT field | Section drops unbound with a red warning in right-panel Settings | Either add the missing field to the CT, or pick a compatible field manually from the dropdown | +| Optional prop has no matching CT field | Section drops, that prop renders empty | Usually leave alone. It's optional | +| Multiple CT fields could match (e.g. both `title` and `headline` are Single Line Text) | Section drops bound to one (first match wins). Right-panel Settings shows the dropdown to switch | Switch the binding via the dropdown | +| The Section's linked schema is empty | Section drops fully unbound: Studio can't infer anything | Either author the linked schema (recommended), or bind each prop manually per template (works but loses reuse benefits) | + +## What changes for the visitor at runtime + +Nothing extra. The same auto-bound prop references that Studio's canvas renders against become the live-site renderer's data fetch. `sdk.fetchCompositionData({ url })` returns the composition spec **with bindings resolved**. `<StudioComponent />` reads the prop values from the fetched entry and renders. The visitor sees the same content the canvas does, same render path. + +## Why this matters + +| Without auto-binding | With auto-binding | +|---|---| +| Every Section drop requires 5 to 15 manual bindings | Drops "just work" if the CT shape matches | +| Section reuse loses 80% of its value to wiring overhead | Section reuse delivers the full speed benefit | +| Authors must understand the React prop names | Authors don't need to know prop names: they only see CT fields | +| Renaming a CMS field breaks N templates silently | Linked schema is the single source of truth: fix once, everywhere re-binds | +| New template against same CT = re-wire every Section | New template = instant productivity | + +This is why "Studio is to your Components what your CMS is to your Content" works: the same way a CMS treats content as data that snaps into shapes, Studio treats composition as data that snaps onto content shapes. + +## How to inspect what auto-binding did + +After dropping a Section on a template: + +- **Right-panel Settings tab**: shows each prop with its current binding chip. Green = bound, red = unbound. +- **Right-panel Data tab** (when a sub-component is selected): shows the data picker. The current binding appears in the picker breadcrumb. +- **Layers panel**: every node is selectable. Clicking any shows its bindings in the right panel. + +If something rendered empty, the right panel tells you which prop didn't bind and to what. + +**Note on what you see on the canvas:** auto-binding wires the props at drop time, but the canvas renders defaults until Preview Mode is toggled. Seeing defaults isn't a binding failure. See `use-repeater` for the two-mode mental model. + +## What can go wrong (real failure modes) + +| Symptom | Likely cause | Fix | +|---|---|---| +| Section renders blank even though the entry has content | A `file` (multiple) field bound directly to a scalar `imageurl` prop | Bind to `field.0.url` instead (see `wire-external-data`) | +| Section auto-binds but renders the wrong field | Two fields had matching UIDs. First match wins | Switch the binding in the right-panel Settings dropdown | +| Section drops unbound on a CT that "looks right" | Field UIDs differ (`title` vs `headline`) | Rename the CT field OR change the Section's linked schema to match, best to align on convention | +| Repeater renders only one row | Repeater isn't bound to an array field, or the binding scope is wrong | See `use-repeater` | + +## Next steps + +| If you want to… | Skill | +|---|---| +| Create the Section that will auto-bind | `build-section` | +| Author the linked schema so auto-binding can work | `build-section` (linked-schema step) | +| Drop a Section into a template and see auto-binding live | `build-connected-template` | +| Diagnose a Section that didn't auto-bind | `troubleshoot-data-binding` | + +## Data-flow at render time: `selectedField` re-scopes the section + +Drop-scope (page / Repeater / Slot) is what this skill covers: the UI-level "where did I drop it, what fields are visible." A separate mechanism runs at render time: `getScopedData(pageEntry, selectedField)` on the Section's linked-schema entry. When `selectedField` is set, the Section's `template` becomes that one field's value. When it is unset, `template` is the whole page entry. The Section's own canvas always shows one empty placeholder because the scoping only happens at template-render time. See [`build-section`](build-section.md) § How a Section gets its data for the set-vs-unset decision rule. + +## See also + +- `understand-linked-schemas`: the declaration that makes auto-binding work +- `understand-sections`: what Sections are +- `build-section` § How a Section gets its data: `selectedField` model + set-vs-unset rule +- `author-composition-via-api` § Authoring a Section composition: wire shapes +- `docs/32-sections/binding-to-cms.md`: long-form reference diff --git a/skills/studio/understand-bring-your-own-data.md b/skills/studio/understand-bring-your-own-data.md new file mode 100644 index 0000000..5e0cc4b --- /dev/null +++ b/skills/studio/understand-bring-your-own-data.md @@ -0,0 +1,87 @@ +--- +name: understand-bring-your-own-data +--- + +## When to use + +Explain the three ways your own data reaches a composition (the `data` prop, `<StudioComposition context>`, and `<Slot data>`) and which one to pick. + +Use before `render-with-own-data` or `wire-slot-data` when the user holds data outside the CMS and isn't sure which surface takes it. Phrases: "render a section with my own data", "pass data into a slot", "which prop do I use". Do NOT use to write code. Run the action skill. + +> **Auth preflight: settle the credential before the first API call.** Resolve it OAuth-first per [`authenticate-cma`](authenticate-cma.md): `CS_OAUTH_ACCESS_TOKEN`, else the Contentstack MCP's stored session: Read that skill for where it is, even with no MCP connected. **Never ask the user for a session `authtoken`.** If nothing resolves, or a refresh fails with `400 invalid_refresh_token`, hand them `! CONTENTSTACK_REGION=<code> npx @contentstack/mcp --auth` (it needs a TTY and a browser, so it cannot be run for them) and wait. `403 error_code 316` is a valid credential aimed at another org: fix the org or the `api_key`, do **not** re-authenticate. + +# Bringing your own data into a composition + +## The one-line answer + +**Studio compositions normally bind to Contentstack content, but three render-time props let you hand a composition data you already hold**: and each one covers a different scope. Pick by asking what part of the tree needs the data. + +Reference: `docs/25-bring-your-own-data/overview.md`. + +## The three surfaces + +| Prop | Scope of the data | How authors reach it | Skill | +|---|---|---|---| +| `data` on `<StudioComponent />` | The whole composition on a route | In the Data Picker, under **Component Default Data** | `wire-external-data` | +| `context` on `<StudioComposition />` | The whole composition you mount yourself, placed at `dataSources.template`, so the composition's own template bindings resolve against it | Nothing to bind: the bindings already exist in the composition | `render-with-own-data` | +| `data` on `<Slot>` | One slot's subtree | In the Data Picker, under **Component Default Data**, then **Component Props**, scoped to the slot | `wire-slot-data` | + +All three are **render-time only**. They live in your application code. Nothing extra is stored in Contentstack, and nothing about the saved composition changes. + +## The decision + +Ask two questions in order: + +1. **Who owns the route?** + - Studio owns it (a template renders the page), so you're on `<StudioComponent />`. External values enter through its `data` prop. See `wire-external-data`. + - Your code owns it (a hand-written page, a loop, an embedded block), so you mount `<StudioComposition />` and supply `context`. See `render-with-own-data`. + +2. **Is the data for the whole composition or for one slot?** + - Whole composition: the props above. + - Only for whatever an author drops into a specific `slot` prop: use `<Slot data={...}>`. See `wire-slot-data`. + +The second question is independent of the first: a slot inside a `<StudioComponent />` page and a slot inside a `<StudioComposition />` subtree both carry data the same way. + +## Why `context` is not `data` + +They look interchangeable and are not: + +- `data` (on `<StudioComponent />`) lands in the **Component Default Data** source. An author must bind a prop to it before anything renders differently. It's a new source alongside the CMS ones. +- `context` (on `<StudioComposition />`) lands at `dataSources.template`, the slot the SDK's own template fetch would fill. The composition's existing bindings resolve against it with no author action, which is what makes "render this section per array element" a one-liner. + +So `data` adds a source authors bind to. `context` substitutes the template data the composition already binds to. + +## A real example of each + +**`data` prop**: a product page template renders on `/p/[sku]`. Live pricing comes from a commerce API. The route fetches the price and passes `data={{ livePricing }}`. An author binds the price component's prop to `livePricing.unit_price`. + +**`context` prop**: an entry holds a multiple-group field of 12 features. You fetch the entry yourself and render a `feature_tile` section once per element: `<StudioComposition spec={spec} context={element} />`. Each tile's existing bindings resolve against its own element. + +**`<Slot data>`**: a Hero banner exposes a `ctaSlot` so authors drop any CTA into it, but the banner owns `ctaDestination`. Props can't reach a slot, so the banner carries the value through it: `<Slot data={{ destination: ctaDestination }}>{ctaSlot}</Slot>`. + +## What none of them do + +| Misconception | Reality | +|---|---| +| "This stores my data in Contentstack" | No. All three are render-time props. The saved composition is unchanged. | +| "`<StudioComposition />` replaces `<StudioComponent />`" | No. `<StudioComponent />` owns a route and fetches composition + CMS data. `<StudioComposition />` renders a composition you already have data for, anywhere in your tree. | +| "`context` shows up in the Data Picker as a new source" | No. It substitutes template data. Only `data` (component or slot) adds a pickable source. | +| "A `slot` prop passes the parent's props down automatically" | No. A slot's children are author-chosen, so props can't reach them. `<Slot data={...}>` is the bridge. | +| "I need `context` to render a section outside its template" | Only if you hold the data. A section can also render through its own template route, or through `embed-composition`. | + +## Next steps + +| If you want to… | Skill to run | +|---|---| +| Render a composition against data you hold (loop a group array, render a section per record) | `render-with-own-data` | +| Let components dropped into a slot bind the owner's data | `wire-slot-data` | +| Bring non-CMS values into a route-rendered page | `wire-external-data` | +| Give unbound props placeholder content | `wire-component-default-data` | +| Declare the `slot` prop in the first place | `register-component` § 5a | + +## See also + +- `docs/25-bring-your-own-data/overview.md`: the chapter overview +- `docs/25-bring-your-own-data/studio-composition-props-reference.md`: `<StudioComposition />` prop table + `sdk.fetchComposition` +- `docs/25-bring-your-own-data/slot-props-reference.md`: `<Slot>` prop table +- `understand-canvas-vs-component`: the sibling distinction between the two mounted SDK components diff --git a/skills/studio/understand-canvas-url.md b/skills/studio/understand-canvas-url.md new file mode 100644 index 0000000..c30ad0b --- /dev/null +++ b/skills/studio/understand-canvas-url.md @@ -0,0 +1,112 @@ +--- +name: understand-canvas-url +--- + +## When to use + +Explain the Canvas URL: why it must be a relative path (not a full URL), why `/canvas` is standard, and how it differs from the env Base URL (origin). + +Concept on-ramp when someone asks "what is the Canvas URL", "why is my canvas blank", sets it wrong, or creating/configuring a project or setting a Canvas URL or Base URL (UI or API), so path/origin aren't merged. Phrases: "Canvas URL", "/canvas vs full origin". Do NOT use to wire the route. Run `setup-section-preview`. Concept only. + +> **Auth preflight: settle the credential before the first API call.** Resolve it OAuth-first per [`authenticate-cma`](authenticate-cma.md): `CS_OAUTH_ACCESS_TOKEN`, else the Contentstack MCP's stored session: Read that skill for where it is, even with no MCP connected. **Never ask the user for a session `authtoken`.** If nothing resolves, or a refresh fails with `400 invalid_refresh_token`, hand them `! CONTENTSTACK_REGION=<code> npx @contentstack/mcp --auth` (it needs a TTY and a browser, so it cannot be run for them) and wait. `403 error_code 316` is a valid credential aimed at another org: fix the org or the `api_key`, do **not** re-authenticate. + +# What is the Canvas URL? + +## The one-line answer + +**Canvas URL is a relative path (default `/canvas`): the route inside your canvas-app where `<StudioCanvas />` is mounted. Just a path, nothing else.** It tells Studio which route to load for section preview. The canvas-app's origin (e.g. `http://localhost:5173`) is a **separate** setting handled elsewhere. Don't put it here. + +Concrete: Canvas URL = `/canvas`. That's it. No `https://`, no hostname, no port, just `/canvas`. + +## What it must be + +- **A relative path**: starts with `/`, no scheme, no host, no port. +- **Pointing to a route in your canvas-app** that renders `<StudioCanvas />` and nothing else (no header, footer, nav: just the canvas mount). +- **The same in every environment.** Local dev, staging, prod: they all use the same Canvas URL (e.g. `/canvas`). Only the **origin** changes per environment. + +| Correct | Wrong | Why wrong | +|---|---|---| +| `/canvas` | `https://localhost:5173/canvas` | Including the origin hard-codes it to one environment. Studio joins the path with the project's environment-aware origin at runtime. | +| `/studio-canvas` | `localhost:5173/canvas` | Missing scheme + missing leading `/`, won't parse. | +| `/preview` | `canvas` | No leading slash, so the value is ambiguous. Studio refuses. | + +## Why the standard is `/canvas` + +- **Recognizable**: anyone reading the codebase sees `/canvas` and knows it's the Studio mount. +- **Unlikely to clash**: most apps don't have a public `/canvas` route already. +- **Same across teams**: every Contentstack reference project and recipe uses `/canvas`, so support and docs assume it. + +Use a different path **only if** `/canvas` collides with an existing route. Common alternatives: `/studio-canvas`, `/preview/canvas`, `/_studio`. Whatever you pick, document it in your team's README. The value is per-project and lives in `Studio → Project → Settings → Configuration`. + +## What lives at the Canvas URL + +A minimal route mounting `<StudioCanvas />`: + +```tsx +// Vite + React Router — src/routes/CanvasRoute.tsx +import { StudioCanvas } from "@contentstack/studio-react"; +export default function CanvasRoute() { + return <StudioCanvas />; +} +``` + +`<StudioCanvas />` is client-only: it reads the post-message channel with Studio's parent frame and `window.location`. For SSR frameworks (Next, Remix), load it client-only via `next/dynamic({ ssr: false })` or `ClientOnly`. `setup-section-preview` emits the right shape per framework. + +The route should NOT include nav, header, footer, or any other layout chrome. Those would push the canvas down and clip Studio's overlays. Bare `<StudioCanvas />` and nothing else. + +## Where Studio uses the Canvas URL + +| Studio feature | Uses Canvas URL? | Notes | +|---|---|---| +| **Section preview** (Sections tab, then click any section) | **Yes** | Studio iframes `<canvas-app-origin>/<Canvas URL>` | +| **Component preview / palette tile previews** | Yes: inside the same section canvas iframe | | +| **Connected template preview** (Templates tab, then a connected template) | **No**: uses the **environment base URL + template URL** | `Stack → Settings → Environments → <env> → Base URL` + the template's resolved path. See `setup-template-preview-routes`. | +| **Live website rendering** | No | The live site uses whatever route(s) you set up to serve `<StudioComponent />`, not the canvas mount | + +**Canvas URL is sections-only.** Templates preview at the **environment base URL** plus the template's own URL. That's the same path a real visitor would hit. They render through a route on your app that mounts `<StudioComponent />`, usually the catch-all that `setup-template-preview-routes` configures by default, but **a dedicated per-template route works too** (e.g. `app/blog/[slug]/page.tsx` mounting `<StudioComponent />` for blog URLs only). Either approach renders templates. Neither uses Canvas URL. + +The most common confusion: a user sets the Canvas URL correctly, sections render in Studio, then a template fails. That's because the template preview iframes the **env base URL + the template's resolved path**, completely separate from Canvas URL. See `build-connected-template` §15 for the env-base-URL scheme-match fix. + +## Where it's stored + +| Surface | Where to set it | +|---|---| +| In the Studio UI | `Studio → Project → Settings → Configuration → Canvas URL` | +| Via the provisioning API | `PATCH /v1/projects/{projectId} { canvas_url: "/canvas" }` | +| In the canvas-app | Nowhere: the canvas-app doesn't need to know its own Canvas URL. It just needs the route to exist at the matching path. | + +## "Where does the canvas-app's origin come from then?" + +That's a **separate** Studio setting, not the Canvas URL field. The origin (e.g. `http://localhost:5173` for local dev, `https://staging.example.com` for staging) is configured per Contentstack environment, distinct from the Canvas URL path. + +Keep them mentally separate when reading the docs: + +- **Canvas URL** = path. `/canvas`. Lives in Studio Project Settings, under Configuration. That's all this skill is about. +- The canvas-app's origin = a different Contentstack setting in a different place. Out of scope here. + +If a Studio screenshot or instruction tells you to put `http://localhost:5173` in the Canvas URL field, it's wrong. That's an origin, not a Canvas URL. Canvas URL stays `/canvas`. + +For local dev origins specifically: `http://localhost:<port>` works fine. Modern browsers treat localhost as a trusted origin per the W3C Secure Contexts spec, so no HTTPS / mkcert is required for the basic case. Use `setup-local-https-canvas` only if your app separately needs HTTPS-on-localhost (service workers, secure cookies, strict policy). + +## Quick sanity check before setting Canvas URL + +1. Decide the path: default `/canvas` unless it collides. +2. Confirm your canvas-app has (or will have) a route at that path mounting `<StudioCanvas />`. If it doesn't yet, run `setup-section-preview`. +3. Confirm the canvas-app's origin (the environment base URL) is reachable from a browser. For local dev, `http://localhost:<port>` works. Use `setup-local-https-canvas` only if you separately need HTTPS-on-localhost (service workers, etc.). +4. Then set the Canvas URL in Studio Project Settings, under Configuration. + +## What if I get this wrong? + +| Symptom | Likely cause | +|---|---| +| Canvas iframe is blank, no console error | Canvas URL is a full URL (with `https://`) instead of a relative path, **or** the path doesn't exist on the canvas-app yet, **or** (rare) a corporate browser policy overrides the localhost secure-context exemption and a mkcert HTTPS cert is needed | +| Canvas iframe shows the host app's home page | Canvas URL is `/` or empty, no dedicated canvas route | +| "host not allowed" 403 | Canvas URL is correct but the canvas-app's dev server rejects the iframe origin. See `setup-section-preview` for `allowedHosts: true` + `frame-ancestors *` config | +| Sections render, templates don't | Canvas URL is fine. The failure is the **environment base URL** scheme (HTTP vs HTTPS mismatch). Templates use the env base URL + their own URL, not Canvas URL. | + +## See also + +- `setup-section-preview`: creates the route file and sets Canvas URL in Studio +- `setup-local-https-canvas`: required prereq for local dev (mkcert) +- `setup-template-preview-routes`: connected templates use the env base URL, not Canvas URL +- `troubleshoot-canvas`: blank-iframe symptom rows diff --git a/skills/studio/understand-canvas-vs-component.md b/skills/studio/understand-canvas-vs-component.md new file mode 100644 index 0000000..77883cf --- /dev/null +++ b/skills/studio/understand-canvas-vs-component.md @@ -0,0 +1,98 @@ +--- +name: understand-canvas-vs-component +--- + +## When to use + +`<StudioCanvas />` mounts Sections inside Studio's iframe. `<StudioComponent />` renders Templates on visitor routes AND Studio's template-preview iframe. The split is by kind, not surface. + +Use when the user asks "what's the difference between StudioCanvas and StudioComponent", "which one for Sections vs Templates", "do I render Templates with StudioCanvas inside Studio", or before `setup-section-preview` / `setup-template-preview-routes` if the two mounts haven't been contrasted yet. Concept only: for routes, run those skills. + +> **Mandatory auth preflight: settle the credential before the first API call.** Resolve it OAuth-first per [`authenticate-cma`](authenticate-cma.md): `CS_OAUTH_ACCESS_TOKEN`, else the Contentstack MCP's stored session: Read that skill for where it is, even with no MCP connected. **Never ask the user for a session `authtoken`.** If nothing resolves, or a refresh fails with `400 invalid_refresh_token`, hand them `! CONTENTSTACK_REGION=<code> npx @contentstack/mcp --auth` (it needs a TTY and a browser, so it cannot be run for them) and wait. `403 error_code 316` is a valid credential aimed at another org: fix the org or the `api_key`, do **not** re-authenticate. + +# `<StudioCanvas />` vs `<StudioComponent />`: the Sections-vs-Templates axis + +## The one-line answer + +**`<StudioCanvas />` is for Sections. `<StudioComponent />` is for Templates.** The split is along the **Section vs Template** axis (what you're rendering) not along authoring vs visitor. + +- `<StudioCanvas />` renders **Sections only**. It's used inside Studio's iframe for Section building, authoring, and previewing. Templates never render through `<StudioCanvas />`. +- `<StudioComponent />` renders **Templates**. It's used everywhere a Template renders: your live visitor routes AND Studio's template-preview iframe (which iframes the env Base URL + Template URL, hitting the same route a visitor would). + +There IS still an authoring-vs-visitor distinction for Templates, but it's the SAME mount (`<StudioComponent />`) in both cases: Studio's iframe injects edit-mode signals. The visitor's browser doesn't. Both go through the same `<StudioComponent />`-mounted route. + +## The two mounts side-by-side + +| | `<StudioCanvas />` | `<StudioComponent />` | +|---|---|---| +| **What it renders** | Sections, and only Sections | Templates (which internally render their Sections) | +| **Where it lives** | One dedicated route in your canvas-app (default `/canvas`) | Your real routes: a catch-all `[...slug]`, or per-template files like `app/blog/[slug]/page.tsx` | +| **Used by** | Studio's iframe, for building, authoring, and previewing Sections | (a) Real visitors on your live site, (b) Studio's iframe when previewing Templates. Both go through this same mount | +| **Inside Studio?** | Yes, always inside Studio's iframe | Yes when Studio previews a Template (Studio iframes env Base URL + Template URL), also rendered to real visitors outside Studio | +| **Edit overlays** | Yes: hover handles, drop targets, Property panel hooks for Section editing | Studio's iframe injects edit signals so the same render shows overlays in the iframe. A visitor's browser sees the clean render | +| **Reachable URL** | `<env Base URL>` + `<Canvas URL path>` (e.g. `https://localhost:5173/canvas`) | `<env Base URL>` + the Template's resolved URL (e.g. `https://localhost:5173/blog/my-post`), the same URL a visitor would type | +| **Set up via** | `setup-section-preview` | `setup-template-preview-routes` | + +## Why Templates never go through `<StudioCanvas />` + +A Section is a unit of composition, a reusable piece with a linked schema and bindings to it. A Template is the page-level container that assembles one or more Sections into a complete page shape. + +Studio designed two separate iframe paths for these: + +- **Section preview**: Studio iframes `<env Base URL>` + `<Canvas URL path>`. That route mounts `<StudioCanvas />`. Studio sends the Section UID in URL params. `<StudioCanvas />` reads them and renders the requested Section in edit mode. +- **Template preview**: Studio iframes `<env Base URL>` + `<Template URL>`. That route mounts `<StudioComponent />`, the exact same route a real visitor would hit. Studio's iframe injects edit-mode signals on top of the live render. + +The benefit of the second design is that Studio's "preview" of a Template is rendered through your real visitor code path. If it renders correctly for a visitor, it renders correctly in Studio. There's no separate authoring renderer for Templates to drift out of sync. + +So `<StudioCanvas />` is **strictly** for Sections, **strictly** inside Studio's iframe. It has no role in Template rendering, anywhere. + +## Why only Templates render through `<StudioComponent />` + +Visitors hit URLs that resolve to **pages**, not Sections. So the live-render path needs the Template (the page). That Template internally renders its Sections. There's no public route for "give me Section X by itself": Sections only ever appear inside a Template, and the Template renders through `<StudioComponent />`. + +If a user asks "how do I render a Section on my live site standalone," the answer is: you don't. Put it in a Template (even a tiny one-Section Template if you must), then render that Template via `<StudioComponent />`. That's the only path. + +## Common misconceptions + +| Statement | Right or wrong? | Why | +|---|---|---| +| "I use `<StudioCanvas />` for authoring (both Sections and Templates) and `<StudioComponent />` for visitors only" | **Wrong** | The split is Section vs Template, not authoring vs visitor. Templates author through the same `<StudioComponent />` route a visitor hits. Studio just iframes it with edit signals. `<StudioCanvas />` never renders Templates. | +| "I need a separate `<StudioCanvas />` route for Templates" | **Wrong** | There's no such route. Templates use `<StudioComponent />`, even when previewed inside Studio. | +| "Sections show up on visitor pages standalone" | **Wrong** | Sections only render inside a Template. The visitor sees the Template (via `<StudioComponent />`), and the Template renders its Sections. | +| "`<StudioCanvas />` is only used during dev" | **Wrong** | It's used whenever an author opens a Section in Studio: dev, staging, or prod. The canvas-app needs the `/canvas` route in every environment Studio is allowed to point at. | +| "I can replace `<StudioComponent />` with `<StudioCanvas />` on my live route" | **Wrong** | `<StudioCanvas />` expects to be inside Studio's iframe with its post-message channel. On a live route it has nothing to talk to and won't render correctly. Also, it only renders Sections: visitors need Templates. | +| "Templates compose Sections" | **Right** | This is the canonical assembly direction. Build each Section first, then assemble them into a Template. | +| "Studio's Template preview hits the same route a visitor hits" | **Right** | Studio iframes env Base URL + Template URL: that's your visitor route, mounting `<StudioComponent />`. Edit overlays come from signals Studio injects into the iframe, not from a separate authoring renderer. | + +## Where each mount lives in a typical app + +``` +your-canvas-app/ +├── app/ # (Next.js App Router shown; Vite/Remix are similar) +│ ├── canvas/ +│ │ └── page.tsx # <StudioCanvas /> ← Section mount, ONE route +│ ├── [[...slug]]/ +│ │ └── page.tsx # <StudioComponent /> ← Template mount, catch-all +│ └── blog/ +│ └── [slug]/ +│ └── page.tsx # <StudioComponent /> ← OR per-template mount +``` + +- The `/canvas` route is created by `setup-section-preview` and mounts `<StudioCanvas />`. **One** such route, regardless of how many Sections the project has: Studio passes the Section UID in URL params. +- The visitor / template-preview routes are created by `setup-template-preview-routes`. Either a single catch-all (renders any Template by URL) or per-template routes (more control, more boilerplate). Both mount `<StudioComponent />`. These are the same routes Studio iframes when previewing a Template. + +## Quick decision questions + +- **"An author is editing a Section in Studio."** Studio's iframe loads your `/canvas` route, and `<StudioCanvas />` renders the Section. +- **"An author is editing/previewing a Template in Studio."** Studio's iframe loads `<env Base URL>` plus `<Template URL>`, and `<StudioComponent />` on your real route renders the Template. Same code path a visitor hits. Edit signals injected by Studio. +- **"A visitor typed my URL into a browser."** Your app's normal routing hits a `<StudioComponent />` mount, which live-renders the Template, and the Template renders its Sections inside. +- **"I'm rendering a Section directly on my live site."** You don't. Put the Section in a Template. Render the Template via `<StudioComponent />`. + +## See also + +- `understand-sections`: what a Section is +- `understand-templates`: what a Template is + Templates-compose-Sections +- `understand-canvas-url`: the path-only Canvas URL setting that points Studio at the `<StudioCanvas />` route +- `setup-section-preview`: creates the `/canvas` route mounting `<StudioCanvas />` +- `setup-template-preview-routes`: creates the visitor / template-preview route(s) mounting `<StudioComponent />` +- `configure-csr-vs-ssr`: choosing how `<StudioComponent />` renders (CSR / SSR / RSC) diff --git a/skills/studio/understand-contentstack-mcp.md b/skills/studio/understand-contentstack-mcp.md new file mode 100644 index 0000000..a0d545b --- /dev/null +++ b/skills/studio/understand-contentstack-mcp.md @@ -0,0 +1,132 @@ +--- +name: understand-contentstack-mcp +--- + +## When to use + +What the Contentstack MCP server does, and what it does not. Read before installing, to decide whether it fits the task. Installing it is [`install-contentstack-mcp`](install-contentstack-mcp.md). + +## What it does: and does NOT do + +Grounded in `contentstack.com/docs/agent-os/contentstack-mcp-server` **and verified against the CLI source** (`~/.npm/_npx/*/node_modules/@contentstack/mcp/dist/index.js`). + +> **REGION GATE: establish the data center BEFORE `--auth`, never after.** Verified against the CLI (`@contentstack/mcp` 0.9.0, `dist/chunk-IJB7CYTF.js`). Two independent settings, and only one of them is prod-locked: +> +> - **Region**: `--region` / `CONTENTSTACK_REGION`, normalized through `REGION_ALIASES` to exactly 7 codes: `NA` (aliases `US`, `AWS_NA`), `EU` (`AWS_EU`), `AU` (`AWS_AU`), `AZURE_NA`, `AZURE_EU`, `GCP_NA`, `GCP_EU`. Anything else (including a non-prod environment name) **throws at startup**: `Unknown region "<value>". Provide a valid region via --region or CONTENTSTACK_REGION`. +> - **Hosts**: overridable per tool group via `ENDPOINT_OVERRIDE_ENV`: `CONTENTSTACK_CMA_BASE_URL`, `CONTENTSTACK_CDA_BASE_URL`, `CONTENTSTACK_PERSONALIZE_BASE_URL`, `CONTENTSTACK_LAUNCH_BASE_URL`, `CONTENTSTACK_AUTOMATIONS_BASE_URL`, `CONTENTSTACK_DEVELOPERHUB_BASE_URL`, `CONTENTSTACK_ANALYTICS_BASE_URL`, `CONTENTSTACK_BRANDKIT_API_BASE_URL`, `CONTENTSTACK_LYTICS_BASE_URL`. These **win over the region-derived endpoint**. +> +> **So non-prod API calls ARE reachable**: a valid region code (it only has to normalize) plus the base-URL overrides for your `<env>`. **But the OAuth login itself is not:** the authorize host is region-derived with no override, so `--auth` can't mint a token for a custom DC. Use a management token there, or `csdx`. See § [OAuth on a custom region](#oauth-on-a-custom-region). +> +> **Why this must gate the login.** `DEFAULT_REGION = "NA"`. With nothing set, the CLI silently authenticates and runs **against NA prod**, no prompt, no warning. On a non-prod project that means an OAuth session and CMA writes pointing at the wrong data center, and every call succeeds. Resolve the DC first (Step 0). Only then run `--auth`. + +### `--auth` is an interactive TUI: only the browser login needs you + +Runtime-verified against `@contentstack/mcp` 0.9.0. `--auth` is **not** a headless command. It opens an `inquirer` menu on the terminal: + +``` +? Select an action: ❯ Authorization / Exit +? Authorization actions: ❯ Login / Reauthorize / Logout / Back +? Select your Contentstack region: ❯ <region> +``` + +…then opens a browser and waits for the OAuth callback on `http://localhost:8184`. + +Without a TTY it dies immediately with **`Error: User force closed the prompt with 0 null`**. Piping input gets through the first menu and fails on the second, measured. And even with the menus driven, the browser login still needs a human. + +**So drive the menus via a PTY ([`install-contentstack-mcp`](install-contentstack-mcp.md) Step 3) or hand the user the command, region included.** Never make them work out flags. In Claude Code, `!` runs it in the session so the output lands back in the conversation: + +``` +! CONTENTSTACK_REGION=<NA|EU|AU|AZURE_NA|AZURE_EU|GCP_NA|GCP_EU> npx @contentstack/mcp --auth +``` + +Then choose **Authorization**, then **Login**. The browser opens and you finish the login there. The region belongs **in the command**, because `--auth` bakes the data center into the stored session. Setting it afterwards does not move an existing token (§ REGION GATE). + +**Never print the command without a resolved region.** `DEFAULT_REGION` is `NA`, so omitting it silently authenticates against NA prod. + +### When to re-run it: the session can die mid-work + +Three signatures mean the stored session is unusable and the command above is the fix: + +| Signature | Cause | +|---|---| +| `401 error_code 105` "access token is invalid or expired or revoked", and a refresh isn't possible | access token past its hour, no usable refresh token | +| `400 {"message":["invalid refresh_token"]}` | the stored refresh token was **spent**: refresh tokens rotate, and something refreshed without writing the new pair back ([`authenticate-cma`](authenticate-cma.md) § Refresh tokens rotate) | +| the store file is absent | never authenticated on this machine | + +**`403 error_code 316` is NOT one of them.** That is a valid credential pointed at a stack outside its org. Re-authenticating changes nothing unless you deliberately switch org. Check with `GET /v3/stacks` + `organization_uid` first. + +**Replaces the `authtoken`.** `npx @contentstack/mcp --auth` opens a browser OAuth login, then saves the tokens locally and reuses them automatically on every later run. + +**Its OAuth session is reusable outside the MCP.** `--auth` writes `~/Library/Application Support/ContentstackMCP/oauth-config.json` (`%LOCALAPPDATA%\ContentstackMCP` on Windows, `~/.config/ContentstackMCP` elsewhere) holding `access_token`, `refresh_token`, `expires_in` (3600), `token_issued_at` and `organization_uid`. Scripts can read it instead of demanding an exported token. Scripts that refresh it must write the rotated pair back. The MCP refreshes within **5 minutes** of expiry. Anything sharing the store should use the same margin. See [`authenticate-cma`](authenticate-cma.md) § Where the credential actually lives. + +**Auto-captures `organization_uid` + region.** The OAuth token stores the **org** (your login's default org) and the region, no need to ask for `org_id`. (This corrects the docs' "can't discover" claim: org IS captured. Only the stack isn't.) + +**Runs CMA ops as tools** (77 CMA + 22 CMA-extended): `create_a_content_type`, `update_content_type`, `get_all_content_types`, `create_a_global_field`, `update_a_global_field`, `create_an_entry`, `update_an_entry`, `publish_an_entry`, `get_all_entries`, `get_all_environments`, `create_an_environment`, `get_all_assets`, `publish_an_asset`. Covers most of `provision-studio-stack`, the CT-create in `provision-studio-project`, and entry create/publish in `author-composition-via-api`. **Note: asset upload has NO tool** (only get/publish/update/delete of existing assets). Creating a new asset from a binary stays raw multipart `POST /v3/assets`. + +**Does NOT auto-capture the stack `api_key`.** The token's `stack_api_key` is empty, no `list_stacks` tool. Before asking, read `CONTENTSTACK_API_KEY` from the MCP's registration (this project's `mcpServers.contentstack.env` in `~/.claude.json`), else [`install-playwright-mcp`](install-playwright-mcp.md) Step 5b's `CS_RECENT_STACK_API_KEY`. Found one: ask "Use stack `<key>`, or give another key?" None: ask for it. It MUST belong to the OAuth'd org (Step 5 new-vs-existing / 403-recovery loop). + +**Studio API (`/v1/projects`): OAuth FIRST, session authtoken only as fallback.** The MCP has no tool for it, so this is raw HTTP either way. But **attempt OAuth before asking anyone for a session token**, in this order: + +1. **OAuth `Authorization: Bearer <access_token>`**: the token `--auth` already stored. Works for `/v1/projects` read+write on environments where the Studio API accepts app tokens (confirmed on a non-prod data center after COMS-1710). **Always try this first.** +2. **`csdx --oauth`**: the CLI's own OAuth session, same credential class, no pasting. +3. **Create the project in the Studio UI**: zero tokens, and it onboards the stack too. Often the fastest answer for a one-off. +4. **Session authtoken, last resort only.** Fall back only after observing one of these exact rejections: + - `authtoken: <oauth-token>` returns **401 `error_code 105`** "authtoken is not valid" + - `Authorization: Bearer <oauth-token>` returns **422 `error_code 21`** "Stack not found" (auth accepted, stack unresolvable via the app-token path) + - a management token as `authtoken:` returns **401** "not logged in" + +> **Why the ordering matters: a session authtoken is a full user session credential.** It carries the whole logged-in identity, not a scoped grant: every org, every stack, every permission that user holds. Pasting one into a shell, a script, or an env file spreads it into history, logs and process lists, and it stays valid until that session ends. Treat it as a break-glass credential: **prefer OAuth, don't ask for a session token before OAuth has actually failed, never persist it, never echo it, never commit it.** If you must use one, scope it to the single call and discard it. + +**Capability varies by environment and is still rolling out**: some non-prod data centers accept OAuth Bearer for `/v1/projects`. A fresh NA prod stack was observed rejecting it. So don't hardcode either assumption: attempt OAuth, branch on the signatures above. See [`provision-studio-project`](provision-studio-project.md) step 10. + +**No delivery/preview-token tool, no stack-create, no Live-Preview-enable tool.** All stay raw HTTP (`provision-studio-project` steps 3, 5, and stack-create). + +> **The split:** Contentstack MCP = auth (incl. org) + CMA execution, **prod only**. Playwright MCP = stack-key discovery from the UI. Raw HTTP = Studio API + tokens + LP-enable + stack-create. Non-prod works through the MCP too, via base-URL overrides (Step 0). No single tool does all of it. + +<a id="non-prod-auth-path"></a> + +### Non-prod data centers: the one clear path + +The MCP's OAuth login is unreachable here because its authorize host cannot be overridden. API calls can still use explicit host overrides, so choose the fixed fallback per operation below: + +| Operation | Non-prod path | Notes | +|---|---|---| +| **CMA**: content types, entries, assets, publish | Raw `curl` against the non-prod CMA host with `-H "$CS_AUTH"`: **OAuth Bearer by default**. A session `authtoken` only as the last rung ([`authenticate-cma`](authenticate-cma.md)) | **Use `curl`, not Python.** `urllib`/`requests` fail with `SSLCertVerificationError`, the non-prod chain isn't in Python's CA store. `curl` reads the system keychain. See [`author-composition-via-api`](author-composition-via-api.md) § Common pitfalls | +| **Studio API**: `/v1/projects` register/configure | `csdx` with OAuth, or raw `curl` with a session authtoken against **`<env>-composable-studio-api.<non-prod-domain>`** (prod is `composable-studio-api.contentstack.com` + regional variants, see [`install-studio`](install-studio.md) § host table) | **Superseded:** an earlier version of this row said prod rejects OAuth Bearer. Runtime-verified since: `GET /v1/projects` on `composable-studio-api.contentstack.com` with `Authorization: Bearer <oauth>` + `organization_uid` returns **200** with the project list, and a non-prod data center accepts read+write on current builds. **OAuth is the first rung on prod too**. See [`authenticate-cma`](authenticate-cma.md). Still branch on the response rather than assuming, and pair Bearer with `organization_uid`, never a stack `api_key` | +| **`csdx` against a non-prod DC** | Set the custom region, then pass the DC's own OAuth app credentials as env vars, see § OAuth on a custom region below | The built-in app ids are per-DC, runtime-confirmed on a non-prod data center, where csdx's own default id fails the authorize step with **"App with id `<id>` not found"** | +| **Stack API key discovery** | Playwright MCP against the non-prod web app, or ask the user | No CLI/MCP path | +| **Anything the MCP would have done** | The equivalent raw-`curl` step in the owning skill | Every provisioning skill keeps a full curl fallback. Nothing is MCP-only | + +<a id="oauth-on-a-custom-region"></a> + +#### OAuth on a custom region: three things must line up, and two of them you supply + +OAuth apps are **registered per data center**. Ids from one data center do not exist on another. So authorizing against a custom region needs all three of: + +| # | What | Prod | Custom region | +|---|---|---|---| +| 1 | **Authorize UI host** | Derived from the region automatically | Must be that DC's UI host, e.g. `https://<env>-app.<non-prod-domain>` | +| 2 | **OAuth app id** | Built in / fetched automatically | **User supplies it**: it's registered on that DC only | +| 3 | **OAuth client id** | Built in / fetched automatically | **User supplies it** | + +**Never guess, reuse, or hardcode ids.** They are per-DC, they rotate, and they belong in the user's environment. **Always read them from env vars the user sets. Never write a concrete id into a skill, script, config, or commit.** Ask the user for their DC's values. The failure is unambiguous when the id is wrong: the authorize page returns **"App with id `<id>` not found"** (runtime-confirmed on a non-prod data center using csdx's own built-in default). + +**`csdx` works, because it lets you set all three.** The region config supplies the UI host. The app credentials come from env: + +```bash +OAUTH_APP_ID=<dc-app-id> \ +OAUTH_CLIENT_ID=<dc-client-id> \ +OAUTH_APP_REDIRECT_URL=http://localhost:8184 \ +csdx auth:login --oauth +``` + +**The MCP: blocked on (1) at v0.9.0.** It exposes `CONTENTSTACK_OAUTH_APP_ID`, `CONTENTSTACK_OAUTH_CLIENT_ID` and `CONTENTSTACK_OAUTH_REDIRECT_URI` (note the different names from csdx), so items 2 and 3 are settable, but `applyRegion()` hardcodes `oauthBaseUrl = OAUTH_URLS[region].uiHost`, built from the 7 prod regions with **no env override**. Custom-region app credentials therefore get sent to a prod authorize endpoint, where that app doesn't exist. **So `--auth` cannot mint a token for a custom region**, and the `*_BASE_URL` overrides don't help: they redirect API calls, not the login. + +Two ways to use the MCP on a custom region anyway: + +- **Management token instead of OAuth**: CMA accepts `--management-token` / `CONTENTSTACK_MANAGEMENT_TOKEN` in place of `--auth`. Combine with `CONTENTSTACK_CMA_BASE_URL` / `CDA_BASE_URL` for the DC. This is the working path today. +- **Skip the MCP** for that work and use `csdx` (OAuth, as above) or raw `curl`. + +**Worth filing upstream:** every other piece is already overridable. One `CONTENTSTACK_OAUTH_UI_HOST` (or letting the region accept a custom endpoint set) would make the MCP work on non-prod. + +**Decide the host before the first call.** Switching a half-provisioned flow from prod to non-prod means re-checking auth on every step: the token types, the working header, and the SSL behavior all differ. diff --git a/skills/studio/understand-installation.md b/skills/studio/understand-installation.md new file mode 100644 index 0000000..8b79f5d --- /dev/null +++ b/skills/studio/understand-installation.md @@ -0,0 +1,70 @@ +--- +name: understand-installation +--- + +## When to use + +Explain what installing Contentstack Studio adds to a project: three SDKs (delivery / live-preview / studio-react), one shared init, two component mounts (StudioCanvas + StudioComponent). Concept on-ramp before `install-studio`. + +Use when a new user asks "what does installing Studio do", "what packages are added", "which SDKs do I need", "what's StudioCanvas vs StudioComponent", or is about to run `install-studio` without context. Concept only: for the actual install run `install-studio`. Do NOT use to fix an install (use `troubleshoot-canvas` or `verify-setup`). Do NOT use for component registration (use `register-component`). + +> **Auth preflight: settle the credential before the first API call.** Resolve it OAuth-first per [`authenticate-cma`](authenticate-cma.md): `CS_OAUTH_ACCESS_TOKEN`, else the Contentstack MCP's stored session: Read that skill for where it is, even with no MCP connected. **Never ask the user for a session `authtoken`.** If nothing resolves, or a refresh fails with `400 invalid_refresh_token`, hand them `! CONTENTSTACK_REGION=<code> npx @contentstack/mcp --auth` (it needs a TTY and a browser, so it cannot be run for them) and wait. `403 error_code 316` is a valid credential aimed at another org: fix the org or the `api_key`, do **not** re-authenticate. + +# What installing Studio adds to your project + +## The three SDKs + +Installing Studio adds **three packages** to your app. They're effectively one install. Each owns a distinct piece: + +| Package | What it does | Whose credentials | +|---|---|---| +| `@contentstack/delivery-sdk` | Reads published content from Contentstack's CDA. | Stack API Key + Delivery Token | +| `@contentstack/live-preview-utils` | Real-time edit channel: Studio's iframe pushes edits to your running app within ~1s. | Preview Token (paired with the Delivery Token) | +| `@contentstack/studio-react` | The Studio bridge: `studioSdk` (init), `<StudioCanvas />` (canvas mount), `<StudioComponent />` (visitor + template-preview mount), plus hooks like `useCompositionData`. | (uses the other two via `studioSdk.init({ stackSdk })`) | + +You don't install one without the others: install-studio wires all three. + +## The two React components, and where each goes + +This is the single most-confused part of the install: + +- **`<StudioCanvas />`** mounts on **ONE canvas route** (e.g. `/canvas`). It's the surface Studio's iframe uses to author Sections. Visitors never hit this route. A project has one Canvas URL: set it in Project Settings to the path you mount it on. See [`setup-section-preview`](setup-section-preview.md). +- **`<StudioComponent />`** mounts on your visitor routes. The default is **ONE catch-all route** (`app/[[...slug]]/page.tsx` for Next App Router, `<Route path="*">` for React Router): Studio resolves every visitor URL through this one mount via `sdk.fetchCompositionData({ url })`. Per-template routes (e.g. `app/blog/[slug]/page.tsx`) also work, since `fetchCompositionData` only needs the URL. They mean more boilerplate. See [`setup-template-preview-routes`](setup-template-preview-routes.md). + +Two mounts in the default setup. The install does not add per-template routes. + +## What gets wired at install time + +After `install-studio` completes: + +1. **Three SDK packages** in `package.json`. +2. **`src/lib/contentstack.ts`** (or framework equivalent): the single bootstrap module. Exports `stack`, `sdk`, optionally `ContentstackLivePreview`. This is where `studioSdk.init({ stackSdk, contentTypeUid })` happens. +3. **A side-effect import** at the app shell so the bootstrap runs once. Next App Router needs a `"use client" StudioInit` boundary. Vite / Pages Router / Remix import directly. +4. **`.env.local`** with the five credentials. + +The two route mounts are SEPARATE skills: `setup-section-preview` adds the canvas route. `setup-template-preview-routes` adds the visitor route (catch-all by default). `install-studio` deliberately stops before them so each route is an explicit decision. + +## What install does NOT do + +- Does NOT register any of your components. That's [`register-component`](register-component.md). +- Does NOT configure the Studio project (Environment, Canvas URL). That's [`configure-studio`](configure-studio.md), done in `app.contentstack.com`. +- Does NOT enable Live Preview at the stack level. That's [`enable-visual-experience`](enable-visual-experience.md), done in Stack settings BEFORE the app-side install. + +## The expected install order + +1. [`analyze-project-fit`](analyze-project-fit.md): preflight, picks greenfield vs minimal-add path. +2. [`enable-visual-experience`](enable-visual-experience.md): turn on Live Preview at stack level (otherwise app install succeeds silently but nothing flows). +3. [`install-studio`](install-studio.md): adds the three SDKs, writes the bootstrap module. +4. [`register-component`](register-component.md): register the customer's components so Studio's palette uses them. +5. [`setup-section-preview`](setup-section-preview.md): adds the `/canvas` route. +6. [`setup-template-preview-routes`](setup-template-preview-routes.md): adds the catch-all visitor route. +7. [`configure-studio`](configure-studio.md): set Environment, Canvas URL in the Studio web app. +8. [`verify-setup`](verify-setup.md): confirm all four layers green. + +This sequence is also walked by [`studio-tour`](studio-tour.md) and [`byoc-end-to-end`](byoc-end-to-end.md) orchestrators when you want one skill driving the whole flow. On an existing app, [`convert-project-to-studio`](convert-project-to-studio.md) runs steps 1 to 3 and 5 to 8 as a minimal conversion that leaves every current page untouched. + +## What this skill is NOT + +- Not the install itself. Run `install-studio`. +- Not a troubleshooting guide. Run [`troubleshoot`](troubleshoot.md) router if something's broken. +- Not the conceptual onboarding for new-to-Studio users. That's [`start-here-zero-knowledge`](start-here-zero-knowledge.md). diff --git a/skills/studio/understand-linked-schemas.md b/skills/studio/understand-linked-schemas.md new file mode 100644 index 0000000..e0da801 --- /dev/null +++ b/skills/studio/understand-linked-schemas.md @@ -0,0 +1,141 @@ +--- +name: understand-linked-schemas +--- + +## When to use + +Explain the **linked schema** on a Section (the declared data shape) and why declaring it once makes the Section auto-bind onto any template with a matching content type. + +Use before `build-section` or `build-connected-template` when the user is new to Studio. Also when the user asks "why doesn't my Section auto-bind?", "what's a linked schema?", "do I have to wire each binding every time?". Do NOT use to actually edit a linked schema: for that, edit the Section in Studio. Concept only. + +> **Auth preflight: settle the credential before the first API call.** Resolve it OAuth-first per [`authenticate-cma`](authenticate-cma.md): `CS_OAUTH_ACCESS_TOKEN`, else the Contentstack MCP's stored session: Read that skill for where it is, even with no MCP connected. **Never ask the user for a session `authtoken`.** If nothing resolves, or a refresh fails with `400 invalid_refresh_token`, hand them `! CONTENTSTACK_REGION=<code> npx @contentstack/mcp --auth` (it needs a TTY and a browser, so it cannot be run for them) and wait. `403 error_code 316` is a valid credential aimed at another org: fix the org or the `api_key`, do **not** re-authenticate. + +# What is a linked schema? + +## The one-line answer + +**A linked schema is the data shape a Section says it needs (declared once on the Section) so that when the Section is dropped on any template whose content type has that shape, Studio binds every inner prop to the matching CMS fields automatically.** No re-wiring per template. + +## Why this exists + +Without a linked schema, every time you drop a Section onto a new template, you'd have to wire every prop's binding by hand. That's tedious and error-prone, and it defeats reuse. A Section that takes 10 minutes to wire up per template isn't really reusable. + +With a linked schema, the Section says up front: "to render me, you need to give me a content shape with a `title` (string), a `body` (rich text), and a `cover` (image)." Studio then matches that declaration against the template's connected content type and binds the fields automatically. + +## A concrete example + +You have a Section called **"Blog Article Header."** It contains a Hero atomic component with props: + +``` +<Hero + title="..." // bound to ??? + subhead="..." // bound to ??? + cover="..." // bound to ??? +/> +``` + +When you author the Section, you declare its **linked schema**: + +``` +linked_schemas: { + title: { field_type: "Single line text", required: true }, + subhead: { field_type: "Multi line text", required: false }, + cover: { field_type: "File (image)", required: true }, +} +``` + +This says: "any content type with a `title`, optional `subhead`, and `cover` can drive this Section." + +Now you create three connected templates against three different content types: + +| Template | Connected CT | CT has these fields | Section drops and… | +|---|---|---|---| +| Blog post template | `blog_post` | `title`, `subhead`, `cover`, `body`, `author`, … | **Auto-binds all three slots silently** | +| Case study template | `case_study` | `title`, `cover`, `body`, `client`, … (no subhead) | **Auto-binds `title` + `cover`. Leaves `subhead` empty** | +| Product launch template | `product_launch` | `name`, `description`, `image` | **No match by field name**: Section drops unbound and Studio asks you to pick fields manually (or the template author edits the CT to add matching fields) | + +Same Section. Three templates. Zero manual binding when the shape matches. + +## What "the shape matches" actually means + +Studio's auto-binder matches on three signals, in order: + +1. **Field name (UID)**: the Section's linked-schema field UID equals a field UID on the template's connected CT. +2. **Field type**: the type matches (Single Line Text binds to Single Line Text. Not a Multi-line being squeezed into a Single Line). +3. **Required-ness**: required fields on the linked schema MUST exist on the CT. Optional ones may be missing. + +If all three line up for a given prop, Studio binds it. If even one mismatches, that prop drops unbound and the template author is prompted. + +## Why declare a linked schema at all (vs. binding per template) + +Three benefits: + +| Without linked schema (bind per template) | With linked schema (declare once) | +|---|---| +| Drop Section, wire 6 bindings, save | Drop Section, and all 6 bindings are done | +| Add a new template against a similar CT, and repeat all 6 bindings | Add a new template, and auto-bind is instant if the CT matches | +| Forget to wire one binding, which produces a silent empty render | Linked schema enforces the required props are filled | +| Rename a CMS field, which breaks every template that uses the Section | Rename a CMS field, fix the linked schema once, and every template re-binds | + +## What if the template's CT doesn't match the linked schema? + +Three outcomes depending on the kind of mismatch: + +| Mismatch | What Studio does | +|---|---| +| Required field missing on CT | Section drops unbound. Right-panel Settings shows the required field with a red "no source" warning. Author must either pick a different field (rare, they share a UID by convention) or add the field to the CT. | +| Optional field missing on CT | Section drops. That prop renders empty (`""` / `null`). No warning. | +| Field exists on CT but type differs | Section drops unbound for that prop. Studio asks the author to pick a compatible field manually. | +| Field exists on CT but a different UID (e.g. CT calls it `headline`, Section linked schema calls it `title`) | Section drops unbound for that prop. The author maps the binding manually one time. This is rare: teams usually align CT field names with the Section's expected UIDs. | + +## How linked schemas appear in Studio + +- **Section author** sets the linked schema when authoring the Section, in the Section's Settings panel, "Linked Schema" tab. +- **Template author** doesn't see the linked schema directly. They just experience the auto-binding (or the prompt to fix a mismatch). The schema is the contract Studio uses behind the scenes. + +## What linked schemas are NOT + +| Misconception | Reality | +|---|---| +| "Linked schema = the connected content type" | No. The linked schema is the Section's declared expectation. The connected content type is what a template is attached to. They're matched, not the same thing. | +| "Linked schema defines new CMS fields" | No. It references the shape of fields you've already defined in your CMS (or the conventions you want CTs to follow). It doesn't create CT fields. | +| "I have to use the exact same UIDs the Section expects" | Not strictly, but it makes auto-binding silent. Different UIDs require manual binding on first drop. | + +## Next steps + +| If you want to… | Skill / doc | +|---|---| +| Create a Section with a linked schema | `build-section` | +| Understand what happens when a Section drops on a template | `understand-auto-binding` | +| Read the long-form reference | `docs/90-reference/linked-schema-matching-rules.md` | +| Inspect a Section's linked schema | Open the Section in Studio, then in the right panel open Settings, then Linked Schema | + +## Sections have no data of their own: `selectedField` scopes the slice + +The linked schema declares what fields the Section CAN read, but the Section itself is data-less at author time. When it's placed on a template, the SDK calls `getScopedData(pageEntry, selectedField)`: + +- With `selectedField` set, `template` inside the section becomes that field's value only. +- With `selectedField` unset, `template` becomes the whole page entry. + +**Repeater or one item: the `cardinality` question.** Studio asks it in exactly one case: the section's `selected_field` has `multiple: true` AND `data_type` `group`, `global_field`, `file` or `link`. Its prompt: *"This schema supports multiple items. Add a repeater to render each item automatically, or skip if you only need a single section for now."* When authoring by API, **ask the user the same question** before building the section, and record the answer: + +| Answer | `linked_schemas[].cardinality` | Section `ui` | Bindings | +|---|---|---|---| +| Add: every item | `"collection"` | Root Repeater, `items` bound `{ "type": "template", "value": { "path": {} } }` | `repeater` bindings on the iteration item | +| Skip: one item | `"single"` | No root Repeater | `template` paths on one element (`title`, not `0.title`). Placed alone it shows the first element. Inside a page-side Repeater, the current item | + +Do **not** ask, and write no `cardinality`, when: there is no `selected_field` (whole entry), the field has `multiple: false`, or it is a `reference` or `blocks` field. Those always get a root Repeater with a Condition Block per content type or block (`author-composition-repeater-recipes`). Studio also skips the question once the section has bound content, and keeps the existing structure: a root Repeater means `"collection"`. + +```jsonc +"linked_schemas": [{ "content_type_uid": "blog_post", "selected_field": "features", "cardinality": "single" }] +``` + +Consequence: the Section's own canvas always shows **one empty placeholder**, not the rendered card: the data only arrives at template-render time. See [`build-section`](build-section.md) § How a Section gets its data for the set-vs-unset decision rule. Wire shapes and reference-iteration handling in [`author-composition-via-api`](author-composition-via-api.md) § Authoring a Section composition: scoping rules. + +## See also + +- `understand-sections`: the parent concept +- `understand-auto-binding`: what happens at drop time when a linked schema matches +- `build-section` § How a Section gets its data: the `selectedField` decision rule +- `author-composition-via-api` § Authoring a Section composition: full wire shapes for `selectedField` + `resolvedReferences` +- `docs/32-sections/binding-to-cms.md`: reference diff --git a/skills/studio/understand-reference-depth.md b/skills/studio/understand-reference-depth.md new file mode 100644 index 0000000..e6925dd --- /dev/null +++ b/skills/studio/understand-reference-depth.md @@ -0,0 +1,69 @@ +--- +name: understand-reference-depth +--- + +## When to use + +What reference depth actually counts, and the two signals that say it is finished. Read when you are unsure how deep to fetch, or why adding depth stopped helping. Depth counts reference hops; Groups and blocks are free, and stub count is not a progress bar. + +Resolving it in practice is [`resolve-reference-depth`](resolve-reference-depth.md). + +## The ladder + +**`include_all_depth` cannot be trusted to do anything: measure it, never assume it.** On one stack measured, an entry on a five-hop chain returned byte-identical payloads at 1, 2, 3, 4, 5, 6 and 10: same bytes, same stubs, no error at any value, and no effect layered on top of declared paths. It may behave differently on the stack in front of you. **So probe it once (Step 2b) rather than either trusting it or writing it off.** + +**What actually resolves a hop is a declared path, and every hop needs its own.** Same entry, same environment: + +| Declared | Real stubs left | Bytes | +|---|---|---| +| nothing | 11 | 9,699 | +| hop 1 | 12 | 62,052 | +| hop 1 + hop 2 (`….author`) | 11 | 63,955 | +| hop 1 + hop 2 + `….category` | 10 | 64,405 | + +One declared path buys exactly one hop. Nothing cascades. + +So the ladder is not a depth counter. It is: **derive the path, declare it, and when the path cannot be derived, ask.** + +| Pass | What you do | If data still missing | +|---|---|---| +| 1 | Declare every hop you can derive **from the schema**, then fetch once | **Continue automatically**: deriving costs one schema read | +| 2 | For each remaining stub, resolve its target CT and declare the next hop | **Continue** while each next hop is derivable, there is no depth number to stop at | +| 3 | A stub whose path you cannot derive: ambiguous multi-CT reference, a target you cannot see, or a chain that leaves this stack | **Ask the user.** Never guess, never re-root on your own | + +**Ask only where the next step is unbounded or hard to undo.** Passes 1 and 2 are neither: the schema states the path, so deriving it is bounded work with one right answer, and stopping to ask spends the user's attention on something you can look up. Pass 3 is different. There is no schema answer left, so a guess is a guess. That is where the checkpoint belongs. + +**Do not escalate depth as a substitute for asking.** Raising a number that the measurement above shows is inert, then reporting "resolved", is the exact failure this skill exists to prevent. + +### Two signals that depth is finished + +**A plateau.** When a pass returns exactly what the previous one did, the remaining stubs are not reachable by depth from this root. Measured: an entry held the same three unresolved paths at depths 2, 3 and 4 without moving. + +A plateau has two causes and they need opposite responses: the chain continues past what the schema can derive (a Step 3 question), or the targets are not published in this environment (absent content, nothing to ask about). **Run the stub probe from Step 0 to tell them apart before prompting anyone.** In every measured case it was the second, and a prompt would have been the wrong move. + +**`141`.** Same conclusion, reached by error rather than by plateau. + +### Depth counts reference hops. Groups and blocks are free. + +Nesting inside the same entry (groups, group-multiples, modular blocks, blocks within blocks) costs **no depth at all**. Only a hop into another entry does. This is the single most common reason a chain is diagnosed wrongly. + +Measured on a real page entry. This path is nine segments and crosses two modular-block levels, two block uids and a group: + +``` +page_blocks . slider_block . cards . card_with_image . card . detail_blocks . info_card . partner_group . partner + ↑MB ↑block ↑MB ↑block ↑REF ↑MB ↑block ↑group ↑REF +``` + +It contains exactly **two reference hops**, and it resolved completely at `include_all_depth: 2`, stub at 1, resolved at 2, unchanged at 3. Nine segments, depth two. + +**So a value buried four groups deep inside an entry inside an entry is depth 2, not depth 6.** If it renders blank, no amount of extra depth will fix it, and raising the number only spends the path budget until it returns `141`. + +The blank field in that situation is almost always the declared path instead: every group and block segment must appear in it, the block uid included, with reference-target CT uids dropped. Those four shapes and their exact terminations are canonical in [`author-composition-via-api`](author-composition-repeater-recipes.md#reference-path-termination): the path rules live there, the depth ladder lives here, and the two failures look identical from the outside. + +**Count hops from the schema, not from the dots.** Walk the content type: each segment whose field is `data_type: "reference"` is one hop. Group, blocks and block-uid segments are zero. The hop count is the depth you need. The full segment list is the path you must declare. + +### Stub count is not a progress bar + +The number of unresolved references can **rise** while you are succeeding, because resolving one level exposes the stubs at the next. Measured on two entries: 39, then 34, then **45** across three passes, while resolved references went 72, then 111, then 140. And 11 rose to **12** on declaring a single hop that added 52KB of content. + +So never report progress as "stubs went down", and never stop because they went up. Track the specific paths the binding actually needs, and report those by name. diff --git a/skills/studio/understand-section-slots.md b/skills/studio/understand-section-slots.md new file mode 100644 index 0000000..11c5a87 --- /dev/null +++ b/skills/studio/understand-section-slots.md @@ -0,0 +1,137 @@ +--- +name: understand-section-slots +--- + +## When to use + +Explain Section Slots: designated swap points inside a Section where a template author drops a different Section per instance without forking. Section-in-Slot, not raw component. + +Use before `use-section-slot` when the user is new to Studio. Also when the user asks "what's a Section Slot?", "do I need a new Section for X?", or is stuck between forking a Section vs exposing a slot. Phrases: "what's a slot", "swap point", "drop zone". Do NOT use to actually create a slot. For that, run `use-section-slot`. + +> **Auth preflight: settle the credential before the first API call.** Resolve it OAuth-first per [`authenticate-cma`](authenticate-cma.md): `CS_OAUTH_ACCESS_TOKEN`, else the Contentstack MCP's stored session: Read that skill for where it is, even with no MCP connected. **Never ask the user for a session `authtoken`.** If nothing resolves, or a refresh fails with `400 invalid_refresh_token`, hand them `! CONTENTSTACK_REGION=<code> npx @contentstack/mcp --auth` (it needs a TTY and a browser, so it cannot be run for them) and wait. `403 error_code 316` is a valid credential aimed at another org: fix the org or the `api_key`, do **not** re-authenticate. + +# What is a Section Slot? + +## "Slot" lives at two layers: read this first + +Studio uses the word "Slot" at two different layers. They compose, but they're distinct mechanisms. This skill teaches the second one. You'll see references to the first one too. + +| Layer | Name | Where it's declared | Who declares it | Skill | +|---|---|---|---|---| +| **Component schema** | `slot`-typed prop | In `registerComponent({ props: { body: { type: "slot" } } })` | The engineer registering a component | `register-component` § 5a | +| **Section composition** | **Section Slot** | A Smart Container element carved into a Section's tree | The author building a Section | This skill + `use-section-slot` | + +**They compose together.** Canonical pattern: +1. An engineer registers a `Card` component with a `body: { type: "slot" }` prop (component-level extensibility). +2. The section author carves a **Section Slot** inside that `body` region of the Card on the section canvas (section-level extensibility, see `use-section-slot` § component-slot-prop placement). +3. Template authors drop another Section into the Section Slot at template-authoring time (per-template extensibility). + +Three layers of extensibility working together. If you just want to skim: most authoring decisions in this skill are about layer 2 (Section Slot). The component-level `slot` prop is a separate engineer-side concern documented in `register-component`. + +## The one-line answer + +**A Section Slot is a placeholder inside a Section that template authors can fill with a different component per template instance.** It's how you make one Section work for multiple template layouts without copy-pasting the Section N times. + +## A real example + +You have one Section called **"Hero Strip"**, a banner with a title and a CTA area on the right. You use it on three pages: + +- Marketing landing pages need a **Button** in the CTA area (`Get started →`) +- Blog post pages need a **Subscribe form** in the CTA area (email input + submit) +- Product detail pages need an **Add to cart** widget in the CTA area + +All three pages share the same Hero Strip layout (same title position, same background, same spacing) but want a different component on the right. + +Without slots: three separate Sections (`HeroStripWithButton`, `HeroStripWithSubscribe`, `HeroStripWithCart`) that drift apart over time. + +**With a Section Slot:** one Hero Strip Section with a slot called `cta`. Template authors drop a Button into the slot on the marketing template, a Subscribe form on the blog template, an Add to Cart on the product template. One Section, three template layouts. + +## The mental model + +A Section Slot is **a designated placeholder** in the composed React tree: + +``` +Section "HeroStrip" +├── Title (bound to entry.title) +├── Background image (bound to entry.background) +└── Section Slot "cta" ← template author drops a component here +``` + +When a template uses this Section, the slot is empty until the author drags something into the outlined region on the canvas. Drop a `Button`, and Studio renders `Button` inside the slot for that template. The next template can drop something else. + +## What fills a Slot: a Section, not a raw component + +A Slot is filled at template-authoring time by **dropping another Section into it**, not a raw registered component. This matters because of how binding works: + +- A **Section** is already bound to its own linked schema. When it lands in a Slot, its bindings come along: the dropped Section knows what data it renders without the author touching any prop. Studio auto-binds the Section's linked schema to whatever data scope the Slot sits in (e.g. an iterated item if the Slot is inside a Repeater). +- A **raw registered component** has only prop signatures. Studio has no way to know which field maps to which prop at the Slot location. The author would have to hand-bind every prop through the Data Picker. That's the wrong granularity for a Section Slot. + +So the recipe is: **build the child as its own Section**, with its own linked schema, then drop that Section into the parent's Slot. One drop, no per-prop binding clicks. + +In the Hero Strip example, the CTA-area variations are each their own Section: a "CTA Button" Section bound to a button content shape, a "Subscribe Form" Section bound to a subscription content shape, etc. The template author drops the appropriate Section into the `cta` slot, and Studio handles the wiring. + +## Slot vs Exposed Prop: when do I use which? + + +| Section Slot | Exposed Prop | +|---|---| +| Swap a **child component** (Button vs Form vs Cart widget) | Override a **value on an existing prop** (button label = "Buy" vs "Get started") | +| The shape of the inner UI changes | The content of an existing prop changes | +| Template author drags a different component into the slot | Template author types a value in the right-panel Settings tab | +| Adds a tree-shape variation point | Adds a per-template content override | + +Use **a slot** when the kind of thing varies per template. Use **an exposed prop** when only the value of an existing thing varies. + +## When to expose one: the Global Field / Modular Block / Group / Reference heuristic + +The strongest, most reliable signal that a Section should expose a Slot is the **shape of the CT it's bound to**. If your CT has any **Global Field**, **Modular Block**, **Group**, or **Reference** field, the corresponding Section should expose a Slot for it: those fields are inherently composable / reusable, and inlining them into the parent Section throws that reusability away. + +**Global Fields are the strongest candidate of all**: they exist because the same field set is meant to be reused across multiple content types. If a CT embeds a Global Field, that Global Field almost always deserves its own Section (built against the Global Field schema directly) which the parent Section then exposes as a Slot. Inlining a Global Field's children into the parent Section duplicates work every team that uses the same Global Field elsewhere will also have to do. + +Why the rest qualify: each of those field kinds already represents another unit of structured content that other Sections can be built against. A Modular Block can be filled with different block types per entry. A Group / Reference points at a nested shape that any compatible Section can render. Exposing them as Slots lets the parent Section be filled with a different child Section per usage: same parent layout, different inner content shape. Inlining them locks in one shape forever. + +**Rule of thumb:** for every Global Field / Modular Block / Group / Reference field on the CT, default to exposing a Slot in the Section. Override only when you are certain that nested shape is genuinely one-off and never going to vary, and even then, Global Fields rarely qualify for the override (their whole reason for existing is cross-CT reuse). + +## When to add a Slot to a Section + +Add a Section Slot when ALL three are true: + +1. **The same Section layout** is used on multiple templates. +2. **One specific child component** needs to differ per template. +3. The different components share enough role/space that they fit in the same outlined region (a Button and a Form both go in "CTA area". A Button and a 600px-tall video grid don't). + +If only the value differs (button label, image URL), use an Exposed Prop instead: slots are heavier (they require the inner component to be droppable in Studio). + +If every template uses a different layout shape, the Sections probably aren't really the same. Consider separate Sections. + +## What a Slot does NOT do + +| Misconception | Reality | +|---|---| +| "A slot binds to CMS data" | No. The slot itself doesn't have a binding. The Section dropped INTO the slot brings its own bindings (via its linked schema). | +| "I drop a registered component into the slot and it just works" | No. Raw components have no bindings. The author would have to hand-bind every prop through the Data Picker. Drop a **Section** into the slot instead: Sections come pre-bound to their linked schema, so Studio auto-binds at the Section level. | +| "Slots have to be filled" | No. An empty slot just renders nothing. Templates that don't need the slot leave it empty. | +| "I should add a slot everywhere just in case" | No. Slots add authoring surface area and complexity. Add one only when you have a real per-template-component-variation need. | +| "A slot is the same as the React children prop" | Conceptually similar, but a Section Slot is a named drop target in Studio's authoring UI: authors can find it by name in the canvas. The React `children` prop is the rendering mechanism underneath. | + +## How slots appear in Studio + +- **Inside the Section's canvas** (when you're authoring the Section), the slot shows as an outlined region with the slot name as a label. +- **When the Section is dropped on a template**, the slot appears in the same outlined region, droppable, empty until filled. +- **From the right-panel Layers tree**, the slot is a node you can select to inspect. +- **Studio Slot vs Section Slot terminology:** they're the same thing. Studio's docs sometimes use "Section Slot" to disambiguate from React's `children` slot pattern. + +## Next steps + +| If you want to… | Skill to run | +|---|---| +| Add a Section Slot to an existing Section | `use-section-slot` | +| Make a value tweakable per template instance (not a child swap) | `expose-section-props` | +| Create the Section first | `build-section` | + +## See also + +- `docs/34-smart-containers/section-slots.md`: long-form reference +- `understand-sections`: the parent concept (Sections themselves) +- `use-section-slot`: actually add a slot after the concept is clear +- `configure-slot-defaults`: the two optional settings on a slot: a default section for the empty state, and an allowed-sections whitelist diff --git a/skills/studio/understand-sections.md b/skills/studio/understand-sections.md new file mode 100644 index 0000000..58f6ddc --- /dev/null +++ b/skills/studio/understand-sections.md @@ -0,0 +1,134 @@ +--- +name: understand-sections +--- + +## When to use + +Explain what a Section is: a reusable page part (hero, feature grid, CTA banner) built by binding atomic React components to CMS data, optionally composing into compounds. + +Use as the concept on-ramp before any Section-authoring skill, or when a new user asks "what is a Section". Phrases: "what's a Section", "Section vs Template", "reusable page parts". Do NOT use to actually create a Section. For that, run `build-section`. Concept only. + +> **Mandatory auth preflight: settle the credential before the first API call.** Resolve it OAuth-first per [`authenticate-cma`](authenticate-cma.md): `CS_OAUTH_ACCESS_TOKEN`, else the Contentstack MCP's stored session: Read that skill for where it is, even with no MCP connected. **Never ask the user for a session `authtoken`.** If nothing resolves, or a refresh fails with `400 invalid_refresh_token`, hand them `! CONTENTSTACK_REGION=<code> npx @contentstack/mcp --auth` (it needs a TTY and a browser, so it cannot be run for them) and wait. `403 error_code 316` is a valid credential aimed at another org: fix the org or the `api_key`, do **not** re-authenticate. + +# What is a Section? + +## The one-line answer + +**A Section is a reusable part of a webpage (a hero, a feature grid, a testimonial strip, a CTA banner, a footer) with its CMS data binding already baked in.** It's a compound React component: one or many of your atomic components, bound to specific CMS fields, packaged as one drop-in unit that authors can place on any template. + +Think of the parts of a webpage you build over and over: every site has a hero, several content blocks, some CTAs, a footer. In a hand-coded site each is a JSX snippet you copy-paste between pages. In Studio those are **Sections**, authored once, bound to CMS data once, then reused everywhere by drag-and-drop. + +## A Section in plain language + +Imagine a marketing page. From top to bottom you might see: + +1. A **hero** with a headline, subheading, background image, and a CTA button +2. A **feature grid** with three feature cards (icon + title + body each) +3. A **testimonial strip** with a quote, author, and avatar +4. A **CTA banner** with a headline and a button +5. A **footer** with links and a logo + +Each of those five chunks is **a Section** in Studio. Each one is a part of the page, designed to be reused on similar pages with different content. + +## A Section is a Studio component + +A **Studio component** — assembled from your atomic React components and bound to your CMS, on the canvas rather than in code: + +``` +Section "Hero" = <Hero/> + binding to CT `hero_section` + entry "Homepage Hero" +Section "ProductHighlight" = <Hero/> + 3×<FeatureCard/> + <CTABlock/> + + bindings to product_highlight CT + + entry sources for each +``` + +Two anatomies, same kind of thing: a reusable, bindable, previewable unit. The atomic components inside don't change. Their composition + binding map is what the Section carries. + +> **What you DON'T do:** build a separate React component just to be "a Section." Sections are not a new component type. They're a composition + binding wrapper around the atomic React components you already have. + +## What a Section adds on top of a raw component + +A registered atomic component (`<Hero/>` with props `headline`, `subhead`, `imageUrl`) becomes a Section when you add three things: + +| Layer | What it carries | +|---|---| +| **Binding map** | Which CMS field drives which prop, in which iteration scope. E.g. `props.headline ← hero_section.title`, `props.subhead ← hero_section.subtitle`. | +| **Composition** | If the Section is compound (multiple atomic components), the layout/order/nesting and any Section Slots (substitution points). | +| **Live preview against real data** | The composed unit renders in Studio's canvas with the actual entry the binding points at, same data a visitor would see. Storybook shows a single component with mock props. A Section shows the composed shape with the real CMS content. | + +The atomic React code never changes. The "extra knowledge" lives as data in Studio. + +## What qualifies as a Section + +**The operational rule: a Section qualifies when the binding work amortizes**: i.e., when the same N bindings would otherwise be repeated by the author every time the component is dropped on a template. Sections pay off by baking the binding map ONCE. Reuse is free thereafter. + +Two amortizing shapes: + +1. **The component has ≥2 separate prop bindings**: a Hero with `title` + `cover.url`, a Card with `image` + `title` + `summary` + `link`, a ProductHighlight composed of a Hero + 3 FeatureCards + a CTA. Without a Section, every template drop = N right-panel binding clicks. With a Section, those N bindings are saved once. The author drops the Section and every prop is already wired. +2. **The component takes one prop bound to a structural shape**: Modular Block list, Reference array, Group field, Global Field. The outer bind is 1 click, but the internal rendering walks N sub-fields per iteration (or per nested object). That sub-field binding work amortizes the same way. + +The only case that does **NOT** qualify: + +- **A single atomic component bound to a single primitive (scalar) field**: `Heading` bound to `entry.title`, `Image` bound to `entry.cover.url`, `Text` bound to `entry.tagline`. One bind, one drop, no repetition cost: wrapping it as a Section gives no payoff. Studio's picker also rejects scalar fields at the Connect-a-Schema step. Use these components inline as Basic field components on a Template or inside another Section. + +**Diagnostic question:** "If I drop this component on the next template, how many bindings would the author wire by hand?" +- ≥2 bindings: make it a Section +- 1 binding: leave it as a registered component used inline + +## When to make something a Section vs leave it as a registered component + +| Leave it as a registered component (no Section) | Make it a Section | +|---|---| +| Single bound prop, single primitive value (`Heading` bound to `entry.title`), one click to bind, no amortization | ≥2 bound props (`Hero` bound to `entry.title` plus `entry.cover.url`), author would otherwise wire each prop on every drop | +| Component takes a single scalar | Component takes a structural-shape value (Group, Global Field, Modular Block, Block, Reference) whose internal rendering walks multiple sub-fields | +| Used as a primitive inside other compositions | Authors recognize it as "a part of a page" (hero, footer, CTA strip, card grid) and drop it themselves with bindings pre-wired | + +If in doubt: ask "how many bindings will the author wire by hand on the next drop?" If ≥2, make it a Section. + +## How Sections relate to Templates + +- **Atoms** are your registered React components (Button, Card, Hero, FeatureCard, …). +- **Sections** combine atoms and their CMS binding, which produces a reusable part of a page. +- **Templates** combine Sections into a full page shape (e.g. a connected template for `blog_post` content type). + +A template is essentially a stack of Sections. Authors drop Sections onto a template, and the template-plus-Sections renders a live page at the matching URL. + +## When to expose a Section Slot + +Any Global Field / Modular Block / Group / Reference field on the CT is a strong Slot candidate: default to exposing a Slot rather than inlining. See `understand-section-slots` § When to expose one: the Global Field / Modular Block / Group / Reference heuristic. + +## Where you'll see Sections in Studio + +- **Sections tab** in the Studio project: lists every Section authored in this project. The skill `discover-sections` lists them programmatically. +- **Sections category** in the palette when you're inside a Template canvas: drag from here onto the template. +- **As nested children** when you compose one Section inside another (a Section can include another Section via Section Slots, see `use-section-slot`). + +## Where you'll see Sections in your codebase + +- **You won't.** Sections live in Studio as data: they reference your registered components by `componentType` UID but they aren't React components in your repo. The atomic React components ARE in your repo (the ones you ran `register-component` on). The Sections that compose them are data Studio holds. + +## What a Section is NOT + +| Misconception | Reality | +|---|---| +| "A Section is a new React component I need to write" | No. Sections are composed from atomic components you already have. No new code per Section. | +| "A Section is the same thing as a Template" | No. A Section is a part of a page (reusable block). A Template is a whole page shape (a stack of Sections). | +| "A Section locks me into one piece of content" | No. The binding map references a CMS field (e.g. `hero_section.title`). Different entries that share the CT shape can drive the same Section on different page instances. | +| "Sections are only for marketing teams" | No. Sections are the unit of reusable page parts: engineering, design, and authors all benefit. Engineering builds the atoms once. Everyone composes Sections forever. | + +## Next steps + +| If you want to… | Skill to run | +|---|---| +| List the Sections already in this project | `discover-sections` | +| Create a new Section in Studio | `build-section` | +| Make a Section field tweakable per template instance | `expose-section-props` | +| Drop a Section into a template | `build-connected-template` | +| Substitute a child component inside a Section per template | `use-section-slot` | +| Render a list inside a Section | `use-repeater` | + +## See also + +- `docs/32-sections/overview.md`: long-form reference. This skill is the short on-ramp +- `understand-canvas-url`: concept explainer for the Canvas URL field (sister skill) +- `studio-tour`: broader Studio walkthrough for first-time users +- `build-section`: actually create a Section after the concept is clear diff --git a/skills/studio/understand-templates.md b/skills/studio/understand-templates.md new file mode 100644 index 0000000..2a624c2 --- /dev/null +++ b/skills/studio/understand-templates.md @@ -0,0 +1,136 @@ +--- +name: understand-templates +--- + +## When to use + +Explain Templates: they compose Sections into a page-shape, rendering against a content type at a derived URL pattern (one Template produces N live pages). + +Use before any template-building skill whenever the user is new to Studio. Also when the user asks "what is a template?" or "how do Sections become a page?". Do NOT use this skill to actually create a template. For that, run `build-connected-template`. Concept only. + +> **Mandatory auth preflight: settle the credential before the first API call.** Resolve it OAuth-first per [`authenticate-cma`](authenticate-cma.md): `CS_OAUTH_ACCESS_TOKEN`, else the Contentstack MCP's stored session: Read that skill for where it is, even with no MCP connected. **Never ask the user for a session `authtoken`.** If nothing resolves, or a refresh fails with `400 invalid_refresh_token`, hand them `! CONTENTSTACK_REGION=<code> npx @contentstack/mcp --auth` (it needs a TTY and a browser, so it cannot be run for them) and wait. `403 error_code 316` is a valid credential aimed at another org: fix the org or the `api_key`, do **not** re-authenticate. + +# What is a Template? + +## The one-line answer + +**A Template is a page shape (a stack of Sections) that renders one whole page on your site.** A Connected Template binds to a content type and renders every entry of that CT at its URL pattern (one Template produces N live pages). + +If a Section is a part of a page (hero, feature grid, footer), a Template is a whole page made of Sections. + +## Templates compose Sections + +**A Template is a page-level container. Sections fill it.** Templates do not model fields directly: they hold one or more Sections, each bound to its own CT, Global Field, Group, Block, or Reference. Any "multi-section template", "many sections in one page", "combining sections": all the canonical assembly. + +**The workflow is fixed:** + +1. Build each Section first via `build-section`, one per CT / slice of content the page needs. +2. Then add those Sections into a Template via `build-connected-template`. + +A Template can hold one Section or many. Its only job: which Sections, in what order, with what per-instance overrides. If you want to bind raw fields directly on a Template, build a Section for that shape first. Sections carry bindings across Templates. + +## A concrete example + +You author a Template called **"Blog Post"** containing three Sections, top to bottom: + +1. `BlogArticleHeader` (hero with title, subtitle, cover) +2. `ArticleBody` (the rich-text body + side rail) +3. `RelatedPosts` (grid of three other articles) + +You connect this Template to the `blog_post` content type. + +You publish 12 blog posts in Contentstack. Studio's catch-all (or your dedicated route) sees each blog post's URL (`/blog/the-launch-post`, `/blog/why-studio`, …) and renders the **same Template** against each entry. **One Template produces 12 live pages**, each with its own content driven by its entry. + +Authors never touched code to add a blog post. They created an entry. The Template renders it. + +## Connected Template + +Bound to a content type. One Template renders N pages, one per published entry. This is what every repeatable page shape uses, and what every one-off page uses too (via a single-entry CT). + +| Connected Template | Example | +|---|---| +| `blog_post` | `/blog/the-launch-post`, `/blog/why-studio`, … | +| `product` | `/products/widget-a`, `/products/widget-b`, … | +| `case_study` | `/case-studies/acme`, `/case-studies/globex`, … | +| `author` | `/authors/jane-doe`, `/authors/john-roe`, … | + +The Template defines the **shape**. Each entry's `url` field defines the **path**. Studio matches the URL pattern (`/blog/{{entry.url}}`) to render the right Template against the right entry. + +## How Sections compose into a Template + +Templates are stacks of Sections. Drag and drop: + +``` +Template "Blog Post" +├── BlogArticleHeader (Section) +├── ArticleBody (Section) +└── RelatedPosts (Section) +``` + +When the Template renders, each Section renders in order, top to bottom. Each Section already knows how to bind to the entry data (via its `understand-linked-schemas` declaration). The Template's job is just which Sections, in what order, with what per-instance prop overrides (see `expose-section-props`). + +You can also drop **individual registered components** outside of Sections, useful for one-off Template-only chrome. But for anything reusable across Templates, prefer a Section. + +## How a Template becomes a live page + +1. **You author the Template** in Studio: drop Sections, arrange, save. +2. **Studio stores the Template as a composition spec** (data, not code) in your CMS. +3. **Visitor hits `/blog/the-launch-post`** on your live site. +4. **Your app's route** (catch-all or dedicated, see `setup-template-preview-routes`) mounts `<StudioComponent />`, which calls `sdk.fetchCompositionData({ url: "/blog/the-launch-post" })`. +5. **Studio's SDK matches the URL** against your published Templates, finds `Blog Post` template (because its pattern is `/blog/{{entry.url}}` and the URL matches an entry). +6. **The SDK returns the composition spec**: every Section and registered component the Template uses, with bindings resolved against the entry's data. +7. **`<StudioComponent />` renders the spec**: your React components, your design system, your bundle. + +The visitor sees a fully-rendered page. No special server, no static export, no per-page code change. Add a new blog post and it gets a live URL, because the Template plus the entry are enough. + +## How a Template renders in Studio's canvas (preview) + +When you open a Template in Studio: + +- Studio iframes the **environment base URL + the resolved URL** (e.g. `https://localhost:5173/blog/the-launch-post`), NOT the Canvas URL (which is sections-only, see `understand-canvas-url`). +- Your app's route mounts `<StudioComponent />`, same renderer as production. +- `<StudioComponent />` detects it's in Studio's iframe and switches to showing the **unsaved working spec** (instead of the published one). +- The PREVIEW ENTRY in Studio's top bar lets you swap entries. The canvas redraws against the new entry instantly. + +This is why **what the author sees in the canvas IS what the visitor sees in production**: same renderer, same components, same data, just the unsaved-vs-published variant differs. + +## Why Connected Templates work for every page + +- **Scales with content**: one Template renders unlimited entries. Add a new page = add an entry, not a new Template. +- **Auto-binds `template.*`** against the connected entry through linked schemas, no per-binding wiring. +- **Supports `{{entry.*}}` URL variables** (`/blog/{{entry.slug}}`, `/products/{{entry.handle}}`). +- **Content-first workflow**: authors edit the entry in Contentstack. The page updates automatically. Devs and authors don't need to touch the composition. +- **CT-level governance**: validation rules, locales, variants, publish workflows, scheduled publishing, role-based access all come for free. +- **Single-entry CTs cover one-off pages**: homepage, about page, contact page, even most "campaign" pages have a stable shape and should be modelled as a one-CT-one-entry Connected page. + +## What a Template is NOT + +| Misconception | Reality | +|---|---| +| "A Template is a React component" | No. Templates are composition data in Studio, a list of Sections + arrangement + per-instance overrides. The React components ARE in your repo (the atomic components Sections compose). Templates reference them by ID. | +| "I need one Template per published page" | No. One Connected Template renders N pages, one per entry of the connected CT. | +| "Templates and pages are the same thing" | Close, but: a Template is the recipe. A page is a rendered instance. A Connected Template's recipe + one entry = one page. Same recipe + a different entry = a different page. | +| "I can't change a Template after creating it" | You can. Templates are editable data. Save the Template and existing pages re-render with the new layout. Authors can iterate without engineering tickets. | + +## Why this matters for "first-time" Studio users + +A common mistake new users make: skip Sections + Templates and try to drop atomic components straight onto a route. That works on day one but kills reuse: every page becomes one-off composition that doesn't transfer. + +**The Studio pattern is**: register atomic components, compose them into Sections, arrange Sections into Templates, and let the Templates render live pages. Each layer compounds reuse. + +If you're new and asking "do I really need Sections and Templates?" Yes, for any site beyond a one-off landing page. + +## Next steps + +| If you want to… | Skill | +|---|---| +| Create a Connected Template | `build-connected-template` | +| Wire up the route that renders Templates on the live site | `setup-template-preview-routes` | +| Make a Section field tweakable per Template instance | `expose-section-props` | + +## See also + +- `understand-sections`: what Sections are (the building blocks of Templates) +- `understand-linked-schemas`: how a Section declares its data shape +- `understand-auto-binding`: what happens when a Section drops on a Template +- `docs/31-templates/`: long-form reference diff --git a/skills/studio/upgrade-studio-sdk.md b/skills/studio/upgrade-studio-sdk.md new file mode 100644 index 0000000..9a58194 --- /dev/null +++ b/skills/studio/upgrade-studio-sdk.md @@ -0,0 +1,86 @@ +--- +name: upgrade-studio-sdk +--- + +## When to use + +Bump the Studio SDK packages (studio-react, studio-react-components, studio-client, studio-registry, studio-core, studio-internal, studio-react-editor) in the host app: pin versions, read CHANGELOGs, smoke-check, roll back if needed. + +Use when the user wants to upgrade the Studio SDK packages in their host app: "upgrade Studio SDK to latest", "what's new in `@contentstack/studio-react`", "is `studio-client@1.5` safe", "I want the new repeater fix". Do NOT use to install for the first time (`install-studio`). Do NOT use to develop the SDK itself. + +> **Auth preflight: settle the credential before the first API call.** Resolve it OAuth-first per [`authenticate-cma`](authenticate-cma.md): `CS_OAUTH_ACCESS_TOKEN`, else the Contentstack MCP's stored session: Read that skill for where it is, even with no MCP connected. **Never ask the user for a session `authtoken`.** If nothing resolves, or a refresh fails with `400 invalid_refresh_token`, hand them `! CONTENTSTACK_REGION=<code> npx @contentstack/mcp --auth` (it needs a TTY and a browser, so it cannot be run for them) and wait. `403 error_code 316` is a valid credential aimed at another org: fix the org or the `api_key`, do **not** re-authenticate. + +# Upgrade the Studio SDK packages + +## Context + +The Studio SDK is a multi-package monorepo published as independent versions per package (lerna `versioning: independent`). The packages a host app typically installs are: + +| Package | Role | Recent versions | +|---|---|---| +| `@contentstack/studio-react` | Renderer + hooks (`StudioComponent`, `useCompositionData`, registration entry points) | `1.3.x` | +| `@contentstack/studio-react-components` | Built-in basics (Page, Section, Text, Repeater, ConditionBlock, …), `"use client"` modules | `1.5.x` | +| `@contentstack/studio-client` | CDA/CMA orchestration: `fetchCompositionData`, `fetchSpec`, URL resolution | `1.5.x` | +| `@contentstack/studio-registry` | `ComponentRegistry`, `DesignRegistry`, data-binder, globalThis singleton | `1.4.x` | +| `@contentstack/studio-core` | Internal core: light SDK init, font loading, design-token cascade | `1.1.x` | +| `@contentstack/studio-internal` | Internal types + apply/transform helpers | `1.2.x` | +| `@contentstack/studio-react-editor` | The edit-mode renderer (`StudioCanvas`'s renderer) | `1.3.x` | + +Versions follow [SemVer](https://semver.org). Changelogs are committed per package in the SDK repo and follow Conventional Commits: entries are grouped by `feat` / `fix` / `perf` / `refactor`. There is no separate "Breaking Changes" section. Treat any minor-version bump that involves cross-package work (registry, renderer, basics together) as carrying coordination risk. + +The `peerDependencies` are `react ^18.0.0 || ^19.0.0` and `react-dom ^18.0.0 || ^19.0.0` from 1.5.1 onward. Only 1.5.0 peer-deps `^18.0.0` alone (1.4.x and earlier declare `>=16.0.0`), so a React 19 app pinned to 1.5.0 must upgrade the SDK first (never downgrade React). The SDK packages also depend on each other internally. Mixing major versions across them is unsupported. + +## Task + +1. **Capture current state.** + - `cat package.json | grep '"@contentstack/studio'` in the host app to see currently pinned ranges. + - `npm ls @contentstack/studio-react @contentstack/studio-react-components @contentstack/studio-client @contentstack/studio-registry @contentstack/studio-core @contentstack/studio-internal @contentstack/studio-react-editor` to see the actually-resolved versions. + - If the lockfile is missing or stale, also run `npm view @contentstack/studio-react versions --json | tail` to see what's published. + +2. **Read the CHANGELOG delta: actually read it.** + - For each package the host app installs, open its CHANGELOG and read every entry from the installed version up to the target. Do not summarize from imagination. Copy or paraphrase actual entries. + - The studio-client CHANGELOG lives at `node_modules/@contentstack/studio-client/CHANGELOG.md` on the consumer side. Same for the other packages. + - Flag any entry that touches: registry singleton, SSR / `"use client"` boundary, `fetchSpec` / `fetchCompositionData` signatures, URL pattern compilation, design-token / font loading, data-binder type-mismatch. Those are the ones most likely to surface integration friction even when the bump is technically non-breaking. + +3. **Upgrade the packages the host declares, together.** Bump every `@contentstack/studio-*` package listed in the host's `package.json` in the same change. Usually that is only `@contentstack/studio-react`, as `install-studio` installs it. `studio-react` declares the other Studio packages as its own dependencies, so upgrading it brings in matching versions. Do **not** add those internal packages to `package.json`: a direct pin that falls outside `studio-react`'s range installs a second copy, which splits the registry singleton (the globalThis-backed singleton landed across packages in coordinated bumps). + - Pick the exact versions (avoid `^` for the upgrade commit so the resolved versions are reproducible. You can relax to `^` after smoke passes). + - Run `npm install @contentstack/studio-react@<v>` (plus any other `@contentstack/studio-*` package already in `package.json`), or the matching `pnpm` / `yarn` form. Use `--save-exact` if you want the upgrade-commit to pin exact versions. + - Re-run the `npm ls` from step 1: each Studio package should resolve to one version, with the nested copies `deduped`. + +4. **Smoke-test the host app.** Concrete steps depend on the framework. The matrix below is the minimum surface that catches the common breakages. + + | Framework | Smoke steps | + |---|---| + | `next-app` | Run `npm run build` (it catches `"use client"` boundary errors at compile), then `npm start`, then load a Connected Template URL. In the DevTools console: no errors, no `useData` warnings in production. In the DevTools Network tab: confirm composition + entry queries return 200. | + | `next-pages` | Run `npm run build`, then load a composition route, and confirm no console errors. | + | `vite-spa` | Run `npm run build && npm run preview`, then load a composition URL, confirm the console is clean, and check `<StudioCanvas>` mounts on the canvas route. | + | `remix` | Run `npm run build` (the loader-side fetch must compile), then load a composition URL, and ensure the server fetch passes `searchQuery`. | + + Across all frameworks: re-check the canvas route (`/canvas` or equivalent) separately. The canvas-route renderer has historically had built-in registration regressions that don't always surface on `<StudioComponent>` routes (see the corresponding `troubleshoot-canvas` row). + +5. **If something breaks.** Match the symptom to the troubleshooting skill: `troubleshoot-ssr-rendering` for boundary errors, `troubleshoot-data-binding` for binding drift, `troubleshoot-composition-resolution` for live-site URL behavior, `troubleshoot-canvas` for editor iframe. Almost every recent SDK regression has a corresponding row in one of those skills. + +6. **Rollback path** (concrete. Do not skip in production). + - Restore `package.json` and the project's own lockfile (`package-lock.json`, `pnpm-lock.yaml`, or `yarn.lock`) from the last commit before the upgrade: `git checkout <pre-upgrade-commit> -- package.json <lockfile>`. Find that commit with `git log -- package.json`, not by assuming `HEAD~1`. + - `rm -rf node_modules`, then a clean install from that lockfile with the project's package manager: `npm ci`, `pnpm install --frozen-lockfile`, or `yarn install --frozen-lockfile` (`yarn install --immutable` on Yarn 2+). Don't try to selectively downgrade: lockfile reconciliation across the Studio packages is unreliable. + - Re-run the smoke check. If you can't get back to a working state with a hard revert, escalate. + +## Knowing what actually changed + +Each package publishes a CHANGELOG derived from Conventional Commits. Read the CHANGELOG for every package in your delta before upgrading. + +A non-exhaustive list of changes that historically required consumer-side action (read each from its CHANGELOG before claiming it applies to your delta): + +- **Registry singleton moved to `globalThis`** (`studio-registry` / `studio-react` coordinated bump): required for RSC server-side fetch to see registered components. If you're upgrading FROM a pre-singleton version TO a post-singleton version, server-side `fetchSpec` calls that used to return "component not registered" will now resolve. No consumer code change beyond the upgrade. +- **Basics moved behind a `"use client"` isolation boundary** (`studio-react`): fixes "Attempted to call Page() from the server" in Next App Router. No consumer code change required. The renderer pulls in the client-only basics automatically. +- **Tolerant `useData` fallback** (`studio-react-components`): replaces a throw with an empty-data fallback plus a dev-only warning. SSR pass of basics no longer crashes outside a `DataCtxProvider`. Consumer-side: dev console may show one new warning, production unchanged. +- **`fetchCompositionData` requires `searchQuery` on the server**: see [`configure-csr-vs-ssr.md`](configure-csr-vs-ssr.md). Required for locale/variant/preview to flow from Studio's iframe params. Pre-existing as an integration step. + +Do NOT extrapolate from this list. **Only the actual CHANGELOG entries for your delta** tell you which of these apply. + +## What this skill is NOT + +- Not a guide to first-time installation (use [`install-studio`](install-studio.md)). +- Not a guide to SDK-internal development. +- Not a substitute for reading the CHANGELOG. Always read the actual CHANGELOG entries for your version delta. +- Not a guide to upgrading the Contentstack Delivery SDK (`@contentstack/delivery-sdk`) or Live Preview Utils (`@contentstack/live-preview-utils`): those are separate packages with their own CHANGELOGs, only loosely coupled to the Studio SDK. diff --git a/skills/studio/use-compositions-api.md b/skills/studio/use-compositions-api.md new file mode 100644 index 0000000..8fe4098 --- /dev/null +++ b/skills/studio/use-compositions-api.md @@ -0,0 +1,153 @@ +--- +name: use-compositions-api +--- + +## When to use + +Create, read, list, update, or delete Studio compositions through the **managed Composable Studio API service**, the REST layer that validates the layout, compresses the `ui` spec, and forwards the caller's identity, so you don't hand-encode entries against the raw CMA. + +Use when the project talks to the Composable Studio API service (`/v1/projects/:projectUid/compositions`) and you want guardrails: server-side Studio validation, automatic `ui` compression, `composable_uid` backfill, a referenced-guard on delete, and list/references endpoints. Phrases: "compositions REST API", "Studio API service", "create composition endpoint", "list compositions", "composition references API", "managed composition API". Do NOT use for: raw-CMA / seed-pipeline / migration authoring where you hold a management token and encode the `ui` yourself. That's `author-composition-via-api`. Do NOT use for interactive UI authoring. That's `build-section` / `build-connected-template`. The `ui`-tree shapes themselves are NOT redefined here. They live in `author-composition-via-api`. + +> **Auth preflight: settle the credential before the first API call.** This skill used to carry an exception for the compositions service. **That exception is gone**: since 2026-08-25 the service forwards OAuth Bearer to the CMA, and since 2026-08-27 registered components does too. OAuth-first applies here like everywhere else, so **do not ask the user for a session `authtoken`**. Resolve it OAuth-first per [`authenticate-cma`](authenticate-cma.md): `CS_OAUTH_ACCESS_TOKEN`, else the Contentstack MCP's stored session: Read that skill for where it is, even with no MCP connected. **Never ask the user for a session `authtoken`.** If nothing resolves, or a refresh fails with `400 invalid_refresh_token`, hand them `! CONTENTSTACK_REGION=<code> npx @contentstack/mcp --auth` (it needs a TTY and a browser, so it cannot be run for them) and wait. `403 error_code 316` is a valid credential aimed at another org: fix the org or the `api_key`, do **not** re-authenticate. + +# Use the Compositions API (managed Studio service) + +## Context + +The **Composable Studio API service** is a REST wrapper over the CMS. Instead of building a composition entry by hand, you `POST` the layout and the service does the mechanical work: + +- **You send the `ui` tree uncompressed** (a JSON object). The service compresses it to `zlib:<base64>`. You never touch zlib. +- **The service validates** the tree against Studio's structural rules and returns all problems at once, field-anchored. +- **`composable_uid` is backfilled** with the entry uid when you omit it, and uniqueness is enforced content-type-wide. +- **Delete is guarded**: it refuses to orphan a referenced composition unless you force it. + +This is the counterpart to `author-composition-via-api`, which drives the **raw CMA** (management token, self-encoded `ui`). Same result (a composition entry) at a higher level. Pick this skill when you want the guardrails and are acting as a signed-in user. Pick `author-composition-via-api` for management-token automation and full low-level control. + +> **Preview: not yet GA.** The service is rolling out. **Paths and response shapes may change**, and it may not be enabled on your region/tenant. If a call 404s at the host itself (not a composition-not-found body), the service isn't deployed for you. Fall back to `author-composition-via-api`. A stack-scoped **management-token** mode is not available yet. Management-token automation must use the raw-CMA path. + +## Pre-flight checks + +- A Studio **project** exists and is bound to a stack + a compositions content type. If not, run `provision-studio-project`. +- You have the **`projectUid`**, every route is nested under it. +- You have a resolved credential (**OAuth Bearer by default**, session `authtoken` only as the last rung, [`authenticate-cma`](authenticate-cma.md)) and the **`organization_uid`** that owns the project. Both styles are accepted. The service forwards whichever you send. `401 error_code 39` means **neither** arrived, not that Bearer was refused. +- You know the **base URL** for the region (see below). Confirm the service answers before scripting against it. + +## The endpoints + +All routes are prefixed with `/v1` and nested under the project. Base URL per region, e.g. AWS NA `https://composable-studio-api.contentstack.com/v1`. EU / Azure / GCP have their own hosts. + +| Operation | Method + path | +|---|---| +| Create (placement in body) | `POST /v1/projects/{projectUid}/compositions` | +| Create a template | `POST …/compositions/templates` | +| Create a section | `POST …/compositions/sections` | +| List / query | `GET …/compositions` | +| Fetch one | `GET …/compositions/{uid}` | +| Fetch, assert template | `GET …/compositions/{uid}/template` | +| Fetch, assert section | `GET …/compositions/{uid}/section` | +| List references | `GET …/compositions/{uid}/references` | +| Update (partial merge) | `PUT …/compositions/{uid}` | +| Delete (guarded) | `DELETE …/compositions/{uid}` | + +## Authentication + +Send two headers on every request. The service acts **as the calling user**: it forwards your own credential to the CMA rather than holding a service credential of its own. + +| Header | Value | +|---|---| +| `$CS_AUTH` | `authorization: Bearer <oauth>` by default. `authtoken: <session>` only as the last rung, [`authenticate-cma`](authenticate-cma.md). Send exactly one. `401` `39` means neither arrived | +| `organization_uid` | Org that owns the project | + +Three checks, each with its own failure code: a **project access** denial gives `422`, **no credential** reaching the service gives `401`, and when Contentstack rejects the token the upstream **401/403** is preserved. Reads need read access to the project's stack. Writes need write access. + +## Task + +### Step 1: Build the `ui` tree (uncompressed) + +Assemble the layout as a plain node object. **Do not compress it**. That's the service's job. The node anatomy, value sources, Repeater / Condition Block / Section Slot / Binding Override, and the closed prop/node-type sets are the single source of truth in `author-composition-via-api` (and the Building Blocks API reference). Reuse them. This skill does not restate them. + +### Step 2: Create the composition + +`POST` the uncompressed payload. Placement comes from the body on the generic route, or from the flavored routes (`/templates` maps to `page`, `/sections` to `section`). + +```bash +curl -X POST 'https://<host>/v1/projects/<projectUid>/compositions' \ + -H "$CS_AUTH" \ + -H 'organization_uid: <org-uid>' \ + -H 'Content-Type: application/json' \ + -d '{ + "title": "Blog Post", + "place_composition_as": "page", + "url": "/blog/{{entry.slug}}", + "connected_content_type": "blog_post", + "ui": { "uid": "root", "type": "page", "props": {}, "slots": {}, "metadata": {} }, + "data_sources": [] + }' +``` + +On success (`201`) the response is `{ notice, composition, warnings? }`. The returned `composition.ui` is the **compressed** string as stored. `composable_uid` equals the entry uid unless you supplied one. Non-blocking findings ride along in `warnings[]`. + +### Step 3: Handle validation + +Blocking problems return **`422` `composition_invalid`** with every failing path at once: `errors: { "<path>": ["<message>"] }`. Fix each path and resend. Warnings don't block, surface them but proceed. + +### Step 4: Read, list, reference + +- **Fetch one:** `GET …/{uid}` returns the entry with `ui` compressed by default. Add `?decompression=true` to get the node tree back (and `data_sources` parsed). +- **List:** `GET …/compositions` returns lightweight items (`ui` omitted). Filter with `place_composition_as`, `composable_uid`, `title`. Paginate with `limit` (1 to 100, default 50) / `skip`. Add `include_count=true` for the total. Add `include_ui=true` (+ `decompression=true`) to include the spec. +- **References:** `GET …/{uid}/references` lists what points at a composition. Check this before deleting a section. + +### Step 5: Update and delete + +- **Update** is a **partial merge**: `PUT …/{uid}` with only the fields you're changing. If you send `ui`, send it uncompressed. The service re-validates and re-compresses. `composable_uid` is **immutable** (`422` if you try to change it). +- **Delete**: `DELETE …/{uid}`. If other compositions reference it, you get **`409` `composition_referenced`**. Pass `?force=true` to delete anyway. Send the DELETE **without** a JSON `Content-Type` on an empty body (Fastify rejects that combination with a `400`). + +### Step 6: Attach a thumbnail when `place_composition_as: "section"` + +A section created through this API has no `ui_preview`, so it lands as a blank tile in the Sections accordion and the Section Slot picker. Studio only captures one when an author opens the section and clicks **Save**. Browsing or reloading never repairs it. + +```bash +tsx scripts/make-ui-preview.ts --entry <composition-entry-uid> +``` + +Routes, trade-offs and pitfalls: [`author-composition-via-api` § Section thumbnails](author-composition-thumbnails.md#section-thumbnails-ui-preview). + +**This step is mandatory, not optional.** A create is not complete until `ui_preview` is set. If the thumbnail call fails, report it and retry, do not silently continue, and do not describe the section as created. "Cosmetic" means it cannot corrupt data. It does not mean skippable. + +## Inputs needed from the user + +- Region **base URL** (or confirm which cloud/region the project is in). +- **`projectUid`**, **`organization_uid`**, and a valid credential resolved per [`authenticate-cma`](authenticate-cma.md). +- The composition to create (or the `uid` to read/update/delete). + +## Definition of done + +A composition created through this API is not finished when the write returns `200`. Section compositions need `ui_preview`, the components they place need `thumbnailUrl` and CSLP tags, and the project needs Live Preview on, all six rows of [`complete-the-build`](complete-the-build.md). None of them affect rendering, so a green create call proves nothing about them. + +## Acceptance + +- Create returns `201` with a `composition.uid`. `composable_uid` is set (backfilled or yours). +- The `ui` you sent as an object comes back as a `zlib:`-prefixed string. +- A structurally invalid `ui` returns `422` with field-anchored `errors`, not a partial write. +- `GET …/{uid}?decompression=true` round-trips your tree. +- Deleting a referenced composition is blocked (`409`) unless `?force=true`. +- For `place_composition_as: "section"`, `ui_preview` is set, **verified by re-reading the entry**, not assumed from a successful POST. A blank tile means the create is unfinished (Step 6). + +## Common pitfalls + +| Pitfall | Why it bites | Fix | +|---|---|---| +| Sending `ui` pre-compressed | The service compresses for you. A `zlib:` string is treated as the tree and fails validation | Send the **uncompressed node object** | +| `data_sources` as a JSON string | The service stringifies it. A string double-encodes | Send a plain **array** | +| Blank `composable_uid` | A whitespace-only value is rejected, not backfilled | Omit it, or send a real slug. A blank value gives `422` | +| Duplicate `composable_uid` in another locale | Uniqueness is content-type-wide, not per-locale | Expect `409` at create. Pick a unique slug | +| Treating `422 stack_not_found` as "missing" | It's the **access-denied** code (Studio convention, not 403) | Check the caller's rights on the project's stack | +| `DELETE` with `Content-Type: application/json` + empty body | Fastify rejects it with `400` before the handler | Omit the header on a body-less DELETE | +| Host itself 404s | The service isn't deployed for your region/tenant yet | Fall back to `author-composition-via-api` (raw CMA) | + +## See also + +- `author-composition-via-api`: the raw-CMA path and the **canonical `ui`-tree / binding shapes** (management token, self-encoded). +- `understand-authoring-headlessly`: the `ui` zlib tree + `data_sources` + the six binding types, conceptually. +- `embed-composition`: render one managed region inside a code-owned page once the composition exists. +- `provision-studio-project`: create the project + compositions content type this API targets. diff --git a/skills/studio/use-condition-block.md b/skills/studio/use-condition-block.md new file mode 100644 index 0000000..ffce749 --- /dev/null +++ b/skills/studio/use-condition-block.md @@ -0,0 +1,78 @@ +--- +name: use-condition-block +--- + +## When to use + +Wrap component bindings inside a Repeater with sibling Condition Blocks (one per block / content type) so each iteration item resolves to a specific schema before fields bind. Use Studio UI, driven through Playwright per [`drive-studio-canvas-ui`](drive-studio-canvas-ui.md). Don't hand the clicks to the user or switch to the API unless they ask; that's [`author-composition-via-api`](author-composition-via-api.md). + +Use whenever a Repeater iterates a Modular Block list or a Reference field. Without sibling Condition Blocks the iteration items have no resolved schema and every binding shows empty. Phrases: "modular block repeater", "reference repeater", "blocks render empty". Do NOT use for plain multi-valued fields (text/number arrays don't need schema disambiguation): skip Condition Block entirely. + +> **Auth preflight: settle the credential before the first API call.** Resolve it OAuth-first per [`authenticate-cma`](authenticate-cma.md): `CS_OAUTH_ACCESS_TOKEN`, else the Contentstack MCP's stored session: Read that skill for where it is, even with no MCP connected. **Never ask the user for a session `authtoken`.** If nothing resolves, or a refresh fails with `400 invalid_refresh_token`, hand them `! CONTENTSTACK_REGION=<code> npx @contentstack/mcp --auth` (it needs a TTY and a browser, so it cannot be run for them) and wait. `403 error_code 316` is a valid credential aimed at another org: fix the org or the `api_key`, do **not** re-authenticate. + +# Use a Condition Block + +## Context + +A Condition Block narrows the iteration item inside a Repeater to a specific schema (a Modular Block type, or a referenced content type) so that field bindings underneath it resolve. Without it, Studio cannot know which schema a given iteration item has, so the Data Picker cannot offer typed fields and the canvas surfaces the hint `Add a condition for <ct> schema, then bind the component within it to the hover fields`. Accepting that hint auto-wraps the binding in a Condition Block: this skill is the manual, deliberate equivalent. + +**Model: one Condition Block per type, as sibling children of the Repeater.** Studio's **Add Condition** modal lets you tick multiple block types or content types in one go. It inserts one Condition Block per selected UID, all as siblings under the Repeater. There is no "multi-branch" mode where one Condition Block routes between several types: each CB narrows to exactly one type via its `condition.type` metadata (`modular_block` matched to a block UID, or `reference` matched to a content-type UID). + +Condition Blocks are required inside a Repeater for: + +- **Modular Block lists** with two or more block types (the iteration item could be any one of them). +- **Reference fields with multiple content types** (the related entry could be any one of the allowed CTs). +- **Reference fields with a single content type**: still required, because Contentstack composition schema narrows iteration item type via the Condition Block before `repeater.<field>` paths resolve. One Condition Block matching the single CT is enough. + +Condition Blocks live in the palette under **Smart Containers**. Reference: `docs/34-smart-containers/control-visibility-with-condition-blocks.md`. + +## Task + +1. **Confirm a Repeater exists** bound to the Modular Block or Reference field named in `repeaterSource`. If not, stop and run `use-repeater` first: a Condition Block has nothing to discriminate without a parent Repeater iteration scope. + +2. **Open the Add Condition modal.** It opens when you bind the Repeater's `items` to the field: never click **Skip**. Re-bind `items` to reopen it (it lists only types without a Condition Block yet), or accept the data picker's **Add Condition** prompt on a child of the Repeater. No Repeater action opens it. The modal lists every block type (Modular Block source) or content type (Reference source) the Repeater's binding allows. + +3. **Tick every UID in `branches`** (in any order: Studio inserts them as siblings under the Repeater. Their order doesn't change the output: each item renders through the one branch that matches it, and items appear in the data's order). Confirm. Studio creates one Condition Block per selected UID, each with its discriminator (`modular_block` block UID, or `reference` content-type UID) recorded as `condition.type` metadata on the Condition Block node, not as a path expression the user writes. + +4. **For each newly-created Condition Block, drop a component into the Box Studio placed inside it** (a Condition Block takes one child) and bind the component's props via the Data Picker. The Data Picker surfaces the **Repeater Data** root inside a Condition Block, narrowed to the fields of the type that Condition Block matches: pick from there. Use **Component Default Data** only for runtime values not tied to the iteration item. + +5. **Save the composition and verify in Preview Mode.** Select the parent Repeater in Layers and toggle **Preview Mode** in Properties, then Configuration. Conditions apply their real type checks only in Preview Mode. In Design Mode (the default) you see the un-evaluated branch with placeholder data. Step through iterations and confirm each item routes through its matching Condition Block. Items whose UID matches none of the inserted Condition Blocks render nothing: there is no built-in default fallback. + +## Inputs needed from the user + +In this order. Stop and ask if any is missing: DO NOT guess `branches`. + +1. `compositionName`: Section or template canvas in scope +2. `repeaterSource`: parent Repeater binding (e.g. `blog_post.body_blocks`) +3. `branches`: comma-separated block-type or content-type UIDs + +If `repeaterSource` is a Reference and the user gives only one UID, still create a single-CB setup: single-CT references are NOT exempt from the Condition Block requirement. + +## Acceptance + +This skill succeeds only when ALL of the following are true. If any fails, do not claim success: surface the failure and stop. + +- [ ] In Layers the tree reads `Repeater > [Condition Block × N]`: each Condition Block is a direct child of the Repeater, or of the one Box that is the Repeater's only child (for Visual Editor's item tags: [`make-visual-editor-editable`](make-visual-editor-editable.md) § 2), never wrapping it. +- [ ] There is exactly one Condition Block per UID in `branches`. UIDs not in `branches` do not have a Condition Block. +- [ ] Each Condition Block's `condition.type` metadata is set to `modular_block` (Modular Block source) or `reference` (Reference source), matched to a specific block / content-type UID, never to a shared field name like `title`. +- [ ] Inside each Condition Block, the type-specific component's props are bound via `repeater.<field>` paths. The Data Picker shows the typed fields of that Condition Block's schema, not a generic union. +- [ ] With Preview Mode toggled on the parent Repeater, each iteration item visibly renders through its matching Condition Block. No item shows the "Add a condition for <ct> schema" hint. +- [ ] The user is informed that items whose type matches none of the inserted Condition Blocks render nothing (no built-in fallback). If that is unwanted, the user must add additional Condition Blocks for those types. + +## Common pitfalls + +| Pitfall | Why it breaks | Fix | +| --- | --- | --- | +| Trying to add multiple "branches" to a single Condition Block | Each Condition Block narrows to exactly one type. There is no multi-branch model | Insert sibling Condition Blocks (one per type) under the Repeater | +| Condition Block wraps the Repeater | Discriminates the parent scope, not the iteration item. `repeater.<field>` still cannot resolve | Drop Condition Block INSIDE the Repeater as a direct child sibling | +| Trying to write a path expression for the discriminator (e.g. `block.type === "hero"`) | Studio stores the discriminator as `condition.type` metadata on the node, not as a user-written path expression | Pick the block / content-type UID in the Add Condition modal. Studio handles the discriminator under the hood | +| Skipping Condition Block for single-CT Reference | Picker shows generic union, `repeater.<field>` paths fail to resolve | Add one Condition Block matching that single CT | +| Binding fields directly under Repeater on a Reference / mixed Modular Block | Canvas shows `Add a condition for <ct> schema` hint. Bindings stay broken | Accept the hint OR add Condition Blocks via the modal per this skill | +| Expecting an unmatched-fallback Condition Block | No such affordance in Studio: items with unmatched types render nothing | If you need a fallback, add a Condition Block for every type you expect. Revisit if new types are added later | +| Forgetting Preview Mode when verifying | Repeater renders only one iteration by default. You cannot see whether each Condition Block routes correctly | Select Repeater in Layers, toggle Preview Mode in Properties | + +## See also + +- `docs/34-smart-containers/control-visibility-with-condition-blocks.md`: reference +- `docs/34-smart-containers/create-repeatable-content-with-repeaters.md`: parent container +- `docs/40-recipes/marketing-site-walkthrough-with-four-end-to-end-scenarios.md`: Modular Block recipe in action diff --git a/skills/studio/use-repeater.md b/skills/studio/use-repeater.md new file mode 100644 index 0000000..119842d --- /dev/null +++ b/skills/studio/use-repeater.md @@ -0,0 +1,107 @@ +--- +name: use-repeater +--- + +## When to use + +Drop a Repeater on a section or template canvas and bind it to a multi-valued field so the same shape renders once per item. Two-mode mental model: bind-then-iterate. Use Studio UI, driven through Playwright per [`drive-studio-canvas-ui`](drive-studio-canvas-ui.md). Don't hand the clicks to the user or switch to the API unless they ask; that's [`author-composition-via-api`](author-composition-via-api.md). + +Use when rendering the same component once per item from a multi-valued source: Modular Block lists, multi-Reference fields, Group with multiple:true, or any array field. Phrases: "repeat per item", "list of blocks", "iterate references". Pair with `use-condition-block` for heterogeneous iteration items (Modular Blocks, References). Do NOT use for single-value fields. + +> **Mandatory auth preflight: settle the credential before the first API call.** Resolve it OAuth-first per [`authenticate-cma`](authenticate-cma.md): `CS_OAUTH_ACCESS_TOKEN`, else the Contentstack MCP's stored session: Read that skill for where it is, even with no MCP connected. **Never ask the user for a session `authtoken`.** If nothing resolves, or a refresh fails with `400 invalid_refresh_token`, hand them `! CONTENTSTACK_REGION=<code> npx @contentstack/mcp --auth` (it needs a TTY and a browser, so it cannot be run for them) and wait. `403 error_code 316` is a valid credential aimed at another org: fix the org or the `api_key`, do **not** re-authenticate. + +# Use a Repeater + +## Context + +> **Prerequisite: the canvas must render.** This is a canvas operation. If the section/template canvas is blank, the canvas chain isn't wired (route + Canvas URL path + the targeted environment's per-locale Base URL). Run [`setup-section-preview`](setup-section-preview.md) first. It walks the whole chain, including the env Base URL. + +A Repeater is a Smart Container that renders its child subtree once per item of a multi-valued source: Modular Block lists, Reference fields with multiple entries, Group with `multiple: true`, or any array field. + +**The scope rule, in user terms:** clicking inside a Repeater switches the Data Picker roots. Outside the Repeater = the Section's linked-schema fields. **Inside the Repeater = the iteration item's fields, labeled "Repeater Data".** Bind from whichever root the picker shows. The location determines the shape, not your choice. + +When the iteration items are heterogeneous (Modular Block lists, or References [single-CT and multi-CT]), the immediate child of the Repeater must be a Condition Block. Studio surfaces an inline hint when you bind a child directly and offers to wrap it for you. For Visual Editor's add / move / delete controls, then put those Condition Blocks inside one Box that is the Repeater's only child: [`make-visual-editor-editable`](make-visual-editor-editable.md) § 2. + +## What goes into a repeating Slot: a Section, not a raw component + +When the Repeater contains a Section Slot (the common "list of cards" pattern), what fills the slot is **another Section**, not a raw registered component: the Section's pre-bound linked schema auto-binds to each iterated item, so no per-prop hand-binding is needed. See `understand-section-slots` § What fills a Slot: a Section, not a raw component. + +Reference: `docs/34-smart-containers/create-repeatable-content-with-repeaters.md`. + +## Task + +1. **Confirm the source field is multi-valued.** Inspect `sourceFieldPath` on the connected content type or Pinned Query. If it is single-valued (single reference, group with `multiple: false`, scalar), stop. Redirect the user to bind the component directly without a Repeater. + +2. **Drag the Repeater tile from the palette.** In the palette, open **Smart Containers**, then drag **Repeater** onto the canvas region of `compositionName` where iterations should appear. + +3. **Select the Repeater node from the Layers panel.** Clicking the Repeater on the canvas is unreliable because nested children intercept the click. Always select it from **Layers**. + +4. **Bind the Repeater to the source.** In the right panel **Properties** section, click **Bind items** to open the Data Picker. Navigate to `sourceFieldPath`: + - For a template field: pick under the connected content type root. + - For a Pinned Query: pick under **Pinned Queries**. + - The picked node must be a list/array. The picker disables scalar leaves for this slot. + +5. **Wrap iteration in a Condition Block when required.** If `itemKind` is `modular-block` or `reference` (single-CT or multi-CT), the immediate child of the Repeater must be a Condition Block. The cleanest path: drop the `iterationComponent` directly inside the Repeater first. When you bind any prop on it, Studio shows an inline hint that this source needs a Condition Block. Accept the wrap prompt and Studio inserts the Condition Block around the component, scoping it to the active item type. For `group` and `array` of scalars, skip this step. + +6. **Drop the iteration content inside the Repeater.** + - **Preferred: drop a Section.** If the iteration content is non-trivial (a card with title + image + price + rating, etc.), build it as its own Section bound to the iterated item's CT and drop that Section into the Repeater (or into the Condition Block if step 5 applied, or into a Section Slot inside the Repeater. See the "What goes into a repeating Slot" pattern in Context above). The Section is pre-bound, nothing to wire per drop. + - **Fallback: drop raw components.** For trivial single-prop iterations only, you can drop a raw registered component directly inside the Repeater and bind its props via the Data Picker. The picker is already showing the **Repeater Data** root (iteration item fields) because you're inside the Repeater. Pick from there, not from the parent Section's linked-schema fields. This works but doesn't scale beyond simple cases. Reach for the Section route the moment you have more than one or two props. + +7. **Toggle Preview Mode on the Repeater to verify with real data.** + + Studio's canvas has two render modes for Smart Containers (Repeater, Condition Block, and similar): + + - **Design Mode (default).** Renders with **placeholder defaults**: Repeaters iterate once, Conditions show the un-checked branch, bindings on registered components resolve to their `defaultValue` from registration (not to real CMS data). Purpose: lay out structure without depending on live data. You can position, size, style, and arrange Sections without waiting for fetches or worrying about empty arrays. + - **Preview Mode (toggle).** Renders with **real data**: Repeaters iterate over real items, Conditions apply real checks, bindings resolve to actual CMS values. Purpose: verify the bound result looks right against live content. + + You **design** in Design Mode and **verify** in Preview Mode. Both modes are correct for their phase of authoring. Don't treat Design Mode's single-iteration placeholder as a bug. It's by design, so you can edit the iteration shape without fetching N items every time you nudge a margin. + + Select the Repeater in Layers, open the right-panel **Configuration**, then toggle **Preview Mode** to confirm N iterations render with the expected per-item values. + +8. **Save the composition.** Verify in Layers that the tree is `Repeater → (Condition Block →) iterationComponent`, and on the canvas (with Preview Mode on) that each iteration renders the expected per-item values. + +## Inputs needed from the user + +Collect these in order. Stop and ask if any is missing. Do not guess `sourceFieldPath` or `itemKind`. + +1. `compositionName`: section or template hosting the Repeater +2. `sourceFieldPath`: a list field on the Section's linked schema, or a Pinned Query name +3. `itemKind`: one of `modular-block`, `reference`, `group`, `array` (drives whether a Condition Block is required) +4. `iterationComponent`: optional. If blank, prompt the user to drop one manually after step 5 + +## Acceptance + +Succeeds only when ALL are true. If any fails, surface it and stop. + +- [ ] A Repeater node exists in `compositionName`'s tree, selected and visible in Layers. +- [ ] The Repeater's **Bind items** property points at `sourceFieldPath` via the Data Picker. +- [ ] If `itemKind` is `modular-block` or `reference`: a Condition Block wraps the iteration component, directly under the Repeater or inside the one Box that is its only child. +- [ ] The rendered page carries a `<field>` tag and a `<field>.<N>` tag per item ([`make-visual-editor-editable`](make-visual-editor-editable.md) § 2). +- [ ] If a raw iteration component was dropped: its bound props all resolve under the **Repeater Data** root in the Data Picker (iteration-item fields), not the parent Section's linked-schema root. Confirm by inspecting any bound prop in the picker breadcrumb. +- [ ] If a child Section was dropped (preferred path): the Section appears inside the Repeater (or its Condition Block / Slot), with no hand-binding needed. The Section's own bindings flow through automatically. +- [ ] Preview Mode on the Repeater renders more than one iteration on the canvas, each showing distinct per-item values. +- [ ] The composition is saved. + +## Common pitfalls + +| Pitfall | Why it bites | Fix | +| --- | --- | --- | +| Selecting the Repeater by clicking the canvas | Nested children intercept the click. You end up selecting the wrong node and binding the wrong slot | Always select the Repeater from the **Layers** panel | +| Binding iteration props from the parent Section's linked-schema root instead of the iteration item's root | Resolves at parent scope: every iteration shows the same value | Inside the Repeater, the Data Picker shows the **Repeater Data** root automatically. Bind from there: those are the iteration item's fields. | +| Dropping a raw multi-prop card into a repeating Slot and hand-binding each prop | Doesn't scale beyond one or two props. Easy to miss a prop or pick the wrong path | Build the card as its own Section bound to the iterated item's CT, then drop that Section into the Slot. Section-level auto-binding handles the wiring. | +| Skipping the Condition Block for References | References (single-CT and multi-CT) are heterogeneous per Studio schema. Bindings silently fail to resolve | Accept Studio's inline wrap prompt, or insert a Condition Block manually as the Repeater's direct child | +| Expecting all iterations to render in Design Mode (Preview Mode off) | Design Mode renders one placeholder iteration on purpose so you can lay out the structure without depending on live data. It's not a bug. It's the design phase. | Toggle **Preview Mode** in Properties, under Configuration, when you're ready to verify with real data | +| Repeater still renders 0 items AFTER toggling Preview Mode on | Preview Mode controls whether bindings resolve, not whether they resolve correctly. If Preview Mode is on and the Repeater is still empty, the toggle isn't the issue. The binding shape is. Flipping Preview Mode again does nothing once it's already on. | Stop flipping the toggle. Diagnose the binding: re-check the Bind items source field, confirm the source has items in real data (in DevTools, open the Network tab and read the composition or entry payload), verify scope (is the binding picked from the right Data Picker root?), confirm the Condition Block is in place for Reference / Modular Block iteration. See `troubleshoot-data-binding` and `build-repeating-section`. | +| Dropping the Repeater on a single-value field | Repeater requires an iterable source. Binding rejects scalars | Bind the component directly without a Repeater | +| Dropping multiple iteration components as siblings inside the Repeater | All siblings render once per item, multiplying output unintentionally | Wrap intended iteration shape in a single container (Box/Row) inside the Repeater | +| Reaching for a Repeater on a static, single-instance page | Repeater exists to iterate a list. A page with one hero + one promo card has nothing to iterate: adding a Repeater introduces a phantom iteration scope, shifts the Data Picker to "Repeater Data" so the parent's fields aren't reachable, and leaves an empty iteration placeholder in the canvas | Confirm the data source is genuinely multi-valued BEFORE dropping a Repeater. For static single-instance content, drop the component directly on the canvas, no Repeater | + +## See also + +- `build-repeating-section`: the end-to-end "parent Section with Repeater + Slot, child Section drops in" recipe. Use that when the iteration content is a non-trivial card that should be reusable elsewhere +- `adapt-collection-component`: reach for this when the iteration wraps a **production component whose interface takes an array/object prop** (`<Carousel items={[…]} />`). Native `array`/`object` prop types don't bind to CMS fields, so the wrapper needs a `slot` prop + leaf adapter to become a List Section. Migration flavor of List Section authoring. +- `verify-visual-parity`: Repeater's per-iteration `display:contents` wrapper is a common source of layout drift and breaks JS libraries that measure children (Embla, Swiper). If a slider/carousel misbehaves at runtime, run visual parity per Section against the production URL and classify the drift. +- `author-composition-via-api` § Recipe: Nested Repeaters: nested-repeater shapes (inner `items` binding uses the OUTER repeater's `repeaterUID`, inner card props bind to the inner repeater scope). Studio's UI doesn't have a direct authoring path for nested repeaters yet. The API recipe is the reference +- `docs/34-smart-containers/create-repeatable-content-with-repeaters.md`: full reference +- `docs/34-smart-containers/control-visibility-with-condition-blocks.md`: when and how to wrap heterogeneous iteration +- `skills/src/08-section-slots-and-smart-containers/use-condition-block.md`: sibling skill, always invoked for Modular Block and Reference iteration diff --git a/skills/studio/use-section-slot.md b/skills/studio/use-section-slot.md new file mode 100644 index 0000000..acaaca8 --- /dev/null +++ b/skills/studio/use-section-slot.md @@ -0,0 +1,112 @@ +--- +name: use-section-slot +--- + +## When to use + +Carve a Section Slot into an already-designed section so template authors drop components into a named placeholder without unlocking the surrounding design. Path B (default): Section-with-Slot + child Section. Use Studio UI, driven through Playwright per [`drive-studio-canvas-ui`](drive-studio-canvas-ui.md). Don't hand the clicks to the user or switch to the API unless they ask; that's [`author-composition-via-api`](author-composition-via-api.md). + +Use when authoring a Section and wanting to expose a variable region template authors fill per-instance: "add a slot", "section slot", "carve out a drop zone". Surrounding section stays locked. Only slot contents vary per template instance. Do NOT use for value-level overrides (use Expose Section Prop). Do NOT use on an empty section. + +> **Mandatory auth preflight: settle the credential before the first API call.** Resolve it OAuth-first per [`authenticate-cma`](authenticate-cma.md): `CS_OAUTH_ACCESS_TOKEN`, else the Contentstack MCP's stored session: Read that skill for where it is, even with no MCP connected. **Never ask the user for a session `authtoken`.** If nothing resolves, or a refresh fails with `400 invalid_refresh_token`, hand them `! CONTENTSTACK_REGION=<code> npx @contentstack/mcp --auth` (it needs a TTY and a browser, so it cannot be run for them) and wait. `403 error_code 316` is a valid credential aimed at another org: fix the org or the `api_key`, do **not** re-authenticate. + +# Use a Section Slot + +## Context + +> **Prerequisite: the canvas must render.** This is a canvas operation. If the section canvas is blank, the canvas chain isn't wired (route + Canvas URL path + the targeted environment's per-locale Base URL). Run [`setup-section-preview`](setup-section-preview.md) first: it walks the whole chain, including the env Base URL. + +A Section Slot is a named, drop-target region carved into an already-designed Section. The surrounding section layout, bindings and styling stay locked at the section level. Only the slot's contents vary per template instance. Template authors see each slot as a dashed rectangle showing its label and drop any component or section into it. + +Section Slots are siblings of Exposed Section Props but solve a different problem: + +- **Section Slot**: the variable thing is a region of UI (children: components, nested sections, repeated items). +- **Expose Section Prop**: the variable thing is a single value on an existing component (a label, a flag, a piece of text). + +The Section Slot tile lives in the palette under **Smart Containers** and is only visible when you are in section authoring mode (it is hidden inside page editing). Slots only become live drop targets when the section is dropped onto a template: inside the section's own canvas they render as inert labeled placeholders, by design. + +Reference: `docs/34-smart-containers/section-slots.md`. + +## Task + +1. **Open the section in section authoring mode** in Studio. Confirm the Section Slot tile is visible in the palette under Smart Containers. If it is not, you are in page editing mode. Switch to the Section. + +2. **Confirm the section already has fixed structure.** Layout containers, linked-schema bindings, headings and any repeaters that are part of the locked design should already exist on the canvas. Identify the one to four regions that should be variable per template instance. If the section is empty, stop and tell the user to build the fixed structure first: a section that is just a placeholder has no reason to exist. + +3. **Decide placement** based on the `placement` input: + - `component-slot-prop` (recommended): drop the Section Slot inside a component's `slot`-typed prop (e.g. a Card's `body` slot prop, a Hero's `children` slot prop). The slot then renders inside the component's frame and inherits its layout. The target component must already be registered with a `slot`-typed prop. See `register-component` § 5a. Exposing extensible regions with `slot` props. + - `layout-container`: drop the Section Slot inside an existing Box / Row / Grid the section already laid out. + - `repeater-item`: see step 6 below. + + Never drop a Section Slot as a bare sibling at the section root: it renders as a disconnected block with no surrounding design. + +4. **Drag the Section Slot tile onto the chosen target.** Use the canvas drop indicators to confirm it lands inside the intended container, not next to it. + +5. **Select the Section Slot node** (on the canvas or in Layers) and, in the right panel, set **Drop placeholder label** to the supplied `slotLabel`. This label is the only contract template authors have with the section. Make it intent-revealing. + +6. **List + Slot variant (only if `placement = repeater-item`).** If the section owns a list layout where each item should be template-defined: + a. Drop a **Repeater** bound to `repeaterBindingPath` via the Data Picker. Inside the Repeater, the Data Picker switches root to the iteration item's fields (shown as **Repeater Data** in the picker): no manual binding inside the Slot is needed. The child Section dropped into the Slot at template time will bind through its own linked schema against the iteration item. + b. Drop **exactly one** Section Slot inside the Repeater item. + c. Set its label to `slotLabel` (typically `Item template`). The slot renders once in section authoring and repeats per item at render time. Scope inside the slot inherits the current iteration item, so no manual per-item binding is needed. + +7. **Repeat steps 3 to 5** for every additional variable region the section needs. Sections commonly carry 1 to 4 slots (a card section, for example: `Media`, `Headline`, `Body`, `Action`). + +8. **Save the section.** Drop the section onto a test template to verify each slot appears as a dashed labeled drop target. Drop a component into one slot, then drop the same section onto a second template, and confirm the two templates' slot fills are independent: fills live on the template, not on the section. + +## Inputs needed from the user + +In this order. Stop and ask if any is missing. DO NOT guess placement or label. + +1. `sectionUid`: which Section to carve into +2. `slotLabel`: the drop placeholder label authors see (must be intent-revealing, reject "Slot 1" / "Content" and ask again) +3. `placement`: defaults to `component-slot-prop` +4. `repeaterBindingPath`: only required when `placement = repeater-item` + +If the user wants multiple slots, collect a `slotLabel` per region and loop steps 3 to 5. + +## Layout container: the Slot must sit inside a sized cell + +A Section Slot does not define its own width. It fills whatever container wraps it. If the Slot sits directly on the Section's root (with no layout container around it), anything dropped into the Slot at template time stretches to the **full canvas width**: full-bleed cards, full-bleed sub-Sections, full-bleed everything. + +This is by design: registered components and child Sections are layout-agnostic by contract (see `register-component`'s layout rule), rendering at `width: 100%` of their container. They fill their cell. They don't decide cell size. + +So: **the Section that exposes a Slot must wrap that Slot in a layout container** that defines the cell. + +- For a list of cards: a Box with `display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 24px;` wrapping the Repeater + Slot. +- For a single sized region (sidebar card, hero CTA): a Box with `max-width: 360px` (or whatever sizing the design calls for) wrapping the Slot. Under `allowedValuesLevel: "tokens"` that is a registered `size.maxWidth` token ([`import-design-tokens`](import-design-tokens.md)): raw values are locked. +- For a flex strip of cards: a Box with `display: flex; gap: 16px;` and `flex-basis` set on each iteration so they don't stretch to fill. + +If you find yourself adding overrides on the dropped Section (or component) to make it look right inside this Slot, the layout coupling is backwards. Move sizing into the parent Section's container. Let the child stay at `width: 100%`. + +## Acceptance + +This skill succeeds only when ALL of the following are true. If any fails, do not claim success. Surface the failure and stop. + +- [ ] The Section Slot node exists in the section's tree at the placement chosen in step 3 (not as a bare sibling at the section root). +- [ ] The slot's Drop placeholder label matches the supplied `slotLabel`. +- [ ] In section authoring canvas the slot renders as an inert labeled placeholder (it intentionally rejects drops here). +- [ ] When the section is dropped on a test template, each slot appears as a dashed rectangle showing its label and accepts any component or section. +- [ ] Filling a slot on Template A does not change the slot on Template B (fills are stored per-template). +- [ ] For `repeater-item` placement: exactly one Section Slot is nested inside a Repeater bound to `repeaterBindingPath`, and rendered output repeats per item in the bound list. +- [ ] The Slot sits inside a **layout container** in the Section (a Box with grid tracks / flex / `max-width`) that defines the cell size, confirmed by inspecting the Section's Layers panel. The Slot does NOT sit as a bare child of the Section root with no sized parent. + +## Common pitfalls + +| Pitfall | Why it bites | Fix | +| --- | --- | --- | +| Slot-first authoring | Empty section wrapping a slot = "a section that is just a placeholder", defeats the point | Build the fixed structure first, then carve the slot | +| Sibling-of-component placement | Slot next to a component at section root breaks visual integration | Drop inside the component's slot-typed prop or a layout container | +| Vague label | "Slot 1" / "Content" tells the template author nothing | Use intent-revealing labels like `Drop a card here` | +| Using a slot to constrain type | Slots accept any component/section. No "type X only" restriction | For a typed value override use an exposed Section Prop | +| Expecting fills inside the section's own canvas | Slots only become live drop targets on a template | Verify by dropping the section on a test template | +| Deleting a slot with fills | Template-side fills orphan and get cleaned on next template save | Migrate fills manually before deleting | +| Scope confusion in nested sections | Section B inside A's slot binds against A's scope, not the outer page CT | Use the Data Picker's explicit "page" scope when B needs the page entry | +| Slot with no layout container around it | Drops stretch full canvas width (full-bleed) | Wrap the Slot in a Box with grid tracks, flex sizing, or `max-width` | + +## See also + +- `docs/32-sections/expose-section-props.md`: value-level sibling of Section Slots +- `docs/32-sections/binding-to-cms.md`: scope-root matching for nested sections +- `docs/40-recipes/card-grid-with-slots.md`: full List + Slot recipe +- `configure-slot-defaults`: give the slot a default section for its empty state, or restrict which sections may drop in +- `wire-slot-data`, the code-side sibling: carry an owning component's data into its `slot` prop via `<Slot data={...}>` diff --git a/skills/studio/verify-setup.md b/skills/studio/verify-setup.md new file mode 100644 index 0000000..a8c8138 --- /dev/null +++ b/skills/studio/verify-setup.md @@ -0,0 +1,264 @@ +--- +name: verify-setup +--- + +## When to use + +Run a layered smoke test confirming Studio is wired correctly: Live Preview reachable, Visual Editor opens, Studio canvas loads the canvas-app fixture, and at least one composition renders. + +Use right after `install-studio` and `setup-template-preview-routes` to catch breakage before authoring. Also when a previously working setup regressed ("the canvas was working yesterday"). Phrases: "verify Studio setup", "smoke test", "is Studio working". Do NOT use as a creative authoring skill: purely diagnostic. Does NOT replace `troubleshoot-canvas`: verify tells you WHAT is broken, troubleshoot tells you WHY. + +> **Auth preflight: settle the credential before the first API call.** Resolve it OAuth-first per [`authenticate-cma`](authenticate-cma.md): `CS_OAUTH_ACCESS_TOKEN`, else the Contentstack MCP's stored session: Read that skill for where it is, even with no MCP connected. **Never ask the user for a session `authtoken`.** If nothing resolves, or a refresh fails with `400 invalid_refresh_token`, hand them `! CONTENTSTACK_REGION=<code> npx @contentstack/mcp --auth` (it needs a TTY and a browser, so it cannot be run for them), and wait. `403 error_code 316` is a valid credential aimed at another org: fix the org or the `api_key`, do **not** re-authenticate. + +# Verify Studio setup end-to-end + +## Context + +Studio sits on top of three other systems: Delivery SDK, Live Preview, Visual Editor. A canvas that won't load is almost always one of those failing underneath. Walk the stack layer-by-layer to find the lowest failing layer. + +The four layers, lowest-to-highest: + +1. **Delivery SDK**: can the app fetch an entry from Contentstack at all? +2. **Live Preview**: does the iframe channel open between Contentstack and the app? +3. **Visual Editor**: does the entry-form UI render fields with edit markers? +4. **Studio**: does the canvas mount and render a composition with real bindings? + +If layer 1 fails, layers 2 to 4 will all fail and chasing them is wasted work. Always read failures bottom-up. + +Reference: `docs/10-setup/verify-end-to-end.md`, `docs/10-setup/troubleshoot-common-studio-issues.md`. + +## Task + +### Evidence required before reporting setup complete + +Record each check as **passed**, **failed**, or **unverified**, with the observed URL/result. HTTP 200 proves reachability only. A blank `<div />` can return 200 without mounting Studio. + +- Load an existing code page, a published Studio composition when available, an unmatched URL, and a bad entry slug under a connected template when available. Check rendered content and errors as well as status codes. +- Open the actual Section iframe and template iframe in Studio. Record their exact URLs, including locale prefixes and preview query parameters. Verify content renders, then make one authorized unsaved edit and observe the update. If browser access or a suitable composition is unavailable, report that check as unverified; do not claim live editing works. +- Inspect visible header/footer ownership. Providers or a cart wrapper do not establish that header/footer chrome renders. Check for missing or duplicated chrome. +- Validate preview credentials using Layer 6's preview host and entry endpoint. A 401 from a different host or endpoint is not evidence to replace a token. Reuse valid credentials. +- For Next.js, stop the relevant dev process before a production build sharing its output directory, or use separate output directories. Restart dev after the build and inspect runtime logs. Build/lint success does not catch every request-time ReferenceError, including a missing `next/script` import. + +Use existing compositions for verification where possible. Any temporary authoring or publishing must remain within the user's authorized scope. Setup-only completion does not require unrelated component registration, thumbnails, or publication. + +0. **Layer 0: Stack-level Visual Experience setup.** + - Before testing any app-side wiring, ask the user to confirm ALL of: + - `Settings → Visual Experience → General → Live Preview → Enable Live Preview` checkbox is **checked + Save clicked** + - A `Default Preview Environment` is selected on the same page + - The `Preview URL` sub-tab has a `Custom Preview URL` set (and is NOT showing the "Custom Preview URL Unavailable" lockout banner) + - The Delivery Token in `.env` has a Preview Token generated (the **Create Preview Token** button on the token edit page must have been clicked at least once) + - **The environment your Studio project targets has a non-empty per-locale URL** for your locale, and for **every** other locale the app serves on a localized project (in the stack, open Settings, then Environments, then `<env>`, and read the URL for `<locale>`). This is the canvas Base URL (origin). Empty = blank canvas with an error that points at the wrong layer. For local dev it must equal your dev origin (e.g. `http://localhost:5173`). + - If any of these is unconfirmed (or the user is unsure), STOP and route them through `enable-visual-experience` (Live Preview) or `setup-section-preview` (the env Base URL). Re-run this skill after that completes. + - Catches the most common failure: app wired, stack-level flag off, runtime silently broken. Skipping makes Layer 2 failures look like SDK bugs when they're actually web-app toggles. + +1. **Layer 1: Delivery SDK reachability.** + - Run: `npx tsx -e "import('@contentstack/delivery-sdk').then(async cs => { const stack = cs.default.stack({ apiKey: process.env.CS_API_KEY, deliveryToken: process.env.CS_DELIVERY_TOKEN, environment: process.env.CS_ENVIRONMENT || 'preview' }); const r = await stack.ctQuery('home_page', { limit: 1 }).find(); console.log('OK entries:', r.entries?.length ?? 0); })"` + - Or, programmatically, call any existing `stack.ctQuery(<a known CT>).find()` in the app. + - Pass: a query returns without throwing. + - Fail modes: 401 means a wrong Delivery Token, 422 a wrong environment name, 404 a wrong CT UID, and ECONNREFUSED a wrong region host. + +2. **Layer 2: Live Preview iframe channel.** + - Open the host app at the Canvas URL in a browser. + - In the host app, attach a one-off `ContentstackLivePreview.onEntryChange(() => console.log('lp:update'))` (or rely on an existing one). Make a content edit in Contentstack. + - Pass: the callback fires within ~2s of the edit (you see `lp:update` in the host console). + - Fail modes: no callback fire means the Preview Token is wrong, not generated yet, or Live Preview is not enabled on the stack. CORS errors in the console mean the app origin is not allowed in the Custom Preview URL config (re-verify under Visual Experience, then Preview URL). Don't inspect raw post-message event names: that's an internal channel that may change. The `onEntryChange` callback is the stable public surface. + +3. **Layer 3: Visual Editor.** + - In Contentstack web UI, open any entry of a CT whose template is mounted at the Canvas URL. + - Click the "Preview" or eye icon, and the Visual Editor opens. + - Pass: the entry preview shows, fields have hover-edit highlights, clicking a field opens its edit panel. + - Fail modes: a blank iframe means the Canvas URL is wrong. Missing field highlights mean the `data-cslp` tags aren't being injected (the app isn't using `live-preview-utils` correctly). Stuck on "Initializing the editor…" means Live Preview is not in `mode: "builder"`. + - Lists: the rendered HTML has a `data-cslp="….<field>"` tag and a `….<field>.0` tag for every list, or Visual Editor cannot add, move or delete items ([`make-visual-editor-editable`](make-visual-editor-editable.md) § 2). + +4. **Layer 4: Studio canvas.** + - Open Studio at `studioUrl`. Pick the project. Open any composition. + - Pass: canvas iframe renders a non-empty `[data-composable-studio-id]` node tree within 10s. + - Fail modes: + - A blank canvas means the canvas iframe address is wrong. Check both halves: the **Canvas URL** (path) on the Studio project must match the mounted route, AND the **targeted environment's per-locale Base URL** (origin) must be non-empty and reachable. An empty env Base URL is the most common cause and surfaces as a "no base URL"-style error pointing at the Studio project, not the environment. + - "Component Loading Error" means the section ref's `compositionUID` doesn't resolve (sections not preloaded or fetched from CDA, see F-018-style issues) + - "Authentication failed" means the Delivery or Preview token in `.env` is wrong or revoked. In the stack, open Settings, then Tokens, then the token itself to read its current values, paste those into `.env`, and restart the dev server + + ### Expected (not-a-failure) console messages when verifying + + Two log lines are expected during verification and should NOT be interpreted as failures: + + - **`[StudioCanvas] is editing-only and renders nothing outside Composable Studio. Mount it on a dedicated route…`**: logged when you open the canvas route directly in a normal browser tab (outside Studio's iframe). `<StudioCanvas />` intentionally renders nothing without a parent Studio host to drive it. The route is verified by opening it INSIDE Studio's canvas, not by hitting its URL directly. A blank standalone page + this warning is expected. + - **"SDK Not Initialized" tooltip inside Studio's canvas**: expected until step 3 of `install-studio` (bootstrap) has run + the browser can reach the canvas-app's origin. If you see it AFTER install: check that `studioSdk.init()` runs on the canvas route (the module holding init must be imported by that route's module tree) AND that the browser can reach the canvas-app (no mixed-content, no PNA block). + +5. **Layer 5: New-user trap smoke tests** (runs after Layer 4 passes, catches the gaps that previously bit fresh installs). + + - **5a. Single-React check** (defends against the dual-React trap): + ```bash + <pm> ls react + ``` + Pass: exactly one tree entry, on `18.x` or `19.x`. Fail mode: more than one entry (a peer dep pulled a second copy), so see `analyze-project-fit` and `install-studio` single-React step. + + **Caveat:** even with one React in `node_modules`, the canvas can see two React instances at runtime via Vite's dev optimizer pre-bundling the SDK with a separately-loaded React (different `?v=` hash). Fix is environmental. See 5a-bis. + + - **5a-bis. Canvas is served from a prod build, not the dev server.** Visit your `canvasAppUrl` and open the DevTools Network tab. Hard-reload. Check the first JS responses: + - Pass: file names look like `assets/<chunkname>-<hash>.js` (Rollup output). One `react.js` bundle, no `?v=` query strings. + - Fail: file names include `?v=<hash>` query strings (Vite dev-optimizer signature). Two or more separate React bundles confirm dual-React at runtime, so switch the canvas target from `vite` (dev) to `vite preview` (prod build). See `install-studio` step 9 and `setup-section-preview` for the config. + + - **5b. Local HTTPS reachability** (only if `canvasAppUrl` is `https://localhost*`): + ```bash + curl -I -sS --max-time 5 ${canvasAppUrl} 2>&1 | head -3 + ``` + Pass: `HTTP/2 200` (or `HTTP/1.1 200`) without any `self signed certificate` or `unable to get local issuer certificate` warning. Fail: an untrusted cert, so run `setup-local-https-canvas` (mkcert). HTTP localhost isn't mixed content. Other HTTP origins are: use HTTPS. + + - **5c. iframe-allowed headers**: + ```bash + curl -I -sS --max-time 5 ${canvasAppUrl} | grep -iE 'content-security-policy|access-control-allow-origin' + ``` + Pass: `frame-ancestors` (`*` or `https://*.contentstack.com`) plus `Access-Control-Allow-Origin: *`. Fail: missing, so see `setup-section-preview` for the Vite/Next config block. The Studio iframe will silently fail to embed without these. + + - **5d. Arity ≥ 1 on every registered component**: + ```bash + grep -nE "registerComponent\(\{" -A 5 src/ lib/ | grep "component:" | head -20 + ``` + Manually inspect: every `component:` should either be a named component declared with at least one parameter (e.g. `function Hero(props) {…}`) or a wrapped form `(props) => createElement(Comp, props)`. Fail: any `component: Foo` where `Foo` is declared as `function Foo() {…}` (no parameter), which is the arity-0 trap and throws React #321 at render. See `register-component` arity rule. <!-- style-lint: allow --> + + - **5e. Connected-template URL match** (only if any connected templates exist): + - Open any connected template in Studio. Confirm the canvas iframe renders (not "SDK Not Initialized"). + - If it fails, immediately apply the section-test diagnostic: open a section composition. If the section renders, the SDK is fine. The failure is the connected template's URL match. See `troubleshoot-composition-resolution` (entry `url` field needs a leading slash, and composition `url` must contain a `{{entry.x}}` pattern). + - Also verify the env base URL scheme: in `GET /v3/environments/<env>`, `urls[].url` must be HTTPS if the canvas-app is HTTPS. + +6. **Layer 6: Preview Token authenticates with the CDA preview API** (behavior-based, project-agnostic, does NOT assume any specific env file path or env var name). + + The most common silent failure on a fresh setup: the **Preview Token** passed to the Delivery SDK / Live Preview SDK is invalid or empty, the CDA preview API returns 401 without CORS headers, and the browser surfaces a misleading "CORS error" instead of "401 Unauthorized." Hours of CORS chasing for a token paste. Verify the token authenticates **before** opening Studio. + + **The check is project-agnostic.** Don't grep `.env.local`, don't assume `VITE_*` / `NEXT_PUBLIC_*` / `REACT_APP_*` env var names: every framework / project layout differs. Just ask the user for the three runtime values and curl-verify them directly. If you (the agent) can read the project's config to discover them, fine. Otherwise ask. + + Inputs: + - `stackApiKey`: the stack's API key the canvas-app uses + - `previewToken`: the Preview Token value the canvas-app currently passes to `@contentstack/live-preview-utils` / Delivery SDK at runtime, wherever it's loaded from (env file, runtime config, secrets manager, fetched-from-API, hardcoded, doesn't matter) + - `previewHost`: region-dependent: `rest-preview.contentstack.com` (NA prod), `eu-rest-preview.contentstack.com` (EU prod), `azure-na-rest-preview.contentstack.com`, etc. (see `install-studio` § Regional host map). For non-prod / custom-cloud, take the host from [`analyze-project-fit`](analyze-project-fit.md) § 2b: Determine the data center (e.g. `<env>-rest-preview.<non-prod-domain>`) rather than asking again. + - One `<CT>`, `<env>`, `<locale>` from the project to query against: pick any content type the user knows has at least one entry in that environment. + + Run: + ```bash + curl -is "https://${previewHost}/v3/content_types/${CT}/entries?environment=${env}&locale=${locale}&limit=1" \ + -H "api_key: ${stackApiKey}" \ + -H "preview_token: ${previewToken}" + ``` + + Outcomes: + - **HTTP 200** + JSON `entries` array: **Pass**. The Preview Token authenticates. CORS headers WILL be present (`access-control-allow-origin: *`). Done. <!-- style-lint: allow --> + - **HTTP 400** + `"Please provide live preview hash in request"`: still a **Pass**. The token authenticated. The 400 is just because curl didn't send the `live_preview` hash header the canvas-app SDK provides automatically at runtime. + - **HTTP 401** + `"errors":{"preview_token":["is not valid."]}`: **Fail**. The Preview Token is bad. Common causes: literal string `"undefined"` from a provisioning script that couldn't mint via CMA, revoked (paired Delivery Token deleted), truncated copy-paste, wrong stack. Direct user to `troubleshoot-canvas` § CORS error on the CDA preview host row, plus `enable-visual-experience` Step 4 to mint / re-mint. + - **HTTP 403**: **Fail**. The token is valid but lacks scope for that CT or environment. Direct user to Settings, then Tokens, to broaden scope. + - **HTTP 422**: **Fail**. Environment or locale name typo. + - **No CORS header on a 401 response** is normal Contentstack behavior. That's WHY the browser misattributes it as "CORS." Layer 6 sidesteps the browser entirely. + +7. **Layer 7: Section scoping resolves correctly via the SSR cold-load** (only if the project has section compositions placed on any template). The most reliable headless check for section binding, bypasses the editor entirely. + + Section compositions are data-less standalone (their own canvas has no data, see `author-composition-via-api` § Authoring a Section composition). The editor canvas can't confirm a section's scope is correct. The SSR cold-load does: `await sdk.fetchCompositionData({ url, searchQuery })` runs the full data-collection + scoping pipeline and attaches `spec.data.section_scoped_data` (one entry per section instance) with the resolved `selectedField` and the actual scoped `template` slice. + + Inputs: + - `previewUrl`: any visitor URL that resolves to a Connected Template that has sections placed on it (e.g. `https://localhost:3006/products/some-product`, `https://your-staging-site/blog/some-post`). The canvas-app must be running and reachable at this URL. + + Run (as a one-off Node script using the canvas-app's already-installed SDK): + + ```js + import contentstack from "@contentstack/delivery-sdk"; + import { studioSdk } from "@contentstack/studio-react"; + + // Replicate the canvas-app's stack init (whatever credentials it uses at runtime) + const stack = contentstack.stack({ + apiKey: process.env.STACK_API_KEY, + deliveryToken: process.env.DELIVERY_TOKEN, + environment: process.env.ENV_NAME, + live_preview: { preview_token: process.env.PREVIEW_TOKEN, enable: true, host: process.env.PREVIEW_HOST }, + }); + + const sdk = studioSdk.init({ + contentTypeUid: process.env.COMPOSITIONS_CT, // whatever the project's compositions CT is + stackSdk: stack, + }); + + // Throwaway verification script — no wrapper on purpose. A thrown + // ComposableStudioError IS the result here: read err.code + // (NO_COMPOSITION_FOR_URL / URL_MATCHED_NO_ENTRY) and + // err.details.troubleshootingInfo. In app code, wrap it — see + // configure-csr-vs-ssr § "fetchCompositionData does not return not-found". + const spec = await sdk.fetchCompositionData({ url: process.env.PREVIEW_URL }); + const scoped = (spec?.data ?? {}).section_scoped_data ?? {}; + + for (const [instanceUid, data] of Object.entries(scoped)) { + const tmpl = data.template; + const len = Array.isArray(tmpl) ? tmpl.length : (tmpl ? 1 : 0); + console.log(JSON.stringify({ + instanceUid, + selectedField: data.selectedField, + parentRepeaterUID: data.parentRepeaterUID, + templateKind: Array.isArray(tmpl) ? "array" : typeof tmpl, + templateLen: len, + sample: Array.isArray(tmpl) ? tmpl.slice(0, 2).map(t => t?.title ?? t?.uid ?? "<no-title-or-uid>") : null, + })); + } + ``` + + `section_scoped_data[<instanceUid>]` carries: + - `template`: the scoped slice the section actually renders against + - `selectedField`: the resolved selectedField, taken from `sectionBindingOverride`, else `metadata.selectedField`, else the `linked_schemas` default, in that priority order + - `parentRepeaterUID`: null at top level, set when the section was placed inside a Repeater + - `contentstack`, `contentstack_queries`, `static_value`, `page`, `repeater`, `uidRemapping`, `componentPropsFallback`: full resolved data the section gets + + Per-instance pass/fail: + + | Outcome | What it means | Pass? | + |---|---|---| + | `selectedField` is set + `template` is an array of resolved entries with `templateLen > 0` | Section is correctly scoped to an iterated field, has data to render | Yes | + | `selectedField` is unset/empty + `template` is the whole page entry (object, `templateKind: "object"`) | Whole-entry section (per P25's multi-field decision), correct for sections that read multiple page fields | Yes | + | `selectedField` is set but `template` is `undefined` / null / empty array | Scope resolution failed. Reasons: `selectedField` points at a field that doesn't exist on the page CT, the field is empty on this preview entry, `data_sources.resolvedReferences` is missing for a reference path (see `author-composition-via-api` § Recipe: Repeat over a multi-reference). | No | + | `selectedField` is set to one value but you EXPECTED another | The `linked_schemas` vs `sectionBindingOverride` paths disagree, OR the section's `linked_schemas` was dropped by the compositions CT (the linked_schemas-as-reference trap, P23). Compare both paths. Both must agree. | No | + | `Object.keys(scoped).length === 0` but the template HAS section nodes in its `ui` | The composition's `linked_sections` reference field isn't populated (P27 territory: API-placed sections need this reference set even though SSR walks the `section-composition` nodes itself). | No | + + Direct user to `author-composition-via-api` § Authoring a Section composition: scoping rules for fix paths on any **No** row. + +8. **Report.** Print each layer as a pass or a fail, with the exact failing call. If everything passes, print "All seven layers green. Canvas URL: <canvasAppUrl>". If anything fails, name the LOWEST failing layer and suggest invoking `troubleshoot-canvas` (or for Layer 7, `author-composition-via-api`) with that symptom. + +## Inputs needed from the user + +1. `canvasAppUrl`: used in Layer 2 (open in browser) + Layer 4 (compare against Studio project config). +2. `studioUrl`: used in Layer 4. +3. `stackApiKey`: used in Layer 6 (curl against preview API). +4. `previewToken`: used in Layer 6. Whatever value the canvas-app currently passes to the Delivery / Live Preview SDK at runtime, wherever the project loads it from (env file, runtime config, secrets manager, hardcoded). If unsure, ask the user to read it from their project's config layer. Do NOT assume an env file path or variable name. +5. `previewHost`: used in Layer 6. Region-dependent (see `install-studio` § Regional host map). For non-prod / custom-cloud, the explicit host the user gives you (e.g. `<env>-rest-preview.<non-prod-domain>`). +6. One `<CT>`, `<env>`, `<locale>` triple: used in Layer 6 as a known-good probe target. Any content type the user knows has at least one entry in that environment. +7. `previewUrl`: used in Layer 7. Any visitor URL on the canvas-app that resolves to a Connected Template with sections placed on it (e.g. `http://localhost:3006/products/some-product` for local dev). Skip Layer 7 if the project has no section compositions placed on any template yet. + +If `.env` exists in the project, read `CANVAS_APP_BASE_URL` and `STUDIO_BASE_URL` as defaults for the first two before asking. Do NOT auto-read `previewToken` from any env file: its location is project-specific. + +## Acceptance + +This skill succeeds only when Layer 0 + ALL seven downstream layers were attempted (Layer 7 may be skipped if the project has no section compositions placed on any template) and a clear pass/fail recorded for each. + +- [ ] Layer 0 confirmed (Enable Live Preview checked + Default Preview Environment set + Custom Preview URL set + Preview Token created on the Delivery Token + targeted environment has a non-empty Base URL for every locale the project serves). +- [ ] Layer 1 result reported (delivery SDK call result or HTTP status code). +- [ ] Layer 2 result reported (live-preview event seen / not seen within timeout). +- [ ] Layer 3 result reported (entry preview opens / fails / can't even open the entry). +- [ ] Layer 4 result reported (canvas DOM tree present in the iframe / not present / specific error string). +- [ ] Layer 5 smoke tests reported: single-React, local HTTPS, iframe-allowed headers, arity-of-registered-components, connected-template URL match (where applicable). Any failure flagged with the skill to invoke next. +- [ ] Layer 6 reported: Preview Token authenticated against the CDA preview host (HTTP 200 or HTTP 400-with-live-preview-hash-message = PASS, HTTP 401/403/422 = FAIL with the specific fix path identified). Behavior-based check, project-specific env layout was NOT assumed. +- [ ] Layer 7 reported (or skipped with explicit reason: "no section compositions placed on any template"): for every section instance in `spec.data.section_scoped_data`, the resolved `selectedField` and `template` shape match expectations. Any failing instance is named with the specific failure class (undefined template, wrong selectedField, missing linked_sections, etc.) and routed to `author-composition-via-api` for the fix. +- [ ] If any layer failed: the LOWEST failing layer is named, not the highest visible symptom. +- [ ] If everything passed: the all-clear message + canvas URL is printed. + +## Common pitfalls + +| Pitfall | Why it bites | Fix | +| --- | --- | --- | +| Reporting "Studio canvas is broken" when Layer 1 is failing | Layer 4 failures are downstream of every layer below: Studio gets blamed for SDK auth issues | Always report the LOWEST failing layer, not the most visible symptom | +| Confusing Live Preview enable on the stack vs Live Preview install in the app | The stack-level flag controls if the channel exists. The app must also install `@contentstack/live-preview-utils` | Both must be true. In the stack, the flag is under Settings, then Visual Experience, then General. App install is in `package.json`. | +| Confusing Canvas URL (project-level, Studio) with the Custom Preview URL (stack-level, Visual Editor) | Two different settings. Setting one doesn't set the other | Canvas URL is in Studio, under Project, then Settings. Custom Preview URL is in the stack, under Settings, then Visual Experience. | +| Treating Canvas URL as a full origin (e.g. `http://localhost:5173/canvas`) | Canvas URL is the **path only** (`/canvas`). The origin is the environment's per-locale Base URL. A full URL here breaks the composed iframe address. | Canvas URL is the path. The origin comes from the stack, under Settings, then Environments, then `<env>`, as the URL for `<locale>`. | +| Skipping Layer 1 because "the credentials worked yesterday" | Delivery / Preview tokens can be revoked or rotated, and the failure is silent until the next request | Always re-run Layer 1, it takes 2 seconds | +| Marking Layer 4 as "fail" when the canvas is blank because nothing has been authored yet | Empty canvas is normal for a new composition | Check Layers panel: empty Layers = unauthored, fine. Non-empty Layers + blank canvas = real failure. | + +## See also + +- `docs/10-setup/verify-end-to-end.md`: the layered smoke-test reference +- `docs/10-setup/troubleshoot-common-studio-issues.md`: common failures + fixes +- `troubleshoot-canvas`: invoke after this skill if any layer fails +- `install-studio`: Layer 1 and Layer 2 install steps +- `setup-template-preview-routes`: Layer 3 / Layer 4 route mounting diff --git a/skills/studio/verify-visual-parity.md b/skills/studio/verify-visual-parity.md new file mode 100644 index 0000000..7b1c0fa --- /dev/null +++ b/skills/studio/verify-visual-parity.md @@ -0,0 +1,116 @@ +--- +name: verify-visual-parity +--- + +## When to use + +Drive a browser to compare a Studio-composed page against its production hand-coded counterpart, Section-by-Section at matched viewports, and classify every drift by root cause: unbound prop rendering a default, boolean flag the live call site set, prop-routing mismatch, or layout-wrapper drift. Loop until dry, no new drift class two rounds running. + +Use during a brownfield migration once the Studio composition renders end-to-end and the production URL is still shipping. Phrases: visual parity, compare with live URL, drift between composed and hand-coded, screenshot diff, migration QA. Do NOT use for greenfield pages (no production counterpart to compare), for pure design-system audits (that's a code-review, not a Studio task), or as a substitute for `verify-setup` (which checks the plumbing, not the pixels). + +> **Auth preflight: settle the credential before the first API call.** Resolve it OAuth-first per [`authenticate-cma`](authenticate-cma.md): `CS_OAUTH_ACCESS_TOKEN`, else the Contentstack MCP's stored session: Read that skill for where it is, even with no MCP connected. **Never ask the user for a session `authtoken`.** If nothing resolves, or a refresh fails with `400 invalid_refresh_token`, hand them `! CONTENTSTACK_REGION=<code> npx @contentstack/mcp --auth` (it needs a TTY and a browser, so it cannot be run for them) and wait. `403 error_code 316` is a valid credential aimed at another org: fix the org or the `api_key`, do **not** re-authenticate. + +# Verify visual parity: composed vs production, Section by Section + +## Context + +Every brownfield migration ships a Studio composition alongside an existing production URL rendering the same content. The composition **will drift** from the production page in ways nobody decided on purpose: heading typography, item basis, gap, boolean flag defaults, per-CT image fallbacks. Manual screenshot ping-pong ("does this look right?") converges slowly and hides root causes. + +This skill drives a browser, captures the two side by side at matched viewports, and classifies each visible difference into one of four root-cause buckets. The bucket names the fix. The fix goes into the adapter or the registration. + +The four root-cause buckets and their fixes: + +| Cause class | What it looks like | Fix | +|---|---|---| +| **A. Unbound prop rendering a default** | A prop was registered with `defaultValue: 'Resources related title'` (meant as palette preview) but the composition never bound it: the literal ships to the live page | Bind the prop, OR change the registration to have no default (blank in palette is worse UX but honest), OR exclude registration defaults from published output (product gap) | +| **B. Boolean flag the live call site set** | Production JSX says `<Card isInteractive={false} />`: the Studio adapter registered `isInteractive` with `defaultValue: true` (framework default), so composed cards animate on hover while live ones don't | Set the adapter's `defaultValue` to the call-site literal. Better: preset per known call site | +| **C. Prop-routing mismatch** | Production reroutes fields (`copy = short_description ?? description`) before passing. Studio binds a bare path (`copy → description`) and gets the wrong field | Route inside the adapter: accept both fields, choose per-branch. Or expose the raw fields as separate props and let a preset choose | +| **D. Layout-wrapper drift** | Heading class differs (`cs-h2` vs `h1-display-compact`), item `basis` off by one breakpoint, `gap` mismatched, subtitle not composed | Transcribe production wrapper CSS line-by-line into the Studio adapter wrapper. Add `KEEP-IN-SYNC` comment naming both files | + +Every drift falls into exactly one bucket. If a drift resists classification, it's usually two stacked. Split it. + +## Task + +1. **List the Sections.** From the composition (via the canvas or `docs/prompts/build-connected-template`'s output), enumerate the composed Sections in DOM order. Number them 1..N. + +2. **Match viewports.** Pick three: mobile (390 wide), tablet (768), desktop (1280). If the production site has a hard breakpoint the migration must match, add that one too. Every capture happens at every viewport. + +3. **Capture per Section.** For each Section × viewport: + - Navigate the browser to the production URL, scroll the Section into view, screenshot the Section's bounding rect. + - Navigate to the composed URL, scroll the same Section into view, screenshot the same rect. + - Save the pair with a name like `Section-3-tablet-{prod,composed}.png`. + - A composed Section renders no element of its own (Repeater and Slot wrappers are `display: contents`), so crop by band: from the Section's first text to the next Section's. Run this as is (Playwright `browser_run_code`), filling in the URLs and each Section's first text **as the production page shows it**, in order (a text the composed page lacks is then a finding): + ```js + async (page) => { + const sections = ["<Section 1 first text>", "<Section 2 first text>"], missing = []; + const urls = {prod: "<production URL>", composed: "<composed URL>"}; + for (const w of [390, 768, 1280]) for (const [side, url] of Object.entries(urls)) { + await page.setViewportSize({width: w, height: 900}); await page.goto(url, {waitUntil: "networkidle"}); + const tops = await page.evaluate(fs => { let prev = -1; return fs.map(t => { + const ys = [...document.querySelectorAll("body *")].filter(e => !e.children.length && e.textContent.trim() === t) + .map(e => e.getBoundingClientRect().top + scrollY).filter(y => y > prev); + return ys.length ? (prev = Math.min(...ys)) : null; }); }, sections); + const end = await page.evaluate(() => document.documentElement.scrollHeight); + for (const [i, y] of tops.entries()) { const name = `Section-${i+1}-${w}-${side}`; + if (y == null) { missing.push(name); continue; } + const next = tops.slice(i+1).find(t => t != null) ?? end; + await page.screenshot({path: `parity/${name}.png`, fullPage: true, clip: {x: 0, y, width: w, height: next - y}}); } + } + return missing; // a missing first text is a finding, never a reason to take a full-page shot + } + ``` + +4. **Classify each visible drift.** Open both screenshots side by side (`browser_evaluate` to render an HTML page with both images), and for every difference name the cause class (A/B/C/D) and the fix. Record as `{ Section, viewport, cause: A|B|C|D, symptom, fix }`. + +5. **Apply fixes at the source.** Never patch the composed page directly. + - **Class A**: edit the composition (bind the prop) or the registration file (drop the default). + - **Class B**: edit the adapter's registration (set `defaultValue` to the call-site literal) or add a preset. + - **Class C**: edit the adapter body (reroute inside). + - **Class D**: edit the adapter wrapper CSS to match production line-by-line. + +6. **Re-capture the affected Section and re-classify.** Do not move to the next Section until this one is drift-free at all viewports. Localized drift is 10× cheaper to fix than compounded drift. + +7. **Loop until dry.** When a full Section pass at all viewports finds no new drift for two consecutive Sections, you're done. If ANY class-A/B drift keeps appearing (registration defaults leaking, call-site literals missed), stop and run the call-site-sweep step from `register-component` § Acceptance: you're missing a pattern, not fixing individual defects. + +## Inputs needed from the user + +1. Production URL (`https://…/glossary/a-b-testing`). +2. Composed URL (`http://localhost:PORT/glossary/a-b-testing` after `setup-template-preview-routes`). +3. Which viewports matter (default: 390/768/1280 unless the user's design system defines others). +4. Any Sections to skip (e.g. header/footer are usually app-shell, not composed). + +## Acceptance + +- [ ] **Pixel parity is not structural parity.** This skill compares rendered output, and a monolithic template (one component bound to the whole page body, no Sections) matches production exactly and passes every line below. Before reporting a migration done, count `section-composition` nodes per [`build-connected-template`](build-connected-template.md) § Post-build structural check. A request phrased as "make it look exactly like the existing design" routes here, which is why the structural check has to be claimed here too. +- [ ] Every Section screenshotted at every viewport, prod + composed, saved with predictable names. +- [ ] Every visible drift labeled with a cause class (A/B/C/D) and a source-file fix, no "adjust CSS on the composed page" fixes. +- [ ] Every class-A/B fix lands in the adapter or registration, not the composition. +- [ ] Every class-D fix carries a `KEEP-IN-SYNC` comment on both the Studio adapter wrapper and the production wrapper. +- [ ] Two consecutive Sections drift-free at all viewports, which is the signal to stop. + +## Common pitfalls + +| Pitfall | Why it bites | Fix | +|---|---|---| +| Patching the composition (moving/resetting values) to hide drift | Loses the root-cause signal. Next brownfield project re-lives the same fix cycle | Fix at the source (adapter, registration, wrapper CSS). The composition is the outcome of the fix, not the fix. | +| Comparing the whole page instead of Section-by-Section | Drift compounds. The second Section's failure is polluted by the first | Screenshot Sections with a bounding-rect element target. Classify per-Section. | +| Skipping mobile viewport | Peek percentages, breakpoint splits, and container queries only misbehave on narrow viewports | 390-wide capture is mandatory even if desktop looks fine. | +| Measuring inside the canvas iframe at its default width | The preview frame is far narrower than the desktop breakpoint, so a 3-column grid measures as **1 column** and reads as a broken build. The composition is correct. The viewport is not | Set the viewport wide enough to cross the desktop breakpoint before measuring, and compare a comp's desktop numbers only against a desktop-width render. | +| Measuring `querySelector('h4')` instead of the element you authored | The first match is often app chrome or a wrapper, not your node. Two "mismatches" (a 14px heading and a transparent button) were both the wrong element. The authored nodes were correct | Select by the text you wrote (`textContent` match, `children.length === 0`), then read `tagName` + `className` to confirm identity before trusting the numbers | +| Assuming the canvas iframe is as wide as the browser window | It is not: the editor chrome and its own scaling mean browser widths of 1282 / 1600 / 1920 produced frames of 591 / 909 / 1229. A width measured against the browser size is wrong by hundreds of pixels | Measure `document.documentElement.clientWidth` **inside the frame**, and compare content width to that, not to the window. | +| Reporting a width mismatch without checking the gutters | Fluid content differs from the comp at every width except the comp's own. Reading `content 1085 vs comp 1138` as a defect leads to adding a `max_width` that then breaks at real viewports | Verify `content = frame − 2×gutter` holds at two or more widths. If it does, the layout is fluid and correct. See [`decompose-design`](decompose-design.md) § Step 4b. | +| Comparing at wrong scroll position (auto-loaded content) | Lazy-loaded sections render skeleton in one capture, real content in another | Scroll into view + `wait_for(networkidle)` + a short pause before each capture. | +| **Excluding "site chrome" by tag hides your own content** | A parity probe that skips `nav` / `header` to drop site chrome also drops component chrome. A tab bar authored as `<nav>` reported 0 links. An article's `<header>` made its `<h1>` invisible to the probe. Both produced "missing h1 / missing CTAs" reports against pages that were correct | Exclude by position, not tag: `body > header`, `body > footer`, or a known site-shell class. Then confirm the element you think is missing really is absent (`document.querySelector('h1')` with no filter) before reporting it | +| **Counting broken images without `complete`** | `naturalWidth === 0` is also true for an image still in flight, so a lazy-loaded page reports phantom "broken images" that vanish on the next run | Test `img.complete && img.naturalWidth === 0`. Scroll the full page first so lazy images actually load | +| **Hunting horizontal overflow with bounding rects** | An element can push `documentElement.scrollWidth` past the viewport without its own box exceeding it, so a "which element sticks out?" scan returns nothing while the page still scrolls sideways. Three fixes aimed at text wrapping changed nothing before the real cause showed up | Walk every element comparing `scrollWidth > clientWidth` with `overflow-x: visible`. Measured causes, in order of frequency: an embed carrying a fixed authoring width (an `<iframe>` in rich-text body), a list element falling back to the browser's default marker padding because only its sibling type was styled, and a long unbroken token such as a URL. Fix respectively with `max-w-full` on embeds, explicit list styling, and `overflow-wrap: anywhere` | +| **Comparing raw image / section counts against the live site** | A production marketing site's mega-menu and footer contribute dozens of images and `<section>`s. Counted as page content they invent a gap that isn't there: a page can read as a 3x image deficit when the real content gap is a fraction of that | Compare **body text length** inside content elements, which is far less contaminated, and compare asset identity (filenames) rather than counts. Treat count-based ratios as a smell, not a verdict | +| **Trusting a measurement when the dev server is serving a stale build** | A dev server can silently stop recompiling. The edit sits on disk while the page serves the old bundle, so a correct fix measures as "still broken" and invites a second, wrong fix on top of it | Before concluding a fix failed, dump what the browser actually received, e.g. read the rendered element's `className` and look for the class you just added. If it is absent, restart the dev server rather than re-editing the code | +| Chasing individual drift instead of classifying | Endless individual fixes, same bug re-appears three Sections later | Every drift gets a cause class. Two class-B drifts in a row = you missed a call-site sweep pattern. Stop and address the pattern. | + +## See also + +- `adapt-collection-component`: the decomposition this skill verifies. The four cause classes above map to steps 4/6/7 in that skill. +- `register-component` § Acceptance: the call-site sweep step. Class B drift means it wasn't run. +- `use-repeater`: Repeater's `display:contents` wrapper is a common source of layout drift at the leaf level. +- `troubleshoot-canvas`: separate skill. That's for "canvas won't load," this one is for "canvas loads but pixels are wrong." +- `docs/for-developers/migration/` playbook: the codified migration flow this skill's per-Section verification loop plugs into. diff --git a/skills/studio/wire-component-default-data.md b/skills/studio/wire-component-default-data.md new file mode 100644 index 0000000..2d4b021 --- /dev/null +++ b/skills/studio/wire-component-default-data.md @@ -0,0 +1,139 @@ +--- +name: wire-component-default-data +--- + +## When to use + +Add per-prop `defaultValue` to a registered Studio component so it renders visibly the moment an author drops it on the canvas, before any data binding. + +Use when registering (or revisiting) a component that needs placeholder content the instant an author drags it from the palette, before any prop is bound. Also for triaging "component renders blank after drop", empty palette previews, blank array items. Phrases: "set default value", "blank on drop", "palette preview empty". Defaults are UX requirement, not polish. + +> **Mandatory auth preflight: settle the credential before the first API call.** Resolve it OAuth-first per [`authenticate-cma`](authenticate-cma.md): `CS_OAUTH_ACCESS_TOKEN`, else the Contentstack MCP's stored session: Read that skill for where it is, even with no MCP connected. **Never ask the user for a session `authtoken`.** If nothing resolves, or a refresh fails with `400 invalid_refresh_token`, hand them `! CONTENTSTACK_REGION=<code> npx @contentstack/mcp --auth` (it needs a TTY and a browser, so it cannot be run for them) and wait. `403 error_code 316` is a valid credential aimed at another org: fix the org or the `api_key`, do **not** re-authenticate. + +# Wire Component Default Data + +## Context + +Studio renders a component the moment it's dropped on the canvas, long before the author wires any prop to entry data. If the `props` schema passed to `registerComponent` doesn't set `defaultValue` on each visible prop, the canvas shows empty rectangles, missing images, and unlabeled buttons. Authors read that as "broken", not "unbound". + +Reference: `docs/20-bring-your-own-components/set-component-default-data.md`. + +Mental model: + +- `defaultValue` lives **on each prop**, never on the component as a whole. +- Defaults apply only when a prop is **unbound and not explicitly cleared**. The moment the author binds the prop to `template.*`, `contentstack.*`, repeater context (e.g. `repeater.title`), or a static value, the binding wins. +- If the author clears a field (empty string / null), Studio respects the explicit empty: it does **not** fall back to the default. +- `defaultValue` is different from `defaultValueHint`: + - `defaultValue`: real value passed to the component until overridden. + - `defaultValueHint`: placeholder text inside the form input only, never reaches the component. + +Use defaults for **palette preview + pre-binding state**. Use bindings for real authored content. + +## Task + +1. **Open the registration file** at `registrationFile` and locate the `registerComponent({ type: "<componentType>", ... })` call. + +2. **List the props that contribute visible structure**: text, images, choice variants, link labels, repeated items. Behavioral props (callbacks, ids, analytics tags) generally do **not** need defaults. + +3. **Set per-prop `defaultValue`** matching each prop's `type`. Use this template: + + ```ts + registerComponent({ + type: "Hero", + component: Hero, + props: { + headline: { type: "string", defaultValue: "Your headline here" }, + subtitle: { type: "string", defaultValue: "Short description of this section" }, + image: { type: "imageurl", defaultValue: "/placeholder-hero.png" }, + size: { type: "choice", options: ["small", "medium", "large"], defaultValue: ["medium"] }, + cta: { + type: "object", + properties: { + label: { type: "string", defaultValue: "Get started" }, + href: { type: "href", defaultValue: "#" }, + }, + }, + features: { + type: "array", + items: { type: "string", defaultValue: "Feature item" }, + defaultValue: ["First feature", "Second feature", "Third feature"], + }, + }, + }); + ``` + + Rules by type: + - **`choice`** defaults: write an array (`defaultValue: ["medium"]`). It works for single- and multi-select on every SDK version. Multi-select requires it. From `studio-registry` 1.7.0, single-select also accepts the bare value (`"medium"`) and converts it to an array. Before 1.7.0 a bare value renders with nothing selected. + - **`object`** parents do **not** take their own `defaultValue`: each child prop owns its default. + - **`array`** props need two defaults: outer `defaultValue` (initial items the canvas shows) and `items.defaultValue` (each new row added later). + - **`imageurl`** defaults must resolve: ship the placeholder asset alongside the component bundle at `placeholderAssetPath`. + +4. **Use placeholder phrasing, not marketing copy.** Defaults ship as-is if the author forgets to bind. + - Good: `"Your headline here"`, `"Short description of this section"`, `"Feature item"`. + - Bad: `"Buy our amazing product today!"`. + +5. **Default `choice` props to the safest / most common variant** (`size: ["medium"]`, `tone: ["neutral"]`), not the rarest. + +6. **Decide between `defaultValue` and `defaultValueHint` per prop.** If you want guidance text in the form input only (and a blank canvas is fine for that prop) use the hint instead: + + ```ts + title: { + type: "string", + defaultValueHint: "e.g. Welcome to our site", + } + ``` + + Reach for `defaultValue` when the canvas must not render blank. Reach for `defaultValueHint` when blank is fine but the form needs a nudge. + +7. **For complex components (heroes, cards with many regions) pre-populate a coherent stub set** across every visible region so the dropped component reads as a recognizable shape, not a half-rendered skeleton. + +8. **Ship any referenced placeholder assets.** If a `defaultValue` points at `/placeholder-hero.png`, add that file to the project's `public/` (or framework equivalent) and confirm it loads. A broken image is worse than no image. + +## Defaults vs binding: when each applies + +| Situation | What renders | +| --- | --- | +| Prop unbound, no author input | `defaultValue` | +| Prop bound to `template.title` / `contentstack.*` / `repeater.<field>` | Bound value (default ignored) | +| Author cleared the field (empty string / null) | The empty value: Studio does NOT fall back to default | +| `validate` rejects every value | Default still renders, but a validation error shows in the form | + +## Inputs needed from the user + +In this order. If any is missing, ask before editing code. + +1. `componentType`: the `type` string used at registration. +2. `registrationFile`: path to the file calling `registerComponent`. +3. `placeholderAssetPath`: optional, only required if the component has image props. + +## Acceptance + +This skill succeeds only when ALL of the following are true. If any fails, surface the failure and stop. + +- [ ] Every visible prop on the component has either a `defaultValue` or a deliberate `defaultValueHint`. +- [ ] `choice` defaults are arrays (`["medium"]`, not `"medium"`). +- [ ] No `object` parent has its own `defaultValue`. Each child prop owns its default. +- [ ] Every `array` prop has both outer `defaultValue` and `items.defaultValue`. +- [ ] No default contains real marketing copy. +- [ ] Every `imageurl` default resolves (the placeholder file exists in `public/`). +- [ ] Rebuilding the canvas app, refreshing Studio, and dragging the component onto an empty canvas renders every region with placeholder content, verified by inspecting a PNG screenshot of the canvas iframe (a11y snapshots are opaque to iframe contents). +- [ ] Binding one prop to `template.*` replaces the default. Unbinding restores the default. + +## Common pitfalls + +| Pitfall | Why it bites | Fix | +| --- | --- | --- | +| `choice` defaults written as strings | Multi-select, or `studio-registry` before 1.7.0: silently falls back to the first option or blank | Wrap in an array: `["medium"]` | +| `defaultValue` on an `object` parent | Has no effect. Only child props take defaults | Set defaults on each child prop | +| Forgetting `items.defaultValue` on arrays | Initial items render. "Add item" produces a blank row | Set both outer and `items.defaultValue` | +| Confusing `defaultValue` with `defaultValueHint` | Hints never render on the canvas: component appears blank on drop | Use `defaultValue` when canvas must not render blank | +| Real marketing copy as defaults | Authors leave it in place and it ships to production | Use placeholder phrasing like "Your headline here" | +| Expecting defaults to back-fill after a clear | Studio respects explicit empties | If a default is needed, don't let the author clear | +| Image defaults that 404 | Broken placeholder is worse than no image | Ship the asset alongside the bundle | + +## See also + +- `docs/20-bring-your-own-components/set-component-default-data.md`: full reference. +- Pair with `register-component` when you're introducing the component for the first time. +- Pair with `build-connected-template` once the component renders and the author is ready to bind real data in the UI (`author-composition-bindings` for the same by API). +- `wire-slot-data`: when the values a child needs come from the component that owns its slot, not from a static default. diff --git a/skills/studio/wire-external-data.md b/skills/studio/wire-external-data.md new file mode 100644 index 0000000..915f0f1 --- /dev/null +++ b/skills/studio/wire-external-data.md @@ -0,0 +1,134 @@ +--- +name: wire-external-data +--- + +## When to use + +Bring external (non-Contentstack) data into a composition by fetching in the host app and passing via the `data` prop on `<StudioComponent />`, surfaced as Component Default Data. + +Use when a composition needs values outside Contentstack: live pricing, A/B variants, user profile, geolocation, feature flags, URL query params. Works in BOTH CSR and SSR. Phrases: "show data from X here", "external API in composition", "use query params", "feature flag value". Pattern is fetch-then-pass-through `data={...}`, bind under Component Default Data. + +> **Auth preflight: settle the credential before the first API call.** Resolve it OAuth-first per [`authenticate-cma`](authenticate-cma.md): `CS_OAUTH_ACCESS_TOKEN`, else the Contentstack MCP's stored session: Read that skill for where it is, even with no MCP connected. **Never ask the user for a session `authtoken`.** If nothing resolves, or a refresh fails with `400 invalid_refresh_token`, hand them `! CONTENTSTACK_REGION=<code> npx @contentstack/mcp --auth` (it needs a TTY and a browser, so it cannot be run for them) and wait. `403 error_code 316` is a valid credential aimed at another org: fix the org or the `api_key`, do **not** re-authenticate. + +# Wire External Data + +## Context + +Studio compositions natively bind to Contentstack entries, but most apps also need non-CMS data: live pricing, URL parameters, A/B flags, user region, weather. Studio handles this through one mechanism: the `data` prop on `<StudioComponent />`. Whatever object you pass becomes available in the canvas Data Picker under **Component Default Data**, alongside the usual Contentstack roots (Linked Template Entry, Pinned Entries, Contentstack Query, Repeater Data). The host app owns the fetch. Studio just renders. The same pattern works in CSR (Vite/SPA) and every SSR flavour (Next App Router, Next Pages Router, Remix, Astro). Only the fetch location moves. + +Reference: `docs/20-bring-your-own-components/set-component-default-data.md`. + +## Task + +1. **Prereq: `<StudioComponent specOptions={...} />` already renders on this route.** If not, run `setup-template-preview-routes` first: there is nothing to attach `data` to until the renderer is mounted. + +2. **Construct the data object.** It must be **JSON-serializable**: plain objects, arrays, strings, numbers, booleans, null. No functions, no class instances, no Dates that aren't serialized, no React elements. Examples by `dataSource`: + + ```ts + // Live pricing API + const data = { livePricing: await getLivePricing(sku) }; + + // URL query params — Next App Router (server component) + const data = { params: Object.fromEntries(new URLSearchParams(searchParams)) }; + + // URL query params — Next Pages Router (getServerSideProps) + const data = { params: context.query }; + + // URL query params — Vite SPA (client) + const [searchParams] = useSearchParams(); + const data = { params: Object.fromEntries(searchParams) }; + + // User region from request headers + const data = { region: getRegionFromRequest(request) }; + + // Feature flags for the current user + const data = { flags: await flagsClient.getAllForUser(userId) }; + ``` + +3. **Pass `data` to `<StudioComponent />` per `renderPath`:** + + **CSR (Vite / SPA)**: fetch composition with `useCompositionData`, fetch external data via `useState` + `useEffect`, render once both resolve: + + ```tsx + const { specOptions } = useCompositionData(/* ... */); + const [extData, setExtData] = useState(null); + useEffect(() => { + let stale = false; + setExtData(null); // new SKU: don't render the old price + getLivePricing(sku).then((d) => { if (!stale) setExtData(d); }); + return () => { stale = true; }; // a slower old-SKU response is ignored + }, [sku]); + if (!specOptions?.spec || !extData) return null; + return <StudioComponent specOptions={specOptions} data={{ livePricing: extData }} />; + ``` + + **SSR (Next App Router**) server component awaits both in parallel, hands off to a client wrapper: + + ```tsx + // app/[[...slug]]/page.tsx (server) + const [specOptions, livePricing] = await Promise.all([ + sdk.fetchCompositionData(/* ... */), + getLivePricing(sku), + ]); + return <StudioClient specOptions={specOptions} data={{ livePricing }} />; + + // StudioClient.tsx (client) + "use client"; + export function StudioClient({ specOptions, data }) { + return <StudioComponent specOptions={specOptions} data={data} />; + } + ``` + + **SSR (Next Pages Router / Remix**) same shape via `getServerSideProps` / `loader`. Pass `data` through props. + +4. **Bind in the Studio canvas.** Open the composition, **select the component** on the canvas (the Component Default Data root only appears when a component is selected AND that component has registered props in its `registerComponent` schema, verified by Studio tests: `hides Component Default Data tab when component has no props`). In the right panel, open the **Data** tab. Click the binding chip on the prop you want to wire, pick **Component Default Data**, then pick **`<yourKey>.<field>`**, e.g. `livePricing.unit_price`, `params.utm_source`, `flags.checkout_v2`. Save. + +5. **Verify.** Open the route in a normal browser tab. The component renders with the external value. For URL params, change the query string: the bound props update on reload (CSR) or next request (SSR). + +## URL parameters: extra notes + +- Capture from the request, put the value into `data.params`, then bind components to `params.<key>` in the picker. +- **SSR**: read on the server (`searchParams` prop in App Router, `context.query` in Pages Router, `request.url` in Remix loader) and forward into `data`. Do **NOT** call `useSearchParams` in a server component. +- **CSR**: `useSearchParams` from `react-router-dom` is fine. Convert to a plain object with `Object.fromEntries`. + +## Inputs needed from the user + +In this order. If any is missing, ask before editing code. + +1. `dataSource`: where the external data comes from. +2. `dataShape`: sketch of the object so we know the binding keys. +3. `renderPath`: picks the code shape in step 3. +4. `routePath`: the file to edit. + +## Acceptance + +This skill succeeds only when ALL of the following are true. If any fails, surface the failure and stop. + +- [ ] `routePath` fetches `dataSource` and passes the result through `data={...}` on `<StudioComponent />`. +- [ ] The data object is JSON-serializable end-to-end (no functions / class instances / non-serializable values). +- [ ] In SSR, fetch happens on the server. The client wrapper only renders. +- [ ] Opening the route's composition in Studio and selecting a component shows **Component Default Data** as a root in the Data Picker. +- [ ] A prop bound to `<key>.<field>` displays the external value in a normal browser tab, verified by reload/screenshot. +- [ ] For URL params: changing the query string changes the bound prop value. + +## Common pitfalls + +| Pitfall | Symptom | Fix | +| --- | --- | --- | +| Component Default Data root missing in picker | No "Component Default Data" entry when binding | Select a component first. Ensure the component declares props in its `registerComponent` schema (the root is gated on registered props, not on the `data` prop itself) | +| Non-JSON values in `data` | SSR serialization error / hydration crash | Strip functions, class instances, Dates. Pass plain objects only | +| Hydration mismatch on data-driven props | React warning + flicker on first paint | Compute the value once server-side. Do not recompute differently on the client | +| Fetching external data inside a registered component | Component re-fetches on every render, can't reuse elsewhere | Move the fetch to the route, pass through `data` prop | +| `useSearchParams` in a server component | Build / runtime error | Read `searchParams` prop (App Router) or `context.query` (Pages Router) instead | +| Binding chip points at `template.*` or `contentstack.*` | Wrong picker root selected | Re-open picker, choose **Component Default Data** explicitly | +| **Binding a `file` (multiple) field directly to an `imageurl` / scalar URL prop**, renders "No image" or shows the array `.toString()` | A multiple-file field is an asset array, not a scalar URL. Binding to `images` yields the whole array. The prop expects a single string. | Bind to the **first asset's URL**: `entryBind("imageurl", "images.0.url")`. If the prop should iterate (multiple images shown), use a **Repeater** bound to the array and bind `imageurl` to `repeater.url` inside the repeater item. `.0.url` is for single-image props only. | +| **Binding a `file` (SINGLE) field directly to an `imageurl` / scalar URL prop**, `<img>` renders broken / `src` is `[object Object]` / built-in `Image` renders the default placeholder | A single-file field is an **asset object** (`{ uid, url, content_type, filename, title, ... }`), NOT a URL string. Binding `featured_image` to a string-expecting prop yields the whole object. The data binder treats `imageurl` as a string. There's no special unwrap. Built-in `Image` then renders `<img src="[object Object]">` (or falls back to the default-placeholder URL when the asset object stringifies to empty). | **Bind the `.url` subfield**: `entryBind("imageurl", "featured_image.url")`. For the built-in `Image` component (prop `src` typed `imageurl`), bind to `<file_field>.url`, e.g. `featured_image.url`. For custom registered components consuming the asset object, you can also accept the full object and read `asset.url` yourself. A small `assetUrl()` helper handling `string \| { url }` is the safest cross-binding signature. | + +## See also + +- `docs/20-bring-your-own-components/set-component-default-data.md`: full reference. +- `docs/90-reference/composition-rendering-reference.md`: the `data` prop's exact shape + how authors see it in the Data Picker, alongside the other prop (`specOptions`). +- Pair with `setup-template-preview-routes` if `<StudioComponent />` is not yet mounted on the route. +- Pair with `configure-csr-vs-ssr` when choosing the render path for a new project. +- `understand-bring-your-own-data`: this `data` prop vs `<StudioComposition context>` vs `<Slot data>`. +- `render-with-own-data`: when your code (not a Studio route) mounts the composition and supplies the template data. diff --git a/skills/studio/wire-slot-data.md b/skills/studio/wire-slot-data.md new file mode 100644 index 0000000..843fba6 --- /dev/null +++ b/skills/studio/wire-slot-data.md @@ -0,0 +1,119 @@ +--- +name: wire-slot-data +--- + +## When to use + +Wrap a component's `slot` prop in `<Slot data={...}>` so components an author drops into the slot can bind the owner's data via Component Default Data. + +Use when a registered component exposes a `slot` prop AND holds data the dropped component needs (a Hero banner owning its CTA's destination). Phrases: "pass data into a slot", "slot children can't see props", "dropped component needs the parent's value". Do NOT use to declare the `slot` prop (`register-component`) or to carve a Section Slot (`use-section-slot`). + +> **Auth preflight: settle the credential before the first API call.** Resolve it OAuth-first per [`authenticate-cma`](authenticate-cma.md): `CS_OAUTH_ACCESS_TOKEN`, else the Contentstack MCP's stored session: Read that skill for where it is, even with no MCP connected. **Never ask the user for a session `authtoken`.** If nothing resolves, or a refresh fails with `400 invalid_refresh_token`, hand them `! CONTENTSTACK_REGION=<code> npx @contentstack/mcp --auth` (it needs a TTY and a browser, so it cannot be run for them) and wait. `403 error_code 316` is a valid credential aimed at another org: fix the org or the `api_key`, do **not** re-authenticate. + +# Wire Slot Data + +## Context + +A `slot`-typed prop is a region an author fills by dropping components into it. The owning component doesn't know what lands there, so it can't pass props to it: the moment a spot becomes a slot, prop-passing stops working for that subtree. + +`<Slot data={...}>` bridges that. Wrap the slot's value and the components dropped inside can **bind** those keys through the `component_props` data source, with the nearest slot winning over data provided higher up. + +Reference: `docs/25-bring-your-own-data/slot-data.md`, `docs/25-bring-your-own-data/slot-props-reference.md`. + +Reach for it when **both** are true: + +1. A component exposes a `slot`, a spot authors fill with whatever component fits, not one you hard-wire. +2. The owning component holds data the dropped component needs. + +Two situations produce that pair, and the fix is the same for both: you're designing a fresh component and already know one region should be open, or you're opening up a spot that used to hold a fixed child receiving props directly. In the second case the data that child received still has to reach whatever replaces it. + +`<Slot>` is **render-time only** (nothing is stored in the composition) and opt-in: rendering the bare slot value still works, it just carries no data. + +## Task + +1. **Prereq: the component is registered with a `slot`-typed prop and renders it.** See `register-component` § 5a. Exposing extensible regions with `slot` props. Without a registered slot there is no drop target and nothing to wrap. + +2. **List the keys the dropped component needs.** Only values the owner holds and the child can't get itself: a destination URL, an id, a price the owner computed. Name each key by its **role in the slot**, not after the owner's variable: `destination`, not `ctaDestination`. The key names are the contract authors see in the Data Picker. + +3. **Wrap the slot value, the exact `ReactNode` the renderer passed for that prop:** + + ```tsx + import { Slot, type SlotProps, type StudioAttributes } from "@contentstack/studio-react"; + + interface HeroBannerProps extends StudioAttributes { + headline?: string; + ctaDestination?: string; // where the CTA should link — the banner owns this + ctaSlot?: SlotProps["children"]; + } + + export function HeroBanner({ headline, ctaDestination, ctaSlot }: HeroBannerProps) { + return ( + <section className="hero"> + <h1>{headline}</h1> + + {/* `ctaSlot` is an open slot — authors drop any CTA. The banner owns + `ctaDestination`, so it carries it through the slot. */} + <Slot data={{ destination: ctaDestination }}>{ctaSlot}</Slot> + </section> + ); + } + ``` + + Pass the slot prop straight through as `children`. Do not rebuild it (`Children.map` + `cloneElement`, re-wrapping each child in your own element): the renderer stamps slot identity onto that element, and `<Slot>` reads it to report the keys to the editor. Recreated children lose it: the render-time merge still happens, but the Data Picker never lists the keys. + +4. **Keep `data` plain and its keys stable.** Strings, numbers, booleans, arrays, plain objects. The editor report is serialized, so functions and class instances don't survive it. A fresh object literal each render is fine (the SDK skips unchanged reports), but renaming a key breaks every binding already made against it. + +5. **Rebuild the canvas bundle and reload Studio**, so the canvas runs the version of the component that wraps the slot. + +6. **Bind in the canvas.** Open a composition using the component, **select a component that sits inside the slot** (not the owner). In the right panel, open the **Data** tab, click the binding chip on the prop, then open **Component Default Data** and its **Component Props** node, and pick the key. Save. + +7. **For nested slots, rely on nearest-wins deliberately.** `data` shallow-merges over `component_props` inherited from further up, the same model as Repeater context. A key set both globally (the `data` prop on `<StudioComponent />`) and on a slot resolves to the slot's value inside that slot. The deepest slot wins over shallower ones. + +8. **Verify on the live site**, in a normal browser tab: the dropped component renders the owner's value, even though the owner never passes it a prop. + +## Inputs needed from the user + +In this order. If any is missing, ask before editing code. + +1. `componentPath`: the component that owns the slot. +2. `slotPropName`: which `slot`-typed prop to wrap (e.g. `ctaSlot`). +3. `dataKeys`: the mapping to expose, from key to owner value (e.g. `destination: ctaDestination`). +4. `registrationFile`: only needed if the `slot` prop is not registered yet. + +## Acceptance + +This skill succeeds only when ALL of the following are true. If any fails, surface the failure and stop. + +- [ ] `slotPropName` is registered as `{ type: "slot" }` in the component's `registerComponent` props. +- [ ] The component renders `<Slot data={...}>{slotPropName}</Slot>`, the slot prop value passed through unchanged as `children`. +- [ ] Every `dataKeys` value is JSON-plain (no functions, class instances, `Map`/`Set`). +- [ ] Selecting a component **inside** the slot in the canvas shows the keys under **Component Default Data**, then **Component Props**. +- [ ] A prop bound to a slot key renders the owner's value on the live site in a normal browser tab, verified by screenshot or rendered output. +- [ ] The slot still accepts and renders any dropped component when `data` is empty or a key is `undefined`: wrapping changes data flow, not droppability. +- [ ] For a nested slot: the inner slot's value applies inside it, and the outer value applies outside it. + +## Common pitfalls + +| Pitfall | Why it bites | Fix | +| --- | --- | --- | +| Rendering the bare slot value (`{ctaSlot}`) | Dropped components have no way to reach the owner's data | Wrap it in `<Slot data={...}>` | +| Rebuilding children instead of passing the slot value through | Slot identity is stamped on the renderer's element. Recreated children lose it, so the editor never receives the keys | Pass the slot prop directly as `children` | +| Looking for the keys with the owner selected | The Component Default Data source is scoped to the selection | Select a component inside the slot | +| Binding through a CMS or external-data source | The keys only exist under Component Default Data, then Component Props | Select that source explicitly | +| Functions or class instances in `data` | The editor report is serialized. Those keys don't survive | Pass plain values only | +| Renaming a key after authors bound it | Bindings resolve to nothing, silently | Treat key names as a contract. Add a new key instead | +| Keys named after the owner's internals (`ctaDestination`) | Authors read the key in the picker with no owner context | Name by role in the slot (`destination`) | +| Same key in global `data` and slot `data` by accident | Nearest wins, so the slot value silently shadows the global one | Rename, or rely on the override deliberately | +| Using `<Slot>` on a hard-wired child | Props already reach a fixed child. The wrapper adds nothing | Pass props directly. Use `<Slot>` only for author-filled slots | +| Confusing `<Slot>` with the `slot` prop type | Different layers: the prop type declares the region, `<Slot>` carries data into it | `register-component` § 5a declares. This skill wires | +| Confusing this with a Section Slot | A Section Slot is a Smart Container element carved into a Section on the canvas | `use-section-slot` for that flow | +| Canvas not rebuilt after the edit | The canvas runs the old bundle, so no keys appear | Rebuild the canvas app and reload Studio | + +## See also + +- `docs/25-bring-your-own-data/slot-data.md`: the conceptual guide + worked Hero banner example +- `docs/25-bring-your-own-data/slot-props-reference.md`: the full `<Slot>` prop surface and merge behavior +- `understand-bring-your-own-data`: which of the three data surfaces fits +- `render-with-own-data`: the other bring-your-own-data surface (whole composition) +- `register-component` § 5a: declaring the `slot` prop this skill wires +- `use-repeater`: the same nearest-wins context model, for iteration diff --git a/skills/studio/wire-studio-state.md b/skills/studio/wire-studio-state.md new file mode 100644 index 0000000..f805ccc --- /dev/null +++ b/skills/studio/wire-studio-state.md @@ -0,0 +1,179 @@ +--- +name: wire-studio-state +--- + +## When to use + +Wire shared state and app behavior into Studio: declared state variables authors can bind, a custom store (Redux / Zustand / signals / web storage) via `getState`/`setState`, and named functions authors attach to buttons through `action` props. + +Use when a composition needs state shared across components, or an author needs to wire a button to app logic. Phrases: "share state between sections", "cart count in Studio", "use my Redux store", "Bring Your Own State", "BYOS", "author should pick which function the button calls", "action prop", "stateVariable", "getState/setState". Do NOT use for external read-only data passed once into a page (that's `wire-external-data`), for personalization branching (`wire-variant-alias`), or for plain per-prop defaults (`wire-component-default-data`). + +> **Auth preflight: settle the credential before the first API call.** Resolve it OAuth-first per [`authenticate-cma`](authenticate-cma.md): `CS_OAUTH_ACCESS_TOKEN`, else the Contentstack MCP's stored session: Read that skill for where it is, even with no MCP connected. **Never ask the user for a session `authtoken`.** If nothing resolves, or a refresh fails with `400 invalid_refresh_token`, hand them `! CONTENTSTACK_REGION=<code> npx @contentstack/mcp --auth` (it needs a TTY and a browser, so it cannot be run for them) and wait. `403 error_code 316` is a valid credential aimed at another org: fix the org or the `api_key`, do **not** re-authenticate. + +# Wire Studio state + functions (BYOS) + +## Preflight: is BYOS in the installed SDK? Check the EXPORT, not the version + +**Runtime-verified, and the reason a version check is not enough:** public npm's `@contentstack/studio-react` **`1.7.0` does NOT contain BYOS**: zero occurrences of `useStudioState` in its `dist`. `1.9.0` (the `latest` tag on 2026-10-05) does. The SDK monorepo also calls itself `1.7.0` but carries the feature (with `studio-registry` at 1.8.0 / `studio-react-components` at 1.9.0). **Same version number, different contents.** So never gate on a version string. Gate on whether the symbol is actually there: + +```bash +npm ls @contentstack/studio-react --depth=0 # what is installed +grep -rq "useStudioState" node_modules/@contentstack/studio-react/dist \ + && echo "BYOS present" || echo "BYOS ABSENT" +``` + +Do **not** use `node -e "require('@contentstack/studio-react/package.json')"`: the package's `exports` map doesn't expose `./package.json`, so it dies with `ERR_PACKAGE_PATH_NOT_EXPORTED`. `npm ls` works. + +**Availability has two independent halves**: the SDK in your app and the Studio editor. The editor half (declared variables in the Data Picker, the `action` prop picker) is deployed on **dev / non-prod** environments and **not yet on prod** (per the Studio team), so a prod stack can't author BYOS even with a correct SDK build. Check the DC alongside the export: [`analyze-project-fit`](analyze-project-fit.md) § 2b. + +**`BYOS ABSENT` means stop.** Releases up to `1.7.0` are ABSENT; `1.9.0` ships it. This chapter applies only to a build that ships it. Either obtain such a build, or fall back to component-local state (a plain `useState` inside one registered component, no author binding, no cross-component sharing) and say so explicitly. Don't emit `useStudioState` against a package that can't import it. + +## Context + +Registered components give Studio your markup. **BYOS gives it your behavior.** Three independent pieces: + +| Piece | Gives you | API | +|---|---|---| +| **State variables** | Values shared across components / sections / templates, bindable by authors | `state.variables`, `registerComponentStateVariable` | +| **Storage** | Where values live: session, local, or your own store | `storage`, `getState`, `setState`, `subscribe` | +| **Functions + action props** | Logic authors invoke by name from a button | `registerStudioFunctions`, `type: "action"` | + +**Why this matters.** Without BYOS every interactive piece must be a self-contained registered component with behavior baked in: authors re-bind props and nothing else. With it, an author-dropped "Add to cart" calls your `cart:add`, and every component reading `cart:count` updates. Skipping BYOS is the difference between Studio composing your app and Studio decorating it. + +Full reference: [`docs/20-bring-your-own-components/bring-your-own-state.md`](../../docs/20-bring-your-own-components/bring-your-own-state.md). + +## Decide what's actually needed: don't wire all three by reflex + +| Evidence in the app | Reach for | +|---|---| +| Two components must agree on a value (cart count, active filter, theme) | **State variable** | +| The app already owns that state in Redux / Zustand / signals | State variable + **`storage: "custom"`** | +| Value should survive reload / revisit but there's no app store | `storage: "session"` / `"local"` | +| An author should choose what a button does | **Function + `action` prop** | +| Read-only data fetched once for a page (pricing, geo, inventory) | **Not BYOS**, `wire-external-data`'s `data` prop | +| One prop varying per template instance | **Not BYOS**, `expose-section-props` | + +## Task + +1. **Declare the variables.** Only **declared** variables are author-bindable. Undeclared keys work in code via `useStudioState("key")` but never reach the Data Picker. Pick the tier deliberately: + + - **`init` tier**: `state.variables` in `studioSdk.init`, for keys the app owns globally. + - **`component` tier**: `registerComponentStateVariable(key, def)` at component **module scope**, for keys a component owns. + + ```ts + registerComponentStateVariable("cart:filter", { type: "string", defaultValue: "all" }); + + export function CartFilter() { + const [filter, setFilter] = useStudioState<string>("cart:filter"); + // … + } + ``` + + **Precedence (verified in `studio-registry/src/state-store`):** `component` **beats** `init` for the same key, deterministically and **regardless of module evaluation order**. Same-tier re-declaration keeps the **first**. A runtime `set` always beats a declared default. So a component can own its key without coordinating with the init file. + + Types: `string · number · boolean · choice · array · object`. `choice` **requires** `options`. + +2. **Choose storage.** Omitting `state` entirely keeps the SDK's in-memory Map (back-compatible). Providing `state` without `storage` gives the built-in **session** store, unless `getState`/`setState` are present, which implies `custom`. + + | `storage` | Behavior | Requires | + |---|---|---| + | `"session"` | `sessionStorage`, survives reloads, cleared with the tab | - | + | `"local"` | `localStorage`, survives across visits | - | + | `"custom"` | delegates to the app's store | **`getState` + `setState`** | + + `storageKey` (built-in stores only) defaults to `"cs-studio:state"`. + +3. **For `custom`, wire all three functions, and don't skip `subscribe`.** + + ```ts + state: { + storage: "custom", + getState: (key) => store.getState().studio[key], + setState: (key, value) => store.dispatch({ type: "studio/set", key, value }), + subscribe: (key, listener) => store.subscribe(listener), // returns unsubscribe + variables: { "cart:count": { type: "number", defaultValue: 0 } }, + } + ``` + + `getState`/`setState` are mandatory for `custom`. **Without `subscribe` the flow is one-way**: Studio's own writes land, but a `dispatch` from elsewhere in the app never re-renders Studio-bound components. Wire it whenever anything outside Studio mutates these keys. + + A `getState` returning a fresh object/array per call is safe: the hook caches per key and re-reads only on a version change, so it won't trip React's "getSnapshot should be cached" warning. + +4. **Read/write from the right place.** + - In React, use `const [v, setV] = useStudioState<T>(key)`, `setV(next)` or `setV(prev => …)`. SSR-safe: the server renders the declared default and subscribes only on the client. + - Outside React (handlers, analytics, business logic), use `studioState.get / set / subscribe / reset`. Same store instance as the hook and author bindings. + - `getStudioStateSnapshot()` returns `[{ key, value }]` for declared variables, which is what the picker lists. + +5. **Register functions if authors should choose behavior.** `ctx` is `{ get, set, subscribe }` over the same store. Registrations **merge**. Re-registration overwrites with a dev warning (HMR). + + ```ts + registerStudioFunctions({ + "cart:add": (ctx, args) => { + ctx.set("cart:count", (prev) => (prev ?? 0) + 1); + ctx.set("cart:last_added", args?.sku ?? ""); + }, + }); + ``` + + Namespace the names (`cart:add`, `coupon:apply`): the registry is flat and a collision silently overwrites in production, where the dev warning doesn't fire. + +6. **Expose `action` props so authors can attach them.** `action` is a first-class prop type alongside `string`, `number`, `boolean`, `choice`, `href`, `imageurl`, `datestring`, `array`, `object`, `slot`, `json_rte`, `any`, see [`register-component`](register-component.md). + + ```ts + props: { + label: { type: "string", defaultValue: "Add to cart" }, + onCtaClick: { type: "action", displayName: "CTA Click Function" }, + } + ``` + + The component just calls the prop, `onCtaClick?.({ sku })`. The SDK wraps the author's chosen function. Extra positional args are forwarded verbatim, so `onSelect(id, meta)` loses nothing. From your own code, call `callStudioFunction("cart:add", { sku })`. + +7. **SSR: declare on the client before hydration.** Server init declares at module scope, but the **client** init is usually a lazy import that evaluates after hydration, so state-bound nodes hydrate against undeclared keys and mismatch the server HTML. Statically import a module that declares: + + ```ts + // lib/register-state-variables.ts — imported STATICALLY from _app / root layout + registerGlobalStateVariables(studioStateConfig.variables ?? {}); + ``` + + That declares at the `init` tier, so component declarations still win. + +8. **Verify.** Open the Data Picker on a component prop: every **declared** variable appears under Component Props with its current value. Bind one, change it from a second component, and confirm the first re-renders. For `custom`: mutate the key from outside Studio (a dispatch in the console) and confirm the bound component updates. If it doesn't, `subscribe` is missing. For an `action` prop: bind it, click in the canvas, and confirm the registered function ran. + +## Inputs needed from the user + +1. `variables`: the keys to share, each with type + default (and `options` for `choice`). Derive candidates from the app: values already lifted into a store or passed through several components. +2. `storage`: `session` / `local` / `custom`. Default `session` unless the app already owns the state. +3. `getState` / `setState` / `subscribe`: required for `custom`. Ask which store and how it reads/writes. +4. `functions`: named logic authors should be able to attach, if any. + +## Acceptance + +- [ ] **Preflight passed**: the installed `@contentstack/studio-react` actually exports the state APIs (≥ 1.7.0). Nothing was written against a version that can't import them. +- [ ] Every variable authors need is **declared** (init or component tier): nothing relies on an ad-hoc key being bindable. +- [ ] The tier choice is deliberate, and no key is declared in both places expecting init to win. +- [ ] `storage` is set explicitly, and for `custom` both `getState` and `setState` exist. +- [ ] **`subscribe` is wired** whenever anything outside Studio mutates these keys, verified by mutating from outside and watching a bound component update. +- [ ] Declared variables appear in the Data Picker under Component Props. A value set by one component is observed by another. +- [ ] If authors should control behavior, functions are registered **and** exposed via `action` props, not hardcoded in the component. +- [ ] SSR apps statically import a module calling `registerGlobalStateVariables`. No hydration mismatch on state-bound nodes. + +## Common pitfalls + +| Pitfall | Why it bites | Fix | +| --- | --- | --- | +| Expecting an undeclared key in the Data Picker | Only declared variables are author-bindable. Ad-hoc keys are code-only by design | Declare it at the init or component tier | +| `storage: "custom"` without `getState`/`setState` | The SDK can't read or write a store it doesn't know | Both are mandatory for `custom` | +| Custom store without `subscribe` | One-way: external mutations never re-render Studio-bound components. Looks like a stale-render bug | Pass `subscribe(key, listener)` returning an unsubscribe | +| Lazy client init on an SSR app | Client declarations evaluate after hydration, so state-bound nodes mismatch the server HTML | Static import + `registerGlobalStateVariables` (step 7) | +| Assuming init `state.variables` overrides a component declaration | Component tier wins by design, regardless of module order | Component declaration is authoritative. Init covers keys no component owns | +| Interactive components with no `action` props | Authors can't attach behavior: interactivity stays hardcoded and the component is a black box | Add `type: "action"` props and register the functions | +| Unnamespaced function names | Flat registry. A collision overwrites silently in production (the dev warning is dev-only) | Namespace them: `cart:add`, `checkout:apply` | +| Using BYOS for read-only page data | Shared mutable state is the wrong tool for a value fetched once | `wire-external-data`'s `data` prop | + +## See also + +- [`docs/20-bring-your-own-components/bring-your-own-state.md`](../../docs/20-bring-your-own-components/bring-your-own-state.md): full reference +- [`register-component`](register-component.md): where `action` props are declared +- [`wire-external-data`](wire-external-data.md): read-only external data, not shared state +- [`wire-variant-alias`](wire-variant-alias.md): user-state branching via Personalize +- [`expose-section-props`](expose-section-props.md): per-instance prop overrides diff --git a/skills/studio/wire-variant-alias.md b/skills/studio/wire-variant-alias.md new file mode 100644 index 0000000..e236b79 --- /dev/null +++ b/skills/studio/wire-variant-alias.md @@ -0,0 +1,163 @@ +--- +name: wire-variant-alias +--- + +## When to use + +Replace a route-level user-state branch with a Contentstack Personalize-driven render: read variant alias from cookies/headers, pass to `<StudioComponent />` via `variantAlias` query option. + +Use during a hand-coded migration when `migrate-page-to-studio` surfaces a user-driven JSX branch (logged-in tier, persona, A/B, geolocation). Personalize handles audiences + variants. Studio renders whichever alias resolves. Do NOT use for content-driven branching (use Condition Block). Do NOT use for layout-shift needing different sections. That's a separate-template decision. + +> **Auth preflight: settle the credential before the first API call.** Resolve it OAuth-first per [`authenticate-cma`](authenticate-cma.md): `CS_OAUTH_ACCESS_TOKEN`, else the Contentstack MCP's stored session: Read that skill for where it is, even with no MCP connected. **Never ask the user for a session `authtoken`.** If nothing resolves, or a refresh fails with `400 invalid_refresh_token`, hand them `! CONTENTSTACK_REGION=<code> npx @contentstack/mcp --auth` (it needs a TTY and a browser, so it cannot be run for them) and wait. `403 error_code 316` is a valid credential aimed at another org: fix the org or the `api_key`, do **not** re-authenticate. + +# Wire a route-level branch to variant aliases + +## Context + +A common hand-coded pattern is to branch on user state inside the JSX: + +```tsx +{user.tier === "premium" ? <PremiumHero {...entry.hero} /> : <Hero {...entry.hero} />} +``` + +Three things are happening at once: (1) a visitor segment is computed, (2) a component swap is wired into the render, (3) the entry data is reused across both branches. Migration to Studio splits these: + +- **(1) Visitor segmentation** stays in route code. Your cookie/header/middleware logic doesn't change. +- **(2) Component swap** becomes a Contentstack variant alias. Each variant has its own version of the entry. The Studio template renders the variant's overrides on top of the base entry. +- **(3) Entry data reuse** is automatic. Studio merges variant overrides onto the base entry. Unchanged fields fall through. + +The result: ONE template, ONE route, N variants per entry. No code branch in the JSX. No template per persona. + +This skill is for **user-driven branching only**. The variant is chosen based on who the visitor is (tier, persona, cookie, A/B). For **content-driven branching** (the variation lives in the entry's own fields), use [`use-condition-block`](use-condition-block.md) instead. That stays in the Studio canvas, no variant aliases needed. + +Reference: `docs/50-advanced/variant-aliases-deep-dive.md` (full deep-dive), `docs/40-recipes/migrating-hand-coded-pages-to-studio.md` Step 6 (handling edges). + +## Task + +1. **Read `routeFilePath`** and locate the `branchExpression`. Confirm it's a user-driven branch (the condition references runtime state: `user`, `cookies`, `headers`, `experiment`, `persona`). If the condition references `entry.<field>`, STOP. That's content-driven branching. Recommend `use-condition-block` instead. + +2. **Point the user at Contentstack Personalize** to set up audiences + variant aliases. This skill does NOT call CMA, does NOT write to the stack, and does NOT touch entry data. Personalize is Contentstack's product feature for audience-driven variants. Configure it in the Contentstack web app: + + ``` + Open https://app.contentstack.com → your stack → Personalize. + + 1. Create an Experience targeted at the routes you're migrating (e.g. + "<contentTypeUid> personalization"). An Experience is the wrapper + that holds audiences + variant aliases together. + + 2. Define one or more Audiences. An Audience is the visitor-segment + definition (e.g. "logged-in premium tier", "experiment B cohort"). + Personalize lets you define audiences from cookies, query params, + headers, or external signals. + + 3. For each non-base name in your variants list, create a Variant + under the Experience: + - Variant alias UID: <variant> ← match what your route will pass + - Display name: <variant> ← what authors see in Studio + - Targeting: link to one of the Audiences from step 2 + + 4. Publish the Experience. Personalize handles the cookie/header + orchestration server-side; your route only needs to read the + variant cookie/header Personalize sets and forward it to + <StudioComponent />. + ``` + +3. **Per-entry variant overrides** are authored in Contentstack's normal entry editor, in the "Variants" tab of each entry of `contentTypeUid`. Print: + + ``` + Open https://app.contentstack.com → Content → <contentTypeUid> → pick an entry → "Variants" tab. + + For each variant alias you created in Personalize: + - Click "+ Add Variant". + - Pick the alias UID (e.g. "premium"). + - Override the fields that differed in your original JSX branch: + <list the fields the original branch differed on — derived from the branchExpression> + - Leave other fields empty — they inherit from the base entry. + - Save. + ``` + + The skill never touches entries. The user configures Personalize + populates variant content themselves. + +4. **Generate the route swap.** Rewrite `routeFilePath`: + + ```diff + - export default async function ProductPage({ params }) { + - const user = await getCurrentUser(); + - const entry = await fetchEntry(params.sku); + - return ( + - <> + - {user.tier === "premium" + - ? <PremiumHero {...entry.hero} /> + - : <Hero {...entry.hero} />} + - <Body {...entry.body} /> + - </> + - ); + - } + + import { cookies } from "next/headers"; + + import { sdk } from "@/lib/contentstack"; + + import { StudioComponent } from "@contentstack/studio-react"; + + + + export default async function ProductPage({ params }) { + + const variant = (await cookies()).get("variant-alias")?.value; + + // fetchCompositionData THROWS on a miss — wrap it, or an unknown sku 500s + + // instead of 404ing. resolveComposition: configure-csr-vs-ssr. + + const { specOptions, notFound: miss } = await resolveComposition( + + sdk, + + { templateContentTypeUid: "product" }, + + { templateEntryUid: params.sku, variantAlias: variant || undefined }, + + ); + + if (miss) notFound(); + + return <StudioComponent specOptions={specOptions} />; + + } + ``` + + Match the resolver style to `visitorSegmentSource`: + - `cookie:variant-alias`: read from `cookies()` (App Router), `req.cookies` (Pages Router), or `useCookies()` (CSR) + - `header:x-variant`: read from `headers()` + - `searchParam:variant`: read from URL search params + - `custom`: leave a TODO comment where the user wires their own resolver + +5. **Print the before/after diff** for review. Wait for explicit confirmation before writing the file. + +6. **Hand off to the user:** + - "In Contentstack, open Content Models, select `<contentTypeUid>`, open an entry, and go to the Variants tab. Fill in per-variant overrides for the fields the original branch differed on (e.g. for the `premium` variant: set a different `hero.headline`, swap `hero.cover`, etc.)." + - "In Studio, open the template the route maps to, then use the navbar's variant picker to preview each variant against the preview entry. Confirm the layout renders the variant's overrides." + - "Wire your middleware / authentication layer to set the `variant-alias` cookie (or whichever segmentation source you picked) per visitor segment." + +7. **Run `verify-setup`** at the end. Layer 4 should render the template. Switching the cookie value in DevTools should re-fetch the page with the new variant on next request. + +## Inputs needed from the user + +1. `routeFilePath`: required, real path. +2. `branchExpression`: required. If the user pastes a content-driven branch by mistake (`entry.is_premium ? ...`), redirect to `use-condition-block`. +3. `variants`: required. The first name is the base (the entry's default state). Subsequent names are the variants you're authoring overrides for. +4. `contentTypeUid`: required. +5. `visitorSegmentSource`: required. Pre-default to `cookie:variant-alias`. Ask if their app uses a different convention. + +## Acceptance + +- [ ] The skill pointed the user at **Contentstack Personalize** for setting up the Experience + Audiences + Variant Aliases, and made clear it does NOT touch the stack itself, does NOT call CMA, and does NOT need auth tokens or Management Tokens. +- [ ] The skill printed step-by-step UI instructions for scaffolding per-variant entry overrides on one representative entry (via the entry editor's "Variants" tab, the same UI authors use day-to-day). +- [ ] Route file is rewritten with the variant resolver matching `visitorSegmentSource`, and the JSX branch is removed. +- [ ] Before/after diff was shown to the user and they confirmed before write. +- [ ] `verify-setup` ran and Layer 4 (Studio canvas) renders the template against the base entry. +- [ ] User was given the three hand-off tasks: (1) fill in per-variant content in Contentstack, (2) preview each variant in Studio, (3) wire their middleware to set the segment source. +- [ ] If the supplied branch was content-driven (entry-field-based), the skill STOPPED and redirected to `use-condition-block`. + +## Common pitfalls + +| Pitfall | Why it bites | Fix | +| --- | --- | --- | +| Using variant aliases for content-driven variation | Variant aliases are a per-VISITOR mechanism. Using them for entry-shape variation conflates the two concerns and makes future authoring confusing | Content-driven variation belongs in `use-condition-block`. Visitor-driven variation belongs here. | +| Forking the template per variant | One template per variant = N times the structural change cost. The whole point of variants is the same template renders all | Always one template. Variants live on the entry, not the composition | +| Resolving the variant client-side AFTER the server rendered | Hydration mismatch + content-flash. Variant must be resolved BEFORE the SSR fetch | Read cookie/header/middleware before `fetchCompositionData`. Pass `variantAlias` to the fetcher | +| Forgetting to set the variant in the cache key | CDN serves all visitors the variant of the first request | `Vary` on the segmentation header, or include the variant in the URL/cache key, or revalidate per request for variant-aware routes | +| Creating variants on the stack but not on the entry | Studio's variant picker shows the alias but no overrides exist. The canvas renders the base everywhere | Step 3 of this skill scaffolds the entry-override skeleton. Confirm it ran | + +## See also + +- `docs/50-advanced/variant-aliases-deep-dive.md`, full deep dive: A/B, personalization, seasonal variants, cache strategy, edge cases +- `use-condition-block`: for content-driven branching (the variant decision lives in the entry, not in visitor state) +- `migrate-page-to-studio`: the final route-swap skill. This skill handles ONE specific edge that surfaces during migration (user-driven branch) +- `docs/40-recipes/migrating-hand-coded-pages-to-studio.md` Step 6: the recipe section that names this skill as the translation for user-driven branches +- `wire-external-data`: sibling skill for cases where the branch needs external data (not just visitor state) to decide