smoodly 0.0.32 → 0.0.34

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.
Files changed (76) hide show
  1. package/README.md +77 -15
  2. package/dist/addresses.d.ts +42 -0
  3. package/dist/addresses.js +51 -0
  4. package/dist/admin/editor/EditorView.js +5 -3
  5. package/dist/admin/editor/PageSettings.js +7 -2
  6. package/dist/admin/editor/autosave.d.ts +2 -1
  7. package/dist/admin/editor/autosave.js +6 -0
  8. package/dist/admin/editor/useCanvasBridge.js +25 -2
  9. package/dist/admin/fixed-nodes.d.ts +9 -24
  10. package/dist/admin/fixed-nodes.js +36 -72
  11. package/dist/admin/forms/FieldWidget.d.ts +5 -1
  12. package/dist/admin/forms/FieldWidget.js +6 -3
  13. package/dist/admin/forms/slug-field.d.ts +17 -0
  14. package/dist/admin/forms/slug-field.js +39 -0
  15. package/dist/admin/health.js +9 -0
  16. package/dist/admin/next/create-admin.d.ts +4 -3
  17. package/dist/admin/next/create-admin.js +14 -3
  18. package/dist/admin/ops-impl.js +144 -14
  19. package/dist/admin/redirect-path.d.ts +3 -0
  20. package/dist/admin/redirect-path.js +9 -0
  21. package/dist/admin/serialize.d.ts +3 -0
  22. package/dist/admin/serialize.js +1 -0
  23. package/dist/admin/shell/AddRedirectModal.js +2 -2
  24. package/dist/admin/shell/EntryForm.js +46 -6
  25. package/dist/admin/shell/GlobalForm.js +1 -1
  26. package/dist/admin/shell/PageDialogs.js +11 -5
  27. package/dist/admin/shell/PagesList.js +29 -12
  28. package/dist/admin/shell/live-url.d.ts +3 -1
  29. package/dist/admin/shell/live-url.js +5 -2
  30. package/dist/admin/shell/pages-tree.d.ts +19 -14
  31. package/dist/admin/shell/pages-tree.js +49 -16
  32. package/dist/admin/ui/primitives.js +1 -0
  33. package/dist/admin/ui/theme.js +1 -0
  34. package/dist/admin/validate.d.ts +2 -0
  35. package/dist/admin/validate.js +9 -0
  36. package/dist/anchors.d.ts +42 -0
  37. package/dist/anchors.js +72 -0
  38. package/dist/cli/export.js +3 -1
  39. package/dist/cli/import.d.ts +1 -0
  40. package/dist/cli/import.js +13 -2
  41. package/dist/cli/space-io.d.ts +1 -2
  42. package/dist/cli/space-io.js +5 -5
  43. package/dist/collections.js +15 -0
  44. package/dist/config.d.ts +6 -0
  45. package/dist/config.js +53 -18
  46. package/dist/current-space.d.ts +4 -0
  47. package/dist/current-space.js +9 -0
  48. package/dist/entry-store.js +4 -2
  49. package/dist/env.d.ts +4 -3
  50. package/dist/env.js +15 -17
  51. package/dist/index.d.ts +4 -2
  52. package/dist/index.js +4 -2
  53. package/dist/next/page-renderer.js +2 -2
  54. package/dist/path-index.d.ts +11 -0
  55. package/dist/path-index.js +52 -25
  56. package/dist/paths.d.ts +32 -12
  57. package/dist/paths.js +80 -21
  58. package/dist/schema-version.d.ts +1 -1
  59. package/dist/schema-version.js +1 -1
  60. package/dist/site.d.ts +7 -3
  61. package/dist/site.js +26 -15
  62. package/dist/space-transfer.d.ts +2 -0
  63. package/dist/space-transfer.js +8 -1
  64. package/dist/sql.js +35 -9
  65. package/dist/store.d.ts +15 -1
  66. package/dist/store.js +27 -6
  67. package/dist/supabase-entry-store.d.ts +0 -1
  68. package/dist/supabase-entry-store.js +6 -11
  69. package/dist/supabase-path-index.d.ts +12 -1
  70. package/dist/supabase-path-index.js +56 -19
  71. package/dist/supabase-store.d.ts +2 -0
  72. package/dist/supabase-store.js +30 -9
  73. package/migrations/20260929120000_smoodly_v5.sql +56 -0
  74. package/package.json +1 -1
  75. package/dist/admin/shell/page-dialogs.d.ts +0 -9
  76. package/dist/admin/shell/page-dialogs.js +0 -19
package/README.md CHANGED
@@ -601,6 +601,28 @@ Implemented:
601
601
  `cli-transfer`, `space-transfer.supabase`,
602
602
  `examples/site/e2e/transfer.spec.ts`.
603
603
 
