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.
Files changed (70) hide show
  1. package/README.md +318 -74
  2. package/dist/admin/client-ops.js +3 -2
  3. package/dist/admin/editor/EditorView.js +229 -30
  4. package/dist/admin/editor/PageSettings.d.ts +8 -4
  5. package/dist/admin/editor/PageSettings.js +25 -25
  6. package/dist/admin/fixed-nodes.d.ts +16 -10
  7. package/dist/admin/fixed-nodes.js +55 -41
  8. package/dist/admin/index.d.ts +3 -2
  9. package/dist/admin/index.js +1 -0
  10. package/dist/admin/ops-impl.js +218 -36
  11. package/dist/admin/ops.d.ts +73 -12
  12. package/dist/admin/serialize.d.ts +11 -0
  13. package/dist/admin/serialize.js +8 -0
  14. package/dist/admin/shared-items.d.ts +9 -0
  15. package/dist/admin/shared-items.js +61 -0
  16. package/dist/admin/shell/AdminApp.js +11 -34
  17. package/dist/admin/shell/EntriesList.d.ts +7 -0
  18. package/dist/admin/shell/EntriesList.js +95 -0
  19. package/dist/admin/shell/EntryForm.js +167 -49
  20. package/dist/admin/shell/PagesList.js +86 -26
  21. package/dist/admin/shell/SharedForm.d.ts +7 -0
  22. package/dist/admin/shell/SharedForm.js +146 -0
  23. package/dist/admin/shell/SharedList.d.ts +6 -0
  24. package/dist/admin/shell/SharedList.js +62 -0
  25. package/dist/admin/shell/entries-list.d.ts +21 -0
  26. package/dist/admin/shell/entries-list.js +36 -0
  27. package/dist/admin/shell/pages-tree.d.ts +30 -12
  28. package/dist/admin/shell/pages-tree.js +48 -14
  29. package/dist/admin/shell/shared-list.d.ts +14 -0
  30. package/dist/admin/shell/shared-list.js +17 -0
  31. package/dist/admin/tree-ops.d.ts +12 -5
  32. package/dist/admin/tree-ops.js +39 -8
  33. package/dist/admin/ui/LocaleSwitcher.d.ts +11 -0
  34. package/dist/admin/ui/LocaleSwitcher.js +15 -0
  35. package/dist/collections.d.ts +10 -1
  36. package/dist/collections.js +16 -0
  37. package/dist/config.d.ts +3 -0
  38. package/dist/config.js +58 -4
  39. package/dist/entry-store.d.ts +141 -55
  40. package/dist/entry-store.js +317 -87
  41. package/dist/index.d.ts +11 -7
  42. package/dist/index.js +8 -5
  43. package/dist/localize.d.ts +0 -11
  44. package/dist/localize.js +11 -29
  45. package/dist/next/page-renderer.d.ts +4 -0
  46. package/dist/next/page-renderer.js +14 -1
  47. package/dist/page.js +5 -1
  48. package/dist/paths.d.ts +46 -27
  49. package/dist/paths.js +62 -29
  50. package/dist/refs.js +8 -0
  51. package/dist/resolve.d.ts +3 -0
  52. package/dist/resolve.js +38 -0
  53. package/dist/revalidate.d.ts +4 -0
  54. package/dist/revalidate.js +6 -1
  55. package/dist/shared.d.ts +46 -0
  56. package/dist/shared.js +36 -0
  57. package/dist/site.d.ts +16 -2
  58. package/dist/site.js +92 -39
  59. package/dist/sql-space.d.ts +1 -1
  60. package/dist/sql-space.js +16 -13
  61. package/dist/sql.js +95 -70
  62. package/dist/store.d.ts +66 -32
  63. package/dist/store.js +205 -90
  64. package/dist/supabase-entry-store.d.ts +30 -9
  65. package/dist/supabase-entry-store.js +360 -178
  66. package/dist/supabase-store.d.ts +21 -9
  67. package/dist/supabase-store.js +202 -111
  68. package/dist/zones.d.ts +6 -2
  69. package/dist/zones.js +8 -2
  70. package/package.json +1 -1
package/dist/localize.js CHANGED
@@ -1,29 +1,8 @@
1
- // Per-field locale fallback for the two overlays the stores carry
2
- // (spec 2026-09-05 §2): `pages.i18n[locale].title` and
3
- // `entries.i18n[locale]`. Slugs are paths.ts's business (segmentFor);
4
- // this module owns titles and entry fields. Pure and framework-free.
5
- import { segmentFor } from "./paths.js";
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
- const slug = typeof schema.slug === "string" ? schema.slug : schema.slug[set.default];
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
- const i18n = typeof schema.slug === "string" ? null : Object.fromEntries(Object.entries(schema.slug).map(([l, s]) => [l, { slug: s }]));
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 { kind: "locked", component: p.component };
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
- type SlugI18n = Record<string, {
21
- slug?: string;
22
- }> | null | undefined;
23
- /** The locale's own segment, or the default locale's (per-field fallback). */
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
- slug: string;
41
- i18n: Record<string, {
42
- slug?: string;
43
- title?: string;
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
- * localized ancestor chain joined. THE derivation — pagePathRows,
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
- fields: Record<string, unknown>;
60
- i18n: Record<string, Record<string, unknown>> | null;
61
- locales: string[] | null;
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
- * The sibling index predicate in sql.ts (`entries_sibling_slug_key`)
67
- * mirrors this rule exactly, so Postgres and memory agree on which
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
- export declare function entrySegment(entry: EntryLike, locale: string, set: LocaleSet): string | null;
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). Pure and
2
- // framework-free: both store adapters and the tree query call these, so
3
- // the `paths` index and every derived `path` value agree by construction.
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 locale's own segment, or the default locale's (per-field fallback). */
18
- export function segmentFor(node, locale, set) {
19
- if (locale === set.default)
20
- return node.slug;
21
- const own = node.i18n?.[locale]?.slug;
22
- return typeof own === "string" && own.length > 0 ? own : node.slug;
23
- }
24
- /** null = every supported locale; otherwise the intersection. */
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
- const isHome = (node, homePageSlug) => node.parentId === null && node.slug === homePageSlug;
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
- * localized ancestor chain joined. THE derivation — pagePathRows,
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 isHome(node, opts.homePageSlug)
52
- ? "/"
53
- : joinPath(chainOf(node, byId).map((n) => segmentFor(n, locale, opts.locales)));
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 localesOf(target, opts.locales)) {
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
- * The sibling index predicate in sql.ts (`entries_sibling_slug_key`)
74
- * mirrors this rule exactly, so Postgres and memory agree on which
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
- export function entrySegment(entry, locale, set) {
81
- const own = locale === set.default ? undefined : entry.i18n?.[locale]?.slug;
82
- return typeof own === "string" && own.length > 0 ? own : slugOf(entry.fields);
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 localesOf(target, set)) {
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, set));
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: pagePath(r, byId, locale, opts),
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) {
@@ -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;
@@ -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
- if (page?.publishedVersionId)
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 };
@@ -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
+ }