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 +20 -12
- package/dist/admin/editor/EditorView.js +2 -2
- package/dist/admin/editor/PageSettings.js +5 -1
- package/dist/admin/editor/autosave.d.ts +2 -1
- package/dist/admin/editor/autosave.js +6 -0
- package/dist/admin/forms/FieldWidget.d.ts +5 -1
- package/dist/admin/forms/FieldWidget.js +6 -3
- package/dist/admin/forms/slug-field.d.ts +17 -0
- package/dist/admin/forms/slug-field.js +39 -0
- package/dist/admin/ops-impl.js +24 -3
- package/dist/admin/shell/EntryForm.js +46 -6
- package/dist/admin/shell/GlobalForm.js +1 -1
- package/dist/admin/shell/PageDialogs.js +11 -5
- package/dist/admin/ui/primitives.js +1 -0
- package/dist/admin/validate.d.ts +2 -0
- package/dist/admin/validate.js +9 -0
- package/dist/collections.js +10 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/paths.d.ts +7 -0
- package/dist/paths.js +21 -0
- package/package.json +1 -1
- package/dist/admin/shell/page-dialogs.d.ts +0 -9
- package/dist/admin/shell/page-dialogs.js +0 -19
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 `
|
|
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
|
|
1153
|
-
|
|
1154
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/admin/ops-impl.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
422
|
-
|
|
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 {
|
|
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
|
-
|
|
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] }));
|
package/dist/admin/validate.d.ts
CHANGED
package/dist/admin/validate.js
CHANGED
|
@@ -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))
|
package/dist/collections.js
CHANGED
|
@@ -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,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
|
-
}
|