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
@@ -1,11 +1,15 @@
1
- import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
2
- // The site-side richtext renderer (spec 2026-09-07, list field §5):
3
- // TipTap JSON → React, a fragment with no wrapper so the developer owns
4
- // the surrounding element and its class. Pure React, no Node imports, so
5
- // it works in shared, server and client components alike. Unknown nodes
6
- // render their children — a document from a later, richer editor degrades
7
- // instead of crashing. Elements as node views are Phase 3.
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ // The site-side richtext renderer (spec 2026-09-07 list field §5; block
3
+ // nodes spec 2026-09-08): TipTap JSON → React, a fragment with no wrapper
4
+ // so the developer owns the surrounding element and its class. Pure
5
+ // React, no Node or Next imports, so it works in shared, server and
6
+ // client components alike. Unknown nodes render their children — a
7
+ // document from a later, richer editor degrades instead of crashing.
8
+ // Block nodes: an `image` node renders through the `image` render prop
9
+ // (default: a figure over a plain <img>); an `element` node renders the
10
+ // paired component named in `elements`, nothing when unknown.
8
11
  import { Fragment, createElement } from "react";
12
+ import { ELEMENT_NODE, IMAGE_NODE } from "./richtext-walk.js";
9
13
  /** Only schemes an editor's link may carry reach the page: http(s), mailto, tel,
10
14
  * and site-relative targets. Anything else (javascript:, data:, an empty href,
11
15
  * a protocol-relative `//host` or a backslash form `/\host` that a browser
@@ -18,19 +22,27 @@ function safeHref(raw) {
18
22
  const href = raw.replace(/[\t\n\r]/g, "").trim();
19
23
  return href !== "" && SAFE_HREF.test(href) ? href : null;
20
24
  }
21
- export function RichText({ doc }) {
25
+ /** Lazy by default: body images sit below the fold, and a lazy <img> is
26
+ * the one React does not hoist a preload hint for on the server. */
27
+ export function defaultRichTextImage({ image, caption }) {
28
+ return (_jsxs("figure", { children: [_jsx("img", { src: image.url, width: image.width, height: image.height, alt: image.alt, loading: "lazy" }), caption !== "" && _jsx("figcaption", { children: caption })] }));
29
+ }
30
+ const isResolvedImage = (v) => typeof v === "object" && v !== null && typeof v.url === "string"
31
+ && typeof v.width === "number" && typeof v.height === "number";
32
+ export function RichText({ doc, elements = [], image = defaultRichTextImage }) {
22
33
  const d = doc;
23
34
  if (!d || typeof d !== "object" || d.type !== "doc" || !Array.isArray(d.content))
24
35
  return null;
25
- return _jsx(_Fragment, { children: renderNodes(d.content) });
36
+ const r = { elements: new Map(elements.map((e) => [e.schema.name, e])), image };
37
+ return _jsx(_Fragment, { children: renderNodes(d.content, r) });
26
38
  }
