smoodly 0.0.6 → 0.0.8
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 +318 -74
- package/dist/admin/client-ops.js +3 -2
- package/dist/admin/editor/EditorView.js +229 -30
- package/dist/admin/editor/PageSettings.d.ts +8 -4
- package/dist/admin/editor/PageSettings.js +25 -25
- package/dist/admin/fixed-nodes.d.ts +16 -10
- package/dist/admin/fixed-nodes.js +55 -41
- package/dist/admin/index.d.ts +3 -2
- package/dist/admin/index.js +1 -0
- package/dist/admin/ops-impl.js +218 -36
- package/dist/admin/ops.d.ts +73 -12
- package/dist/admin/serialize.d.ts +11 -0
- package/dist/admin/serialize.js +8 -0
- package/dist/admin/shared-items.d.ts +9 -0
- package/dist/admin/shared-items.js +61 -0
- package/dist/admin/shell/AdminApp.js +11 -34
- package/dist/admin/shell/EntriesList.d.ts +7 -0
- package/dist/admin/shell/EntriesList.js +95 -0
- package/dist/admin/shell/EntryForm.js +167 -49
- package/dist/admin/shell/PagesList.js +86 -26
- package/dist/admin/shell/SharedForm.d.ts +7 -0
- package/dist/admin/shell/SharedForm.js +146 -0
- package/dist/admin/shell/SharedList.d.ts +6 -0
- package/dist/admin/shell/SharedList.js +62 -0
- package/dist/admin/shell/entries-list.d.ts +21 -0
- package/dist/admin/shell/entries-list.js +36 -0
- package/dist/admin/shell/pages-tree.d.ts +30 -12
- package/dist/admin/shell/pages-tree.js +48 -14
- package/dist/admin/shell/shared-list.d.ts +14 -0
- package/dist/admin/shell/shared-list.js +17 -0
- package/dist/admin/tree-ops.d.ts +12 -5
- package/dist/admin/tree-ops.js +39 -8
- package/dist/admin/ui/LocaleSwitcher.d.ts +11 -0
- package/dist/admin/ui/LocaleSwitcher.js +15 -0
- package/dist/collections.d.ts +10 -1
- package/dist/collections.js +16 -0
- package/dist/config.d.ts +3 -0
- package/dist/config.js +58 -4
- package/dist/entry-store.d.ts +141 -55
- package/dist/entry-store.js +317 -87
- package/dist/index.d.ts +11 -7
- package/dist/index.js +8 -5
- package/dist/localize.d.ts +0 -11
- package/dist/localize.js +11 -29
- package/dist/next/page-renderer.d.ts +4 -0
- package/dist/next/page-renderer.js +14 -1
- package/dist/page.js +5 -1
- package/dist/paths.d.ts +46 -27
- package/dist/paths.js +62 -29
- package/dist/refs.js +8 -0
- package/dist/resolve.d.ts +3 -0
- package/dist/resolve.js +38 -0
- package/dist/revalidate.d.ts +4 -0
- package/dist/revalidate.js +6 -1
- package/dist/shared.d.ts +46 -0
- package/dist/shared.js +36 -0
- package/dist/site.d.ts +16 -2
- package/dist/site.js +92 -39
- package/dist/sql-space.d.ts +1 -1
- package/dist/sql-space.js +16 -13
- package/dist/sql.js +95 -70
- package/dist/store.d.ts +66 -32
- package/dist/store.js +205 -90
- package/dist/supabase-entry-store.d.ts +30 -9
- package/dist/supabase-entry-store.js +360 -178
- package/dist/supabase-store.d.ts +21 -9
- package/dist/supabase-store.js +202 -111
- package/dist/zones.d.ts +6 -2
- package/dist/zones.js +8 -2
- package/package.json +1 -1
package/dist/localize.js
CHANGED
|
@@ -1,29 +1,8 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
import {
|
|
6
|
-
const present = (v) => v !== undefined && v !== null && v !== "";
|
|
7
|
-
/** The locale's title, or the default locale's. */
|
|
8
|
-
export function titleFor(node, locale, set) {
|
|
9
|
-
if (locale === set.default)
|
|
10
|
-
return node.title;
|
|
11
|
-
const own = node.i18n?.[locale]?.title;
|
|
12
|
-
return typeof own === "string" && own.length > 0 ? own : node.title;
|
|
13
|
-
}
|
|
14
|
-
/** A fresh object: the default locale's fields, overlaid per field with
|
|
15
|
-
* the locale's values. An overlay value that is undefined, null or ""
|
|
16
|
-
* falls back — a translator may leave a field blank. */
|
|
17
|
-
export function localizeFields(fields, i18n, locale, set) {
|
|
18
|
-
const out = structuredClone(fields);
|
|
19
|
-
if (locale === set.default)
|
|
20
|
-
return out;
|
|
21
|
-
for (const [key, value] of Object.entries(i18n?.[locale] ?? {})) {
|
|
22
|
-
if (present(value))
|
|
23
|
-
out[key] = structuredClone(value);
|
|
24
|
-
}
|
|
25
|
-
return out;
|
|
26
|
-
}
|
|
1
|
+
// The fixed-page path derivation — the escape hatch's addressing (spec
|
|
2
|
+
// 2026-09-05 §5). Entries and pages alike have no field fallback: their
|
|
3
|
+
// locale rows own their fields, titles and slugs (spec 2026-09-06). This
|
|
4
|
+
// module owns nothing but fixedPathFor now.
|
|
5
|
+
import { fixedPageSegments } from "./paths.js";
|
|
27
6
|
/** The path a FIXED page registration is served at in one locale — the
|
|
28
7
|
* escape hatch's addressing (spec §5: renderSmoodlyPage). Home is judged
|
|
29
8
|
* by the default-locale segment, exactly as the stores judge the record. */
|
|
@@ -31,12 +10,15 @@ export function fixedPathFor(schema, locale, set, homePageSlug) {
|
|
|
31
10
|
if (schema.slug === undefined) {
|
|
32
11
|
throw new Error(`smoodly: page registration "${schema.name}" has no fixed slug — open pages are served by renderSmoodlyPath.`);
|
|
33
12
|
}
|
|
34
|
-
|
|
13
|
+
// A registration declares its slugs in code, so a locale it omits means
|
|
14
|
+
// "the same segment", not "absent" — paths.ts's fixedPageSegments fills
|
|
15
|
+
// it, the same fill the boot check and the materializer use.
|
|
16
|
+
const segments = fixedPageSegments(schema.slug, set);
|
|
17
|
+
const slug = segments[set.default];
|
|
35
18
|
if (slug === undefined) {
|
|
36
19
|
throw new Error(`smoodly: page registration "${schema.name}" declares a per-locale slug without the default locale "${set.default}".`);
|
|
37
20
|
}
|
|
38
21
|
if (slug === homePageSlug)
|
|
39
22
|
return "/";
|
|
40
|
-
|
|
41
|
-
return `/${segmentFor({ slug, i18n }, locale, set)}`;
|
|
23
|
+
return `/${segments[locale] ?? slug}`;
|
|
42
24
|
}
|
|
@@ -2,6 +2,7 @@ import type { ComponentType, ReactElement } from "react";
|
|
|
2
2
|
import type { Metadata } from "next";
|
|
3
3
|
import type { Descriptor } from "../fields.ts";
|
|
4
4
|
import { type PairedPage } from "../render.tsx";
|
|
5
|
+
import type { SharedSchema } from "../shared.ts";
|
|
5
6
|
import { type SmoodlySite } from "../site.ts";
|
|
6
7
|
/** A registered section, as renderPage's registry needs it. */
|
|
7
8
|
type SectionEntry = ComponentType<any> & {
|
|
@@ -31,6 +32,9 @@ export type PathRenderOptions = {
|
|
|
31
32
|
searchParams?: Promise<Record<string, string | string[] | undefined>>;
|
|
32
33
|
};
|
|
33
34
|
export declare function createPageRenderer(options: PageRendererOptions): {
|
|
35
|
+
getSmoodlyShared: <V>(item: SharedSchema<V>, opts?: {
|
|
36
|
+
locale?: string;
|
|
37
|
+
}) => Promise<V | null>;
|
|
34
38
|
renderSmoodlyPath: (segments: string[], opts?: PathRenderOptions) => Promise<ReactElement<unknown, string | import("react").JSXElementConstructor<any>>>;
|
|
35
39
|
smoodlyMetadata: (segments: string[], opts?: {
|
|
36
40
|
locale?: string;
|
|
@@ -7,7 +7,7 @@ import { EditorBridge } from "../admin/next/bridge.js";
|
|
|
7
7
|
import { fixedPathFor } from "../localize.js";
|
|
8
8
|
import { joinPath } from "../paths.js";
|
|
9
9
|
import { renderPage } from "../render.js";
|
|
10
|
-
import { entryTag, pageTag } from "../revalidate.js";
|
|
10
|
+
import { entryTag, pageTag, sharedTag } from "../revalidate.js";
|
|
11
11
|
import { pageContextOf } from "../site.js";
|
|
12
12
|
import { metadataFrom } from "./meta.js";
|
|
13
13
|
export function createPageRenderer(options) {
|
|
@@ -82,7 +82,20 @@ export function createPageRenderer(options) {
|
|
|
82
82
|
* error surfaces as a rejected promise like every other failure. */
|
|
83
83
|
const fixedPath = (Page) => (locale) => fixedPathFor(Page.schema, locale, site.locales, site.homePageSlug);
|
|
84
84
|
const literal = (segments) => () => joinPath(segments);
|
|
85
|
+
/** A shared item for a layout: cached per (item, locale) under the
|
|
86
|
+
* shared tag outside draft mode, the live draft inside it. */
|
|
87
|
+
async function getSmoodlyShared(item, opts) {
|
|
88
|
+
await connection();
|
|
89
|
+
const locale = opts?.locale ?? site.locales.default;
|
|
90
|
+
const { isEnabled: draft } = await draftMode();
|
|
91
|
+
if (draft)
|
|
92
|
+
return site.getShared(item, { locale, draft: true });
|
|
93
|
+
return unstable_cache(() => site.getShared(item, { locale }), ["smoodly-shared", item.name, locale], {
|
|
94
|
+
tags: [sharedTag(item.name)],
|
|
95
|
+
})();
|
|
96
|
+
}
|
|
85
97
|
return {
|
|
98
|
+
getSmoodlyShared,
|
|
86
99
|
renderSmoodlyPath: (segments, opts) => page(literal(segments), opts, any),
|
|
87
100
|
smoodlyMetadata: (segments, opts) => metadata(literal(segments), opts, any),
|
|
88
101
|
/** A fixed registration rendered from the developer's own route file. */
|
package/dist/page.js
CHANGED
|
@@ -10,7 +10,11 @@ function normalizePolicy(p) {
|
|
|
10
10
|
case "freeform":
|
|
11
11
|
return { kind: "freeform", allow: p.allow, rows: p.rows };
|
|
12
12
|
case "locked":
|
|
13
|
-
return {
|
|
13
|
+
return {
|
|
14
|
+
kind: "locked",
|
|
15
|
+
component: p.component,
|
|
16
|
+
...(typeof p.shared === "string" ? { shared: p.shared } : {}),
|
|
17
|
+
};
|
|
14
18
|
default:
|
|
15
19
|
throw new Error(`smoodly: unknown zone policy kind "${String(p.kind)}".`);
|
|
16
20
|
}
|
package/dist/paths.d.ts
CHANGED
|
@@ -5,8 +5,21 @@ export type LocaleSet = {
|
|
|
5
5
|
default: string;
|
|
6
6
|
supported: string[];
|
|
7
7
|
};
|
|
8
|
+
/** ONE text for both stores: a locale outside the config set. */
|
|
9
|
+
export declare const unsupportedLocale: (locale: string) => Error;
|
|
10
|
+
export declare function assertSupportedLocale(locale: string, set: LocaleSet): void;
|
|
8
11
|
/** A per-locale segment map: a string applies to every supported locale; an object is copied as declared. */
|
|
9
12
|
export declare function perLocaleSegments(value: string | Record<string, string>, supported: string[]): Record<string, string>;
|
|
13
|
+
/** The segments a FIXED PAGE registration claims, per locale. A
|
|
14
|
+
* registration declares its slugs in code, so a locale its map omits
|
|
15
|
+
* means "the default locale's segment", not "absent" — the
|
|
16
|
+
* page_locales presence rule is about records, not registrations
|
|
17
|
+
* (spec 2026-09-06 §3). The boot collision check (config.ts), the
|
|
18
|
+
* materializer (admin/fixed-nodes.ts) and the fixed path derivation
|
|
19
|
+
* (localize.ts) all fill through here, or a collision passes boot and
|
|
20
|
+
* only surfaces at materialization. Collection mounts are NOT filled:
|
|
21
|
+
* a per-locale path claims exactly the locales it names. */
|
|
22
|
+
export declare function fixedPageSegments(slug: string | Record<string, string>, locales: LocaleSet): Record<string, string>;
|
|
10
23
|
export type PathTarget = {
|
|
11
24
|
kind: "page" | "entry";
|
|
12
25
|
id: string;
|
|
@@ -17,18 +30,10 @@ export type PathRow = PathTarget & {
|
|
|
17
30
|
};
|
|
18
31
|
export declare const joinPath: (segments: string[]) => string;
|
|
19
32
|
export declare const splitPath: (path: string) => string[];
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
export declare function segmentFor(node: {
|
|
25
|
-
slug: string;
|
|
26
|
-
i18n?: SlugI18n;
|
|
27
|
-
}, locale: string, set: LocaleSet): string;
|
|
28
|
-
/** null = every supported locale; otherwise the intersection. */
|
|
29
|
-
export declare function localesOf(node: {
|
|
30
|
-
locales?: string[] | null;
|
|
31
|
-
}, set: LocaleSet): string[];
|
|
33
|
+
/** The node's segment in one locale. An absent locale is an error, never
|
|
34
|
+
* a fallback: the stores' parent rule guarantees a present node has a
|
|
35
|
+
* present ancestor chain, so a throw here is a broken invariant. */
|
|
36
|
+
export declare function segmentFor(node: PageLike, locale: string): string;
|
|
32
37
|
export declare function chainOf<T extends {
|
|
33
38
|
id: string;
|
|
34
39
|
parentId: string | null;
|
|
@@ -37,44 +42,58 @@ export declare function assertDepth(depth: number, max: number, what: string): v
|
|
|
37
42
|
export type PageLike = {
|
|
38
43
|
id: string;
|
|
39
44
|
parentId: string | null;
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
slug
|
|
43
|
-
|
|
44
|
-
}> | null;
|
|
45
|
-
locales: string[] | null;
|
|
45
|
+
/** The locales the node exists in, each with the node's OWN segment there. Presence IS the key (spec 2026-09-06 §3). */
|
|
46
|
+
locales: Record<string, {
|
|
47
|
+
slug: string;
|
|
48
|
+
}>;
|
|
46
49
|
};
|
|
47
50
|
export type PagePathOptions = {
|
|
48
51
|
locales: LocaleSet;
|
|
49
52
|
homePageSlug: string;
|
|
50
53
|
};
|
|
54
|
+
/** Home is judged by identity: the root node whose DEFAULT-locale slug is
|
|
55
|
+
* homePageSlug serves "/" in every locale it exists in. */
|
|
56
|
+
export declare const isHomeNode: (node: PageLike, opts: PagePathOptions) => boolean;
|
|
51
57
|
/** The node's path in one locale: `/` for the home node, else the
|
|
52
|
-
*
|
|
58
|
+
* locale's ancestor chain joined. THE derivation — pagePathRows,
|
|
53
59
|
* buildPageTree and the site layer all call this. */
|
|
54
60
|
export declare function pagePath(node: PageLike, byId: (id: string) => PageLike | undefined, locale: string, opts: PagePathOptions): string;
|
|
61
|
+
/** The same path, or null when the node or an ancestor is absent from the
|
|
62
|
+
* locale — what the Pages list shows as a dimmed "not in fi" row. */
|
|
63
|
+
export declare function pagePathIn(node: PageLike, byId: (id: string) => PageLike | undefined, locale: string, opts: PagePathOptions): string | null;
|
|
64
|
+
/** One row per locale the target EXISTS in. */
|
|
55
65
|
export declare function pagePathRows(targets: PageLike[], byId: (id: string) => PageLike | undefined, opts: PagePathOptions): PathRow[];
|
|
56
66
|
export type EntryLike = {
|
|
57
67
|
id: string;
|
|
58
68
|
parentId: string | null;
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
69
|
+
/** The locales the entry exists in, each with its OWN segment there (null =
|
|
70
|
+
* the entry claims no URL in that locale). Presence IS the key (spec
|
|
71
|
+
* 2026-09-06 §7). */
|
|
72
|
+
locales: Record<string, {
|
|
73
|
+
slug: string | null;
|
|
74
|
+
}>;
|
|
62
75
|
};
|
|
63
76
|
export declare function collectionSegment(path: string | Record<string, string> | undefined, locale: string, set: LocaleSet): string | null;
|
|
64
77
|
/** The "claims a URL" predicate (§1): a slug is only a real segment when
|
|
65
78
|
* it is a non-empty string — absent, null, or `""` all mean "no URL".
|
|
66
|
-
*
|
|
67
|
-
*
|
|
79
|
+
* There is no sibling-slug index: sibling uniqueness is the primary key
|
|
80
|
+
* of `paths` plus the pre-check each store makes per locale before it
|
|
81
|
+
* writes (spec 2026-09-06 §12), so Postgres and memory agree on which
|
|
68
82
|
* entries can collide. */
|
|
69
83
|
export declare const slugOf: (fields: Record<string, unknown>) => string | null;
|
|
70
|
-
|
|
84
|
+
/** The entry's segment in one locale: its row's slug. An absent locale
|
|
85
|
+
* or a slug-less row is null — no URL there, never a fallback. */
|
|
86
|
+
export declare function entrySegment(entry: EntryLike, locale: string): string | null;
|
|
87
|
+
/** One row per locale the target EXISTS in, where the whole chain has a
|
|
88
|
+
* segment in that locale. The stores' parent rule guarantees an ancestor
|
|
89
|
+
* is present wherever a descendant is; an ancestor without a slug there
|
|
90
|
+
* simply yields no URL for the subtree in that locale. */
|
|
71
91
|
export declare function entryPathRows(targets: EntryLike[], byId: (id: string) => EntryLike | undefined, collection: {
|
|
72
92
|
path?: string | Record<string, string>;
|
|
73
93
|
}, set: LocaleSet): PathRow[];
|
|
74
94
|
export type PageTreeNode<T extends PageLike> = {
|
|
75
95
|
record: T;
|
|
76
|
-
path: string;
|
|
96
|
+
path: string | null;
|
|
77
97
|
children: PageTreeNode<T>[];
|
|
78
98
|
};
|
|
79
99
|
export declare function buildPageTree<T extends PageLike>(records: T[], locale: string, opts: PagePathOptions): PageTreeNode<T>[];
|
|
80
|
-
export {};
|
package/dist/paths.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
// The one place a URL is derived (spec 2026-09-05 §1, §4
|
|
2
|
-
// framework-free: both store adapters and
|
|
3
|
-
// the `paths` index and every derived
|
|
1
|
+
// The one place a URL is derived (spec 2026-09-05 §1, §4; spec
|
|
2
|
+
// 2026-09-06 §3, §7). Pure and framework-free: both store adapters and
|
|
3
|
+
// the tree query call these, so the `paths` index and every derived
|
|
4
|
+
// `path` value agree by construction.
|
|
4
5
|
export const SEGMENT_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
5
6
|
export const isSegment = (s) => SEGMENT_RE.test(s);
|
|
6
7
|
export function assertSegment(s, what) {
|
|
@@ -8,24 +9,39 @@ export function assertSegment(s, what) {
|
|
|
8
9
|
throw new Error(`smoodly: ${what} "${s}" is not a valid slug — use lowercase letters, digits and single hyphens.`);
|
|
9
10
|
}
|
|
10
11
|
}
|
|
12
|
+
/** ONE text for both stores: a locale outside the config set. */
|
|
13
|
+
export const unsupportedLocale = (locale) => new Error(`smoodly: "${locale}" is not a supported locale.`);
|
|
14
|
+
export function assertSupportedLocale(locale, set) {
|
|
15
|
+
if (!set.supported.includes(locale))
|
|
16
|
+
throw unsupportedLocale(locale);
|
|
17
|
+
}
|
|
11
18
|
/** A per-locale segment map: a string applies to every supported locale; an object is copied as declared. */
|
|
12
19
|
export function perLocaleSegments(value, supported) {
|
|
13
20
|
return typeof value === "string" ? Object.fromEntries(supported.map((l) => [l, value])) : { ...value };
|
|
14
21
|
}
|
|
22
|
+
/** The segments a FIXED PAGE registration claims, per locale. A
|
|
23
|
+
* registration declares its slugs in code, so a locale its map omits
|
|
24
|
+
* means "the default locale's segment", not "absent" — the
|
|
25
|
+
* page_locales presence rule is about records, not registrations
|
|
26
|
+
* (spec 2026-09-06 §3). The boot collision check (config.ts), the
|
|
27
|
+
* materializer (admin/fixed-nodes.ts) and the fixed path derivation
|
|
28
|
+
* (localize.ts) all fill through here, or a collision passes boot and
|
|
29
|
+
* only surfaces at materialization. Collection mounts are NOT filled:
|
|
30
|
+
* a per-locale path claims exactly the locales it names. */
|
|
31
|
+
export function fixedPageSegments(slug, locales) {
|
|
32
|
+
const raw = perLocaleSegments(slug, locales.supported);
|
|
33
|
+
return Object.fromEntries(locales.supported.map((l) => [l, raw[l] || raw[locales.default]]));
|
|
34
|
+
}
|
|
15
35
|
export const joinPath = (segments) => (segments.length === 0 ? "/" : "/" + segments.join("/"));
|
|
16
36
|
export const splitPath = (path) => path.split("/").filter((s) => s.length > 0);
|
|
17
|
-
/** The
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
const own = node.
|
|
22
|
-
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export function localesOf(node, set) {
|
|
26
|
-
if (!node.locales)
|
|
27
|
-
return [...set.supported];
|
|
28
|
-
return set.supported.filter((l) => node.locales.includes(l));
|
|
37
|
+
/** The node's segment in one locale. An absent locale is an error, never
|
|
38
|
+
* a fallback: the stores' parent rule guarantees a present node has a
|
|
39
|
+
* present ancestor chain, so a throw here is a broken invariant. */
|
|
40
|
+
export function segmentFor(node, locale) {
|
|
41
|
+
const own = node.locales[locale];
|
|
42
|
+
if (!own)
|
|
43
|
+
throw new Error(`smoodly: page "${node.id}" does not exist in locale "${locale}".`);
|
|
44
|
+
return own.slug;
|
|
29
45
|
}
|
|
30
46
|
export function chainOf(node, byId) {
|
|
31
47
|
const chain = [node];
|
|
@@ -43,19 +59,30 @@ export function assertDepth(depth, max, what) {
|
|
|
43
59
|
if (depth > max)
|
|
44
60
|
throw new Error(`smoodly: ${what} would exceed the tree depth (${max}).`);
|
|
45
61
|
}
|
|
46
|
-
|
|
62
|
+
/** Home is judged by identity: the root node whose DEFAULT-locale slug is
|
|
63
|
+
* homePageSlug serves "/" in every locale it exists in. */
|
|
64
|
+
export const isHomeNode = (node, opts) => node.parentId === null && node.locales[opts.locales.default]?.slug === opts.homePageSlug;
|
|
47
65
|
/** The node's path in one locale: `/` for the home node, else the
|
|
48
|
-
*
|
|
66
|
+
* locale's ancestor chain joined. THE derivation — pagePathRows,
|
|
49
67
|
* buildPageTree and the site layer all call this. */
|
|
50
68
|
export function pagePath(node, byId, locale, opts) {
|
|
51
|
-
return
|
|
52
|
-
|
|
53
|
-
|
|
69
|
+
return isHomeNode(node, opts) ? "/" : joinPath(chainOf(node, byId).map((n) => segmentFor(n, locale)));
|
|
70
|
+
}
|
|
71
|
+
/** The same path, or null when the node or an ancestor is absent from the
|
|
72
|
+
* locale — what the Pages list shows as a dimmed "not in fi" row. */
|
|
73
|
+
export function pagePathIn(node, byId, locale, opts) {
|
|
74
|
+
if (!node.locales[locale])
|
|
75
|
+
return null;
|
|
76
|
+
if (isHomeNode(node, opts))
|
|
77
|
+
return "/";
|
|
78
|
+
const chain = chainOf(node, byId);
|
|
79
|
+
return chain.every((n) => n.locales[locale] !== undefined) ? joinPath(chain.map((n) => n.locales[locale].slug)) : null;
|
|
54
80
|
}
|
|
81
|
+
/** One row per locale the target EXISTS in. */
|
|
55
82
|
export function pagePathRows(targets, byId, opts) {
|
|
56
83
|
const rows = [];
|
|
57
84
|
for (const target of targets) {
|
|
58
|
-
for (const locale of
|
|
85
|
+
for (const locale of Object.keys(target.locales)) {
|
|
59
86
|
rows.push({ kind: "page", id: target.id, locale, path: pagePath(target, byId, locale, opts) });
|
|
60
87
|
}
|
|
61
88
|
}
|
|
@@ -70,26 +97,32 @@ export function collectionSegment(path, locale, set) {
|
|
|
70
97
|
}
|
|
71
98
|
/** The "claims a URL" predicate (§1): a slug is only a real segment when
|
|
72
99
|
* it is a non-empty string — absent, null, or `""` all mean "no URL".
|
|
73
|
-
*
|
|
74
|
-
*
|
|
100
|
+
* There is no sibling-slug index: sibling uniqueness is the primary key
|
|
101
|
+
* of `paths` plus the pre-check each store makes per locale before it
|
|
102
|
+
* writes (spec 2026-09-06 §12), so Postgres and memory agree on which
|
|
75
103
|
* entries can collide. */
|
|
76
104
|
export const slugOf = (fields) => {
|
|
77
105
|
const slug = fields.slug;
|
|
78
106
|
return typeof slug === "string" && slug.length > 0 ? slug : null;
|
|
79
107
|
};
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
108
|
+
/** The entry's segment in one locale: its row's slug. An absent locale
|
|
109
|
+
* or a slug-less row is null — no URL there, never a fallback. */
|
|
110
|
+
export function entrySegment(entry, locale) {
|
|
111
|
+
return entry.locales[locale]?.slug ?? null;
|
|
83
112
|
}
|
|
113
|
+
/** One row per locale the target EXISTS in, where the whole chain has a
|
|
114
|
+
* segment in that locale. The stores' parent rule guarantees an ancestor
|
|
115
|
+
* is present wherever a descendant is; an ancestor without a slug there
|
|
116
|
+
* simply yields no URL for the subtree in that locale. */
|
|
84
117
|
export function entryPathRows(targets, byId, collection, set) {
|
|
85
118
|
const rows = [];
|
|
86
119
|
for (const target of targets) {
|
|
87
120
|
const chain = chainOf(target, byId);
|
|
88
|
-
for (const locale of
|
|
121
|
+
for (const locale of Object.keys(target.locales)) {
|
|
89
122
|
const base = collectionSegment(collection.path, locale, set);
|
|
90
123
|
if (base === null)
|
|
91
124
|
continue;
|
|
92
|
-
const segments = chain.map((e) => entrySegment(e, locale
|
|
125
|
+
const segments = chain.map((e) => entrySegment(e, locale));
|
|
93
126
|
if (segments.some((s) => s === null))
|
|
94
127
|
continue;
|
|
95
128
|
rows.push({ kind: "entry", id: target.id, locale, path: joinPath([base, ...segments]) });
|
|
@@ -103,7 +136,7 @@ export function buildPageTree(records, locale, opts) {
|
|
|
103
136
|
.filter((r) => r.parentId === parentId)
|
|
104
137
|
.map((r) => ({
|
|
105
138
|
record: r,
|
|
106
|
-
path:
|
|
139
|
+
path: pagePathIn(r, byId, locale, opts),
|
|
107
140
|
children: build(r.id),
|
|
108
141
|
}));
|
|
109
142
|
return build(null);
|
package/dist/refs.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// and never the source of truth. Stores rewrite a source's edges on
|
|
5
5
|
// every save; `refs` answers only the REVERSE question (who depends on
|
|
6
6
|
// this?) — forward resolution stays resolveTree's job.
|
|
7
|
+
import { sharedLink } from "./shared.js";
|
|
7
8
|
function targetOf(d) {
|
|
8
9
|
const t = d.target?.();
|
|
9
10
|
if (!t?.name)
|
|
@@ -39,6 +40,13 @@ export function collectTreeRefs(tree, sections) {
|
|
|
39
40
|
const out = [];
|
|
40
41
|
for (const nodes of Object.values(tree.zones ?? {})) {
|
|
41
42
|
for (const node of nodes) {
|
|
43
|
+
// A placement (spec 2026-09-07 §5): one edge, keyed by the node id
|
|
44
|
+
// so two placements of one item on one page stay distinct rows.
|
|
45
|
+
const link = sharedLink(node);
|
|
46
|
+
if (link) {
|
|
47
|
+
out.push({ field: node.id, targetCollection: link.item, targetId: link.ref });
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
42
50
|
const schema = byName.get(node.type);
|
|
43
51
|
if (!schema)
|
|
44
52
|
continue; // fail-soft, same as renderPage
|
package/dist/resolve.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Descriptor } from "./fields.ts";
|
|
2
2
|
import type { CollectionSchema } from "./collections.ts";
|
|
3
3
|
import type { PageTree } from "./render.tsx";
|
|
4
|
+
import { type SharedSchema } from "./shared.ts";
|
|
4
5
|
export type EntryFetcher = (collection: string, ids: string[]) => Promise<Record<string, Record<string, unknown>>>;
|
|
5
6
|
type SchemaLike = {
|
|
6
7
|
name: string;
|
|
@@ -15,6 +16,8 @@ type ResolveOptions = {
|
|
|
15
16
|
})[];
|
|
16
17
|
collections: CollectionSchema<any>[];
|
|
17
18
|
fetch: EntryFetcher;
|
|
19
|
+
/** The registered shared items a placement node may name (spec 2026-09-07 §4). */
|
|
20
|
+
shared?: SharedSchema[];
|
|
18
21
|
/** Optional safety valve on chain length; unlimited by default. */
|
|
19
22
|
maxDepth?: number;
|
|
20
23
|
};
|
package/dist/resolve.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// Cycles are cut per path — a ref back to an entry already on its own
|
|
7
7
|
// chain gets a shallow copy (refs as ids), so traversal terminates by
|
|
8
8
|
// construction and the output stays JSON-serializable.
|
|
9
|
+
import { sharedLink, SHARED_NODE_TYPE, STYLES_KEY } from "./shared.js";
|
|
9
10
|
function targetOf(d) {
|
|
10
11
|
const t = d.target?.();
|
|
11
12
|
if (!t?.name)
|
|
@@ -27,6 +28,7 @@ export async function resolveTree(tree, options) {
|
|
|
27
28
|
return [schema.name, schema];
|
|
28
29
|
}));
|
|
29
30
|
const resolved = structuredClone(tree);
|
|
31
|
+
await rewritePlacements(resolved, options.shared ?? [], options.fetch);
|
|
30
32
|
const tasks = [];
|
|
31
33
|
for (const nodes of Object.values(resolved.zones ?? {})) {
|
|
32
34
|
for (const node of nodes) {
|
|
@@ -39,6 +41,42 @@ export async function resolveTree(tree, options) {
|
|
|
39
41
|
await runResolution(tasks, { collections: options.collections, fetch: options.fetch, maxDepth });
|
|
40
42
|
return resolved;
|
|
41
43
|
}
|
|
44
|
+
/** Placements first (spec 2026-09-07 §4): fetch every linked item in one
|
|
45
|
+
* call per item, rewrite each node into the item's section node — the
|
|
46
|
+
* placement's id stays, so the canvas selects it — and drop what the
|
|
47
|
+
* site cannot render: an unpublished or dangling row, an object item
|
|
48
|
+
* (no view), an unknown item. The normal pass then hydrates the
|
|
49
|
+
* rewritten nodes' own refs. */
|
|
50
|
+
async function rewritePlacements(tree, shared, fetch) {
|
|
51
|
+
const byName = new Map(shared.map((s) => [s.name, s]));
|
|
52
|
+
const wanted = new Map();
|
|
53
|
+
for (const nodes of Object.values(tree.zones ?? {})) {
|
|
54
|
+
for (const node of nodes) {
|
|
55
|
+
const link = sharedLink(node);
|
|
56
|
+
if (!link || !byName.get(link.item)?.section)
|
|
57
|
+
continue;
|
|
58
|
+
const ids = wanted.get(link.item) ?? new Set();
|
|
59
|
+
ids.add(link.ref);
|
|
60
|
+
wanted.set(link.item, ids);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const fetched = new Map();
|
|
64
|
+
await Promise.all([...wanted].map(async ([item, ids]) => fetched.set(item, await fetch(item, [...ids]))));
|
|
65
|
+
for (const [zone, nodes] of Object.entries(tree.zones ?? {})) {
|
|
66
|
+
tree.zones[zone] = nodes.flatMap((node) => {
|
|
67
|
+
const link = sharedLink(node);
|
|
68
|
+
if (!link)
|
|
69
|
+
return node.type === SHARED_NODE_TYPE ? [] : [node];
|
|
70
|
+
const schema = byName.get(link.item);
|
|
71
|
+
const row = fetched.get(link.item)?.[link.ref];
|
|
72
|
+
if (!schema?.section || !row)
|
|
73
|
+
return [];
|
|
74
|
+
const { [STYLES_KEY]: styles, ...fields } = structuredClone(row);
|
|
75
|
+
const isObject = styles !== null && typeof styles === "object";
|
|
76
|
+
return [{ id: node.id, type: schema.section, fields, ...(isObject ? { styles: styles } : {}) }];
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
42
80
|
/** One entry's fields, refs hydrated — the entry page's counterpart of
|
|
43
81
|
* resolveTree. Same rules, same batching, same cycle cut. */
|
|
44
82
|
export async function resolveFields(fields, descriptors, options) {
|
package/dist/revalidate.d.ts
CHANGED
|
@@ -8,6 +8,10 @@ export type AffectedTargets = {
|
|
|
8
8
|
};
|
|
9
9
|
export declare const pageTag: (id: string) => string;
|
|
10
10
|
export declare const entryTag: (id: string) => string;
|
|
11
|
+
/** A shared item's cache unit, by NAME (a singleton has one row per
|
|
12
|
+
* locale, and code owns the name): the Next glue's getSmoodlyShared
|
|
13
|
+
* caches under it; ops.shared expires it. */
|
|
14
|
+
export declare const sharedTag: (name: string) => string;
|
|
11
15
|
export declare function affectedTargets(stores: {
|
|
12
16
|
pages: PageStore;
|
|
13
17
|
entries: EntryStore;
|
package/dist/revalidate.js
CHANGED
|
@@ -7,6 +7,10 @@
|
|
|
7
7
|
// change (spec 2026-09-05 §5).
|
|
8
8
|
export const pageTag = (id) => `page:${id}`;
|
|
9
9
|
export const entryTag = (id) => `entry:${id}`;
|
|
10
|
+
/** A shared item's cache unit, by NAME (a singleton has one row per
|
|
11
|
+
* locale, and code owns the name): the Next glue's getSmoodlyShared
|
|
12
|
+
* caches under it; ops.shared expires it. */
|
|
13
|
+
export const sharedTag = (name) => `shared:${name}`;
|
|
10
14
|
export async function affectedTargets(stores, entryId, options = {}) {
|
|
11
15
|
const maxDepth = options.maxDepth ?? Infinity;
|
|
12
16
|
const seen = new Set([entryId]);
|
|
@@ -32,7 +36,8 @@ export async function affectedTargets(stores, entryId, options = {}) {
|
|
|
32
36
|
const published = [];
|
|
33
37
|
for (const id of pageIds) {
|
|
34
38
|
const page = await stores.pages.getPage(id);
|
|
35
|
-
|
|
39
|
+
// Published in ANY locale: the tag covers every locale's cached unit.
|
|
40
|
+
if (page && Object.values(page.locales).some((l) => l.status === "published"))
|
|
36
41
|
published.push(id);
|
|
37
42
|
}
|
|
38
43
|
return { pageIds: published, entryIds };
|
package/dist/shared.d.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { CollectionSchema } from "./collections.ts";
|
|
2
|
+
import type { Descriptor } from "./fields.ts";
|
|
3
|
+
import type { PairedComponent } from "./pairing.tsx";
|
|
4
|
+
import type { BuilderMap, NamespaceValues, SectionSchema } from "./schema.ts";
|
|
5
|
+
/** The reserved tree node type of a placement. */
|
|
6
|
+
export declare const SHARED_NODE_TYPE = "shared";
|
|
7
|
+
/** The reserved key a visual item's styles live under in the node's shared JSONB. */
|
|
8
|
+
export declare const STYLES_KEY = "$styles";
|
|
9
|
+
export type SharedSchema<V = Record<string, unknown>> = CollectionSchema<any> & {
|
|
10
|
+
kind: "shared";
|
|
11
|
+
/** The registered section a visual item renders through; absent = an object item. */
|
|
12
|
+
section?: string;
|
|
13
|
+
/** The section's style descriptors (empty for an object item). */
|
|
14
|
+
styles: Record<string, Descriptor>;
|
|
15
|
+
sample?: Record<string, unknown>;
|
|
16
|
+
/** phantom: what getShared returns */
|
|
17
|
+
readonly __value?: V;
|
|
18
|
+
};
|
|
19
|
+
export type SharedLink = {
|
|
20
|
+
item: string;
|
|
21
|
+
ref: string;
|
|
22
|
+
};
|
|
23
|
+
/** The placement a tree node is, or null for any other node. Accepts a
|
|
24
|
+
* bare `{ type, fields }` shape or a full tree node — `id` (and any
|
|
25
|
+
* other tree-node property) is ignored. */
|
|
26
|
+
export declare function sharedLink(node: {
|
|
27
|
+
id?: string;
|
|
28
|
+
type: string;
|
|
29
|
+
fields?: Record<string, unknown>;
|
|
30
|
+
}): SharedLink | null;
|
|
31
|
+
type SharedInput = {
|
|
32
|
+
name: string;
|
|
33
|
+
title: string;
|
|
34
|
+
versions?: boolean;
|
|
35
|
+
};
|
|
36
|
+
export declare function shared<F extends BuilderMap>(input: SharedInput & {
|
|
37
|
+
fields: F;
|
|
38
|
+
section?: undefined;
|
|
39
|
+
}): SharedSchema<NamespaceValues<F>>;
|
|
40
|
+
export declare function shared<F extends BuilderMap, St extends BuilderMap>(input: SharedInput & {
|
|
41
|
+
section: PairedComponent<SectionSchema<F, St>, F, St>;
|
|
42
|
+
fields?: undefined;
|
|
43
|
+
}): SharedSchema<NamespaceValues<F> & {
|
|
44
|
+
styles: NamespaceValues<St>;
|
|
45
|
+
}>;
|
|
46
|
+
export {};
|
package/dist/shared.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/** The reserved tree node type of a placement. */
|
|
2
|
+
export const SHARED_NODE_TYPE = "shared";
|
|
3
|
+
/** The reserved key a visual item's styles live under in the node's shared JSONB. */
|
|
4
|
+
export const STYLES_KEY = "$styles";
|
|
5
|
+
/** The placement a tree node is, or null for any other node. Accepts a
|
|
6
|
+
* bare `{ type, fields }` shape or a full tree node — `id` (and any
|
|
7
|
+
* other tree-node property) is ignored. */
|
|
8
|
+
export function sharedLink(node) {
|
|
9
|
+
if (node.type !== SHARED_NODE_TYPE)
|
|
10
|
+
return null;
|
|
11
|
+
const { item, ref } = node.fields ?? {};
|
|
12
|
+
return typeof item === "string" && typeof ref === "string" ? { item, ref } : null;
|
|
13
|
+
}
|
|
14
|
+
export function shared(input) {
|
|
15
|
+
if (!input?.name)
|
|
16
|
+
throw new Error("smoodly: a shared item requires a name.");
|
|
17
|
+
if ((input.fields === undefined) === (input.section === undefined)) {
|
|
18
|
+
throw new Error(`smoodly: shared item "${input.name}" must declare exactly one of \`section\` or \`fields\`.`);
|
|
19
|
+
}
|
|
20
|
+
const fields = input.section
|
|
21
|
+
? input.section.schema.fields
|
|
22
|
+
: Object.fromEntries(Object.entries(input.fields).map(([k, b]) => [k, b.descriptor]));
|
|
23
|
+
if (Object.prototype.hasOwnProperty.call(fields, STYLES_KEY)) {
|
|
24
|
+
throw new Error(`smoodly: shared item "${input.name}" has a field named "${STYLES_KEY}" — that key is reserved for a visual item's styles.`);
|
|
25
|
+
}
|
|
26
|
+
return {
|
|
27
|
+
kind: "shared",
|
|
28
|
+
name: input.name,
|
|
29
|
+
title: input.title,
|
|
30
|
+
fields,
|
|
31
|
+
styles: input.section ? input.section.schema.styles : {},
|
|
32
|
+
...(input.section ? { section: input.section.schema.name } : {}),
|
|
33
|
+
...(input.section?.schema.sample ? { sample: input.section.schema.sample } : {}),
|
|
34
|
+
...(input.versions ? { versions: true } : {}),
|
|
35
|
+
};
|
|
36
|
+
}
|