npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@bettercms-ai/convert

v0.44.5

Published

Deterministic codemod that turns an imported site's text-matched bindings into declared ones — parser offsets in, one magic-string pass out, plus a receipt of everything it could not do.

Readme

@bettercms-ai/convert

Turn an imported site's text-matched bindings into declared ones — deterministically, and with a receipt of everything it could not do.

BetterCMS can already make an imported site editable by matching text: a release reads the built HTML, finds the element whose text equals a field's value, and binds it. That works until someone edits the copy, at which point the match is gone and the field quietly stops reflecting. The fix is for the template itself to say which element holds which field — data-bcms-field — and to read the value from the CMS with the current copy as its in-code fallback. This package does that edit.

Use it

# In your site repo, with the brief from get_conversion_brief
npx @bettercms-ai/convert --brief brief.json --root .
git diff
modify src/pages/index.astro
add    src/bcms-content.ts
add    bcms-content/home.json

10 of 11 paths bound (0 already declared, 1 pending)
  pending /about p-founded-in-2019-in — DIALECT_UNSUPPORTED (src/data/facts.js)

Exit codes: 0 done — the pending list is printed, not hidden; 1 something was refused; 2 only with --strict, when any path is left pending. That last one is for CI.

| flag | | |---|---| | --brief <file> | the brief, as get_conversion_brief returns it. Required. | | --root <dir> | the repository to convert. Nothing outside it is ever read or written. | | --dry-run | print what would change; write nothing. | | --receipt <file> | write the receipt as JSON. | | --strict | exit 2 when any path is pending. | | --overwrite-helper | replace a bcms-content helper whose bytes this tool did not write — including one of ours that has since been edited. | | --verbose | stack traces, and every file considered. | | --xray <dist-dir> | a mode of its own: measure a LOCAL build instead of converting one. |

--xray — what is not editable

A receipt says what the codemod bound. It cannot say what it was never asked to bind, so a site can report full coverage while a quarter of its visible text is unreachable from the editor. --xray walks a build and counts that, per page, with no deploy in the loop:

npm run build && npx @bettercms-ai/convert --xray dist
docs/index.html  visible=3512 unaddressable=701 (20.0%)
     379ch    4n  code @ main  e.g. "const site = { direction: 'rtl', language: 'ar'," | "npm install"
     188ch   12n  span @ main>ul  e.g. "Performance" | "100"

Glyphs, ordinals, the platform badge and skip links are excluded from BOTH halves of the ratio — counting them would put a floor under every route that no amount of binding could lift. --strict exits 2 while any page is above 2 %. It takes no --brief: it measures, it does not convert.

What it actually does

Parser offsets in, one magic-string splice out. The tree is never re-serialised, so your formatting, comments and directives survive and the diff is one a human can read. parse5 reads html, @astrojs/compiler reads astro, @babel/parser reads jsx/tsx, svelte/compiler reads svelte and @vue/compiler-sfc reads vue — five dialects, one neutral tree, one matcher.