604
+ - Nested addresses (spec 2026-09-29 nested paths, schema v5): a
605
+ collection `path` or fixed `slug` may have several parts
606
+ (`company/news`), each checked as a segment, a locale the map omits
607
+ filled from the default. `src/addresses.ts` turns the registry into
608
+ code addresses by owner key (`collection:<name>`, `page:<name>`),
609
+ `config.addresses`; `pages.fixed` names a code-owned record's owner,
610
+ the record stays a root with the last part as its slug, and its path
611
+ comes from the config. Anchored paths (spec 2026-09-29 anchored
612
+ paths): each `paths` row names its anchor (`pages.fixed`'s owner key,
613
+ `''` for the editors' tree) and stores only the editors' part; the
614
+ path index (`src/anchors.ts`, both adapters, `config.anchors`) joins
615
+ the address from the config, so a code change needs no write and the
616
+ live site does one lookup per request. `ensureFixedNodes` finds
617
+ records by owner and creates them over an editor's page, never
618
+ adopting it: code wins the address. The ops refuse a page write onto
619
+ or below a code address naming the owner; the Pages list flags an
620
+ editor's page there and a record no longer in code (delete only).
621
+ Tests: `addresses`, `anchors`, the path index contract,
622
+ `admin-fixed-nodes`, `admin-ops-addresses`, `admin-pages-tree`,
623
+ `site`, the page and entry store contracts,
624
+ `examples/site/e2e/entries.spec.ts`.
625
+
604
626
  Not yet: canvas drag-and-drop (list fields, the outline, manual
605
627
  collections and the pages tree drag since 2026-09-09 — spec
606
628
  `docs/superpowers/specs/2026-09-09-drag-and-drop-design.md`), undo/redo, sample/placeholder content
@@ -627,6 +649,56 @@ after both are on the registry. From the repo root:
627
649
  `prepublishOnly` rebuilds each package. The Claude Code skill
628
650
  `.claude/skills/releasing-smoodly` carries the same order with its checks.
629
651
 
652
+ ## Follow-ups (logged 2026-09-29, nested collection paths)
653
+
654
+ - `homePageSlug` is compared with a collection's whole default path, so
655
+ `"home/news"` passes boot with the home page as its middle part: the
656
+ home record serves `/`, so `/home` is no page while `/home/news` is.
657
+ Refuse an address whose first part is the home slug if a site trips on
658
+ it.
659
+ - An owner key that changes in code — a fixed page that becomes a
660
+ collection's index page (`page:x` → `collection:y`), a collection
661
+ renamed — leaves the old record "No longer in code" with its
662
+ sections and history, and a new empty record takes the address.
663
+ Carry the record over (rewrite `fixed`) when a site asks.
664
+ - An index page registration renamed (`a` → `b`) leaves the record on
665
+ template `a`: `ensureFixedNodes` only handles a folder gaining an
666
+ index page, and `setTemplate` refuses a record that has one.
667
+ - The rename/move confirm's code-address line names the addresses in
668
+ the selected locale only, though a move re-paths every locale; the
669
+ Pages list's conflict notice is one ellipsized line (the whole text
670
+ on hover), and an editor's flagged page cannot be moved from the
671
+ notice itself.
672
+ - An index page removed from code leaves the collection's record with
673
+ its template (spec amendment 3): the record keeps rendering as that
674
+ page type's stale name until an editor or a later pass clears it.
675
+ - The code-owned part of a URL is never stored (spec 2026-09-29 anchored
676
+ paths): a row names its anchor and the index joins the config's
677
+ address, so an address changed in code moves its URLs at the deploy
678
+ with no write, and the old URLs need a `next.config.ts` redirect.
679
+ Rows written before schema v5's anchors (none installed) would sit
680
+ under `''` and go dark under a code address until re-saved.
681
+ - `listLive` / `listRedirects` on Supabase read at most PostgREST's
682
+ `max_rows` (1000) per locale, as before, now ordered by (anchor, path)
683
+ and sorted by URL in JS; page them if a locale's redirect list grows
684
+ past it.
685
+ - Older than this plan: the Supabase `addLocale` rollback on
686
+ `alreadyInLocale` (`supabase-store.ts`, the insertLocale catch)
687
+ deletes the racing WINNER's locale row and versions. Two admins
688
+ adding one locale at once converge on the next list; fix by rolling
689
+ back only what this call inserted.
690
+
691
+ ## Follow-ups (logged 2026-09-29, pre-install fixes)
692
+
693
+ - Content health lists an entry slug outside the grammar, but its link
694
+ opens an entry form that marks nothing on the slug: drift leaves the
695
+ slug out on purpose (a marker would flash while it is typed), so only
696
+ the publish refusal marks it. Mark it in the form once the box has lost
697
+ focus, if editors miss it.
698
+ - Content health does not check page slugs: pages refuse a bad slug at
699
+ create and rename, so only an import of older data or a hand edit could
700
+ hold one.
701
+
630
702
  ## Follow-ups (logged 2026-09-28, export/import spec)
631
703
 
632
704
  - `import --replace`: delete the space's content and media first, after
@@ -647,9 +719,6 @@ after both are on the registry. From the repo root:
647
719
  - Import cannot expire the app's cache tags; it prints a restart note. A
648
720
  revalidate call through the app (an op the CLI could reach) would make
649
721
  the note unnecessary.
650
- - A resumed import that lands nothing still prints the full "Imported …"
651
- summary and the member list — accurate (the space holds all of it) but
652
- noisy; count what actually landed and say "nothing new" when it is zero.
653
722
 
654
723
  ## Follow-ups (logged 2026-09-27, shadowed-page detector spec)
655
724
 
@@ -798,14 +867,6 @@ after both are on the registry. From the repo root:
798
867
  - A Deploying guide (nav lists it, unwritten) should show
799
868
  `npx smoodly migrate --check` as a build step.
800
869
 
801
- ## Follow-ups (logged 2026-09-25, docs session)
802
-
803
- - `f.slug({ from })` stores `from` on the descriptor, but nothing reads it:
804
- the admin never derives a slug from the named field. Either wire it (fill
805
- the slug from `from` while the slug is untouched, in the entry form) or
806
- drop the option. The Fields guide documents the slug as a plain text box
807
- with the URL prefix and does not mention `from`.
808
-
809
870
  ## Follow-ups (logged 2026-09-17, admin path spec)
810
871
 
811
872
  - A robots `Disallow` for the admin path from `next/meta`.