27
- function renderNodes(nodes) {
28
- return nodes.map((n, i) => _jsx(Fragment, { children: renderNode(n) }, i));
39
+ function renderNodes(nodes, r) {
40
+ return nodes.map((n, i) => _jsx(Fragment, { children: renderNode(n, r) }, i));
29
41
  }
30
- function renderNode(node) {
42
+ function renderNode(node, r) {
31
43
  if (!node || typeof node !== "object")
32
44
  return null;
33
- const children = Array.isArray(node.content) ? renderNodes(node.content) : null;
45
+ const children = Array.isArray(node.content) ? renderNodes(node.content, r) : null;
34
46
  switch (node.type) {
35
47
  case "text": return withMarks(node.text ?? "", node.marks ?? []);
36
48
  case "paragraph": return _jsx("p", { children: children });
@@ -43,6 +55,21 @@ function renderNode(node) {
43
55
  case "listItem": return _jsx("li", { children: children });
44
56
  case "blockquote": return _jsx("blockquote", { children: children });
45
57
  case "hardBreak": return _jsx("br", {});
58
+ case IMAGE_NODE: {
59
+ const a = (node.attrs ?? {});
60
+ if (!isResolvedImage(a.image))
61
+ return null;
62
+ return r.image({ image: a.image, caption: typeof a.caption === "string" ? a.caption : "" });
63
+ }
64
+ case ELEMENT_NODE: {
65
+ const a = (node.attrs ?? {});
66
+ const Component = typeof a.name === "string" ? r.elements.get(a.name) : undefined;
67
+ if (!Component)
68
+ return null;
69
+ const fields = a.fields && typeof a.fields === "object" ? a.fields : {};
70
+ const styles = a.styles && typeof a.styles === "object" ? a.styles : {};
71
+ return _jsx(Component, { ...fields, styles: styles });
72
+ }
46
73
  default: return children;
47
74
  }
48
75
  }
@@ -0,0 +1,44 @@
1
+ import type { Descriptor } from "./fields.ts";
2
+ export declare const IMAGE_NODE = "image";
3
+ export declare const ELEMENT_NODE = "element";
4
+ export type ImageNodeAttrs = {
5
+ image?: unknown;
6
+ caption?: unknown;
7
+ };
8
+ export type ElementNodeAttrs = {
9
+ name?: unknown;
10
+ fields?: unknown;
11
+ styles?: unknown;
12
+ };
13
+ /** A block node as stored — `attrs` is the live object, so a walk may resolve in place. */
14
+ export type BlockNode = {
15
+ type: string;
16
+ attrs: Record<string, unknown>;
17
+ };
18
+ /** Every image or element node in document order, at any depth. */
19
+ export declare function richtextBlocks(doc: unknown): BlockNode[];
20
+ /** An element's descriptors by name — what the walks read an element node against. */
21
+ export type ElementSchemas = Record<string, {
22
+ fields: Record<string, Descriptor>;
23
+ styles: Record<string, Descriptor>;
24
+ }>;
25
+ type ElementLike = {
26
+ name: string;
27
+ fields: Record<string, Descriptor>;
28
+ styles?: Record<string, Descriptor>;
29
+ } | {
30
+ schema: {
31
+ name: string;
32
+ fields: Record<string, Descriptor>;
33
+ styles?: Record<string, Descriptor>;
34
+ };
35
+ };
36
+ export declare function elementSchemas(list: ElementLike[] | undefined): ElementSchemas;
37
+ /** The element schema an element node names, or null for an unknown one. */
38
+ export declare function elementOf(node: BlockNode, elements: ElementSchemas): ElementSchemas[string] | null;
39
+ /** An element node's field and style value sets, for a walk. */
40
+ export declare function elementSets(node: BlockNode, schema: ElementSchemas[string]): {
41
+ values: Record<string, unknown>;
42
+ descriptors: Record<string, Descriptor>;
43
+ }[];
44
+ export {};
@@ -0,0 +1,46 @@
1
+ export const IMAGE_NODE = "image";
2
+ export const ELEMENT_NODE = "element";
3
+ const isNode = (v) => typeof v === "object" && v !== null && typeof v.type === "string";
4
+ /** Every image or element node in document order, at any depth. */
5
+ export function richtextBlocks(doc) {
6
+ const out = [];
7
+ const walk = (n) => {
8
+ if (!isNode(n))
9
+ return;
10
+ if (n.type === IMAGE_NODE || n.type === ELEMENT_NODE) {
11
+ if (typeof n.attrs !== "object" || n.attrs === null)
12
+ n.attrs = {};
13
+ out.push(n);
14
+ }
15
+ if (Array.isArray(n.content))
16
+ for (const c of n.content)
17
+ walk(c);
18
+ };
19
+ walk(doc);
20
+ return out;
21
+ }
22
+ export function elementSchemas(list) {
23
+ const out = {};
24
+ for (const e of list ?? []) {
25
+ const s = "schema" in e ? e.schema : e;
26
+ if (!(s.name in out))
27
+ out[s.name] = { fields: s.fields, styles: s.styles ?? {} };
28
+ }
29
+ return out;
30
+ }
31
+ /** The element schema an element node names, or null for an unknown one. */
32
+ export function elementOf(node, elements) {
33
+ const name = node.attrs.name;
34
+ return typeof name === "string" ? elements[name] ?? null : null;
35
+ }
36
+ const asRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v) ? v : undefined;
37
+ /** An element node's field and style value sets, for a walk. */
38
+ export function elementSets(node, schema) {
39
+ const a = node.attrs;
40
+ const fields = asRecord(a.fields);
41
+ const styles = asRecord(a.styles);
42
+ return [
43
+ ...(fields ? [{ values: fields, descriptors: schema.fields }] : []),
44
+ ...(styles ? [{ values: styles, descriptors: schema.styles }] : []),
45
+ ];
46
+ }
package/dist/schema.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Descriptor, FieldBuilder, ValueOf } from "./fields.ts";
1
+ import { type Descriptor, type FieldBuilder, type ValueOf } from "./fields.ts";
2
2
  import { pageSchema } from "./page.ts";
3
3
  export type BuilderMap = Record<string, FieldBuilder<any, any>>;
4
4
  export type SectionSchema<F extends BuilderMap = BuilderMap, S extends BuilderMap = BuilderMap> = {
package/dist/schema.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { describePath, findMatch } from "./fields.js";
1
2
  import { pageSchema } from "./page.js";
2
3
  function toDescriptors(map) {
3
4
  return Object.fromEntries(Object.entries(map ?? {}).map(([k, b]) => [k, b.descriptor]));
@@ -35,6 +36,17 @@ export const schema = {
35
36
  return { kind: "section", ...baseSchema(input) };
36
37
  },
37
38
  element(input) {
38
- return { kind: "element", contexts: input.contexts, ...baseSchema(input) };
39
+ const base = baseSchema(input);
40
+ // An element inside a document is unreachable to the refs index, which
41
+ // addresses refs by top-level key (spec 2026-09-08 block nodes §1).
42
+ if (input.contexts.includes("richtext")) {
43
+ for (const [key, d] of Object.entries(base.fields)) {
44
+ const hit = findMatch(d, (sub) => sub.type === "ref" || sub.type === "refList", key);
45
+ if (hit !== null) {
46
+ throw new Error(`smoodly: element "${base.name}" cannot hold a ref (at ${describePath(hit)}) — refs cannot live inside richtext.`);
47
+ }
48
+ }
49
+ }
50
+ return { kind: "element", contexts: input.contexts, ...base };
39
51
  },
40
52
  };
@@ -1,4 +1,5 @@
1
1
  import { createElement } from "react";
2
+ import { NODE_ATTR } from "./marks.js";
2
3
  // The root wrapper every section renders through. Owns the mechanical DOM
3
4
  // work on the real root element (no extra div): element choice via `as`,
4
5
  // string-valued styles emitted as kebab-cased data attributes. Built-in
@@ -10,7 +11,7 @@ export function Section({ as = "section", styles, className, style, children, })
10
11
  const { __smoodlyNode, ...rest } = (styles ?? {});
11
12
  const dataAttrs = {};
12
13
  if (typeof __smoodlyNode === "string")
13
- dataAttrs["data-smoodly-node"] = __smoodlyNode;
14
+ dataAttrs[NODE_ATTR] = __smoodlyNode;
14
15
  for (const [key, value] of Object.entries(rest)) {
15
16
  if (typeof value === "string")
16
17
  dataAttrs[`data-${kebab(key)}`] = value;
package/dist/shared.d.ts CHANGED
@@ -6,7 +6,7 @@ import type { BuilderMap, NamespaceValues, SectionSchema } from "./schema.ts";
6
6
  export declare const SHARED_NODE_TYPE = "shared";
7
7
  /** The reserved key a visual item's styles live under in the node's shared JSONB. */
8
8
  export declare const STYLES_KEY = "$styles";
9
- export type SharedSchema<V = Record<string, unknown>> = CollectionSchema<any> & {
9
+ export type SharedSchema<V = Record<string, unknown>, N extends string = string> = CollectionSchema<any, N> & {
10
10
  kind: "shared";
11
11
  /** The registered section a visual item renders through; absent = an object item. */
12
12
  section?: string;
@@ -28,19 +28,19 @@ export declare function sharedLink(node: {
28
28
  type: string;
29
29
  fields?: Record<string, unknown>;
30
30
  }): SharedLink | null;
31
- type SharedInput = {
32
- name: string;
31
+ type SharedInput<N extends string = string> = {
32
+ name: N;
33
33
  title: string;
34
34
  versions?: boolean;
35
35
  };
36
- export declare function shared<F extends BuilderMap>(input: SharedInput & {
36
+ export declare function shared<F extends BuilderMap, const N extends string = string>(input: SharedInput<N> & {
37
37
  fields: F;
38
38
  section?: undefined;
39
- }): SharedSchema<NamespaceValues<F>>;
40
- export declare function shared<F extends BuilderMap, St extends BuilderMap>(input: SharedInput & {
39
+ }): SharedSchema<NamespaceValues<F>, N>;
40
+ export declare function shared<F extends BuilderMap, St extends BuilderMap, const N extends string = string>(input: SharedInput<N> & {
41
41
  section: PairedComponent<SectionSchema<F, St>, F, St>;
42
42
  fields?: undefined;
43
43
  }): SharedSchema<NamespaceValues<F> & {
44
44
  styles: NamespaceValues<St>;
45
- }>;
45
+ }, N>;
46
46
  export {};
package/dist/site.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { AssetStore } from "./asset-store.ts";
2
2
  import type { CollectionSchema, EntryOrder } from "./collections.ts";
3
- import { type EntryRecord, type EntryStore } from "./entry-store.ts";
3
+ import type { Descriptor } from "./fields.ts";
4
+ import { type EntryRecord, type EntryStore, type Where } from "./entry-store.ts";
4
5
  import { type LocaleSet, type PathTarget } from "./paths.ts";
5
6
  import type { SectionLike } from "./refs.ts";
6
7
  import type { PageContext, PageLink, PageTree } from "./render.tsx";
@@ -14,9 +15,21 @@ export type SiteOptions = {
14
15
  };
15
16
  collections: CollectionSchema<any>[];
16
17
  sections: SectionLike[];
18
+ /** Registered Elements a richtext document may embed (spec 2026-09-08 block nodes §2). */
19
+ elements?: SectionLike[];
17
20
  shared?: SharedSchema[];
18
21
  locales: LocaleSet;
19
22
  homePageSlug?: string;
23
+ /** Page shapes (entry pages are ignored): the source of each template's page-field descriptors (spec 2026-09-10 page fields §3). */
24
+ pages?: ReadonlyArray<SitePageSchema>;
25
+ };
26
+ /** A page registration as the site needs it: the template's page fields. */
27
+ export type SitePageSchema = {
28
+ schema: {
29
+ kind: string;
30
+ name: string;
31
+ fields?: Record<string, Descriptor>;
32
+ };
20
33
  };
21
34
  export type SitePage = {
22
35
  kind: "page";
@@ -25,6 +38,8 @@ export type SitePage = {
25
38
  tree: PageTree;
26
39
  title: string;
27
40
  path: string;
41
+ /** The page's own fields (spec 2026-09-10 page fields §3): resolved, defaults applied. */
42
+ fields: Record<string, unknown>;
28
43
  ancestors: PageLink[];
29
44
  children: PageLink[];
30
45
  /** locale → path, for metadata alternates: the locales the page is
@@ -48,6 +63,7 @@ export type PageTreeItem = {
48
63
  title: string;
49
64
  path: string;
50
65
  hasPage: boolean;
66
+ fields: Record<string, unknown>;
51
67
  children: PageTreeItem[];
52
68
  };
53
69
  export type SiteEntryItem = {
@@ -57,10 +73,14 @@ export type SiteEntryItem = {
57
73
  export type EntryTreeItem = SiteEntryItem & {
58
74
  children: EntryTreeItem[];
59
75
  };
76
+ /** `where`, `limit`, `parent` and `order` run in the store (spec 2026-09-11 §3); `draft` reads live rows inside the editor preview. */
60
77
  export type EntryQuery = {
61
78
  locale?: string;
62
79
  parent?: string | null;
63
80
  order?: EntryOrder;
81
+ where?: Where;
82
+ limit?: number;
83
+ draft?: boolean;
64
84
  };
65
85
  export type LoadOptions = {
66
86
  draft?: boolean;
@@ -75,16 +95,20 @@ export interface SmoodlySite {
75
95
  resolve(locale: string, path: string, options?: LoadOptions): Promise<SiteHit | null>;
76
96
  getPagesTree(options?: {
77
97
  locale?: string;
98
+ draft?: boolean;
78
99
  }): Promise<PageTreeItem[]>;
79
100
  getPage(pathOrId: string, options?: {
80
101
  locale?: string;
102
+ draft?: boolean;
81
103
  }): Promise<SitePage | null>;
82
104
  getEntries(collection: string, options?: EntryQuery): Promise<SiteEntryItem[]>;
83
105
  getEntry(collection: string, idOrPath: string, options?: {
84
106
  locale?: string;
107
+ draft?: boolean;
85
108
  }): Promise<SiteEntryItem | null>;
86
109
  getEntriesTree(collection: string, options?: {
87
110
  locale?: string;
111
+ draft?: boolean;
88
112
  }): Promise<EntryTreeItem[]>;
89
113
  /** A shared item's content in a locale (spec 2026-09-07 §4): an object
90
114
  * item's merged fields, or a visual item's section props (fields plus
package/dist/site.js CHANGED
@@ -1,7 +1,9 @@
1
- import { entryFieldsIn, schemaOf } from "./entry-store.js";
1
+ import { ARRAY_FIELD_TYPES, entryFieldsIn, localizedKeys, schemaOf } from "./entry-store.js";
2
2
  import { buildPageTree, chainOf, entryPathRows, entrySegment, pagePath, } from "./paths.js";
3
3
  import { resolveFields, resolveMedia, resolveMediaSets, resolveTree } from "./resolve.js";
4
+ import { elementSchemas } from "./richtext-walk.js";
4
5
  import { STYLES_KEY } from "./shared.js";
6
+ import { withDefaults } from "./field-defaults.js";
5
7
  /** The `page` prop for a loaded page. `toUrl` is the app's `href` seam
6
8
  * bound to the render locale; the default keeps CMS paths as URLs. */
7
9
  export function pageContextOf(hit, toUrl = (p) => p) {
@@ -11,6 +13,7 @@ export function pageContextOf(hit, toUrl = (p) => p) {
11
13
  title: hit.title,
12
14
  path: hit.path,
13
15
  url: toUrl(hit.path),
16
+ fields: hit.fields,
14
17
  ancestors: hit.ancestors.map(withUrl),
15
18
  children: hit.children.map(withUrl),
16
19
  };
@@ -18,6 +21,7 @@ export function pageContextOf(hit, toUrl = (p) => p) {
18
21
  export function createSmoodlySite(options) {
19
22
  const locales = options.locales;
20
23
  const homePageSlug = options.homePageSlug ?? "home";
24
+ const elements = elementSchemas(options.elements);
21
25
  const pathOpts = { locales, homePageSlug };
22
26
  const mounted = options.collections.filter((c) => c.path !== undefined);
23
27
  /** The resolver's fetcher, bound to a locale: the merged fields of that
@@ -44,11 +48,43 @@ export function createSmoodlySite(options) {
44
48
  const row = node.locales[locale];
45
49
  return node.template !== null && row !== undefined && (draft || row.status === "published");
46
50
  };
51
+ const pageFields = (template) => {
52
+ if (template === null)
53
+ return {};
54
+ const p = (options.pages ?? []).find((x) => x.schema.kind === "page" && x.schema.name === template);
55
+ return p?.schema.fields ?? {};
56
+ };
57
+ /** The version whose fields a link shows: the draft's in draft mode, else the published one. */
58
+ const versionFor = (node, locale, draft) => {
59
+ const row = node.locales[locale];
60
+ if (!row || node.template === null)
61
+ return null;
62
+ return draft ? row.draftVersionId : row.publishedVersionId;
63
+ };
64
+ /** Raw page fields (no asset resolution) of many nodes in ONE store read
65
+ * (spec 2026-09-10 page fields §3), defaults applied; `{}` for a folder. */
66
+ async function linkFields(nodes, locale, draft) {
67
+ const { pages } = options.stores();
68
+ const wanted = new Map(); // node id → version id
69
+ for (const n of nodes) {
70
+ const v = versionFor(n, locale, draft);
71
+ if (v)
72
+ wanted.set(n.id, v);
73
+ }
74
+ const byVersion = wanted.size > 0 ? await pages.fieldsOf([...new Set(wanted.values())]) : {};
75
+ const out = new Map();
76
+ for (const n of nodes) {
77
+ const v = wanted.get(n.id);
78
+ out.set(n.id, n.template === null ? {} : withDefaults(v ? byVersion[v] : undefined, pageFields(n.template)));
79
+ }
80
+ return out;
81
+ }
47
82
  /** Only ever called for a node PRESENT in the locale (visible() or an ancestor of one). */
48
- const link = (node, byId, locale, draft) => ({
83
+ const link = (node, byId, locale, draft, fields) => ({
49
84
  title: node.locales[locale].title,
50
85
  path: pagePath(node, byId, locale, pathOpts),
51
86
  hasPage: hasPage(node, locale, draft),
87
+ fields: fields.get(node.id) ?? {},
52
88
  });
53
89
  async function loadPage(id, locale, path, draft) {
54
90
  const { pages } = options.stores();
@@ -65,17 +101,19 @@ export function createSmoodlySite(options) {
65
101
  sections: options.sections,
66
102
  collections: options.collections,
67
103
  shared: options.shared,
104
+ elements: options.elements,
68
105
  fetch: fetcher(draft, locale),
69
106
  assets: assetFetcher(),
107
+ page: { fields: pageFields(record.template) },
70
108
  });
71
109
  const all = await pages.listPages();
72
110
  const index = new Map(all.map((n) => [n.id, n]));
73
111
  const byId = (x) => index.get(x);
74
112
  const chain = chainOf(record, byId);
75
- const ancestors = chain.slice(0, -1).map((n) => link(n, byId, locale, draft));
76
- const children = all
77
- .filter((n) => n.parentId === id && visible(n, locale, draft))
78
- .map((n) => link(n, byId, locale, draft));
113
+ const kids = all.filter((n) => n.parentId === id && visible(n, locale, draft));
114
+ const fields = await linkFields([...chain.slice(0, -1), ...kids], locale, draft);
115
+ const ancestors = chain.slice(0, -1).map((n) => link(n, byId, locale, draft, fields));
116
+ const children = kids.map((n) => link(n, byId, locale, draft, fields));
79
117
  // Alternates advertise PUBLISHED locales only; the editor (draft) sees every present one.
80
118
  const paths = Object.fromEntries(Object.entries(await pages.pathsOf(id)).filter(([l]) => draft || record.locales[l]?.status === "published"));
81
119
  return {
@@ -85,6 +123,7 @@ export function createSmoodlySite(options) {
85
123
  tree,
86
124
  title: row.title,
87
125
  path,
126
+ fields: withDefaults(tree.fields, pageFields(record.template)),
88
127
  ancestors,
89
128
  children,
90
129
  paths,
@@ -107,7 +146,7 @@ export function createSmoodlySite(options) {
107
146
  : (await entries.getMany(schema.name, [id], locale, { status: "published" }))[id];
108
147
  if (!raw)
109
148
  return null;
110
- const fields = await resolveFields(raw, schema.fields, { collections: options.collections, fetch: fetcher(draft, locale), assets: assetFetcher() });
149
+ const fields = await resolveFields(raw, schema.fields, { collections: options.collections, elements: options.elements, fetch: fetcher(draft, locale), assets: assetFetcher() });
111
150
  // Alternates advertise PUBLISHED locales only; the editor (draft) sees every present one.
112
151
  const paths = Object.fromEntries(Object.entries(await entries.pathsOf(schema.name, id)).filter(([l]) => draft || record.locales[l]?.status === "published"));
113
152
  return { kind: "entry", collection: schema.name, entry: { ...record, fields }, path, paths };
@@ -128,28 +167,55 @@ export function createSmoodlySite(options) {
128
167
  path: entryPathRows([record], byId, schema, locales).find((r) => r.locale === locale)?.path ?? null,
129
168
  }));
130
169
  }
131
- /** Public in a locale: its row exists there and is published. */
132
- const publicEntry = (e, locale) => e.locales[locale]?.status === "published";
133
170
  async function getEntries(collection, o = {}) {
134
171
  const schema = schemaOf(options.collections, collection);
135
172
  const locale = localeOf(o);
173
+ const draft = o.draft ?? false;
136
174
  const { entries } = options.stores();
137
- const all = await entries.list(collection, { order: o.order });
138
- const wanted = all.filter((e) => publicEntry(e, locale) && (o.parent === undefined || e.parentId === o.parent));
139
- const published = await entries.getMany(collection, wanted.map((e) => e.id), locale, { status: "published" });
175
+ // A filter that silently matched nothing would be worse than an error (spec 2026-09-11 §3).
176
+ for (const key of Object.keys(o.where ?? {})) {
177
+ if (key === "slug" || localizedKeys(schema).includes(key)) {
178
+ throw new Error(`smoodly: getEntries("${collection}"): "${key}" is localized — where filters shared fields only (spec 2026-09-11 §3).`);
179
+ }
180
+ if (!(key in schema.fields))
181
+ throw new Error(`smoodly: getEntries("${collection}"): "${key}" is not a field of the collection.`);
182
+ // Any-of reads the text form of the whole field (PostgREST's `->>`), which for
183
+ // an array is its JSON — `["a"]` never matches "a" — so refuse rather than match nothing.
184
+ if (Array.isArray(o.where[key]) && ARRAY_FIELD_TYPES.has(schema.fields[key].type ?? "")) {
185
+ throw new Error(`smoodly: getEntries("${collection}"): "${key}" is array-valued — any-of is for scalar fields; pass one value to match entries containing it.`);
186
+ }
187
+ }
188
+ if (o.limit !== undefined && (!Number.isInteger(o.limit) || o.limit < 0)) {
189
+ throw new Error(`smoodly: getEntries("${collection}"): limit must be a non-negative integer.`);
190
+ }
191
+ // The store filters, orders and limits (spec 2026-09-11 §3): the locale
192
+ // gate — presence in draft mode, published otherwise — is part of the
193
+ // query, so `limit` counts rows that are really there.
194
+ const inLocale = await entries.list(collection, {
195
+ order: o.order, where: o.where, limit: o.limit,
196
+ ...(o.parent !== undefined ? { parent: o.parent } : {}),
197
+ ...(draft ? { locale } : { status: "published", locale }),
198
+ });
199
+ // Paths need every ancestor: a tree collection reads the whole collection for that, a flat one does not.
200
+ const all = schema.tree ? await entries.list(collection) : inLocale;
201
+ const fieldsById = draft
202
+ ? Object.fromEntries(inLocale.map((e) => [e.id, entryFieldsIn(e, locale)]))
203
+ : await entries.getMany(collection, inLocale.map((e) => e.id), locale, { status: "published" });
140
204
  // Refs stay ids on the content API; media resolves (one asset round for
141
205
  // the whole list), because a list page's cards need their image URLs.
142
206
  const fetchAssets = assetFetcher();
143
207
  if (fetchAssets)
144
- await resolveMediaSets(Object.values(published).map((values) => ({ values, descriptors: schema.fields })), fetchAssets);
145
- return entryItems(schema, wanted, all, locale, published);
208
+ await resolveMediaSets(Object.values(fieldsById).map((values) => ({ values, descriptors: schema.fields })), fetchAssets, elements);
209
+ return entryItems(schema, inLocale, all, locale, fieldsById);
146
210
  }
147
211
  const api = {
148
212
  async getPagesTree(o) {
149
213
  const locale = localeOf(o);
214
+ const draft = o?.draft ?? false;
150
215
  const all = await options.stores().pages.listPages();
151
- const shown = all.filter((n) => visible(n, locale, false));
216
+ const shown = all.filter((n) => visible(n, locale, draft));
152
217
  const index = new Map(shown.map((n) => [n.id, n]));
218
+ const fields = await linkFields(shown, locale, draft);
153
219
  const toItem = (node) => ({
154
220
  id: node.record.id,
155
221
  title: node.record.locales[locale].title,
@@ -157,7 +223,8 @@ export function createSmoodlySite(options) {
157
223
  // buildPageTree's nullable path is never null here; pagePath is
158
224
  // the same derivation and keeps the type honest.
159
225
  path: node.path ?? pagePath(node.record, (x) => index.get(x), locale, pathOpts),
160
- hasPage: hasPage(node.record, locale, false),
226
+ hasPage: hasPage(node.record, locale, draft),
227
+ fields: fields.get(node.record.id) ?? {},
161
228
  children: node.children.map(toItem),
162
229
  });
163
230
  return buildPageTree(shown, locale, pathOpts).map(toItem);
@@ -167,7 +234,7 @@ export function createSmoodlySite(options) {
167
234
  const path = pathOrId.startsWith("/") ? pathOrId : (await options.stores().pages.pathsOf(pathOrId))[locale];
168
235
  if (!path)
169
236
  return null;
170
- const hit = await site.resolve(locale, path);
237
+ const hit = await site.resolve(locale, path, { draft: o?.draft ?? false });
171
238
  return hit?.kind === "page" ? hit : null;
172
239
  },
173
240
  getEntries,
@@ -175,10 +242,10 @@ export function createSmoodlySite(options) {
175
242
  schemaOf(options.collections, collection); // an unknown collection is a wiring error on every branch
176
243
  const locale = localeOf(o);
177
244
  if (idOrPath.startsWith("/")) {
178
- const hit = await site.resolve(locale, idOrPath);
245
+ const hit = await site.resolve(locale, idOrPath, { draft: o?.draft ?? false });
179
246
  return hit?.kind === "entry" && hit.collection === collection ? { entry: hit.entry, path: hit.path } : null;
180
247
  }
181
- const items = await getEntries(collection, { locale });
248
+ const items = await getEntries(collection, { locale, draft: o?.draft ?? false });
182
249
  const byId = items.find((i) => i.entry.id === idOrPath);
183
250
  if (byId)
184
251
  return byId;
@@ -188,7 +255,7 @@ export function createSmoodlySite(options) {
188
255
  return items.find((i) => i.entry.parentId === null && entrySegment(i.entry, locale) === idOrPath) ?? null;
189
256
  },
190
257
  async getEntriesTree(collection, o) {
191
- const items = await getEntries(collection, { locale: localeOf(o) });
258
+ const items = await getEntries(collection, { locale: localeOf(o), draft: o?.draft ?? false });
192
259
  const build = (parentId) => items.filter((i) => i.entry.parentId === parentId).map((i) => ({ ...i, children: build(i.entry.id) }));
193
260
  return build(null);
194
261
  },
@@ -207,13 +274,13 @@ export function createSmoodlySite(options) {
207
274
  : (await entries.getMany(item.name, [record.id], locale, { status: "published" }))[record.id];
208
275
  if (!raw)
209
276
  return null;
210
- const fields = await resolveFields(raw, item.fields, { collections: options.collections, fetch: fetcher(draft, locale), assets: assetFetcher() });
277
+ const fields = await resolveFields(raw, item.fields, { collections: options.collections, elements: options.elements, fetch: fetcher(draft, locale), assets: assetFetcher() });
211
278
  if (!item.section)
212
279
  return fields;
213
280
  const { [STYLES_KEY]: styles, ...rest } = fields;
214
281
  const fetch = assetFetcher();
215
282
  if (fetch && styles && typeof styles === "object")
216
- await resolveMedia(styles, sectionStyles(item.section), fetch);
283
+ await resolveMedia(styles, sectionStyles(item.section), fetch, elements);
217
284
  return { ...rest, styles: styles !== null && typeof styles === "object" ? styles : {} };
218
285
  },
219
286
  };
package/dist/store.d.ts CHANGED
@@ -78,6 +78,9 @@ export interface PageStore {
78
78
  publish(id: string, locale: string): Promise<PageRecord>;
79
79
  getDraftTree(id: string, locale: string): Promise<PageTree | null>;
80
80
  getPublishedTree(id: string, locale: string): Promise<PageTree | null>;
81
+ /** The page fields of many versions in ONE read (spec 2026-09-10 page fields §3):
82
+ * versionId → `tree.fields ?? {}`; ids that do not exist are absent. */
83
+ fieldsOf(versionIds: string[]): Promise<Record<string, Record<string, unknown>>>;
81
84
  listVersions(id: string, locale: string): Promise<PageVersion[]>;
82
85
  deletePage(id: string): Promise<void>;
83
86
  }
@@ -86,6 +89,8 @@ export interface PageStore {
86
89
  * written; without `entries` publishing is not integrity-checked. */
87
90
  export type PageStoreOptions = {
88
91
  sections?: SectionLike[];
92
+ /** Registered Elements a richtext document may embed (spec 2026-09-08 block nodes §2). */
93
+ elements?: SectionLike[];
89
94
  entries?: EntryStore;
90
95
  /** The shared URL index (one instance for pages AND entries). */
91
96
  paths?: PathIndex;
@@ -123,7 +128,7 @@ export declare function localeRow(page: PageRecord, locale: string): PageLocale;
123
128
  /** A page's refs rows cover EVERY live tree — each locale's draft and
124
129
  * published trees — so an entry stays delete-guarded while any of them
125
130
  * shows it. Callers dedupe on write (the SQL PK does it for real). */
126
- export declare function pageRefEdges(trees: PageTree[], sections: SectionLike[]): RefEdge[];
131
+ export declare function pageRefEdges(trees: PageTree[], sections: SectionLike[], elements?: SectionLike[]): RefEdge[];
127
132
  /** The publish-integrity gate (DESIGN.md §3), per locale: a page must not
128
133
  * go live in a locale showing entries the published site cannot resolve
129
134
  * THERE — every referenced entry must exist, have a row in that locale,
@@ -177,6 +182,7 @@ export declare class MemoryPageStore implements PageStore {
177
182
  saveDraft(id: string, locale: string, tree: PageTree): Promise<PageVersion>;
178
183
  publish(id: string, locale: string): Promise<PageRecord>;
179
184
  getDraftTree(id: string, locale: string): Promise<PageTree | null>;
185
+ fieldsOf(versionIds: string[]): Promise<Record<string, Record<string, unknown>>>;
180
186
  getPublishedTree(id: string, locale: string): Promise<PageTree | null>;
181
187
  listVersions(id: string, locale: string): Promise<PageVersion[]>;
182
188
  deletePage(id: string): Promise<void>;
package/dist/store.js CHANGED
@@ -50,10 +50,10 @@ export function localeRow(page, locale) {
50
50
  /** A page's refs rows cover EVERY live tree — each locale's draft and
51
51
  * published trees — so an entry stays delete-guarded while any of them
52
52
  * shows it. Callers dedupe on write (the SQL PK does it for real). */
53
- export function pageRefEdges(trees, sections) {
53
+ export function pageRefEdges(trees, sections, elements) {
54
54
  const edges = [];
55
55
  for (const tree of trees)
56
- edges.push(...collectTreeRefs(tree, sections));
56
+ edges.push(...collectTreeRefs(tree, sections, elements));
57
57
  return edges;
58
58
  }
59
59
  /** The publish-integrity gate (DESIGN.md §3), per locale: a page must not
@@ -159,10 +159,10 @@ export class MemoryPageStore {
159
159
  return trees;
160
160
  }
161
161
  writeRefs(page) {
162
- const { sections, refs } = this.options;
162
+ const { sections, elements, refs } = this.options;
163
163
  if (!sections || !refs)
164
164
  return;
165
- refs.rewrite({ sourceKind: "page", sourceId: page.id }, pageRefEdges(this.liveTrees(page), sections));
165
+ refs.rewrite({ sourceKind: "page", sourceId: page.id }, pageRefEdges(this.liveTrees(page), sections, elements));
166
166
  }
167
167
  newVersion(pageId, locale, tree) {
168
168
  const version = { id: uid(), pageId, locale, tree: structuredClone(tree), createdAt: Date.now() };
@@ -410,6 +410,15 @@ export class MemoryPageStore {
410
410
  return null;
411
411
  return structuredClone(this.versions.get(row.draftVersionId)?.tree ?? null);
412
412
  }
413
+ async fieldsOf(versionIds) {
414
+ const out = {};
415
+ for (const id of versionIds) {
416
+ const v = this.versions.get(id);
417
+ if (v)
418
+ out[id] = structuredClone(v.tree.fields ?? {});
419
+ }
420
+ return out;
421
+ }
413
422
  async getPublishedTree(id, locale) {
414
423
  const row = this.pages.get(id)?.locales[locale];
415
424
  if (!row?.publishedVersionId)