smoodly 0.0.13 → 0.0.15

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 (58) hide show
  1. package/README.md +87 -15
  2. package/dist/admin/editor/EditorView.js +12 -10
  3. package/dist/admin/editor/PageInspector.d.ts +21 -0
  4. package/dist/admin/editor/PageInspector.js +24 -0
  5. package/dist/admin/editor/PageSettings.d.ts +1 -3
  6. package/dist/admin/editor/PageSettings.js +7 -6
  7. package/dist/admin/editor/SeoSettings.d.ts +7 -0
  8. package/dist/admin/editor/SeoSettings.js +15 -0
  9. package/dist/admin/forms/FieldWidget.d.ts +1 -1
  10. package/dist/admin/forms/FieldWidget.js +24 -4
  11. package/dist/admin/ops-impl.js +72 -11
  12. package/dist/admin/serialize.d.ts +4 -0
  13. package/dist/admin/serialize.js +2 -0
  14. package/dist/admin/tree-ops.d.ts +2 -0
  15. package/dist/admin/tree-ops.js +5 -1
  16. package/dist/admin/ui/primitives.d.ts +9 -0
  17. package/dist/admin/ui/primitives.js +6 -0
  18. package/dist/admin/ui/resize.d.ts +1 -1
  19. package/dist/admin/ui/resize.js +1 -1
  20. package/dist/admin/ui/theme.js +16 -1
  21. package/dist/admin/validate.js +24 -3
  22. package/dist/collections.d.ts +6 -5
  23. package/dist/entry-store.d.ts +18 -1
  24. package/dist/entry-store.js +27 -2
  25. package/dist/field-defaults.d.ts +8 -0
  26. package/dist/field-defaults.js +18 -0
  27. package/dist/fields.d.ts +26 -1
  28. package/dist/fields.js +31 -1
  29. package/dist/index.d.ts +8 -6
  30. package/dist/index.js +5 -3
  31. package/dist/next/index.d.ts +2 -0
  32. package/dist/next/index.js +1 -0
  33. package/dist/next/meta.d.ts +7 -3
  34. package/dist/next/meta.js +18 -9
  35. package/dist/next/page-renderer.js +1 -1
  36. package/dist/next/queries.d.ts +36 -0
  37. package/dist/next/queries.js +87 -0
  38. package/dist/page-tree.d.ts +3 -0
  39. package/dist/page-tree.js +6 -0
  40. package/dist/page.d.ts +11 -3
  41. package/dist/page.js +26 -0
  42. package/dist/pairing.d.ts +2 -1
  43. package/dist/pairing.js +2 -9
  44. package/dist/render.d.ts +14 -7
  45. package/dist/render.js +2 -2
  46. package/dist/resolve.d.ts +4 -0
  47. package/dist/resolve.js +8 -0
  48. package/dist/revalidate.d.ts +6 -0
  49. package/dist/revalidate.js +6 -0
  50. package/dist/shared.d.ts +7 -7
  51. package/dist/site.d.ts +23 -1
  52. package/dist/site.js +83 -19
  53. package/dist/store.d.ts +4 -0
  54. package/dist/store.js +9 -0
  55. package/dist/supabase-entry-store.js +32 -10
  56. package/dist/supabase-store.d.ts +1 -0
  57. package/dist/supabase-store.js +13 -0
  58. package/package.json +1 -1
@@ -69,6 +69,8 @@ export function serializeAdminConfig(config) {
69
69
  ...(slug !== undefined ? { slug } : {}),
70
70
  ...(typeof p.slug === "object" && p.slug !== null ? { slugs: { ...p.slug } } : {}),
71
71
  zones: p.zones ?? {},
72
+ fields: serializeMap(p.fields),
73
+ seo: p.meta?.seo === true,
72
74
  };
73
75
  }),
