smoodly 0.0.34 → 0.0.36

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 (79) hide show
  1. package/README.md +807 -1326
  2. package/dist/admin/drift.js +3 -2
  3. package/dist/admin/editor/EditorView.js +10 -3
  4. package/dist/admin/editor/SeoSettings.d.ts +7 -1
  5. package/dist/admin/editor/SeoSettings.js +2 -2
  6. package/dist/admin/editor/autosave.d.ts +6 -1
  7. package/dist/admin/editor/autosave.js +43 -3
  8. package/dist/admin/field-label.d.ts +2 -0
  9. package/dist/admin/field-label.js +2 -0
  10. package/dist/admin/forms/FieldWidget.js +2 -2
  11. package/dist/admin/forms/list.js +10 -1
  12. package/dist/admin/next/create-admin.d.ts +4 -4
  13. package/dist/admin/next/create-admin.js +5 -5
  14. package/dist/admin/next/index.d.ts +1 -1
  15. package/dist/admin/serialize.d.ts +8 -1
  16. package/dist/admin/serialize.js +23 -7
  17. package/dist/admin/shell/AdminApp.d.ts +3 -3
  18. package/dist/admin/shell/AdminApp.js +2 -2
  19. package/dist/admin/shell/EntryForm.js +11 -3
  20. package/dist/admin/shell/GlobalForm.js +3 -2
  21. package/dist/admin/shell/presence-realtime.d.ts +2 -2
  22. package/dist/admin/shell/router.d.ts +5 -0
  23. package/dist/admin/shell/router.js +13 -0
  24. package/dist/cli/space-io.js +1 -1
  25. package/dist/collections.d.ts +4 -0
  26. package/dist/collections.js +9 -1
  27. package/dist/entry-page.d.ts +12 -10
  28. package/dist/entry-store.d.ts +29 -0
  29. package/dist/entry-store.js +53 -0
  30. package/dist/env.d.ts +5 -5
  31. package/dist/env.js +2 -2
  32. package/dist/fields.d.ts +13 -7
  33. package/dist/fields.js +2 -2
  34. package/dist/global.d.ts +4 -7
  35. package/dist/index.d.ts +10 -39
  36. package/dist/index.js +1 -1
  37. package/dist/migrations.js +1 -1
  38. package/dist/next/canvas-slot.d.ts +7 -0
  39. package/dist/next/canvas-slot.js +9 -0
  40. package/dist/next/index.d.ts +3 -1
  41. package/dist/next/index.js +3 -1
  42. package/dist/next/meta.d.ts +6 -4
  43. package/dist/next/meta.js +14 -17
  44. package/dist/next/page-renderer.d.ts +2 -0
  45. package/dist/next/page-renderer.js +18 -11
  46. package/dist/next/preview-pill.d.ts +9 -0
  47. package/dist/next/preview-pill.js +56 -0
  48. package/dist/next/preview-route.d.ts +1 -0
  49. package/dist/next/preview-route.js +30 -0
  50. package/dist/next/queries.d.ts +10 -11
  51. package/dist/next/queries.js +7 -2
  52. package/dist/page.d.ts +5 -2
  53. package/dist/page.js +3 -1
  54. package/dist/pairing.d.ts +10 -10
  55. package/dist/path-index.d.ts +3 -0
  56. package/dist/path-index.js +7 -0
  57. package/dist/read-shapes.d.ts +64 -0
  58. package/dist/read-shapes.js +52 -0
  59. package/dist/render.d.ts +3 -3
  60. package/dist/resolve.d.ts +12 -0
  61. package/dist/resolve.js +29 -7
  62. package/dist/schema-version.d.ts +1 -1
  63. package/dist/schema-version.js +1 -1
  64. package/dist/schema.d.ts +22 -1
  65. package/dist/seo.d.ts +18 -0
  66. package/dist/seo.js +53 -0
  67. package/dist/site.d.ts +15 -16
  68. package/dist/site.js +64 -58
  69. package/dist/sql-space.d.ts +15 -0
  70. package/dist/sql-space.js +54 -39
  71. package/dist/sql.d.ts +11 -1
  72. package/dist/sql.js +78 -25
  73. package/dist/supabase-entry-store.d.ts +3 -1
  74. package/dist/supabase-entry-store.js +39 -0
  75. package/dist/supabase-path-index.d.ts +1 -0
  76. package/dist/supabase-path-index.js +16 -0
  77. package/migrations/20260929135523_smoodly_v6.sql +200 -0
  78. package/migrations/20260930040956_smoodly_v7.sql +84 -0
  79. package/package.json +1 -5
