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