smoodly 0.0.12 → 0.0.14

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 (159) hide show
  1. package/README.md +294 -40
  2. package/dist/admin/editor/CanvasOverlay.d.ts +6 -2
  3. package/dist/admin/editor/CanvasOverlay.js +9 -11
  4. package/dist/admin/editor/EditorView.js +135 -28
  5. package/dist/admin/editor/FloatingToolbar.d.ts +16 -0
  6. package/dist/admin/editor/FloatingToolbar.js +14 -0
  7. package/dist/admin/editor/InlineRichtext.d.ts +9 -0
  8. package/dist/admin/editor/InlineRichtext.js +50 -0
  9. package/dist/admin/editor/Outline.d.ts +16 -10
  10. package/dist/admin/editor/Outline.js +13 -10
  11. package/dist/admin/editor/PageInspector.d.ts +21 -0
  12. package/dist/admin/editor/PageInspector.js +24 -0
  13. package/dist/admin/editor/PageSettings.d.ts +1 -3
  14. package/dist/admin/editor/PageSettings.js +7 -6
  15. package/dist/admin/editor/SeoSettings.d.ts +7 -0
  16. package/dist/admin/editor/SeoSettings.js +15 -0
  17. package/dist/admin/editor/inline-dom.d.ts +27 -0
  18. package/dist/admin/editor/inline-dom.js +43 -0
  19. package/dist/admin/editor/inline-session.d.ts +30 -0
  20. package/dist/admin/editor/inline-session.js +19 -0
  21. package/dist/admin/editor/protocol.d.ts +22 -1
  22. package/dist/admin/editor/useCanvasBridge.d.ts +8 -4
  23. package/dist/admin/editor/useCanvasBridge.js +12 -7
  24. package/dist/admin/editor/useInlineSession.d.ts +46 -0
  25. package/dist/admin/editor/useInlineSession.js +244 -0
  26. package/dist/admin/field-paths.d.ts +13 -0
  27. package/dist/admin/field-paths.js +49 -0
  28. package/dist/admin/forms/FieldWidget.d.ts +7 -2
  29. package/dist/admin/forms/FieldWidget.js +35 -43
  30. package/dist/admin/forms/ListField.d.ts +10 -0
  31. package/dist/admin/forms/ListField.js +42 -0
  32. package/dist/admin/forms/MediaWidget.js +10 -3
  33. package/dist/admin/forms/RichtextElementCard.d.ts +3 -0
  34. package/dist/admin/forms/RichtextElementCard.js +37 -0
  35. package/dist/admin/forms/RichtextImageCard.d.ts +3 -0
  36. package/dist/admin/forms/RichtextImageCard.js +42 -0
  37. package/dist/admin/forms/RichtextInsertMenu.d.ts +6 -0
  38. package/dist/admin/forms/RichtextInsertMenu.js +6 -0
  39. package/dist/admin/forms/RichtextPopovers.d.ts +7 -0
  40. package/dist/admin/forms/RichtextPopovers.js +25 -0
  41. package/dist/admin/forms/RichtextToolbar.d.ts +4 -2
  42. package/dist/admin/forms/RichtextToolbar.js +4 -2
  43. package/dist/admin/forms/RichtextWidget.d.ts +4 -1
  44. package/dist/admin/forms/RichtextWidget.js +23 -120
  45. package/dist/admin/forms/elements-context.d.ts +7 -0
  46. package/dist/admin/forms/elements-context.js +13 -0
  47. package/dist/admin/forms/reveal.d.ts +5 -0
  48. package/dist/admin/forms/reveal.js +47 -0
  49. package/dist/admin/forms/richtext-extensions.d.ts +8 -0
  50. package/dist/admin/forms/richtext-extensions.js +11 -0
  51. package/dist/admin/forms/richtext-form-context.d.ts +16 -0
  52. package/dist/admin/forms/richtext-form-context.js +18 -0
  53. package/dist/admin/forms/richtext-insert.d.ts +38 -0
  54. package/dist/admin/forms/richtext-insert.js +37 -0
  55. package/dist/admin/forms/richtext-nodes.d.ts +37 -0
  56. package/dist/admin/forms/richtext-nodes.js +85 -0
  57. package/dist/admin/forms/richtext-paste.js +5 -0
  58. package/dist/admin/forms/tabs.d.ts +3 -0
  59. package/dist/admin/forms/tabs.js +6 -0
  60. package/dist/admin/forms/useRichtextEditor.d.ts +51 -0
  61. package/dist/admin/forms/useRichtextEditor.js +281 -0
  62. package/dist/admin/next/bridge.js +51 -20
  63. package/dist/admin/ops-impl.js +85 -19
  64. package/dist/admin/richtext-doc.d.ts +3 -0
  65. package/dist/admin/richtext-doc.js +12 -0
  66. package/dist/admin/serialize.d.ts +11 -0
  67. package/dist/admin/serialize.js +18 -1
  68. package/dist/admin/shell/AdminApp.js +2 -1
  69. package/dist/admin/shell/EntriesList.js +34 -12
  70. package/dist/admin/shell/EntryForm.js +22 -15
  71. package/dist/admin/shell/IconRail.js +6 -5
  72. package/dist/admin/shell/ListView.d.ts +9 -2
  73. package/dist/admin/shell/ListView.js +7 -6
  74. package/dist/admin/shell/LoginView.js +2 -2
  75. package/dist/admin/shell/PagesList.js +53 -24
  76. package/dist/admin/shell/SharedForm.js +17 -11
  77. package/dist/admin/shell/SharedList.js +5 -3
  78. package/dist/admin/shell/entries-list.d.ts +4 -0
  79. package/dist/admin/shell/entries-list.js +6 -0
  80. package/dist/admin/shell/pages-tree.d.ts +24 -0
  81. package/dist/admin/shell/pages-tree.js +59 -1
  82. package/dist/admin/tree-ops.d.ts +11 -1
  83. package/dist/admin/tree-ops.js +32 -1
  84. package/dist/admin/ui/LocaleSwitcher.d.ts +10 -0
  85. package/dist/admin/ui/LocaleSwitcher.js +28 -8
  86. package/dist/admin/ui/Modal.js +14 -2
  87. package/dist/admin/ui/ResizeHandle.d.ts +1 -0
  88. package/dist/admin/ui/ResizeHandle.js +127 -0
  89. package/dist/admin/ui/primitives.d.ts +38 -4
  90. package/dist/admin/ui/primitives.js +35 -10
  91. package/dist/admin/ui/resize.d.ts +16 -0
  92. package/dist/admin/ui/resize.js +23 -0
  93. package/dist/admin/ui/sortable.d.ts +23 -0
  94. package/dist/admin/ui/sortable.js +57 -0
  95. package/dist/admin/ui/theme.d.ts +3 -1
  96. package/dist/admin/ui/theme.js +527 -165
  97. package/dist/admin/ui/useSortable.d.ts +35 -0
  98. package/dist/admin/ui/useSortable.js +220 -0
  99. package/dist/admin/validate.d.ts +6 -1
  100. package/dist/admin/validate.js +52 -12
  101. package/dist/collections.d.ts +6 -5
  102. package/dist/config.d.ts +1 -0
  103. package/dist/config.js +36 -0
  104. package/dist/entry-store.d.ts +23 -3
  105. package/dist/entry-store.js +31 -5
  106. package/dist/field-defaults.d.ts +8 -0
  107. package/dist/field-defaults.js +18 -0
  108. package/dist/fields.d.ts +30 -1
  109. package/dist/fields.js +33 -3
  110. package/dist/image/SmoodlyImage.d.ts +4 -1
  111. package/dist/image/SmoodlyImage.js +2 -2
  112. package/dist/index.d.ts +16 -7
  113. package/dist/index.js +10 -4
  114. package/dist/marks.d.ts +53 -0
  115. package/dist/marks.js +43 -0
  116. package/dist/media.d.ts +3 -2
  117. package/dist/media.js +48 -6
  118. package/dist/next/index.d.ts +2 -0
  119. package/dist/next/index.js +1 -0
  120. package/dist/next/meta.d.ts +7 -3
  121. package/dist/next/meta.js +18 -9
  122. package/dist/next/page-renderer.js +1 -1
  123. package/dist/next/queries.d.ts +36 -0
  124. package/dist/next/queries.js +87 -0
  125. package/dist/page-tree.d.ts +3 -0
  126. package/dist/page-tree.js +6 -0
  127. package/dist/page.d.ts +11 -3
  128. package/dist/page.js +26 -0
  129. package/dist/pairing.d.ts +2 -1
  130. package/dist/pairing.js +2 -9
  131. package/dist/refs.d.ts +2 -2
  132. package/dist/refs.js +33 -12
  133. package/dist/render.d.ts +14 -7
  134. package/dist/render.js +4 -3
  135. package/dist/resolve.d.ts +12 -2
  136. package/dist/resolve.js +16 -7
  137. package/dist/revalidate.d.ts +6 -0
  138. package/dist/revalidate.js +6 -0
  139. package/dist/richtext/index.d.ts +12 -0
  140. package/dist/richtext/index.js +9 -0
  141. package/dist/richtext-render.d.ts +22 -2
  142. package/dist/richtext-render.js +40 -13
  143. package/dist/richtext-walk.d.ts +44 -0
  144. package/dist/richtext-walk.js +46 -0
  145. package/dist/schema.d.ts +1 -1
  146. package/dist/schema.js +13 -1
  147. package/dist/section-wrapper.js +2 -1
  148. package/dist/shared.d.ts +7 -7
  149. package/dist/site.d.ts +25 -1
  150. package/dist/site.js +89 -22
  151. package/dist/store.d.ts +7 -1
  152. package/dist/store.js +13 -4
  153. package/dist/supabase-entry-store.d.ts +1 -0
  154. package/dist/supabase-entry-store.js +34 -11
  155. package/dist/supabase-store.d.ts +1 -0
  156. package/dist/supabase-store.js +14 -1
  157. package/dist/toolset.d.ts +7 -1
  158. package/dist/toolset.js +13 -4
  159. package/package.json +6 -1
