smoodly 0.0.31 → 0.0.33

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 CHANGED
@@ -587,6 +587,20 @@ Implemented:
587
587
  but `smoodly/admin/next` reaches no client module except those two),
588
588
  `examples/site/e2e/site-bundle.spec.ts` (CI only: production chunks).
589
589
 
590
+ - Export and import (spec 2026-09-28 export/import): `npx smoodly export
591
+ [--out <dir>]` writes the key's space — the nine content tables with
592
+ history, the member list, the ready assets' files — into a folder
593
+ (`manifest.json`, `rows/<table>.json` without `space_id`, `media/`
594
+ without the space prefix), written as `.partial` and renamed last; it
595
+ refuses a publishable key (the `smoodly_meta` read), a database behind
596
+ the package, and a snapshot whose foreign keys do not close ("content
597
+ changed during export"). `npx smoodly import <dir>` writes it into a
598
+ space that is empty or holds only this export's rows, schema versions
599
+ equal: media first, rows in reference order with on-conflict-do-nothing,
600
+ so a stopped run finishes by running it again. Tests: `space-transfer`,
601
+ `cli-transfer`, `space-transfer.supabase`,
602
+ `examples/site/e2e/transfer.spec.ts`.
603
+
590
604
  Not yet: canvas drag-and-drop (list fields, the outline, manual
591
605
  collections and the pages tree drag since 2026-09-09 — spec
592
606
  `docs/superpowers/specs/2026-09-09-drag-and-drop-design.md`), undo/redo, sample/placeholder content
@@ -613,6 +627,45 @@ after both are on the registry. From the repo root:
613
627
  `prepublishOnly` rebuilds each package. The Claude Code skill
614
628
  `.claude/skills/releasing-smoodly` carries the same order with its checks.
615
629
 
630
+ ## Follow-ups (logged 2026-09-28, ASCII slugs spec)
631
+
632
+ - Unicode redirect sources: a replaced site's old address may hold `ä`
633
+ (`/sää-tänään`). Unchecked whether the catch-all hands `site.locate`
634
+ the decoded or the percent-encoded path, so a source typed with `ä`
635
+ may never match. Test it with a real old URL; normalize (decode + NFC)
636
+ on both sides if it fails.
637
+ - Content health does not report an entry slug outside the grammar: the
638
+ scan and the markers use drift's `fits` (a slug fits if it is text), and
639
+ the grammar is checked only at publish and on a published row's save.
640
+ - `f.slug()` under a key other than `slug` is accepted by `collection()`
641
+ but never follows its `from` and is not the entry's URL; refuse it, or
642
+ say so in the Fields guide, which already says the field must be named
643
+ `slug`.
644
+
645
+ ## Follow-ups (logged 2026-09-28, export/import spec)
646
+
647
+ - `import --replace`: delete the space's content and media first, after
648
+ typing the space to confirm — a backup restored over a live project.
649
+ Mention it in the not-empty refusal when it lands.
650
+ - A copy inside one database with fresh IDs — the cloud's "duplicate
651
+ environment", staging → production; needs the reference walk (parent
652
+ links, version pointers, refs, paths, asset ids in trees, fields and
653
+ richtext) the eject avoids by keeping IDs.
654
+ - A readable export for leaving Smoodly: one document per page or entry,
655
+ fields resolved, no internal IDs.
656
+ - A "Download backup" button in the admin: the same export behind an op,
657
+ zipped for an editor.
658
+ - A ready asset whose `poster_id` names a pending asset would fail the
659
+ export's consistency check on every run (pending assets are not
660
+ exported). Unverified whether the upload flow can leave that state;
661
+ if it can, export the poster or null the pointer with a warning.
662
+ - Import cannot expire the app's cache tags; it prints a restart note. A
663
+ revalidate call through the app (an op the CLI could reach) would make
664
+ the note unnecessary.
665
+ - A resumed import that lands nothing still prints the full "Imported …"
666
+ summary and the member list — accurate (the space holds all of it) but
667
+ noisy; count what actually landed and say "nothing new" when it is zero.
668
+
616
669
  ## Follow-ups (logged 2026-09-27, shadowed-page detector spec)
617
670
 
618
671
  - A section's crash on a live page after data changed outside the page
@@ -760,14 +813,6 @@ after both are on the registry. From the repo root:
760
813
  - A Deploying guide (nav lists it, unwritten) should show
761
814
  `npx smoodly migrate --check` as a build step.
762
815
 
763
- ## Follow-ups (logged 2026-09-25, docs session)
764
-
765
- - `f.slug({ from })` stores `from` on the descriptor, but nothing reads it:
766
- the admin never derives a slug from the named field. Either wire it (fill
767
- the slug from `from` while the slug is untouched, in the entry form) or
768
- drop the option. The Fields guide documents the slug as a plain text box
769
- with the URL prefix and does not mention `from`.
770
-
771
816
  ## Follow-ups (logged 2026-09-17, admin path spec)
772
817
 
773
818
  - A robots `Disallow` for the admin path from `next/meta`.
