@bettercms-ai/convert 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,123 @@
1
+ # @bettercms-ai/convert
2
+
3
+ Turn an imported site's text-matched bindings into **declared** ones — deterministically, and with
4
+ a receipt of everything it could not do.
5
+
6
+ BetterCMS can already make an imported site editable by matching text: a release reads the built
7
+ HTML, finds the element whose text equals a field's value, and binds it. That works until someone
8
+ edits the copy, at which point the match is gone and the field quietly stops reflecting. The fix is
9
+ for the template itself to say which element holds which field — `data-bcms-field` — and to read the
10
+ value from the CMS with the current copy as its in-code fallback. This package does that edit.
11
+
12
+ ## Use it
13
+
14
+ ```bash
15
+ # In your site repo, with the brief from get_conversion_brief
16
+ npx @bettercms-ai/convert --brief brief.json --root .
17
+ git diff
18
+ ```
19
+
20
+ ```
21
+ modify src/pages/index.astro
22
+ add src/bcms-content.ts
23
+ add bcms-content/home.json
24
+
25
+ 10 of 11 paths bound (0 already declared, 1 pending)
26
+ pending /about p-founded-in-2019-in — DIALECT_UNSUPPORTED (src/data/facts.js)
27
+ ```
28
+
29
+ Exit codes: `0` done — the pending list is printed, not hidden; `1` something was refused;
30
+ `2` only with `--strict`, when any path is left pending. That last one is for CI.
31
+
32
+ | flag | |
33
+ |---|---|
34
+ | `--brief <file>` | the brief, as `get_conversion_brief` returns it. Required. |
35
+ | `--root <dir>` | the repository to convert. Nothing outside it is ever read or written. |
36
+ | `--dry-run` | print what would change; write nothing. |
37
+ | `--receipt <file>` | write the receipt as JSON. |
38
+ | `--strict` | exit 2 when any path is pending. |
39
+ | `--overwrite-helper` | replace a `bcms-content` helper whose bytes this tool did not write — including one of ours that has since been edited. |
40
+ | `--verbose` | stack traces, and every file considered. |
41
+
42
+ ## What it actually does
43
+
44
+ **Parser offsets in, one `magic-string` splice out.** The tree is never re-serialised, so your
45
+ formatting, comments and directives survive and the diff is one a human can read. `parse5` reads
46
+ html, `@astrojs/compiler` reads astro, `@babel/parser` reads jsx/tsx, `svelte/compiler` reads
47
+ svelte and `@vue/compiler-sfc` reads vue — five dialects, one neutral tree, one matcher.
48
+
49
+ **Values are read at BUILD time, page-scoped.** A converted page statically imports
50
+ `bcms-content/<page>.json` and calls a ~30-line helper this package commits. Every bundler resolves
51
+ that at build time — no `fs`, no fetch, no key, and only that page's content can reach its bundle.
52
+ The committed stubs are `{}`, so a build with them renders every fallback and still succeeds; the
53
+ platform overwrites them before a real build. The consequence is worth stating plainly: outside the
54
+ visual editor, seeing an edit means a rebuild.
55
+
56
+ **Three tiers per file, and a file is exactly one of them.** The dialect's parser; failing that, a
57
+ tolerant pass that will only place a literal occurring exactly once between a `>` and a `<`; failing
58
+ that, an injected `llmFallback` whose output is accepted only if it re-parses and declares exactly
59
+ the paths that were asked for. The CLI injects none — the agent running it is tier 3.
60
+
61
+ **Everything it will not do has a name.** `AMBIGUOUS_LITERAL` when two different paths share a
62
+ sentence and position cannot separate them (both are skipped; the rest of the file still converts),
63
+ `SUBSTRING_ONLY`, `IN_SCRIPT_OR_COMMENT`, `KIND_MISMATCH`, `NOT_IN_SOURCE`, `PARSE_ERROR` — and the
64
+ receipt's arithmetic is checked, not hoped for:
65
+
66
+ ```
67
+ paths.declared === paths.rewritten + paths.alreadyDeclared + paths.pending.length
68
+ ```
69
+
70
+ A path counts as rewritten only when EVERY located occurrence of it was. A value rendered by both
71
+ the home page and a shared header is two places a reader sees it, and converting one of them reads
72
+ as a finished job.
73
+
74
+ ## As a library
75
+
76
+ ```ts
77
+ import { convertSources } from "@bettercms-ai/convert";
78
+
79
+ const { files, receipt } = await convertSources(brief, sources);
80
+ ```
81
+
82
+ Pure: no filesystem, no network, no credential, no clock. `cli.ts` is the only module that touches a
83
+ disk. That is what lets the interesting half — which element holds which path, and why one could not
84
+ be found — be tested without a repository.
85
+
86
+ **A repeater becomes a loop, not three stamps.** Three identical siblings the derive lane called
87
+ `cards[i].*` are rewritten as one row inside the dialect's loop, reading
88
+ `bcmsRows(page, "cards", [ …every row's copy… ])` — so a card added in the dashboard renders, and a
89
+ build against the committed stub still renders the three the repository has. The bindings there are
90
+ template literals (`` data-bcms-field={`cards[${i}].title`} ``), the receipt lists each one, and the
91
+ proposer re-derives them from its own scan rather than believing the receipt. A group that cannot
92
+ become a loop — non-consecutive siblings, one row shaped differently, the html dialect — is still
93
+ declared and read per index and reported `REPEATER_FIXED_LENGTH`.
94
+
95
+ **A prop-drilled literal is converted in two hops, in one proposal.** `<Hero title="…" />` becomes
96
+ `title={bcms(…)}` plus `bcmsBindings={{ title: "hero.title" }}`, and the `<h1>{title}</h1>` inside
97
+ `Hero` — resolved through the file's own imports, relative or through the project's tsconfig
98
+ `paths` — gets `data-bcms-field={bcmsBindings?.title}`, the prop added to its destructuring, and
99
+ `bcmsBindings?: Record<string, string>` added to its `Props` type when it has one. A chain through
100
+ intermediate components is followed for four components — including a `{...props}` spread, which is
101
+ how most of them forward — with the object handed over explicitly at each call site; deeper is
102
+ `PROP_DRILLED_DEEP`. Which component is analysed comes from the IMPORT, so a module exporting a
103
+ `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
104
+ 1 still reads from the CMS, the bindings prop is DROPPED, and the path is `PROP_TARGET_NOT_FOUND`.
105
+
106
+ **The shared chrome is one field, not one per route.** A path the brief marks `scope: "layout"` is
107
+ read with `bcmsLayout("<address>", "…")` and declared on `data-bcms-layout-field` — the chrome
108
+ lane's own attribute, so the block lane's `[data-bcms-field]` query cannot reach it — and it is
109
+ counted once however many pages list it.
110
+
111
+ **A dynamic route reads its own entry.** `[slug].astro` appends `Astro.params.slug`, SvelteKit
112
+ `$page.params.slug` (importing the store), Nuxt `useRoute().params.slug`. A Next `[slug]/page.tsx`
113
+ gets a TYPED `{ params }: { params: Promise<{ slug: string }> }` on its page component, is made
114
+ `async` if it is not already, and reads `(await params).slug` — because `params` is a promise, and
115
+ reading `.slug` off one is `undefined` on every request. A `"use client"` page cannot be made async
116
+ and a signature this cannot edit is not guessed at: both are `DYNAMIC_PARAMS_UNAVAILABLE` and are
117
+ left alone. A repeater on a dynamic route passes the same parameter to `bcmsRows`.
118
+
119
+ ## Not here yet
120
+
121
+ `promoteSharedChrome`, and prop drilling through a svelte or vue component (both parse, and both
122
+ bind their own copy; only `findPropTargets` is jsx/astro-only, so a drilled prop there is
123
+ `PROP_TARGET_NOT_FOUND` rather than a wrong edit).