smoodly 0.0.32 → 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
@@ -627,6 +627,21 @@ after both are on the registry. From the repo root:
627
627
  `prepublishOnly` rebuilds each package. The Claude Code skill
628
628
  `.claude/skills/releasing-smoodly` carries the same order with its checks.
629
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
+
630
645
  ## Follow-ups (logged 2026-09-28, export/import spec)
631
646
 
632
647
  - `import --replace`: delete the space's content and media first, after
@@ -798,14 +813,6 @@ after both are on the registry. From the repo root:
798
813
  - A Deploying guide (nav lists it, unwritten) should show
799
814
  `npx smoodly migrate --check` as a build step.
800
815
 
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
816
  ## Follow-ups (logged 2026-09-17, admin path spec)
810
817
 
811
818
  - A robots `Disallow` for the admin path from `next/meta`.
@@ -1144,14 +1151,15 @@ tool — reset and reseed a development database (`supabase db reset`,
1144
1151
  - The Pages list's prompts are dialogs on the same card since the same
1145
1152
  day (`shell/PageDialogs.tsx` over `ui/Dialog.tsx`): New page / New
1146
1153
  path / New subpage / New subpath (title, slug following the title
1147
- through `slugify` in `shell/page-dialogs.ts`, page type when more than
1154
+ through `slugify` in `paths.ts`, page type when more than
1148
1155
  one open template), Add page here (page type; skipped with one), Rename
1149
1156
  (title and slug; the home page's title alone). Still native: the
1150
1157
  `window.alert`s on a refused list op outside a dialog (`run` in
1151
1158
  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.
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).
1155
1163
  - The dialog's fields take the tile tone on the white card
1156
1164
  (`.sm-dialog__form .sm-input`), the reverse of fields on tiles.
1157
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))
@@ -34,6 +34,16 @@ export function collection(input) {
34
34
  input.fields[input.order.by]?.descriptor.localized === true) {
35
35
  throw new Error(`smoodly: collection "${input.name}" orders by "${input.order.by}", which is localized — order fields are shared across locales.`);
36
36
  }
