@bettercms-ai/convert 0.2.1 → 0.4.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/CHANGELOG.md CHANGED
@@ -1,5 +1,342 @@
1
1
  # @bettercms-ai/convert
2
2
 
3
+ ## 0.4.0
4
+
5
+ The workspace lockfile records this version: a lock still naming 0.3.0 makes a frozen install
6
+ reject the checkout, which is what broke the migrator image on #616.
7
+
8
+ ### Minor Changes
9
+
10
+ - Componentize Phase B: an extracted section declares the lane its copy actually lives in.
11
+
12
+ An element carries exactly ONE `data-bcms-field`, and which address it names is now decided
13
+ when the component RENDERS rather than when the codemod ran. A placement bound to a page
14
+ field group declares the page path, as before. A placement that owns its copy — `overrides`
15
+ and no `bind` — declares the block lane, `<blockId>__overrides.<propKey>`, which is where
16
+ the editor commits an instance's own value.
17
+
18
+ 🔴 The extracted files are BYTE-IDENTICAL in both copy models. That is the point: moving a
19
+ page's copy onto its instances is a data change on the server, not a second cut of the
20
+ repository, and one component renders a Phase A placement on one page and a Phase B
21
+ instance on the next. Each of P2's bindings is wrapped in the placement's own chooser,
22
+ `bcmsFields({ blockId, bind, overrides }, "<prefix>")`, so the page path is still verbatim
23
+ in the file. The prefix that turns a page path into a prop key comes from the plan's
24
+ `fields[].key`/`path` map, not from the group key.
25
+
26
+ The root's `data-bcms-block` is now REPLACED when the repository already wrote one, instead
27
+ of a second being appended beside it — two instances claiming one element.
28
+
29
+ - `--copy bind|instance` (default `bind`), and the `COPY_MODE_MISMATCH` refusal.
30
+
31
+ An assertion rather than a switch, since the output does not depend on it. It catches the
32
+ one order-of-operations mistake that would otherwise be silent: `--copy instance` run in the
33
+ repository before the server has moved the copy leaves a tree that looks converted and
34
+ renders every section out of page fields the dashboard is about to hide. A plan whose
35
+ placements all still carry `bind` refuses every section by name, with nothing written. The
36
+ receipt carries `copy`, the same word the server's receipt uses.
37
+
38
+ The registry's shipped fallback now omits `bind` for an instance-owned placement, for the
39
+ same reason: binding it would make a fresh clone render a section out of hidden fields.
40
+
41
+ - The extracted-component format is VERSIONED (`section v2`), and older ones are upgraded.
42
+
43
+ 🔴 Without this Phase B never reaches a repository that already ran 0.3. Completion is
44
+ decided on the marker and a v1 component carries one, so every already-componentized page
45
+ was reported done, its components kept their page-path bindings, and `--copy instance`
46
+ there placed five sections while moving no address at all. Each v1 component is now brought
47
+ to the current format in place — every `data-bcms-field` wrapped in the chooser, the chooser
48
+ declared and imported, the marker restamped — once, before any page is called done. A
49
+ component the plan says nothing about is left alone; one that cannot be upgraded is
50
+ `SECTION_UNMIGRATABLE` rather than a silent skip.
51
+
52
+ - An already-componentized page's shipped placements are restated from the current plan.
53
+
54
+ A bind run followed by an instance run kept `bind` on every placement in the registry's
55
+ fallback table, so a fresh clone rendered sections out of page fields the dashboard had
56
+ already hidden — and the reverse transition never put `bind` back. The `bind` is now
57
+ restated on every run, in the order the registry already holds; nothing is extracted, and
58
+ the registry is rewritten only when those bytes actually change. Repeated placements of one
59
+ component — two groups sharing a `shapeHash`, which is what `shapeHash` is FOR — are matched
60
+ POSITIONALLY, registry order against plan order per slug: keying them by slug collapsed them,
61
+ so a page with one group still bound and another moved wrote both from the second.
62
+
63
+ - The v1 → v2 upgrade reuses names the file already has, or refuses.
64
+
65
+ These files have been sitting in somebody's repository. Blindly adding
66
+ `import { bcmsFields }` and `const bcmsField = …` to a module that already declares either
67
+ produced a duplicate binding — a file that does not compile — reported as a successful
68
+ upgrade. An import of the helper's own `bcmsFields` is reused under whatever local name it
69
+ has; a `bcmsField` the file demonstrably builds from our helper is left alone and only its
70
+ bindings are wrapped; anything else is `SECTION_UNMIGRATABLE`.
71
+
72
+ 🔴 A NAME IS NOT A PROVENANCE. Reusing anything *called* `bcmsFields` trusted an import from
73
+ any module at all — `import { bcmsFields } from "./their-utils"` is a different function with
74
+ the same name — and any coexisting `bcmsField` vouched for itself. The specifier must RESOLVE
75
+ to the helper module this package writes (through the project's own aliases), and `bcmsField`
76
+ must be declared as `const bcmsField = <that exact local>(…)`, read structurally off the
77
+ module's own top-level AST.
78
+
79
+ - Which group an upgraded component was cut from is read off the FILE, not off the plan.
80
+
81
+ One component serves every page whose group has its shape, and its markup carries the paths of
82
+ the one page it was cut FROM. Taking the plan's first placement for that slug gave a component
83
+ reading `hero.*` the prefix `landingHero.` as soon as that placement came first: every wrapped
84
+ address matched nothing, the block lane never fired, and the file was stamped current for good.
85
+ The candidate prefixes are matched against the addresses the file actually declares, and a slug
86
+ whose placements cannot be told apart that way is `SECTION_UNMIGRATABLE`. Server-pending
87
+ placements count as prefix EVIDENCE — which group the bytes were cut from is a fact about the
88
+ past, and a refused placement may be the only thing that explains them — while still being
89
+ excluded from placement and copy-mode decisions.
90
+
91
+ - Fresh extraction allocates collision-free names for the chooser and its import.
92
+
93
+ The markup being cut is the PAGE's, and a page is free to have declared `bcmsField` itself.
94
+ Declaring ours unconditionally — and listing the name as `provided`, so `carryFor` neither
95
+ carried the page's declaration nor reported it free — silently rebound every use of theirs
96
+ inside the extracted section to our chooser. Both names now go through the same allocator as
97
+ every other identifier this codemod introduces, reserved against the module's bindings AND
98
+ against every identifier-shaped token in the bytes being lifted: in a TSX page that declaration
99
+ lives inside the component function, where a module-level check cannot see it, so the capture
100
+ happened anyway. ALL of the names, not the two base ones — reserving only `bcmsField` sent the
101
+ allocator to `bcmsField_1`, which a section referencing both also uses, and the capture simply
102
+ moved one suffix along.
103
+
104
+ - The prop map is checked before this repository is consulted at all, AND against the
105
+ addresses it has to convert.
106
+
107
+ Checking it where the markup is cut let an ALREADY-componentized page bypass it: a later plan
108
+ spelling `{ key: "title", path: "hero.heading" }` was reported complete while the component it
109
+ names went on addressing `overrides.heading`, a key the instance does not hold. The map is a
110
+ property of the plan, so it is now answered from the plan, before completion is even asked.
111
+
112
+ 🔴 AND A MAP CAN BE INTERNALLY CONSISTENT AND STILL WRONG ABOUT THE SECTION.
113
+ `{ groupKey: "hero", key: "title", path: "other.title" }` derives one clean prefix, `other.`,
114
+ which matches nothing the markup declares — so every element quietly stayed on the page lane
115
+ while the run reported the section done. The derived prefix is now verified against every
116
+ address the section actually declares — and a field address this cannot read statically
117
+ (`{computedPath}`, `{prefix + ".title"}`) counts as one it cannot convert, because contributing
118
+ nothing to the check is how such an element stayed on the page lane at render time. A
119
+ repeater's template literal is readable and is not affected.
120
+
121
+ - Migration refusals are about ONE component, and a `.tsx` chooser is found where it lives.
122
+
123
+ Keyed by slug alone, an unmigratable `Hero.astro` marked the perfectly good `Hero.tsx`
124
+ unmigratable in a mixed repository — a page refused for a fact about a file its route does not
125
+ render. And a `.tsx` component declares its chooser INSIDE the function, which is where this
126
+ package puts it: a program-level binding check saw nothing there and inserted a second
127
+ `const bcmsField` into the same block. Both scopes are read; the refusal is keyed by dialect
128
+ and slug.
129
+
130
+ Reading a registry back also resolves its extensionless imports to a file its OWN dialect can
131
+ render. Two dialects share a component name whenever they share a slug, and a general resolver
132
+ — whose extension order is this package's, not the project's — bound the `.tsx` registry's
133
+ `hero` to the astro file, after which everything was about the wrong component.
134
+
135
+ - `readRegistry` validates each `SHIPPED` row instead of trusting the array.
136
+
137
+ `[null]` is valid JSON, and the first thing to read it back dereferenced `placement.slug` and
138
+ threw a `TypeError` out of a pure function that names nothing. A row must be an object with a
139
+ string `slug` and, when present, string `bind`/`blockId`; anything else is `REGISTRY_UNREADABLE`,
140
+ which is what every other unreadable row in that file already was — as is a `REGISTRY` naming
141
+ no components (this module is only emitted when there is at least one, so a table with no rows
142
+ is one whose rows were lost) and a `SHIPPED` slug the `REGISTRY` does not name. An EMPTY
143
+ `SHIPPED` is still read: a run whose only page was `SECTION_NOT_CONTIGUOUS` writes the
144
+ components and no order at all, so refusing that would strand the repository. A single page
145
+ whose placement list is EMPTY is refused, though — `every` is vacuously true of it, and the
146
+ fallback exists precisely so a build with no CMS blocks renders the bands the repository ships.
147
+
148
+ - Two more edges: a root carrying SEVERAL `data-bcms-block` attributes keeps exactly one (only
149
+ the first was being rewritten, so the extracted element still came out with two), and a v1
150
+ component with no `data-bcms-field` at all is upgraded on its marker alone rather than refused
151
+ for an ambiguity it cannot have — every candidate prefix matched it vacuously.
152
+
153
+ ### Patch Changes
154
+
155
+ - No synthesized group fallback: an extracted component reads `bcmsSection(page, bind, overrides)`.
156
+
157
+ 🔴 `bind ?? "<groupKey>"` belonged to Phase A, where every placement was bound and the default
158
+ could only restate what the placement already said. Under Phase B a placement that OWNS its
159
+ copy carries no bind on purpose — and the fallback put the page's field group straight back
160
+ underneath it, so a key the instance had not been given rendered the page's stale copy out of
161
+ fields the dashboard has already hidden. A placement that names no group has none, and every
162
+ `bcms(...)` then renders what the template shipped with. The v1 → v2 upgrade rewrites the old
163
+ form away rather than stamping it as current — through the module's own AST, by offset and
164
+ through the file's OWN local name for the export, because an exact-text rewrite missed a call a
165
+ formatter had reflowed and a name-matched one missed an aliased
166
+ `import { bcmsSection as sectionCopy }`; the marker then said the question had been settled. A
167
+ module with no readable program is refused, and so is one where nothing was rewritten while a
168
+ `??` fallback sits in a call this cannot identify as the helper's.
169
+
170
+ - Only provably safe field expressions count as an address.
171
+
172
+ Starting with a quote is not a proof: `{"hero.title".replace("hero", "footer")}` names
173
+ `footer.title` at render time, and reading its first characters as the address accepted a
174
+ section whose element then quietly stayed on the page lane — the failure the check exists to
175
+ catch, reached through the check itself. Two shapes are provable: a string literal and NOTHING
176
+ else, and a template literal whose every interpolation is an INDEX (`[${i}]`, what a repeater
177
+ writes, which cannot change the prefix). Everything else — `{computedPath}`, `{"a" + x}`,
178
+ `` {`${prefix}.title`} `` — is opaque, and opaque is `PROP_MAP_INCONSISTENT`.
179
+
180
+ - `COPY_MODE_MISMATCH` ignores sections the SERVER already refused.
181
+
182
+ A `copy: "instance"` section carrying `pending` is not a placement this run would make, but it
183
+ counted as evidence that the plan had moved its copy — so `--copy instance` passed the gate on
184
+ a plan whose only instance-owned section was server-pending, and the run wrote the BIND
185
+ conversion for the sections it could act on. Which is the exact tree the flag exists to stop.
186
+
187
+ - A bound address is RE-BASED onto its own placement's group.
188
+
189
+ The markup is cut from ONE page and carries that page's paths, and the whole point of
190
+ `shapeHash` is that a second page renders the same component bound to a group of its own.
191
+ Handing the embedded path through unchanged declared `hero.title` on a page whose field is
192
+ `landingHero.title` — an address that page does not have, so the editor wrote into nothing
193
+ and the meter counted a binding that was never there.
194
+
195
+ - `INLINE_NEEDS_INSTANCE`: a page that cannot be looped cannot own its copy.
196
+
197
+ The in-place call site — `<Hero bind="hero" page={pageSnapshot("home")} />` — is written by
198
+ the codemod from the plan and carries no `blockId` and no `overrides`; it never consults the
199
+ CMS's blocks at all, which is exactly what makes the inline form componentisation *without*
200
+ the page builder. Emitting it for a placement whose copy has MOVED rendered the page's field
201
+ group and declared the page lane for an address that no longer holds anything. The component
202
+ is still written; the page is left as its author wrote it.
203
+
204
+ - `HELPER_CONFLICT` and `COPY_MODE_MISMATCH` now return an EMPTY file list.
205
+
206
+ On an already-componentized repository the registry read-back seeds the component table, so
207
+ the emit fired even though every section was pending: the helper was upgraded, the sections
208
+ library rebuilt and the registry rewritten for a run the operator was told did nothing. With
209
+ a hand-edited helper it was worse — the v1 → v2 upgrade rewrote components to
210
+ `import { bcmsFields }` from a module that does not export it. A refusal that modifies the
211
+ tree is not a refusal, so both return before reading or emitting any generated artefact.
212
+
213
+ - Two new refusals where the codemod used to guess.
214
+
215
+ `PROP_MAP_INCONSISTENT`: a plan whose `fields[].path` is not one common prefix plus its
216
+ `key` — `{ path: "hero.heading", key: "title" }` — fell back to `<groupKey>.`, so the block
217
+ address became `overrides.heading` for a prop the instance holds as `title`, and every edit
218
+ went to a key nothing reads. `DUPLICATE_FIELD_BINDING`: an element already carrying two
219
+ `data-bcms-field` had both rewritten, leaving the extracted element still violating the
220
+ one-attribute invariant.
221
+
222
+ - Helper v4: `bcmsSection` merges OVERRIDES-FIRST, per key.
223
+
224
+ `overrides[key] ?? group[key]` rather than "overrides, or else the group". The case that
225
+ matters is the one in between — a section whose copy is half moved — where a key the
226
+ instance does not hold has to keep rendering the page's value rather than nothing. `??`,
227
+ never `||`: an empty string is a value an editor typed. A table prop stored in the shape
228
+ the dashboard edits (`{ rows: [...] }`) is unwrapped to its list, so `bcmsRows` sees an
229
+ array instead of falling back to the rows the template shipped with.
230
+
231
+ New export `bcmsFields`, which is what an extracted section's bindings read through. An
232
+ existing v1–v3 helper is upgraded in place as usual; one this package did not write is
233
+ still `HELPER_CONFLICT`.
234
+
235
+ ## 0.3.0
236
+
237
+ ### Minor Changes
238
+
239
+ - Componentize: a section the repository has ALREADY componentised is REGISTERED, not refused.
240
+
241
+ A page whose `index.astro` imports `Hero.astro` and renders `<Hero />` has made the cut
242
+ this lane exists to make. There is nothing in the page to extract, and refusing left the
243
+ CMS naming a section no component in the repo rendered. The existing file is now
244
+ registered under the section's slug — the registry imports it where it lives — and the
245
+ page keeps its call site. It has to be ONE file, one the page IMPORTS, and one the
246
+ registry's own dialect can render; anything else is `SECTION_ROOT_AMBIGUOUS` with the
247
+ reason named.
248
+
249
+ New receipt bucket, in the arithmetic:
250
+ `plan sections === extracted + inlineOnly + alreadyExtracted + registered + pending.length`.
251
+ `registered` is orthogonal to placement — nothing was written for those sections, so
252
+ counting them `extracted` would claim authorship of bytes this package never produced.
253
+
254
+ A page with a registered section is ALL OR NOTHING: `<Sections>` renders every placement
255
+ the page has, so the run is replaced by one `<Sections page="…" />` only when every
256
+ section of the page resolved and every call site is a bare tag in that run. Otherwise the
257
+ call sites stay exactly where their author put them — replacing a subset would render a
258
+ registered section twice, once in place and once out of the registry. The registry
259
+ read-back also resolves ANY relative import now, not only `./Name`: without that a second
260
+ run dropped the registered entry and the page rendered blank.
261
+
262
+ - Repeaters: a row that holds a list of its own becomes a loop inside the loop.
263
+
264
+ `cards[i].items[j].label` is emitted as a nested `.map` in astro and jsx, reading its rows
265
+ off the ROW — `bcmsRows(card, "items", [ …this card's own items… ])` — so a bullet added in
266
+ the dashboard renders. The committed helper needed nothing: `bcmsRows` already passes a
267
+ nested array through untouched and `bcmsSection` hands a section its group whole.
268
+
269
+ The bindings are `` data-bcms-field={`cards[${i}].items[${j}].label`} `` and the shape is
270
+ `cards[*].items[*].label`; the loop now REPORTS the bindings it wrote, both loop variables
271
+ included, instead of the caller re-deriving a literal the file does not contain. ONE level
272
+ of nesting — what the plan mints and what the component prop validator accepts — so a leaf
273
+ indexed twice stays `REPEATER_FIXED_LENGTH`, and so does the html dialect, which has no
274
+ loop form at all.
275
+
276
+ - A hand-written declaration counts as `alreadyDeclared`, never `NO_ORIGINAL`.
277
+
278
+ An element that names the target (`data-bcms-field`, `data-bcms-layout-field`, an address
279
+ in `data-bcms-props`) but gets its copy some other way is DECLARED: the publisher injects
280
+ into it and the editor makes it click-to-edit. It is simply not reading through the
281
+ committed helper. The receipt gains one row per already-declared path —
282
+ `paths.declarations[] { route, scope, path, kind, file, readsFromCms }` — and the
283
+ arithmetic is unchanged. The evidence has to be on the file the ROUTER says serves that
284
+ route: without a read there is nothing in a declaration that says which route it is for,
285
+ and two routes legitimately carry `hero.title`.
286
+
287
+ README: the "declared by hand" paragraph, and the address grammar in one place —
288
+ `data-bcms-field` / `data-bcms-layout-field="layout:<section>:<field>"`, attribute values
289
+ through `data-bcms-props="<address>|<kind>|<attr>"`, and `data-bcms-block` on a section root.
290
+
291
+ - Review hardening for all three of the above.
292
+
293
+ **Componentize.** A section the page renders BOTH inline and through a component it imports is
294
+ `SECTION_ROOT_AMBIGUOUS`: cutting the inline copy left `<Sections>` rendering the band out of
295
+ the registry while the untouched `<Hero />` beside it rendered it again. So is a section two
296
+ imported components both render, and a namespace the page imports but never calls. A registered
297
+ component is imported in the shape its module actually exports — default, `import { Hero }`, or
298
+ `import * as Bands` plus the member the call site names — because a default import of a named
299
+ export binds `undefined`. Contiguity now requires whitespace or comments between the roots, not
300
+ merely consecutive element siblings: `<Hero />Read this first<Features />` had no element in the
301
+ way and the run splice deleted the sentence. The registry read-back goes through the dialect's
302
+ parser instead of a line-shaped regex, so a formatter cannot make it forget the components it
303
+ names — and when the tables genuinely cannot be reconstructed the run refuses with the new
304
+ `REGISTRY_UNREADABLE` rather than rebuilding a registry without them.
305
+
306
+ **Repeaters.** A nested list the repository ships ONE row of still becomes a loop; the plan
307
+ already says `items` is an array, and reading one bullet as "not a list" is how the second one an
308
+ editor adds never renders.
309
+
310
+ **Declarations.** The route rule now covers LOCATED paths too — a hand-written declaration on
311
+ another route's page file, or on an element two routes both claim, is not evidence for either —
312
+ and every declarations row names the file it was found on. `aliasesFrom` reads JSONC (block
313
+ comments, trailing commas), because a tsconfig this package could not parse silently declared no
314
+ aliases and every aliased import then read as "not imported"; one it still cannot read is a
315
+ `TSCONFIG_UNREADABLE` note on the receipt.
316
+
317
+ - A second review round, over the same three.
318
+
319
+ **The registry read-back.** An import must resolve to a file that is actually there —
320
+ composing the path from our own naming convention meant a component somebody DELETED still
321
+ "resolved", and the run re-emitted a registry importing it. Both tables are now parsed
322
+ property by property, with strings and comments skipped by the brace matcher, so a one-line
323
+ table, a wrapped one and a comment containing a `}` are all read whole; anything that cannot
324
+ be reconstructed in full is `REGISTRY_UNREADABLE` rather than a quiet partial rebuild.
325
+
326
+ **Bindings.** `import Default, { Hero } from "./bands"` binds one module twice; the binding
327
+ the page actually CALLS is the one registered, instead of whichever came first.
328
+
329
+ **Declarations.** The route gate now also applies to a declaration that reads from the CMS —
330
+ a home page rendering a teaser of `/about`'s headline reads `/about`'s snapshot legitimately,
331
+ and counting it left `/about`'s own page hardcoded and unreported. "Another route's page" means
332
+ a route THE BRIEF names: `routeOfFile` reads the repository's convention and the two disagree
333
+ about a dynamic route (`/[slug]` vs `/post/[slug]`), and refusing on a name the brief never
334
+ issued rejected every dynamic route's own page.
335
+
336
+ **Configs.** A tsconfig whose `baseUrl`/`paths` are valid JSON of the wrong type threw a
337
+ `TypeError` out of a pure function; the shape is checked and a bad one is
338
+ `TSCONFIG_UNREADABLE`, which the componentize receipt now carries too.
339
+
3
340
  ## 0.2.0
4
341
 
5
342
  ### Minor Changes