@@ -1144,14 +1205,15 @@ tool — reset and reseed a development database (`supabase db reset`,
1144
1205
  - The Pages list's prompts are dialogs on the same card since the same
1145
1206
  day (`shell/PageDialogs.tsx` over `ui/Dialog.tsx`): New page / New
1146
1207
  path / New subpage / New subpath (title, slug following the title
1147
- through `slugify` in `shell/page-dialogs.ts`, page type when more than
1208
+ through `slugify` in `paths.ts`, page type when more than
1148
1209
  one open template), Add page here (page type; skipped with one), Rename
1149
1210
  (title and slug; the home page's title alone). Still native: the
1150
1211
  `window.alert`s on a refused list op outside a dialog (`run` in
1151
1212
  PagesList, EntriesList's add/remove) — a notice banner is the follow-up.
1152
- - `slugify` is the admin's first slugifier (title → segment); the slug
1153
- field widget (`f.slug`) does not use it yet, and neither does the entry
1154
- create-on-click. OQ 24 (unicode slugs) would relax its grammar.
1213
+ - `slugify` (`paths.ts`, exported) is the one slugifier; the slug-box
1214
+ helpers in `admin/forms/slug-field.ts` (`followsTitle`, `tidySlug`,
1215
+ `slugHint`, `withFollowingSlug`) serve the page dialogs, page Settings,
1216
+ the `f.slug` widget and the entry form (spec 2026-09-28 ASCII slugs).
1155
1217
  - The dialog's fields take the tile tone on the white card
1156
1218
  (`.sm-dialog__form .sm-input`), the reverse of fields on tiles.
1157
1219
  - The confirm's copy lives at each call site (`title` / `body` /
@@ -0,0 +1,42 @@
1
+ import { type LocaleSet } from "./paths.ts";
2
+ /** owner key → locale → address, without slashes at either end (`company/news`). */
3
+ export type Addresses = Record<string, Record<string, string>>;
4
+ export declare const collectionKey: (name: string) => string;
5
+ export declare const pageKey: (name: string) => string;
6
+ /** The collection an owner key names, or null for a fixed page. */
7
+ export declare const ownerCollection: (key: string) => string | null;
8
+ export declare function assertAddress(address: string, what: string): void;
9
+ /** The address's last part — what a code-owned record keeps in `page_locales.slug`. */
10
+ export declare const lastPart: (address: string) => string;
11
+ export type CodeAddress = {
12
+ /** The owner key stored in `pages.fixed`. */
13
+ key: string;
14
+ /** locale → address, in every supported locale: a locale the slug or path omits takes the default's,
15
+ * as the entry derivation does (collectionSegment) — code owns an address in every locale. */
16
+ segments: Record<string, string>;
17
+ title: string;
18
+ /** The fixed page registration rendered at the address; null for a collection with no index page (a folder). */
19
+ template: string | null;
20
+ /** The collection whose address this is; null for a plain fixed page. */
21
+ collection: string | null;
22
+ };
23
+ type PageLike = {
24
+ schema: {
25
+ kind: string;
26
+ name: string;
27
+ title?: string;
28
+ slug?: string | Record<string, string>;
29
+ };
30
+ };
31
+ type CollectionLike = {
32
+ name: string;
33
+ title: string;
34
+ path?: string | Record<string, string>;
35
+ };
36
+ export declare function codeAddresses(input: {
37
+ locales: LocaleSet;
38
+ pages: ReadonlyArray<PageLike>;
39
+ collections: ReadonlyArray<CollectionLike>;
40
+ }): CodeAddress[];
41
+ export declare const addressMap: (list: CodeAddress[]) => Addresses;
42
+ export {};
@@ -0,0 +1,51 @@
1
+ // Code-owned addresses (spec 2026-09-29 nested paths): what the registry
2
+ // claims in the URL space, per locale, keyed by the owner a `pages.fixed`
3
+ // column names. A fixed page's slug and a collection's path may have
4
+ // several parts (`company/news`); each part is a segment under the ASCII
5
+ // rule, so SEGMENT_RE stays the only gate. Pure: defineConfig, the
6
+ // materializer, the stores, the site and the admin tree all read it here.
7
+ import { assertSegment, fixedPageSegments } from "./paths.js";
8
+ export const collectionKey = (name) => `collection:${name}`;
9
+ export const pageKey = (name) => `page:${name}`;
10
+ /** The collection an owner key names, or null for a fixed page. */
11
+ export const ownerCollection = (key) => (key.startsWith("collection:") ? key.slice("collection:".length) : null);
12
+ export function assertAddress(address, what) {
13
+ const parts = address.split("/");
14
+ if (parts.some((p) => p === "")) {
15
+ throw new Error(`smoodly: ${what} "${address}" has an empty part — write segments joined by single slashes, like "company/news".`);
16
+ }
17
+ for (const part of parts)
18
+ assertSegment(part, what);
19
+ }
20
+ /** The address's last part — what a code-owned record keeps in `page_locales.slug`. */
21
+ export const lastPart = (address) => address.slice(address.lastIndexOf("/") + 1);
22
+ export function codeAddresses(input) {
23
+ const def = input.locales.default;
24
+ const byDefault = new Map();
25
+ const out = [];
26
+ for (const p of input.pages) {
27
+ if (p.schema.kind !== "page" || p.schema.slug === undefined)
28
+ continue;
29
+ const segments = fixedPageSegments(p.schema.slug, input.locales);
30
+ const spec = { key: pageKey(p.schema.name), segments, title: p.schema.title ?? p.schema.name, template: p.schema.name, collection: null };
31
+ byDefault.set(segments[def], spec);
32
+ out.push(spec);
33
+ }
34
+ for (const c of input.collections) {
35
+ if (c.path === undefined)
36
+ continue;
37
+ const segments = fixedPageSegments(c.path, input.locales);
38
+ const index = byDefault.get(segments[def]);
39
+ if (index) {
40
+ // defineConfig proved the page sits on the collection's address in
41
+ // every locale: that record is the collection's, owned by it.
42
+ index.key = collectionKey(c.name);
43
+ index.collection = c.name;
44
+ index.title = c.title;
45
+ continue;
46
+ }
47
+ out.push({ key: collectionKey(c.name), segments, title: c.title, template: null, collection: c.name });
48
+ }
49
+ return out;
50
+ }
51
+ export const addressMap = (list) => Object.fromEntries(list.map((a) => [a.key, a.segments]));
@@ -891,7 +891,7 @@ export function EditorView({ ops, registry, id }) {
891
891
  trailing: registry.locales.supported.length > 1 && (_jsx(LocaleSwitcher, { registry: registry, present: record ? registry.locales.supported.filter((l) => record.locales[l]) : [locale], parentLocales: parentLocales, value: locale, adding: adding, onSwitch: (l) => { void switchLocale(l); }, onAdd: (l) => { void doAddLocale(l); } })),
892
892
  },
893
893
  { label },
894
- ] }) }), right: _jsxs(_Fragment, { children: [(touched || saveState === "error") && _jsx(SaveIndicator, { state: saveState, pending: dirty }), ready && (_jsx(LinkButton, { icon: true, href: siteUrl, target: "_blank", rel: "noreferrer", "aria-label": "View live", title: "View live \u2014 opens the site route. While the admin's draft-mode cookie is on, this shows the DRAFT \u2014 open it in a private window to see what visitors see.", children: _jsx(Eye, { size: 16, strokeWidth: 1.5, "aria-hidden": "true" }) })), _jsx(PublishControl, { status: record?.locales[locale] ? localeStatusOf(record.locales[locale]) : null, pending: dirty || saveState !== "saved", busy: pageBusy, flash: published, onPublish: () => { void doPublish(); }, onDiscard: () => { void doDiscard(); }, onUnpublish: () => { void doUnpublish(); }, onHistory: () => { void doHistory(); } })] }) }), target && _jsx(PresenceLine, { names: editorsOf(others, target), noun: "page" }), stale && _jsx(StaleBanner, { info: stale, onReload: loadTheirs }), _jsx(DriftNotice, { draft: findings, live: liveFindings }), history && record?.locales[locale] && (_jsx(HistoryModal, { rows: history, draftId: record.locales[locale].draftVersionId, onClose: () => setHistory(null), onRestore: doRestore, render: (versionId) => _jsx("iframe", { className: "sm-history__frame", title: "Version preview", src: withVersionParam(siteUrl, versionId) }) })), _jsxs("div", { className: "sm-editor", inert: stale ? true : undefined, children: [_jsxs("div", { className: "sm-canvas", children: [(banner || diagnosis || crashedLines.length > 0) && (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 8, padding: "0 20px 10px" }, children: [banner && (_jsxs(BannerBox, { tone: banner.fields && banner.fields.length > 0 ? "warn" : "error", onDismiss: () => setBanner(null), children: [_jsx("p", { children: banner.text }), _jsx(FieldErrorList, { fields: banner.fields ?? [] })] })), diagnosis && _jsx(CanvasWarning, { cause: diagnosis.cause, path: diagnosis.path }), crashedLines.length > 0 && (_jsx(BannerBox, { tone: "warn", children: crashedLines.map((line, i) => _jsx("p", { children: line }, i)) }))] })), _jsx("div", { className: "sm-canvas__scroll", ref: scrollerRef, onClick: (e) => { if (e.target === e.currentTarget)
894
+ ] }) }), right: _jsxs(_Fragment, { children: [(touched || saveState === "error" || saveState === "refused") && _jsx(SaveIndicator, { state: saveState, pending: dirty }), ready && (_jsx(LinkButton, { icon: true, href: siteUrl, target: "_blank", rel: "noreferrer", "aria-label": "View live", title: "View live \u2014 opens the site route. While the admin's draft-mode cookie is on, this shows the DRAFT \u2014 open it in a private window to see what visitors see.", children: _jsx(Eye, { size: 16, strokeWidth: 1.5, "aria-hidden": "true" }) })), _jsx(PublishControl, { status: record?.locales[locale] ? localeStatusOf(record.locales[locale]) : null, pending: dirty || saveState !== "saved", busy: pageBusy, flash: published, onPublish: () => { void doPublish(); }, onDiscard: () => { void doDiscard(); }, onUnpublish: () => { void doUnpublish(); }, onHistory: () => { void doHistory(); } })] }) }), target && _jsx(PresenceLine, { names: editorsOf(others, target), noun: "page" }), stale && _jsx(StaleBanner, { info: stale, onReload: loadTheirs }), _jsx(DriftNotice, { draft: findings, live: liveFindings }), history && record?.locales[locale] && (_jsx(HistoryModal, { rows: history, draftId: record.locales[locale].draftVersionId, onClose: () => setHistory(null), onRestore: doRestore, render: (versionId) => _jsx("iframe", { className: "sm-history__frame", title: "Version preview", src: withVersionParam(siteUrl, versionId) }) })), _jsxs("div", { className: "sm-editor", inert: stale ? true : undefined, children: [_jsxs("div", { className: "sm-canvas", children: [(banner || diagnosis || crashedLines.length > 0) && (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 8, padding: "0 20px 10px" }, children: [banner && (_jsxs(BannerBox, { tone: banner.fields && banner.fields.length > 0 ? "warn" : "error", onDismiss: () => setBanner(null), children: [_jsx("p", { children: banner.text }), _jsx(FieldErrorList, { fields: banner.fields ?? [] })] })), diagnosis && _jsx(CanvasWarning, { cause: diagnosis.cause, path: diagnosis.path }), crashedLines.length > 0 && (_jsx(BannerBox, { tone: "warn", children: crashedLines.map((line, i) => _jsx("p", { children: line }, i)) }))] })), _jsx("div", { className: "sm-canvas__scroll", ref: scrollerRef, onClick: (e) => { if (e.target === e.currentTarget)
895
895
  setSelectedId(null); }, children: _jsxs("div", { className: "sm-page", style: { overflow: "hidden" }, children: [iframeSrc ? (_jsx("iframe", { ref: iframeRef, src: iframeSrc, title: "Page canvas", onLoad: onFrameLoad, style: { width: "100%", height: docH, border: 0, display: "block" } })) : (_jsx("div", { className: "sm-canvas__loading", children: "Loading canvas\u2026" })), _jsx(CanvasOverlay, { emptyZones: emptyZones, hoverRect: hoverRect, hoverGlobal: hoverGlobal, selection: selection, fieldHover: fieldHover, editing: editing, plusButtons: plusButtons, onPlus: (p, x, y) => setPicker({ zone: p.zone, index: p.index, x: x - 150, y: y + 12 }), onMove: doMove, onDuplicate: doDuplicate, onRemove: doRemove }), inline.richtext && (() => {
896
896
  const { session } = inline.richtext;
897
897
  const item = globalItemOf(session.node);
@@ -907,8 +907,10 @@ export function EditorView({ ops, registry, id }) {
907
907
  else
908
908
  mutate((t) => patchNodePath(t, session.node, session.path, doc));
909
909
  }, onEscape: inline.end }));
910
- })()] }) })] }), _jsx(ResizeHandle, {}), _jsxs("aside", { className: "sm-inspector", ref: inspectorRef, children: [_jsxs("div", { className: "sm-card", children: [selected && !selectedSection && !selectedItem ? (_jsx(UnknownNode, { node: selected, label: labelOf(selected), onRemove: () => { void doRemove(selected.id, true); } })) : selectedSection || selectedItem ? (_jsxs(_Fragment, { children: [_jsxs("div", { className: "sm-inspector__head", children: [_jsxs("span", { style: { display: "flex", alignItems: "center", gap: 8, minWidth: 0 }, children: [_jsx("span", { className: "sm-blockglyph" }), _jsx("span", { className: "sm-title", style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: selectedSection?.title ?? (selectedItem ? `${selectedItem.title} · global` : null) }), selectedItem && (globalTouched || globalEdit.saveState === "error") && _jsx(SaveIndicator, { state: globalEdit.saveState, pending: globalDirty })] }), _jsx(IconButton, { label: "Clear selection", onClick: () => setSelectedId(null), children: "\u00D7" })] }), tabs.length > 0 && selectedSection && (_jsx(Tabs, { tabs: tabs.map((t) => t.tab), value: activeTab, onChange: (t) => setTab(t) }))] })) : record ? (_jsx(PageInspector, { record: record, locale: locale, registry: registry, tree: tree, path: paths[locale], redirects: redirects[locale] ?? [], fixed: isFixedNode(record, registry), onRename: onRename, onPatch: doPatchPage, drift: findingsAt.get("page") ?? [], onClearPageField: (key) => mutate((t) => ({ ...t, fields: unsetAtPath(t.fields ?? {}, key) })), onRemoveLocale: Object.keys(record.locales).length > 1 &&
911
- !isFixedNode(record, registry) &&
910
+ })()] }) })] }), _jsx(ResizeHandle, {}), _jsxs("aside", { className: "sm-inspector", ref: inspectorRef, children: [_jsxs("div", { className: "sm-card", children: [selected && !selectedSection && !selectedItem ? (_jsx(UnknownNode, { node: selected, label: labelOf(selected), onRemove: () => { void doRemove(selected.id, true); } })) : selectedSection || selectedItem ? (_jsxs(_Fragment, { children: [_jsxs("div", { className: "sm-inspector__head", children: [_jsxs("span", { style: { display: "flex", alignItems: "center", gap: 8, minWidth: 0 }, children: [_jsx("span", { className: "sm-blockglyph" }), _jsx("span", { className: "sm-title", style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: selectedSection?.title ?? (selectedItem ? `${selectedItem.title} · global` : null) }), selectedItem && (globalTouched || globalEdit.saveState === "error" || globalEdit.saveState === "refused") && _jsx(SaveIndicator, { state: globalEdit.saveState, pending: globalDirty })] }), _jsx(IconButton, { label: "Clear selection", onClick: () => setSelectedId(null), children: "\u00D7" })] }), tabs.length > 0 && selectedSection && (_jsx(Tabs, { tabs: tabs.map((t) => t.tab), value: activeTab, onChange: (t) => setTab(t) }))] })) : record ? (_jsx(PageInspector, { record: record, locale: locale, registry: registry, tree: tree, path: paths[locale], redirects: redirects[locale] ?? [],
911
+ // Code-owned, or no longer in code (only deleting is open then): either way the URL is not the editor's.
912
+ fixed: isFixedNode(record, registry) || !!record.fixed, onRename: onRename, onPatch: doPatchPage, drift: findingsAt.get("page") ?? [], onClearPageField: (key) => mutate((t) => ({ ...t, fields: unsetAtPath(t.fields ?? {}, key) })), onRemoveLocale: Object.keys(record.locales).length > 1 &&
913
+ !record.fixed &&
912
914
  !(record.parentId === null && record.locales[registry.locales.default]?.slug === registry.homePageSlug)
913
915
  ? () => { void doRemoveLocale(); }
914
916
  : undefined })) : null, (selectedSection || selectedItem) && (_jsxs("div", { className: "sm-inspector__body", children: [selected && findingsAt.get(selected.id)?.filter((f) => f.kind === "zone-disallowed").map((f) => (_jsx("p", { className: "sm-outline-note", children: driftMessage(f) }, f.path))), selected && selectedSection && (_jsx(InspectorFields, { groups: activeGroups, values: selected, render: ({ key, ns, descriptor }, { emptyLabel, scope }) => (_jsx(FieldWidget, { fieldKey: key, descriptor: descriptor, value: valueOf(selected, { key, ns, descriptor }), emptyLabel: emptyLabel, scope: scope, errors: ns === "css" ? undefined : misfitErrorsIn(findingsAt.get(selected.id) ?? [], selected.id, ns), refOptions: ns === "fields" || ns === "settings" ? refOptionsFor(refOptions, refTargets, selectedSection, ns, key, descriptor) : undefined, inlinePath: ns === "fields" && inline.session?.node === selected.id ? inline.session.path : undefined, onEditHere: () => { const p = inline.session?.path; inline.end(); if (p)
@@ -11,8 +11,10 @@ import { useEffect, useRef, useState } from "react";
11
11
  import { updatedLine } from "../ui/format.js";
12
12
  import { Field, InfoRow, SectionLabel } from "../ui/primitives.js";
13
13
  import { confirmLiveUrl } from "../shell/live-url.js";
14
+ import { codeAddressNote } from "../shell/pages-tree.js";
14
15
  import { RouteLink } from "../shell/RouteLink.js";
15
16
  import { useAdminHref } from "../shell/base.js";
17
+ import { tidySlug } from "../forms/slug-field.js";
16
18
  export function PageSettings({ record, locale, registry, path, fixed, redirects, onRename }) {
17
19
  const row = record.locales[locale];
18
20
  const [draft, setDraft] = useState({ title: row?.title ?? "", slug: row?.slug ?? "" });
@@ -33,7 +35,10 @@ export function PageSettings({ record, locale, registry, path, fixed, redirects,
33
35
  committing.current = true;
34
36
  try {
35
37
  setError(null);
36
- const next = draft[key];
38
+ // A slug tidies before it is committed (spec 2026-09-28 ASCII slugs §4).
39
+ const next = key === "slug" ? tidySlug(draft.slug) : draft[key];
40
+ if (next !== draft[key])
41
+ setDraft((d) => ({ ...d, [key]: next }));
37
42
  if (next === row[key])
38
43
  return;
39
44
  if (next === "") {
@@ -43,7 +48,7 @@ export function PageSettings({ record, locale, registry, path, fixed, redirects,
43
48
  // A published slug is a live URL: one confirm with the redirect box, Cancel restores the input (members management §5, redirects §6).
44
49
  let redirect = true;
45
50
  if (key === "slug") {
46
- const live = await confirmLiveUrl({ status: row.status, from: row.slug, to: next }, { name: row.title, path });
51
+ const live = await confirmLiveUrl({ status: row.status, from: row.slug, to: next }, { name: row.title, path }, codeAddressNote(path ?? null, registry, locale));
47
52
  if (!live.ok) {
48
53
  setDraft((d) => ({ ...d, slug: row.slug }));
49
54
  return;
@@ -1,5 +1,6 @@
1
1
  import type { OpResult } from "../ops.ts";
2
- export type SaveState = "saved" | "saving" | "error" | "stale";
2
+ /** `refused`: the op refused the value itself (a validation error) — not retried; the next edit saves. */
3
+ export type SaveState = "saved" | "saving" | "error" | "stale" | "refused";
3
4
  export type DraftAutosave = {
4
5
  /** Record a local change; a debounced save follows. */
5
6
  mark(): void;
@@ -52,6 +52,12 @@ export function createDraftAutosave(opts) {
52
52
  setState("stale");
53
53
  return false;
54
54
  }
55
+ else if (result.code === "validation") {
56
+ // The same value would be refused again (spec 2026-09-28 ASCII slugs): no
57
+ // retry — the caller shows the field errors, and the next edit saves.
58
+ setState("refused");
59
+ return false;
60
+ }
55
61
  else {
56
62
  // Keep local state; retry with a doubling, capped delay.
57
63
  setState("error");
@@ -22,8 +22,24 @@ export function useCanvasBridge({ iframeRef, iframeSrc, onSelect, onHover, onRen
22
22
  // A new canvas URL (a locale switch, a rename) means new geometry — the
23
23
  // previous page's rects are stale the instant the iframe starts loading
24
24
  // a different route, so clear them alongside resetting geometry.
25
- useEffect(() => { setGeometry("waiting"); setLoaded(false); setRects([]); setEmptyZones([]); setFields([]); setCrashed([]); }, [iframeSrc]);
25
+ // A refresh posted before this canvas's bridge has spoken reaches the loading document and is lost, while the
26
+ // server may have rendered the page before the save it announces (a fill saved as the frame loads): it is sent
27
+ // again on the bridge's first message.
28
+ const heard = useRef(false);
29
+ const missedRefresh = useRef(false);
30
+ useEffect(() => {
31
+ setGeometry("waiting");
32
+ setLoaded(false);
33
+ setRects([]);
34
+ setEmptyZones([]);
35
+ setFields([]);
36
+ setCrashed([]);
37
+ heard.current = false;
38
+ missedRefresh.current = false;
39
+ }, [iframeSrc]);
26
40
  const postToCanvas = useCallback((m) => {
41
+ if (m.kind === "refresh" && !heard.current)
42
+ missedRefresh.current = true;
27
43
  iframeRef.current?.contentWindow?.postMessage(m, "*");
28
44
  }, [iframeRef]);
29
45
  useEffect(() => {
@@ -34,6 +50,13 @@ export function useCanvasBridge({ iframeRef, iframeSrc, onSelect, onHover, onRen
34
50
  if (e.source !== iframeRef.current?.contentWindow)
35
51
  return;
36
52
  setGeometry("ok"); // any bridge message: Smoodly rendered the canvas with its bridge
53
+ if (!heard.current) {
54
+ heard.current = true;
55
+ if (missedRefresh.current) {
56
+ missedRefresh.current = false;
57
+ postToCanvas({ source: "smoodly", kind: "refresh" });
58
+ }
59
+ }
37
60
  if (m.kind === "rects") {
38
61
  setRects(m.nodes);
39
62
  setEmptyZones(m.emptyZones);
@@ -53,7 +76,7 @@ export function useCanvasBridge({ iframeRef, iframeSrc, onSelect, onHover, onRen
53
76
  };
54
77
  window.addEventListener("message", onMessage);
55
78
  return () => window.removeEventListener("message", onMessage);
56
- }, [iframeRef]);
79
+ }, [iframeRef, postToCanvas]);
57
80
  useEffect(() => {
58
81
  if (!iframeSrc || !loaded || geometry !== "waiting")
59
82
  return;
@@ -1,29 +1,14 @@
1
1
  import type { ResolvedConfig } from "../config.ts";
2
2
  import type { PageRecord, PageStore } from "../store.ts";
3
- export type FixedNode = {
4
- /** The node's segment per locale it exists in. A fixed page always
5
- * carries every supported locale — a slug map that omits one falls
6
- * back to the default locale's segment, same as a string slug. A
7
- * collection mount's segments are the collection's path exactly:
8
- * every supported locale for a string path, exactly its keys for a
9
- * per-locale map. */
10
- segments: Record<string, string>;
11
- title: string;
12
- /** The fixed page registration's name; null for a plain mount (a folder). */
13
- template: string | null;
14
- /** The collection whose `path` this node is; null for a plain fixed page. */
15
- collection: string | null;
16
- };
17
- export declare function fixedNodeSpecs(config: ResolvedConfig): FixedNode[];
18
- /** The spec a ROOT record materializes, or null for an editor-owned node.
19
- * Judged by the default-locale slug only — a root that exists in Finnish
20
- * alone is never a fixed node, whatever its Finnish slug. */
3
+ import { type CodeAddress } from "../addresses.ts";
4
+ export type FixedNode = CodeAddress;
5
+ export declare const fixedNodeSpecs: (config: ResolvedConfig) => FixedNode[];
6
+ /** The spec that owns a record, or null for an editor's page and for a record no registration owns any more. */
21
7
  export declare function fixedNodeOf(record: {
22
- parentId: string | null;
23
- locales: Record<string, {
24
- slug: string;
25
- }>;
8
+ fixed?: string | null;
26
9
  }, config: ResolvedConfig): FixedNode | null;
27
- /** Create every fixed node that has no root record yet, give every fixed
28
- * node the locales it lacks, then list. */
10
+ /** Create every code-owned record that has none yet and give each the
11
+ * locales it lacks, then list. Code wins an address (spec 2026-09-29
12
+ * anchored paths §4): an editor's page already there changes nothing
13
+ * here — the index serves the record, the Pages list flags the page. */
29
14
  export declare function ensureFixedNodes(pages: PageStore, config: ResolvedConfig): Promise<PageRecord[]>;
@@ -1,97 +1,61 @@
1
- import { fixedPageSegments, perLocaleSegments } from "../paths.js";
2
- export function fixedNodeSpecs(config) {
3
- const { default: def, supported } = config.locales;
4
- const perLocale = (v) => perLocaleSegments(v, supported);
5
- const specs = new Map();
6
- for (const p of config.registry.pages ?? []) {
7
- if (p.schema.kind !== "page" || p.schema.slug === undefined)
8
- continue;
9
- // Fixed pages exist in every supported locale (spec 2026-09-06 §3): a
10
- // slug map that omits a locale falls back to the default locale's
11
- // segment. One helper fills it for the boot check (config.ts), here
12
- // and fixedPathFor (localize.ts), so the three cannot drift.
13
- const segments = fixedPageSegments(p.schema.slug, config.locales);
14
- specs.set(segments[def], {
15
- segments,
16
- title: p.schema.title ?? p.schema.name,
17
- template: p.schema.name,
18
- collection: null,
19
- });
20
- }
21
- for (const c of config.registry.collections ?? []) {
22
- if (c.path === undefined)
23
- continue;
24
- const segments = perLocale(c.path);
25
- const indexPage = specs.get(segments[def]);
26
- if (indexPage) {
27
- // defineConfig proved the page sits at the collection's segment in
28
- // every locale: that page IS the mount — the collection's index page.
29
- indexPage.collection = c.name;
30
- continue;
31
- }
32
- specs.set(segments[def], { segments, title: c.title, template: null, collection: c.name });
33
- }
34
- return [...specs.values()];
35
- }
36
- /** The spec a ROOT record materializes, or null for an editor-owned node.
37
- * Judged by the default-locale slug only — a root that exists in Finnish
38
- * alone is never a fixed node, whatever its Finnish slug. */
1
+ import { codeAddresses, lastPart } from "../addresses.js";
2
+ export const fixedNodeSpecs = (config) => codeAddresses({ locales: config.locales, pages: config.registry.pages ?? [], collections: config.registry.collections ?? [] });
3
+ /** The spec that owns a record, or null for an editor's page and for a record no registration owns any more. */
39
4
  export function fixedNodeOf(record, config) {
40
- if (record.parentId !== null)
41
- return null;
42
- const def = config.locales.default;
43
- const slug = record.locales[def]?.slug;
44
- if (slug === undefined)
45
- return null;
46
- return fixedNodeSpecs(config).find((s) => s.segments[def] === slug) ?? null;
5
+ return record.fixed ? fixedNodeSpecs(config).find((s) => s.key === record.fixed) ?? null : null;
47
6
  }
48
- /** Create every fixed node that has no root record yet, give every fixed
49
- * node the locales it lacks, then list. */
7
+ /** Create every code-owned record that has none yet and give each the
8
+ * locales it lacks, then list. Code wins an address (spec 2026-09-29
9
+ * anchored paths §4): an editor's page already there changes nothing
10
+ * here — the index serves the record, the Pages list flags the page. */
50
11
  export async function ensureFixedNodes(pages, config) {
51
12
  const def = config.locales.default;
52
13
  const all = await pages.listPages();
53
- const roots = all.filter((p) => p.parentId === null);
54
- let attempted = false;
14
+ let wrote = false;
55
15
  for (const spec of fixedNodeSpecs(config)) {
56
- const slug = spec.segments[def];
57
- let record = roots.find((p) => p.locales[def]?.slug === slug);
16
+ let record = all.find((p) => p.fixed === spec.key);
17
+ const defAddress = spec.segments[def];
58
18
  if (!record) {
59
- attempted = true;
19
+ wrote = true;
60
20
  try {
61
- record = await pages.createPage({ locale: def, slug, template: spec.template, title: spec.title });
21
+ record = await pages.createPage({ locale: def, slug: lastPart(defAddress), template: spec.template, title: spec.title, fixed: spec.key });
62
22
  }
63
23
  catch (e) {
64
24
  // Two admins loading at once race on this very create; the loser's
65
- // slugTaken is the other one winning, not a failure, so it skips
66
- // silently — no warn. Anything else is worth saying out loud.
25
+ // fixedTaken is the other one winning. Anything else is said out loud.
67
26
  const message = e instanceof Error ? e.message : String(e);
68
- if (!message.includes("already exists")) {
69
- console.warn(`smoodly: could not materialize "${slug}": ${message}`);
70
- }
27
+ if (!message.includes("already exists"))
28
+ console.warn(`smoodly: could not materialize "${defAddress}": ${message}`);
71
29
  continue;
72
30
  }
73
31
  }
74
- // Every locale the registration names — including one added to the
75
- // config after the record was first materialized.
76
- for (const [locale, segment] of Object.entries(spec.segments)) {
32
+ else if (record.template === null && spec.template !== null) {
33
+ // The collection gained its index page. A racing admin's hasTemplate is that admin winning.
34
+ wrote = true;
35
+ try {
36
+ record = await pages.setTemplate(record.id, spec.template);
37
+ }
38
+ catch (e) {
39
+ const message = e instanceof Error ? e.message : String(e);
40
+ if (!message.includes("already has a page type"))
41
+ console.warn(`smoodly: could not give "${defAddress}" its page type: ${message}`);
42
+ }
43
+ }
44
+ // Every locale the registration names — including one added to the config after the record was first materialized.
45
+ for (const [locale, address] of Object.entries(spec.segments)) {
77
46
  if (record.locales[locale])
78
47
  continue;
79
- attempted = true;
48
+ wrote = true;
80
49
  try {
81
- record = await pages.addLocale(record.id, locale, { from: def, slug: segment });
50
+ record = await pages.addLocale(record.id, locale, { from: def, slug: lastPart(address) });
82
51
  }
83
52
  catch (e) {
84
- // One locale's row that cannot land — a segment or path an editor
85
- // already claimed there — must not take the node or the whole list
86
- // down: warn, skip that locale, keep materializing the rest. Only a
87
- // concurrent add of the SAME locale is a race, and it skips silently.
53
+ // Only a concurrent add of the SAME locale is a race; it skips silently.
88
54
  const message = e instanceof Error ? e.message : String(e);
89
- if (!message.includes("already exists in locale")) {
90
- console.warn(`smoodly: could not materialize "${slug}" in "${locale}": ${message}`);
91
- }
55
+ if (!message.includes("already exists in locale"))
56
+ console.warn(`smoodly: could not materialize "${address}" in "${locale}": ${message}`);
92
57
  }
93
58
  }
94
59
  }
95
- // A create was tried, so the pre-race list is stale either way.
96
- return attempted ? pages.listPages() : all;
60
+ return wrote ? pages.listPages() : all;
97
61
  }
@@ -6,7 +6,7 @@ export type WidgetScope = {
6
6
  fields?: Record<string, unknown>;
7
7
  settings?: Record<string, unknown>;
8
8
  };
9
- export declare function FieldWidget({ descriptor: d, value: valueProp, onChange, errors, path: pathProp, bare, refOptions, suggestions, fieldKey, prefix, mark, inlinePath, onEditHere, scope, emptyLabel, }: {
9
+ export declare function FieldWidget({ descriptor: d, value: valueProp, onChange, errors, path: pathProp, bare, refOptions, suggestions, fieldKey, prefix, mark, inlinePath, onEditHere, scope, emptyLabel, hint, onBlur, }: {
10
10
  descriptor: AdminField;
11
11
  value: unknown;
12
12
  onChange(value: unknown): void;
@@ -30,6 +30,10 @@ export declare function FieldWidget({ descriptor: d, value: valueProp, onChange,
30
30
  scope?: WidgetScope;
31
31
  /** An optional radio's empty pill; `None` unless the caller says otherwise (`Default` for a styles step). */
32
32
  emptyLabel?: string;
33
+ /** A line under the field — the slug field's no-letters hint (spec 2026-09-28 ASCII slugs §6). */
34
+ hint?: string;
35
+ /** After a slug field's tidy on blur — the entry form commits a held live slug here. */
36
+ onBlur?(): void;
33
37
  }): ReactElement;
34
38
  /** The richtext field while it is edited in the canvas (spec 2026-09-10 §5). */
35
39
  export declare function InlineNote({ onEditHere }: {
@@ -12,9 +12,10 @@ import { isHidden } from "../../conditions.js";
12
12
  import { cssProblem } from "../../css.js";
13
13
  import { Button, Field, Switch } from "../ui/primitives.js";
14
14
  import { patchObject } from "./list.js";
15
+ import { tidySlug } from "./slug-field.js";
15
16
  /** A key as a caption, sentence case: `spacingTop` → "Spacing top", `css` → "Custom CSS". */
16
17
  const label = (key) => key === "css" ? "Custom CSS" : key.replace(/([A-Z])/g, (c) => ` ${c.toLowerCase()}`).replace(/^./, (c) => c.toUpperCase());
17
- export function FieldWidget({ descriptor: d, value: valueProp, onChange, errors, path: pathProp, bare, refOptions, suggestions, fieldKey, prefix, mark, inlinePath, onEditHere, scope, emptyLabel, }) {
18
+ export function FieldWidget({ descriptor: d, value: valueProp, onChange, errors, path: pathProp, bare, refOptions, suggestions, fieldKey, prefix, mark, inlinePath, onEditHere, scope, emptyLabel, hint, onBlur, }) {
18
19
  const path = pathProp ?? fieldKey;
19
20
  // A tags field has no per-chip slot: the first item error shows under the field.
20
21
  const below = d.type === "tags" && errors ? Object.keys(errors).find((k) => k.startsWith(`${path}.`)) : undefined;
@@ -33,7 +34,9 @@ export function FieldWidget({ descriptor: d, value: valueProp, onChange, errors,
33
34
  case "text":
34
35
  case "link":
35
36
  case "slug": {
36
- const input = (_jsx("input", { className: d.type === "slug" ? "sm-input sm-input--mono" : "sm-input", value: String(value ?? ""), onChange: (e) => onChange(e.target.value) }));
37
+ // A slug tidies when it loses focus (spec 2026-09-28 ASCII slugs §4).
38
+ const input = (_jsx("input", { className: d.type === "slug" ? "sm-input sm-input--mono" : "sm-input", value: String(value ?? ""), onChange: (e) => onChange(e.target.value), onBlur: d.type === "slug" ? (e) => { const tidy = tidySlug(e.target.value); if (tidy !== e.target.value)
39
+ onChange(tidy); onBlur?.(); } : undefined }));
37
40
  control = d.type === "slug" && prefix
38
41
  ? _jsxs("span", { className: "sm-inputwrap", children: [_jsx("span", { className: "sm-prefix", children: prefix }), input] })
39
42
  : input;
@@ -143,7 +146,7 @@ export function FieldWidget({ descriptor: d, value: valueProp, onChange, errors,
143
146
  // A <label> activates its first labelable descendant, so it may only wrap a
144
147
  // single control — around refList's rows, an object's fieldset or a list's
145
148
  // rows it would turn every click into a press of the first button.
146
- return (_jsx(Field, { label: label(fieldKey), mark: mark, counter: counter, error: message, asDiv: asDiv, path: path, children: control }));
149
+ return (_jsx(Field, { label: label(fieldKey), mark: mark, counter: counter, hint: hint, error: message, asDiv: asDiv, path: path, children: control }));
147
150
  }
148
151
  /** The richtext field while it is edited in the canvas (spec 2026-09-10 §5). */
149
152
  export function InlineNote({ onEditHere }) {
@@ -0,0 +1,17 @@
1
+ /** The slug follows its title until the editor types a slug of their
2
+ * own; a slug equal to what the previous title produced is still
3
+ * "following". */
4
+ export declare function followsTitle(slug: string, previousTitle: string): boolean;
5
+ /** Under a slug box whose source text makes no slug (§6). */
6
+ export declare const NO_LETTERS_HINT = "This title has no letters a slug can use \u2014 type one, like novosti.";
7
+ /** What a slug box holds after blur (§4): the slugified text; when
8
+ * nothing survives, the text as typed if it has letters (Новости), so
9
+ * publish can name the problem, and "" if it had none (" ", "---"). */
10
+ export declare function tidySlug(value: string): string;
11
+ /** NO_LETTERS_HINT when `source` has text but no slug in it (§6). */
12
+ export declare function slugHint(source: unknown): string | undefined;
13
+ /** The entry form's fields after `key` changed to `value` (§5): while the
14
+ * row has never been published, an `f.slug({ from })` slug that still
15
+ * follows the previous `from` value follows the new one. A published
16
+ * row's slug is a live URL and never moves on its own. */
17
+ export declare function withFollowingSlug(fields: Record<string, unknown>, key: string, value: unknown, from: string | undefined, neverPublished: boolean): Record<string, unknown>;