Svelte and Vue loop in the template, and every lane reads it. Astro and JSX loop in code ({rows.map((row, i) => …)}); Svelte writes {#each rows as row, i} and Vue v-for="(row, i) in rows". Their parsers hand those loops over beside the tree, so the data, entry, config and drilling lanes below bind a Svelte or Vue loop as they bind a .map, and write in the file's own grammar: attr={expr} and {@html …} in Svelte; :attr="expr", {{ … }}, v-html and v-bind="obj" in Vue, where a string written inside a quoted attribute is single-quoted. An index a loop lacks is added as , i (row in rows becomes (row, i) in rows). Imports go at the top of <script> / <script setup>, which is opened for a component that has none.

{#each features as feature, i}
  <h3 data-bcms-field={`features[${i}].title`} data-bcms-kind="text">{feature.title}</h3>
{/each}
<h3 v-for="(feature, i) in features" :key="i" :data-bcms-field="`features[${i}].title`" data-bcms-kind="text">{{ feature.title }}</h3>

The helper lives where the framework keeps its code: src/bcms-content.ts (Astro, JSX), src/lib/bcms-content.ts (SvelteKit), composables/bcms-content.ts (Nuxt), and app/composables/bcms-content.ts in a Nuxt 4 app whose code is under app/. The snapshots stay in bcms-content/ at the repository root, where the platform writes them.

Values are read at BUILD time, page-scoped. A converted page statically imports bcms-content/<page>.json and calls a ~30-line helper this package commits. Every bundler resolves that at build time — no fs, no fetch, no key, and only that page's content can reach its bundle. The committed stubs are {}, so a build with them renders every fallback and still succeeds; the platform overwrites them before a real build. The consequence is worth stating plainly: outside the visual editor, seeing an edit means a rebuild.

Three tiers per file, and a file is exactly one of them. The dialect's parser; failing that, a tolerant pass that will only place a literal occurring exactly once between a > and a <; failing that, an injected llmFallback whose output is accepted only if it re-parses and declares exactly the paths that were asked for. The CLI injects none — the agent running it is tier 3.

A prose file is a template only where a framework renders one. .md, .mdx, .njk, .hbs, .ejs, .liquid, .erb and .twig are opened under a directory named pages, content, src, app, routes, layouts, components, templates, views, _includes or _layouts — and never at the repository root or under docs/. A README quoting the hero line is documentation ABOUT the site, and because a path counts as rewritten only when every located occurrence was, one of them pended 176 of 735 paths on a real repository with a reason naming a file nobody would ever want bound.

A scalar module literal is converted too. const tagline = "…" rendered as {tagline} becomes const tagline = bcms(page, "<path>", "…"), with the declaration on the element that renders it — a literal path, because there is no index to template. Refused as IN_EXPRESSION when the identifier is rendered more than once, used in an attribute as well, or transformed on the way: moving the declaration to a CMS read changes the value everywhere that identifier is used.

Copy that lives in a data literal is converted too. deriveSchema reads the BUILT page, so four cards arrive as cards[0..3]; the repository renders ONE element from a frontmatter (astro) or module-scope (jsx/tsx) array through .map. The declaration becomes

const features = bcmsRowsAs(bcmsHome, "cards", [ …the original array, verbatim… ],
                            { title: "h3-field", body: "p-field" });

— the rows come from the CMS and are renamed onto the template's OWN property names, so feature.tone, feature.link and every other property the brief never heard of keep rendering (bcmsRowsAs merges each CMS row over the row the template shipped with, and a row an editor adds inherits row 0's styling). Each row element then declares data-bcms-field={`cards[${i}].<leaf>`}, and the callback gains an index parameter if it had none. An imported data module stays IN_DATA_FILE.

A list kept in another file is read at the page's loop. An array a page imports from a data module (import { features } from "../data/site", SvelteKit's import { features } from "$lib/content") or a Nuxt page gets from an auto-imported composable (const features = useFeatures(), const { team } = useSiteData(), from composables/ or app/composables/) is matched by its rows exactly as a local one, and read where the page iterates it: {#each bcmsRowsAs(bcmsHome, "features-cards", features, { … }) as feature, i}, v-for="(feature, i) in bcmsRowsAs(…)". The module itself never changes. SvelteKit's $lib and Nuxt's ~/@ are the framework's aliases, read when the project's tsconfig extends the generated one. Words a list in that module shares with another route's list (a team member who is also a post's author) belong to the route whose page iterates it.

An array of PRIMITIVES or TUPLES is converted through its paths, not through its name. The same shape one rung simpler: const deliverables = ["…", "…", "…"], or const faqs = [["q", "a"], …], iterated by .map with the callback rendering the item (or its destructured positions) on one element each. There is no row object to key, so every element's path is passed to the read:

const faqs = bcmsTuples(bcmsLanding, [["faqs[0].dt-field", "faqs[0].dd-field"], …],
                        [ …the original array, verbatim… ]);

bcmsList is the same for a list one wide. Each rendered element declares data-bcms-field={`faqs[${i}].<leaf>`} — the same templated path a repeater writes, so a second run reads it back and changes nothing — and the callback gains an index parameter, and a jsx row its key, if it had none.

These arrays render exactly the paths this run wrote. Unlike bcmsRows, which reads the array itself, bcmsList/bcmsTuples map over that fixed path list — so every row's TEXT is editable and the NUMBER of rows is still the template's: a row added in the CMS has no path and never appears, and a row deleted falls back to the copy the template shipped with. The receipt says so out loud, once per array, as a PRIMITIVE_LIST note. Reading the array itself is the derive lane's job, not a fourth shape here.

The lane refuses, by name: IN_EXPRESSION when the identifier is used anywhere but that one .map or is exported (the read replaces the value everywhere, including in files this run cannot see), when the callback renders the item inside a larger expression, when an element is not a string literal, when one tuple position is text in one row and richtext in the next (one element has one sink), when the brief's paths do not cover EVERY element of the array, and when the paths are unrelated scalars rather than one group's rows — that last one could only be declared as an expression nothing downstream can verify; REPEATER_AMBIGUOUS when two loops iterate the array.

An array literal written INLINE in the markup is the same lane, with no name between the two halves. {['Offer', 'Structure', 'RTL'].map((item) => <li>…</li>)} is what a template reaches for whenever the list is four words long — nobody lifts four bullet points into a const — and the copy then sits inside an expression the matcher is blind to by construction. The ] immediately before the .map( IS the literal's close, so there is exactly one loop over it and nothing else in the file can hold a second reference to it: no "used anywhere but that one .map" test is needed, and everything else — the paths, the templated declaration, the index parameter, the refusals — is the named lane's. A value rendered BESIDE something else (<li><span>✓</span>{item}</li>) gets a <span> of its own rather than declaring on the parent, because a publish writing the value over the parent deletes the icon; the same wrap the scalar lane writes for a mixed element.

A row's leaf handed to a component is drilled, exactly as a scalar prop is. demos.map((demo) => <DemoCard title={demo.title} />) renders the value in the COMPONENT's file, so the page's call site declares bcmsBindings={{ title: `cards[${i}].title` }} — one object per element, merged when a row hands two leaves to one component — and the component's element gets data-bcms-field={bcmsBindings?.title} with the prop added to its destructuring and its props type. {...row} counts as passing the prop: it is how most templates hand a row over. The group still binds whole or not at all, so a drill that misses (the component cannot be resolved, nothing in it renders the prop, this conversion already rewrites that file, or the call site already carries a bcmsBindings) refuses the WHOLE group by the drill's own reason.

A router's link component is an anchor. <Link> and <NavLink> imported from react-router(-dom), next/link, @remix-run/react or @tanstack/react-router, and Vue's global <NuxtLink> / <RouterLink>, render one <a> carrying the attributes they are given: their words bind on them as an <a>'s would, and their to is the href (data-bcms-props="…|href", the to attribute rewritten in place). A nav the layout maps from its own array — links.map((l) => <NavLink to={l.to}>{l.label}</NavLink>), <NuxtLink v-for="item in nav"> — binds row by row like any chrome array, and a framework layout (+layout.svelte, layouts/*.vue, a React app's layout route) counts as rendered on every route. A local component that happens to be called Link is followed into its own file, as any component is.

An image is bound through its import, not through its url. An image's original is the name the bundler minted (/_astro/hero.do77EGgx_ZNfVA8.jpg) — it exists in no file, and the asset itself is not a source candidate, so a text search can never find it. What survives the build is the BASE NAME, and the page says it itself: import hero from "../assets/hero.jpg". The element rendering that binding — <img>, <source>, <video poster>, or <Image>/<Picture> from astro:assets, which forward unknown props to the <img> they render — gets src={bcmsImage(page, "<path>", hero)} and the declaration beside it.

Everything it will not do has a name, and the name is TRUE. AMBIGUOUS_LITERAL when two different paths share a sentence and position cannot separate them (both are skipped; the rest of the file still converts), SUBSTRING_ONLY, IN_SCRIPT_OR_COMMENT, KIND_MISMATCH, REPEATER_AMBIGUOUS (one array declaration, two loops over it — the row has two homes), SUBTREE_NOT_HTML (an INLINE richtext heading whose element holds a component, a <slot> or an expression: its whole subtree is the value and no stored string can re-emit that), IMAGE_ASSET_UNRESOLVED, PARSE_ERROR. NOT_IN_SOURCE is reserved for what it says: a path locate found in a file is IN_EXPRESSION when it sits in the module region or an expression node, and where NOT_IN_SOURCE is still the answer the row names the file it was found in. And the receipt's arithmetic is checked, not hoped for:

paths.declared === paths.rewritten + paths.alreadyDeclared + paths.pending.length

What pending means — every row carries the edit, not the diagnosis

A reason is a diagnosis and nobody can act on one. So every paths.pending[] row also carries a fix: the file, the 1-based line and column, the bytes that are there now, and ONE action out of a closed set. That is what makes "run it again until nothing is pending" a loop an agent can actually finish — and what get_next_steps prints, one line per path.

{ "route": "/about", "scope": "page", "path": "p-founded-in-2019-in", "kind": "text",
  "reason": "DIALECT_UNSUPPORTED",
  "fix": { "file": "src/data/facts.js", "line": 3, "col": 25,
           "snippet": "export const founded = \"Founded in 2019 in Bristol.\";",
           "action": "bind-data",
           "why": "src/data/facts.js holds this copy as data — bind the element that renders it." } }

| action | what to type | |---|---| | wrap-span | the value shares its element with something else, or is rendered in a position no attribute reaches — give it a <span data-bcms-field="…"> of its own. | | declare-attr | the element is there and nothing said which path it is — add data-bcms-field to it at file:line. | | declare-richtext | the container is fed markup (set:html, dangerouslySetInnerHTML) — bind it with the richtext helper. | | bind-expression | the value is computed in the markup (a ternary, a template literal) — read the path there instead. | | bind-data | the copy lives in a data module, a frontmatter array or a content-collection entry — bind THAT field, and the element that renders it. | | manual | none of the above is true; why says what a human has to decide. |

