smoodly 0.0.4 → 0.0.5
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 +408 -34
- package/dist/admin/client-ops.js +2 -2
- package/dist/admin/editor/EditorView.d.ts +3 -2
- package/dist/admin/editor/EditorView.js +133 -80
- package/dist/admin/editor/PageSettings.d.ts +13 -0
- package/dist/admin/editor/PageSettings.js +40 -0
- package/dist/admin/fixed-nodes.d.ts +23 -0
- package/dist/admin/fixed-nodes.js +83 -0
- package/dist/admin/forms/FieldWidget.d.ts +2 -1
- package/dist/admin/forms/FieldWidget.js +16 -16
- package/dist/admin/index.d.ts +2 -1
- package/dist/admin/index.js +1 -0
- package/dist/admin/next/create-admin.js +7 -2
- package/dist/admin/next/op-handler.d.ts +1 -0
- package/dist/admin/next/op-handler.js +7 -2
- package/dist/admin/ops-impl.d.ts +4 -1
- package/dist/admin/ops-impl.js +190 -48
- package/dist/admin/ops.d.ts +39 -11
- package/dist/admin/serialize.d.ts +10 -1
- package/dist/admin/serialize.js +13 -2
- package/dist/admin/shell/AdminApp.js +36 -74
- package/dist/admin/shell/EntryForm.js +34 -10
- package/dist/admin/shell/ErrorPane.d.ts +4 -0
- package/dist/admin/shell/ErrorPane.js +9 -0
- package/dist/admin/shell/ListView.d.ts +8 -5
- package/dist/admin/shell/ListView.js +12 -11
- package/dist/admin/shell/LoginView.js +6 -2
- package/dist/admin/shell/PagesList.d.ts +6 -0
- package/dist/admin/shell/PagesList.js +148 -0
- package/dist/admin/shell/base.d.ts +1 -0
- package/dist/admin/shell/base.js +4 -0
- package/dist/admin/shell/pages-tree.d.ts +39 -0
- package/dist/admin/shell/pages-tree.js +65 -0
- package/dist/admin/ui/format.d.ts +1 -0
- package/dist/admin/ui/format.js +25 -0
- package/dist/admin/ui/primitives.d.ts +89 -0
- package/dist/admin/ui/primitives.js +115 -0
- package/dist/admin/ui/theme.d.ts +5 -0
- package/dist/admin/ui/theme.js +396 -0
- package/dist/collections.d.ts +23 -2
- package/dist/collections.js +19 -0
- package/dist/config.d.ts +11 -1
- package/dist/config.js +71 -13
- package/dist/entry-page.d.ts +16 -0
- package/dist/entry-page.js +6 -0
- package/dist/entry-store.d.ts +94 -7
- package/dist/entry-store.js +175 -26
- package/dist/index.d.ts +24 -8
- package/dist/index.js +14 -3
- package/dist/localize.d.ts +19 -0
- package/dist/localize.js +42 -0
- package/dist/next/index.d.ts +2 -2
- package/dist/next/index.js +1 -1
- package/dist/next/meta.d.ts +0 -6
- package/dist/next/meta.js +0 -11
- package/dist/next/page-renderer.d.ts +26 -18
- package/dist/next/page-renderer.js +79 -56
- package/dist/page.d.ts +10 -3
- package/dist/page.js +12 -2
- package/dist/pairing.d.ts +7 -6
- package/dist/path-index.d.ts +16 -0
- package/dist/path-index.js +33 -0
- package/dist/paths.d.ts +80 -0
- package/dist/paths.js +110 -0
- package/dist/render.d.ts +34 -5
- package/dist/render.js +9 -2
- package/dist/resolve.d.ts +8 -0
- package/dist/resolve.js +19 -7
- package/dist/revalidate.d.ts +10 -2
- package/dist/revalidate.js +13 -8
- package/dist/site.d.ts +85 -0
- package/dist/site.js +164 -0
- package/dist/sql-space.d.ts +1 -1
- package/dist/sql-space.js +2 -0
- package/dist/sql.d.ts +1 -1
- package/dist/sql.js +92 -15
- package/dist/store.d.ts +106 -30
- package/dist/store.js +242 -66
- package/dist/supabase-entry-store.d.ts +17 -3
- package/dist/supabase-entry-store.js +198 -22
- package/dist/supabase-path-index.d.ts +11 -0
- package/dist/supabase-path-index.js +52 -0
- package/dist/supabase-store.d.ts +46 -16
- package/dist/supabase-store.js +322 -110
- package/package.json +5 -4
- package/dist/admin/page-path.d.ts +0 -1
- package/dist/admin/page-path.js +0 -6
- package/dist/admin/shell/pages-list.d.ts +0 -22
- package/dist/admin/shell/pages-list.js +0 -31
package/README.md
CHANGED
|
@@ -41,26 +41,69 @@ Implemented:
|
|
|
41
41
|
`coreTablesSQL` emits the once-ever core tables including `entries`
|
|
42
42
|
(`collection` + `fields`/`i18n` jsonb, `space_id` multi-tenancy with
|
|
43
43
|
a default space for self-hosted, `sort` for manual ordering, ONE
|
|
44
|
-
generic slug-uniqueness index across all spaces and collections
|
|
44
|
+
generic slug-uniqueness index across all spaces and collections,
|
|
45
|
+
scoped to siblings — `(space_id, collection, parent_id, slug)`, like
|
|
46
|
+
pages, partial on a non-null slug);
|
|
45
47
|
`collectionSQL` emits only the disposable per-collection guardrails —
|
|
46
48
|
a published-only read view over `entries` (typed casts, snake_case
|
|
47
49
|
aliases, refs as plain id values) and partial btree indexes for
|
|
48
|
-
declared
|
|
49
|
-
- `PageStore` contract + `MemoryPageStore` —
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
`
|
|
50
|
+
the declared `order.by` field, on the jsonb VALUE (`("fields"->'x')`)
|
|
51
|
+
- `PageStore` contract + `MemoryPageStore` — id-keyed page persistence:
|
|
52
|
+
every read and write takes the record's `id`, and the URL is derived
|
|
53
|
+
from the tree, never stored on the record. Pages form a tree (`parentId`,
|
|
54
|
+
sibling `sort`, a null `template` = folder, `pages.tree.depth` capping
|
|
55
|
+
how deep editors may nest), carry a `title` and a per-locale `i18n`
|
|
56
|
+
overlay, and expose `rename` (per-locale slug/title), `move`,
|
|
57
|
+
`setTemplate`, `resolvePath(locale, path)` and `pathsOf(id)` alongside
|
|
58
|
+
the draft/publish semantics (versions accumulate; publish moves a
|
|
59
|
+
pointer; drafts never leak into published reads). The contract is a
|
|
60
|
+
reusable suite (`test/page-store-contract.ts`); every adapter runs the
|
|
61
|
+
same tests.
|
|
62
|
+
- `SupabasePageStore` — the same id-keyed contract over
|
|
63
|
+
pages/page_versions/paths (a server-side client from
|
|
64
|
+
`createSmoodlyClient`: the service role in self-host, a write key in
|
|
65
|
+
the cloud). Contract-tested against a real local stack: `supabase
|
|
66
|
+
start` at the repo root (the generated DDL is its one migration), then
|
|
67
|
+
`npm run test:supabase`; plain `npm test` skips it and never needs
|
|
68
|
+
Docker.
|
|
69
|
+
- `paths.ts` — the ONE place a URL is derived: pure, framework-free
|
|
70
|
+
functions (`segmentFor`, `chainOf`, `pagePath`, `pagePathRows`,
|
|
71
|
+
`entryPathRows`, `buildPageTree`, segment and depth assertions) that both
|
|
72
|
+
adapters and the tree query call, so the index and every derived `path`
|
|
73
|
+
agree by construction. `pagePath` is the single page-path derivation —
|
|
74
|
+
`pagePathRows`, `buildPageTree` and the site layer all go through it.
|
|
75
|
+
- `PathIndex` — the derived URL index, `MemoryPathIndex` and
|
|
76
|
+
`SupabasePathIndex` over the `paths` table ((space, locale, path) → page
|
|
77
|
+
or entry). Pages and entries share ONE instance, so a page and an entry
|
|
78
|
+
can never claim the same URL; the Supabase adapter rewrites a whole
|
|
79
|
+
subtree in one statement through the `smoodly_rewrite_paths` SQL
|
|
80
|
+
function, and a collision surfaces as "the path … is already taken".
|
|
81
|
+
- Collections declare their own URL shape: `path` (the code-owned base
|
|
82
|
+
segment, per locale — absent = the collection has no URLs), `tree`
|
|
83
|
+
(entries nest under entries up to a depth), and `order` (`"manual"` or
|
|
84
|
+
`{ by, direction }`, replacing `admin.orderBy` and driving both the
|
|
85
|
+
store's list order and the generated index). A declared field orders by
|
|
86
|
+
its jsonb VALUE, so numbers sort numerically and strings — ISO dates
|
|
87
|
+
included — alphabetically, the same in Supabase as in memory.
|
|
88
|
+
- `smoodly.entryPage(collection, View)` — a paired component registered
|
|
89
|
+
like a page (`kind: "entryPage"`, name `entry:<collection>`), served for
|
|
90
|
+
every entry of the collection by the catch-all through
|
|
91
|
+
`renderSmoodlyPath`.
|
|
59
92
|
- `resolveTree` — the batched ref-resolution pass: one fetch per
|
|
60
93
|
collection per round, ref-site depth modes (bare ref = entry only,
|
|
61
94
|
`.resolve(names)` = named refs one level, bare `.resolve()` = whole
|
|
62
95
|
chain), per-path cycle cutting with shallow copies, optional `maxDepth`
|
|
63
96
|
safety valve (default unlimited)
|
|
97
|
+
- `resolveFields` — the same pass over ONE entry's fields against its
|
|
98
|
+
collection's descriptors, for the entry-page route: the shared task
|
|
99
|
+
collection and batched rounds are factored out, so a ref site behaves
|
|
100
|
+
identically whether it sits in a page tree or in an entry.
|
|
101
|
+
- `localize.ts` — per-field locale fallback over the two overlays the
|
|
102
|
+
stores carry: `titleFor` (a page's `i18n[locale].title`, else the default
|
|
103
|
+
locale's), `localizeFields` (an entry's `i18n[locale]`, per field, with a
|
|
104
|
+
blank overlay value falling back) and `fixedPathFor` (the path a fixed
|
|
105
|
+
page registration is served at in one locale — the escape hatch's
|
|
106
|
+
addressing). Slugs stay `paths.ts`'s business.
|
|
64
107
|
|
|
65
108
|
- `collectEntryRefs` / `collectTreeRefs` — the refs-index feeders: walk
|
|
66
109
|
an entry or a page tree against its schemas and list every outgoing
|
|
@@ -70,9 +113,13 @@ Implemented:
|
|
|
70
113
|
every `saveDraft`/`publish`, so cross-kind safe-delete holds while
|
|
71
114
|
either tree shows an entry. Publish integrity: `publish` walks the
|
|
72
115
|
draft tree directly and blocks on missing/unpublished entries.
|
|
73
|
-
Revalidation fan-out: `
|
|
116
|
+
Revalidation fan-out: `affectedTargets` — reverse BFS over refs
|
|
74
117
|
(entry ← entry ← page, cycle-safe, optional `maxDepth`) returning
|
|
75
|
-
|
|
118
|
+
`{ pageIds, entryIds }`: every PUBLISHED page whose tree reaches the
|
|
119
|
+
changed entry, and the entry itself plus every entry that references it.
|
|
120
|
+
Ids, not slugs — `pageTag(id)`/`entryTag(id)` name the cache tags, and
|
|
121
|
+
the op handler's `revalidate` effect passes each to Next's `updateTag`,
|
|
122
|
+
so a rename never orphans a tag. All wired via
|
|
76
123
|
store constructor options (`sections`, `entries`, shared
|
|
77
124
|
`MemoryRefIndex` in memory); the cross-store contract
|
|
78
125
|
(`test/page-refs-contract.ts`) runs against memory AND Supabase.
|
|
@@ -84,6 +131,9 @@ Implemented:
|
|
|
84
131
|
published-route read — a post-publish demotion resolves as missing
|
|
85
132
|
instead of leaking draft fields — DESIGN.md §3 "Page edges in refs"), deletes are blocked while
|
|
86
133
|
referenced,
|
|
134
|
+
entry slugs are unique among SIBLINGS in both adapters (the same rule
|
|
135
|
+
pages have: `/docs/guide-a/intro` and `/docs/guide-b/intro` coexist,
|
|
136
|
+
a slug-less entry never conflicts),
|
|
87
137
|
and `reorder` rewrites sort 1..N through the `smoodly_reorder` SQL
|
|
88
138
|
function (one statement, `updated_at` untouched). Same
|
|
89
139
|
reusable-contract pattern as PageStore; `npm run test:supabase` runs
|
|
@@ -91,7 +141,12 @@ Implemented:
|
|
|
91
141
|
|
|
92
142
|
- Proven end-to-end in `examples/site` — a real Next.js app wiring the
|
|
93
143
|
config + Supabase stores into routes: tag-cached resolved trees,
|
|
94
|
-
draft-mode preview, server-action writes with revalidation fan-out
|
|
144
|
+
draft-mode preview, server-action writes with revalidation fan-out.
|
|
145
|
+
The example is the multilingual layout (next-intl, `en`/`fi`, the site
|
|
146
|
+
under `app/[locale]/`, the admin outside it), and `create-smoodly-app`
|
|
147
|
+
generates both of its templates from it — `templates/next-intl`
|
|
148
|
+
verbatim, `templates/next` with the four single-language route files
|
|
149
|
+
from `variants/single/` and two rewritten lines of glue
|
|
95
150
|
|
|
96
151
|
- The space layer (spec 2026-09-04 §3): `space_id` on every content
|
|
97
152
|
table, `smoodly_current_space()`, the insert trigger, the row cap, the
|
|
@@ -137,19 +192,86 @@ Implemented:
|
|
|
137
192
|
`examples/site/app/(smoodly)/admin` against the local Supabase stack —
|
|
138
193
|
see its README's acceptance walk.
|
|
139
194
|
|
|
140
|
-
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
195
|
+
- The admin is the page TREE (spec 2026-09-05 §7): the ops seam addresses
|
|
196
|
+
pages by id — `pages.list`/`get`/`create`/`saveDraft`/`publish`/
|
|
197
|
+
`delete`/`rename`/`move`/`setTemplate`, plus `entries.move`.
|
|
198
|
+
`pages.list` materializes the code-owned root nodes before an editor
|
|
199
|
+
touches them (`admin/fixed-nodes.ts` derives fixed pages and collection
|
|
200
|
+
mounts from the registry, never from a column), and every guard asks
|
|
201
|
+
the same `fixedNodeOf`: a fixed node is never renamed, moved or
|
|
202
|
+
deleted, and no page is created under a mount, and a `homePageSlug`
|
|
203
|
+
guard keeps the site root from being deleted, moved or re-slugged
|
|
204
|
+
even without a fixed registration. `pages.get` returns the
|
|
205
|
+
record, its draft, `paths` (locale → CMS path) and `urls` (`href`
|
|
206
|
+
applied), so the canvas and "View live" get site URLs. A tree write
|
|
207
|
+
expires the neighbours whose cached units embed it: publish and delete
|
|
208
|
+
the record and its parent, rename the subtree and its parent, move the
|
|
209
|
+
subtree plus the old and the new parent; `entries.move` expires the
|
|
210
|
+
entry and its descendants. The Pages list IS that tree — `admin/shell/pages-tree.ts`
|
|
211
|
+
is the pure view model (indent per depth, chevron on parents, the
|
|
212
|
+
derived path as the Path column, and per row its kind, status, sibling
|
|
213
|
+
position and whether it may take children), rendered by
|
|
214
|
+
`admin/shell/PagesList.tsx` with a row menu over the tree ops (New
|
|
215
|
+
subpage, New subfolder, Add page here, Rename…, Move up / Move down,
|
|
216
|
+
Delete, and Edit index page on a mount a fixed page claims). The editor
|
|
217
|
+
is keyed by id, with a locale switcher pointing the canvas at
|
|
218
|
+
`urls[locale]` and, while no block is selected, page settings
|
|
219
|
+
(`admin/editor/PageSettings.tsx`: title and slug per locale through
|
|
220
|
+
`pages.rename`; SEO/meta fields stay OQ #17). The entry form prefixes
|
|
221
|
+
its slug field with the collection's mount path. The serialized
|
|
222
|
+
registry carries `collections[].path`/`depth`, `pages[].slugs` and
|
|
223
|
+
`pageDepth`, and `defineConfig` validates `homePageSlug` — a real
|
|
224
|
+
segment, and never a collection's mount.
|
|
225
|
+
|
|
226
|
+
- `createSmoodlySite` — the site layer (`site.ts`), framework-free: one
|
|
227
|
+
place turns a (locale, path) into a loaded page or entry, so the Next
|
|
228
|
+
renderer and the content API can never disagree about published-only
|
|
229
|
+
reads, folders or fallback. `locate(locale, path)` is one primary-key
|
|
230
|
+
hit on the `paths` index; `load(target, locale, path, { draft? })`
|
|
231
|
+
fetches the record and its tree (draft or published), resolves refs,
|
|
232
|
+
applies the `i18n` overlays, and returns a `SitePage` — `record`,
|
|
233
|
+
`template`, `tree`, localized `title`, `path`, `ancestors`, `children`,
|
|
234
|
+
and `paths` (locale → path, for alternates) — or a `SiteEntry` with the
|
|
235
|
+
entry's fields localized and resolved. `resolve(locale, path, { draft? })`
|
|
236
|
+
is the two in sequence — `locate` then `load` — for a caller that just
|
|
237
|
+
wants the hit. A folder (null template), an
|
|
238
|
+
unpublished record outside draft mode, or a missing one is `null`.
|
|
239
|
+
On top of it the published-only content API for menus, sitemaps and
|
|
240
|
+
custom routes: `getPagesTree({ locale })` (nested
|
|
241
|
+
`{ id, title, path, hasPage, children }`), `getPage(pathOrId)`,
|
|
242
|
+
`getEntries(collection, { locale, parent?, order? })`,
|
|
243
|
+
`getEntry(collection, idOrPath)` (an id, a path, or a bare slug naming a
|
|
244
|
+
root entry) and `getEntriesTree(collection)`.
|
|
245
|
+
- `smoodly/next` — `createPageRenderer`, the site-route seam over that
|
|
246
|
+
layer, bound once by the app like `createAdminOpHandler`. The two route
|
|
247
|
+
files call `renderSmoodlyPath(segments, { locale?, searchParams? })` and
|
|
248
|
+
`smoodlyMetadata(segments, { locale? })`: a page at any depth in any
|
|
249
|
+
locale rendered by the shape its `template` names, with the `page` prop
|
|
250
|
+
(`{ id, title, path, url, ancestors, children }`, every ancestor and
|
|
251
|
+
child carrying `url` beside its locale-relative `path`, both put through
|
|
252
|
+
the `href` seam) beside `zones`; a collection
|
|
253
|
+
entry rendered by its `smoodly.entryPage` as `{ entry, path }`; a folder,
|
|
254
|
+
an absent locale or an unregistered template a 404. The published read is
|
|
255
|
+
cached per (locale, path) and tagged `page:<id>` / `entry:<id>` — the
|
|
256
|
+
`locate` hit runs outside the cache, because the tag needs the id first.
|
|
257
|
+
Draft mode reads drafts uncached, and the editor param mounts
|
|
258
|
+
`EditorBridge`. Metadata is the tree's `meta` plus
|
|
259
|
+
`alternates.languages`, built through the injectable `href(path, locale)`
|
|
260
|
+
seam (identity by default, `getPathname` with next-intl) and emitted only
|
|
261
|
+
for a multi-locale site. `renderSmoodlyPage(Paired, …)` and
|
|
262
|
+
`smoodlyPageMetadata` remain as the escape hatch for a FIXED page
|
|
263
|
+
rendered from the developer's own route file; each serves only its own
|
|
264
|
+
registration's record. All four await Next's
|
|
265
|
+
`connection()` before reading the stores, so a route with no dynamic
|
|
266
|
+
segment is request-rendered without a `force-dynamic` export and
|
|
267
|
+
`next build` never reaches the database
|
|
268
|
+
(`test/next-page-renderer.test.tsx`). Page schemas may declare a fixed `slug`
|
|
269
|
+
(owned by code, not by editors); the admin reads it to filter
|
|
149
270
|
New Page and protect the record. `defineConfig({ homePageSlug })`
|
|
150
|
-
(default `"home"`) names the slug the site root serves; the
|
|
151
|
-
|
|
152
|
-
|
|
271
|
+
(default `"home"`) names the slug the site root serves; the paths index
|
|
272
|
+
derives every URL from it, and `pages.get(id)` hands the admin the
|
|
273
|
+
finished `paths` (locale → CMS path) and `urls` (`href` applied), so the
|
|
274
|
+
admin no longer derives a path of its own.
|
|
153
275
|
|
|
154
276
|
- Locked zones are usable: `fillLockedZones` (a pure tree op) materializes
|
|
155
277
|
the one node of every empty locked zone from the component's `sample`
|
|
@@ -160,9 +282,11 @@ Implemented:
|
|
|
160
282
|
|
|
161
283
|
Not yet: richtext components (TipTap; `plainToRichtext`/
|
|
162
284
|
`richtextToPlain` exist but no rich editor widget), drag-and-drop
|
|
163
|
-
reordering (move-up/move-down buttons
|
|
164
|
-
|
|
165
|
-
locale
|
|
285
|
+
reordering (move-up/move-down buttons in the editor, Move up / Move down
|
|
286
|
+
in the Pages list's row menu), undo/redo, sample/placeholder content
|
|
287
|
+
marking, per-locale section field values (OQ #3 — the editor's locale
|
|
288
|
+
switcher moves the canvas and page settings between locales, but a
|
|
289
|
+
section's fields are one set for every locale).
|
|
166
290
|
|
|
167
291
|
## Releasing
|
|
168
292
|
|
|
@@ -178,6 +302,259 @@ Both packages move together. From the repo root:
|
|
|
178
302
|
`examples/site` in that build, so the template always matches the example
|
|
179
303
|
site at the tagged commit.
|
|
180
304
|
|
|
305
|
+
## Follow-ups (logged 2026-09-06)
|
|
306
|
+
|
|
307
|
+
- Drag-and-drop in the Pages list — the ops it needs exist
|
|
308
|
+
(`pages.move(id, { parentId, index })`); the row menu's Move up / Move
|
|
309
|
+
down is the interim.
|
|
310
|
+
- Nested entries have no admin UI beyond `entries.move`: nothing creates
|
|
311
|
+
a child entry or shows the entry tree, and the entry form's slug prefix
|
|
312
|
+
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
|
+
- SEO/meta fields in page settings — DESIGN.md OQ #17. Title and slug per
|
|
319
|
+
locale shipped; nothing writes `PageTree.meta` yet.
|
|
320
|
+
- `pages.get` makes three sequential store round trips (`getPage`,
|
|
321
|
+
`getDraftTree`, `pathsOf`), and the editor calls it on load and after
|
|
322
|
+
every rename — one store call returning the three would halve the
|
|
323
|
+
editor's latency.
|
|
324
|
+
- `entries.move` expires the entry and its descendants but does not fan
|
|
325
|
+
out to the pages referencing them, nor to the old and the new parent
|
|
326
|
+
entries the way `pages.move` does — asymmetric with `entries.save` and
|
|
327
|
+
`entries.setStatus`, which both fan out.
|
|
328
|
+
- The row menu's item set (`menuItems`) and the row-click routing live in
|
|
329
|
+
`PagesList.tsx`, untested; extract them into `pages-tree.ts` before
|
|
330
|
+
plan 4 adds entry rows to the same menu.
|
|
331
|
+
- The shell derives the fixed nodes a second time, over the serialized
|
|
332
|
+
registry (`mountOf` / `isFixedNode` in `pages-tree.ts`), beside the
|
|
333
|
+
server's `fixedNodeOf` — one derivation shipped to the client would do.
|
|
334
|
+
- The locale switcher points the canvas at `urls[locale]`, and `href` is
|
|
335
|
+
identity in the example, so every path serves the default locale: a
|
|
336
|
+
page with a translated slug shows a 404 in the canvas for that locale
|
|
337
|
+
until next-intl is wired (plan 4). **Done 2026-09-06** — `href` is
|
|
338
|
+
`getPathname` in the example now; the canvas loads
|
|
339
|
+
`/fi/yritys/tietoa-meista?smoodly-editor=1` and renders (walked in
|
|
340
|
+
`examples/site/README.md`).
|
|
341
|
+
- Page settings prints `paths[locale]` under each slug field — the
|
|
342
|
+
locale-relative CMS path, not `urls[locale]`, the site URL the canvas
|
|
343
|
+
and "View live" actually open. On a translated site the Finnish hint
|
|
344
|
+
reads `/yritys/tietoa-meista` while the page lives at
|
|
345
|
+
`/fi/yritys/tietoa-meista`. Both maps are already in `EditorView`;
|
|
346
|
+
which one belongs under the field is a UI decision, not a missing
|
|
347
|
+
value.
|
|
348
|
+
- `ensureFixedNodes` runs on every `pages.list` and every ROOT
|
|
349
|
+
`pages.create` — one `listPages()` each, and a second one when it
|
|
350
|
+
tried to create something. It also treats a root record sitting at a
|
|
351
|
+
fixed slug as that node even when its `template` is a foreign one: the
|
|
352
|
+
node is adopted, never recreated, and the ops then guard it as
|
|
353
|
+
code-owned.
|
|
354
|
+
- Two admins loading at once race on materialization. Any spec whose
|
|
355
|
+
create fails is warned about and skipped, not fatal to the rest of the
|
|
356
|
+
list — except the loser's `slugTaken`, which means the other admin won
|
|
357
|
+
the same race: that one message is swallowed on purpose, silently,
|
|
358
|
+
with no warn.
|
|
359
|
+
- The `smoodly_reorder` / `smoodly_reorder_pages` RPCs keep their
|
|
360
|
+
implicit sibling scope (decided 2026-09-06: every caller passes exactly
|
|
361
|
+
one sibling set). A parent argument waits for a caller that needs it.
|
|
362
|
+
- The row menu in `PagesList.tsx` does not close on Escape — only a click
|
|
363
|
+
on the backdrop closes it.
|
|
364
|
+
- `PageSettings` re-seeds its inputs from `record` on every rename (and
|
|
365
|
+
every publish), so keystrokes typed while a rename is in flight can be
|
|
366
|
+
dropped. The blur- and Enter-driven commits themselves are unaffected.
|
|
367
|
+
- `subtreeIds` and `entrySubtreeIds` in `ops-impl.ts` are the same O(n²)
|
|
368
|
+
walk over a full list, written twice. One generic descendant walk when
|
|
369
|
+
the list sizes make it matter.
|
|
370
|
+
- `publish`'s "has no draft to publish" branch is unreachable with the
|
|
371
|
+
memory store — `createPage` gives every templated page a first version
|
|
372
|
+
— and is untested. It is defence for the Supabase adapter.
|
|
373
|
+
- Classifications the ops tests do not assert: a move that breaks the
|
|
374
|
+
home page's no-children rule or exceeds the tree depth (create asserts
|
|
375
|
+
both), and `entries.move`'s cross-collection and sibling-slug guards.
|
|
376
|
+
`MemoryEntryStore.move` has no children guard at all — the subtree
|
|
377
|
+
rides along — so `has child` is reachable from delete only.
|
|
378
|
+
- The seed's early exit still probes `/` alone: a run that dies between
|
|
379
|
+
the two publishes reports "Already seeded" on the next run and never
|
|
380
|
+
gets its `company` folder.
|
|
381
|
+
- `create-smoodly-app` scaffolds now ship `pages: { tree: { depth: 3 } }`
|
|
382
|
+
— the template build rewrites only the locale set in `smoodly/config.ts`
|
|
383
|
+
and `href` in `smoodly/server.ts` (each with its comment) — so nesting
|
|
384
|
+
is on by default in a fresh app.
|
|
385
|
+
- A childless folder row is inert on click (a mount opens its collection,
|
|
386
|
+
a page opens the editor, a folder with children expands); "Add page
|
|
387
|
+
here" lives in the row menu only.
|
|
388
|
+
- The tree rows nest interactive buttons — the chevron and the `···`
|
|
389
|
+
menu — inside a `role="button"` row. It works, because both stop the
|
|
390
|
+
click and the keydown, but it is an accessibility smell a real treegrid
|
|
391
|
+
would not have.
|
|
392
|
+
- `EditorView.tsx` is ~720 lines. The inspector and the canvas column are
|
|
393
|
+
the extraction seam whenever it is next touched.
|
|
394
|
+
- `i18n/request.ts` returns `{ locale, messages: {} }`. next-intl 4.14.2
|
|
395
|
+
throws `No messages found` when the provider inherits none, and the
|
|
396
|
+
example has no UI strings of its own, so the object is empty; a
|
|
397
|
+
scaffolded site that translates its own UI fills it in (the comment in
|
|
398
|
+
the file points at next-intl's configuration docs). No `messages/`
|
|
399
|
+
folder is scaffolded — UI-string translation is next-intl's job, not
|
|
400
|
+
Smoodly's.
|
|
401
|
+
- `i18n/request.ts` reads `next/root-params` (next-intl's current
|
|
402
|
+
guidance; `requestLocale` is deprecated). Its real types are generated
|
|
403
|
+
into `.next/types/` by `next dev` / `next build` / `next typegen`;
|
|
404
|
+
`tsc` before a first build still passes, because `next` ships a bare
|
|
405
|
+
`declare module 'next/root-params'` for exactly that window.
|
|
406
|
+
- The four single-language route files in
|
|
407
|
+
`packages/create-smoodly-app/variants/single/` are compiled by nothing
|
|
408
|
+
but the CI hosted-mode e2e's `next build` — the example is the
|
|
409
|
+
multilingual layout, so `npm test` never type-checks them. A renderer
|
|
410
|
+
or admin signature change breaks them there, a whole CI job later than
|
|
411
|
+
it should. The fix is a `tsconfig` project for `variants/` with the
|
|
412
|
+
scaffold's `@/*` paths mapped into the example, or a scaffold-then-
|
|
413
|
+
`tsc` smoke test.
|
|
414
|
+
- `ArticleList` renders titles, not links. A section that linked to its
|
|
415
|
+
entries would put the entry's `path` through the app's `Link` / `href`
|
|
416
|
+
the way `components/pages/Page.tsx` does — `getEntries` returns `path`
|
|
417
|
+
already; the section simply does not use it.
|
|
418
|
+
- `smoodlyMetadata` emits `alternates.languages` as relative URLs and
|
|
419
|
+
neither the example nor either template sets `metadataBase`, so the
|
|
420
|
+
rendered `hreflang` hrefs are relative (`/fi/yritys/tietoa-meista`).
|
|
421
|
+
Crawlers want absolute ones: a real site sets `metadataBase` in its
|
|
422
|
+
root layout. `next build` on the example (2026-09-06, Next 16.3.4) does
|
|
423
|
+
not warn — every route is request-rendered, so nothing resolves the
|
|
424
|
+
relative URLs at build time.
|
|
425
|
+
- The proxy's matcher hard-codes `admin` and `api`
|
|
426
|
+
(`/((?!admin|api|_next|_vercel|.*\..*).*)`). An app that mounts the
|
|
427
|
+
admin somewhere else edits `proxy.ts` by hand; nothing derives the
|
|
428
|
+
matcher from the config.
|
|
429
|
+
|
|
430
|
+
## Follow-ups (logged 2026-09-05)
|
|
431
|
+
|
|
432
|
+
- A page's cached unit embeds its ancestors' and children's titles and
|
|
433
|
+
paths (the `page` prop) but is tagged `page:<own id>` only. The admin
|
|
434
|
+
ops now expire the neighbours they change — publish and delete the
|
|
435
|
+
parent, rename and move the subtree and both parents (2026-09-06) — so
|
|
436
|
+
what remains stale is a write that does not go through them: the seed,
|
|
437
|
+
a script, any out-of-band writer. Closing that needs one tree-wide tag
|
|
438
|
+
instead of a neighbour list (DESIGN.md OQ #26).
|
|
439
|
+
- The cached unit is keyed by (locale, path) but tagged by the id found at
|
|
440
|
+
the first miss, so a path that changes hands without both ids' tags firing
|
|
441
|
+
serves the previous occupant. Delete fires its own tag and the rename/move
|
|
442
|
+
fan-out covers the moving record; re-keying the unit by
|
|
443
|
+
(locale, kind, id) would remove the class (DESIGN.md OQ #26).
|
|
444
|
+
- `ancestors` on a page hit are NOT filtered by locale (children are): a
|
|
445
|
+
page ancestor whose `locales` exclude `fi` (`hasPage` true) still
|
|
446
|
+
produces a breadcrumb link whose path 404s in `fi`.
|
|
447
|
+
- `smoodlyMetadata` does not apply the registration check `renderSmoodlyPath`
|
|
448
|
+
applies: a record whose `template` has no registration is a 404 body with
|
|
449
|
+
its meta emitted beside it.
|
|
450
|
+
- `sections` is passed twice — to `createSmoodlySite` (ref resolution) and to
|
|
451
|
+
`createPageRenderer` (the component map) — and `elements` cannot be passed
|
|
452
|
+
at all; the two lists can drift silently.
|
|
453
|
+
- `getEntry` by bare slug matches `entrySegment` on the ALREADY-localized
|
|
454
|
+
entry, so the overlay is applied twice; it diverges only for a non-string
|
|
455
|
+
`i18n[locale].slug`. `getEntry` by id reads the whole collection to find
|
|
456
|
+
one row.
|
|
457
|
+
- `locate` (one primary-key hit on `paths`) runs per request outside the
|
|
458
|
+
cache, because the cache tag needs the id before the unit can be cached; a
|
|
459
|
+
later `"use cache"` migration (OQ #19) can fold it in.
|
|
460
|
+
- Entry-page metadata is alternates only — entries have no `meta`. A
|
|
461
|
+
`titleField`-based title is a later nicety.
|
|
462
|
+
- Localized tree content (per-locale section fields) is not rendered: there
|
|
463
|
+
is no storage model for it yet (OQ #3). `title` and entry `fields` are
|
|
464
|
+
localized.
|
|
465
|
+
- `getEntries`/`getEntriesTree` return refs as ids (cheap lists); `getEntry`
|
|
466
|
+
by path resolves them, `getEntry` by id or slug does not — document or
|
|
467
|
+
unify when a site needs resolved lists.
|
|
468
|
+
- `loadPage` calls `listPages()` for ancestors and children on every cache
|
|
469
|
+
miss; fine for hundreds of nodes, a `children(id)`/`chain(id)` store query
|
|
470
|
+
is the fix when a site outgrows it.
|
|
471
|
+
- Views rendered outside the site get `EMPTY_PAGE_CONTEXT`: tests and any
|
|
472
|
+
preview of a shape that isn't going through `site.load`.
|
|
473
|
+
- `loadEntry` probes each mounted collection in turn: the `paths` row
|
|
474
|
+
carries the target id but not its collection.
|
|
475
|
+
- A published entry under a draft parent is dropped from `getEntriesTree`
|
|
476
|
+
(the parent is not in the list to hang it from) but returned flat by
|
|
477
|
+
`getEntries`. Untested either way.
|
|
478
|
+
- `SmoodlyConfig.registry.pages` (`Registered` in `src/config.ts`) has no
|
|
479
|
+
call signature, so every app's glue casts it `as PageRegistration[]`.
|
|
480
|
+
- The entry-page view receives the whole `EntryRecord`, every locale's
|
|
481
|
+
`i18n` included; a client entry page would ship all of it to the browser.
|
|
482
|
+
- `move` is best-effort across two statements (reorder, then path rewrite):
|
|
483
|
+
a failed rewrite restores each destination sibling's prior `sort` row by
|
|
484
|
+
row, so a failure in the rollback itself still leaves the order changed.
|
|
485
|
+
- The live suites run on vitest's DEFAULT 5s per-test timeout against a real
|
|
486
|
+
database, while a single contract test makes dozens of round trips. On a
|
|
487
|
+
loaded or degraded local stack `npm run test:supabase` then fails with bare
|
|
488
|
+
timeouts on arbitrary tests (every file passes on its own, and the whole
|
|
489
|
+
run passes with `--testTimeout=30000`). Either raise the timeout in
|
|
490
|
+
`scripts/test-supabase.sh` or accept the flake in CI.
|
|
491
|
+
- The `smoodly_reorder` RPC takes no parent scope — the caller always passes
|
|
492
|
+
one sibling set, so scoping is implicit. Give it a parent argument if a
|
|
493
|
+
caller ever passes a mixed list.
|
|
494
|
+
- An explicit JSON `null` VALUE in a declared `order.by` field sorts
|
|
495
|
+
FIRST in Supabase (a jsonb null is the lowest jsonb value, not SQL
|
|
496
|
+
NULL, so `nullsFirst: false` does not reach it) and LAST in memory. A
|
|
497
|
+
MISSING key is SQL NULL and sorts last in both, and the admin only ever
|
|
498
|
+
produces that — a cleared number field emits `undefined`, which the
|
|
499
|
+
JSON body drops — so this needs a programmatic or external writer.
|
|
500
|
+
Mixed types in one field diverge for the same reason: Postgres orders
|
|
501
|
+
jsonb by type first (null < string < number), memory by `String(...)`.
|
|
502
|
+
- Rollback gaps that remain: a `smoodly_reorder`/`smoodly_reorder_pages`
|
|
503
|
+
failure AFTER the re-parent update has no rollback at all; the entry
|
|
504
|
+
`create` rollback discards its delete result (an orphan row survives
|
|
505
|
+
silently); and a throw inside any rollback replaces — masks — the original
|
|
506
|
+
error.
|
|
507
|
+
- Duplicate sibling `sort` values are reachable — create uses count + 1 while
|
|
508
|
+
delete and move leave gaps — so `sort` is an ordering hint and creation
|
|
509
|
+
order breaks ties.
|
|
510
|
+
- Folders have path rows and resolve as `kind: "page"`; the published-only
|
|
511
|
+
read policy makes them public 404s, while the admin (write key) sees them.
|
|
512
|
+
- `setTemplate`/`saveDraft` insert the version row before the pointer update:
|
|
513
|
+
an infra failure between the two orphans a version.
|
|
514
|
+
- No `rebuildPaths()`: the index is documented as derived and rebuildable, but
|
|
515
|
+
nothing rebuilds it.
|
|
516
|
+
- Two `entryPage` registrations for one collection are silently accepted.
|
|
517
|
+
`PageStore.rename` still accepts a locale outside `supported`; the admin
|
|
518
|
+
op rejects it first, so only a direct store caller reaches it.
|
|
519
|
+
- Entry `move` coverage is thinner than pages' — no omitted-index, clamp or
|
|
520
|
+
cross-collection tests.
|
|
521
|
+
- The memory stores default to SEPARATE private path indexes while the
|
|
522
|
+
Supabase stores share one `paths` table: pages and entries share a URL space
|
|
523
|
+
only when the wiring passes one `paths` option to both (convention, not
|
|
524
|
+
structure).
|
|
525
|
+
- `SupabasePathIndex` falls back to `pathTakenError("?", "?")` when
|
|
526
|
+
Postgres reports a unique violation without `details` — the message then
|
|
527
|
+
names no path.
|
|
528
|
+
- `chainOf` has no cycle guard: a corrupted `parent_id` loop would spin
|
|
529
|
+
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
|
+
- Design system pass (DESIGN.md §2, "The admin's visual system"): the shell,
|
|
534
|
+
lists, entry form, login and editor were restyled onto `admin/ui/theme.tsx`.
|
|
535
|
+
Deferred deliberately, because each is a feature rather than a restyle: the
|
|
536
|
+
list search field and status filter, the locale select in the shell's top bar
|
|
537
|
+
(the editor has one; the lists always read the default locale), the article
|
|
538
|
+
sidebar's taxonomy/SEO/publish-date sections, and the rich-text toolbar. The
|
|
539
|
+
pages list's tree shape (chevrons, depth indent) and its row `···` menu
|
|
540
|
+
shipped 2026-09-06, without the drag handle. The tokens for all of them are
|
|
541
|
+
already in place.
|
|
542
|
+
- The `scroll` message in `admin/editor/protocol.ts` is no longer sent: with the
|
|
543
|
+
canvas column as the scroller, `EditorView` scrolls it directly instead of
|
|
544
|
+
asking the bridge to `scrollIntoView` across the frame. `EditorBridge` still
|
|
545
|
+
handles the message, so old and new admins interoperate; drop the message on
|
|
546
|
+
the next protocol change.
|
|
547
|
+
- Light/dark toggle (nav foot): the choice is stored in `localStorage`
|
|
548
|
+
(`smoodly-theme`) and written as `data-theme` on the admin wrapper, never on
|
|
549
|
+
the host `<html>`. It is per browser, not per editor account — a server-side
|
|
550
|
+
preference would need a place to live (`editors`, or a cloud user profile).
|
|
551
|
+
- `lucide-react` is now a runtime dependency, for the toggle's two icons. The
|
|
552
|
+
rest of the UI still uses the design system's unicode glyphs (↑ ↓ ⧉ × ··· ▾
|
|
553
|
+
← ⋮⋮); swapping them for Lucide is the next step whenever it is wanted.
|
|
554
|
+
- `admin/ui/theme.tsx` pulls Public Sans from Google Fonts with an `@import`.
|
|
555
|
+
Fine today; a self-hosted font (or `next/font` in the glue layer) removes the
|
|
556
|
+
third-party request and the flash before it loads.
|
|
557
|
+
|
|
181
558
|
## Follow-ups (logged 2026-09-04)
|
|
182
559
|
|
|
183
560
|
- Cloud-ready foundation — done, including the self-host init
|
|
@@ -193,7 +570,7 @@ site at the tagged commit.
|
|
|
193
570
|
- `isEditor` reads `editors` with no space filter: under a cloud write key RLS scopes it, but a service-role client bypasses RLS and would admit an editor of any space. Harmless in self-host (one space); never use a service-role client against the shared content project.
|
|
194
571
|
- The `sb_secret_` branch of `clientOptions` (secret key as the sole `apikey`, no bearer) was exercised live on 2026-09-05 against a hosted project: PostgREST reads with the service-role template and the GoTrue admin API (`listUsers`, `createUser`) both accept it; a password sign-in with the matching `sb_publishable_` key returns a session. Unit tests still cover the shape only; the local stack stays on legacy JWT keys.
|
|
195
572
|
- The migration is not re-appliable to a pre-rename (`project_id`) database: `create table if not exists` leaves it untouched and the `space_id` policies then fail. No installs predate the rename; fold into DESIGN.md OQ #2 (schema upgrades).
|
|
196
|
-
- `examples/site/
|
|
573
|
+
- `examples/site/smoodly/server.ts` should carry `import "server-only"` now that it also exports `publicAuth` — a client-side import would fail loudly at build instead of at runtime.
|
|
197
574
|
- `exports` has no `"./package.json"` entry, so tooling that reads a dependency's `package.json` through the exports map cannot. One line to add when something needs it.
|
|
198
575
|
- `src/ref-index.ts` contains two raw NUL bytes used as key separators, which makes git show the file as binary in diffs. Replacing them with `\0` escapes would make it diffable again.
|
|
199
576
|
|
|
@@ -203,9 +580,6 @@ site at the tagged commit.
|
|
|
203
580
|
|
|
204
581
|
- Editor canvas URL mapping: now `homePageSlug` config (done 2026-09-03); remaining follow-up is a registration-declared `path` for fixed pages at arbitrary paths.
|
|
205
582
|
- `serialize.ts` structural cast duplicates `PageSchema`; tighten `Registered.schema` so the cast goes.
|
|
206
|
-
- Pages list: no re-entrancy guard on a fast double-click of "New page" / a not-created row, and the row `onClick` promise is unhandled (a transport throw is an unhandled rejection); fix both together with a `.catch` → alert.
|
|
207
|
-
- `pagesRows`: pin with a test the case of a record at a fixed slug whose `template` is an open registration (self-healing today: `/` 404s, `/<slug>` serves it, delete is allowed, then the singleton row reappears).
|
|
208
583
|
- Catch-all `templates` map should filter to open registrations to state its intent (unreachable today).
|
|
209
584
|
- 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.
|
|
210
|
-
- Next acceptance walk: start from a database with no `home` record so the "not created" singleton row is exercised.
|
|
211
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).
|
package/dist/admin/client-ops.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
const GROUPS = {
|
|
2
|
-
pages: ["list", "get", "create", "saveDraft", "publish", "delete"],
|
|
3
|
-
entries: ["list", "get", "create", "save", "setStatus", "delete"],
|
|
2
|
+
pages: ["list", "get", "create", "saveDraft", "publish", "delete", "rename", "move", "setTemplate"],
|
|
3
|
+
entries: ["list", "get", "create", "save", "setStatus", "delete", "move"],
|
|
4
4
|
preview: ["enable", "disable"],
|
|
5
5
|
};
|
|
6
6
|
export function makeClientOps(action, token) {
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { AdminOps } from "../ops.ts";
|
|
2
2
|
import type { AdminRegistry } from "../serialize.ts";
|
|
3
|
-
export declare
|
|
3
|
+
export declare const withEditorParam: (url: string) => string;
|
|
4
|
+
export declare function EditorView({ ops, registry, id }: {
|
|
4
5
|
ops: AdminOps;
|
|
5
6
|
registry: AdminRegistry;
|
|
6
|
-
|
|
7
|
+
id: string;
|
|
7
8
|
}): import("react").JSX.Element;
|