@@ -0,0 +1,56 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // The preview pill (spec 2026-09-29 pre-install fixes §2): while draft mode
3
+ // is on, outside the editor's canvas, a floating pill says the page shows
4
+ // unpublished changes and offers Exit preview. It is an inline script
5
+ // defining a custom element with its styles in a Shadow DOM — no React,
6
+ // no client module, never in the site's bundles (the live-page rule, §1).
7
+ // The renderer emits it only in draft mode, so a live page's HTML is
8
+ // unchanged. A strict Content-Security-Policy blocks the inline script;
9
+ // the Next.js guide says so. The script is a constant: the route rides on
10
+ // the element, so nothing is interpolated into code.
11
+ import { createElement } from "react";
12
+ const CSS = ":host{all:initial}:host([hidden]){display:none}" +
13
+ ".pill{position:fixed;bottom:16px;z-index:2147483647;display:flex;align-items:center;gap:8px;padding:6px 6px 6px 14px;" +
14
+ "border-radius:999px;background:#151727;color:#fff;font:500 13px/1.2 system-ui,-apple-system,sans-serif;" +
15
+ "box-shadow:0 4px 16px rgba(0,0,0,.25);cursor:grab;user-select:none;touch-action:none}" +
16
+ ".pill[data-pos=left]{left:16px}.pill[data-pos=center]{left:50%;transform:translateX(-50%)}.pill[data-pos=right]{right:16px}" +
17
+ ".pill.dragging{cursor:grabbing}" +
18
+ "a{color:#151727;background:#fff;border-radius:999px;padding:5px 10px;text-decoration:none;font-weight:600}" +
19
+ "button{all:unset;cursor:pointer;width:24px;height:24px;border-radius:999px;display:grid;place-items:center;color:#fff;opacity:.7}" +
20
+ "button:hover,button:focus-visible{opacity:1;outline:2px solid #fff}";
21
+ const HTML = '<div class="pill" role="region" aria-label="Smoodly preview">' +
22
+ "<span>Preview \\u00b7 unpublished changes</span>" +
23
+ '<a href="#">Exit preview</a>' +
24
+ '<button type="button" aria-label="Hide until reload">\\u00d7</button>' +
25
+ "</div>";
26
+ /** The pill's whole client side. `closed` lives in the closure, so a hidden
27
+ * pill stays hidden across client navigations and returns on a reload. */
28
+ export const PREVIEW_PILL_SCRIPT = "(function(){" +
29
+ 'if(customElements.get("smoodly-preview-pill"))return;' +
30
+ 'var KEY="smoodly-pill-position",closed=false;' +
31
+ 'function load(){try{var v=localStorage.getItem(KEY);return v==="left"||v==="right"?v:"center"}catch(e){return"center"}}' +
32
+ "function save(v){try{localStorage.setItem(KEY,v)}catch(e){}}" +
33
+ "class Pill extends HTMLElement{connectedCallback(){" +
34
+ "if(closed){this.hidden=true;return}" +
35
+ "if(this.shadowRoot)return;" +
36
+ 'var root=this.attachShadow({mode:"open"}),self=this;' +
37
+ `root.innerHTML="<style>${CSS}</style>${HTML.replace(/"/g, '\\"')}";` +
38
+ 'var pill=root.querySelector(".pill"),link=root.querySelector("a"),close=root.querySelector("button");' +
39
+ 'pill.setAttribute("data-pos",load());' +
40
+ 'link.addEventListener("click",function(e){e.preventDefault();var route=self.getAttribute("data-route")||"/api/preview";' +
41
+ 'location.href=route+(route.indexOf("?")<0?"?":"&")+"disable&path="+encodeURIComponent(location.pathname+location.search)});' +
42
+ 'close.addEventListener("click",function(){closed=true;self.hidden=true});' +
43
+ "var start=null;" +
44
+ 'pill.addEventListener("pointerdown",function(e){if(e.target.closest("a,button"))return;start=e.clientX;pill.setPointerCapture(e.pointerId);pill.classList.add("dragging")});' +
45
+ 'pill.addEventListener("pointermove",function(e){if(start!==null)pill.style.translate=(e.clientX-start)+"px 0"});' +
46
+ 'pill.addEventListener("pointerup",function(e){if(start===null)return;start=null;pill.classList.remove("dragging");pill.style.translate="";' +
47
+ 'var x=e.clientX/innerWidth,pos=x<1/3?"left":x>2/3?"right":"center";pill.setAttribute("data-pos",pos);save(pos)});' +
48
+ 'pill.addEventListener("pointercancel",function(){if(start===null)return;start=null;pill.classList.remove("dragging");pill.style.translate=""});' +
49
+ "}}" +
50
+ 'customElements.define("smoodly-preview-pill",Pill);' +
51
+ "})();";
52
+ /** The pill for a route that does not go through the renderer: render it
53
+ * when `(await draftMode()).isEnabled`, never in the editor's canvas. */
54
+ export function previewPill(opts) {
55
+ return (_jsxs(_Fragment, { children: [_jsx("script", { dangerouslySetInnerHTML: { __html: PREVIEW_PILL_SCRIPT } }), createElement("smoodly-preview-pill", { "data-route": opts?.route ?? "/api/preview" })] }));
56
+ }
@@ -0,0 +1 @@
1
+ export declare function previewRoute(request: Request): Promise<Response>;
@@ -0,0 +1,30 @@
1
+ // The draft-mode switch every starter mounts at app/api/preview/route.ts
2
+ // (spec 2026-09-29 pre-install fixes §2). Turning drafts OFF is harmless,
3
+ // so `?disable` needs no secret — the preview pill's Exit links here.
4
+ // Turning them ON needs SMOODLY_PREVIEW_SECRET and fails closed when it is
5
+ // unset. Either way it returns to a same-origin path.
6
+ import { draftMode } from "next/headers";
7
+ import { redirect } from "next/navigation";
8
+ export async function previewRoute(request) {
9
+ const url = new URL(request.url);
10
+ const draft = await draftMode();
11
+ if (url.searchParams.has("disable")) {
12
+ draft.disable();
13
+ }
14
+ else {
15
+ const secret = process.env.SMOODLY_PREVIEW_SECRET;
16
+ if (!secret || url.searchParams.get("secret") !== secret)
17
+ return new Response("Invalid secret", { status: 401 });
18
+ draft.enable();
19
+ }
20
+ redirect(sameOriginPath(url.searchParams.get("path") ?? "/", url));
21
+ }
22
+ /** The path as this origin resolves it, or "/" when it would leave the site. A browser's URL parser strips
23
+ * tab, CR and LF before it resolves a Location, so "/\t/evil.test" is "//evil.test" to it: resolve the
24
+ * way it does and compare origins, and refuse control characters and backslashes outright. */
25
+ function sameOriginPath(raw, base) {
26
+ if (!raw.startsWith("/") || /[\u0000-\u001f\u007f\\]/.test(raw))
27
+ return "/";
28
+ const to = new URL(raw, base);
29
+ return to.origin === base.origin ? to.pathname + to.search + to.hash : "/";
30
+ }
@@ -1,28 +1,27 @@
1
1
  import type { CollectionSchema } from "../collections.ts";