37
+ // f.slug({ from }) follows a field in the entry form (spec 2026-09-28
38
+ // ASCII slugs §5): a typo would silently never follow, so it fails here.
39
+ for (const [key, builder] of Object.entries(input.fields)) {
40
+ const from = builder.descriptor.type === "slug" ? builder.descriptor.from : undefined;
41
+ if (from === key)
42
+ throw new Error(`smoodly: collection "${input.name}" slug field "${key}" cannot follow itself.`);
43
+ if (typeof from === "string" && !Object.prototype.hasOwnProperty.call(input.fields, from)) {
44
+ throw new Error(`smoodly: collection "${input.name}" slug field "${key}" follows an unknown field "${from}".`);
45
+ }
46
+ }
37
47
  return {
38
48
  kind: "collection",
39
49
  name: input.name,
package/dist/index.d.ts CHANGED
@@ -57,7 +57,7 @@ export { affectedTargets, pageTag, entryTag, globalTag, pagesTreeTag, collection
57
57
  export type { AffectedTargets } from "./revalidate.ts";
58
58
  export { resolveTree, resolveFields, resolveMedia } from "./resolve.ts";
59
59
  export type { EntryFetcher, ResolveFieldsOptions, AssetFetcher } from "./resolve.ts";
60
- export { SEGMENT_RE, isSegment, assertSegment, joinPath, splitPath, segmentFor, isHomeNode, pagePathIn, chainOf, assertDepth, pagePath, pagePathRows, entryPathRows, collectionSegment, buildPageTree, perLocaleSegments, assertSupportedLocale, unsupportedLocale, pageAlternates, entryAlternates, } from "./paths.ts";
60
+ export { SEGMENT_RE, isSegment, assertSegment, slugify, joinPath, splitPath, segmentFor, isHomeNode, pagePathIn, chainOf, assertDepth, pagePath, pagePathRows, entryPathRows, collectionSegment, buildPageTree, perLocaleSegments, assertSupportedLocale, unsupportedLocale, pageAlternates, entryAlternates, } from "./paths.ts";
61
61
  export type { LocaleSet, PathTarget, PathRow, PageLike, EntryLike, PageTreeNode, PagePathOptions, LocaleAlternate, PageAlternate } from "./paths.ts";
62
62
  export { fixedPathFor } from "./localize.ts";
63
63
  export type { PageStore, PageRecord, PageLocale, PageVersion, PageScan, PageStoreOptions, CreatePageInput, AddLocaleOptions } from "./store.ts";
package/dist/index.js CHANGED
@@ -41,7 +41,7 @@ export { SupabasePathIndex } from "./supabase-path-index.js";
41
41
  export { entryErrors, compareEntries, DEFAULT_ENTRY_LOCALES, localizedKeys, splitFields, entryLocaleRow, entryFieldsIn, entryRefEdges, entryTitleIn, matchesWhere, } from "./entry-store.js";
42
42
  export { affectedTargets, pageTag, entryTag, globalTag, pagesTreeTag, collectionTag } from "./revalidate.js";
43
43
  export { resolveTree, resolveFields, resolveMedia } from "./resolve.js";
44
- export { SEGMENT_RE, isSegment, assertSegment, joinPath, splitPath, segmentFor, isHomeNode, pagePathIn, chainOf, assertDepth, pagePath, pagePathRows, entryPathRows, collectionSegment, buildPageTree, perLocaleSegments, assertSupportedLocale, unsupportedLocale, pageAlternates, entryAlternates, } from "./paths.js";
44
+ export { SEGMENT_RE, isSegment, assertSegment, slugify, joinPath, splitPath, segmentFor, isHomeNode, pagePathIn, chainOf, assertDepth, pagePath, pagePathRows, entryPathRows, collectionSegment, buildPageTree, perLocaleSegments, assertSupportedLocale, unsupportedLocale, pageAlternates, entryAlternates, } from "./paths.js";
45
45
  export { fixedPathFor } from "./localize.js";
46
46
  export { pathOptions, pageErrors, localeRow, pageRefEdges, DEFAULT_LOCALES } from "./store.js";
47
47
  export { DEFAULT_TOOLSET } from "./toolset.js";
package/dist/paths.d.ts CHANGED
@@ -1,6 +1,13 @@
1
1
  export declare const SEGMENT_RE: RegExp;
2
2
  export declare const isSegment: (s: string) => boolean;
3
3
  export declare function assertSegment(s: string, what: string): void;
4
+ /** A segment from any text, the one slugifier (spec 2026-09-28 ASCII
5
+ * slugs §2): compatibility forms folded (NFKD: fi → fi, A → a),
6
+ * diacritics stripped (ä → a, é → e), the letters in TRANSLITERATE
7
+ * spelled out (ø → o, ß → ss), everything else a hyphen, no leading,
8
+ * trailing or doubled hyphens. The result passes isSegment
9
+ * or is "" — a title with no Latin letters or digits makes no slug. */
10
+ export declare function slugify(text: string): string;
4
11
  export type LocaleSet = {
5
12
  default: string;
6
13
  supported: string[];
package/dist/paths.js CHANGED
@@ -9,6 +9,27 @@ export function assertSegment(s, what) {
9
9
  throw new Error(`smoodly: ${what} "${s}" is not a valid slug — use lowercase letters, digits and single hyphens.`);
10
10
  }
11
11
  }
12
+ /** Letters with no Unicode decomposition, which NFKD cannot strip to a base
13
+ * letter (spec 2026-09-28 ASCII slugs §2). Applied after lowercasing. */
14
+ const TRANSLITERATE = {
15
+ ø: "o", æ: "ae", œ: "oe", ß: "ss", ł: "l", đ: "d", ð: "d", þ: "th", ħ: "h", ı: "i",
16
+ };
17
+ const TRANSLITERATE_RE = new RegExp(`[${Object.keys(TRANSLITERATE).join("")}]`, "g");
18
+ /** A segment from any text, the one slugifier (spec 2026-09-28 ASCII
19
+ * slugs §2): compatibility forms folded (NFKD: fi → fi, A → a),
20
+ * diacritics stripped (ä → a, é → e), the letters in TRANSLITERATE
21
+ * spelled out (ø → o, ß → ss), everything else a hyphen, no leading,
22
+ * trailing or doubled hyphens. The result passes isSegment
23
+ * or is "" — a title with no Latin letters or digits makes no slug. */
24
+ export function slugify(text) {
25
+ return text
26
+ .normalize("NFKD")
27
+ .replace(/[\u0300-\u036f]/g, "")
28
+ .toLowerCase()
29
+ .replace(TRANSLITERATE_RE, (ch) => TRANSLITERATE[ch])
30
+ .replace(/[^a-z0-9]+/g, "-")
31
+ .replace(/^-+|-+$/g, "");
32
+ }
12
33
  /** ONE text for both stores: a locale outside the config set. */
13
34
  export const unsupportedLocale = (locale) => new Error(`smoodly: "${locale}" is not a supported locale.`);
14
35
  export function assertSupportedLocale(locale, set) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "smoodly",
3
- "version": "0.0.32",
3
+ "version": "0.0.33",
4
4
  "description": "Open-source, code-first CMS for Next.js on Supabase: define a component once, it becomes a typed, editable building block.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,9 +0,0 @@
1
- /** A URL segment from a title: lowercase ASCII letters, digits and
2
- * hyphens (the path grammar, DESIGN.md OQ 24), diacritics stripped
3
- * (ä → a, é → e), everything else a hyphen, no leading, trailing or
4
- * doubled hyphens. */
5
- export declare function slugify(title: string): string;
6
- /** The slug field follows the title until the editor types a slug of
7
- * their own; a slug equal to what the previous title produced is still
8
- * "following". */
9
- export declare function followsTitle(slug: string, previousTitle: string): boolean;
@@ -1,19 +0,0 @@
1
- // Pure helpers for the page dialogs (PageDialogs.tsx).
2
- /** A URL segment from a title: lowercase ASCII letters, digits and
3
- * hyphens (the path grammar, DESIGN.md OQ 24), diacritics stripped
4
- * (ä → a, é → e), everything else a hyphen, no leading, trailing or
5
- * doubled hyphens. */
6
- export function slugify(title) {
7
- return title
8
- .normalize("NFD")
9
- .replace(/[̀-ͯ]/g, "")
10
- .toLowerCase()
11
- .replace(/[^a-z0-9]+/g, "-")
12
- .replace(/^-+|-+$/g, "");
13
- }
14
- /** The slug field follows the title until the editor types a slug of
15
- * their own; a slug equal to what the previous title produced is still
16
- * "following". */
17
- export function followsTitle(slug, previousTitle) {
18
- return slug === "" || slug === slugify(previousTitle);
19
- }