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

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.

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.

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.

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

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 }]

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 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_plancomponentize_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.tspageSections(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 stubsblocks: [] added to each page's snapshot (keys it already has are kept), and bcms-content/components.json = [].

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 pathbcms-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. | | COMPONENT_CONFLICT | a file already sits at src/components/bcms/<Name> 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/Sections.tsx or src/lib/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 page is svelte, vue or html. Nothing is written for it. | | HELPER_CONFLICT | src/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.

Forms

The third mode, and the shortest. The release-time derive lane reads every <form> in the built site and creates a DRAFT form per distinct shape, so the Forms tab fills on import while the repository's own markup goes on posting wherever it always did. --forms joins the two halves.

# after the import; the brief carries the project's forms
npx @bettercms-ai/convert --forms --brief brief.json --root . --receipt forms-receipt.json
git diff
modify src/pages/contact.astro

1 forms wired (0 already wired, 0 pending)
  todo publish form Contact in the Forms tab — a draft form rejects every submission with 403.

It writes six things onto a matched <form> and nothing else: action="<submitUrl>", method="post", data-bcms-form="<id>" (plus data-bcms-form-success when the CMS form has a success message), data-bcms-form-field="<key>" on each matched control, a <p class="bcms-form-msg" hidden> before </form> when there is none, and the submit script once per file — the same bytes the hosted renderer ships, pinned by a test. No helper, no snapshot, no import: a form posts to an absolute URL at runtime, so there is nothing to read at build time.

The match is the FIELD KEYS: a <form> is a CMS form's only when the names its controls post are a superset of that form's field keys and exactly one <form> answers. Everything else is pending with a reason — FORM_NOT_IN_SOURCE, FORM_AMBIGUOUS, FIELD_UNMATCHED, ACTION_IS_EXPRESSION, DIALECT_UNSUPPORTED, PARSE_ERROR — because a form wired to the wrong id sends the customer's leads into another form's inbox, silently, forever. A <form> that already carries data-bcms-form is alreadyWired and is left alone, so a second run is a no-op.

Two <form>s that both post exactly a form's fields are FORM_AMBIGUOUS, and no rule can break that tie: --pin "<form>=<file>" (repeatable, form by id or name) names the file, and a pin is exclusive only against forms that carry no pin of their own, so two forms living in one file can both be pinned to it. Keep passing a pin on every later run — a pin is not written into the tree, so an unpinned run over the same repository reports that form ambiguous again and its receipt stops listing the file it is wired to, even though the markup still is.

--receipt <file> writes this run's own receipt (forms-receipt.json — a separate file from the P2 run's, since the two runs describe different work), and --strict exits 2 on any pending form. --forms and --componentize are separate runs; doing both in one command is refused.

Publish the drafts afterwards. The lane creates forms as drafts and the public submit route 403s a draft, so a wired draft form is an inviting form on a live site that rejects every submission. The receipt carries one notes line per draft naming the form to publish in the Forms tab; that is the step no codemod can do for you.

Not here yet

GROWABLE lists. An array of primitives or tuples is bound (above) but renders the number of rows the template shipped with, because the read maps over a fixed path list rather than over the CMS array — and when the brief gave its elements unrelated scalar paths (li-field, li-rtl, …) rather than one group's rows, it is not bound at all: the only declaration that could carry a different path per row is an expression, and one nothing downstream can verify is worse than the honest IN_EXPRESSION. Both are the same fix on the derive side — minting the group — not a further shape here. Svelte and vue data literals are not converted either; all three lanes are written against the two dialects that keep their module and their markup in one file with an AST this package already holds.

SHARED CHROME the brief still calls page copy. A header rendered by one component that nine routes each declare a nav[0].label for is nine targets on one element, and there is no honest binding: a read names ONE route's snapshot. That is promoteSharedChrome's job on the derive side — once the field is scope: "layout" the codemod collapses the nine to one identity and binds it. Until then those paths are AMBIGUOUS_LITERAL, which is true.

promoteSharedChrome, and prop drilling through a svelte or vue component (both parse, and both bind their own copy; only findPropTargets is jsx/astro-only, so a drilled prop there is PROP_TARGET_NOT_FOUND rather than a wrong edit).

Componentize: svelte and vue extraction. Phase B — copy owned by the INSTANCE — is here for both dialects this lane extracts: the block-lane address is chosen at render time, so moving a page's copy onto its instances is a data change rather than another cut of the repository. What Phase B does NOT reach is the inlineOnly shape: a page whose roots could not be looped keeps calling <Hero bind="hero" …/> in place, which never consults the CMS's blocks at all, so its copy stays the page's whatever the plan says.