74
76
  shared: (config.registry.shared ?? []).map((s) => ({
@@ -32,3 +32,5 @@ export declare function nodeFromSample(section: Pick<AdminSection, "name" | "fie
32
32
  * schema. Returns the same tree when nothing changed. */
33
33
  export declare function fillLockedZones(tree: PageTree, policies: Record<string, ZonePolicy>, sections: AdminSection[], sharedIds?: Record<string, string>): PageTree;
34
34
  export declare function newNodeId(type: string): string;
35
+ /** A page-level value — the template's page fields or the SEO meta (spec 2026-09-10 page fields §5). */
36
+ export declare function patchPage(tree: PageTree, ns: "fields" | "meta", key: string, value: unknown): PageTree;
@@ -116,7 +116,7 @@ export function nodeFromSample(section, id) {
116
116
  const fields = {};
117
117
  const styles = {};
118
118
  for (const [key, d] of Object.entries(section.fields)) {
119
- const sampled = section.sample?.[key];
119
+ const sampled = section.sample?.[key] ?? d.default ?? (d.type === "boolean" ? false : undefined);
120
120
  if (sampled !== undefined)
121
121
  fields[key] = sampled;
122
122
  else if (ABSENT_WHEN_UNSAMPLED.has(d.type))
@@ -168,3 +168,7 @@ export function fillLockedZones(tree, policies, sections, sharedIds = {}) {
168
168
  export function newNodeId(type) {
169
169
  return `${type}-${crypto.randomUUID().slice(0, 8)}`;
170
170
  }
171
+ /** A page-level value — the template's page fields or the SEO meta (spec 2026-09-10 page fields §5). */
172
+ export function patchPage(tree, ns, key, value) {
173
+ return { ...tree, [ns]: { ...(tree[ns] ?? {}), [key]: value } };
174
+ }
@@ -27,6 +27,15 @@ export declare function IconButton({ label, tone, size, children, ...rest }: {
27
27
  size?: number;
28
28
  children: ReactNode;
29
29
  } & ButtonHTMLAttributes<HTMLButtonElement>): import("react").JSX.Element;
30
+ /** An on/off control in the design system's control language: a pill
31
+ * track, a knob, the accent when on. A button, so it sits inside a
32
+ * field's implicit label and the label click toggles it. */
33
+ export declare function Switch({ checked, onChange, disabled, label }: {
34
+ checked: boolean;
35
+ onChange(next: boolean): void;
36
+ disabled?: boolean;
37
+ label?: string;
38
+ }): import("react").JSX.Element;
30
39
  /** The drag handle every sortable row carries at its left edge (spec
31
40
  * 2026-09-09 §2): a button, so it is focusable and takes the arrow keys;
32
41
  * `touch-action: none` (theme) so a finger on it drags instead of scrolling. */
@@ -51,6 +51,12 @@ export function LinkButton({ variant = "secondary", children, ...rest }) {
51
51
  export function IconButton({ label, tone = "default", size = 24, children, ...rest }) {
52
52
  return (_jsx("button", { type: "button", "aria-label": label, title: label, className: cx("sm-iconbtn", tone === "inverse" && "sm-iconbtn--inverse"), style: { width: size, height: size }, ...rest, children: children }));
53
53
  }
54
+ /** An on/off control in the design system's control language: a pill
55
+ * track, a knob, the accent when on. A button, so it sits inside a
56
+ * field's implicit label and the label click toggles it. */
57
+ export function Switch({ checked, onChange, disabled, label }) {
58
+ return (_jsx("button", { type: "button", role: "switch", "aria-checked": checked, "aria-label": label, disabled: disabled, className: "sm-switch", onClick: () => onChange(!checked), children: _jsx("span", { className: "sm-switch__knob" }) }));
59
+ }
54
60
  /** The drag handle every sortable row carries at its left edge (spec
55
61
  * 2026-09-09 §2): a button, so it is focusable and takes the arrow keys;
56
62
  * `touch-action: none` (theme) so a finger on it drags instead of scrolling. */
@@ -3,7 +3,7 @@ export declare const INSPECTOR_W_KEY = "smoodly-inspector-w";
3
3
  /** Below this the richtext toolbar and the outline rows no longer fit. */
4
4
  export declare const MIN_INSPECTOR_W = 260;
5
5
  /** The token's value in the warm look; what a reset returns to. */
6
- export declare const DEFAULT_INSPECTOR_W = 320;
6
+ export declare const DEFAULT_INSPECTOR_W = 360;
7
7
  /** One arrow-key press. */
8
8
  export declare const RESIZE_STEP = 16;
9
9
  /** Half the editor row (canvas + handle + inspector), never below the minimum. */
@@ -5,7 +5,7 @@ export const INSPECTOR_W_KEY = "smoodly-inspector-w";
5
5
  /** Below this the richtext toolbar and the outline rows no longer fit. */
6
6
  export const MIN_INSPECTOR_W = 260;
7
7
  /** The token's value in the warm look; what a reset returns to. */
8
- export const DEFAULT_INSPECTOR_W = 320;
8
+ export const DEFAULT_INSPECTOR_W = 360;
9
9
  /** One arrow-key press. */
10
10
  export const RESIZE_STEP = 16;
11
11
  /** Half the editor row (canvas + handle + inspector), never below the minimum. */
@@ -101,7 +101,7 @@ const SCALE = `
101
101
  --control-h:40px; --control-h-sm:34px; --control-h-lg:48px; --button-h:36px; --row-h:48px; --topbar-h:64px;
102
102
  --outline-row-h:36px; --seg-pad-y:7px; --tabs-pad:3px;
103
103
  --plus-h:32px; --plus-pad:0 14px 0 6px; --plus-label:inline;
104
- --nav-w:72px; --inspector-w:320px; --gutter:20px; --frame-pad:24px;
104
+ --nav-w:72px; --inspector-w:360px; --gutter:20px; --frame-pad:24px;
105
105
  --rail-pad:22px 6px 16px; --rail-logo:none; --topbar-pad:0; --back-size:32px; --back-bg:var(--surface);
106
106
  --table-pad:8px 12px; --table-top:80px; --canvas-pad:24px 40px 0; --card-pad:16px 18px; --card-gap:12px; --form-pad:32px 40px;
107
107
  --border:1px solid var(--line);
@@ -637,6 +637,21 @@ body:has(.sm-admin){margin:0}
637
637
  .sm-seg__opt[aria-pressed="true"]{background:var(--surface);color:var(--text);font-weight:600;box-shadow:var(--shadow-seg)}
638
638
  /* A locale the record does not have yet: subtle, still a button (it opens the add pane). */
639
639
  .sm-seg__opt--absent{color:var(--text-subtle)}
640
+ /* Switch (f.boolean, the SEO noindex toggle): a pill track, knob slides right when on. */
641
+ .sm-switch{width:36px;height:20px;padding:2px;border:0;border-radius:var(--radius-round);background:var(--seg-bg);cursor:pointer;display:inline-flex;align-items:center;flex:none}
642
+ .sm-switch[aria-checked="true"]{background:var(--accent)}
643
+ .sm-switch__knob{width:16px;height:16px;border-radius:50%;background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.25);transition:transform .15s}
644
+ .sm-switch[aria-checked="true"] .sm-switch__knob{transform:translateX(16px)}
645
+ .sm-switch:disabled{opacity:.6;cursor:default}
646
+ .sm-switch:focus-visible{box-shadow:var(--focus-ring)}
647
+ /* Radio groups and checkbox lists (f.radio, f.checkboxes): native inputs in the accent, one row per option. */
648
+ .sm-choices{display:flex;flex-direction:column;gap:8px;padding:4px 0}
649
+ .sm-choice{display:flex;align-items:center;gap:8px;font-size:var(--text-md);cursor:pointer}
650
+ .sm-choice input{accent-color:var(--accent);width:16px;height:16px;margin:0;flex:none}
651
+ /* A soft counter past its limit (SEO title and description). */
652
+ .sm-counter--over{color:var(--warn)}
653
+ /* The page glyph beside the page title in the empty-selection inspector head. */
654
+ .sm-pageglyph{width:10px;height:13px;border:1.5px solid var(--text-muted);border-radius:var(--radius-xs);flex:none}
640
655
  .sm-divider{height:1px;background:var(--line);flex:none}
641
656
  .sm-sectionlabel{display:flex;align-items:center;justify-content:space-between;gap:8px}
642
657
  .sm-sectionlabel--mono>.sm-label{
@@ -2,6 +2,7 @@
2
2
  // lives admin/server-side). Entry saves validate; page drafts do NOT
3
3
  // (drafts must save freely) — publish validates every node. Objects and
4
4
  // lists recurse; error paths are dotted (`cta.href`, `points.2.heading`).
5
+ import { optionValues } from "../fields.js";
5
6
  import { hasContent } from "./richtext-doc.js";
6
7
  import { ELEMENT_NODE, richtextBlocks } from "../richtext-walk.js";
7
8
  const subfields = (d) => d.fields && typeof d.fields === "object" ? d.fields : {};
@@ -9,7 +10,7 @@ const itemOf = (d) => d.item && typeof d.item === "object" ? d.item : undefined;
9
10
  const isEmpty = (v) => v === undefined || v === null || v === "" || (Array.isArray(v) && v.length === 0);
10
11
  /** An object whose every subfield is optional — or is itself an object that
11
12
  * may be absent — may be absent altogether. */
12
- const mayBeAbsent = (d) => d.optional === true || (d.type === "object" && Object.values(subfields(d)).every(mayBeAbsent));
13
+ const mayBeAbsent = (d) => d.optional === true || d.type === "boolean" || (d.type === "object" && Object.values(subfields(d)).every(mayBeAbsent));
13
14
  const plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
14
15
  function validateValue(path, value, d, errors, elements) {
15
16
  if (isEmpty(value)) {
@@ -69,8 +70,28 @@ function validateValue(path, value, d, errors, elements) {
69
70
  validateValue(`${path}.${k}`, value[k], sub, errors, elements);
70
71
  return;
71
72
  }
72
- if (d.type === "select" && Array.isArray(d.options) && !d.options.includes(value)) {
73
- errors.push({ field: path, message: `Must be one of: ${d.options.join(", ")}.` });
73
+ if (d.type === "boolean") {
74
+ if (typeof value !== "boolean")
75
+ errors.push({ field: path, message: "Must be on or off." });
76
+ return;
77
+ }
78
+ if (d.type === "select" || d.type === "radio") {
79
+ const allowed = optionValues(d.options);
80
+ if (allowed.length > 0 && !allowed.includes(value)) {
81
+ errors.push({ field: path, message: `Must be one of: ${allowed.join(", ")}.` });
82
+ }
83
+ return;
84
+ }
85
+ if (d.type === "checkboxes") {
86
+ if (!Array.isArray(value)) {
87
+ errors.push({ field: path, message: "Must be a list." });
88
+ return;
89
+ }
90
+ const allowed = optionValues(d.options);
91
+ if (value.some((v) => !allowed.includes(v))) {
92
+ errors.push({ field: path, message: `Must be one of: ${allowed.join(", ")}.` });
93
+ }
94
+ return;
74
95
  }
75
96
  if (typeof d.max === "number" && typeof value === "string" && value.length > d.max) {
76
97
  errors.push({ field: path, message: `Must be at most ${d.max} characters.` });
@@ -8,12 +8,13 @@ export type EntryOrder = "manual" | {
8
8
  };
9
9
  export declare const DEFAULT_ORDER: EntryOrder;
10
10
  export declare const BUILT_IN_ORDER_FIELDS: readonly ["createdAt", "updatedAt"];
11
- export type CollectionSchema<F extends BuilderMap = BuilderMap> = {
11
+ export type CollectionSchema<F extends BuilderMap = BuilderMap, N extends string = string> = {
12
12
  /** "shared" marks a code-declared singleton (spec 2026-09-07 decision
13
13
  * 4): a shared item IS a collection with one entry, so both entry
14
14
  * stores accept it in the same list and branch on this. */
15
15
  kind: "collection" | "shared";
16
- name: string;
16
+ /** Literal when declared inline (spec 2026-09-11 §6), so a queries object can check names. */
17
+ name: N;
17
18
  title: string;
18
19
  titleField?: string;
19
20
  fields: Record<string, Descriptor>;
@@ -38,8 +39,8 @@ export type CollectionSchema<F extends BuilderMap = BuilderMap> = {
38
39
  };
39
40
  export declare const collectionOrder: (c: CollectionSchema<any>) => EntryOrder;
40
41
  export declare const collectionDepth: (c: CollectionSchema<any>) => number;
41
- export declare function collection<F extends BuilderMap>(input: {
42
- name: string;
42
+ export declare function collection<F extends BuilderMap, const N extends string = string>(input: {
43
+ name: N;
43
44
  title: string;
44
45
  titleField?: string;
45
46
  fields: F;
@@ -52,4 +53,4 @@ export declare function collection<F extends BuilderMap>(input: {
52
53
  listColumns?: string[];
53
54
  };
54
55
  versions?: boolean;
55
- }): CollectionSchema<F>;
56
+ }): CollectionSchema<F, N>;
@@ -39,10 +39,27 @@ export type EntryUsage = {
39
39
  sourceId: string;
40
40
  sourceField: string;
41
41
  };
42
+ export type WhereValue = string | number | boolean;
43
+ /** Equality (scalar) or any-of (array) on top-level SHARED fields (spec 2026-09-11 §3). */
44
+ export type Where = Record<string, WhereValue | WhereValue[]>;
45
+ /** The field types whose value is an array: a scalar `where` on one is
46
+ * containment, and any-of on one is refused by the site layer (both
47
+ * adapters read the text form of the whole array, never its elements). */
48
+ export declare const ARRAY_FIELD_TYPES: ReadonlySet<string>;
49
+ /** The memory adapter's and the tests' reading of `where`: a scalar matches an
50
+ * equal value or an array holding it (JSONB containment); an array is any-of,
51
+ * compared as text like PostgREST's `->>`. */
52
+ export declare function matchesWhere(fields: Record<string, unknown>, where: Where): boolean;
42
53
  export type ListOptions = {
54
+ /** Equality / any-of on shared fields, executed by the store (spec 2026-09-11 §3). */
55
+ where?: Where;
56
+ /** Applied after order and after the `status` filter. */
57
+ limit?: number;
43
58
  /** Absent = the collection's configured order (config); `"manual"` sorts by `sort` asc. */
44
59
  order?: EntryOrder;
45
- /** With `status`: the locale whose row is inspected; the default locale when omitted. */
60
+ /** Alone: only entries PRESENT in this locale (spec 2026-09-11 §3, so `limit`
61
+ * counts rows that are there). With `status`: the locale whose row is
62
+ * inspected; the default locale when only `status` is given. */
46
63
  locale?: string;
47
64
  /** Only entries whose row in `locale` is published. */
48
65
  status?: "published";
@@ -16,6 +16,24 @@ import { MemoryPathIndex } from "./path-index.js";
16
16
  import { sharedEntryId } from "./shared-id.js";
17
17
  import { DEFAULT_SPACE_ID } from "./sql-space.js";
18
18
  import { assertDepth, assertSupportedLocale, chainOf, entryPathRows, slugOf, unsupportedLocale, } from "./paths.js";
19
+ /** The field types whose value is an array: a scalar `where` on one is
20
+ * containment, and any-of on one is refused by the site layer (both
21
+ * adapters read the text form of the whole array, never its elements). */
22
+ export const ARRAY_FIELD_TYPES = new Set(["checkboxes", "refList", "list"]);
23
+ /** PostgREST's `->>`: a scalar's text, an array's or object's JSON text (Postgres
24
+ * spaces an object's text differently, but no scalar equals either form). */
25
+ const textForm = (value) => (value !== null && typeof value === "object" ? JSON.stringify(value) : String(value));
26
+ /** The memory adapter's and the tests' reading of `where`: a scalar matches an
27
+ * equal value or an array holding it (JSONB containment); an array is any-of,
28
+ * compared as text like PostgREST's `->>`. */
29
+ export function matchesWhere(fields, where) {
30
+ return Object.entries(where).every(([key, want]) => {
31
+ const have = fields[key];
32
+ if (Array.isArray(want))
33
+ return want.some((w) => String(w) === textForm(have));
34
+ return Array.isArray(have) ? have.includes(want) : have === want;
35
+ });
36
+ }
19
37
  export function schemaOf(collections, name) {
20
38
  const schema = collections.find((c) => c.name === name);
21
39
  if (!schema)
@@ -307,13 +325,20 @@ export class MemoryEntryStore {
307
325
  async list(collection, options) {
308
326
  const schema = this.schema(collection);
309
327
  let rows = [...this.entries.values()].filter((e) => e.collection === collection);
310
- if (options?.status) {
328
+ if (options?.status || options?.locale !== undefined) {
311
329
  const locale = options.locale ?? this.locales.default;
312
- rows = rows.filter((e) => e.locales[locale]?.status === options.status);
330
+ rows = rows.filter((e) => {
331
+ const row = e.locales[locale];
332
+ return row !== undefined && (!options.status || row.status === options.status);
333
+ });
313
334
  }
314
335
  if (options?.parent !== undefined)
315
336
  rows = rows.filter((e) => e.parentId === options.parent);
337
+ if (options?.where)
338
+ rows = rows.filter((e) => matchesWhere(e.fields, options.where));
316
339
  rows.sort(compareEntries(options?.order ?? collectionOrder(schema), (e) => this.order.get(e.id)));
340
+ if (typeof options?.limit === "number")
341
+ rows = rows.slice(0, options.limit);
317
342
  return rows.map((e) => this.snapshot(e));
318
343
  }
319
344
  async getMany(collection, ids, locale, options) {
@@ -0,0 +1,8 @@
1
+ type Defaultable = {
2
+ type?: string;
3
+ default?: unknown;
4
+ };
5
+ export declare function defaultsOf(descriptors: Record<string, Defaultable>): Record<string, unknown>;
6
+ /** `values` with every absent key filled from the descriptors' defaults; a new object. */
7
+ export declare function withDefaults(values: Record<string, unknown> | undefined, descriptors: Record<string, Defaultable>): Record<string, unknown>;
8
+ export {};
@@ -0,0 +1,18 @@
1
+ export function defaultsOf(descriptors) {
2
+ const out = {};
3
+ for (const [key, d] of Object.entries(descriptors)) {
4
+ if (d.default !== undefined)
5
+ out[key] = d.default;
6
+ else if (d.type === "boolean")
7
+ out[key] = false;
8
+ }
9
+ return out;
10
+ }
11
+ /** `values` with every absent key filled from the descriptors' defaults; a new object. */
12
+ export function withDefaults(values, descriptors) {
13
+ const out = defaultsOf(descriptors);
14
+ for (const [key, v] of Object.entries(values ?? {}))
15
+ if (v !== undefined)
16
+ out[key] = v;
17
+ return out;
18
+ }
package/dist/fields.d.ts CHANGED
@@ -4,6 +4,25 @@ export type Descriptor = {
4
4
  type: string;
5
5
  [key: string]: unknown;
6
6
  };
7
+ /** A choice: what is stored (`value`) and what the editor reads (`label`). */
8
+ export type Option<V extends string = string> = {
9
+ value: V;
10
+ label: string;
11
+ };
12
+ /** What a schema may write: an option object, or a bare string as shorthand for value = label. */
13
+ export type OptionInput<V extends string = string> = V | Option<V>;
14
+ /** The stored value of one option input. */
15
+ export type OptionValue<T> = T extends string ? T : T extends {
16
+ value: infer V extends string;
17
+ } ? V : never;
18
+ /** Normalizes a schema's option list to objects; refuses an empty list,
19
+ * since a choice with nothing to choose is a schema bug (spec 2026-09-10
20
+ * page fields §1). */
21
+ export declare function normalizeOptions<V extends string>(options: readonly OptionInput<V>[]): Option<V>[];
22
+ /** The options of a descriptor in either shape (objects since spec 2026-09-10; bare strings in older data). */
23
+ export declare function optionsOf(options: unknown): Option[];
24
+ /** The values a stored choice may take. */
25
+ export declare const optionValues: (options: unknown) => string[];
7
26
  export type FieldBuilder<Value = unknown, Flags extends BuilderFlags = {}> = {
8
27
  readonly descriptor: Descriptor;
9
28
  optional(): FieldBuilder<Value, Flags & {
@@ -65,7 +84,13 @@ export declare const f: {
65
84
  toolset?: Toolset;
66
85
  }) => FieldBuilder<unknown, {}>;
67
86
  dataAttributes: () => FieldBuilder<Record<string, string>, {}>;
68
- select: <const O extends readonly string[]>(options: O) => FieldBuilder<O[number], {}>;
87
+ boolean: () => FieldBuilder<boolean, {}>;
88
+ /** One value from a list, shown as a dropdown. */
89
+ select: <const O extends readonly OptionInput[]>(options: O) => FieldBuilder<OptionValue<O[number]>, {}>;
90
+ /** One value from a list, shown as a radio group. */
91
+ radio: <const O extends readonly OptionInput[]>(options: O) => FieldBuilder<OptionValue<O[number]>, {}>;
92
+ /** Several values from a list, stored as an array, shown as a checkbox list. */
93
+ checkboxes: <const O extends readonly OptionInput[]>(options: O) => FieldBuilder<OptionValue<O[number]>[], {}>;
69
94
  object: <S extends Record<string, FieldBuilder<any, any>>>(shape: S & { [K in keyof S]: NotLocalized<S[K]>; }) => FieldBuilder<{ [K in keyof S]: ValueOf<S[K]>; }, {}>;
70
95
  /** A repeating item: any builder, but no list at any depth inside the item
71
96
  * — a list in an object in a list is still a nested list on screen, and a
package/dist/fields.js CHANGED
@@ -3,6 +3,30 @@
3
3
  // keyed by descriptor type. Chaining is immutable.
4
4
  import { DEFAULT_TOOLSET } from "./toolset.js";
5
5
  import { parseAspect } from "./media.js";
6
+ /** Normalizes a schema's option list to objects; refuses an empty list,
7
+ * since a choice with nothing to choose is a schema bug (spec 2026-09-10
8
+ * page fields §1). */
9
+ export function normalizeOptions(options) {
10
+ if (!Array.isArray(options) || options.length === 0)
11
+ throw new Error("smoodly: a choice field needs at least one option.");
12
+ return options.map((o) => (typeof o === "string" ? { value: o, label: o } : { value: o.value, label: o.label }));
13
+ }
14
+ /** The options of a descriptor in either shape (objects since spec 2026-09-10; bare strings in older data). */
15
+ export function optionsOf(options) {
16
+ if (!Array.isArray(options))
17
+ return [];
18
+ return options.flatMap((o) => {
19
+ if (typeof o === "string")
20
+ return [{ value: o, label: o }];
21
+ if (o && typeof o === "object" && typeof o.value === "string") {
22
+ const opt = o;
23
+ return [{ value: opt.value, label: typeof opt.label === "string" ? opt.label : opt.value }];
24
+ }
25
+ return [];
26
+ });
27
+ }
28
+ /** The values a stored choice may take. */
29
+ export const optionValues = (options) => optionsOf(options).map((o) => o.value);
6
30
  function builder(descriptor) {
7
31
  const chain = (patch) => builder({ ...descriptor, ...patch });
8
32
  return {
@@ -74,7 +98,13 @@ export const f = {
74
98
  file: () => builder({ type: "file" }),
75
99
  richtext: (opts) => builder({ type: "richtext", toolset: opts?.toolset ?? DEFAULT_TOOLSET }),
76
100
  dataAttributes: () => builder({ type: "dataAttributes" }),
77
- select: (options) => builder({ type: "select", options: [...options] }),
101
+ boolean: () => builder({ type: "boolean" }),
102
+ /** One value from a list, shown as a dropdown. */
103
+ select: (options) => builder({ type: "select", options: normalizeOptions(options) }),
104
+ /** One value from a list, shown as a radio group. */
105
+ radio: (options) => builder({ type: "radio", options: normalizeOptions(options) }),
106
+ /** Several values from a list, stored as an array, shown as a checkbox list. */
107
+ checkboxes: (options) => builder({ type: "checkboxes", options: normalizeOptions(options) }),
78
108
  object: (shape) => {
79
109
  for (const [k, v] of Object.entries(shape))
80
110
  assertContainerItem("f.object", v.descriptor, k);
package/dist/index.d.ts CHANGED
@@ -13,7 +13,9 @@ export type { RichTextProps, RichTextElement, RichTextImageProps } from "./richt
13
13
  export type { PageSchema } from "./page.ts";
14
14
  export { fixedSlug } from "./page.ts";
15
15
  export type { EntryPageSchema, PairedEntryPage, EntryPageProps } from "./entry-page.tsx";
16
- export { f } from "./fields.ts";
16
+ export { f, normalizeOptions, optionsOf, optionValues } from "./fields.ts";
17
+ export { defaultsOf, withDefaults } from "./field-defaults.ts";
18
+ export { filterPageTree } from "./page-tree.ts";
17
19
  export { ASSET_COLLECTION, DEFAULT_MEDIA, MEDIA_TYPES, FULL_CROP, CENTER_HOTSPOT, parseAspect, cropAspect, clamp01, isMediaValue, collectAssetIds, applyAssets, } from "./media.ts";
18
20
  export type { Crop, Hotspot, AssetKind, ImageValue, VideoValue, FileValue, AssetMeta, AssetRecord, ResolvedImage, ResolvedVideo, ResolvedFile, } from "./media.ts";
19
21
  export { z } from "./zones.ts";
@@ -35,9 +37,9 @@ export type { PathIndex } from "./path-index.ts";
35
37
  export { MemoryEntryStore } from "./entry-store.ts";
36
38
  export { SupabaseEntryStore } from "./supabase-entry-store.ts";
37
39
  export { SupabasePathIndex } from "./supabase-path-index.ts";
38
- export type { EntryStore, EntryRecord, EntryLocale, EntryVersion, EntryUsage, ListOptions, CreateEntryOptions, AddEntryLocaleOptions, EntryStoreOptions, } from "./entry-store.ts";
39
- export { entryErrors, compareEntries, DEFAULT_ENTRY_LOCALES, localizedKeys, splitFields, entryLocaleRow, entryFieldsIn, entryRefEdges, entryTitleIn, } from "./entry-store.ts";
40
- export { affectedTargets, pageTag, entryTag, sharedTag } from "./revalidate.ts";
40
+ export type { EntryStore, EntryRecord, EntryLocale, EntryVersion, EntryUsage, ListOptions, CreateEntryOptions, AddEntryLocaleOptions, EntryStoreOptions, Where, WhereValue, } from "./entry-store.ts";
41
+ export { entryErrors, compareEntries, DEFAULT_ENTRY_LOCALES, localizedKeys, splitFields, entryLocaleRow, entryFieldsIn, entryRefEdges, entryTitleIn, matchesWhere, } from "./entry-store.ts";
42
+ export { affectedTargets, pageTag, entryTag, sharedTag, pagesTreeTag, collectionTag } from "./revalidate.ts";
41
43
  export type { AffectedTargets } from "./revalidate.ts";
42
44
  export { resolveTree, resolveFields, resolveMedia } from "./resolve.ts";
43
45
  export type { EntryFetcher, ResolveFieldsOptions, AssetFetcher } from "./resolve.ts";
@@ -47,7 +49,7 @@ export { fixedPathFor } from "./localize.ts";
47
49
  export type { PageStore, PageRecord, PageLocale, PageVersion, PageStoreOptions, CreatePageInput, AddLocaleOptions } from "./store.ts";
48
50
  export { pathOptions, pageErrors, localeRow, pageRefEdges, DEFAULT_LOCALES } from "./store.ts";
49
51
  export type { SmoodlyConfig, ResolvedConfig } from "./config.ts";
50
- export type { FieldBuilder, Descriptor, ValueOf } from "./fields.ts";
52
+ export type { FieldBuilder, Descriptor, ValueOf, Option, OptionInput, OptionValue } from "./fields.ts";
51
53
  export type { SectionSchema, ElementSchema, BuilderMap } from "./schema.ts";
52
54
  export type { Props, PairedComponent } from "./pairing.tsx";
53
55
  export type { CollectionSchema, EntryOrder } from "./collections.ts";
@@ -138,4 +140,4 @@ export { resolveSmoodlyEnv, smoodlyEnv } from "./env.ts";
138
140
  export type { SmoodlyEnv } from "./env.ts";
139
141
  export { clientOptions, createSmoodlyClient } from "./client.ts";
140
142
  export { createSmoodlySite, pageContextOf } from "./site.ts";
141
- export type { SiteOptions, SitePage, SiteEntry, SiteHit, SmoodlySite, LoadOptions, PageTreeItem, SiteEntryItem, EntryTreeItem, EntryQuery, } from "./site.ts";
143
+ export type { SiteOptions, SitePage, SitePageSchema, SiteEntry, SiteHit, SmoodlySite, LoadOptions, PageTreeItem, SiteEntryItem, EntryTreeItem, EntryQuery, } from "./site.ts";
package/dist/index.js CHANGED
@@ -10,7 +10,9 @@ import { marks } from "./marks.js";
10
10
  export { Zone, renderPage, EMPTY_PAGE_CONTEXT } from "./render.js";
11
11
  export { RichText, defaultRichTextImage } from "./richtext-render.js";
12
12
  export { fixedSlug } from "./page.js";
13
- export { f } from "./fields.js";
13
+ export { f, normalizeOptions, optionsOf, optionValues } from "./fields.js";
14
+ export { defaultsOf, withDefaults } from "./field-defaults.js";
15
+ export { filterPageTree } from "./page-tree.js";
14
16
  export { ASSET_COLLECTION, DEFAULT_MEDIA, MEDIA_TYPES, FULL_CROP, CENTER_HOTSPOT, parseAspect, cropAspect, clamp01, isMediaValue, collectAssetIds, applyAssets, } from "./media.js";
15
17
  export { z } from "./zones.js";
16
18
  export { defineConfig } from "./config.js";
@@ -27,8 +29,8 @@ export { MemoryPathIndex } from "./path-index.js";
27
29
  export { MemoryEntryStore } from "./entry-store.js";
28
30
  export { SupabaseEntryStore } from "./supabase-entry-store.js";
29
31
  export { SupabasePathIndex } from "./supabase-path-index.js";
30
- export { entryErrors, compareEntries, DEFAULT_ENTRY_LOCALES, localizedKeys, splitFields, entryLocaleRow, entryFieldsIn, entryRefEdges, entryTitleIn, } from "./entry-store.js";
31
- export { affectedTargets, pageTag, entryTag, sharedTag } from "./revalidate.js";
32
+ export { entryErrors, compareEntries, DEFAULT_ENTRY_LOCALES, localizedKeys, splitFields, entryLocaleRow, entryFieldsIn, entryRefEdges, entryTitleIn, matchesWhere, } from "./entry-store.js";
33
+ export { affectedTargets, pageTag, entryTag, sharedTag, pagesTreeTag, collectionTag } from "./revalidate.js";
32
34
  export { resolveTree, resolveFields, resolveMedia } from "./resolve.js";
33
35
  export { SEGMENT_RE, isSegment, assertSegment, joinPath, splitPath, segmentFor, isHomeNode, pagePathIn, chainOf, assertDepth, pagePath, pagePathRows, entryPathRows, collectionSegment, buildPageTree, perLocaleSegments, assertSupportedLocale, unsupportedLocale, } from "./paths.js";
34
36
  export { fixedPathFor } from "./localize.js";
@@ -1,3 +1,5 @@
1
1
  export { createPageRenderer } from "./page-renderer.tsx";
2
2
  export type { PageRendererOptions, PathRenderOptions, PageRegistration } from "./page-renderer.tsx";
3
3
  export { metadataFrom } from "./meta.ts";
4
+ export { createSmoodlyQueries } from "./queries.ts";
5
+ export type { SmoodlyQueries, QueryOptions, EntriesQuery, TypedEntryItem, SharedValue } from "./queries.ts";
@@ -1,2 +1,3 @@
1
1
  export { createPageRenderer } from "./page-renderer.js";
2
2
  export { metadataFrom } from "./meta.js";
3
+ export { createSmoodlyQueries } from "./queries.js";
@@ -1,4 +1,8 @@
1
1
  import type { Metadata } from "next";
2
- /** Maps the page tree's meta bag onto Next metadata. Absent meta returns
3
- * {} so the app's own layout.tsx defaults apply untouched. */
4
- export declare function metadataFrom(meta: unknown): Metadata;
2
+ /** Maps the page tree's meta onto Next metadata (spec 2026-09-10 page
3
+ * fields §4). The SEO title falls back to the page's own title so every
4
+ * page carries one; absent meta and no title returns {} so the app's
5
+ * layout.tsx defaults apply untouched. */
6
+ export declare function metadataFrom(meta: unknown, page?: {
7
+ title?: string;
8
+ }): Metadata;
package/dist/next/meta.js CHANGED
@@ -1,13 +1,15 @@
1
- /** Maps the page tree's meta bag onto Next metadata. Absent meta returns
2
- * {} so the app's own layout.tsx defaults apply untouched. */
3
- export function metadataFrom(meta) {
4
- if (typeof meta !== "object" || meta === null || Array.isArray(meta))
5
- return {};
6
- const m = meta;
1
+ import { imageUrl } from "../image/loaders.js";
2
+ const isResolvedImage = (v) => typeof v === "object" && v !== null && typeof v.url === "string";
3
+ /** Maps the page tree's meta onto Next metadata (spec 2026-09-10 page
4
+ * fields §4). The SEO title falls back to the page's own title so every
5
+ * page carries one; absent meta and no title returns {} so the app's
6
+ * layout.tsx defaults apply untouched. */
7
+ export function metadataFrom(meta, page) {
8
+ const m = typeof meta === "object" && meta !== null && !Array.isArray(meta) ? meta : {};
7
9
  const str = (key) => (typeof m[key] === "string" && m[key] ? m[key] : undefined);
8
- const title = str("title");
10
+ const title = str("title") ?? (page?.title ? page.title : undefined);
9
11
  const description = str("description");
10
- const ogImage = str("ogImage");
12
+ const ogImage = m.ogImage;
11
13
  const out = {};
12
14
  if (title)
13
15
  out.title = title;
@@ -18,9 +20,16 @@ export function metadataFrom(meta) {
18
20
  og.title = title;
19
21
  if (description)
20
22
  og.description = description;
21
- if (ogImage)
23
+ if (isResolvedImage(ogImage)) {
24
+ // The delivery seam decides the URL; the cloud's transform loader (OQ 28) can serve a real 1200-wide render.
25
+ og.images = [{ url: imageUrl(ogImage, { width: 1200 }), width: ogImage.width, height: ogImage.height, ...(ogImage.alt ? { alt: ogImage.alt } : {}) }];
26
+ }
27
+ else if (typeof ogImage === "string" && ogImage) {
22
28
  og.images = [ogImage];
29
+ }
23
30
  if (Object.keys(og).length > 0)
24
31
  out.openGraph = og;
32
+ if (m.noindex === true)
33
+ out.robots = { index: false, follow: false };
25
34
  return out;
26
35
  }
@@ -73,7 +73,7 @@ export function createPageRenderer(options) {
73
73
  const hit = await load(locale, path, draft);
74
74
  if (!hit || !accept(hit))
75
75
  return {};
76
- return { ...(hit.kind === "page" ? metadataFrom(hit.tree.meta) : {}), ...alternates(hit) };
76
+ return { ...(hit.kind === "page" ? metadataFrom(hit.tree.meta, { title: hit.title }) : {}), ...alternates(hit) };
77
77
  }
78
78
  const any = () => true;
79
79
  /** The escape hatch may only render ITS OWN registration's record. */
@@ -0,0 +1,36 @@
1
+ import type { CollectionSchema } from "../collections.ts";
2
+ import type { EntryRecord } from "../entry-store.ts";
3
+ import type { SharedSchema } from "../shared.ts";
4
+ import type { EntryQuery, PageTreeItem, SiteEntryItem, SitePage, SmoodlySite } from "../site.ts";
5
+ export type QueryOptions = {
6
+ locale?: string;
7
+ };
8
+ export type EntriesQuery = Omit<EntryQuery, "locale" | "draft"> & QueryOptions;
9
+ type ByName<T, N> = Extract<T, {
10
+ name: N;
11
+ }>;
12
+ /** Fields typed from the schema; a widened `CollectionSchema[]` array (names are `string`) falls back to the untyped record instead of `never`. */
13
+ type EntryFields<C extends readonly CollectionSchema<any, string>[], N> = string extends C[number]["name"] ? Record<string, unknown> : NonNullable<ByName<C[number], N>["__entry"]>;
14
+ /** A list item whose `entry.fields` are the collection's typed values. */
15
+ export type TypedEntryItem<C extends readonly CollectionSchema<any, string>[], N> = Omit<SiteEntryItem, "entry"> & {
16
+ entry: Omit<EntryRecord, "fields"> & {
17
+ fields: EntryFields<C, N>;
18
+ };
19
+ };
20
+ export type SharedValue<S extends readonly SharedSchema<any, string>[], N> = string extends S[number]["name"] ? Record<string, unknown> : NonNullable<ByName<S[number], N>["__value"]>;
21
+ export type SmoodlyQueries<C extends readonly CollectionSchema<any, string>[], S extends readonly SharedSchema<any, string>[]> = {
22
+ /** The published page tree in a locale, every item with its page fields. */
23
+ getPageTree(options?: QueryOptions): Promise<PageTreeItem[]>;
24
+ /** A page by locale-relative path ("/company/about-us") or id. */
25
+ getPage(pathOrId: string, options?: QueryOptions): Promise<SitePage | null>;
26
+ /** Published entries of a collection: `where` (shared fields), `order`, `limit`, `parent` run in the store. */
27
+ getEntries<N extends C[number]["name"]>(collection: N, options?: EntriesQuery): Promise<TypedEntryItem<C, N>[]>;
28
+ getEntry<N extends C[number]["name"]>(collection: N, idOrPath: string, options?: QueryOptions): Promise<TypedEntryItem<C, N> | null>;
29
+ getShared<N extends S[number]["name"]>(item: N, options?: QueryOptions): Promise<SharedValue<S, N> | null>;
30
+ };
31
+ export declare function createSmoodlyQueries<const C extends readonly CollectionSchema<any, string>[], const S extends readonly SharedSchema<any, string>[] = []>(options: {
32
+ site: SmoodlySite;
33
+ collections: C;
34
+ shared?: S;
35
+ }): SmoodlyQueries<C, S>;
36
+ export {};