@@ -0,0 +1,87 @@
1
+ // The in-app read API (spec 2026-09-11): five typed queries over the
2
+ // framework-free site layer, each draft-aware inside the editor preview
3
+ // and cached under a tag outside it — the pattern getSmoodlyShared set.
4
+ // Lives in next/ because draftMode and unstable_cache are Next's; an
5
+ // Astro glue wraps the same site reads over its own cache seam.
6
+ import { unstable_cache } from "next/cache";
7
+ import { draftMode } from "next/headers";
8
+ import { connection } from "next/server";
9
+ import { collectionTag, entryTag, pageTag, pagesTreeTag, sharedTag } from "../revalidate.js";
10
+ /** JSON with object keys sorted at every level and `undefined` dropped, so two spellings of one query share a unit. */
11
+ function stable(value) {
12
+ if (Array.isArray(value))
13
+ return value.map(stable);
14
+ if (value !== null && typeof value === "object") {
15
+ return Object.fromEntries(Object.entries(value)
16
+ .filter(([, v]) => v !== undefined)
17
+ .sort(([a], [b]) => (a < b ? -1 : 1))
18
+ .map(([k, v]) => [k, stable(v)]));
19
+ }
20
+ return value;
21
+ }
22
+ /** A stable key for an entries query: only what was given, normalized. */
23
+ function entriesKey(o) {
24
+ const { locale: _locale, ...rest } = o;
25
+ return JSON.stringify(stable(rest));
26
+ }
27
+ export function createSmoodlyQueries(options) {
28
+ const { site } = options;
29
+ const sharedByName = new Map((options.shared ?? []).map((s) => [s.name, s]));
30
+ const localeOf = (o) => o?.locale ?? site.locales.default;
31
+ /** Every query gates on the request (never a build prerender), then reads live in draft mode or cached under `tags`. */
32
+ async function run(key, tags, read) {
33
+ await connection();
34
+ const { isEnabled: draft } = await draftMode();
35
+ if (draft)
36
+ return read(true);
37
+ return unstable_cache(() => read(false), ["smoodly-q", ...key], { tags })();
38
+ }
39
+ /** The tag needs the id: a path is located first, uncached, as the renderer does. */
40
+ async function idOf(kind, locale, pathOrId) {
41
+ if (!pathOrId.startsWith("/"))
42
+ return pathOrId;
43
+ await connection();
44
+ const target = await site.locate(locale, pathOrId);
45
+ return target?.kind === kind ? target.id : null;
46
+ }
47
+ return {
48
+ getPageTree: (o) => {
49
+ const locale = localeOf(o);
50
+ return run(["pageTree", locale], [pagesTreeTag()], (draft) => site.getPagesTree({ locale, draft }));
51
+ },
52
+ async getPage(pathOrId, o) {
53
+ const locale = localeOf(o);
54
+ const id = await idOf("page", locale, pathOrId);
55
+ if (!id)
56
+ return null;
57
+ return run(["page", locale, pathOrId], [pageTag(id)], (draft) => site.getPage(pathOrId, { locale, draft }));
58
+ },
59
+ getEntries: (collection, o = {}) => {
60
+ const locale = localeOf(o);
61
+ const { locale: _locale, ...query } = o;
62
+ return run(["entries", collection, locale, entriesKey(o)], [collectionTag(collection)], (draft) => site.getEntries(collection, { ...query, locale, draft }));
63
+ },
64
+ async getEntry(collection, idOrPath, o) {
65
+ const locale = localeOf(o);
66
+ const id = await idOf("entry", locale, idOrPath);
67
+ if (!id)
68
+ return null;
69
+ return run(["entry", collection, locale, idOrPath], [entryTag(id)], async (draft) => {
70
+ const hit = await site.getEntry(collection, idOrPath, { locale, draft });
71
+ // The site layer also answers a bare root slug, but the unit would sit under
72
+ // a tag no op ever fires — so a slug is refused here rather than cached stale.
73
+ if (hit && !idOrPath.startsWith("/") && hit.entry.id !== idOrPath) {
74
+ throw new Error(`smoodly: getEntry("${collection}", "${idOrPath}") takes an id or a path — for a slug, pass the entry's path.`);
75
+ }
76
+ return hit;
77
+ });
78
+ },
79
+ async getShared(name, o) {
80
+ const locale = localeOf(o);
81
+ const item = sharedByName.get(name);
82
+ if (!item)
83
+ throw new Error(`smoodly: no shared item named "${name}".`);
84
+ return run(["shared", name, locale], [sharedTag(name)], (draft) => site.getShared(item, { locale, draft }));
85
+ },
86
+ };
87
+ }
@@ -0,0 +1,3 @@
1
+ export declare function filterPageTree<T extends {
2
+ children: T[];
3
+ }>(items: T[], keep: (item: T) => boolean): T[];
@@ -0,0 +1,6 @@
1
+ // The one rule for pruning a navigation (spec 2026-09-11 §4): keep an
2
+ // item when `keep` says so and recurse; a dropped parent drops its
3
+ // subtree, since a nav cannot show orphans. Pure; the input is untouched.
4
+ export function filterPageTree(items, keep) {
5
+ return items.filter(keep).map((item) => ({ ...item, children: filterPageTree(item.children, keep) }));
6
+ }
package/dist/page.d.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import type { ZonePolicy } from "./zones.ts";
2
- export type PageSchema<Zones extends Record<string, unknown> = Record<string, unknown>> = {
2
+ import { type Descriptor } from "./fields.ts";
3
+ import type { BuilderMap } from "./schema.ts";
4
+ export type PageSchema<Zones extends Record<string, unknown> = Record<string, unknown>, F extends BuilderMap = BuilderMap> = {
3
5
  kind: "page";
4
6
  name: string;
5
7
  title: string;
@@ -12,16 +14,22 @@ export type PageSchema<Zones extends Record<string, unknown> = Record<string, un
12
14
  slug?: string | Record<string, string>;
13
15
  zones: Record<keyof Zones & string, ZonePolicy>;
14
16
  meta: Record<string, unknown>;
17
+ /** The template's own page-level fields (spec 2026-09-10 page fields §1):
18
+ * stored in the tree per locale, typed on the view's `page.fields`. */
19
+ fields: Record<string, Descriptor>;
15
20
  /** phantom: zone names for Props inference */
16
21
  readonly __zones?: Zones;
22
+ /** phantom: field builders for Props inference */
23
+ readonly __fields?: F;
17
24
  };
18
- export declare function pageSchema<Zones extends Record<string, object>>(input: {
25
+ export declare function pageSchema<Zones extends Record<string, object>, F extends BuilderMap = {}>(input: {
19
26
  name: string;
20
27
  title: string;
21
28
  slug?: string | Record<string, string>;
22
29
  zones: Zones;
23
30
  meta?: Record<string, unknown>;
24
- }): PageSchema<Zones>;
31
+ fields?: F;
32
+ }): PageSchema<Zones, F>;
25
33
  /** The default-locale segment of a fixed slug — what the admin lists and
26
34
  * what today's root-level lookups use. */
27
35
  export declare function fixedSlug(schema: {
package/dist/page.js CHANGED
@@ -1,3 +1,25 @@
1
+ import { describePath, findMatch } from "./fields.js";
2
+ /** What a page field may be: a settings form, never a ref, a list or a document (spec 2026-09-10 page fields §1). */
3
+ const PAGE_FIELD_TYPES = new Set(["text", "link", "date", "number", "boolean", "select", "radio", "checkboxes", "image", "video", "file", "object"]);
4
+ /** The descriptor at a dotted path below `d` (object subfields only — page fields hold no lists). */
5
+ function descriptorAt(d, path) {
6
+ let cur = d;
7
+ for (const seg of path.split("."))
8
+ cur = cur.fields[seg];
9
+ return cur;
10
+ }
11
+ function assertPageField(page, key, d) {
12
+ const bad = findMatch(d, (sub) => !PAGE_FIELD_TYPES.has(sub.type), key);
13
+ if (bad !== null) {
14
+ const at = bad === key ? d : descriptorAt(d, bad.slice(key.length + 1));
15
+ const kind = at.type === "ref" || at.type === "refList" ? "refs" : at.type;
16
+ throw new Error(`smoodly: page "${page}": field ${describePath(bad)} — ${kind} cannot live in page fields.`);
17
+ }
18
+ const loc = findMatch(d, (sub) => sub.localized === true, key);
19
+ if (loc !== null) {
20
+ throw new Error(`smoodly: page "${page}": field ${describePath(loc)} uses .localized(), but page fields are per locale already.`);
21
+ }
22
+ }
1
23
  /** Policies arrive as z.* results (possibly carrying builder methods) — store plain data only. */
2
24
  function normalizePolicy(p) {
3
25
  switch (p.kind) {
@@ -31,6 +53,9 @@ export function pageSchema(input) {
31
53
  throw new Error(`smoodly: page "${input.name}" declares an invalid slug — a fixed slug must be a non-empty string or a per-locale object of them.`);
32
54
  }
33
55
  }
56
+ const fields = Object.fromEntries(Object.entries(input.fields ?? {}).map(([k, b]) => [k, b.descriptor]));
57
+ for (const [key, d] of Object.entries(fields))
58
+ assertPageField(input.name, key, d);
34
59
  return {
35
60
  kind: "page",
36
61
  name: input.name,
@@ -38,6 +63,7 @@ export function pageSchema(input) {
38
63
  ...(input.slug !== undefined ? { slug: input.slug } : {}),
39
64
  zones: Object.fromEntries(Object.entries(input.zones).map(([k, p]) => [k, normalizePolicy(p)])),
40
65
  meta: input.meta ?? {},
66
+ fields,
41
67
  };
42
68
  }
43
69
  /** The default-locale segment of a fixed slug — what the admin lists and
package/dist/pairing.d.ts CHANGED
@@ -9,9 +9,10 @@ import type { PageContext, ZoneContent } from "./render.tsx";
9
9
  export type Props<S> = S extends {
10
10
  kind: "page";
11
11
  __zones?: infer Z extends Record<string, unknown>;
12
+ __fields?: infer F extends BuilderMap;
12
13
  } ? {
13
14
  zones: Record<keyof Z & string, ZoneContent>;
14
- page: PageContext;
15
+ page: PageContext<NamespaceValues<F>>;
15
16
  } : S extends {
16
17
  __f?: infer F extends BuilderMap;
17
18
  __s?: infer St extends BuilderMap;
package/dist/pairing.js CHANGED
@@ -1,14 +1,7 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- function styleDefaultsOf(styles) {
3
- const out = {};
4
- for (const [key, d] of Object.entries(styles)) {
5
- if (d.default !== undefined)
6
- out[key] = d.default;
7
- }
8
- return out;
9
- }
2
+ import { defaultsOf } from "./field-defaults.js";
10
3
  function pair(schema, View) {
11
- const defaults = styleDefaultsOf(schema.styles);
4
+ const defaults = defaultsOf(schema.styles);
12
5
  const Component = (props) => {
13
6
  const { styles: styleOverrides, ...fields } = props ?? {};
14
7
  const styles = { ...defaults, ...styleOverrides };
package/dist/refs.d.ts CHANGED
@@ -6,7 +6,7 @@ export type RefEdge = {
6
6
  targetCollection: string;
7
7
  targetId: string;
8
8
  };
9
- export declare function collectEntryRefs(fields: Record<string, unknown>, schema: CollectionSchema<any>): RefEdge[];
9
+ export declare function collectEntryRefs(fields: Record<string, unknown>, schema: CollectionSchema<any>, elements?: SectionLike[]): RefEdge[];
10
10
  /** A section schema, or anything carrying one (a paired component). */
11
11
  export type SectionLike = {
12
12
  name: string;
@@ -19,4 +19,4 @@ export type SectionLike = {
19
19
  styles?: Record<string, Descriptor>;
20
20
  };
21
21
  };
22
- export declare function collectTreeRefs(tree: PageTree, sections: SectionLike[]): RefEdge[];
22
+ export declare function collectTreeRefs(tree: PageTree, sections: SectionLike[], elements?: SectionLike[]): RefEdge[];
package/dist/refs.js CHANGED
@@ -6,32 +6,52 @@
6
6
  // this?) — forward resolution stays resolveTree's job.
7
7
  import { sharedLink } from "./shared.js";
8
8
  import { ASSET_COLLECTION, isMediaValue, MEDIA_TYPES } from "./media.js";
9
+ import { ELEMENT_NODE, IMAGE_NODE, elementOf, elementSchemas, elementSets, richtextBlocks } from "./richtext-walk.js";
9
10
  function targetOf(d) {
10
11
  const t = d.target?.();
11
12
  if (!t?.name)
12
13
  throw new Error("smoodly: ref descriptor has no resolvable target.");
13
14
  return t.name;
14
15
  }
15
- /** Media edges walk into containers (spec 2026-09-08 media §8); `field`
16
- * stays the top-level key so the edge is addressable like a ref. */
17
- function collectMedia(field, value, d, out) {
16
+ /** Media edges walk into containers (spec 2026-09-08 media §8) and into
17
+ * richtext documents (block nodes §2); `field` stays the top-level key
18
+ * so the edge is addressable like a ref. */
19
+ function collectMedia(field, value, d, out, elements) {
18
20
  if (value == null)
19
21
  return;
20
22
  if (MEDIA_TYPES.has(d.type)) {
21
23
  if (isMediaValue(value))
22
24
  out.push({ field, targetCollection: ASSET_COLLECTION, targetId: value.asset });
23
25
  }
26
+ else if (d.type === "richtext") {
27
+ for (const node of richtextBlocks(value)) {
28
+ if (node.type === IMAGE_NODE) {
29
+ const image = node.attrs.image;
30
+ if (isMediaValue(image))
31
+ out.push({ field, targetCollection: ASSET_COLLECTION, targetId: image.asset });
32
+ }
33
+ else if (node.type === ELEMENT_NODE) {
34
+ const schema = elementOf(node, elements);
35
+ if (!schema)
36
+ continue;
37
+ for (const set of elementSets(node, schema)) {
38
+ for (const [k, sub] of Object.entries(set.descriptors))
39
+ collectMedia(field, set.values[k], sub, out, elements);
40
+ }
41
+ }
42
+ }
43
+ }
24
44
  else if (d.type === "object" && typeof value === "object" && !Array.isArray(value)) {
25
45
  for (const [k, sub] of Object.entries(d.fields)) {
26
- collectMedia(field, value[k], sub, out);
46
+ collectMedia(field, value[k], sub, out, elements);
27
47
  }
28
48
  }
29
49
  else if (d.type === "list" && Array.isArray(value)) {
30
50
  for (const v of value)
31
- collectMedia(field, v, d.item, out);
51
+ collectMedia(field, v, d.item, out, elements);
32
52
  }
33
53
  }
34
- function collectFrom(values, descriptors, out) {
54
+ function collectFrom(values, descriptors, out, elements) {
35
55
  for (const [field, d] of Object.entries(descriptors)) {
36
56
  const value = values[field];
37
57
  if (value == null)
@@ -47,16 +67,17 @@ function collectFrom(values, descriptors, out) {
47
67
  }
48
68
  else {
49
69
  // Refs are top-level only (f.list/f.object refuse them); media is not.
50
- collectMedia(field, value, d, out);
70
+ collectMedia(field, value, d, out, elements);
51
71
  }
52
72
  }
53
73
  }
54
- export function collectEntryRefs(fields, schema) {
74
+ export function collectEntryRefs(fields, schema, elements) {
55
75
  const out = [];
56
- collectFrom(fields, schema.fields, out);
76
+ collectFrom(fields, schema.fields, out, elementSchemas(elements));
57
77
  return out;
58
78
  }
59
- export function collectTreeRefs(tree, sections) {
79
+ export function collectTreeRefs(tree, sections, elements) {
80
+ const els = elementSchemas(elements);
60
81
  const byName = new Map(sections.map((s) => {
61
82
  const schema = "schema" in s ? s.schema : s;
62
83
  return [schema.name, schema];
@@ -74,9 +95,9 @@ export function collectTreeRefs(tree, sections) {
74
95
  const schema = byName.get(node.type);
75
96
  if (!schema)
76
97
  continue; // fail-soft, same as renderPage
77
- collectFrom(node.fields, schema.fields, out);
98
+ collectFrom(node.fields, schema.fields, out, els);
78
99
  if (schema.styles && node.styles)
79
- collectFrom(node.styles, schema.styles, out);
100
+ collectFrom(node.styles, schema.styles, out, els);
80
101
  }
81
102
  }
82
103
  return out;
package/dist/render.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { ComponentType, ReactElement } from "react";
2
2
  import type { PageSchema } from "./page.ts";
3
+ import type { BuilderMap, NamespaceValues } from "./schema.ts";
3
4
  export type TreeNode = {
4
5
  id: string;
5
6
  type: string;
@@ -15,26 +16,32 @@ export type TreeNode = {
15
16
  export type PageTree = {
16
17
  zones: Record<string, TreeNode[]>;
17
18
  meta?: Record<string, unknown>;
19
+ fields?: Record<string, unknown>;
18
20
  };
19
21
  /** Where a rendered page sits in the tree — what a view needs for
20
22
  * breadcrumbs and sub-navigation without a query (spec 2026-09-05 §5).
21
23
  * `path` is the locale-relative CMS path (what the content API returns);
22
24
  * `url` is that path through the app's `href` seam — `/fi/yritys` under
23
25
  * next-intl, `/yritys` without it — so a page shape links the same way
24
- * in both layouts. */
26
+ * in both layouts. `fields` is the linked page's own page fields (spec
27
+ * 2026-09-10 page fields §3): the published tree's outside draft mode,
28
+ * raw (no asset resolution), defaults applied, `{}` for a folder. */
25
29
  export type PageLink = {
26
30
  title: string;
27
31
  path: string;
28
32
  hasPage: boolean;
33
+ fields: Record<string, unknown>;
29
34
  };
30
35
  export type PageContextLink = PageLink & {
31
36
  url: string;
32
37
  };
33
- export type PageContext = {
38
+ export type PageContext<Fields = Record<string, unknown>> = {
34
39
  id: string;
35
40
  title: string;
36
41
  path: string;
37
42
  url: string;
43
+ /** The page's own fields, typed from its schema on the view; resolved, defaults applied. */
44
+ fields: Fields;
38
45
  /** Root first, parent last. */
39
46
  ancestors: ReadonlyArray<PageContextLink>;
40
47
  children: ReadonlyArray<PageContextLink>;
@@ -58,14 +65,14 @@ export type ZoneContent = {
58
65
  export declare function Zone({ of: content }: {
59
66
  of: ZoneContent;
60
67
  }): import("react").JSX.Element | null;
61
- export type PageViewProps<Zones extends Record<string, unknown>> = {
68
+ export type PageViewProps<Zones extends Record<string, unknown>, Fields = Record<string, unknown>> = {
62
69
  zones: Record<keyof Zones & string, ZoneContent>;
63
- page: PageContext;
70
+ page: PageContext<Fields>;
64
71
  };
65
- export type PairedPage<Zones extends Record<string, unknown>> = ((props: PageViewProps<Zones>) => ReactElement) & {
66
- schema: PageSchema<Zones>;
72
+ export type PairedPage<Zones extends Record<string, unknown>, F extends BuilderMap = BuilderMap> = ((props: PageViewProps<Zones, NamespaceValues<F>>) => ReactElement) & {
73
+ schema: PageSchema<Zones, F>;
67
74
  };
68
- export declare function page<Zones extends Record<string, unknown>>(schema: PageSchema<Zones>, View: ComponentType<PageViewProps<Zones>>): PairedPage<Zones>;
75
+ export declare function page<Zones extends Record<string, unknown>, F extends BuilderMap = {}>(schema: PageSchema<Zones, F>, View: ComponentType<PageViewProps<Zones, NamespaceValues<F>>>): PairedPage<Zones, F>;
69
76
  export type Registry = {
70
77
  sections?: AnyPaired[];
71
78
  elements?: AnyPaired[];
package/dist/render.js CHANGED
@@ -1,10 +1,11 @@
1
1
  import { jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { EMPTY_ATTR, ZONE_ATTR } from "./marks.js";
2
3
  /** The `page` prop of a view rendered outside the site (a test, a preview
3
4
  * of a shape): a real object, so a view never guards an optional prop, but
4
5
  * no position in the tree. Frozen: one singleton shared by every such
5
6
  * render, so a view that pushed onto it would leak into the next. */
6
7
  export const EMPTY_PAGE_CONTEXT = Object.freeze({
7
- id: "", title: "", path: "/", url: "/", ancestors: Object.freeze([]), children: Object.freeze([]),
8
+ id: "", title: "", path: "/", url: "/", fields: Object.freeze({}), ancestors: Object.freeze([]), children: Object.freeze([]),
8
9
  });
9
10
  export function Zone({ of: content }) {
10
11
  // A malformed tree (bad data, a client that skipped isPageTree) must not
@@ -13,7 +14,7 @@ export function Zone({ of: content }) {
13
14
  if (!nodes.length) {
14
15
  // In editor mode an empty zone still needs a measurable box for the
15
16
  // overlay's "+ add" affordance; in production it renders nothing.
16
- return content?.editor ? (_jsx("div", { "data-smoodly-zone": content.name, "data-smoodly-empty": "", style: { minHeight: 48 } })) : null;
17
+ return content?.editor ? (_jsx("div", { [ZONE_ATTR]: content.name, [EMPTY_ATTR]: "", style: { minHeight: 48 } })) : null;
17
18
  }
18
19
  return (_jsx(_Fragment, { children: nodes.map((node) => {
19
20
  const Component = content.components.get(node.type);
@@ -44,5 +45,5 @@ export function renderPage(Page, tree, registry, options) {
44
45
  zone,
45
46
  { name: zone, nodes: tree.zones?.[zone] ?? [], components, editor: options?.editor ?? false },
46
47
  ]));
47
- return _jsx(Page, { zones: zones, page: options?.page ?? EMPTY_PAGE_CONTEXT });
48
+ return _jsx(Page, { zones: zones, page: (options?.page ?? EMPTY_PAGE_CONTEXT) });
48
49
  }
package/dist/resolve.d.ts CHANGED
@@ -3,6 +3,8 @@ import type { CollectionSchema } from "./collections.ts";
3
3
  import type { PageTree } from "./render.tsx";
4
4
  import { type SharedSchema } from "./shared.ts";
5
5
  import { type AssetRecord } from "./media.ts";
6
+ import { type ElementSchemas } from "./richtext-walk.ts";
7
+ import type { SectionLike } from "./refs.ts";
6
8
  export type EntryFetcher = (collection: string, ids: string[]) => Promise<Record<string, Record<string, unknown>>>;
7
9
  export type AssetFetcher = (ids: string[]) => Promise<Record<string, AssetRecord>>;
8
10
  type SchemaLike = {
@@ -26,20 +28,28 @@ type ResolveOptions = {
26
28
  assets?: AssetFetcher;
27
29
  /** The registered shared items a placement node may name (spec 2026-09-07 §4). */
28
30
  shared?: SharedSchema[];
31
+ /** Registered Elements a richtext document may embed (spec 2026-09-08 block nodes §2). */
32
+ elements?: SectionLike[];
29
33
  /** Optional safety valve on chain length; unlimited by default. */
30
34
  maxDepth?: number;
35
+ /** The page's own field descriptors (spec 2026-09-10 page fields §3): `tree.fields` resolves like a node's fields, and `tree.meta.ogImage` as an image. */
36
+ page?: {
37
+ fields: Record<string, Descriptor>;
38
+ };
31
39
  };
32
40
  export declare function resolveTree(tree: PageTree, options: ResolveOptions): Promise<PageTree>;
33
41
  /** One value set's media, resolved in place. Exported for the site layer's shared-item styles. */
34
- export declare function resolveMedia(values: Record<string, unknown>, descriptors: Record<string, Descriptor>, fetch: AssetFetcher): Promise<void>;
42
+ export declare function resolveMedia(values: Record<string, unknown>, descriptors: Record<string, Descriptor>, fetch: AssetFetcher, elements?: ElementSchemas): Promise<void>;
35
43
  /** Many value sets, one asset round (plus one for posters): the tree's
36
44
  * nodes and every entry hydrated through a ref, or a list page's items.
37
45
  * Exported for the site layer's content API. */
38
- export declare function resolveMediaSets(sets: MediaSet[], fetch: AssetFetcher): Promise<void>;
46
+ export declare function resolveMediaSets(sets: MediaSet[], fetch: AssetFetcher, elements?: ElementSchemas): Promise<void>;
39
47
  export type ResolveFieldsOptions = {
40
48
  collections: CollectionSchema<any>[];
41
49
  fetch: EntryFetcher;
42
50
  assets?: AssetFetcher;
51
+ /** Registered Elements a richtext document may embed (spec 2026-09-08 block nodes §2). */
52
+ elements?: SectionLike[];
43
53
  maxDepth?: number;
44
54
  };
45
55
  /** One entry's fields, refs hydrated — the entry page's counterpart of
package/dist/resolve.js CHANGED
@@ -8,6 +8,9 @@
8
8
  // construction and the output stays JSON-serializable.
9
9
  import { sharedLink, SHARED_NODE_TYPE, STYLES_KEY } from "./shared.js";
10
10
  import { applyAssets, collectAssetIds } from "./media.js";
11
+ import { elementSchemas } from "./richtext-walk.js";
12
+ /** The built-in SEO set's one media value (spec 2026-09-10 page fields §2). */
13
+ const META_DESCRIPTORS = { ogImage: { type: "image" } };
11
14
  function targetOf(d) {
12
15
  const t = d.target?.();
13
16
  if (!t?.name)
@@ -39,6 +42,8 @@ export async function resolveTree(tree, options) {
39
42
  collectTasks(node.fields, schema.fields, tasks);
40
43
  }
41
44
  }
45
+ if (options.page && resolved.fields)
46
+ collectTasks(resolved.fields, options.page.fields, tasks);
42
47
  const hydrated = await runResolution(tasks, { collections: options.collections, fetch: options.fetch, maxDepth });
43
48
  if (options.assets) {
44
49
  const sets = [];
@@ -49,7 +54,11 @@ export async function resolveTree(tree, options) {
49
54
  sets.push({ values: node.fields, descriptors: schema.fields });
50
55
  sets.push({ values: node.styles, descriptors: schema.styles ?? {} });
51
56
  }
52
- await resolveMediaSets([...sets, ...hydrated], options.assets);
57
+ if (options.page && resolved.fields)
58
+ sets.push({ values: resolved.fields, descriptors: options.page.fields });
59
+ if (resolved.meta)
60
+ sets.push({ values: resolved.meta, descriptors: META_DESCRIPTORS });
61
+ await resolveMediaSets([...sets, ...hydrated], options.assets, elementSchemas(options.elements));
53
62
  }
54
63
  return resolved;
55
64
  }
@@ -70,19 +79,19 @@ async function fetchAssets(fetch, ids) {
70
79
  return out;
71
80
  }
72
81
  /** One value set's media, resolved in place. Exported for the site layer's shared-item styles. */
73
- export async function resolveMedia(values, descriptors, fetch) {
74
- await resolveMediaSets([{ values, descriptors }], fetch);
82
+ export async function resolveMedia(values, descriptors, fetch, elements = {}) {
83
+ await resolveMediaSets([{ values, descriptors }], fetch, elements);
75
84
  }
76
85
  /** Many value sets, one asset round (plus one for posters): the tree's
77
86
  * nodes and every entry hydrated through a ref, or a list page's items.
78
87
  * Exported for the site layer's content API. */
79
- export async function resolveMediaSets(sets, fetch) {
88
+ export async function resolveMediaSets(sets, fetch, elements = {}) {
80
89
  const ids = new Set();
81
90
  for (const { values, descriptors } of sets)
82
- collectAssetIds(values, descriptors, ids);
91
+ collectAssetIds(values, descriptors, ids, elements);
83
92
  const assets = await fetchAssets(fetch, ids);
84
93
  for (const { values, descriptors } of sets)
85
- applyAssets(values, descriptors, assets);
94
+ applyAssets(values, descriptors, assets, elements);
86
95
  }
87
96
  /** Placements first (spec 2026-09-07 §4): fetch every linked item in one
88
97
  * call per item, rewrite each node into the item's section node — the
@@ -128,7 +137,7 @@ export async function resolveFields(fields, descriptors, options) {
128
137
  collectTasks(resolved, descriptors, tasks);
129
138
  const hydrated = await runResolution(tasks, { ...options, maxDepth: options.maxDepth ?? Infinity });
130
139
  if (options.assets)
131
- await resolveMediaSets([{ values: resolved, descriptors }, ...hydrated], options.assets);
140
+ await resolveMediaSets([{ values: resolved, descriptors }, ...hydrated], options.assets, elementSchemas(options.elements));
132
141
  return resolved;
133
142
  }
134
143
  /** Hydrates every task's refs in place and returns each hydrated entry
@@ -12,6 +12,12 @@ export declare const entryTag: (id: string) => string;
12
12
  * locale, and code owns the name): the Next glue's getSmoodlyShared
13
13
  * caches under it; ops.shared expires it. */
14
14
  export declare const sharedTag: (name: string) => string;
15
+ /** The page-tree listing's cache unit (spec 2026-09-11 §2): one tree-wide
16
+ * tag, fired by every page op that changes what the listing shows. */
17
+ export declare const pagesTreeTag: () => string;
18
+ /** A collection's list units: fired by every entry write in the
19
+ * collection, since a non-versioned collection's published read is its live row. */
20
+ export declare const collectionTag: (name: string) => string;
15
21
  export declare function affectedTargets(stores: {
16
22
  pages: PageStore;
17
23
  entries: EntryStore;
@@ -11,6 +11,12 @@ export const entryTag = (id) => `entry:${id}`;
11
11
  * locale, and code owns the name): the Next glue's getSmoodlyShared
12
12
  * caches under it; ops.shared expires it. */
13
13
  export const sharedTag = (name) => `shared:${name}`;
14
+ /** The page-tree listing's cache unit (spec 2026-09-11 §2): one tree-wide
15
+ * tag, fired by every page op that changes what the listing shows. */
16
+ export const pagesTreeTag = () => "pages:tree";
17
+ /** A collection's list units: fired by every entry write in the
18
+ * collection, since a non-versioned collection's published read is its live row. */
19
+ export const collectionTag = (name) => `entries:${name}`;
14
20
  export async function affectedTargets(stores, entryId, options = {}) {
15
21
  const maxDepth = options.maxDepth ?? Infinity;
16
22
  const seen = new Set([entryId]);
@@ -0,0 +1,12 @@
1
+ import type { ReactElement } from "react";
2
+ import { type RichTextElement } from "../richtext-render.tsx";
3
+ export type CreateRichTextOptions = {
4
+ /** The registered Elements a body may embed. */
5
+ elements?: RichTextElement[];
6
+ /** next/image `sizes` for body images; the site's text column width. */
7
+ sizes?: string;
8
+ };
9
+ export declare function createRichText({ elements, sizes }?: CreateRichTextOptions): (props: {
10
+ doc: unknown;
11
+ }) => ReactElement;
12
+ export type { RichTextElement, RichTextImageProps, RichTextProps } from "../richtext-render.tsx";
@@ -0,0 +1,9 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { RichText } from "../richtext-render.js";
3
+ import { SmoodlyImage } from "../image/SmoodlyImage.js";
4
+ export function createRichText({ elements = [], sizes } = {}) {
5
+ const image = ({ image, caption }) => (_jsxs("figure", { children: [_jsx(SmoodlyImage, { image: image, sizes: sizes }), caption !== "" && _jsx("figcaption", { children: caption })] }));
6
+ return function BoundRichText({ doc }) {
7
+ return _jsx(RichText, { doc: doc, elements: elements, image: image });
8
+ };
9
+ }
@@ -1,3 +1,23 @@
1
- export declare function RichText({ doc }: {
1
+ import { type ReactElement, type ReactNode } from "react";
2
+ import type { ResolvedImage } from "./media.ts";
3
+ export type RichTextImageProps = {
4
+ image: ResolvedImage;
5
+ caption: string;
6
+ };
7
+ /** A paired element component: `smoodly.element(schema, View)`. */
8
+ export type RichTextElement = {
9
+ schema: {
10
+ name: string;
11
+ };
12
+ } & ((props: any) => ReactElement);
13
+ export type RichTextProps = {
2
14
  doc: unknown;
3
- }): import("react").JSX.Element | null;
15
+ /** The registered Elements a document may embed, rendered by name. */
16
+ elements?: RichTextElement[];
17
+ /** How an image node renders; the default is a figure over a plain <img>. */
18
+ image?: (props: RichTextImageProps) => ReactNode;
19
+ };
20
+ /** Lazy by default: body images sit below the fold, and a lazy <img> is
21
+ * the one React does not hoist a preload hint for on the server. */
22
+ export declare function defaultRichTextImage({ image, caption }: RichTextImageProps): ReactNode;
23
+ export declare function RichText({ doc, elements, image }: RichTextProps): import("react").JSX.Element | null;