Reason → action, and the two places the answer is not the obvious one:

| reason | action | |---|---| | AMBIGUOUS_LITERAL, one route | declare-attr — why names every candidate file. | | AMBIGUOUS_LITERAL, several routes | manual — one element cannot carry N routes' bindings. This is SHARED CHROME the derive lane did not promote: a Layout field is one value for the whole site, and an attribute here would bind one route and silently freeze the rest. | | IN_EXPRESSION in the markup | bind-expression, or declare-richtext for a richtext value. | | IN_EXPRESSION in the frontmatter or a <script> | bind-data — the copy is a row in the file's own data, and the array is what gets bound, not the {l.label} that reads it. | | DIALECT_UNSUPPORTED on .md/.mdx | bind-data — a content-collection body is the entry's document field. | | NOT_IN_SOURCE / NO_ORIGINAL | bind-data when the copy is in a data module, else manual: the built page formats it and the source holds the input. | | PROP_TARGET_NOT_FOUND, PROP_DRILLED_DEEP, SUBSTRING_ONLY | wrap-span. | | ATTRIBUTE_READ_UNDECLARED | manual — the read is written, but the component does not hand the link to the element that renders it (no props {...rest} on that element), so nothing declares it for the canvas: spread it there, or declare data-bcms-props on that element. | | LAYOUT_PATH_UNKNOWN | manual — a bcmsLayout("<path>") read names no Layout field the brief lists (or one a list of this run replaces). Not a brief path: out of the arithmetic, like the canvas rows. | | ICON_SET_HTML | manual — a list row's icon is svg markup written with set:html (in the page or the component it is handed to): no element of its own carries an address. Render it as an svg component or an <img>. | | ICON_NOT_FORWARDED | manual — the component that draws a list row's icon spreads its props rest on a wrapper, or nowhere, so an address on the call would land on the wrong element. Pass the icon as src and spread the rest on the <svg>/<img> itself. | | REPEATER_AMBIGUOUS, REPEATER_FIXED_LENGTH | declare-attr on the row's own element. | | IMAGE_ASSET_UNRESOLVED, IN_DATA_FILE | bind-data. | | SUBTREE_NOT_HTML | declare-richtext. | | KIND_MISMATCH, IN_SCRIPT_OR_COMMENT, DYNAMIC_PARAMS_UNAVAILABLE, BRIEF_META_UNPLACED, PARSE_ERROR, TIER3_UNVERIFIABLE, CANVAS_BRIDGE_MANUAL, SSR_DRAFT_ROUTE | manual, with the reason quoted in why — read the row's message for what blocked it. | | CODE_OWNED, CROSS_REGION, SHARED_FILE_PAGE_SCOPE, ONE_ROUTE_ON_TEMPLATE, SEPARATE_LEAVES | manual — the re-run guards; see "Running it on a converted repository". CODE_OWNED needs nothing: the meter leaves it out. |

A path resolves only in a file its route renders. locate finds copy by its words, and words repeat across routes: home's "Our story" button and the about page's <h1>Our story</h1>, the nav's "Blog" and the blog index's <h1>Blog</h1>. A PAGE path keeps only its own route's page file and the components that page renders, never another route's page, a component only other routes render, or the chrome (a framework layout, a React app's router and the layout routes it draws); a LAYOUT path keeps only the chrome and the components wherever one of them holds its words, never a route's page file. Which file is which route is the router's answer: the file convention, or a client-rendered React app's <Route path element> (so src/pages/About.tsx is /about). A path left with no file is NOT_IN_SOURCE, its message naming the files that hold the words as another route's. A router's own definitions (<Route>, <Navigate>, <Redirect>) are never bound: their attributes are the app's URLs.

The document head is SEO, which is native. Nothing inside <head>, <title>, <meta>, <svelte:head>, next/head's <Head> or Nuxt's <Head>/<Title>/<Meta> is ever bound, nor counted as a fragment of a path: <title>Blog | Harbor</title> used to be a SUBSTRING match that outranked the heading's real declaration. A file this package wrote (its helper, the snapshots, the forms helper and form runtime, the componentize lib) is never searched for copy either.

The proof's sha needs a commit. proof.sha names the commit the static proof ran on, and is null on a dirty tree or outside git; the CLI says which: commit and run again, or, for a site that is no git checkout at all (an upload), git init && git add -A && git commit -m baseline first.

A file that talks ABOUT the site is not a source file. A path is rewritten only when every located occurrence was, so a test asserting on the hero line, or a build config quoting the font stack, used to hold an already-declared path pending. *.test.*, *.spec.*, __tests__/, __mocks__/ and root-level *.config.* are never opened, alongside node_modules/ and dist/. A data module under src/ — src/site.config.ts — is not a build config and stays bindable.

--xray and the receipt exclude the same things. The xray measures a BUILT site's visible copy and the receipt measures the brief's paths in SOURCE, so they never have the same denominator — but neither counts a decorative glyph, a <script> or a <style> as copy (isDecorativeGlyph is the one reader), and the two lane rows CANVAS_BRIDGE_MANUAL and SSR_DRAFT_ROUTE ride paths.pending without entering the arithmetic above. A path the receipt calls pending is copy the xray will still count as unaddressed; a path it calls rewritten is not.

A path counts as rewritten only when EVERY located occurrence of it was. A value rendered by both the home page and a shared header is two places a reader sees it, and converting one of them reads as a finished job.

It also decides the live-preview lane. A Next page already rendering through <BcmsBlocks> gets its import moved to @bettercms-ai/next/draft-bridge and its element rewritten to <BcmsDraftBridge slug={…} …/>, so the visual editor can re-render your DRAFTS with your own components instead of falling back to the platform's approximate renderer. The slug is never guessed — it comes from the component's own scope (slug, params, page) or a route literal the brief can name, and anything else is CANVAS_BRIDGE_MANUAL with the file and line, because a bridge on the wrong slug is a page that silently never updates. Astro gets SSR_DRAFT_ROUTE and the recipe: use <BcmsLiveBlocks client:load> from @bettercms-ai/astro, or render those routes on a server. The verdict is canvas: { lane, files? } on the receipt; the two lane reasons ride paths.pending but are outside the arithmetic above — they are facts about the site, not unbound fields.

A declaration this package did not write still counts. An element that names the target — data-bcms-field, data-bcms-layout-field or an address in data-bcms-props — but gets its copy some other way (a literal somebody left there, the project's own data module) is DECLARED: the publisher injects into that element and the editor makes it click-to-edit. It is simply not reading through the committed helper. Those land in alreadyDeclared with a receipt row of their own, readsFromCms: false, rather than in NO_ORIGINAL:

"declarations": [{ "route": "/", "scope": "page", "path": "hero.title",
                   "kind": "text", "file": "src/pages/index.astro", "readsFromCms": false }]

readsFromCms is true only when the declaring element reads its value through this package's helper (bcms(…) and friends); false is not proof the site does not read from the CMS — a snapshot read through the project's own helper is false too.

The evidence may never be ANOTHER ROUTE'S PAGE FILE — one the brief itself names, since routeOfFile reads the repository's convention and the two legitimately disagree about a dynamic route — and it may never be an element two routes both claim. The same rule gates a declaration that DOES read from the CMS: a home page rendering a teaser of /about's headline reads /about's snapshot legitimately, and counting it left /about's own page hardcoded and unreported. Without a read there is nothing in a declaration that says which route it is for, and two routes legitimately carry hero.title — so "some file declares that string" would mark the other route bound and leave it hardcoded forever. A path with no original at all is stricter still: the only evidence available is the file's identity, so it has to be the one file the router says serves that route. A layout path has no route by construction, and any file may carry it.

A tsconfig is JSONC. Comments and trailing commas are read (that is what tsc --init writes), because a config this package cannot parse declares no paths aliases as far as it is concerned — and then every @/components/Hero resolves to nothing and the refusal that follows names the wrong cause. Its alias fields are shape-checked too: "paths": "src/*" is valid JSON and the wrong type, and reading it threw a TypeError out of a pure function naming nothing. One it still cannot read is a receipt NOTE rather than a silence — on the componentize receipt as well, which resolves a page's <Hero /> through exactly those aliases:

"notes": [{ "code": "TSCONFIG_UNREADABLE", "file": "tsconfig.json", "message": "…" }]

The address grammar, in one place. Page copy is data-bcms-field="<path>" plus data-bcms-kind="<kind>". The shared chrome is a DIFFERENT attribute carrying a prefixed address, data-bcms-layout-field="layout:<section>:<field>", so the block lane's own [data-bcms-field] query cannot reach a chrome marker. A value that lives in an ATTRIBUTE (a promoted nav link's href) has no field attribute of its own and rides data-bcms-props="<address>|<kind>|<attr>;…" — where <address> carries the same layout: prefix when it is chrome. A section root additionally carries data-bcms-block="<blockId>", which is what the editor hit-tests for section chrome and reorder — and inside an extracted section that root's own placement decides which lane its elements address: the page path while the copy is the page's, data-bcms-field="<blockId>__overrides.<key>" once the copy is the instance's. The page-lane address is re-based onto that placement's group: the markup was cut from one page and carries its paths, and a second page renders the same component bound to a group of its own, where hero.title is a field that does not exist. One data-bcms-field per element, always. Two would leave the publisher and the editor reading different attributes off one node, and which of them won would depend on the dialect — JSX takes the last, Astro the first.

As a library

import { convertSources } from "@bettercms-ai/convert";

const { files, receipt } = await convertSources(brief, sources);

Pure: no filesystem, no network, no credential, no clock. cli.ts is the only module that touches a disk. That is what lets the interesting half — which element holds which path, and why one could not be found — be tested without a repository.

A repeater becomes a loop, not three stamps. Three identical siblings the derive lane called cards[i].* are rewritten as one row inside the dialect's loop, reading bcmsRows(page, "cards", [ …every row's copy… ]) — so a card added in the dashboard renders, and a build against the committed stub still renders the three the repository has. The bindings there are template literals (data-bcms-field={`cards[${i}].title`}), the receipt lists each one, and the proposer re-derives them from its own scan rather than believing the receipt. A group that cannot become a loop — non-consecutive siblings, one row shaped differently, the html dialect — is still declared and read per index and reported REPEATER_FIXED_LENGTH.

