smoodly 0.0.6 → 0.0.8
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 +318 -74
- package/dist/admin/client-ops.js +3 -2
- package/dist/admin/editor/EditorView.js +229 -30
- package/dist/admin/editor/PageSettings.d.ts +8 -4
- package/dist/admin/editor/PageSettings.js +25 -25
- package/dist/admin/fixed-nodes.d.ts +16 -10
- package/dist/admin/fixed-nodes.js +55 -41
- package/dist/admin/index.d.ts +3 -2
- package/dist/admin/index.js +1 -0
- package/dist/admin/ops-impl.js +218 -36
- package/dist/admin/ops.d.ts +73 -12
- package/dist/admin/serialize.d.ts +11 -0
- package/dist/admin/serialize.js +8 -0
- package/dist/admin/shared-items.d.ts +9 -0
- package/dist/admin/shared-items.js +61 -0
- package/dist/admin/shell/AdminApp.js +11 -34
- package/dist/admin/shell/EntriesList.d.ts +7 -0
- package/dist/admin/shell/EntriesList.js +95 -0
- package/dist/admin/shell/EntryForm.js +167 -49
- package/dist/admin/shell/PagesList.js +86 -26
- package/dist/admin/shell/SharedForm.d.ts +7 -0
- package/dist/admin/shell/SharedForm.js +146 -0
- package/dist/admin/shell/SharedList.d.ts +6 -0
- package/dist/admin/shell/SharedList.js +62 -0
- package/dist/admin/shell/entries-list.d.ts +21 -0
- package/dist/admin/shell/entries-list.js +36 -0
- package/dist/admin/shell/pages-tree.d.ts +30 -12
- package/dist/admin/shell/pages-tree.js +48 -14
- package/dist/admin/shell/shared-list.d.ts +14 -0
- package/dist/admin/shell/shared-list.js +17 -0
- package/dist/admin/tree-ops.d.ts +12 -5
- package/dist/admin/tree-ops.js +39 -8
- package/dist/admin/ui/LocaleSwitcher.d.ts +11 -0
- package/dist/admin/ui/LocaleSwitcher.js +15 -0
- package/dist/collections.d.ts +10 -1
- package/dist/collections.js +16 -0
- package/dist/config.d.ts +3 -0
- package/dist/config.js +58 -4
- package/dist/entry-store.d.ts +141 -55
- package/dist/entry-store.js +317 -87
- package/dist/index.d.ts +11 -7
- package/dist/index.js +8 -5
- package/dist/localize.d.ts +0 -11
- package/dist/localize.js +11 -29
- package/dist/next/page-renderer.d.ts +4 -0
- package/dist/next/page-renderer.js +14 -1
- package/dist/page.js +5 -1
- package/dist/paths.d.ts +46 -27
- package/dist/paths.js +62 -29
- package/dist/refs.js +8 -0
- package/dist/resolve.d.ts +3 -0
- package/dist/resolve.js +38 -0
- package/dist/revalidate.d.ts +4 -0
- package/dist/revalidate.js +6 -1
- package/dist/shared.d.ts +46 -0
- package/dist/shared.js +36 -0
- package/dist/site.d.ts +16 -2
- package/dist/site.js +92 -39
- package/dist/sql-space.d.ts +1 -1
- package/dist/sql-space.js +16 -13
- package/dist/sql.js +95 -70
- package/dist/store.d.ts +66 -32
- package/dist/store.js +205 -90
- package/dist/supabase-entry-store.d.ts +30 -9
- package/dist/supabase-entry-store.js +360 -178
- package/dist/supabase-store.d.ts +21 -9
- package/dist/supabase-store.js +202 -111
- package/dist/zones.d.ts +6 -2
- package/dist/zones.js +8 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -38,40 +38,49 @@ Implemented:
|
|
|
38
38
|
pages runtime: policies normalized to plain data, tree nodes mapped to
|
|
39
39
|
registered components, fail-soft on unknown types
|
|
40
40
|
- `collectionSQL` / `coreTablesSQL` — the stable schema (DESIGN.md §3):
|
|
41
|
-
`coreTablesSQL` emits the once-ever core tables including `entries
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
41
|
+
`coreTablesSQL` emits the once-ever core tables including `entries`,
|
|
42
|
+
`entry_locales` and `entry_versions` (shared fields on the node,
|
|
43
|
+
localized fields, slug and status per locale row, merged snapshots
|
|
44
|
+
per locale for versioned collections; `space_id` multi-tenancy with
|
|
45
|
+
a default space for self-hosted, `sort` for manual ordering, and no
|
|
46
|
+
sibling-slug index at all — sibling uniqueness is the primary key of
|
|
47
|
+
`paths` plus a pre-check the stores make per locale, for entries and
|
|
48
|
+
pages alike);
|
|
47
49
|
`collectionSQL` emits only the disposable per-collection guardrails —
|
|
48
|
-
a published-only read view
|
|
49
|
-
|
|
50
|
-
the
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
50
|
+
a published-only read view joining `entries` and `entry_locales` (one
|
|
51
|
+
row per published locale, a `locale` column; a versioned collection
|
|
52
|
+
reads the published snapshot; typed casts, snake_case aliases, refs as
|
|
53
|
+
plain id values) and partial btree indexes for the declared `order.by`
|
|
54
|
+
field, on the jsonb VALUE (`("fields"->'x')`)
|
|
55
|
+
- `PageStore` contract + `MemoryPageStore` — id-keyed page persistence
|
|
56
|
+
over pages/page_locales/page_versions/paths — one node, one row per
|
|
57
|
+
locale, each locale with its own tree, draft, publish and history
|
|
58
|
+
(spec 2026-09-06). Every read and write takes the record's `id`, and
|
|
59
|
+
the URL is derived from the tree, never stored on the record. Pages
|
|
60
|
+
form a tree (`parentId`, sibling `sort`, a null `template` = folder,
|
|
61
|
+
`pages.tree.depth` capping how deep editors may nest), and expose
|
|
62
|
+
`rename` (a locale's slug/title), `move`, `setTemplate`, `addLocale`,
|
|
63
|
+
`removeLocale`, `resolvePath(locale, path)` and `pathsOf(id)` alongside
|
|
64
|
+
the draft/publish semantics (versions accumulate; publish moves that
|
|
65
|
+
locale's pointer; drafts never leak into published reads). The contract
|
|
66
|
+
is a reusable suite (`test/page-store-contract.ts`); every adapter runs
|
|
67
|
+
the same tests.
|
|
62
68
|
- `SupabasePageStore` — the same id-keyed contract over
|
|
63
|
-
pages/page_versions/paths (a server-side client from
|
|
69
|
+
pages/page_locales/page_versions/paths (a server-side client from
|
|
64
70
|
`createSmoodlyClient`: the service role in self-host, a write key in
|
|
65
71
|
the cloud). Contract-tested against a real local stack: `supabase
|
|
66
72
|
start` at the repo root (the generated DDL is its one migration), then
|
|
67
73
|
`npm run test:supabase`; plain `npm test` skips it and never needs
|
|
68
74
|
Docker.
|
|
69
75
|
- `paths.ts` — the ONE place a URL is derived: pure, framework-free
|
|
70
|
-
functions (`segmentFor
|
|
71
|
-
`
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
76
|
+
functions (`segmentFor` — a locale's own segment, no fallback —
|
|
77
|
+
`chainOf`, `pagePath`, `pagePathIn` (nullable, for the admin),
|
|
78
|
+
`pagePathRows`, `entryPathRows`, `entrySegment` (a locale's own slug,
|
|
79
|
+
no fallback), `assertSupportedLocale` (both stores), `buildPageTree`,
|
|
80
|
+
segment and depth assertions) that both adapters and the tree query call, so the index
|
|
81
|
+
and every derived `path` agree by construction. `pagePath` is the
|
|
82
|
+
single page-path derivation — `pagePathRows`, `buildPageTree` and the
|
|
83
|
+
site layer all go through it.
|
|
75
84
|
- `PathIndex` — the derived URL index, `MemoryPathIndex` and
|
|
76
85
|
`SupabasePathIndex` over the `paths` table ((space, locale, path) → page
|
|
77
86
|
or entry). Pages and entries share ONE instance, so a page and an entry
|
|
@@ -98,12 +107,11 @@ Implemented:
|
|
|
98
107
|
collection's descriptors, for the entry-page route: the shared task
|
|
99
108
|
collection and batched rounds are factored out, so a ref site behaves
|
|
100
109
|
identically whether it sits in a page tree or in an entry.
|
|
101
|
-
- `localize.ts` —
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
addressing). Slugs stay `paths.ts`'s business.
|
|
110
|
+
- `localize.ts` — `fixedPathFor` (the path a fixed page registration
|
|
111
|
+
is served at in one locale — the escape hatch's addressing; a slug map
|
|
112
|
+
that omits a locale means "the same segment", since a registration is
|
|
113
|
+
code, not a record). Pages themselves have no fallback: title and slug
|
|
114
|
+
live on `page_locales`, and slugs stay `paths.ts`'s business.
|
|
107
115
|
|
|
108
116
|
- `collectEntryRefs` / `collectTreeRefs` — the refs-index feeders: walk
|
|
109
117
|
an entry or a page tree against its schemas and list every outgoing
|
|
@@ -124,16 +132,23 @@ Implemented:
|
|
|
124
132
|
`MemoryRefIndex` in memory); the cross-store contract
|
|
125
133
|
(`test/page-refs-contract.ts`) runs against memory AND Supabase.
|
|
126
134
|
- `EntryStore` contract + `MemoryEntryStore` + `SupabaseEntryStore` —
|
|
127
|
-
collection-entry persistence
|
|
128
|
-
|
|
135
|
+
collection-entry persistence over entries/entry_locales/entry_versions/paths
|
|
136
|
+
— one node, one row per locale, each locale with its own slug, status
|
|
137
|
+
and `.localized()` fields, shared fields on the node, and a per-locale
|
|
138
|
+
version history for collections with `versions: true` (spec 2026-09-06
|
|
139
|
+
§7): `create({ locale })`, `update(id, locale, fields)`,
|
|
140
|
+
`setStatus(id, locale, status)`, `addLocale`, `removeLocale`,
|
|
141
|
+
`listVersions`; saves rewrite the entry's refs rows over every locale's
|
|
142
|
+
live field set, `referencesTo` answers the reverse
|
|
129
143
|
question, `getMany` is the batched read whose signature is
|
|
130
|
-
`resolveTree`'s `EntryFetcher`
|
|
144
|
+
`resolveTree`'s `EntryFetcher`, now `(collection, ids, locale, { status })`,
|
|
145
|
+
the published read of a versioned collection being the snapshot
|
|
146
|
+
(with `{ status: "published" }` for the
|
|
131
147
|
published-route read — a post-publish demotion resolves as missing
|
|
132
148
|
instead of leaking draft fields — DESIGN.md §3 "Page edges in refs"), deletes are blocked while
|
|
133
149
|
referenced,
|
|
134
|
-
entry slugs are unique among SIBLINGS in both adapters
|
|
135
|
-
|
|
136
|
-
a slug-less entry never conflicts),
|
|
150
|
+
entry slugs are unique among SIBLINGS per locale in both adapters
|
|
151
|
+
(a code pre-check; the `paths` primary key is the invariant),
|
|
137
152
|
and `reorder` rewrites sort 1..N through the `smoodly_reorder` SQL
|
|
138
153
|
function (one statement, `updated_at` untouched). Same
|
|
139
154
|
reusable-contract pattern as PageStore; `npm run test:supabase` runs
|
|
@@ -157,9 +172,11 @@ Implemented:
|
|
|
157
172
|
so they read through the same policy set rather than their owner's
|
|
158
173
|
privileges. Live acceptance test in `test/rls.supabase.test.ts`. Note
|
|
159
174
|
for self-host: the anon key now reads published rows straight off the
|
|
160
|
-
base tables (`pages
|
|
161
|
-
|
|
162
|
-
|
|
175
|
+
base tables (`pages` and `entries` published in any locale, a published
|
|
176
|
+
`page_locales`/`entry_locales` row, the `page_versions`/`entry_versions`
|
|
177
|
+
row a published locale points at — shared content included, since it
|
|
178
|
+
is stored as entries, spec 2026-09-07), not only through the generated
|
|
179
|
+
views.
|
|
163
180
|
|
|
164
181
|
- Admin login: `supabaseAdminAuth` + `gateAdminOp` around the ops seam;
|
|
165
182
|
`LoginView` + `useAdminSession` in the shell; `createSmoodlyAdmin({ auth })`.
|
|
@@ -195,6 +212,8 @@ Implemented:
|
|
|
195
212
|
- The admin is the page TREE (spec 2026-09-05 §7): the ops seam addresses
|
|
196
213
|
pages by id — `pages.list`/`get`/`create`/`saveDraft`/`publish`/
|
|
197
214
|
`delete`/`rename`/`move`/`setTemplate`, plus `entries.move`.
|
|
215
|
+
`entries.get`/`create`/`save`/`setStatus` take a locale;
|
|
216
|
+
`entries.addLocale`/`removeLocale`.
|
|
198
217
|
`pages.list` materializes the code-owned root nodes before an editor
|
|
199
218
|
touches them (`admin/fixed-nodes.ts` derives fixed pages and collection
|
|
200
219
|
mounts from the registry, never from a column), and every guard asks
|
|
@@ -214,11 +233,12 @@ Implemented:
|
|
|
214
233
|
`admin/shell/PagesList.tsx` with a row menu over the tree ops (New
|
|
215
234
|
subpage, New subfolder, Add page here, Rename…, Move up / Move down,
|
|
216
235
|
Delete, and Edit index page on a mount a fixed page claims). The editor
|
|
217
|
-
is keyed by id, with a locale switcher
|
|
218
|
-
`urls[locale]` and, while no block is
|
|
219
|
-
(`admin/editor/PageSettings.tsx`: title and
|
|
220
|
-
`pages.rename`; SEO/meta fields stay
|
|
221
|
-
its slug field with the collection's
|
|
236
|
+
is keyed by id, with a locale switcher loading that locale's own tree
|
|
237
|
+
and pointing the canvas at `urls[locale]` and, while no block is
|
|
238
|
+
selected, page settings (`admin/editor/PageSettings.tsx`: the title and
|
|
239
|
+
slug of the ACTIVE locale through `pages.rename`; SEO/meta fields stay
|
|
240
|
+
OQ #17). The entry form prefixes its slug field with the collection's
|
|
241
|
+
mount path. The serialized
|
|
222
242
|
registry carries `collections[].path`/`depth`, `pages[].slugs` and
|
|
223
243
|
`pageDepth`, and `defineConfig` validates `homePageSlug` — a real
|
|
224
244
|
segment, and never a collection's mount.
|
|
@@ -229,10 +249,12 @@ Implemented:
|
|
|
229
249
|
reads, folders or fallback. `locate(locale, path)` is one primary-key
|
|
230
250
|
hit on the `paths` index; `load(target, locale, path, { draft? })`
|
|
231
251
|
fetches the record and its tree (draft or published), resolves refs,
|
|
232
|
-
|
|
252
|
+
reads the locale's own rows, and returns a `SitePage` — `record`,
|
|
233
253
|
`template`, `tree`, localized `title`, `path`, `ancestors`, `children`,
|
|
234
254
|
and `paths` (locale → path, for alternates) — or a `SiteEntry` with the
|
|
235
|
-
|
|
255
|
+
locale's merged fields (a versioned collection's published snapshot on
|
|
256
|
+
the public path) resolved, and `paths` for its published locales.
|
|
257
|
+
`resolve(locale, path, { draft? })`
|
|
236
258
|
is the two in sequence — `locate` then `load` — for a caller that just
|
|
237
259
|
wants the hit. A folder (null template), an
|
|
238
260
|
unpublished record outside draft mode, or a missing one is `null`.
|
|
@@ -280,13 +302,25 @@ Implemented:
|
|
|
280
302
|
the only way the node gets there — it also covers pages that existed
|
|
281
303
|
before a locked zone was added to their schema.
|
|
282
304
|
|
|
305
|
+
- Shared content (spec 2026-09-07): `smoodly.shared({ name, title, section })`
|
|
306
|
+
registers a visual item, `smoodly.shared({ name, title, fields })` an
|
|
307
|
+
object item; one registration materializes as a singleton entry in
|
|
308
|
+
every supported locale. `site.getShared(item, { locale })` and the Next
|
|
309
|
+
glue's `getSmoodlyShared(item, { locale })` fetch it code-side; the
|
|
310
|
+
editor places a visual item in a zone from the "+" picker as a linked
|
|
311
|
+
`{ type: "shared", fields: { item, ref } }` node the resolve pass
|
|
312
|
+
rewrites into the section, and `z.locked(footer)` binds a zone to one.
|
|
313
|
+
Edited on its own admin page under Shared content (`/admin/shared/<name>`),
|
|
314
|
+
with "used on N pages" from `refs`. Wired through the `shared` option
|
|
315
|
+
on the entry store and the site layer.
|
|
316
|
+
|
|
283
317
|
Not yet: richtext components (TipTap; `plainToRichtext`/
|
|
284
318
|
`richtextToPlain` exist but no rich editor widget), drag-and-drop
|
|
285
319
|
reordering (move-up/move-down buttons in the editor, Move up / Move down
|
|
286
320
|
in the Pages list's row menu), undo/redo, sample/placeholder content
|
|
287
|
-
marking, per-locale section
|
|
288
|
-
|
|
289
|
-
|
|
321
|
+
marking, per-locale section visibility conditions (OQ #3 — each locale
|
|
322
|
+
now has its own tree, so a section's fields are already per locale; what
|
|
323
|
+
is open is hiding a section by a locale-dependent condition).
|
|
290
324
|
|
|
291
325
|
## Releasing
|
|
292
326
|
|
|
@@ -302,6 +336,79 @@ Both packages move together. From the repo root:
|
|
|
302
336
|
`examples/site` in that build, so the template always matches the example
|
|
303
337
|
site at the tagged commit.
|
|
304
338
|
|
|
339
|
+
## Follow-ups (logged 2026-09-07, shared content spec)
|
|
340
|
+
|
|
341
|
+
- `f.shared(() => item)` — a ref field on a section that renders a shared
|
|
342
|
+
item inside the section's own markup (spec 2026-09-07 decision 3).
|
|
343
|
+
- Instance mode: a shared *type* with many editor-created items; today
|
|
344
|
+
the answer is a collection plus a section with `f.ref` (decision 1).
|
|
345
|
+
- Zone-shaped shared items (`smoodly.shared({ zone })`) (decision 2).
|
|
346
|
+
- Standalone live preview on a visual item's admin page — needs a
|
|
347
|
+
preview route in the glue; the page canvas shows placements today (§6).
|
|
348
|
+
- Built-in meta on shared items and on placements (anchor per placement
|
|
349
|
+
is the likely shape).
|
|
350
|
+
- Per-placement overrides (a shared band with a per-page heading) — out
|
|
351
|
+
of scope; use a normal section with `f.ref` fields.
|
|
352
|
+
- `fillLockedZones` for a zone bound to a shared item waits for
|
|
353
|
+
`ops.shared.list()`; a page opened before that resolves fills on the
|
|
354
|
+
next effect run. Not seen in the walk — the example binds no zone, so
|
|
355
|
+
the path was never exercised — but a double fill is unreachable by
|
|
356
|
+
construction: the effect depends on `sharedIds`, `record`, `mutate` and
|
|
357
|
+
`tree === null`, and `fillLockedZones` only touches EMPTY zones.
|
|
358
|
+
`z.locked(item)` still has no example usage.
|
|
359
|
+
- `SharedForm` lists linking pages by id; a title needs `ops.pages.get`
|
|
360
|
+
per page (or a usage op returning titles).
|
|
361
|
+
- `getSmoodlyShared` reads the store per request in draft mode and once
|
|
362
|
+
per (item, locale) otherwise; a layout calling it for several items
|
|
363
|
+
makes one round trip each — batch if it shows.
|
|
364
|
+
- The Supabase entry store's singleton check is SELECT-then-INSERT with
|
|
365
|
+
no unique index; two admin loads racing on an item's first
|
|
366
|
+
materialization can create two rows. `ensureSharedItems` reads the
|
|
367
|
+
first row either way, and the duplicate cannot be removed through the
|
|
368
|
+
API — `delete` refuses every shared row. Two ways to make the race
|
|
369
|
+
impossible: a deterministic row id (uuid v5 of the item name, so the
|
|
370
|
+
second insert fails on the primary key — no schema change), or a
|
|
371
|
+
per-item partial unique index emitted by the SQL generator the way
|
|
372
|
+
`collectionSQL` emits the rest.
|
|
373
|
+
- `SharedForm.tsx` is a near-verbatim fork of `EntryForm.tsx`
|
|
374
|
+
(`localeFromUrl`, `writeLocaleToUrl`, `applyResult`, `InfoRow`, the
|
|
375
|
+
ref-options effect) and `SharedList.tsx` is the third copy of the
|
|
376
|
+
remembered-locale block (`EntriesList`, `PagesList`); extract
|
|
377
|
+
`shell/locale-url.ts` and a `useFormLocale` hook when the next form
|
|
378
|
+
arrives.
|
|
379
|
+
- A placement selected in a bound locked zone shows the canvas toolbar's
|
|
380
|
+
"fields only" label though a placement has no page-level fields; the
|
|
381
|
+
inspector notice is the real message.
|
|
382
|
+
- A zone whose policy allows a shared item's section also offers that
|
|
383
|
+
section as an ordinary block, so the picker lists e.g. Testimonials
|
|
384
|
+
twice (once under Shared); decide whether a section used by a shared
|
|
385
|
+
item should be hidden from the ordinary list.
|
|
386
|
+
- A placement of an object item or an unregistered item gates the page's
|
|
387
|
+
publish (the refs edge is emitted without a registry lookup) yet never
|
|
388
|
+
renders; only a hand-edited tree can produce one.
|
|
389
|
+
- `ops.shared.get` runs `ensureSharedItems` and then lists the item
|
|
390
|
+
again; one round trip could go.
|
|
391
|
+
- The example's Testimonials `quotes` richtext is required, so the first
|
|
392
|
+
admin save of the seeded band asks for it; make it optional or seed it.
|
|
393
|
+
- `CollectionSchema.kind` was widened to carry `"shared"`, so a
|
|
394
|
+
`SharedSchema` typechecks where `registry.collections` is expected;
|
|
395
|
+
`defineConfig` should throw on a `kind === "shared"` entry listed there.
|
|
396
|
+
- `ensureSharedItems` runs on every `ops.shared.list()` — which is every
|
|
397
|
+
editor mount, not only the Shared content page — costing one
|
|
398
|
+
`entries.list` per registration per open.
|
|
399
|
+
- `fanOut` resolves every shared registration's row id (one
|
|
400
|
+
`entries.list` each) on every entry write, so it can expire
|
|
401
|
+
`shared:<name>` when something the item references changes; a row-id
|
|
402
|
+
cache would remove the reads but has to survive materialization.
|
|
403
|
+
- Because `fanOut` lists every shared registration, an entry store built
|
|
404
|
+
without `options.shared` now fails on every ordinary entry write with
|
|
405
|
+
"no collection named", not only on `ops.shared.*`; both scaffolds pass
|
|
406
|
+
the same `shared` array to the store and the config, so this bites only
|
|
407
|
+
a hand-wired app.
|
|
408
|
+
- `ops.entries.move` expires entry tags directly and does not `fanOut`,
|
|
409
|
+
so a reorder that changes what a shared item's `refList` renders does
|
|
410
|
+
not expire `shared:<name>`.
|
|
411
|
+
|
|
305
412
|
## Follow-ups (logged 2026-09-06)
|
|
306
413
|
|
|
307
414
|
- Drag-and-drop in the Pages list — the ops it needs exist
|
|
@@ -310,17 +417,13 @@ site at the tagged commit.
|
|
|
310
417
|
- Nested entries have no admin UI beyond `entries.move`: nothing creates
|
|
311
418
|
a child entry or shows the entry tree, and the entry form's slug prefix
|
|
312
419
|
is the collection's mount base only, not the parent chain.
|
|
313
|
-
- A blank translated slug cannot be CLEARED from the settings panel: the
|
|
314
|
-
panel skips an empty non-default value (a blank falls back to the
|
|
315
|
-
default locale) and the store reads `slug: undefined` as "no change",
|
|
316
|
-
so removing an existing override would need a `null` patch through
|
|
317
|
-
`rename`.
|
|
318
420
|
- SEO/meta fields in page settings — DESIGN.md OQ #17. Title and slug per
|
|
319
421
|
locale shipped; nothing writes `PageTree.meta` yet.
|
|
320
|
-
- `pages.get` makes
|
|
321
|
-
`getDraftTree`, `pathsOf
|
|
322
|
-
|
|
323
|
-
editor
|
|
422
|
+
- `pages.get` makes four sequential store round trips for a non-root page
|
|
423
|
+
(`getPage`, `getDraftTree`, `pathsOf`, and the parent read behind
|
|
424
|
+
`parentLocales`; three for a root, which has no parent to read), and the
|
|
425
|
+
editor calls it on load and after every rename — one store call
|
|
426
|
+
returning them all would cut the editor's latency.
|
|
324
427
|
- `entries.move` expires the entry and its descendants but does not fan
|
|
325
428
|
out to the pages referencing them, nor to the old and the new parent
|
|
326
429
|
entries the way `pages.move` does — asymmetric with `entries.save` and
|
|
@@ -389,8 +492,23 @@ site at the tagged commit.
|
|
|
389
492
|
menu — inside a `role="button"` row. It works, because both stop the
|
|
390
493
|
click and the keydown, but it is an accessibility smell a real treegrid
|
|
391
494
|
would not have.
|
|
392
|
-
- `
|
|
393
|
-
|
|
495
|
+
- `ensureFixedNodes` warns on every `pages.list` while a fixed node's
|
|
496
|
+
locale cannot land (an editor page holds its path); a one-time notice
|
|
497
|
+
in the admin would be kinder than a warning per listing.
|
|
498
|
+
- Cache tags are still `page:<id>`: publishing one locale expires every
|
|
499
|
+
locale's cached unit of that page (spec 2026-09-06 §4). A
|
|
500
|
+
`page:<id>:<locale>` tag would halve the revalidations on a bilingual
|
|
501
|
+
site.
|
|
502
|
+
- The Pages list's confirms (`Add <locale>`, `Remove from <locale>`,
|
|
503
|
+
`Delete … in every language`) are `window.confirm`, and `EntriesList`
|
|
504
|
+
reports a refused add or remove with `window.alert`; the design
|
|
505
|
+
system's dialog is the follow-up for both, together with the row menu
|
|
506
|
+
extraction already logged above.
|
|
507
|
+
- `EditorView.tsx` is ~850 lines now that it also holds the locale switch,
|
|
508
|
+
add and remove flows — see the 2026-09-03 follow-up below for the seams
|
|
509
|
+
(that entry is the one to act on).
|
|
510
|
+
- The `LocaleSwitcher` is a native `<select>`; a `Segmented` with an
|
|
511
|
+
"+ add" affordance would match the Pages list.
|
|
394
512
|
- `i18n/request.ts` returns `{ locale, messages: {} }`. next-intl 4.14.2
|
|
395
513
|
throws `No messages found` when the provider inherits none, and the
|
|
396
514
|
example has no UI strings of its own, so the object is empty; a
|
|
@@ -426,6 +544,134 @@ site at the tagged commit.
|
|
|
426
544
|
(`/((?!admin|api|_next|_vercel|.*\..*).*)`). An app that mounts the
|
|
427
545
|
admin somewhere else edits `proxy.ts` by hand; nothing derives the
|
|
428
546
|
matcher from the config.
|
|
547
|
+
- `classify` in `ops-impl.ts` is ~20 order-sensitive substring checks over
|
|
548
|
+
store messages — `"already exists in locale"` must precede
|
|
549
|
+
`"already exists"`, or an addLocale conflict reads as a slug conflict. A
|
|
550
|
+
typed code on `pageErrors` (thrown and matched, not re-parsed) would
|
|
551
|
+
remove the ordering hazard before plan 2 adds the entry messages.
|
|
552
|
+
- `ops-impl.ts` is ~436 lines with a ~165-line `pages` group. The guard
|
|
553
|
+
helpers (`nameOf`, `isHomeNode`, `supportedLocale`, `mountGuard`,
|
|
554
|
+
`owned`) are the extraction seam — a `pages-ops.ts` taking them as
|
|
555
|
+
collaborators. The `entries` group now duplicates two of them outright:
|
|
556
|
+
the `supportedLocale` guard and the `parentLocales` computation exist
|
|
557
|
+
once per group.
|
|
558
|
+
- `fixedNodeSpecs(config)` rebuilds the whole spec list on every
|
|
559
|
+
`fixedNodeOf` call, and the ops call `fixedNodeOf` per guarded write. A
|
|
560
|
+
`WeakMap` memo keyed by the config object is the whole fix.
|
|
561
|
+
- `SupabasePageStore.updateLocale` re-reads the record (`must(id)`) after
|
|
562
|
+
every update, so `setTemplate` is N+1 in the page's locales: one update
|
|
563
|
+
and one full read each. Only the last read is used. The Supabase entry
|
|
564
|
+
adapter is the same shape: `create` is insert → insert → read →
|
|
565
|
+
version → read → paths → refs, and `removeLocale` re-fetches the entry
|
|
566
|
+
three times.
|
|
567
|
+
- The single-language branch — no locale selector in the Pages list, no
|
|
568
|
+
switcher, no add/remove — has no automated coverage; only three sites
|
|
569
|
+
branch on the locale count (`PagesList.tsx`, `EditorView.tsx` and
|
|
570
|
+
`page-renderer.tsx`'s alternates), so a regression there shows up only
|
|
571
|
+
in a scaffolded single-language app.
|
|
572
|
+
- No test pins that a published child's breadcrumb carries an UNPUBLISHED
|
|
573
|
+
ancestor's title with `hasPage: false`; `site.test.tsx` covers the
|
|
574
|
+
folder ancestor only, and the two paths share one `hasPage` predicate.
|
|
575
|
+
- A locale dropped from `locales.supported` leaves its `page_locales`,
|
|
576
|
+
`page_versions` and `paths` rows in place but unreachable — nothing
|
|
577
|
+
lists or serves them, and nothing cleans them up. It is the mirror of
|
|
578
|
+
the materializer's top-up, and wants the same deliberate answer (an
|
|
579
|
+
admin-visible orphan list, or a prune op).
|
|
580
|
+
- `LOCALE_CODE_RE` accepts any 2–4 letter subtag as script or region
|
|
581
|
+
(`en-ZZZZ` passes), and a duplicate entry in `supported` passes
|
|
582
|
+
silently — boot validation catches typos in shape, not in fact.
|
|
583
|
+
- `MemoryPageStore.publish` throws the "is a folder" message when a
|
|
584
|
+
templated row has no draft version. Unreachable through the ops (a
|
|
585
|
+
templated page always gets a first version), but the message would be
|
|
586
|
+
wrong if it ever were.
|
|
587
|
+
- The Pages list offers "Add <locale>" on a dimmed row whose parent lacks
|
|
588
|
+
that locale, and learns the refusal only after the round trip.
|
|
589
|
+
`pagesTreeRows` already carries every node's locales, so the parent's
|
|
590
|
+
presence could disable the item with its reason up front, exactly as
|
|
591
|
+
the editor's switcher does with `parentLocales`. `EntriesList` has the
|
|
592
|
+
same gap (and `entryRows` would have to carry the locales again — the
|
|
593
|
+
dead field was deleted rather than left unused); it is unreachable
|
|
594
|
+
today, since nothing in the admin creates a child entry.
|
|
595
|
+
- No version history UI for entries (nor pages): `listVersions` exists on
|
|
596
|
+
both stores; a history pane with restore is the follow-up. Restoring a
|
|
597
|
+
versioned entry's snapshot must also restore the shared fields (the
|
|
598
|
+
snapshot is merged).
|
|
599
|
+
- `entries.list` filters `{ locale, status }` in memory after fetching
|
|
600
|
+
every row (PostgREST cannot filter the parent by an embedded row
|
|
601
|
+
without `!inner`, which would drop the other rows); fine at collection
|
|
602
|
+
sizes today.
|
|
603
|
+
- The entry form's ref picker offers only targets present in the locale
|
|
604
|
+
being edited; a target present but unpublished there is offered marked
|
|
605
|
+
`(draft)` and blocks a page publish in that locale until published —
|
|
606
|
+
the picker could say so.
|
|
607
|
+
- A collection's `path` map that omits a supported locale falls back to
|
|
608
|
+
the default segment for entry paths (`collectionSegment`) while the
|
|
609
|
+
mount node is materialized map-keys-only; the two should agree as
|
|
610
|
+
fixed pages now do.
|
|
611
|
+
- `removeLocale` on the Supabase entry adapter is deliberately
|
|
612
|
+
non-atomic (row and versions go, then paths), the same trade-off as
|
|
613
|
+
pages.
|
|
614
|
+
- `EntryForm.tsx` grew the locale lifecycle beside the fields; the
|
|
615
|
+
`LocaleSwitcher` is shared with the editor now, the add/remove flows
|
|
616
|
+
are not (`doAddLocale`/`doRemoveLocale` exist twice).
|
|
617
|
+
- Turning `.localized()` off for a field with per-locale values leaves
|
|
618
|
+
those values in `entry_locales.fields` until the next save of each
|
|
619
|
+
locale (a save writes only the current split) — the drift/codemod
|
|
620
|
+
pipeline (Phase 2) is where "keep en, discard fi" belongs.
|
|
621
|
+
- The repo-root migration (`supabase/migrations/00000000000000_smoodly.sql`)
|
|
622
|
+
is generated by `packages/smoodly/scripts/emit-sql.ts` from that
|
|
623
|
+
script's OWN fixture collections, not from
|
|
624
|
+
`examples/site/smoodly/collections.ts`; the create-smoodly-app template
|
|
625
|
+
migration IS generated from the example. Since the example's `articles`
|
|
626
|
+
is now `versions: true`, the repo-root `articles` view reads the live
|
|
627
|
+
locale row while the example's model serves the published snapshot —
|
|
628
|
+
the two should share one source.
|
|
629
|
+
- In the entry form, a shared ref field (e.g. an article's author) shows
|
|
630
|
+
`—` in a locale where the target is absent, because the picker offers
|
|
631
|
+
only targets present in the edited locale; the value is kept on the
|
|
632
|
+
node and the other locale's byline is unchanged, but it looks empty
|
|
633
|
+
and cannot be re-set from that locale.
|
|
634
|
+
- `entryPathRows` relies on the stores validating locale keys on write
|
|
635
|
+
(no guard of its own); `list`/`getMany` accept an unsupported locale
|
|
636
|
+
silently and read as "no content".
|
|
637
|
+
- The Supabase entry adapter's `removeLocale` deletes the row and its
|
|
638
|
+
versions before the paths rewrite with no rollback; its residue
|
|
639
|
+
comment describes the opposite case (a surviving path row for a
|
|
640
|
+
removed locale, not a present node without its path row).
|
|
641
|
+
- The entry form's locale switch, add and remove confirm before
|
|
642
|
+
discarding unsaved edits (a `dirty` flag; the form has no autosave,
|
|
643
|
+
unlike the page editor's flush).
|
|
644
|
+
- `addLocale` does not bump the entry's `updatedAt` while `removeLocale`
|
|
645
|
+
does. Both adapters agree, so the contract is honest, but "adding a
|
|
646
|
+
language is not an edit and removing one is" is arbitrary — decide it
|
|
647
|
+
deliberately before someone sorts a list by Updated.
|
|
648
|
+
- The Supabase entry adapter's rollbacks are best-effort and can mask the
|
|
649
|
+
original throw: `create`'s catch runs `paths.remove` before the node
|
|
650
|
+
delete (a failing remove strands the node and replaces the real error),
|
|
651
|
+
`update` and `move` restore by writing, and those writes can throw in
|
|
652
|
+
place of the rewrite's rejection. `recordVersion` is two statements
|
|
653
|
+
(insert, then point at it), so a failure between them leaves an
|
|
654
|
+
unreferenced `entry_versions` row — invisible to every policy and
|
|
655
|
+
harmless, but it accumulates.
|
|
656
|
+
- `removeLocale`'s blocker list (`localeInUse`) is unordered in the
|
|
657
|
+
Supabase adapter — the children query has no `.order()` — so a
|
|
658
|
+
multi-blocker message can name the titles in a different order than the
|
|
659
|
+
memory adapter's. Message text only; the contract case has one blocker.
|
|
660
|
+
- `entry-store.ts` is ~624 lines. The pure helpers (`localizedKeys`,
|
|
661
|
+
`splitFields`, `entryFieldsIn`, `entryRefEdges`, `entryTitleIn`) are the
|
|
662
|
+
extraction seam from the memory adapter, the same shape as the
|
|
663
|
+
`EditorView.tsx` and `ops-impl.ts` entries above.
|
|
664
|
+
- In a single-language project the entry form renders `slug` last:
|
|
665
|
+
`localizedFieldKeys` puts it in the `own` group, which follows
|
|
666
|
+
`shared`, and no section labels are shown — so the field order the
|
|
667
|
+
schema declares is not the order on screen.
|
|
668
|
+
- `admin-entries-list.test.ts` has no case for a row with differing
|
|
669
|
+
pointers (the `draft edits` status), nor one asserting that an absent
|
|
670
|
+
row's `updatedAt` falls back to the node's. Two assertions.
|
|
671
|
+
- `assertPublishable` (`store.ts`) awaits one `entries.get` per
|
|
672
|
+
referenced entry, sequentially, on every publish — a `refList` of a
|
|
673
|
+
dozen articles is a dozen serial reads, now per locale. `getMany(ids,
|
|
674
|
+
locale)` takes exactly that shape and would make it one call.
|
|
429
675
|
|
|
430
676
|
## Follow-ups (logged 2026-09-05)
|
|
431
677
|
|
|
@@ -450,18 +696,18 @@ site at the tagged commit.
|
|
|
450
696
|
- `sections` is passed twice — to `createSmoodlySite` (ref resolution) and to
|
|
451
697
|
`createPageRenderer` (the component map) — and `elements` cannot be passed
|
|
452
698
|
at all; the two lists can drift silently.
|
|
453
|
-
- `getEntry` by bare slug matches `entrySegment`
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
one row.
|
|
699
|
+
- `getEntry` by bare slug matches `entrySegment` — the locale row's own
|
|
700
|
+
`slug`, the same value `paths` is written from — so the two cannot
|
|
701
|
+
diverge (the double-overlay hazard went with `EntryRecord.i18n`).
|
|
702
|
+
`getEntry` by id still reads the whole collection to find one row.
|
|
457
703
|
- `locate` (one primary-key hit on `paths`) runs per request outside the
|
|
458
704
|
cache, because the cache tag needs the id before the unit can be cached; a
|
|
459
705
|
later `"use cache"` migration (OQ #19) can fold it in.
|
|
460
706
|
- Entry-page metadata is alternates only — entries have no `meta`. A
|
|
461
707
|
`titleField`-based title is a later nicety.
|
|
462
|
-
-
|
|
463
|
-
|
|
464
|
-
|
|
708
|
+
- Per-locale section visibility conditions are not modelled (OQ #3). Tree
|
|
709
|
+
content itself is per locale now — each `page_locales` row points at its
|
|
710
|
+
own version — so the section fields a locale sees are its own.
|
|
465
711
|
- `getEntries`/`getEntriesTree` return refs as ids (cheap lists); `getEntry`
|
|
466
712
|
by path resolves them, `getEntry` by id or slug does not — document or
|
|
467
713
|
unify when a site needs resolved lists.
|
|
@@ -478,7 +724,8 @@ site at the tagged commit.
|
|
|
478
724
|
- `SmoodlyConfig.registry.pages` (`Registered` in `src/config.ts`) has no
|
|
479
725
|
call signature, so every app's glue casts it `as PageRegistration[]`.
|
|
480
726
|
- The entry-page view receives the whole `EntryRecord`, every locale's
|
|
481
|
-
`
|
|
727
|
+
row in `locales` included (fields, slug, status and version pointers); a
|
|
728
|
+
client entry page would ship all of it to the browser.
|
|
482
729
|
- `move` is best-effort across two statements (reorder, then path rewrite):
|
|
483
730
|
a failed rewrite restores each destination sibling's prior `sort` row by
|
|
484
731
|
row, so a failure in the rollback itself still leaves the order changed.
|
|
@@ -527,9 +774,6 @@ site at the tagged commit.
|
|
|
527
774
|
names no path.
|
|
528
775
|
- `chainOf` has no cycle guard: a corrupted `parent_id` loop would spin
|
|
529
776
|
forever. The stores prevent cycles on `move`, so it is unreachable today.
|
|
530
|
-
- `EntryRecord.i18n` has no writer yet — the store reads and derives paths
|
|
531
|
-
from it, but nothing sets it, so per-locale entry slugs are unreachable
|
|
532
|
-
until the ops layer exposes them.
|
|
533
777
|
- Design system pass (DESIGN.md §2, "The admin's visual system"): the shell,
|
|
534
778
|
lists, entry form, login and editor were restyled onto `admin/ui/theme.tsx`.
|
|
535
779
|
Deferred deliberately, because each is a feature rather than a restyle: the
|
|
@@ -582,4 +826,4 @@ site at the tagged commit.
|
|
|
582
826
|
- `serialize.ts` structural cast duplicates `PageSchema`; tighten `Registered.schema` so the cast goes.
|
|
583
827
|
- Catch-all `templates` map should filter to open registrations to state its intent (unreachable today).
|
|
584
828
|
- The no-database build is the real proof that routes never prerender against the stores; the unit test only stubs `next/server`. Script it (`SUPABASE_URL=http://127.0.0.1:9 npx next build` in `examples/site`, expect `ƒ /`) and add it to CI, which today runs only the package's tests and typecheck — so a regression is caught when Next changes `connection()` semantics.
|
|
585
|
-
- `EditorView` split, remaining seams (autosave extracted 2026-09-03): pull the canvas overlay and the insert picker into components when the dnd-kit outline work starts; the sidebar form stays put until inline editing lands (DESIGN.md §3 puts TipTap in the canvas, not the sidebar).
|
|
829
|
+
- `EditorView` split, remaining seams (autosave extracted 2026-09-03): the locale lifecycle — `localeFromUrl`, `LocaleSwitcher`, `switchLocale`, `doAddLocale`, `doRemoveLocale` — is the cleanest extraction available now (self-contained, no canvas geometry); pull the canvas overlay and the insert picker into components when the dnd-kit outline work starts; the sidebar form stays put until inline editing lands (DESIGN.md §3 puts TipTap in the canvas, not the sidebar). (logged 2026-09-06)
|
package/dist/admin/client-ops.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
const GROUPS = {
|
|
2
|
-
pages: ["list", "get", "create", "saveDraft", "publish", "delete", "rename", "move", "setTemplate"],
|
|
3
|
-
entries: ["list", "get", "create", "save", "setStatus", "delete", "move"],
|
|
2
|
+
pages: ["list", "get", "create", "saveDraft", "publish", "delete", "rename", "move", "setTemplate", "addLocale", "removeLocale"],
|
|
3
|
+
entries: ["list", "get", "create", "save", "setStatus", "delete", "move", "addLocale", "removeLocale"],
|
|
4
|
+
shared: ["list", "get", "save", "setStatus"],
|
|
4
5
|
preview: ["enable", "disable"],
|
|
5
6
|
};
|
|
6
7
|
export function makeClientOps(action, token) {
|