2
- import type { EntryRecord } from "../entry-store.ts";
3
2
  import type { GlobalSchema } from "../global.ts";
4
- import type { EntryQuery, PageTreeItem, SiteEntryItem, SitePage, SmoodlySite } from "../site.ts";
3
+ import { type EntryItem, type PageItem } from "../read-shapes.ts";
4
+ import type { Materialize } from "../schema.ts";
5
+ import type { EntryQuery, PageTreeItem, SmoodlySite } from "../site.ts";
5
6
  export type QueryOptions = {
6
7
  locale?: string;
7
8
  };
8
- export type EntriesQuery = Omit<EntryQuery, "locale" | "draft"> & QueryOptions;
9
+ export type EntriesQuery = Omit<EntryQuery, "locale" | "draft" | "ids"> & QueryOptions;
9
10
  type ByName<T, N> = Extract<T, {
10
11
  name: N;
11
12
  }>;
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
- };
13
+ /** Fields typed from the schema, references materialized as `EntryRef`s; a widened `CollectionSchema[]` array (names are `string`) falls back to the untyped record instead of `never`. */
14
+ type EntryFields<C extends readonly CollectionSchema<any, string>[], N> = string extends C[number]["name"] ? Record<string, unknown> : {
15
+ [K in keyof NonNullable<ByName<C[number], N>["__entry"]>]: Materialize<NonNullable<ByName<C[number], N>["__entry"]>[K]>;
19
16
  };
17
+ /** A read item whose `fields` are the collection's typed values. */
18
+ export type TypedEntryItem<C extends readonly CollectionSchema<any, string>[], N> = EntryItem<EntryFields<C, N>>;
20
19
  export type GlobalValue<S extends readonly GlobalSchema<any, string>[], N> = string extends S[number]["name"] ? Record<string, unknown> : NonNullable<ByName<S[number], N>["__value"]>;