A row that holds a list of its own becomes a loop inside the loop. cards[i].items[j].label is a nested .map in astro and jsx, reading its rows off the ROW — bcmsRows(card, "items", […]), the same helper one level down, because bcmsRows passes a nested array through untouched and bcmsSection hands a section its group whole. The binding is data-bcms-field={`cards[${i}].items[${j}].label`} and the shape is cards[*].items[*].label. ONE level: that is what the plan mints and what the component prop validator accepts, so a leaf indexed twice keeps the length the template gives it. One inner row is enough to make the loop — the plan already says items is an array, and reading a card that ships a single bullet as "not a list" is how the second bullet an editor adds never renders. The html dialect has no loop form at all, so it stays fixed-length there — every index declared, the number of rows the template's.

A prop-drilled literal is converted in two hops, in one proposal. <Hero title="…" /> becomes title={bcms(…)} plus bcmsBindings={{ title: "hero.title" }}, and the <h1>{title}</h1> inside Hero — resolved through the file's own imports, relative or through the project's tsconfig paths — gets data-bcms-field={bcmsBindings?.title}, the prop added to its destructuring, and bcmsBindings?: Record<string, string> added to its Props type when it has one. A chain through intermediate components is followed for four components — including a {...props} spread, which is how most of them forward — with the object handed over explicitly at each call site; deeper is PROP_DRILLED_DEEP. Which component is analysed comes from the IMPORT, so a module exporting a Hero and a HeroSkeleton gets the declaration on the one the page renders. If the component cannot be resolved or nothing in it renders the prop, hop 1 still reads from the CMS, the bindings prop is DROPPED, and the path is PROP_TARGET_NOT_FOUND.

The shared chrome is one field, not one per route. A path the brief marks scope: "layout" is read with bcmsLayout("<address>", "…") and declared on data-bcms-layout-field — the chrome lane's own attribute, so the block lane's [data-bcms-field] query cannot reach it — and it is counted once however many pages list it.

A header's menu is one list. An astro header or footer that maps its own literal (const menu = [...]) reads it as ONE Layout list in kit shape — bcmsLayoutRows("navigation:items", menu, { … }).map((item, i) => …), footer:groups for a footer's columns — when the brief pinned its rows to fields that do not line up as one path and the brief asks for lists (layoutLists: true, which the server sets for a new import; without it the lane writes its table as before). Each leaf declares its row address in sub-field ids, each carrying its list's id as a prefix (layout:navigation:items[${i}].items.links[${j}].items.links.label, the spelling the backend resolves; src/__tests__/fixtures/layout-address-vectors.json), standalone links right after the loop fold in as kind: "link" rows, and the receipt's layout.lists tells the server the list's shape, rows and the fields it supersedes.

A dynamic route reads its own entry. [slug].astro appends Astro.params.slug, SvelteKit $page.params.slug (importing the store), Nuxt useRoute().params.slug. A Next [slug]/page.tsx gets a TYPED { params }: { params: Promise<{ slug: string }> } on its page component, is made async if it is not already, and reads (await params).slug — because params is a promise, and reading .slug off one is undefined on every request. A "use client" page cannot be made async and a signature this cannot edit is not guessed at: both are DYNAMIC_PARAMS_UNAVAILABLE and are left alone. A repeater on a dynamic route passes the same parameter to bcmsRows.

Componentize

