smoodly 0.0.17 → 0.0.18
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 -5
- package/dist/admin/editor/CanvasOverlay.js +4 -2
- package/dist/admin/editor/EditorView.js +2 -2
- package/dist/admin/editor/GlobalInspector.d.ts +4 -1
- package/dist/admin/editor/GlobalInspector.js +2 -2
- package/dist/admin/ui/ActionMenu.d.ts +3 -1
- package/dist/admin/ui/ActionMenu.js +4 -3
- package/dist/admin/ui/PublishControl.d.ts +3 -1
- package/dist/admin/ui/PublishControl.js +2 -2
- package/dist/admin/ui/primitives.d.ts +1 -1
- package/dist/admin/ui/primitives.js +1 -1
- package/dist/admin/ui/theme.js +7 -4
- package/dist/entry-page.d.ts +5 -0
- package/dist/entry-page.js +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.js +1 -1
- package/dist/next/page-renderer.js +7 -3
- package/dist/paths.d.ts +21 -0
- package/dist/paths.js +25 -0
- package/dist/render.d.ts +10 -0
- package/dist/render.js +2 -1
- package/dist/site.d.ts +14 -9
- package/dist/site.js +31 -18
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -336,9 +336,13 @@ Implemented:
|
|
|
336
336
|
fetches the record and its tree (draft or published), resolves refs,
|
|
337
337
|
reads the locale's own rows, and returns a `SitePage` — `record`,
|
|
338
338
|
`template`, `tree`, localized `title`, `path`, `ancestors`, `children`,
|
|
339
|
-
and `
|
|
340
|
-
locale
|
|
341
|
-
|
|
339
|
+
`locale` and `locales` — the page's `{ slug, path }` in every supported
|
|
340
|
+
locale, null where it is absent or, outside draft mode, unpublished
|
|
341
|
+
(spec 2026-09-15) — or a `SiteEntry` with the locale's merged fields (a
|
|
342
|
+
versioned collection's published snapshot on the public path) resolved,
|
|
343
|
+
`locale`, and the same `locales` map (an entry's slug and path are null
|
|
344
|
+
where its row claims no URL). `getEntries` items and `getEntry` carry
|
|
345
|
+
`locale` and `locales` too.
|
|
342
346
|
`resolve(locale, path, { draft? })`
|
|
343
347
|
is the two in sequence — `locate` then `load` — for a caller that just
|
|
344
348
|
wants the hit. A folder (null template), an
|
|
@@ -372,13 +376,14 @@ Implemented:
|
|
|
372
376
|
(`{ id, title, path, url, ancestors, children }`, every ancestor and
|
|
373
377
|
child carrying `url` beside its locale-relative `path`, both put through
|
|
374
378
|
the `href` seam) beside `zones`; a collection
|
|
375
|
-
entry rendered by its `smoodly.entryPage` as `{ entry, path }`; a folder,
|
|
379
|
+
entry rendered by its `smoodly.entryPage` as `{ entry, path, locale, locales }`; a folder,
|
|
376
380
|
an absent locale or an unregistered template a 404. The published read is
|
|
377
381
|
cached per (locale, path) and tagged `page:<id>` / `entry:<id>` — the
|
|
378
382
|
`locate` hit runs outside the cache, because the tag needs the id first.
|
|
379
383
|
Draft mode reads drafts uncached, and the editor param mounts
|
|
380
384
|
`EditorBridge`. Metadata is the tree's `meta` plus
|
|
381
|
-
`alternates.languages`,
|
|
385
|
+
`alternates.languages`, the locales with a URL in the hit's `locales` map
|
|
386
|
+
through the injectable `href(path, locale)`
|
|
382
387
|
seam (identity by default, `getPathname` with next-intl) and emitted only
|
|
383
388
|
for a multi-locale site. `renderSmoodlyPage(Paired, …)` and
|
|
384
389
|
`smoodlyPageMetadata` remain as the escape hatch for a FIXED page
|
|
@@ -490,6 +495,16 @@ after both are on the registry. From the repo root:
|
|
|
490
495
|
`prepublishOnly` rebuilds each package. The Claude Code skill
|
|
491
496
|
`.claude/skills/releasing-smoodly` carries the same order with its checks.
|
|
492
497
|
|
|
498
|
+
## Follow-ups (logged 2026-09-15, locale alternates spec)
|
|
499
|
+
|
|
500
|
+
- A layout-only language switch, if a real site needs one: a
|
|
501
|
+
`q().getAlternates(path)` over the `paths` index plus a proxy header
|
|
502
|
+
carrying the request path. Not built until asked for — the starters
|
|
503
|
+
render the switch from the page (`components/PageFrame.tsx`,
|
|
504
|
+
`components/LocaleNav.tsx`).
|
|
505
|
+
- The walk covered the Lorem example only; blank-intl is typechecked, not
|
|
506
|
+
walked. Walk it on the next blank-intl install.
|
|
507
|
+
|
|
493
508
|
## Follow-ups (logged 2026-09-15, custom section spec §6, plan B)
|
|
494
509
|
|
|
495
510
|
- Inline editing of a column on the canvas is OFF (2026-09-15, after the
|
|
@@ -6,7 +6,9 @@ import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-run
|
|
|
6
6
|
// Positions come from the geometry the bridge reported; the layer
|
|
7
7
|
// itself never touches the iframe.
|
|
8
8
|
import { useState } from "react";
|
|
9
|
-
import { IconButton } from "../ui/primitives.js";
|
|
9
|
+
import { cx, IconButton } from "../ui/primitives.js";
|
|
10
|
+
/** Pixels above a block the riding pill needs (half its height); less and it moves inside. */
|
|
11
|
+
const TAG_CLEARANCE = 14;
|
|
10
12
|
export function CanvasOverlay({ emptyZones, hoverRect, hoverGlobal, selection, plusButtons, fieldHover, editing, onPlus, onMove, onDuplicate, onRemove, }) {
|
|
11
13
|
const [hoverPlus, setHoverPlus] = useState(null);
|
|
12
14
|
return (_jsxs("div", { style: { position: "absolute", inset: 0, pointerEvents: "none" }, children: [emptyZones.map((z) => (_jsxs("div", { className: "sm-dashed", style: {
|
|
@@ -22,7 +24,7 @@ export function CanvasOverlay({ emptyZones, hoverRect, hoverGlobal, selection, p
|
|
|
22
24
|
width: selection.rect.w, height: selection.rect.h,
|
|
23
25
|
outline: selection.global ? "var(--outline-selection-global)" : "var(--outline-selection)", outlineOffset: -1.5,
|
|
24
26
|
borderRadius: "var(--selection-radius)",
|
|
25
|
-
} }), _jsxs("span", { className: selection.global
|
|
27
|
+
} }), _jsxs("span", { className: cx("sm-blocktag", selection.global && "sm-blocktag--global", selection.rect.y < TAG_CLEARANCE && "sm-blocktag--inside"), style: { position: "absolute", left: selection.rect.x, top: selection.rect.y }, children: [selection.title, selection.global && " · global", selection.locked && " · locked"] }), !selection.layout && (_jsx("div", { className: "sm-blocktoolbar", style: {
|
|
26
28
|
position: "absolute", left: selection.rect.x + selection.rect.w - 8, top: selection.rect.y + 8,
|
|
27
29
|
transform: "translateX(-100%)", pointerEvents: "auto",
|
|
28
30
|
}, children: selection.locked ? (_jsx("span", { className: "sm-blocktoolbar__note", children: "fields only" })) : (_jsxs(_Fragment, { children: [_jsx(IconButton, { label: "Move up", tone: "inverse", onClick: () => onMove(selection.node.id, -1), children: "\u2191" }), _jsx(IconButton, { label: "Move down", tone: "inverse", onClick: () => onMove(selection.node.id, 1), children: "\u2193" }), _jsx(IconButton, { label: "Duplicate", tone: "inverse", onClick: () => onDuplicate(selection.node), children: "\u29C9" }), _jsx(IconButton, { label: "Remove", tone: "inverse", onClick: () => onRemove(selection.node.id), children: "\u00D7" })] })) }))] })), plusButtons.map((p) => {
|
|
@@ -780,7 +780,7 @@ export function EditorView({ ops, registry, id }) {
|
|
|
780
780
|
else
|
|
781
781
|
mutate((t) => patchNodePath(t, session.node, session.path, doc));
|
|
782
782
|
}, onEscape: inline.end }));
|
|
783
|
-
})()] }) })] }), _jsx(ResizeHandle, {}), _jsxs("aside", { className: "sm-inspector", ref: inspectorRef, children: [_jsxs("div", { className: "sm-card", children: [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 })] }), _jsx(IconButton, { label: "Clear selection", onClick: () => setSelectedId(null), children: "\u00D7" })] }), tabs.length > 0 && (_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], fixed: isFixedNode(record, registry), onRename: onRename, onPatch: doPatchPage, onRemoveLocale: Object.keys(record.locales).length > 1 &&
|
|
783
|
+
})()] }) })] }), _jsx(ResizeHandle, {}), _jsxs("aside", { className: "sm-inspector", ref: inspectorRef, children: [_jsxs("div", { className: "sm-card", children: [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 })] }), _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], fixed: isFixedNode(record, registry), onRename: onRename, onPatch: doPatchPage, onRemoveLocale: Object.keys(record.locales).length > 1 &&
|
|
784
784
|
!isFixedNode(record, registry) &&
|
|
785
785
|
!(record.parentId === null && record.locales[registry.locales.default]?.slug === registry.homePageSlug)
|
|
786
786
|
? () => { void doRemoveLocale(); }
|
|
@@ -792,7 +792,7 @@ export function EditorView({ ops, registry, id }) {
|
|
|
792
792
|
doPatch(selected.id, ns, key, value);
|
|
793
793
|
if (ns === "fields")
|
|
794
794
|
syncTexts(selected.id, selectedSection, { ...selected.fields, [key]: value });
|
|
795
|
-
} }, `${selected.id}.${ns}.${key}`)) })), selectedItem && selectedId && (_jsx(GlobalInspector, { item: selectedItem, edit: globalEdit.items[selectedItem.name] ?? null, locale: locale, groups: activeGroups, multilingual: registry.locales.supported.length > 1, refOptions: refOptions, pending: globalDirty || globalEdit.saveState !== "saved", busy: globalBusy, inlinePath: inline.session?.node === selectedId ? inline.session.path : undefined, onEditHere: () => { const p = inline.session?.path; inline.end(); if (p)
|
|
795
|
+
} }, `${selected.id}.${ns}.${key}`)) })), selectedItem && selectedId && (_jsx(GlobalInspector, { item: selectedItem, edit: globalEdit.items[selectedItem.name] ?? null, locale: locale, groups: activeGroups, multilingual: registry.locales.supported.length > 1, refOptions: refOptions, tabs: tabs.length > 0 ? _jsx(Tabs, { tabs: tabs.map((t) => t.tab), value: activeTab, onChange: (t) => setTab(t) }) : undefined, pending: globalDirty || globalEdit.saveState !== "saved", busy: globalBusy, inlinePath: inline.session?.node === selectedId ? inline.session.path : undefined, onEditHere: () => { const p = inline.session?.path; inline.end(); if (p)
|
|
796
796
|
reveal(p, selectedItem); }, onChange: (ns, key, value) => {
|
|
797
797
|
patchGlobal(selectedItem.name, globalPath(ns, key), value);
|
|
798
798
|
if (ns === "fields") {
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
1
2
|
import type { AdminGlobal } from "../serialize.ts";
|
|
2
3
|
import type { Group } from "../forms/tabs.ts";
|
|
3
4
|
import type { GlobalEditItem } from "./global-edit.ts";
|
|
4
5
|
import { type RefOption } from "../shell/GlobalFields.tsx";
|
|
5
6
|
import { type PublishBusy } from "../ui/PublishControl.tsx";
|
|
6
|
-
export declare function GlobalInspector({ item, edit, locale, groups, multilingual, refOptions, inlinePath, onEditHere, onChange, pending, busy, onPublish, onDiscard, onUnpublish, }: {
|
|
7
|
+
export declare function GlobalInspector({ item, edit, locale, groups, multilingual, refOptions, inlinePath, onEditHere, onChange, pending, busy, onPublish, onDiscard, onUnpublish, tabs, }: {
|
|
7
8
|
item: AdminGlobal;
|
|
8
9
|
/** null while the item loads. */
|
|
9
10
|
edit: GlobalEditItem | null;
|
|
@@ -21,4 +22,6 @@ export declare function GlobalInspector({ item, edit, locale, groups, multilingu
|
|
|
21
22
|
onPublish(): void;
|
|
22
23
|
onDiscard(): void;
|
|
23
24
|
onUnpublish(): void;
|
|
25
|
+
/** The Content / Settings tabs, rendered between the control and the fields. */
|
|
26
|
+
tabs?: ReactNode;
|
|
24
27
|
}): import("react").JSX.Element;
|
|
@@ -4,10 +4,10 @@ import { BASE } from "../shell/base.js";
|
|
|
4
4
|
import { localeStatusOf } from "../shell/pages-tree.js";
|
|
5
5
|
import { GlobalFields } from "../shell/GlobalFields.js";
|
|
6
6
|
import { PublishControl } from "../ui/PublishControl.js";
|
|
7
|
-
export function GlobalInspector({ item, edit, locale, groups, multilingual, refOptions, inlinePath, onEditHere, onChange, pending, busy, onPublish, onDiscard, onUnpublish, }) {
|
|
7
|
+
export function GlobalInspector({ item, edit, locale, groups, multilingual, refOptions, inlinePath, onEditHere, onChange, pending, busy, onPublish, onDiscard, onUnpublish, tabs, }) {
|
|
8
8
|
const row = edit?.record.locales[locale];
|
|
9
9
|
// The same three states the global list shows; the control decides what applies.
|
|
10
10
|
const status = row ? localeStatusOf(row) : "draft";
|
|
11
11
|
const pages = edit ? [...new Set(edit.usage.filter((u) => u.sourceKind === "page").map((u) => u.sourceId))] : [];
|
|
12
|
-
return (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 12 }, children: [_jsx("p", { className: "sm-hint", children: "Global content \u2014 changes every page it is used on." }),
|
|
12
|
+
return (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 12 }, children: [_jsx("p", { className: "sm-hint", children: "Global content \u2014 changes every page it is used on." }), pages.length > 0 && (_jsxs("p", { className: "sm-hint", children: [`Used on ${pages.length} page${pages.length === 1 ? "" : "s"}: `, pages.map((id, i) => (_jsxs("span", { children: [i > 0 && ", ", _jsx("a", { href: `${BASE}/pages/${encodeURIComponent(id)}?locale=${encodeURIComponent(locale)}`, children: id })] }, id)))] })), _jsx("div", { style: { display: "flex", alignItems: "center", justifyContent: "flex-end", gap: 8, flexWrap: "wrap" }, children: _jsx(PublishControl, { status: row ? status : null, pending: pending, busy: busy, disabled: !edit, compact: true, onPublish: onPublish, onDiscard: onDiscard, onUnpublish: onUnpublish }) }), tabs, edit ? (_jsx(GlobalFields, { item: item, groups: groups, fields: edit.fields, errors: edit.errors, refOptions: refOptions, multilingual: multilingual, inlinePath: inlinePath, onEditHere: onEditHere, onChange: onChange })) : (_jsx("p", { style: { color: "var(--text-muted)" }, children: "Loading\u2026" }))] }));
|
|
13
13
|
}
|
|
@@ -4,8 +4,10 @@ export type ActionMenuItem = {
|
|
|
4
4
|
disabled?: boolean;
|
|
5
5
|
danger?: boolean;
|
|
6
6
|
};
|
|
7
|
-
export declare function ActionMenu({ label, items, disabled }: {
|
|
7
|
+
export declare function ActionMenu({ label, items, disabled, small }: {
|
|
8
8
|
label: string;
|
|
9
9
|
items: ActionMenuItem[];
|
|
10
10
|
disabled?: boolean;
|
|
11
|
+
/** The smaller round button, next to a small Publish. */
|
|
12
|
+
small?: boolean;
|
|
11
13
|
}): import("react").JSX.Element;
|
|
@@ -4,10 +4,11 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
4
4
|
// publish flow §2). Items are always rendered and disabled when they do
|
|
5
5
|
// not apply — the layout never shifts and the editor learns what exists.
|
|
6
6
|
// The popover is the Pages list's row menu shape (`.sm-picker`), anchored
|
|
7
|
-
// under the button instead of at the pointer
|
|
7
|
+
// under the button instead of at the pointer, hanging from the button's
|
|
8
|
+
// right edge (every control sits at its bar's or card's right).
|
|
8
9
|
import { useEffect, useRef, useState } from "react";
|
|
9
10
|
import { cx } from "./primitives.js";
|
|
10
|
-
export function ActionMenu({ label, items, disabled }) {
|
|
11
|
+
export function ActionMenu({ label, items, disabled, small = false }) {
|
|
11
12
|
const [open, setOpen] = useState(false);
|
|
12
13
|
const ref = useRef(null);
|
|
13
14
|
useEffect(() => {
|
|
@@ -21,5 +22,5 @@ export function ActionMenu({ label, items, disabled }) {
|
|
|
21
22
|
document.addEventListener("keydown", onKey);
|
|
22
23
|
return () => { document.removeEventListener("pointerdown", onDown); document.removeEventListener("keydown", onKey); };
|
|
23
24
|
}, [open]);
|
|
24
|
-
return (_jsxs("div", { ref: ref, style: { position: "relative", flex: "none" }, children: [_jsx("button", { type: "button", className: "sm-dotmenu", "aria-label": label, title: label, "aria-haspopup": "menu", "aria-expanded": open, disabled: disabled, onClick: () => setOpen((o) => !o), children: "\u00B7\u00B7\u00B7" }), open && (_jsx("div", { role: "menu", className: "sm-picker sm-picker--menu", children: items.map((item) => (_jsx("button", { type: "button", role: "menuitem", className: cx("sm-picker__item", item.danger && "sm-picker__item--danger"), disabled: item.disabled, onClick: () => { setOpen(false); item.run(); }, children: item.label }, item.label))) }))] }));
|
|
25
|
+
return (_jsxs("div", { ref: ref, style: { position: "relative", flex: "none" }, children: [_jsx("button", { type: "button", className: cx("sm-dotmenu", small && "sm-dotmenu--sm"), "aria-label": label, title: label, "aria-haspopup": "menu", "aria-expanded": open, disabled: disabled, onClick: () => setOpen((o) => !o), children: "\u00B7\u00B7\u00B7" }), open && (_jsx("div", { role: "menu", className: "sm-picker sm-picker--menu", children: items.map((item) => (_jsx("button", { type: "button", role: "menuitem", className: cx("sm-picker__item", item.danger && "sm-picker__item--danger"), disabled: item.disabled, onClick: () => { setOpen(false); item.run(); }, children: item.label }, item.label))) }))] }));
|
|
25
26
|
}
|
|
@@ -13,7 +13,7 @@ export declare function publishMenu(opts: {
|
|
|
13
13
|
onUnpublish(): void;
|
|
14
14
|
onHistory?(): void;
|
|
15
15
|
}): ActionMenuItem[];
|
|
16
|
-
export declare function PublishControl({ status, pending, busy, flash, disabled, onPublish, onDiscard, onUnpublish, onHistory, }: {
|
|
16
|
+
export declare function PublishControl({ status, pending, busy, flash, disabled, compact, onPublish, onDiscard, onUnpublish, onHistory, }: {
|
|
17
17
|
/** The locale's stored state; null while the document loads or before it exists. */
|
|
18
18
|
status: LocaleStatus | null;
|
|
19
19
|
/** The editor holds a change the store has not confirmed (dirty, or a save pending / in flight / failed). */
|
|
@@ -28,4 +28,6 @@ export declare function PublishControl({ status, pending, busy, flash, disabled,
|
|
|
28
28
|
onUnpublish(): void;
|
|
29
29
|
/** Opens History (spec 2026-09-13 history §5). Absent = no History item (the canvas card). */
|
|
30
30
|
onHistory?(): void;
|
|
31
|
+
/** The small button and menu — the global inspector's control, so it never reads as the page's Publish. */
|
|
32
|
+
compact?: boolean;
|
|
31
33
|
}): import("react").JSX.Element;
|
|
@@ -16,9 +16,9 @@ export function publishMenu(opts) {
|
|
|
16
16
|
{ label: "Unpublish", disabled: !opts.can.unpublish, run: opts.onUnpublish },
|
|
17
17
|
];
|
|
18
18
|
}
|
|
19
|
-
export function PublishControl({ status, pending = false, busy = null, flash = false, disabled = false, onPublish, onDiscard, onUnpublish, onHistory, }) {
|
|
19
|
+
export function PublishControl({ status, pending = false, busy = null, flash = false, disabled = false, compact = false, onPublish, onDiscard, onUnpublish, onHistory, }) {
|
|
20
20
|
const can = publishActions(status, pending);
|
|
21
21
|
const inert = disabled || busy !== null;
|
|
22
22
|
const word = busy ? BUSY_WORD[busy] : flash ? "Published ✓" : "Publish";
|
|
23
|
-
return (_jsxs("span", { style: { display: "inline-flex", alignItems: "center", gap: 8 }, children: [status && _jsx(StatusBadge, { status: statusLabel(status) }), _jsx(Button, { variant: flash && !busy ? "ok" : "primary", disabled: inert || !can.publish, onClick: onPublish, children: word }), _jsx(ActionMenu, { label: "More actions", disabled: inert || status === null, items: publishMenu({ can, onDiscard, onUnpublish, onHistory }) })] }));
|
|
23
|
+
return (_jsxs("span", { style: { display: "inline-flex", alignItems: "center", gap: 8 }, children: [status && _jsx(StatusBadge, { status: statusLabel(status) }), _jsx(Button, { variant: flash && !busy ? "ok" : "primary", size: compact ? "sm" : "md", disabled: inert || !can.publish, onClick: onPublish, children: word }), _jsx(ActionMenu, { label: "More actions", disabled: inert || status === null, small: compact, items: publishMenu({ can, onDiscard, onUnpublish, onHistory }) })] }));
|
|
24
24
|
}
|
|
@@ -14,7 +14,7 @@ export declare function Logo({ height }: {
|
|
|
14
14
|
export declare function ThemeToggle(): import("react").JSX.Element;
|
|
15
15
|
type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
|
|
16
16
|
variant?: "primary" | "secondary" | "ghost" | "danger" | "ok";
|
|
17
|
-
size?: "md" | "lg";
|
|
17
|
+
size?: "sm" | "md" | "lg";
|
|
18
18
|
};
|
|
19
19
|
export declare function Button({ variant, size, className, ...rest }: ButtonProps): import("react").JSX.Element;
|
|
20
20
|
/** The same shape as Button, for navigations that must stay real links. */
|
|
@@ -42,7 +42,7 @@ export function ThemeToggle() {
|
|
|
42
42
|
return (_jsxs("button", { type: "button", role: "menuitem", className: "sm-rail__menu-item", onClick: toggle, children: [_jsxs("span", { className: "sm-when-light", children: [_jsx(Moon, { ...ICON }), "Dark mode"] }), _jsxs("span", { className: "sm-when-dark", children: [_jsx(Sun, { ...ICON }), "Light mode"] })] }));
|
|
43
43
|
}
|
|
44
44
|
export function Button({ variant = "primary", size = "md", className, ...rest }) {
|
|
45
|
-
return (_jsx("button", { type: "button", className: cx("sm-btn", `sm-btn--${variant}`, size === "lg" && "sm-btn--lg", className), ...rest }));
|
|
45
|
+
return (_jsx("button", { type: "button", className: cx("sm-btn", `sm-btn--${variant}`, size === "lg" && "sm-btn--lg", size === "sm" && "sm-btn--sm", className), ...rest }));
|
|
46
46
|
}
|
|
47
47
|
/** The same shape as Button, for navigations that must stay real links. */
|
|
48
48
|
export function LinkButton({ variant = "secondary", children, ...rest }) {
|
package/dist/admin/ui/theme.js
CHANGED
|
@@ -96,13 +96,13 @@ const SCALE = `
|
|
|
96
96
|
--title-font:var(--font-sans); --title-size:18px; --title-weight:600; --title-tracking:-0.01em;
|
|
97
97
|
--doc-title-size:30px;
|
|
98
98
|
--blocktag-font:var(--font-sans); --blocktag-size:11px; --blocktag-transform:none; --blocktag-tracking:0;
|
|
99
|
-
--blocktag-radius:var(--radius-round); --blocktag-shift:translate(16px,-50%);
|
|
99
|
+
--blocktag-radius:var(--radius-round); --blocktag-shift:translate(16px,-50%); --blocktag-shift-inside:translate(16px,8px);
|
|
100
100
|
--badge-h:24px; --badge-dot:inline-block;
|
|
101
101
|
|
|
102
102
|
--radius-xs:2px; --radius-sm:8px; --radius-md:10px; --radius-lg:12px;
|
|
103
103
|
--radius-card:14px; --radius-card-lg:16px; --radius-btn:999px; --radius-round:999px;
|
|
104
104
|
--page-radius:4px 4px 0 0; --selection-radius:4px;
|
|
105
|
-
--control-h:40px; --control-h-sm:34px; --control-h-lg:48px; --button-h:36px; --row-h:48px; --topbar-h:64px;
|
|
105
|
+
--control-h:40px; --control-h-sm:34px; --control-h-lg:48px; --button-h:36px; --button-h-sm:28px; --row-h:48px; --topbar-h:64px;
|
|
106
106
|
--outline-row-h:36px; --seg-pad-y:7px; --tabs-pad:3px;
|
|
107
107
|
--plus-h:32px; --plus-pad:0 14px 0 6px; --plus-label:inline;
|
|
108
108
|
--nav-w:72px; --inspector-w:360px; --gutter:20px; --frame-pad:24px;
|
|
@@ -202,11 +202,11 @@ const TECHNICAL_SHAPE = `
|
|
|
202
202
|
--title-font:var(--font-sans); --title-size:var(--text-xl); --title-weight:600; --title-tracking:0;
|
|
203
203
|
--doc-title-size:var(--text-2xl);
|
|
204
204
|
--blocktag-font:var(--font-mono); --blocktag-size:var(--text-2xs); --blocktag-transform:uppercase; --blocktag-tracking:var(--tracking-tag);
|
|
205
|
-
--blocktag-radius:var(--radius-sm) 0 var(--radius-sm) 0; --blocktag-shift:none;
|
|
205
|
+
--blocktag-radius:var(--radius-sm) 0 var(--radius-sm) 0; --blocktag-shift:none; --blocktag-shift-inside:none;
|
|
206
206
|
--badge-h:20px; --badge-dot:none;
|
|
207
207
|
--radius-lg:var(--radius-md); --radius-card:var(--radius-md); --radius-card-lg:var(--radius-md); --radius-btn:var(--radius-md);
|
|
208
208
|
--page-radius:var(--radius-md); --selection-radius:var(--radius-sm);
|
|
209
|
-
--control-h-sm:24px; --button-h:var(--control-h);
|
|
209
|
+
--control-h-sm:24px; --button-h:var(--control-h); --button-h-sm:24px;
|
|
210
210
|
--outline-row-h:var(--control-h); --seg-pad-y:3px; --tabs-pad:0 14px;
|
|
211
211
|
--plus-h:24px; --plus-pad:0; --plus-label:none;
|
|
212
212
|
--gutter:0px; --frame-pad:0px;
|
|
@@ -377,6 +377,7 @@ body:has(.sm-admin){margin:0}
|
|
|
377
377
|
text-decoration:none;
|
|
378
378
|
}
|
|
379
379
|
.sm-btn--lg{height:var(--control-h-lg);padding:0 20px}
|
|
380
|
+
.sm-btn--sm,.sm-btn--sm.sm-btn--primary,.sm-btn--sm.sm-btn--ok{height:var(--button-h-sm);padding:0 12px;font-size:var(--text-sm)}
|
|
380
381
|
.sm-btn--primary{background:var(--accent);color:var(--on-accent);font-weight:600;padding:0 18px}
|
|
381
382
|
.sm-btn--primary:hover{background:var(--accent-hover);color:var(--on-accent)}
|
|
382
383
|
.sm-btn--secondary{background:var(--btn-secondary-bg);color:var(--text)}
|
|
@@ -635,6 +636,7 @@ body:has(.sm-admin){margin:0}
|
|
|
635
636
|
.sm-dotmenu:hover{background:var(--btn-secondary-hover);color:var(--text)}
|
|
636
637
|
.sm-dotmenu:disabled{opacity:.4;cursor:default}
|
|
637
638
|
.sm-dotmenu:disabled:hover{background:var(--btn-secondary-bg);color:var(--text-muted)}
|
|
639
|
+
.sm-dotmenu--sm{width:var(--button-h-sm);height:var(--button-h-sm);font-size:var(--text-sm)}
|
|
638
640
|
.sm-picker--menu{position:absolute;top:calc(100% + 6px);right:0;width:220px;z-index:60}
|
|
639
641
|
|
|
640
642
|
/* Status pill: a dot before the word in the warm look (none in the technical). */
|
|
@@ -794,6 +796,7 @@ body:has(.sm-admin){margin:0}
|
|
|
794
796
|
border-radius:var(--blocktag-radius);white-space:nowrap;
|
|
795
797
|
}
|
|
796
798
|
.sm-blocktag--global{background:var(--selection-global)}
|
|
799
|
+
.sm-blocktag--inside{transform:var(--blocktag-shift-inside)}
|
|
797
800
|
.sm-blocktoolbar{
|
|
798
801
|
display:flex;gap:2px;padding:3px;background:var(--toolbar-bg);border-radius:var(--radius-btn);
|
|
799
802
|
box-shadow:var(--shadow-float);
|
package/dist/entry-page.d.ts
CHANGED
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
import type { ComponentType, ReactElement } from "react";
|
|
2
2
|
import type { CollectionSchema } from "./collections.ts";
|
|
3
3
|
import type { EntryRecord } from "./entry-store.ts";
|
|
4
|
+
import type { LocaleAlternate } from "./paths.ts";
|
|
4
5
|
export type EntryPageSchema = {
|
|
5
6
|
kind: "entryPage";
|
|
6
7
|
name: string;
|
|
7
8
|
collection: string;
|
|
8
9
|
};
|
|
10
|
+
/** What an entry page renders: the entry in the render locale, its path
|
|
11
|
+
* there, and its address in every supported locale (spec 2026-09-15 §1). */
|
|
9
12
|
export type EntryPageProps = {
|
|
10
13
|
entry: EntryRecord;
|
|
11
14
|
path: string;
|
|
15
|
+
locale: string;
|
|
16
|
+
locales: Record<string, LocaleAlternate | null>;
|
|
12
17
|
};
|
|
13
18
|
export type PairedEntryPage = ((props: EntryPageProps) => ReactElement) & {
|
|
14
19
|
schema: EntryPageSchema;
|
package/dist/entry-page.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
export function entryPage(collection, View) {
|
|
3
|
-
const Component = ((props) => _jsx(View, {
|
|
3
|
+
const Component = ((props) => _jsx(View, { ...props }));
|
|
4
4
|
Component.schema = { kind: "entryPage", name: `entry:${collection.name}`, collection: collection.name };
|
|
5
5
|
return Component;
|
|
6
6
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { entryPage } from "./entry-page.tsx";
|
|
|
7
7
|
import { globalItem } from "./global.ts";
|
|
8
8
|
import { marks } from "./marks.ts";
|
|
9
9
|
export { Zone, renderPage, EMPTY_PAGE_CONTEXT } from "./render.tsx";
|
|
10
|
-
export type { PageTree, TreeNode, ZoneContent, PairedPage, Registry, PageContext, PageContextLink, PageLink, PageViewProps } from "./render.tsx";
|
|
10
|
+
export type { PageTree, TreeNode, ZoneContent, PairedPage, Registry, PageContext, PageContextLink, PageContextAlternate, PageLink, PageViewProps } from "./render.tsx";
|
|
11
11
|
export { RichText, defaultRichTextImage } from "./richtext-render.tsx";
|
|
12
12
|
export type { RichTextProps, RichTextElement, RichTextImageProps } from "./richtext-render.tsx";
|
|
13
13
|
export type { PageSchema } from "./page.ts";
|
|
@@ -48,8 +48,8 @@ export { affectedTargets, pageTag, entryTag, globalTag, pagesTreeTag, collection
|
|
|
48
48
|
export type { AffectedTargets } from "./revalidate.ts";
|
|
49
49
|
export { resolveTree, resolveFields, resolveMedia } from "./resolve.ts";
|
|
50
50
|
export type { EntryFetcher, ResolveFieldsOptions, AssetFetcher } from "./resolve.ts";
|
|
51
|
-
export { SEGMENT_RE, isSegment, assertSegment, joinPath, splitPath, segmentFor, isHomeNode, pagePathIn, chainOf, assertDepth, pagePath, pagePathRows, entryPathRows, collectionSegment, buildPageTree, perLocaleSegments, assertSupportedLocale, unsupportedLocale, } from "./paths.ts";
|
|
52
|
-
export type { LocaleSet, PathTarget, PathRow, PageLike, EntryLike, PageTreeNode, PagePathOptions } from "./paths.ts";
|
|
51
|
+
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";
|
|
52
|
+
export type { LocaleSet, PathTarget, PathRow, PageLike, EntryLike, PageTreeNode, PagePathOptions, LocaleAlternate, PageAlternate } from "./paths.ts";
|
|
53
53
|
export { fixedPathFor } from "./localize.ts";
|
|
54
54
|
export type { PageStore, PageRecord, PageLocale, PageVersion, PageStoreOptions, CreatePageInput, AddLocaleOptions } from "./store.ts";
|
|
55
55
|
export { pathOptions, pageErrors, localeRow, pageRefEdges, DEFAULT_LOCALES } from "./store.ts";
|
package/dist/index.js
CHANGED
|
@@ -35,7 +35,7 @@ export { SupabasePathIndex } from "./supabase-path-index.js";
|
|
|
35
35
|
export { entryErrors, compareEntries, DEFAULT_ENTRY_LOCALES, localizedKeys, splitFields, entryLocaleRow, entryFieldsIn, entryRefEdges, entryTitleIn, matchesWhere, } from "./entry-store.js";
|
|
36
36
|
export { affectedTargets, pageTag, entryTag, globalTag, pagesTreeTag, collectionTag } from "./revalidate.js";
|
|
37
37
|
export { resolveTree, resolveFields, resolveMedia } from "./resolve.js";
|
|
38
|
-
export { SEGMENT_RE, isSegment, assertSegment, joinPath, splitPath, segmentFor, isHomeNode, pagePathIn, chainOf, assertDepth, pagePath, pagePathRows, entryPathRows, collectionSegment, buildPageTree, perLocaleSegments, assertSupportedLocale, unsupportedLocale, } from "./paths.js";
|
|
38
|
+
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";
|
|
39
39
|
export { fixedPathFor } from "./localize.js";
|
|
40
40
|
export { pathOptions, pageErrors, localeRow, pageRefEdges, DEFAULT_LOCALES } from "./store.js";
|
|
41
41
|
export { DEFAULT_TOOLSET } from "./toolset.js";
|
|
@@ -34,7 +34,7 @@ export function createPageRenderer(options) {
|
|
|
34
34
|
console.warn(`smoodly: no page registration named "${hit.template}" — the record at ${hit.path} is a 404.`);
|
|
35
35
|
notFound();
|
|
36
36
|
}
|
|
37
|
-
const page = pageContextOf(hit,
|
|
37
|
+
const page = pageContextOf(hit, href);
|
|
38
38
|
return renderPage(Page, hit.tree, { sections: options.sections, elements: options.elements, styles: site.styles, custom: site.custom }, { editor, page });
|
|
39
39
|
}
|
|
40
40
|
const View = entryPages.get(hit.collection);
|
|
@@ -42,12 +42,16 @@ export function createPageRenderer(options) {
|
|
|
42
42
|
console.warn(`smoodly: no entry page for collection "${hit.collection}" — the entry at ${hit.path} is a 404.`);
|
|
43
43
|
notFound();
|
|
44
44
|
}
|
|
45
|
-
return _jsx(View, { entry: hit.entry, path: hit.path });
|
|
45
|
+
return _jsx(View, { entry: hit.entry, path: hit.path, locale: hit.locale, locales: hit.locales });
|
|
46
46
|
}
|
|
47
|
+
/** hreflang from the SAME map a language switch reads (spec 2026-09-15 §3): the locales with a URL, through href. */
|
|
47
48
|
function alternates(hit) {
|
|
48
49
|
if (site.locales.supported.length < 2)
|
|
49
50
|
return {};
|
|
50
|
-
const languages =
|
|
51
|
+
const languages = {};
|
|
52
|
+
for (const [locale, alt] of Object.entries(hit.locales))
|
|
53
|
+
if (alt?.path)
|
|
54
|
+
languages[locale] = href(alt.path, locale);
|
|
51
55
|
return { alternates: { languages } };
|
|
52
56
|
}
|
|
53
57
|
async function page(pathFor, opts, accept) {
|
package/dist/paths.d.ts
CHANGED
|
@@ -91,6 +91,27 @@ export declare function entrySegment(entry: EntryLike, locale: string): string |
|
|
|
91
91
|
export declare function entryPathRows(targets: EntryLike[], byId: (id: string) => EntryLike | undefined, collection: {
|
|
92
92
|
path?: string | Record<string, string>;
|
|
93
93
|
}, set: LocaleSet): PathRow[];
|
|
94
|
+
/** A target's address in one locale (spec 2026-09-15 §1). A page always
|
|
95
|
+
* has both; an entry's slug is null when its row claims no URL, and its
|
|
96
|
+
* path null then or when the collection has no `path`. */
|
|
97
|
+
export type LocaleAlternate = {
|
|
98
|
+
slug: string | null;
|
|
99
|
+
path: string | null;
|
|
100
|
+
};
|
|
101
|
+
export type PageAlternate = {
|
|
102
|
+
slug: string;
|
|
103
|
+
path: string;
|
|
104
|
+
};
|
|
105
|
+
/** The page's address in EVERY supported locale, in config order: null
|
|
106
|
+
* where the node is absent, where an ancestor is, or where `advertised`
|
|
107
|
+
* says no — the site passes "published, or draft mode", so an
|
|
108
|
+
* unpublished translation is never linked on the public site. */
|
|
109
|
+
export declare function pageAlternates(node: PageLike, byId: (id: string) => PageLike | undefined, opts: PagePathOptions, advertised: (locale: string) => boolean): Record<string, PageAlternate | null>;
|
|
110
|
+
/** The entry's address in EVERY supported locale, in config order: null
|
|
111
|
+
* where the row is absent or not advertised. `pathIn` is the caller's
|
|
112
|
+
* path source — the derived rows in a list, the `paths` index for one
|
|
113
|
+
* loaded record — so both entry reads apply ONE rule. */
|
|
114
|
+
export declare function entryAlternates(entry: EntryLike, set: LocaleSet, advertised: (locale: string) => boolean, pathIn: (locale: string) => string | null): Record<string, LocaleAlternate | null>;
|
|
94
115
|
export type PageTreeNode<T extends PageLike> = {
|
|
95
116
|
record: T;
|
|
96
117
|
path: string | null;
|
package/dist/paths.js
CHANGED
|
@@ -130,6 +130,31 @@ export function entryPathRows(targets, byId, collection, set) {
|
|
|
130
130
|
}
|
|
131
131
|
return rows;
|
|
132
132
|
}
|
|
133
|
+
/** The page's address in EVERY supported locale, in config order: null
|
|
134
|
+
* where the node is absent, where an ancestor is, or where `advertised`
|
|
135
|
+
* says no — the site passes "published, or draft mode", so an
|
|
136
|
+
* unpublished translation is never linked on the public site. */
|
|
137
|
+
export function pageAlternates(node, byId, opts, advertised) {
|
|
138
|
+
const out = {};
|
|
139
|
+
for (const locale of opts.locales.supported) {
|
|
140
|
+
const own = node.locales[locale];
|
|
141
|
+
const path = own && advertised(locale) ? pagePathIn(node, byId, locale, opts) : null;
|
|
142
|
+
out[locale] = own && path !== null ? { slug: own.slug, path } : null;
|
|
143
|
+
}
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
/** The entry's address in EVERY supported locale, in config order: null
|
|
147
|
+
* where the row is absent or not advertised. `pathIn` is the caller's
|
|
148
|
+
* path source — the derived rows in a list, the `paths` index for one
|
|
149
|
+
* loaded record — so both entry reads apply ONE rule. */
|
|
150
|
+
export function entryAlternates(entry, set, advertised, pathIn) {
|
|
151
|
+
const out = {};
|
|
152
|
+
for (const locale of set.supported) {
|
|
153
|
+
const own = entry.locales[locale];
|
|
154
|
+
out[locale] = own && advertised(locale) ? { slug: own.slug, path: pathIn(locale) } : null;
|
|
155
|
+
}
|
|
156
|
+
return out;
|
|
157
|
+
}
|
|
133
158
|
export function buildPageTree(records, locale, opts) {
|
|
134
159
|
const byId = (id) => records.find((r) => r.id === id);
|
|
135
160
|
const build = (parentId) => records
|
package/dist/render.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ComponentType, ReactElement } from "react";
|
|
2
|
+
import type { PageAlternate } from "./paths.ts";
|
|
2
3
|
import type { PageSchema } from "./page.ts";
|
|
3
4
|
import type { BuilderMap, NamespaceValues } from "./schema.ts";
|
|
4
5
|
import { type StyleValues } from "./styles.ts";
|
|
@@ -41,16 +42,25 @@ export type PageLink = {
|
|
|
41
42
|
export type PageContextLink = PageLink & {
|
|
42
43
|
url: string;
|
|
43
44
|
};
|
|
45
|
+
/** The page's address in one other locale, `url` through the app's href seam (spec 2026-09-15 §3). */
|
|
46
|
+
export type PageContextAlternate = PageAlternate & {
|
|
47
|
+
url: string;
|
|
48
|
+
};
|
|
44
49
|
export type PageContext<Fields = Record<string, unknown>> = {
|
|
45
50
|
id: string;
|
|
46
51
|
title: string;
|
|
47
52
|
path: string;
|
|
48
53
|
url: string;
|
|
54
|
+
/** The locale the page was rendered in. */
|
|
55
|
+
locale: string;
|
|
49
56
|
/** The page's own fields, typed from its schema on the view; resolved, defaults applied. */
|
|
50
57
|
fields: Fields;
|
|
51
58
|
/** Root first, parent last. */
|
|
52
59
|
ancestors: ReadonlyArray<PageContextLink>;
|
|
53
60
|
children: ReadonlyArray<PageContextLink>;
|
|
61
|
+
/** One key per supported locale: the page's `{ slug, path, url }` there, or null where it is
|
|
62
|
+
* absent or unpublished (draft mode: absent only). What a language switch links to. */
|
|
63
|
+
locales: Readonly<Record<string, PageContextAlternate | null>>;
|
|
54
64
|
};
|
|
55
65
|
/** The `page` prop of a view rendered outside the site (a test, a preview
|
|
56
66
|
* of a shape): a real object, so a view never guards an optional prop, but
|
package/dist/render.js
CHANGED
|
@@ -8,7 +8,8 @@ import { CUSTOM_NODE_TYPE, createCustomSection } from "./custom/index.js";
|
|
|
8
8
|
* no position in the tree. Frozen: one singleton shared by every such
|
|
9
9
|
* render, so a view that pushed onto it would leak into the next. */
|
|
10
10
|
export const EMPTY_PAGE_CONTEXT = Object.freeze({
|
|
11
|
-
id: "", title: "", path: "/", url: "/", fields: Object.freeze({}), ancestors: Object.freeze([]), children: Object.freeze([]),
|
|
11
|
+
id: "", title: "", path: "/", url: "/", locale: "", fields: Object.freeze({}), ancestors: Object.freeze([]), children: Object.freeze([]),
|
|
12
|
+
locales: Object.freeze({}),
|
|
12
13
|
});
|
|
13
14
|
export function Zone({ of: content }) {
|
|
14
15
|
// A malformed tree (bad data, a client that skipped isPageTree) must not
|
package/dist/site.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { AssetStore } from "./asset-store.ts";
|
|
|
2
2
|
import type { CollectionSchema, EntryOrder } from "./collections.ts";
|
|
3
3
|
import type { Descriptor } from "./fields.ts";
|
|
4
4
|
import { type EntryRecord, type EntryStore, type Where } from "./entry-store.ts";
|
|
5
|
-
import { type LocaleSet, type PathTarget } from "./paths.ts";
|
|
5
|
+
import { type LocaleAlternate, type LocaleSet, type PageAlternate, type PathTarget } from "./paths.ts";
|
|
6
6
|
import type { SectionLike } from "./refs.ts";
|
|
7
7
|
import type { PageContext, PageLink, PageTree } from "./render.tsx";
|
|
8
8
|
import { type GlobalSchema } from "./global.ts";
|
|
@@ -39,6 +39,8 @@ export type SitePageSchema = {
|
|
|
39
39
|
};
|
|
40
40
|
export type SitePage = {
|
|
41
41
|
kind: "page";
|
|
42
|
+
/** The locale the hit was loaded in. */
|
|
43
|
+
locale: string;
|
|
42
44
|
record: PageRecord;
|
|
43
45
|
template: string;
|
|
44
46
|
tree: PageTree;
|
|
@@ -48,20 +50,21 @@ export type SitePage = {
|
|
|
48
50
|
fields: Record<string, unknown>;
|
|
49
51
|
ancestors: PageLink[];
|
|
50
52
|
children: PageLink[];
|
|
51
|
-
/** locale
|
|
52
|
-
*
|
|
53
|
-
|
|
54
|
-
paths: Record<string, string>;
|
|
53
|
+
/** One key per supported locale: the page's `{ slug, path }` there, or null where it is
|
|
54
|
+
* absent or, outside draft mode, unpublished (spec 2026-09-15 §1). */
|
|
55
|
+
locales: Record<string, PageAlternate | null>;
|
|
55
56
|
};
|
|
56
57
|
export type SiteEntry = {
|
|
57
58
|
kind: "entry";
|
|
59
|
+
/** The locale the hit was loaded in. */
|
|
60
|
+
locale: string;
|
|
58
61
|
collection: string;
|
|
59
62
|
/** `fields` are the locale's MERGED fields — shared + localized + slug —
|
|
60
63
|
* with refs resolved: the published snapshot on the public path, the
|
|
61
64
|
* live row in draft mode. */
|
|
62
65
|
entry: EntryRecord;
|
|
63
66
|
path: string;
|
|
64
|
-
|
|
67
|
+
locales: Record<string, LocaleAlternate | null>;
|
|
65
68
|
};
|
|
66
69
|
export type SiteHit = SitePage | SiteEntry;
|
|
67
70
|
export type PageTreeItem = {
|
|
@@ -75,6 +78,8 @@ export type PageTreeItem = {
|
|
|
75
78
|
export type SiteEntryItem = {
|
|
76
79
|
entry: EntryRecord;
|
|
77
80
|
path: string | null;
|
|
81
|
+
locale: string;
|
|
82
|
+
locales: Record<string, LocaleAlternate | null>;
|
|
78
83
|
};
|
|
79
84
|
export type EntryTreeItem = SiteEntryItem & {
|
|
80
85
|
children: EntryTreeItem[];
|
|
@@ -131,7 +136,7 @@ export interface SmoodlySite {
|
|
|
131
136
|
draft?: boolean;
|
|
132
137
|
}): Promise<V | null>;
|
|
133
138
|
}
|
|
134
|
-
/** The `page` prop for a loaded page. `
|
|
135
|
-
*
|
|
136
|
-
export declare function pageContextOf(hit: SitePage,
|
|
139
|
+
/** The `page` prop for a loaded page. `href` is the app's seam from a
|
|
140
|
+
* locale-relative path to a site URL; the default keeps CMS paths as URLs. */
|
|
141
|
+
export declare function pageContextOf(hit: SitePage, href?: (path: string, locale: string) => string): PageContext;
|
|
137
142
|
export declare function createSmoodlySite(options: SiteOptions): SmoodlySite;
|
package/dist/site.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { ARRAY_FIELD_TYPES, entryFieldsIn, localizedKeys, schemaOf } from "./entry-store.js";
|
|
2
|
-
import { buildPageTree, chainOf, entryPathRows, entrySegment, pagePath, } from "./paths.js";
|
|
2
|
+
import { buildPageTree, chainOf, entryAlternates, entryPathRows, entrySegment, pageAlternates, pagePath, } from "./paths.js";
|
|
3
3
|
import { resolveFields, resolveMedia, resolveMediaSets, resolveTree } from "./resolve.js";
|
|
4
4
|
import { elementSchemas } from "./richtext-walk.js";
|
|
5
5
|
import { globalNodeId, SETTINGS_KEY, STYLES_KEY, CSS_KEY } from "./global.js";
|
|
@@ -7,18 +7,20 @@ import { SMD_KEY } from "./section-wrapper.js";
|
|
|
7
7
|
import { resolveStyleValues } from "./styles.js";
|
|
8
8
|
import { resolveCustom } from "./custom/index.js";
|
|
9
9
|
import { withDefaults } from "./field-defaults.js";
|
|
10
|
-
/** The `page` prop for a loaded page. `
|
|
11
|
-
*
|
|
12
|
-
export function pageContextOf(hit,
|
|
13
|
-
const withUrl = (l) => ({ ...l, url:
|
|
10
|
+
/** The `page` prop for a loaded page. `href` is the app's seam from a
|
|
11
|
+
* locale-relative path to a site URL; the default keeps CMS paths as URLs. */
|
|
12
|
+
export function pageContextOf(hit, href = (p) => p) {
|
|
13
|
+
const withUrl = (l) => ({ ...l, url: href(l.path, hit.locale) });
|
|
14
14
|
return {
|
|
15
15
|
id: hit.record.id,
|
|
16
16
|
title: hit.title,
|
|
17
17
|
path: hit.path,
|
|
18
|
-
url:
|
|
18
|
+
url: href(hit.path, hit.locale),
|
|
19
|
+
locale: hit.locale,
|
|
19
20
|
fields: hit.fields,
|
|
20
21
|
ancestors: hit.ancestors.map(withUrl),
|
|
21
22
|
children: hit.children.map(withUrl),
|
|
23
|
+
locales: Object.fromEntries(Object.entries(hit.locales).map(([code, alt]) => [code, alt && { ...alt, url: href(alt.path, code) }])),
|
|
22
24
|
};
|
|
23
25
|
}
|
|
24
26
|
export function createSmoodlySite(options) {
|
|
@@ -128,10 +130,12 @@ export function createSmoodlySite(options) {
|
|
|
128
130
|
const fields = await linkFields([...chain.slice(0, -1), ...kids], locale, draft);
|
|
129
131
|
const ancestors = chain.slice(0, -1).map((n) => link(n, byId, locale, draft, fields));
|
|
130
132
|
const children = kids.map((n) => link(n, byId, locale, draft, fields));
|
|
131
|
-
// Alternates advertise PUBLISHED locales only; the editor (draft) sees every present
|
|
132
|
-
|
|
133
|
+
// Alternates advertise PUBLISHED locales only; the editor (draft) sees every present
|
|
134
|
+
// one. Derived from the tree already listed — no index lookup (spec 2026-09-15 §2).
|
|
135
|
+
const alternates = pageAlternates(record, byId, pathOpts, (l) => draft || record.locales[l]?.status === "published");
|
|
133
136
|
return {
|
|
134
137
|
kind: "page",
|
|
138
|
+
locale,
|
|
135
139
|
record,
|
|
136
140
|
template: record.template,
|
|
137
141
|
tree,
|
|
@@ -140,7 +144,7 @@ export function createSmoodlySite(options) {
|
|
|
140
144
|
fields: withDefaults(tree.fields, pageFields(record.template)),
|
|
141
145
|
ancestors,
|
|
142
146
|
children,
|
|
143
|
-
|
|
147
|
+
locales: alternates,
|
|
144
148
|
};
|
|
145
149
|
}
|
|
146
150
|
async function loadEntry(id, locale, path, draft) {
|
|
@@ -162,8 +166,11 @@ export function createSmoodlySite(options) {
|
|
|
162
166
|
return null;
|
|
163
167
|
const fields = await resolveFields(raw, schema.fields, { collections: options.collections, elements: options.elements, fetch: fetcher(draft, locale), assets: assetFetcher() });
|
|
164
168
|
// Alternates advertise PUBLISHED locales only; the editor (draft) sees every present one.
|
|
165
|
-
|
|
166
|
-
|
|
169
|
+
// One record is in memory (a nested entry's parents are not), so the `paths` index
|
|
170
|
+
// supplies the paths — the same single lookup as before (spec 2026-09-15 §2).
|
|
171
|
+
const known = await entries.pathsOf(schema.name, id);
|
|
172
|
+
const alternates = entryAlternates(record, locales, (l) => draft || record.locales[l]?.status === "published", (l) => known[l] ?? null);
|
|
173
|
+
return { kind: "entry", locale, collection: schema.name, entry: { ...record, fields }, path, locales: alternates };
|
|
167
174
|
}
|
|
168
175
|
return null;
|
|
169
176
|
}
|
|
@@ -171,15 +178,21 @@ export function createSmoodlySite(options) {
|
|
|
171
178
|
/** One locale's published fields and path for entries of one collection;
|
|
172
179
|
* `all` is the whole collection (a draft ancestor still contributes
|
|
173
180
|
* its segment). `fieldsById` is the published read for the locale. */
|
|
174
|
-
function entryItems(schema, wanted, all, locale, fieldsById) {
|
|
181
|
+
function entryItems(schema, wanted, all, locale, draft, fieldsById) {
|
|
175
182
|
const index = new Map(all.map((e) => [e.id, e]));
|
|
176
183
|
const byId = (x) => index.get(x);
|
|
177
184
|
return wanted
|
|
178
185
|
.filter((record) => fieldsById[record.id] !== undefined)
|
|
179
|
-
.map((record) =>
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
186
|
+
.map((record) => {
|
|
187
|
+
const rows = entryPathRows([record], byId, schema, locales);
|
|
188
|
+
const pathIn = (l) => rows.find((r) => r.locale === l)?.path ?? null;
|
|
189
|
+
return {
|
|
190
|
+
entry: { ...record, fields: fieldsById[record.id] },
|
|
191
|
+
path: pathIn(locale),
|
|
192
|
+
locale,
|
|
193
|
+
locales: entryAlternates(record, locales, (l) => draft || record.locales[l]?.status === "published", pathIn),
|
|
194
|
+
};
|
|
195
|
+
});
|
|
183
196
|
}
|
|
184
197
|
async function getEntries(collection, o = {}) {
|
|
185
198
|
const schema = schemaOf(options.collections, collection);
|
|
@@ -220,7 +233,7 @@ export function createSmoodlySite(options) {
|
|
|
220
233
|
const fetchAssets = assetFetcher();
|
|
221
234
|
if (fetchAssets)
|
|
222
235
|
await resolveMediaSets(Object.values(fieldsById).map((values) => ({ values, descriptors: schema.fields })), fetchAssets, elements);
|
|
223
|
-
return entryItems(schema, inLocale, all, locale, fieldsById);
|
|
236
|
+
return entryItems(schema, inLocale, all, locale, draft, fieldsById);
|
|
224
237
|
}
|
|
225
238
|
const api = {
|
|
226
239
|
async getPagesTree(o) {
|
|
@@ -257,7 +270,7 @@ export function createSmoodlySite(options) {
|
|
|
257
270
|
const locale = localeOf(o);
|
|
258
271
|
if (idOrPath.startsWith("/")) {
|
|
259
272
|
const hit = await site.resolve(locale, idOrPath, { draft: o?.draft ?? false });
|
|
260
|
-
return hit?.kind === "entry" && hit.collection === collection ? { entry: hit.entry, path: hit.path } : null;
|
|
273
|
+
return hit?.kind === "entry" && hit.collection === collection ? { entry: hit.entry, path: hit.path, locale: hit.locale, locales: hit.locales } : null;
|
|
261
274
|
}
|
|
262
275
|
const items = await getEntries(collection, { locale, draft: o?.draft ?? false });
|
|
263
276
|
const byId = items.find((i) => i.entry.id === idOrPath);
|
package/package.json
CHANGED