21
20
  export type SmoodlyQueries<C extends readonly CollectionSchema<any, string>[], S extends readonly GlobalSchema<any, string>[]> = {
22
21
  /** The published page tree in a locale, every item with its page fields. */
23
22
  getPageTree(options?: QueryOptions): Promise<PageTreeItem[]>;
24
23
  /** A page by locale-relative path ("/company/about-us") or id. */
25
- getPage(pathOrId: string, options?: QueryOptions): Promise<SitePage | null>;
24
+ getPage(pathOrId: string, options?: QueryOptions): Promise<PageItem | null>;
26
25
  /** Published entries of a collection: `where` (shared fields), `order`, `limit`, `parent` run in the store. */
27
26
  getEntries<N extends C[number]["name"]>(collection: N, options?: EntriesQuery): Promise<TypedEntryItem<C, N>[]>;
28
27
  getEntry<N extends C[number]["name"]>(collection: N, idOrPath: string, options?: QueryOptions): Promise<TypedEntryItem<C, N> | null>;
@@ -7,6 +7,7 @@ import { unstable_cache } from "next/cache";
7
7
  import { draftMode } from "next/headers";
8
8
  import { connection } from "next/server";
9
9
  import { collectionTag, entryTag, pageTag, pagesTreeTag, globalTag } from "../revalidate.js";
10
+ import { referencedCollections } from "../read-shapes.js";
10
11
  /** JSON with object keys sorted at every level and `undefined` dropped, so two spellings of one query share a unit. */
11
12
  function stable(value) {
12
13
  if (Array.isArray(value))
@@ -26,6 +27,7 @@ function entriesKey(o) {
26
27
  }
27
28
  export function createSmoodlyQueries(options) {
28
29
  const { site } = options;
30
+ const collectionByName = new Map(options.collections.map((c) => [c.name, c]));
29
31
  const globalByName = new Map((options.globals ?? []).map((s) => [s.name, s]));
30
32
  const localeOf = (o) => o?.locale ?? site.locales.default;
31
33
  /** Every query gates on the request (never a build prerender), then reads live in draft mode or cached under `tags`. */
@@ -59,7 +61,10 @@ export function createSmoodlyQueries(options) {
59
61
  getEntries: (collection, o = {}) => {
60
62
  const locale = localeOf(o);
61
63
  const { locale: _locale, ...query } = o;
62
- return run(["entries", collection, locale, entriesKey(o)], [collectionTag(collection)], (draft) => site.getEntries(collection, { ...query, locale, draft }));
64
+ // The unit also depends on every collection its references resolve into (spec 2026-09-29 content API contract §6).
65
+ const schema = collectionByName.get(collection);
66
+ const tags = [collection, ...(schema ? referencedCollections(schema, [...options.collections]) : [])];
67
+ return run(["entries", collection, locale, entriesKey(o)], [...new Set(tags)].map(collectionTag), (draft) => site.getEntries(collection, { ...query, locale, draft }));
63
68
  },
64
69
  async getEntry(collection, idOrPath, o) {
65
70
  const locale = localeOf(o);
@@ -70,7 +75,7 @@ export function createSmoodlyQueries(options) {
70
75
  const hit = await site.getEntry(collection, idOrPath, { locale, draft });
71
76
  // The site layer also answers a bare root slug, but the unit would sit under
72
77
  // 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) {
78
+ if (hit && !idOrPath.startsWith("/") && hit.id !== idOrPath) {
74
79
  throw new Error(`smoodly: getEntry("${collection}", "${idOrPath}") takes an id or a path — for a slug, pass the entry's path.`);
75
80
  }
76
81
  return hit;
package/dist/page.d.ts CHANGED
@@ -13,7 +13,8 @@ export type PageSchema<Zones extends Record<string, unknown> = Record<string, un
13
13
  * segment; the default locale's entry is required (see `fixedSlug`). */
14
14
  slug?: string | Record<string, string>;
15
15
  zones: Record<keyof Zones & string, ZonePolicy>;
16
- meta: Record<string, unknown>;
16
+ /** The SEO & Social tab exists for this template; reads return `seo` and the route's metadata uses it (spec 2026-09-29 content API contract §7). */
17
+ seo?: true;
17
18
  /** The template's own page-level fields (spec 2026-09-10 page fields §1):
18
19
  * stored in the tree per locale, typed on the view's `page.fields`. */
19
20
  fields: Record<string, Descriptor>;
@@ -27,7 +28,9 @@ export declare function pageSchema<Zones extends Record<string, object>, F exten
27
28
  title: string;
28
29
  slug?: string | Record<string, string>;
29
30
  zones: Zones;
30
- meta?: Record<string, unknown>;
31
+ seo?: true;
32
+ /** Gone: `meta: { seo: true }` is `seo: true`. */
33
+ meta?: never;
31
34
  fields?: F;
32
35
  }): PageSchema<Zones, F>;
33
36
  /** The default-locale segment of a fixed slug — what the admin lists and
package/dist/page.js CHANGED
@@ -44,6 +44,8 @@ function normalizePolicy(p) {
44
44
  export function pageSchema(input) {
45
45
  if (!input?.name)
46
46
  throw new Error("smoodly: a page requires a name.");
47
+ if (input.meta !== undefined)
48
+ throw new Error(`smoodly: page "${input.name}": meta: { seo: true } is now seo: true.`);
47
49
  // A fixed slug is decided by `slug !== undefined` at every other hop, but
48
50
  // the renderer's path lookup is truthiness-based: an empty string would read
49
51
  // as fixed to the admin and blow up in the route. Reject it at construction.
@@ -62,7 +64,7 @@ export function pageSchema(input) {
62
64
  title: input.title,
63
65
  ...(input.slug !== undefined ? { slug: input.slug } : {}),
64
66
  zones: Object.fromEntries(Object.entries(input.zones).map(([k, p]) => [k, normalizePolicy(p)])),
65
- meta: input.meta ?? {},
67
+ ...(input.seo === true ? { seo: true } : {}),
66
68
  fields,
67
69
  };
68
70
  }
package/dist/pairing.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { ComponentType, ReactElement } from "react";
2
- import type { BuilderMap, ElementSchema, NamespaceValues, SectionSchema } from "./schema.ts";
2
+ import type { BuilderMap, ElementSchema, SectionSchema, Values } from "./schema.ts";
3
3
  import type { PageContext, ZoneContent } from "./render.tsx";
4
4
  import type { StyleProps } from "./styles.ts";
5
5
  /** What the view receives. A page view gets its zones and where it sits
@@ -14,28 +14,28 @@ export type Props<S> = S extends {
14
14
  __fields?: infer F extends BuilderMap;
15
15
  } ? {
16
16
  zones: Record<keyof Z & string, ZoneContent>;
17
- page: PageContext<NamespaceValues<F>>;
17
+ page: PageContext<Values<F>>;
18
18
  } : S extends {
19
19
  __f?: infer F extends BuilderMap;
20
20
  __s?: infer St extends BuilderMap;
21
- } ? NamespaceValues<F> & {
22
- settings: NamespaceValues<St>;
21
+ } ? Values<F> & {
22
+ settings: Values<St>;
23
23
  styles: StyleProps;
24
24
  } : never;
25
25
  /** What hard-coded JSX may pass: fields flat, settings optional and partial, styles optional. */
26
- type ExternalProps<F extends BuilderMap, St extends BuilderMap> = NamespaceValues<F> & {
27
- settings?: Partial<NamespaceValues<St>>;
26
+ type ExternalProps<F extends BuilderMap, St extends BuilderMap> = Values<F> & {
27
+ settings?: Partial<Values<St>>;
28
28
  styles?: StyleProps;
29
29
  };
30
30
  export type PairedComponent<Schema, F extends BuilderMap, St extends BuilderMap> = ((props: ExternalProps<F, St>) => ReactElement) & {
31
31
  schema: Schema;
32
32
  };
33
- export declare function section<F extends BuilderMap, St extends BuilderMap>(schema: SectionSchema<F, St>, View: ComponentType<NamespaceValues<F> & {
34
- settings: NamespaceValues<St>;
33
+ export declare function section<F extends BuilderMap, St extends BuilderMap>(schema: SectionSchema<F, St>, View: ComponentType<Values<F> & {
34
+ settings: Values<St>;
35
35
  styles: StyleProps;
36
36
  }>): PairedComponent<SectionSchema<F, St>, F, St>;
37
- export declare function element<F extends BuilderMap, St extends BuilderMap>(schema: ElementSchema<F, St>, View: ComponentType<NamespaceValues<F> & {
38
- settings: NamespaceValues<St>;
37
+ export declare function element<F extends BuilderMap, St extends BuilderMap>(schema: ElementSchema<F, St>, View: ComponentType<Values<F> & {
38
+ settings: Values<St>;
39
39
  styles: StyleProps;
40
40
  }>): PairedComponent<ElementSchema<F, St>, F, St>;
41
41
  export {};
@@ -16,6 +16,8 @@ export interface PathIndex {
16
16
  resolve(locale: string, path: string): Promise<PathHit | null>;
17
17
  /** locale → the target's LIVE path. */
18
18
  pathsOf(target: PathTarget): Promise<Record<string, string>>;
19
+ /** id → the LIVE full path in one locale, for many targets at once (the resolver's EntryRef paths). */
20
+ livePathsIn(kind: "page" | "entry", ids: string[], locale: string): Promise<Record<string, string>>;
19
21
  /** The target's redirect rows. */
20
22
  redirectsOf(target: PathTarget): Promise<PathRow[]>;
21
23
  listLive(locale: string): Promise<PathRow[]>;
@@ -40,6 +42,7 @@ export declare class MemoryPathIndex implements PathIndex {
40
42
  private hit;
41
43
  resolve(locale: string, path: string): Promise<PathHit | null>;
42
44
  pathsOf(target: PathTarget): Promise<Record<string, string>>;
45
+ livePathsIn(kind: "page" | "entry", ids: string[], locale: string): Promise<Record<string, string>>;
43
46
  redirectsOf(target: PathTarget): Promise<PathRow[]>;
44
47
  /** Every live URL in the locale; an editor's row under a code address is left out, since visitors never reach it (spec §4). */
45
48
  listLive(locale: string): Promise<PathRow[]>;
@@ -61,6 +61,13 @@ export class MemoryPathIndex {
61
61
  out[r.locale] = r.path;
62
62
  return out;
63
63
  }
64
+ async livePathsIn(kind, ids, locale) {
65
+ const wanted = new Set(ids);
66
+ const out = {};
67
+ for (const r of this.shownAll(this.rows.filter((r) => !r.redirect && r.kind === kind && r.locale === locale && wanted.has(r.id))))
68
+ out[r.id] = r.path;
69
+ return out;
70
+ }
64
71
  async redirectsOf(target) {
65
72
  return this.shownAll(this.rows.filter((r) => r.redirect && sameTarget(r, target)));
66
73
  }
@@ -0,0 +1,64 @@
1
+ import type { CollectionSchema } from "./collections.ts";
2
+ import type { EntryRecord } from "./entry-store.ts";
3
+ import type { LocaleAlternate, PageAlternate } from "./paths.ts";
4
+ import type { PageLink } from "./render.tsx";
5
+ import type { ResolvedImage } from "./media.ts";
6
+ import type { SitePage } from "./site.ts";
7
+ import type { StyleProps } from "./styles.ts";
8
+ /** A resolved reference: the target in the read's locale (§2). */
9
+ export type EntryRef<F = Record<string, unknown>> = {
10
+ id: string;
11
+ path: string | null;
12
+ fields: F;
13
+ };
14
+ /** The effective SEO values: an editor's override where set, the fallback otherwise (§3, §7). */
15
+ export type Seo = {
16
+ title?: string;
17
+ description?: string;
18
+ image?: ResolvedImage;
19
+ noindex?: boolean;
20
+ };
21
+ export type EntryItem<F = Record<string, unknown>> = {
22
+ id: string;
23
+ path: string | null;
24
+ locale: string;
25
+ locales: Record<string, LocaleAlternate | null>;
26
+ updatedAt: number;
27
+ fields: F;
28
+ seo?: Seo;
29
+ };
30
+ export type PageItem<F = Record<string, unknown>> = {
31
+ id: string;
32
+ title: string;
33
+ path: string;
34
+ locale: string;
35
+ locales: Record<string, PageAlternate | null>;
36
+ template: string;
37
+ fields: F;
38
+ seo?: Seo;
39
+ ancestors: PageLink[];
40
+ children: PageLink[];
41
+ };
42
+ export type GlobalItem<F = Record<string, unknown>, S = Record<string, unknown>> = {
43
+ locale: string;
44
+ fields: F;
45
+ settings?: S;
46
+ styles?: StyleProps;
47
+ };
48
+ /** A visual item's read: its settings and styles always present. */
49
+ export type VisualGlobalItem<F = Record<string, unknown>, S = Record<string, unknown>> = GlobalItem<F, S> & {
50
+ settings: S;
51
+ styles: StyleProps;
52
+ };
53
+ /** One entry as a read returns it: Smoodly's keys, the content under `fields`; the record never leaves the site layer. */
54
+ export declare function toEntryItem(record: EntryRecord, fields: Record<string, unknown>, o: {
55
+ path: string | null;
56
+ locale: string;
57
+ locales: Record<string, LocaleAlternate | null>;
58
+ seo?: Seo;
59
+ }): EntryItem;
60
+ /** One page as a read returns it: no record, no tree. */
61
+ export declare function toPageItem(hit: SitePage): PageItem;
62
+ /** Every collection a read of `schema` resolves into, by each ref field's own depth (the mirror of
63
+ * runResolution's modes) — a list unit's extra tags (spec 2026-09-29 content API contract §6). */
64
+ export declare function referencedCollections(schema: CollectionSchema<any>, all: CollectionSchema<any>[]): string[];
@@ -0,0 +1,52 @@
1
+ import { SEO_KEY } from "./seo.js";
2
+ /** One entry as a read returns it: Smoodly's keys, the content under `fields`; the record never leaves the site layer. */
3
+ export function toEntryItem(record, fields, o) {
4
+ // The reserved `$seo` values are Smoodly's: they leave as `seo`, never as a field.
5
+ const { [SEO_KEY]: _seo, ...rest } = fields;
6
+ return {
7
+ id: record.id, path: o.path, locale: o.locale, locales: o.locales,
8
+ updatedAt: record.locales[o.locale]?.updatedAt ?? record.updatedAt, fields: rest,
9
+ ...(o.seo ? { seo: o.seo } : {}),
10
+ };
11
+ }
12
+ /** One page as a read returns it: no record, no tree. */
13
+ export function toPageItem(hit) {
14
+ return {
15
+ id: hit.record.id, title: hit.title, path: hit.path, locale: hit.locale, locales: hit.locales,
16
+ template: hit.template, fields: hit.fields, ...(hit.seo ? { seo: hit.seo } : {}),
17
+ ancestors: hit.ancestors, children: hit.children,
18
+ };
19
+ }
20
+ /** Every collection a read of `schema` resolves into, by each ref field's own depth (the mirror of
21
+ * runResolution's modes) — a list unit's extra tags (spec 2026-09-29 content API contract §6). */
22
+ export function referencedCollections(schema, all) {
23
+ const byName = new Map(all.map((c) => [c.name, c]));
24
+ const out = new Set();
25
+ const walk = (s, mode, seen) => {
26
+ for (const [key, raw] of Object.entries(s.fields)) {
27
+ const d = raw;
28
+ if (d.type !== "ref" && d.type !== "refList")
29
+ continue;
30
+ let next;
31
+ if (mode === "own")
32
+ next = d.resolve === "full" ? "full" : d.resolve !== null && typeof d.resolve === "object" ? d.resolve : "shallow";
33
+ else if (mode === "full")
34
+ next = "full";
35
+ else if (mode === "shallow")
36
+ continue;
37
+ else {
38
+ const on = mode[key];
39
+ if (!on)
40
+ continue;
41
+ next = typeof on === "object" ? on : "shallow";
42
+ }
43
+ const name = d.target().name;
44
+ out.add(name);
45
+ const target = byName.get(name);
46
+ if (next !== "shallow" && target && !seen.has(name))
47
+ walk(target, next, new Set([...seen, name]));
48
+ }
49
+ };
50
+ walk(schema, "own", new Set([schema.name]));
51
+ return [...out];
52
+ }
package/dist/render.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { type ComponentType, type ReactElement, type ReactNode } from "react";
2
2
  import type { PageAlternate } from "./paths.ts";
3
3
  import type { PageSchema } from "./page.ts";
4
- import type { BuilderMap, NamespaceValues } from "./schema.ts";
4
+ import type { BuilderMap, Values } from "./schema.ts";
5
5
  import { type StyleValues } from "./styles.ts";
6
6
  import { type CustomOptions } from "./custom/index.ts";
7
7
  export type TreeNode = {
@@ -96,10 +96,10 @@ export type PageViewProps<Zones extends Record<string, unknown>, Fields = Record
96
96
  zones: Record<keyof Zones & string, ZoneContent>;
97
97
  page: PageContext<Fields>;
98
98
  };
99
- export type PairedPage<Zones extends Record<string, unknown>, F extends BuilderMap = BuilderMap> = ((props: PageViewProps<Zones, NamespaceValues<F>>) => ReactElement) & {
99
+ export type PairedPage<Zones extends Record<string, unknown>, F extends BuilderMap = BuilderMap> = ((props: PageViewProps<Zones, Values<F>>) => ReactElement) & {
100
100
  schema: PageSchema<Zones, F>;
101
101
  };
102
- 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>;
102
+ export declare function page<Zones extends Record<string, unknown>, F extends BuilderMap = {}>(schema: PageSchema<Zones, F>, View: ComponentType<PageViewProps<Zones, Values<F>>>): PairedPage<Zones, F>;
103
103
  export type Registry = {
104
104
  sections?: AnyPaired[];
105
105
  elements?: AnyPaired[];
package/dist/resolve.d.ts CHANGED
@@ -7,6 +7,8 @@ import { type ElementSchemas } from "./richtext-walk.ts";
7
7
  import type { SectionLike } from "./refs.ts";
8
8
  import type { Toolset } from "./toolset.ts";
9
9
  export type EntryFetcher = (collection: string, ids: string[]) => Promise<Record<string, Record<string, unknown>>>;
10
+ /** id → the entry's live full path in the read's locale; ids without one are absent. */
11
+ export type PathLookup = (collection: string, ids: string[]) => Promise<Record<string, string>>;
10
12
  export type AssetFetcher = (ids: string[]) => Promise<Record<string, AssetRecord>>;
11
13
  type SchemaLike = {
12
14
  name: string;
@@ -25,6 +27,8 @@ type ResolveOptions = {
25
27
  })[];
26
28
  collections: CollectionSchema<any>[];
27
29
  fetch: EntryFetcher;
30
+ /** Each hydrated ref's address (spec 2026-09-29 content API contract §2). */
31
+ paths?: PathLookup;
28
32
  /** Media (spec 2026-09-08 §9): one batched asset fetch per page, values become resolved objects. */
29
33
  assets?: AssetFetcher;
30
34
  /** The registered global items a placement node may name (spec 2026-09-07 §4). */
@@ -52,6 +56,8 @@ export declare function resolveMediaSets(sets: MediaSet[], fetch: AssetFetcher,
52
56
  export type ResolveFieldsOptions = {
53
57
  collections: CollectionSchema<any>[];
54
58
  fetch: EntryFetcher;
59
+ /** Each hydrated ref's address (spec 2026-09-29 content API contract §2). */
60
+ paths?: PathLookup;
55
61
  assets?: AssetFetcher;
56
62
  /** Registered Elements a richtext document may embed (spec 2026-09-08 block nodes §2). */
57
63
  elements?: SectionLike[];
@@ -60,4 +66,10 @@ export type ResolveFieldsOptions = {
60
66
  /** One entry's fields, refs hydrated — the entry page's counterpart of
61
67
  * resolveTree. Same rules, same batching, same cycle cut. */
62
68
  export declare function resolveFields(fields: Record<string, unknown>, descriptors: Record<string, Descriptor>, options: ResolveFieldsOptions): Promise<Record<string, unknown>>;
69
+ /** Many value sets (a list's entries), one resolution: every ref batched per
70
+ * collection across all sets, then one media round — all in place. */
71
+ export declare function resolveFieldSets(sets: {
72
+ values: Record<string, unknown>;
73
+ descriptors: Record<string, Descriptor>;
74
+ }[], options: ResolveFieldsOptions): Promise<void>;
63
75
  export {};
package/dist/resolve.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // The batched ref-resolution pass (DESIGN.md §3): one walk collects every
2
2
  // ref in the tree, fetches per collection in single batched calls, and
3
- // hands components hydrated entries. The ref site alone decides depth:
3
+ // hands components hydrated entries as `{ id, path, fields }` (spec 2026-09-29 content API contract §2). The ref site alone decides depth:
4
4
  // bare refs hydrate one entry (refs stay ids), `.resolve("key")` follows
5
5
  // the named refs one level, and bare `.resolve()` follows the whole chain.
6
6
  // Cycles are cut per path — a ref back to an entry already on its own
@@ -9,6 +9,7 @@
9
9
  import { globalLink, GLOBAL_NODE_TYPE, SETTINGS_KEY, STYLES_KEY, CSS_KEY } from "./global.js";
10
10
  import { applyAssets, collectAssetIds } from "./media.js";
11
11
  import { elementSchemas } from "./richtext-walk.js";
12
+ import { SEO_KEY } from "./seo.js";
12
13
  import { CUSTOM_NODE_TYPE, customSchema, customToolset } from "./custom/index.js";
13
14
  /** The built-in SEO set's one media value (spec 2026-09-10 page fields §2). */
14
15
  const META_DESCRIPTORS = { ogImage: { type: "image" } };
@@ -47,7 +48,7 @@ export async function resolveTree(tree, options) {
47
48
  }
48
49
  if (options.page && resolved.fields)
49
50
  collectTasks(resolved.fields, options.page.fields, tasks);
50
- const hydrated = await runResolution(tasks, { collections: options.collections, fetch: options.fetch, maxDepth });
51
+ const hydrated = await runResolution(tasks, { collections: options.collections, fetch: options.fetch, paths: options.paths, maxDepth });
51
52
  if (options.assets) {
52
53
  const sets = [];
53
54
  for (const node of Object.values(resolved.zones ?? {}).flat()) {
@@ -150,6 +151,16 @@ export async function resolveFields(fields, descriptors, options) {
150
151
  await resolveMediaSets([{ values: resolved, descriptors }, ...hydrated], options.assets, elementSchemas(options.elements));
151
152
  return resolved;
152
153
  }
154
+ /** Many value sets (a list's entries), one resolution: every ref batched per
155
+ * collection across all sets, then one media round — all in place. */
156
+ export async function resolveFieldSets(sets, options) {
157
+ const tasks = [];
158
+ for (const { values, descriptors } of sets)
159
+ collectTasks(values, descriptors, tasks);
160
+ const hydrated = await runResolution(tasks, { ...options, maxDepth: options.maxDepth ?? Infinity });
161
+ if (options.assets)
162
+ await resolveMediaSets([...sets, ...hydrated], options.assets, elementSchemas(options.elements));
163
+ }
153
164
  /** Hydrates every task's refs in place and returns each hydrated entry
154
165
  * copy with its collection's descriptors — the media pass walks those
155
166
  * too, so a card reached through a ref carries its image URL. */
@@ -157,6 +168,7 @@ async function runResolution(seeds, options) {
157
168
  const collectionSchemas = new Map(options.collections.map((c) => [c.name, c]));
158
169
  // every entry is fetched once; cycle cuts reuse cached ancestors
159
170
  const cache = new Map();
171
+ const pathCache = new Map();
160
172
  const hydrated = [];
161
173
  let tasks = seeds;
162
174
  for (let round = 0; round < options.maxDepth && tasks.length > 0; round++) {
@@ -177,6 +189,14 @@ async function runResolution(seeds, options) {
177
189
  for (const [id, entry] of Object.entries(fetched))
178
190
  table.set(id, entry);
179
191
  cache.set(collection, table);
192
+ // a collection with no path has no addresses: its refs carry path null
193
+ if (options.paths && collectionSchemas.get(collection)?.path !== undefined) {
194
+ const found = await options.paths(collection, [...ids]);
195
+ const paths = pathCache.get(collection) ?? new Map();
196
+ for (const [id, p] of Object.entries(found))
197
+ paths.set(id, p);
198
+ pathCache.set(collection, paths);
199
+ }
180
200
  }));
181
201
  // apply results and queue follow-ups per the task's mode
182
202
  const next = [];
@@ -185,19 +205,21 @@ async function runResolution(seeds, options) {
185
205
  const raw = cache.get(task.collection)?.get(id);
186
206
  if (!raw)
187
207
  return null;
188
- const entry = structuredClone(raw);
208
+ // The SEO panel's values are the entry page's, never a reference's (and would cost a media fetch).
209
+ const { [SEO_KEY]: _seo, ...entry } = structuredClone(raw);
210
+ const ref = { id, path: pathCache.get(task.collection)?.get(id) ?? null, fields: entry };
189
211
  const schema = collectionSchemas.get(task.collection);
190
212
  if (schema)
191
213
  hydrated.push({ values: entry, descriptors: schema.fields });
192
214
  const pathKey = `${task.collection}:${id}`;
193
215
  // cycle: this entry is its own ancestor — shallow copy, no recursion
194
216
  if (task.ancestors.includes(pathKey))
195
- return entry;
217
+ return ref;
196
218
  if (task.mode === "shallow")
197
- return entry;
219
+ return ref;
198
220
  const targetSchema = collectionSchemas.get(task.collection);
199
221
  if (!targetSchema)
200
- return entry;
222
+ return ref;
201
223
  const ancestors = [...task.ancestors, pathKey];
202
224
  for (const [key, d] of Object.entries(targetSchema.fields)) {
203
225
  if (d.type !== "ref" && d.type !== "refList")
@@ -225,7 +247,7 @@ async function runResolution(seeds, options) {
225
247
  ancestors,
226
248
  });
227
249
  }
228
- return entry;
250
+ return ref;
229
251
  };
230
252
  task.holder[task.key] = task.isList
231
253
  ? task.id.map(hydrate)
@@ -1,7 +1,7 @@
1
1
  /** The minimum schema version the package needs. Moves ONLY in a release
2
2
  * that adds a table, column, function or policy the package reads; a
3
3
  * unit test holds it equal to the highest file in `migrations/`. */
4
- export declare const SCHEMA_VERSION = 5;
4
+ export declare const SCHEMA_VERSION = 7;
5
5
  export declare const SCHEMA_VERSION_KEY = "schema_version";
6
6
  export declare const CORE_FILE_RE: RegExp;
7
7
  export declare const VIEWS_FILE_RE: RegExp;
@@ -7,7 +7,7 @@
7
7
  /** The minimum schema version the package needs. Moves ONLY in a release
8
8
  * that adds a table, column, function or policy the package reads; a
9
9
  * unit test holds it equal to the highest file in `migrations/`. */
10
- export const SCHEMA_VERSION = 5;
10
+ export const SCHEMA_VERSION = 7;
11
11
  export const SCHEMA_VERSION_KEY = "schema_version";
12
12
  export const CORE_FILE_RE = /^(\d{14})_smoodly_v(\d+)\.sql$/;
13
13
  export const VIEWS_FILE_RE = /^(\d{14})_smoodly_views\.sql$/;
package/dist/schema.d.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import { type Descriptor, type FieldBuilder, type ValueOf } from "./fields.ts";
2
2
  import { pageSchema } from "./page.ts";
3
+ import type { RefTo } from "./fields.ts";
4
+ import type { EntryRef } from "./read-shapes.ts";
3
5
  export type BuilderMap = Record<string, FieldBuilder<any, any>>;
4
6
  export type SectionSchema<F extends BuilderMap = BuilderMap, S extends BuilderMap = BuilderMap> = {
5
7
  kind: "section";
@@ -37,6 +39,25 @@ export type NamespaceValues<M extends BuilderMap> = {
37
39
  } & {
38
40
  [K in OptionalKeys<M>]?: ValueOf<M[K]>;
39
41
  };
42
+ /** The target's stored entry values (refs still as RefTo markers). */
43
+ type EntryOf<T> = T extends {
44
+ __entry?: infer E;
45
+ } ? NonNullable<E> : Record<string, unknown>;
46
+ /** A target's fields as a ref site with mode M sees them (the ref site alone decides depth, DESIGN.md §3). */
47
+ export type ShapeOf<T, M> = {
48
+ [K in keyof EntryOf<T>]: FieldAt<EntryOf<T>[K], M, K>;
49
+ };
50
+ type FieldAt<V, M, K> = V extends RefTo<infer U, any> ? M extends "full" ? EntryRef<ShapeOf<U, "full">> | null : K extends M ? EntryRef<ShapeOf<U, "shallow">> | null : string | null : V extends RefTo<infer U, any>[] ? M extends "full" ? (EntryRef<ShapeOf<U, "full">> | null)[] : K extends M ? (EntryRef<ShapeOf<U, "shallow">> | null)[] : string[] : V;
51
+ /** One value as a read returns it: a ref becomes an EntryRef (or null), a refList a list of them. */
52
+ export type Materialize<V> = V extends RefTo<infer T, infer M> ? EntryRef<ShapeOf<T, M>> | null : V extends RefTo<infer T, infer M>[] ? (EntryRef<ShapeOf<T, M>> | null)[] : V;
53
+ /** What a view and a read receive (spec 2026-09-29 content API contract §4). */
54
+ export type Values<M extends BuilderMap> = {
55
+ [K in keyof NamespaceValues<M>]: Materialize<NamespaceValues<M>[K]>;
56
+ };
57
+ /** A sample stores ids where a read shows references. */
58
+ type Stored<M extends BuilderMap> = {
59
+ [K in keyof NamespaceValues<M>]: NamespaceValues<M>[K] extends RefTo<any, any> ? string : NamespaceValues<M>[K] extends RefTo<any, any>[] ? string[] : NamespaceValues<M>[K];
60
+ };
40
61
  type SectionInput<F extends BuilderMap, S extends BuilderMap> = {
41
62
  name: string;
42
63
  title: string;
@@ -50,7 +71,7 @@ type SectionInput<F extends BuilderMap, S extends BuilderMap> = {
50
71
  omit: string[];
51
72
  };
52
73
  css?: false;
53
- sample?: Partial<NamespaceValues<F> & NamespaceValues<S>>;
74
+ sample?: Partial<Stored<F> & Stored<S>>;
54
75
  };
55
76
  export declare const schema: {
56
77
  page: typeof pageSchema;