The second mode. P2 above makes a page's copy editable in place; --componentize makes its SECTIONS movable — each band becomes a component the CMS can reorder, duplicate and swap. Where the copy lives is a separate question, and the plan answers it: Phase A leaves it in the page's field groups (props.bind), Phase B moves it onto the instance (props.overrides). The same extracted files serve both.

The division of labour is the one that already works by hand: the CMS owns STRUCTURE, the repo owns RENDERING. The server decides which field groups are sections (get_componentize_plan → componentize_sections); this decides which bytes render them.

# after get_componentize_plan and componentize_sections
npx @bettercms-ai/convert --componentize --brief brief.json --plan componentize.json --root .
git diff
add    src/components/bcms/Hero.astro
add    src/components/bcms/Features.astro
add    src/components/bcms/FAQ.astro
add    src/components/bcms/Sections.astro
add    src/lib/bcms-sections.ts
modify src/pages/index.astro
modify bcms-content/home.json
add    bcms-content/components.json

5 sections extracted (0 registered, 0 inline-only, 0 already extracted, 0 pending), copy: bind

--plan <file> is required and is only read in this mode. --dry-run, --receipt, --strict and --overwrite-helper mean what they mean above; --strict exits 2 on any pending SECTION.

--copy bind|instance (default bind) says which copy model the plan is expected to describe. It is an assertion, not a switch — the extracted files are byte-identical either way — and it exists to catch one order-of-operations mistake that would otherwise be silent: running --copy instance in the repository BEFORE the server has moved the copy leaves a tree that looks converted and renders every section out of page fields the dashboard is about to hide. A plan whose placements all still carry bind says so, and every section is refused COPY_MODE_MISMATCH with nothing written. The receipt carries the mode as copy, the same word the server's receipt uses.

What is written

  • src/components/bcms/<Name>.<astro|tsx> — the section root's markup verbatim, P2's bcms(...) reads and its page paths included, plus data-bcms-block={blockId} on the root (the editor hit-tests that for section chrome and reorder; a root that already carried one has it REPLACED — and any further ones removed — never joined). Its copy comes from bcmsSection(page, bind, overrides) in the committed helper — overrides[key] ?? pageGroup(bind)[key] ?? the fallbacks the template already carries. The placement's own bind, with no synthesized default: bind ?? "<groupKey>" belonged to Phase A, where every placement was bound and the default could only restate what the placement already said; under Phase B it put the page's field group back underneath an instance that owns its copy, so a key the instance had not been given rendered stale page copy out of fields the dashboard has already hidden. A placement that names no group has none, and every bcms(...) renders what the template shipped with — and each of P2's bindings is wrapped in the placement's own chooser, bcmsFields({ blockId, bind, overrides }, "<prefix>"), so the page path is still verbatim in the file and the ADDRESS is decided when the component renders. Both names it introduces are allocated (bcmsField_1, and so on) against the page's own top-level bindings and against every identifier-shaped token in the bytes being lifted — all of them, since a section using bcmsField and bcmsField_1 would otherwise just move the capture one suffix along — because a page that declared bcmsField would otherwise have every use of theirs inside the extracted section silently rebound to our chooser — and in a TSX page that declaration lives inside the component function, where a module-level check cannot see it. Reserving the name puts the reference back on the honest path, which is SECTION_FREE_IDENTIFIERS naming the identifier that could not travel. One component serves every page whose group has the same shape, which is what the plan's shapeHash is for — and one component serves both copy models, which is what makes moving a page's copy a data change rather than another cut.
  • src/lib/bcms-sections.ts — pageSections(pageSlug) over the committed snapshots' blocks plus bcms-content/components.json, returning [{ blockId, slug, bind, overrides }]. An instance naming a component this repo does not have is skipped, never a hole.
  • src/components/bcms/Sections.<astro|tsx> — the registry, slug → component. When the CMS list is empty it falls back to the order the repository shipped, and that is load-bearing: an empty blocks is what a fresh clone, the committed stub and every not-yet-componentised page all look like, so rendering nothing there would blank the site on the first build after the codemod ran, with nothing in the diff to explain it. The fallback is decided on the raw block list: a page whose blocks all name components this repo does not have has still been arranged, and putting the shipped bands back would undo an editor's work on the next build. A shipped placement carries bind only while the plan says its copy is the page's: an instance-owned one has none, because binding it there would make a fresh clone render a section out of fields the dashboard has already hidden.
  • Nothing at all, for a section the repository has ALREADY componentised. A page that renders <Hero /> from a file it imports has made this cut itself: the existing file is REGISTERED under the section's slug — the registry imports it where it lives, in the shape its module actually exports: a default import, import { Hero } for a named export, or import * as Bands plus the member the page's own call site names — and the page keeps calling it. When one module is bound twice (import Default, { Hero } from "./bands"), the binding the page actually CALLS is the one registered. It has to be a file this page imports, the only one it imports that renders the group, and one the registry's dialect can render; a section the page ALSO renders inline is two answers and is refused. Everything else is SECTION_ROOT_AMBIGUOUS with the reason spelled out.
  • The page — the contiguous run of section roots replaced by one <Sections page="<slug>" />, with the import added. Contiguous means consecutive element siblings and nothing but whitespace or comments in the bytes between them: <Hero />Read this first<Features /> has no element in the way, and one splice over that run would delete the sentence. A page with a REGISTERED section is all or nothing: <Sections> renders every placement the page has, so the run is replaced only when every one of the page's sections resolved and every call site is a bare tag in that run. Otherwise the call sites stay exactly where their author put them — replacing a subset would render a registered section twice.
  • The stubs — blocks: [] added to each page's snapshot (keys it already has are kept), and bcms-content/components.json = [].

SvelteKit and Nuxt

The same cut, in the page's own grammar. A section of src/routes/**/+page.svelte becomes src/lib/components/bcms/<Name>.svelte, one of pages/**/*.vue components/bcms/<Name>.vue; the registry is Sections.svelte / Sections.vue beside them, the sections library src/lib/bcms-sections.ts / composables/bcms-sections.ts, and the helper the one the plain run writes (src/lib/bcms-content.ts, composables/bcms-content.ts). Props are declared the way the PAGE writes them — Svelte 5's $props() for a page already in runes mode, export let for one written the legacy way (export let, $:), and the repository's Svelte version (package.json, or runes in any .svelte file) for a page that says neither; Vue's defineProps — typed when the repository has a root tsconfig.json. The copy and the chooser read the props lazily, so in Svelte 5 and Vue a placement handed new props renders them (Svelte 4 on a re-mount):

<script lang="ts">
// @bettercms-ai/convert section v2
import { bcms, bcmsFields, bcmsSection, bcmsSectionStyleText } from "../../bcms-content";

let { blockId, bind, bindPaths, overrides, page, blockStyle }: { … } = $props();
const bcmsHome = { get hero() { return bcmsSection(page, bind, overrides, bindPaths); } };
const bcmsField = (path: string): string => bcmsFields({ blockId, bind, bindPaths, overrides }, "hero.")(path);
</script>