@@ -1106,14 +1151,15 @@ tool — reset and reseed a development database (`supabase db reset`,
1106
1151
  - The Pages list's prompts are dialogs on the same card since the same
1107
1152
  day (`shell/PageDialogs.tsx` over `ui/Dialog.tsx`): New page / New
1108
1153
  path / New subpage / New subpath (title, slug following the title
1109
- through `slugify` in `shell/page-dialogs.ts`, page type when more than
1154
+ through `slugify` in `paths.ts`, page type when more than
1110
1155
  one open template), Add page here (page type; skipped with one), Rename
1111
1156
  (title and slug; the home page's title alone). Still native: the
1112
1157
  `window.alert`s on a refused list op outside a dialog (`run` in
1113
1158
  PagesList, EntriesList's add/remove) — a notice banner is the follow-up.
1114
- - `slugify` is the admin's first slugifier (title → segment); the slug
1115
- field widget (`f.slug`) does not use it yet, and neither does the entry
1116
- create-on-click. OQ 24 (unicode slugs) would relax its grammar.
1159
+ - `slugify` (`paths.ts`, exported) is the one slugifier; the slug-box
1160
+ helpers in `admin/forms/slug-field.ts` (`followsTitle`, `tidySlug`,
1161
+ `slugHint`, `withFollowingSlug`) serve the page dialogs, page Settings,
1162
+ the `f.slug` widget and the entry form (spec 2026-09-28 ASCII slugs).
1117
1163
  - The dialog's fields take the tile tone on the white card
1118
1164
  (`.sm-dialog__form .sm-input`), the reverse of fields on tiles.
1119
1165
  - The confirm's copy lives at each call site (`title` / `body` /
@@ -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,7 +907,7 @@ 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 &&
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] ?? [], 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
911
  !isFixedNode(record, registry) &&
912
912
  !(record.parentId === null && record.locales[registry.locales.default]?.slug === registry.homePageSlug)
913
913
  ? () => { void doRemoveLocale(); }
@@ -13,6 +13,7 @@ import { Field, InfoRow, SectionLabel } from "../ui/primitives.js";
13
13
  import { confirmLiveUrl } from "../shell/live-url.js";
14
14
  import { RouteLink } from "../shell/RouteLink.js";
15
15
  import { useAdminHref } from "../shell/base.js";
16
+ import { tidySlug } from "../forms/slug-field.js";
16
17
  export function PageSettings({ record, locale, registry, path, fixed, redirects, onRename }) {
17
18
  const row = record.locales[locale];
18
19
  const [draft, setDraft] = useState({ title: row?.title ?? "", slug: row?.slug ?? "" });
@@ -33,7 +34,10 @@ export function PageSettings({ record, locale, registry, path, fixed, redirects,
33
34
  committing.current = true;
34
35
  try {
35
36
  setError(null);
36
- const next = draft[key];
37
+ // A slug tidies before it is committed (spec 2026-09-28 ASCII slugs §4).
38
+ const next = key === "slug" ? tidySlug(draft.slug) : draft[key];
39
+ if (next !== draft[key])
40
+ setDraft((d) => ({ ...d, [key]: next }));
37
41
  if (next === row[key])
38
42
  return;
39
43
  if (next === "") {
@@ -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");
@@ -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>;
@@ -0,0 +1,39 @@
1
+ // What a slug box does (spec 2026-09-28 ASCII slugs §4–§6): pure, shared
2
+ // by the page dialogs, page Settings, the slug field widget and the entry
3
+ // form. The grammar and the slugifier live in paths.ts.
4
+ import { slugify } from "../../paths.js";
5
+ /** The slug follows its title until the editor types a slug of their
6
+ * own; a slug equal to what the previous title produced is still
7
+ * "following". */
8
+ export function followsTitle(slug, previousTitle) {
9
+ return slug === "" || slug === slugify(previousTitle);
10
+ }
11
+ /** Under a slug box whose source text makes no slug (§6). */
12
+ export const NO_LETTERS_HINT = "This title has no letters a slug can use — type one, like novosti.";
13
+ /** What a slug box holds after blur (§4): the slugified text; when
14
+ * nothing survives, the text as typed if it has letters (Новости), so
15
+ * publish can name the problem, and "" if it had none (" ", "---"). */
16
+ export function tidySlug(value) {
17
+ const tidy = slugify(value);
18
+ if (tidy !== "")
19
+ return tidy;
20
+ return /[\p{L}\p{N}]/u.test(value) ? value : "";
21
+ }
22
+ /** NO_LETTERS_HINT when `source` has text but no slug in it (§6). */
23
+ export function slugHint(source) {
24
+ return typeof source === "string" && source.trim() !== "" && slugify(source) === "" ? NO_LETTERS_HINT : undefined;
25
+ }
26
+ /** The entry form's fields after `key` changed to `value` (§5): while the
27
+ * row has never been published, an `f.slug({ from })` slug that still
28
+ * follows the previous `from` value follows the new one. A published
29
+ * row's slug is a live URL and never moves on its own. */
30
+ export function withFollowingSlug(fields, key, value, from, neverPublished) {
31
+ const next = { ...fields, [key]: value };
32
+ if (from === undefined || key !== from || !neverPublished)
33
+ return next;
34
+ const slug = typeof fields.slug === "string" ? fields.slug : "";
35
+ const previous = typeof fields[from] === "string" ? fields[from] : "";
36
+ if (!followsTitle(slug, previous))
37
+ return next;
38
+ return { ...next, slug: slugify(typeof value === "string" ? value : "") };
39
+ }
@@ -1,4 +1,5 @@
1
- import { entryFieldsIn, entryTitleIn } from "../entry-store.js";
1
+ import { entryErrors, entryFieldsIn, entryTitleIn } from "../entry-store.js";
2
+ import { isSegment } from "../paths.js";
2
3
  import { distinctTags } from "../tags.js";
3
4
  import { can, isMemberRole, normalizeRole } from "../permissions.js";
4
5
  import { ASSET_COLLECTION } from "../media.js";
@@ -6,7 +7,7 @@ import { adminHref, adminSlugTaken, isAdminSlug } from "../admin-path.js";
6
7
  import { affectedTargets, collectionTag, entryTag, pageTag, pagesTreeTag, globalTag } from "../revalidate.js";
7
8
  import { ensureFixedNodes, fixedNodeOf } from "./fixed-nodes.js";
8
9
  import { ensureGlobalItems } from "./global-items.js";
9
- import { validateCustomNode, validateFields, validateNode } from "./validate.js";
10
+ import { SLUG_GRAMMAR_MESSAGE, validateCustomNode, validateFields, validateNode } from "./validate.js";
10
11
  import { serializeAdminConfig } from "./serialize.js";
11
12
  import { entryDrift, pageDrift } from "./drift.js";
12
13
  import { publishErrors } from "./drift-copy.js";
@@ -675,7 +676,27 @@ export function createAdminOps(deps) {
675
676
  const descriptors = collectionFields(collection);
676
677
  if (!descriptors)
677
678
  return err("not_found", `smoodly: no collection named "${collection}".`);
678
- const entry = await entries.update(collection, id, locale, fields, by, token, { redirect: options?.redirect ?? true });
679
+ // A published row's slug is its live address and is never versioned, so this
680
+ // save is its publish: the grammar holds here (spec 2026-09-28 ASCII slugs §3).
681
+ const slug = typeof fields.slug === "string" && fields.slug !== "" ? fields.slug : null;
682
+ if (descriptors.slug !== undefined && (slug === null || !isSegment(slug))) {
683
+ const row = (await entries.get(collection, id))?.locales[locale];
684
+ if (row?.status === "published" && row.slug !== null && row.slug !== slug) {
685
+ const message = slug === null ? "Required while the entry is published." : SLUG_GRAMMAR_MESSAGE;
686
+ return err("validation", "Some fields need attention.", [{ field: "slug", message }]);
687
+ }
688
+ }
689
+ let entry;
690
+ try {
691
+ entry = await entries.update(collection, id, locale, fields, by, token, { redirect: options?.redirect ?? true });
692
+ }
693
+ catch (e) {
694
+ // A sibling's slug is the slug field's problem, not the whole draft's (spec 2026-09-28 ASCII slugs §5).
695
+ if (e instanceof Error && e.message === entryErrors.slugTaken(collection).message) {
696
+ return err("validation", "Some fields need attention.", [{ field: "slug", message: "Another entry here already uses this slug." }]);
697
+ }
698
+ throw e;
699
+ }
679
700
  await fanOut(id);
680
701
  expireCollection(collection);
681
702
  return ok(entry);
@@ -48,8 +48,11 @@ import { unsetAtPath } from "../field-paths.js";
48
48
  import { usePresence, usePresenceState, useSavedElsewhere, useTrackPresence } from "./presence-context.js";
49
49
  import { editorsOf } from "./presence.js";
50
50
  import { PresenceLine } from "../ui/PresenceLine.js";
51
+ import { slugHint, tidySlug, withFollowingSlug } from "../forms/slug-field.js";
51
52
  /** A stable empty map for the hooks that run before the "no such collection" return. */
52
53
  const NO_FIELDS = {};
54
+ /** True while the entry's slug box has focus: its field wrapper carries the path. */
55
+ const slugBoxFocused = () => typeof document !== "undefined" && document.activeElement instanceof HTMLElement && document.activeElement.closest('[data-sm-path="slug"]') !== null;
53
56
  export function EntryForm({ ops, registry, collection, id }) {
54
57
  const adminHref = useAdminHref();
55
58
  const meta = registry.collections.find((c) => c.name === collection);
@@ -62,6 +65,9 @@ export function EntryForm({ ops, registry, collection, id }) {
62
65
  const [parentLocales, setParentLocales] = useState(registry.locales.supported);
63
66
  const [notice, setNotice] = useState(null);
64
67
  const [fieldErrors, setFieldErrors] = useState({});
68
+ /** A refused save's field errors (spec 2026-09-28 ASCII slugs): kept apart from the publish refusal's, which
69
+ * revalidation shrinks — a sibling's slug is no rule the form can check. Cleared by the next save that lands. */
70
+ const [saveErrors, setSaveErrors] = useState({});
65
71
  const [adding, setAdding] = useState(false);
66
72
  const [reloadKey, setReloadKey] = useState(0);
67
73
  /** An edit since the last save ack — Publish wakes on it, before the autosave runs (spec 2026-09-13 publish flow §2). */
@@ -84,6 +90,8 @@ export function EntryForm({ ops, registry, collection, id }) {
84
90
  const confirmedSlug = useRef(null);
85
91
  /** The confirm's "Redirect the old address" answer for the save that follows it (spec 2026-09-26 redirects §6). */
86
92
  const redirectRef = useRef(true);
93
+ /** A live slug change the autosave held back while its box had focus (spec 2026-09-28 ASCII slugs §4). */
94
+ const slugHeld = useRef(false);
87
95
  // The page editor's controller: debounced, retrying, flushable (spec 2026-09-14 §3).
88
96
  /** The token of the row on screen — what every guarded op sends (spec 2026-09-26 concurrency §1). */
89
97
  const token = () => { const row = recordRef.current?.locales[locale]; return row ? draftToken(row) : null; };
@@ -98,7 +106,23 @@ export function EntryForm({ ops, registry, collection, id }) {
98
106
  // A published entry's slug is a live URL: one confirm at the save, Cancel restores it (members management §5).
99
107
  const row = recordRef.current?.locales[locale];
100
108
  const stored = row?.slug ?? null;
101
- const next = typeof value.slug === "string" ? value.slug : null;
109
+ let next = typeof value.slug === "string" ? value.slug : null;
110
+ // A live slug changes when its box loses focus, never at a pause mid-typing (spec
111
+ // 2026-09-28 ASCII slugs §4): until then the rest of the draft saves with the live slug.
112
+ if (liveUrlChanges({ status: row?.status, from: stored, to: next }) && slugBoxFocused()) {
113
+ slugHeld.current = true;
114
+ value = { ...value, slug: stored };
115
+ next = stored;
116
+ }
117
+ // Tidied before the question, so the dialog asks about the URL that is saved.
118
+ if (next !== null && liveUrlChanges({ status: row?.status, from: stored, to: next })) {
119
+ const tidy = tidySlug(next);
120
+ if (tidy !== next) {
121
+ next = tidy;
122
+ value = { ...value, slug: tidy };
123
+ setFields((f) => ({ ...(f ?? {}), slug: tidy }));
124
+ }
125
+ }
102
126
  if (next !== confirmedSlug.current && liveUrlChanges({ status: row?.status, from: stored, to: next })) {
103
127
  const current = recordRef.current;
104
128
  const subject = current && meta ? { name: entryTitle(current, meta, registry, locale), path: slugPrefix && stored ? `${slugPrefix}${stored}` : null } : undefined;
@@ -116,13 +140,19 @@ export function EntryForm({ ops, registry, collection, id }) {
116
140
  if (r.ok) {
117
141
  redirectRef.current = true;
118
142
  setRecord(r.data);
143
+ setSaveErrors({});
119
144
  presence?.announce(target, r.data.locales[locale]?.draftSavedAt ?? null);
120
145
  }
121
146
  else if (isStale(r))
122
147
  setStale(staleInfoOf(r));
148
+ // A refused save (a sibling's slug, a live slug outside the grammar) marks its fields until a save lands.
149
+ else if (r.code === "validation" && r.fields)
150
+ setSaveErrors(Object.fromEntries(r.fields.map((f) => [f.field, f.message])));
123
151
  return r;
124
152
  },
125
- onSaved: () => setDirty(false),
153
+ // A held slug is still unsaved: the form stays dirty until its box loses focus.
154
+ onSaved: () => { if (!slugHeld.current)
155
+ setDirty(false); },
126
156
  });
127
157
  // Reload and close ask only while a save is pending or in flight.
128
158
  useLeaveGuard(dirty || saveState !== "saved");
@@ -143,6 +173,8 @@ export function EntryForm({ ops, registry, collection, id }) {
143
173
  let cancelled = false;
144
174
  setFields(null);
145
175
  setFieldErrors({});
176
+ setSaveErrors({});
177
+ slugHeld.current = false;
146
178
  setDirty(false);
147
179
  setStale(null);
148
180
  setTouched(false);
@@ -167,7 +199,7 @@ export function EntryForm({ ops, registry, collection, id }) {
167
199
  const findings = useMemo(() => (fields && record?.locales[locale]
168
200
  ? entryDrift(registry, collection, { fields, shared: record.fields, own: record.locales[locale].fields })
169
201
  : []), [fields, record, locale, registry, collection]);
170
- const markerErrors = useMemo(() => ({ ...misfitErrors(findings), ...fieldErrors }), [findings, fieldErrors]);
202
+ const markerErrors = useMemo(() => ({ ...misfitErrors(findings), ...fieldErrors, ...saveErrors }), [findings, fieldErrors, saveErrors]);
171
203
  if (!meta)
172
204
  return _jsx("p", { style: { padding: 32 }, children: "No such collection." });
173
205
  const row = record?.locales[locale];
@@ -418,8 +450,16 @@ export function EntryForm({ ops, registry, collection, id }) {
418
450
  return record ? { name: heading, path: slugPrefix && slug ? `${slugPrefix}${slug}` : null } : undefined;
419
451
  };
420
452
  const present = record ? registry.locales.supported.filter((l) => record.locales[l]) : [locale];
421
- const widget = ([key, d]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: d, value: fields?.[key], errors: markerErrors, refOptions: refTargets[key] ? refOptions[refTargets[key]] : undefined, suggestions: tagSuggestions[key], prefix: d.type === "slug" ? slugPrefix : undefined, mark: multilingual && !localeKeys.includes(key) ? _jsx(UnlocalizedMark, {}) : undefined, onChange: (value) => {
422
- setFields((f) => ({ ...(f ?? {}), [key]: value }));
453
+ // f.slug({ from }) (spec 2026-09-28 ASCII slugs §5–§6): the field a never-published slug follows.
454
+ const declaredFrom = meta.fields.slug?.from;
455
+ const slugFrom = typeof declaredFrom === "string" ? declaredFrom : undefined;
456
+ const widget = ([key, d]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: d, value: fields?.[key], errors: markerErrors, refOptions: refTargets[key] ? refOptions[refTargets[key]] : undefined, suggestions: tagSuggestions[key], prefix: d.type === "slug" ? slugPrefix : undefined, hint: key === "slug" && slugFrom && !fields?.slug ? slugHint(fields?.[slugFrom]) : undefined, onBlur: key === "slug" ? () => { if (slugHeld.current) {
457
+ slugHeld.current = false;
458
+ setDirty(true);
459
+ mark();
460
+ } } : undefined, mark: multilingual && !localeKeys.includes(key) ? _jsx(UnlocalizedMark, {}) : undefined, onChange: (value) => {
461
+ const neverPublished = (recordRef.current?.locales[locale]?.publishedVersionId ?? null) === null;
462
+ setFields((f) => withFollowingSlug(f ?? {}, key, value, slugFrom, neverPublished));
423
463
  setDirty(true);
424
464
  setTouched(true);
425
465
  mark();
@@ -431,7 +471,7 @@ export function EntryForm({ ops, registry, collection, id }) {
431
471
  trailing: multilingual && (_jsx(LocaleSwitcher, { registry: registry, present: present, parentLocales: parentLocales, value: locale, adding: adding, onSwitch: (l) => { void switchLocale(l); }, onAdd: (l) => void doAddLocale(l) })),
432
472
  },
433
473
  { label: heading },
434
- ] }) }), right: _jsxs(_Fragment, { children: [(touched || saveState === "error") && _jsx(SaveIndicator, { state: saveState, pending: dirty }), urls[locale] && (_jsx(LinkButton, { icon: true, href: urls[locale], 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: status, pending: dirty || saveState !== "saved", busy: busy, flash: published, disabled: !row, onPublish: () => { void publish(); }, onUnpublish: () => { void unpublish(); }, onDiscard: () => { void discard(); }, onHistory: () => { void openHistory(); } })] }) }), history && row && (_jsx(HistoryModal, { rows: history, draftId: row.draftVersionId, onSelect: (v) => { void loadSnapshot(v); }, onClose: () => setHistory(null), onRestore: restore, render: (versionId) => snapshot?.id === versionId ? (_jsx("fieldset", { disabled: true, className: "sm-history__form", children: Object.entries(meta.fields).map(([key, d]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: d, value: snapshot.fields[key], refOptions: refTargets[key] ? refOptions[refTargets[key]] : undefined, prefix: d.type === "slug" ? slugPrefix : undefined, onChange: () => { } }, key))) })) : _jsx("p", { className: "sm-hint", style: { padding: 16 }, children: "Loading\u2026" }) })), _jsx(PresenceLine, { names: editorsOf(others, target), noun: "entry" }), stale && _jsx(StaleBanner, { info: stale, onReload: loadTheirs }), _jsx(DriftNotice, { draft: findings, live: liveFindings }), _jsxs("div", { className: "sm-editor", inert: stale ? true : undefined, children: [_jsxs("div", { className: "sm-form", ref: formRef, children: [notice && (_jsxs(Banner, { tone: notice.tone, onDismiss: () => setNotice(null), children: [_jsx("p", { style: { margin: 0 }, children: notice.text }), _jsx(FieldErrorList, { fields: notice.fields ?? [], onReveal: (path) => { if (formRef.current)
474
+ ] }) }), right: _jsxs(_Fragment, { children: [(touched || saveState === "error" || saveState === "refused") && _jsx(SaveIndicator, { state: saveState, pending: dirty }), urls[locale] && (_jsx(LinkButton, { icon: true, href: urls[locale], 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: status, pending: dirty || saveState !== "saved", busy: busy, flash: published, disabled: !row, onPublish: () => { void publish(); }, onUnpublish: () => { void unpublish(); }, onDiscard: () => { void discard(); }, onHistory: () => { void openHistory(); } })] }) }), history && row && (_jsx(HistoryModal, { rows: history, draftId: row.draftVersionId, onSelect: (v) => { void loadSnapshot(v); }, onClose: () => setHistory(null), onRestore: restore, render: (versionId) => snapshot?.id === versionId ? (_jsx("fieldset", { disabled: true, className: "sm-history__form", children: Object.entries(meta.fields).map(([key, d]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: d, value: snapshot.fields[key], refOptions: refTargets[key] ? refOptions[refTargets[key]] : undefined, prefix: d.type === "slug" ? slugPrefix : undefined, onChange: () => { } }, key))) })) : _jsx("p", { className: "sm-hint", style: { padding: 16 }, children: "Loading\u2026" }) })), _jsx(PresenceLine, { names: editorsOf(others, target), noun: "entry" }), stale && _jsx(StaleBanner, { info: stale, onReload: loadTheirs }), _jsx(DriftNotice, { draft: findings, live: liveFindings }), _jsxs("div", { className: "sm-editor", inert: stale ? true : undefined, children: [_jsxs("div", { className: "sm-form", ref: formRef, children: [notice && (_jsxs(Banner, { tone: notice.tone, onDismiss: () => setNotice(null), children: [_jsx("p", { style: { margin: 0 }, children: notice.text }), _jsx(FieldErrorList, { fields: notice.fields ?? [], onReveal: (path) => { if (formRef.current)
435
475
  revealField(formRef.current, path); } })] })), absent ? (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 12 }, children: [_jsxs("p", { children: ["This entry doesn't exist in ", locale, " yet. Adding it copies the ", entrySourceLocale(record, registry), " fields as the first ", locale, " draft."] }), _jsxs("div", { style: { display: "flex", gap: 8 }, children: [_jsxs(Button, { onClick: () => void doAddLocale(locale), disabled: adding || !parentLocales.includes(locale), title: parentLocales.includes(locale) ? undefined : `add ${locale} to the parent first`, children: ["Add ", locale] }), _jsxs(Button, { variant: "secondary", onClick: () => { void switchLocale(entrySourceLocale(record, registry)); }, children: ["Back to ", entrySourceLocale(record, registry)] })] })] })) : fields !== null ? (_jsxs(_Fragment, { children: [Object.entries(meta.fields).map(widget), _jsx(OrphanFields, { orphans: findings, onClear: (path) => {
436
476
  setFields((f) => unsetAtPath(f ?? {}, path));
437
477
  setDirty(true);
@@ -318,6 +318,6 @@ export function GlobalForm({ ops, registry, name }) {
318
318
  trailing: multilingual && (_jsx(LocaleSwitcher, { registry: registry, present: registry.locales.supported, parentLocales: [], value: locale, adding: false, onSwitch: (l) => { void switchLocale(l); }, onAdd: () => undefined })),
319
319
  },
320
320
  { label: item.title },
321
- ] }) }), right: _jsxs(_Fragment, { children: [(touched || saveState === "error") && _jsx(SaveIndicator, { state: saveState, pending: dirty }), _jsx(PublishControl, { status: status, pending: dirty || saveState !== "saved", busy: busy, flash: published, disabled: !row, onPublish: () => { void publish(); }, onUnpublish: () => { void unpublish(); }, onDiscard: () => { void discard(); }, onHistory: () => { void openHistory(); } })] }) }), history && row && (_jsx(HistoryModal, { rows: history, draftId: row.draftVersionId, onSelect: (v) => { void loadSnapshot(v); }, onClose: () => setHistory(null), onRestore: restore, render: (versionId) => snapshot?.id === versionId ? (_jsxs("fieldset", { disabled: true, className: "sm-history__form", children: [_jsx(GlobalFields, { item: item, groups: contentGroups, fields: snapshot.fields, refOptions: refOptions, multilingual: multilingual, onChange: () => { } }), settingGroups.length > 0 && (_jsxs(_Fragment, { children: [_jsx(SectionLabel, { children: "Settings" }), _jsx(GlobalFields, { item: item, groups: settingGroups, fields: snapshot.fields, refOptions: refOptions, multilingual: multilingual, onChange: () => { } })] }))] })) : _jsx("p", { className: "sm-hint", style: { padding: 16 }, children: "Loading\u2026" }) })), _jsx(PresenceLine, { names: editorsOf(others, target), noun: "item" }), stale && _jsx(StaleBanner, { info: stale, onReload: loadTheirs }), _jsx(DriftNotice, { draft: findings, live: liveFindings }), _jsxs("div", { className: "sm-editor", inert: stale ? true : undefined, children: [_jsxs("div", { className: "sm-form", ref: formRef, children: [notice && (_jsxs(Banner, { tone: notice.tone, onDismiss: () => setNotice(null), children: [_jsx("p", { style: { margin: 0 }, children: notice.text }), _jsx(FieldErrorList, { fields: notice.fields ?? [], onReveal: (path) => { if (formRef.current)
321
+ ] }) }), right: _jsxs(_Fragment, { children: [(touched || saveState === "error" || saveState === "refused") && _jsx(SaveIndicator, { state: saveState, pending: dirty }), _jsx(PublishControl, { status: status, pending: dirty || saveState !== "saved", busy: busy, flash: published, disabled: !row, onPublish: () => { void publish(); }, onUnpublish: () => { void unpublish(); }, onDiscard: () => { void discard(); }, onHistory: () => { void openHistory(); } })] }) }), history && row && (_jsx(HistoryModal, { rows: history, draftId: row.draftVersionId, onSelect: (v) => { void loadSnapshot(v); }, onClose: () => setHistory(null), onRestore: restore, render: (versionId) => snapshot?.id === versionId ? (_jsxs("fieldset", { disabled: true, className: "sm-history__form", children: [_jsx(GlobalFields, { item: item, groups: contentGroups, fields: snapshot.fields, refOptions: refOptions, multilingual: multilingual, onChange: () => { } }), settingGroups.length > 0 && (_jsxs(_Fragment, { children: [_jsx(SectionLabel, { children: "Settings" }), _jsx(GlobalFields, { item: item, groups: settingGroups, fields: snapshot.fields, refOptions: refOptions, multilingual: multilingual, onChange: () => { } })] }))] })) : _jsx("p", { className: "sm-hint", style: { padding: 16 }, children: "Loading\u2026" }) })), _jsx(PresenceLine, { names: editorsOf(others, target), noun: "item" }), stale && _jsx(StaleBanner, { info: stale, onReload: loadTheirs }), _jsx(DriftNotice, { draft: findings, live: liveFindings }), _jsxs("div", { className: "sm-editor", inert: stale ? true : undefined, children: [_jsxs("div", { className: "sm-form", ref: formRef, children: [notice && (_jsxs(Banner, { tone: notice.tone, onDismiss: () => setNotice(null), children: [_jsx("p", { style: { margin: 0 }, children: notice.text }), _jsx(FieldErrorList, { fields: notice.fields ?? [], onReveal: (path) => { if (formRef.current)
322
322
  revealField(formRef.current, path); } })] })), record && (_jsxs("p", { className: "sm-hint", children: [pages.size === 0 ? "Not placed on any page yet." : `Used on ${pages.size} page${pages.size === 1 ? "" : "s"}: `, [...pages].map((id, i) => (_jsxs("span", { children: [i > 0 && ", ", _jsx("a", { href: adminHref(`/pages/${encodeURIComponent(id)}?locale=${encodeURIComponent(locale)}`), children: id })] }, id)))] })), fields !== null ? (_jsxs(_Fragment, { children: [_jsx(GlobalFields, { item: item, groups: contentGroups, fields: fields, errors: markerErrors, refOptions: refOptions, multilingual: multilingual, onChange: onChange }), settingGroups.length > 0 && (_jsxs(_Fragment, { children: [_jsx(SectionLabel, { children: "Settings" }), _jsx(GlobalFields, { item: item, groups: settingGroups, fields: fields, errors: markerErrors, refOptions: refOptions, multilingual: multilingual, onChange: onChange })] })), _jsx(OrphanFields, { orphans: findings, onClear: clearOrphan })] })) : !notice && _jsx("p", { style: { color: "var(--text-muted)" }, children: "Loading\u2026" })] }), _jsx(ResizeHandle, {}), _jsx("aside", { className: "sm-inspector", children: record && row && (_jsxs("div", { className: "sm-card", children: [_jsx(SectionLabel, { children: "Document info" }), _jsx(InfoRow, { label: "Kind", value: item.section !== undefined ? `Visual · ${item.section}` : "Fields" }), _jsx(InfoRow, { label: "Updated", value: updatedLine(row.updatedAt, row.updatedBy) }), _jsx(InfoRow, { label: "ID", value: record.id, mono: true })] })) })] })] }));
323
323
  }
@@ -10,7 +10,8 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
10
10
  import { useId, useState } from "react";
11
11
  import { DialogCard } from "../ui/Dialog.js";
12
12
  import { Banner, Button, Field } from "../ui/primitives.js";
13
- import { followsTitle, slugify } from "./page-dialogs.js";
13
+ import { slugify } from "../../paths.js";
14
+ import { followsTitle, slugHint, tidySlug } from "../forms/slug-field.js";
14
15
  export function PageFormDialog({ title: heading, submitLabel, fields, templates = [], parentPath, initial, onClose, onSubmit }) {
15
16
  const [values, setValues] = useState({
16
17
  title: initial?.title ?? "",
@@ -22,17 +23,22 @@ export function PageFormDialog({ title: heading, submitLabel, fields, templates
22
23
  const chooseTemplate = fields.template && templates.length > 1;
23
24
  const prefix = parentPath === undefined ? undefined : parentPath === "/" ? "/" : `${parentPath}/`;
24
25
  const setTitle = (title) => setValues((v) => ({ ...v, title, slug: fields.slug && followsTitle(v.slug, v.title) ? slugify(title) : v.slug }));
26
+ // The slug tidies when it loses focus (spec 2026-09-28 ASCII slugs §4).
27
+ const tidy = () => setValues((v) => ({ ...v, slug: tidySlug(v.slug) }));
25
28
  const submit = async (e) => {
26
29
  e.preventDefault();
27
30
  if (busy)
28
31
  return;
29
32
  setError(null);
30
33
  setBusy(true);
31
- setError(await onSubmit({ ...values, title: values.title.trim(), slug: values.slug.trim() }));
34
+ // Enter submits without a blur: the slug is tidied here too (spec 2026-09-28 ASCII slugs §4).
35
+ const slug = tidySlug(values.slug.trim());
36
+ setValues((v) => ({ ...v, slug }));
37
+ setError(await onSubmit({ ...values, title: values.title.trim(), slug }));
32
38
  setBusy(false);
33
39
  };
34
40
  const formId = `sm-page-form${useId()}`;
35
- return (_jsx(DialogCard, { title: heading, onClose: onClose, actions: _jsxs(_Fragment, { children: [_jsx(Button, { variant: "secondary", onClick: onClose, children: "Cancel" }), _jsx("button", { type: "submit", form: formId, className: "sm-btn sm-btn--primary", disabled: busy, children: busy ? "Saving…" : submitLabel })] }), children: _jsxs("form", { id: formId, className: "sm-dialog__form", onSubmit: submit, children: [fields.title && (_jsx(Field, { label: "Title", children: _jsx("input", { className: "sm-input", required: true, autoFocus: true, value: values.title, onChange: (e) => setTitle(e.target.value) }) })), fields.slug && (_jsx(Field, { label: "Slug", hint: "The URL segment: lowercase letters, digits and hyphens.", children: prefix
36
- ? (_jsxs("span", { className: "sm-inputwrap", children: [_jsx("span", { className: "sm-prefix", children: prefix }), _jsx("input", { className: "sm-input sm-input--mono", required: true, autoFocus: !fields.title, value: values.slug, onChange: (e) => setValues((v) => ({ ...v, slug: e.target.value })) })] }))
37
- : (_jsx("input", { className: "sm-input sm-input--mono", required: true, autoFocus: !fields.title, value: values.slug, onChange: (e) => setValues((v) => ({ ...v, slug: e.target.value })) })) })), chooseTemplate && (_jsx(Field, { label: "Page type", children: _jsx("select", { className: "sm-select", autoFocus: !fields.title && !fields.slug, value: values.template ?? "", onChange: (e) => setValues((v) => ({ ...v, template: e.target.value })), children: templates.map((t) => _jsx("option", { value: t.name, children: t.title }, t.name)) }) })), error && _jsx(Banner, { tone: "error", children: error })] }) }));
41
+ return (_jsx(DialogCard, { title: heading, onClose: onClose, actions: _jsxs(_Fragment, { children: [_jsx(Button, { variant: "secondary", onClick: onClose, children: "Cancel" }), _jsx("button", { type: "submit", form: formId, className: "sm-btn sm-btn--primary", disabled: busy, children: busy ? "Saving…" : submitLabel })] }), children: _jsxs("form", { id: formId, className: "sm-dialog__form", onSubmit: submit, children: [fields.title && (_jsx(Field, { label: "Title", children: _jsx("input", { className: "sm-input", required: true, autoFocus: true, value: values.title, onChange: (e) => setTitle(e.target.value) }) })), fields.slug && (_jsx(Field, { label: "Slug", hint: (values.slug === "" ? slugHint(values.title) : undefined) ?? "The URL segment: lowercase letters, digits and hyphens.", children: prefix
42
+ ? (_jsxs("span", { className: "sm-inputwrap", children: [_jsx("span", { className: "sm-prefix", children: prefix }), _jsx("input", { className: "sm-input sm-input--mono", required: true, autoFocus: !fields.title, value: values.slug, onChange: (e) => setValues((v) => ({ ...v, slug: e.target.value })), onBlur: tidy })] }))
43
+ : (_jsx("input", { className: "sm-input sm-input--mono", required: true, autoFocus: !fields.title, value: values.slug, onChange: (e) => setValues((v) => ({ ...v, slug: e.target.value })), onBlur: tidy })) })), chooseTemplate && (_jsx(Field, { label: "Page type", children: _jsx("select", { className: "sm-select", autoFocus: !fields.title && !fields.slug, value: values.template ?? "", onChange: (e) => setValues((v) => ({ ...v, template: e.target.value })), children: templates.map((t) => _jsx("option", { value: t.name, children: t.title }, t.name)) }) })), error && _jsx(Banner, { tone: "error", children: error })] }) }));
38
44
  }
@@ -132,6 +132,7 @@ export function SaveIndicator({ state, pending = false }) {
132
132
  saving: { label: "Saving", dot: "var(--warn)", color: "var(--text-muted)" },
133
133
  error: { label: "Save failed — retrying", dot: "var(--danger)", color: "var(--danger)" },
134
134
  stale: { label: "Not saved — newer version exists", dot: "var(--danger)", color: "var(--danger)" },
135
+ refused: { label: "Not saved — fix the marked fields", dot: "var(--danger)", color: "var(--danger)" },
135
136
  };
136
137
  const { label, dot, color } = map[pending && state === "saved" ? "saving" : state];
137
138
  return (_jsxs("span", { className: "sm-savestate", "data-testid": "save-status", style: { color }, title: label, children: [_jsx("span", { className: "sm-dot", style: { background: dot } }), label] }));
@@ -1,3 +1,5 @@
1
+ /** A slug outside the path grammar, at publish (spec 2026-09-28 ASCII slugs §3). */
2
+ export declare const SLUG_GRAMMAR_MESSAGE = "Use lowercase letters, digits and single hyphens, like my-post.";
1
3
  export type FieldError = {
2
4
  field: string;
3
5
  message: string;
@@ -13,6 +13,9 @@ import { ELEMENT_NODE, richtextBlocks } from "../richtext-walk.js";
13
13
  import { rowsProblems } from "../custom/rows.js";
14
14
  import { isCalendarDay, isInstant } from "./forms/date-picker.js";
15
15
  import { fits, misfitMessage } from "./drift.js";
16
+ import { isSegment } from "../paths.js";
17
+ /** A slug outside the path grammar, at publish (spec 2026-09-28 ASCII slugs §3). */
18
+ export const SLUG_GRAMMAR_MESSAGE = "Use lowercase letters, digits and single hyphens, like my-post.";
16
19
  const subfields = (d) => d.fields && typeof d.fields === "object" ? d.fields : {};
17
20
  const itemOf = (d) => d.item && typeof d.item === "object" ? d.item : undefined;
18
21
  const isEmpty = (v) => v === undefined || v === null || v === "" || (Array.isArray(v) && v.length === 0);
@@ -37,6 +40,12 @@ function validateValue(path, value, d, errors, elements, scope) {
37
40
  errors.push({ field: path, message: misfitMessage(d, value) });
38
41
  return;
39
42
  }
43
+ // Slugs (spec 2026-09-28 ASCII slugs §3): the path grammar, checked at
44
+ // publish only — saves stay lenient, since the form autosaves mid-typing.
45
+ if (d.type === "slug" && !isSegment(value)) {
46
+ errors.push({ field: path, message: SLUG_GRAMMAR_MESSAGE });
47
+ return;
48
+ }
40
49
  if (d.type === "richtext") {
41
50
  if (!hasContent(value)) {
42
51
  if (!mayBeAbsent(d))
@@ -0,0 +1,14 @@
1
+ import { type SpaceConnection } from "./space-io.ts";
2
+ export type ExportArgs = {
3
+ out: string | null;
4
+ };
5
+ export type ExportDeps = {
6
+ cwd?: string;
7
+ connection?: SpaceConnection;
8
+ now?: () => Date;
9
+ };
10
+ export declare const defaultExportDir: (now: Date) => string;
11
+ export declare const folderExists: (dir: string) => string;
12
+ export declare const CHANGED_DURING_EXPORT = "Content changed during export \u2014 run it again.";
13
+ export declare const missingFile: (path: string) => string;
14
+ export declare function runExport(args: ExportArgs, out: (line: string) => void, deps?: ExportDeps): Promise<number>;