<section class="hero" data-bcms-block={blockId} data-bcms-style="" style={bcmsSectionStyleText(blockStyle)}>
  <h1 data-bcms-field={bcmsField("hero.title")} data-bcms-kind="text">{bcms(bcmsHome, "hero.title", "Ship it")}</h1>
</section>
<script setup lang="ts">
// @bettercms-ai/convert section v2
import { bcms, bcmsFields, bcmsSection, bcmsSectionStyleText } from "../../composables/bcms-content";

const props = defineProps<{ blockId?: string; bind?: string; … }>();
const bcmsHome = { get hero() { return bcmsSection(props.page, props.bind, props.overrides, props.bindPaths); } };
const bcmsField = (path: string): string => bcmsFields({ blockId: props.blockId, … }, "hero.")(path);
</script>

<template>
  <section class="hero" :data-bcms-block="blockId" data-bcms-style="" :style="bcmsSectionStyleText(blockStyle)">
    <h1 :data-bcms-field='bcmsField("hero.title")' data-bcms-kind="text">{{ bcms(bcmsHome, "hero.title", "Ship it") }}</h1>
  </section>
</template>

The page's run becomes <Sections page="home" />, or (a foreign sibling in the run) each section is called in place: <Hero bind="hero" page={pageSnapshot("home")} />, Vue <Hero bind="hero" :page='pageSnapshot("home")' />. The script rows a section loops over (const items = bcmsRows(bcmsHome, …)) travel with it, after the copy they read; a Svelte <style> and a Vue <style scoped> travel too (a global Vue <style> already reaches the component); a Svelte rule whose ancestor stays on the page (.page-wrap .hero h1) would be pruned, so the leading compounds the component lacks become :global(…) (:global(.page-wrap) .hero h1). Names are read through the TEMPLATE's own scopes — {#each rows as row, i}, {#await … then v}, {#snippet s(x)}, {@const}, let:, v-for, v-slot — and a Svelte $store reads store. In Vue a name the page neither imports nor declares is an app global (Nuxt's auto-imports, <NuxtLink>) and resolves in the component as it did on the page. Refused by name (SECTION_FREE_IDENTIFIERS): a prop of the page (export let data, $props(), $$props, any unresolved name on a Vue page that declares props), the page's instance (defineEmits, defineModel, useSlots, useAttrs, $attrs, …), and a name the component's own props take (page — the $page store, say). Page STATE is shared, never copied: a let, $state, ref or writable the section assigns, or that the page still reads once the section has left it, is SECTION_SHARED_STATE — move it into a store both import, or into the section alone. A Vue Options API page is DIALECT_UNSUPPORTED: a component it imports renders only once registered in components.

Which file is a route?

The router's convention, not a search. Every candidate file says which route IT serves — src/pages/about.astro, app/about/page.tsx, src/routes/about/+page.svelte all answer /about, and so does src/app/(marketing)/about/page.tsx, because a Next route group and a parallel-route segment are not in the URL. Asking the SOURCE instead ("which file mentions this field group?") answers a different question: hero is on every landing page, and one route's codemod then rewrote another route's file. A route no file serves is NOT_IN_SOURCE; a route TWO files serve is ROUTE_FILE_AMBIGUOUS — the repository's own router has to pick, and a codemod that picks differently edits a page nobody serves.

What it will not accept

The brief and the plan are validated at ingress, before a byte is written. A page slug becomes a path — bcms-content/<slug>.json — so ../package names the repository's own manifest, from inside --root, where the CLI's boundary check cannot see it. Slugs, group keys and proposed slugs must each be one safe path segment ([A-Za-z0-9_-]{1,64}), routes must be absolute URL paths, and every field the codemod consumes is typed. A value that fails refuses the whole run with BRIEF_INVALID or PLAN_INVALID — a throw, not a receipt, because there is no partial run to report on.

What travels with the markup

Every name the section's CODE uses — expression attributes, expression nodes, component tags; never its TEXT — is accounted for. The page's imports are carried and re-based onto the new file, and the page's module-scope declarations are carried transitively: {plans.map(…)} brings const plans, and plans brings the const currency it reads. A name nothing can supply — a local of the page's own component body, an expression this version cannot parse — is SECTION_FREE_IDENTIFIERS, which is a refusal that names the identifier rather than a component file that does not build.

Scope is LEXICAL: {rows.map((row) => …)} binds row for the markup inside that expression and for nothing else, so a later sibling {row} is free and says so. Astro's shorthand and spread attributes ({title}, {...rest}) are read through the parser's own expression span rather than by looking for a { — the compiler reports both at the identifier, four characters in. A namespace import travels as import * as X, not as a named one.

What it refuses, and what it says

| reason | | |---|---| | SECTION_ROOT_AMBIGUOUS | the group's fields sit under no single element, one section's root contains another's, the page renders it BOTH inline and through a component it imports, two imported components render it, or it is rendered somewhere this cannot register — a file this page does not import, a component of another dialect, a namespace the page never calls. The message says which. | | SECTION_NOT_CONTIGUOUS | the page's section roots share no container at all. The components are still written; the page is left exactly as its author wrote it. | | SECTION_FREE_IDENTIFIERS | the markup uses a name the component file cannot be given. The identifier is named in the message. | | SECTION_SHARED_STATE | Svelte / Vue: the markup assigns page state (let, $state, ref, …), or reads state the page goes on using. A component would get a separate copy, and the two would drift apart. The names are in the message. | | COMPONENT_CONFLICT | a file already sits at src/components/bcms/<Name> (SvelteKit src/lib/components/bcms/, Nuxt components/bcms/) and this package did not write it. Decided on our marker, never on the path — that is a name a person may reasonably have chosen first. | | REGISTRY_CONFLICT | the same, for Sections.astro/.tsx/.svelte/.vue, src/lib/bcms-sections.ts or composables/bcms-sections.ts. Both are rebuilt whole on every run, so an unmarked file there would be destroyed. --overwrite-helper proceeds. | | REGISTRY_UNREADABLE | the registry IS ours and its REGISTRY/SHIPPED tables cannot be read back — a REGISTRY naming no components at all (this module is only emitted when there is at least one, so a table with no rows is one whose rows were lost), a SHIPPED row that is not an object with a string slug (and string bind/blockId when present), a page whose placement list is EMPTY (a row is only written for a page that has some, and the fallback rendering that page blank is the worst thing this could accept), or a SHIPPED slug the REGISTRY does not name. [null] is valid JSON and threw a TypeError out of a pure function the first time anything dereferenced it. An EMPTY SHIPPED is read, deliberately: a run whose only page was SECTION_NOT_CONTIGUOUS writes the components and no order at all, so refusing that would strand the repository — a component it names is GONE from the tree, a row is not a property this can read, or the tables are missing. Every property is parsed structurally and all of them must convert; a partial read is what silently drops a page's bands. It is rebuilt whole every run, so re-emitting it from what this run happened to see would blank those pages. No flag overrides this one: deleting the file is a decision only a person can take. | | ROUTE_FILE_AMBIGUOUS | two files serve one route. | | DIALECT_UNSUPPORTED | the route's file is not a template this lane reads (a plain module), or a Vue Options API page (no <script setup>: an imported component renders only once it is registered in components; add an empty <script setup>). Nothing is written for it. | | HELPER_CONFLICT | src/bcms-content.ts (SvelteKit src/lib/bcms-content.ts, Nuxt composables/bcms-content.ts) is a module this package did not write. Refused for the whole run, with an empty file list: bcmsSection and bcmsFields are what every extracted and every upgraded component reads through, so pending it per page while the rest of the run proceeded rewrote v1 components to import an export the helper does not provide. --overwrite-helper proceeds. | | SNAPSHOT_INVALID | bcms-content/<page>.json is not JSON this can add blocks to. Refused before a byte is written for that route. | | COPY_MODE_MISMATCH | --copy instance was asked of a plan whose placements are all still bound to page field groups. Refused before this repository is even opened — the run returns an empty file list, because a refusal that upgrades the helper and rewrites the registry is not a refusal. The message says to run componentize_sections { copy: "instance" } on the server first. The reverse pairing is NOT a refusal: --copy bind over an instance plan still produces a tree that renders, so refusing it would only stop a run that works. | | PROP_MAP_INCONSISTENT | the plan's fields[].path is not <one common prefix> + fields[].key — { path: "hero.heading", key: "title" }, or two fields that disagree about the prefix. A page path cannot be turned into a prop key losslessly, so the block-lane address would name a key the instance does not hold; falling back to <groupKey>. sent every edit to something nothing reads. Checked before this repository is consulted at all, so an already-componentized page is held to it too — doing it where the markup is cut reported such a page complete while its component went on addressing the wrong key. And checked again against the addresses it has to CONVERT: { groupKey: "hero", key: "title", path: "other.title" } is internally consistent and wrong about the section, whose markup declares hero.title — a prefix that converts none of what the section actually declares would leave every element on the page lane whatever the placement said. A field address this cannot read statically counts as one it cannot convert, and is named in the message. Only two shapes are provable — a string literal and NOTHING else, and a template literal whose every interpolation is a LONE IDENTIFIER used as an index ([${i}], what a repeater writes, which cannot change the prefix; [${getKey()}] names whatever that returns and is not). {computedPath}, {"a" + x} and {"hero.title".replace("hero","footer")} are all opaque: starting with a quote is not a proof, since that last one names footer.title at render time. A field carrying only one of the two says nothing about the mapping and is skipped, not refused: a Phase A plan spells no key at all. | | DUPLICATE_FIELD_BINDING | one element in the section carries more than one data-bcms-field. There is no single address to rewrite, and rewriting both leaves the extracted element still violating the one-attribute invariant. | | INLINE_NEEDS_INSTANCE | the page cannot be looped and the placement's copy has MOVED. The in-place call site the codemod writes — <Hero bind="hero" page={pageSnapshot("home")} /> — comes from the plan and carries no blockId and no overrides: it never consults the CMS's blocks at all, which is what makes the inline form componentisation without the page builder. Emitting it would render the page's field group (the copy the dashboard is about to hide) and declare the page lane for an address that no longer holds anything. The component is still written; the page is left as its author wrote it. | | SECTION_UNMIGRATABLE | a component an OLDER version of this package wrote cannot be brought to the current format: it no longer parses, it no longer declares its placement, its element carries two field attributes, the prefix its markup was cut with cannot be recovered (see below), or it already binds bcmsField/bcmsFields to something this package did not write — adding a second declaration is a file that does not compile, reported as a successful upgrade. A name is not a provenance: an import is reused only when its specifier RESOLVES to the helper module this package writes, and a bcmsField only when it is declared as const bcmsField = <that exact local>(…) — read structurally, in the module scope and in the component function's own body, which is where a .tsx component declares it. It is left exactly as it is, and the section is reported rather than counted alreadyExtracted on a format it is not on. | | NOT_IN_SOURCE | no file we were given renders the group's fields, or the route names no page file. |

A plan spanning TWO frameworks gets two of everything that is dialect-shaped: its own components, its own shipped-order table and its own Sections, per dialect. Only src/lib/bcms-sections.ts is shared, because it is plain TypeScript over the same snapshots.

Running it again

Completion is decided per ROUTE, from that page's own bytes — it imports our registry and calls <Sections page="<its slug>" />, or it calls our components in place. Not from "some file somewhere carries our marker", which is true of every group that has ever been extracted and left a brand-new page untouched.

The marker carries a FORMAT VERSION, and anything older is upgraded before it is called done. A 0.3 component (section v1: page-path bindings, no render-time lane) carries a marker too, so reading the marker alone reported every already-componentized page finished and left its components on the page lane forever — --copy instance there placed five sections and moved no address at all. Each older component is instead brought to the current format in place: every data-bcms-field wrapped in the chooser, the chooser declared and imported, the marker restamped. The Phase A bind ?? "<group>" fallback is rewritten away in the same pass — through the module's own AST, by offset and through the file's OWN local name for the helper's export, so neither a call a formatter has reflowed nor one made through an aliased import is missed. A module with no readable program is refused, and so is one where nothing was rewritten while a ?? fallback sits in a call this cannot identify as the helper's. An upgrade never leaves that behaviour under a current marker. It happens once; the next run sees the current version and writes nothing. A component that binds nothing needs no prefix and cannot be ambiguous about one — the marker is its whole upgrade. A component the plan says nothing about is left alone — nothing asked about it, and a v1 file is still a correct Phase A component. One that cannot be upgraded is SECTION_UNMIGRATABLE, never silently skipped — and that refusal is about ONE component, keyed by dialect as well as slug, so an unmigratable Hero.astro says nothing about the Hero.tsx beside it. (Each registry also resolves its own extensionless imports to a file its dialect can render, which is what makes two same-named components in one directory readable at all.)

Which group an upgraded component was cut from is read off the FILE, not off the plan. One component serves every page whose group has its shape, and its markup carries the paths of the one page it was cut from; taking the plan's first placement for that slug gave a component reading hero.* the prefix landingHero. as soon as that placement came first, so every wrapped address matched nothing, the block lane never fired, and the file was stamped current for good. The candidate prefixes — one per placement of the slug, server-pending ones included, because which group the bytes were cut from is a fact about the past that a refused placement may be the only thing to explain — are matched against the addresses the file actually declares (a repeater's contributes everything before its first interpolation), and a slug whose placements cannot be told apart that way is refused rather than guessed at.

An already-componentized page still has a shipped entry, and the plan may have moved its copy. Its placements' bind is restated from the current plan on every run — in the order the registry already holds, since a page whose markup was cut has no roots left to read an order off — so a bind run followed by an instance run drops bind from the fallback table and the reverse puts it back. Repeated placements of one component (two groups sharing a shapeHash, which is what shapeHash is FOR) are matched positionally, registry order against plan order per slug: keying them by slug collapsed them, and a page with one group still bound and another moved wrote both from the second. Every plan occurrence holds its slot whether or not this run can act on it — one it cannot (refused, server-pending) consumes its position and keeps the earlier record, because a missed update is recoverable and a wrong one is a page rendering someone else's copy. Nothing is extracted there, and the registry is rewritten only if those bytes actually change.

The registry and the sections library are rebuilt WHOLE each run, so they are first read back: components an earlier run wrote are reused rather than rewritten, and the shipped order of every page already served is carried forward. Without that, componentizing one new page emitted a registry that had never heard of the others, and they rendered blank. The read-back goes through the dialect's own PARSER, so a formatter that rewrote our imports (single quotes, a wrapped line) changes nothing; the two tables are brace-matched with strings and comments skipped and every property is parsed structurally, so a one-line table, a wrapped one, or a comment containing a } are all read whole. An import must resolve to a file that is actually there — composing the path from our own naming convention re-emitted a registry importing a component somebody had deleted. When any of that cannot be reconstructed the run refuses by name rather than rebuilding from what it happens to see.

A run whose roots DO share a container but have a foreign sibling between them is not refused: each root becomes its own <Hero …/> where its markup was, counted inlineOnly. The markup is componentised and the ORDER stays the repository's — looping that page would move the sibling too. A placement whose copy the plan has moved cannot take that form and is INLINE_NEEDS_INSTANCE: the in-place call site carries no blockId and no overrides.

The arithmetic is checked rather than hoped for:

plan sections === extracted + inlineOnly + alreadyExtracted + registered + pending.length

registered is orthogonal to placement: nothing was written for those sections — the file was somebody's before the run and stays theirs — so counting them extracted would claim authorship of bytes this package never produced. alreadyExtracted is the bucket the plan document does not name, and it is what makes the round trip observable: a second run over an already-componentised tree reports extracted: 0, pending: [] and still accounts for every section. Our own output is recognised by a marker comment, never by its path — src/components/bcms/ is a directory anybody may put a component in.

Known leftovers, stated rather than hidden: a page whose every read moved into a component keeps the now-unused bcms/snapshot imports P2 added, and a page whose registered call sites became one <Sections> keeps the imports of the components it used to call. They are harmless at build time and removing them would mean editing statements no section asked about.

Section spacing handles on a site converted before helper v10 (--upgrade-sections)

The visual editor shows a page's section spacing and style handles only when EVERY root the build stamps with data-bcms-block also carries data-bcms-style — the site's promise that the root's inline style is blockStyleToCss(block.style). Conversions before 0.21 (helper v6–v9, componentize v2 sections, hand-written roots) stamp the block and not the marker, so those pages never get handles. get_next_steps says so (upgrade-section-roots) when the release finds such roots.

npx @bettercms-ai/convert@latest --upgrade-sections --root . --dry-run   # read the plan
npx @bettercms-ai/convert@latest --upgrade-sections --root .             # then push and release

Each root with data-bcms-block and no data-bcms-style gets the marker together with the style:

  • a component --componentize wrote (data-bcms-block={blockId}): data-bcms-style="" style={bcmsSectionStyle[Text](blockStyle)}, the blockStyle prop, and the registry and src/lib/bcms-sections.ts patched to pass block.style down — byte-identical to what a fresh extraction writes today;
  • data-bcms-block={block.id} where the SDK has sectionRootAttrs (@bettercms-ai/astro >= 0.19, @bettercms-ai/next >= 0.18, @bettercms-ai/sveltekit and @bettercms-ai/nuxt >= 0.3, read off the LOWEST version the range admits): {...sectionRootAttrs(block)}, Vue's v-bind="sectionRootAttrs(block)";
  • the same root on an older SDK: data-bcms-style="" style={bcmsSectionStyle[Text](block.style)} (Vue: :style="bcmsSectionStyle(block.style)") through the helper, which is upgraded (or added, with its bcms-content/layout.json stub).
  • a SvelteKit or Nuxt page --sections looped: each {...bcmsItem.root} / v-bind="bcmsItem.root" (and bcmsItem.box) is already marked, by the SDK's sectionItems; a root that is ALSO a field's (data-bcms-field, a Body declared on the <section>) takes no block style, so its stamp moves to a box around it, <div {...bcmsItem.box}> (form box). --sections writes that box in the first place, and the bindings pass moves it when it declares a field on a looped root: the two passes compose in either order.

Left alone, and listed as skipped with a reason: OWN_STYLE (the root sets its own style), SPREAD (a {...rest} that may carry one), BLOCK_ID_ONLY (only an id reaches the root — data-bcms-block={Astro.props.bcmsBlock}; pass the block or its style down), BLOCK_LITERAL, BLOCK_UNRESOLVABLE (a conditional or a call), NOT_AN_ELEMENT, NAME_TAKEN, REGISTRY_UNRECOGNISED, DIALECT_UNSUPPORTED (html, whose values are written at publish), PARSE_ERROR. A helper this package did not write is HELPER_CONFLICT (exit 1) only when a root needs it; --overwrite-helper replaces it.

The receipt (--receipt <file>) is { helpers, registry, roots: { upgraded, alreadyMarked, skipped } }, each root as { file, line, tag, block, form | reason + message }. No class, className or existing style token changes (--verify-styles stays clean), and a second run writes nothing and counts every root it marked as alreadyMarked. --strict exits 2 while any root is skipped.

Element style handles on a field (--upgrade-element-styles)

A page field's own width, spacing, Align, text Size, Weight and element Size (C3, pages.fieldStyles keyed by the data-bcms-field address) are offered only on an element that also carries data-bcms-style — the site's promise that its inline style is fieldStyleToCss(page.fieldStyles[path]). Helper v16 reads them: bcmsStyleText(page, path) (a CSS string, for Astro) and bcmsStyle(page, path) (an object, for JSX), from the published page row in BCMS_CONTENT_FILE on a server BetterCMS hosts and from the snapshot at build. No style stored: undefined, so no style attribute at all.

The marker's VALUE is its revision. data-bcms-style="2" (helper v16, and the astro / next fieldAttrs) also promises size — sm / lg of a whole element, a button's Small / Large, painted as zoom: 0.875 / zoom: 1.125 after every other declaration; Medium is unset. The editor offers element Size only on "2". A v15 mark (data-bcms-style="") keeps every other handle. Section roots stay data-bcms-style="": a section has no size.

A conversion from 0.27 on marks each element it stamps (text, richtext, image, the text of a link) — a repeater row with its own template address:

<h1 class="type-hero" data-bcms-field="shell-h1-x" data-bcms-kind="text" data-bcms-style="2" style={bcmsStyleText(bcmsHome, "shell-h1-x")}>…</h1>
<h3 data-bcms-field={`g[${index}].h3-x`} data-bcms-kind="text" data-bcms-style="2" style={bcmsStyleText(bc