smoodly 0.0.7 → 0.0.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +88 -2
- package/dist/admin/client-ops.js +1 -0
- package/dist/admin/editor/EditorView.js +64 -8
- package/dist/admin/index.d.ts +3 -2
- package/dist/admin/index.js +1 -0
- package/dist/admin/ops-impl.js +84 -1
- package/dist/admin/ops.d.ts +22 -1
- package/dist/admin/serialize.d.ts +11 -0
- package/dist/admin/serialize.js +8 -0
- package/dist/admin/shared-items.d.ts +9 -0
- package/dist/admin/shared-items.js +61 -0
- package/dist/admin/shell/AdminApp.js +8 -2
- package/dist/admin/shell/SharedForm.d.ts +7 -0
- package/dist/admin/shell/SharedForm.js +146 -0
- package/dist/admin/shell/SharedList.d.ts +6 -0
- package/dist/admin/shell/SharedList.js +62 -0
- package/dist/admin/shell/shared-list.d.ts +14 -0
- package/dist/admin/shell/shared-list.js +17 -0
- package/dist/admin/tree-ops.d.ts +12 -5
- package/dist/admin/tree-ops.js +39 -8
- package/dist/collections.d.ts +4 -1
- package/dist/config.d.ts +3 -0
- package/dist/config.js +35 -1
- package/dist/entry-store.d.ts +9 -1
- package/dist/entry-store.js +25 -3
- package/dist/index.d.ts +5 -1
- package/dist/index.js +4 -1
- package/dist/next/page-renderer.d.ts +4 -0
- package/dist/next/page-renderer.js +14 -1
- package/dist/page.js +5 -1
- package/dist/refs.js +8 -0
- package/dist/resolve.d.ts +3 -0
- package/dist/resolve.js +38 -0
- package/dist/revalidate.d.ts +4 -0
- package/dist/revalidate.js +4 -0
- package/dist/shared.d.ts +46 -0
- package/dist/shared.js +36 -0
- package/dist/site.d.ts +10 -0
- package/dist/site.js +28 -1
- package/dist/sql-space.d.ts +1 -1
- package/dist/sql-space.js +4 -9
- package/dist/sql.js +0 -18
- package/dist/supabase-entry-store.d.ts +1 -1
- package/dist/supabase-entry-store.js +23 -5
- package/dist/zones.d.ts +6 -2
- package/dist/zones.js +8 -2
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -4,6 +4,7 @@ import { collection, toolset } from "./collections.js";
|
|
|
4
4
|
import { Section } from "./section-wrapper.js";
|
|
5
5
|
import { page } from "./render.js";
|
|
6
6
|
import { entryPage } from "./entry-page.js";
|
|
7
|
+
import { shared } from "./shared.js";
|
|
7
8
|
export { Zone, renderPage, EMPTY_PAGE_CONTEXT } from "./render.js";
|
|
8
9
|
export { fixedSlug } from "./page.js";
|
|
9
10
|
export { f } from "./fields.js";
|
|
@@ -19,12 +20,13 @@ export { MemoryEntryStore } from "./entry-store.js";
|
|
|
19
20
|
export { SupabaseEntryStore } from "./supabase-entry-store.js";
|
|
20
21
|
export { SupabasePathIndex } from "./supabase-path-index.js";
|
|
21
22
|
export { entryErrors, compareEntries, DEFAULT_ENTRY_LOCALES, localizedKeys, splitFields, entryLocaleRow, entryFieldsIn, entryRefEdges, entryTitleIn, } from "./entry-store.js";
|
|
22
|
-
export { affectedTargets, pageTag, entryTag } from "./revalidate.js";
|
|
23
|
+
export { affectedTargets, pageTag, entryTag, sharedTag } from "./revalidate.js";
|
|
23
24
|
export { resolveTree, resolveFields } from "./resolve.js";
|
|
24
25
|
export { SEGMENT_RE, isSegment, assertSegment, joinPath, splitPath, segmentFor, isHomeNode, pagePathIn, chainOf, assertDepth, pagePath, pagePathRows, entryPathRows, collectionSegment, buildPageTree, perLocaleSegments, assertSupportedLocale, unsupportedLocale, } from "./paths.js";
|
|
25
26
|
export { fixedPathFor } from "./localize.js";
|
|
26
27
|
export { pathOptions, pageErrors, localeRow, pageRefEdges, DEFAULT_LOCALES } from "./store.js";
|
|
27
28
|
export { collectionOrder, collectionDepth, DEFAULT_ORDER } from "./collections.js";
|
|
29
|
+
export { SHARED_NODE_TYPE, STYLES_KEY, sharedLink } from "./shared.js";
|
|
28
30
|
export const smoodly = {
|
|
29
31
|
schema,
|
|
30
32
|
section,
|
|
@@ -33,6 +35,7 @@ export const smoodly = {
|
|
|
33
35
|
entryPage,
|
|
34
36
|
collection,
|
|
35
37
|
toolset,
|
|
38
|
+
shared,
|
|
36
39
|
Section,
|
|
37
40
|
};
|
|
38
41
|
export { resolveSmoodlyEnv, smoodlyEnv } from "./env.js";
|
|
@@ -2,6 +2,7 @@ import type { ComponentType, ReactElement } from "react";
|
|
|
2
2
|
import type { Metadata } from "next";
|
|
3
3
|
import type { Descriptor } from "../fields.ts";
|
|
4
4
|
import { type PairedPage } from "../render.tsx";
|
|
5
|
+
import type { SharedSchema } from "../shared.ts";
|
|
5
6
|
import { type SmoodlySite } from "../site.ts";
|
|
6
7
|
/** A registered section, as renderPage's registry needs it. */
|
|
7
8
|
type SectionEntry = ComponentType<any> & {
|
|
@@ -31,6 +32,9 @@ export type PathRenderOptions = {
|
|
|
31
32
|
searchParams?: Promise<Record<string, string | string[] | undefined>>;
|
|
32
33
|
};
|
|
33
34
|
export declare function createPageRenderer(options: PageRendererOptions): {
|
|
35
|
+
getSmoodlyShared: <V>(item: SharedSchema<V>, opts?: {
|
|
36
|
+
locale?: string;
|
|
37
|
+
}) => Promise<V | null>;
|
|
34
38
|
renderSmoodlyPath: (segments: string[], opts?: PathRenderOptions) => Promise<ReactElement<unknown, string | import("react").JSXElementConstructor<any>>>;
|
|
35
39
|
smoodlyMetadata: (segments: string[], opts?: {
|
|
36
40
|
locale?: string;
|
|
@@ -7,7 +7,7 @@ import { EditorBridge } from "../admin/next/bridge.js";
|
|
|
7
7
|
import { fixedPathFor } from "../localize.js";
|
|
8
8
|
import { joinPath } from "../paths.js";
|
|
9
9
|
import { renderPage } from "../render.js";
|
|
10
|
-
import { entryTag, pageTag } from "../revalidate.js";
|
|
10
|
+
import { entryTag, pageTag, sharedTag } from "../revalidate.js";
|
|
11
11
|
import { pageContextOf } from "../site.js";
|
|
12
12
|
import { metadataFrom } from "./meta.js";
|
|
13
13
|
export function createPageRenderer(options) {
|
|
@@ -82,7 +82,20 @@ export function createPageRenderer(options) {
|
|
|
82
82
|
* error surfaces as a rejected promise like every other failure. */
|
|
83
83
|
const fixedPath = (Page) => (locale) => fixedPathFor(Page.schema, locale, site.locales, site.homePageSlug);
|
|
84
84
|
const literal = (segments) => () => joinPath(segments);
|
|
85
|
+
/** A shared item for a layout: cached per (item, locale) under the
|
|
86
|
+
* shared tag outside draft mode, the live draft inside it. */
|
|
87
|
+
async function getSmoodlyShared(item, opts) {
|
|
88
|
+
await connection();
|
|
89
|
+
const locale = opts?.locale ?? site.locales.default;
|
|
90
|
+
const { isEnabled: draft } = await draftMode();
|
|
91
|
+
if (draft)
|
|
92
|
+
return site.getShared(item, { locale, draft: true });
|
|
93
|
+
return unstable_cache(() => site.getShared(item, { locale }), ["smoodly-shared", item.name, locale], {
|
|
94
|
+
tags: [sharedTag(item.name)],
|
|
95
|
+
})();
|
|
96
|
+
}
|
|
85
97
|
return {
|
|
98
|
+
getSmoodlyShared,
|
|
86
99
|
renderSmoodlyPath: (segments, opts) => page(literal(segments), opts, any),
|
|
87
100
|
smoodlyMetadata: (segments, opts) => metadata(literal(segments), opts, any),
|
|
88
101
|
/** A fixed registration rendered from the developer's own route file. */
|
package/dist/page.js
CHANGED
|
@@ -10,7 +10,11 @@ function normalizePolicy(p) {
|
|
|
10
10
|
case "freeform":
|
|
11
11
|
return { kind: "freeform", allow: p.allow, rows: p.rows };
|
|
12
12
|
case "locked":
|
|
13
|
-
return {
|
|
13
|
+
return {
|
|
14
|
+
kind: "locked",
|
|
15
|
+
component: p.component,
|
|
16
|
+
...(typeof p.shared === "string" ? { shared: p.shared } : {}),
|
|
17
|
+
};
|
|
14
18
|
default:
|
|
15
19
|
throw new Error(`smoodly: unknown zone policy kind "${String(p.kind)}".`);
|
|
16
20
|
}
|
package/dist/refs.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// and never the source of truth. Stores rewrite a source's edges on
|
|
5
5
|
// every save; `refs` answers only the REVERSE question (who depends on
|
|
6
6
|
// this?) — forward resolution stays resolveTree's job.
|
|
7
|
+
import { sharedLink } from "./shared.js";
|
|
7
8
|
function targetOf(d) {
|
|
8
9
|
const t = d.target?.();
|
|
9
10
|
if (!t?.name)
|
|
@@ -39,6 +40,13 @@ export function collectTreeRefs(tree, sections) {
|
|
|
39
40
|
const out = [];
|
|
40
41
|
for (const nodes of Object.values(tree.zones ?? {})) {
|
|
41
42
|
for (const node of nodes) {
|
|
43
|
+
// A placement (spec 2026-09-07 §5): one edge, keyed by the node id
|
|
44
|
+
// so two placements of one item on one page stay distinct rows.
|
|
45
|
+
const link = sharedLink(node);
|
|
46
|
+
if (link) {
|
|
47
|
+
out.push({ field: node.id, targetCollection: link.item, targetId: link.ref });
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
42
50
|
const schema = byName.get(node.type);
|
|
43
51
|
if (!schema)
|
|
44
52
|
continue; // fail-soft, same as renderPage
|
package/dist/resolve.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Descriptor } from "./fields.ts";
|
|
2
2
|
import type { CollectionSchema } from "./collections.ts";
|
|
3
3
|
import type { PageTree } from "./render.tsx";
|
|
4
|
+
import { type SharedSchema } from "./shared.ts";
|
|
4
5
|
export type EntryFetcher = (collection: string, ids: string[]) => Promise<Record<string, Record<string, unknown>>>;
|
|
5
6
|
type SchemaLike = {
|
|
6
7
|
name: string;
|
|
@@ -15,6 +16,8 @@ type ResolveOptions = {
|
|
|
15
16
|
})[];
|
|
16
17
|
collections: CollectionSchema<any>[];
|
|
17
18
|
fetch: EntryFetcher;
|
|
19
|
+
/** The registered shared items a placement node may name (spec 2026-09-07 §4). */
|
|
20
|
+
shared?: SharedSchema[];
|
|
18
21
|
/** Optional safety valve on chain length; unlimited by default. */
|
|
19
22
|
maxDepth?: number;
|
|
20
23
|
};
|
package/dist/resolve.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// Cycles are cut per path — a ref back to an entry already on its own
|
|
7
7
|
// chain gets a shallow copy (refs as ids), so traversal terminates by
|
|
8
8
|
// construction and the output stays JSON-serializable.
|
|
9
|
+
import { sharedLink, SHARED_NODE_TYPE, STYLES_KEY } from "./shared.js";
|
|
9
10
|
function targetOf(d) {
|
|
10
11
|
const t = d.target?.();
|
|
11
12
|
if (!t?.name)
|
|
@@ -27,6 +28,7 @@ export async function resolveTree(tree, options) {
|
|
|
27
28
|
return [schema.name, schema];
|
|
28
29
|
}));
|
|
29
30
|
const resolved = structuredClone(tree);
|
|
31
|
+
await rewritePlacements(resolved, options.shared ?? [], options.fetch);
|
|
30
32
|
const tasks = [];
|
|
31
33
|
for (const nodes of Object.values(resolved.zones ?? {})) {
|
|
32
34
|
for (const node of nodes) {
|
|
@@ -39,6 +41,42 @@ export async function resolveTree(tree, options) {
|
|
|
39
41
|
await runResolution(tasks, { collections: options.collections, fetch: options.fetch, maxDepth });
|
|
40
42
|
return resolved;
|
|
41
43
|
}
|
|
44
|
+
/** Placements first (spec 2026-09-07 §4): fetch every linked item in one
|
|
45
|
+
* call per item, rewrite each node into the item's section node — the
|
|
46
|
+
* placement's id stays, so the canvas selects it — and drop what the
|
|
47
|
+
* site cannot render: an unpublished or dangling row, an object item
|
|
48
|
+
* (no view), an unknown item. The normal pass then hydrates the
|
|
49
|
+
* rewritten nodes' own refs. */
|
|
50
|
+
async function rewritePlacements(tree, shared, fetch) {
|
|
51
|
+
const byName = new Map(shared.map((s) => [s.name, s]));
|
|
52
|
+
const wanted = new Map();
|
|
53
|
+
for (const nodes of Object.values(tree.zones ?? {})) {
|
|
54
|
+
for (const node of nodes) {
|
|
55
|
+
const link = sharedLink(node);
|
|
56
|
+
if (!link || !byName.get(link.item)?.section)
|
|
57
|
+
continue;
|
|
58
|
+
const ids = wanted.get(link.item) ?? new Set();
|
|
59
|
+
ids.add(link.ref);
|
|
60
|
+
wanted.set(link.item, ids);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const fetched = new Map();
|
|
64
|
+
await Promise.all([...wanted].map(async ([item, ids]) => fetched.set(item, await fetch(item, [...ids]))));
|
|
65
|
+
for (const [zone, nodes] of Object.entries(tree.zones ?? {})) {
|
|
66
|
+
tree.zones[zone] = nodes.flatMap((node) => {
|
|
67
|
+
const link = sharedLink(node);
|
|
68
|
+
if (!link)
|
|
69
|
+
return node.type === SHARED_NODE_TYPE ? [] : [node];
|
|
70
|
+
const schema = byName.get(link.item);
|
|
71
|
+
const row = fetched.get(link.item)?.[link.ref];
|
|
72
|
+
if (!schema?.section || !row)
|
|
73
|
+
return [];
|
|
74
|
+
const { [STYLES_KEY]: styles, ...fields } = structuredClone(row);
|
|
75
|
+
const isObject = styles !== null && typeof styles === "object";
|
|
76
|
+
return [{ id: node.id, type: schema.section, fields, ...(isObject ? { styles: styles } : {}) }];
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
42
80
|
/** One entry's fields, refs hydrated — the entry page's counterpart of
|
|
43
81
|
* resolveTree. Same rules, same batching, same cycle cut. */
|
|
44
82
|
export async function resolveFields(fields, descriptors, options) {
|
package/dist/revalidate.d.ts
CHANGED
|
@@ -8,6 +8,10 @@ export type AffectedTargets = {
|
|
|
8
8
|
};
|
|
9
9
|
export declare const pageTag: (id: string) => string;
|
|
10
10
|
export declare const entryTag: (id: string) => string;
|
|
11
|
+
/** A shared item's cache unit, by NAME (a singleton has one row per
|
|
12
|
+
* locale, and code owns the name): the Next glue's getSmoodlyShared
|
|
13
|
+
* caches under it; ops.shared expires it. */
|
|
14
|
+
export declare const sharedTag: (name: string) => string;
|
|
11
15
|
export declare function affectedTargets(stores: {
|
|
12
16
|
pages: PageStore;
|
|
13
17
|
entries: EntryStore;
|
package/dist/revalidate.js
CHANGED
|
@@ -7,6 +7,10 @@
|
|
|
7
7
|
// change (spec 2026-09-05 §5).
|
|
8
8
|
export const pageTag = (id) => `page:${id}`;
|
|
9
9
|
export const entryTag = (id) => `entry:${id}`;
|
|
10
|
+
/** A shared item's cache unit, by NAME (a singleton has one row per
|
|
11
|
+
* locale, and code owns the name): the Next glue's getSmoodlyShared
|
|
12
|
+
* caches under it; ops.shared expires it. */
|
|
13
|
+
export const sharedTag = (name) => `shared:${name}`;
|
|
10
14
|
export async function affectedTargets(stores, entryId, options = {}) {
|
|
11
15
|
const maxDepth = options.maxDepth ?? Infinity;
|
|
12
16
|
const seen = new Set([entryId]);
|
package/dist/shared.d.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { CollectionSchema } from "./collections.ts";
|
|
2
|
+
import type { Descriptor } from "./fields.ts";
|
|
3
|
+
import type { PairedComponent } from "./pairing.tsx";
|
|
4
|
+
import type { BuilderMap, NamespaceValues, SectionSchema } from "./schema.ts";
|
|
5
|
+
/** The reserved tree node type of a placement. */
|
|
6
|
+
export declare const SHARED_NODE_TYPE = "shared";
|
|
7
|
+
/** The reserved key a visual item's styles live under in the node's shared JSONB. */
|
|
8
|
+
export declare const STYLES_KEY = "$styles";
|
|
9
|
+
export type SharedSchema<V = Record<string, unknown>> = CollectionSchema<any> & {
|
|
10
|
+
kind: "shared";
|
|
11
|
+
/** The registered section a visual item renders through; absent = an object item. */
|
|
12
|
+
section?: string;
|
|
13
|
+
/** The section's style descriptors (empty for an object item). */
|
|
14
|
+
styles: Record<string, Descriptor>;
|
|
15
|
+
sample?: Record<string, unknown>;
|
|
16
|
+
/** phantom: what getShared returns */
|
|
17
|
+
readonly __value?: V;
|
|
18
|
+
};
|
|
19
|
+
export type SharedLink = {
|
|
20
|
+
item: string;
|
|
21
|
+
ref: string;
|
|
22
|
+
};
|
|
23
|
+
/** The placement a tree node is, or null for any other node. Accepts a
|
|
24
|
+
* bare `{ type, fields }` shape or a full tree node — `id` (and any
|
|
25
|
+
* other tree-node property) is ignored. */
|
|
26
|
+
export declare function sharedLink(node: {
|
|
27
|
+
id?: string;
|
|
28
|
+
type: string;
|
|
29
|
+
fields?: Record<string, unknown>;
|
|
30
|
+
}): SharedLink | null;
|
|
31
|
+
type SharedInput = {
|
|
32
|
+
name: string;
|
|
33
|
+
title: string;
|
|
34
|
+
versions?: boolean;
|
|
35
|
+
};
|
|
36
|
+
export declare function shared<F extends BuilderMap>(input: SharedInput & {
|
|
37
|
+
fields: F;
|
|
38
|
+
section?: undefined;
|
|
39
|
+
}): SharedSchema<NamespaceValues<F>>;
|
|
40
|
+
export declare function shared<F extends BuilderMap, St extends BuilderMap>(input: SharedInput & {
|
|
41
|
+
section: PairedComponent<SectionSchema<F, St>, F, St>;
|
|
42
|
+
fields?: undefined;
|
|
43
|
+
}): SharedSchema<NamespaceValues<F> & {
|
|
44
|
+
styles: NamespaceValues<St>;
|
|
45
|
+
}>;
|
|
46
|
+
export {};
|
package/dist/shared.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/** The reserved tree node type of a placement. */
|
|
2
|
+
export const SHARED_NODE_TYPE = "shared";
|
|
3
|
+
/** The reserved key a visual item's styles live under in the node's shared JSONB. */
|
|
4
|
+
export const STYLES_KEY = "$styles";
|
|
5
|
+
/** The placement a tree node is, or null for any other node. Accepts a
|
|
6
|
+
* bare `{ type, fields }` shape or a full tree node — `id` (and any
|
|
7
|
+
* other tree-node property) is ignored. */
|
|
8
|
+
export function sharedLink(node) {
|
|
9
|
+
if (node.type !== SHARED_NODE_TYPE)
|
|
10
|
+
return null;
|
|
11
|
+
const { item, ref } = node.fields ?? {};
|
|
12
|
+
return typeof item === "string" && typeof ref === "string" ? { item, ref } : null;
|
|
13
|
+
}
|
|
14
|
+
export function shared(input) {
|
|
15
|
+
if (!input?.name)
|
|
16
|
+
throw new Error("smoodly: a shared item requires a name.");
|
|
17
|
+
if ((input.fields === undefined) === (input.section === undefined)) {
|
|
18
|
+
throw new Error(`smoodly: shared item "${input.name}" must declare exactly one of \`section\` or \`fields\`.`);
|
|
19
|
+
}
|
|
20
|
+
const fields = input.section
|
|
21
|
+
? input.section.schema.fields
|
|
22
|
+
: Object.fromEntries(Object.entries(input.fields).map(([k, b]) => [k, b.descriptor]));
|
|
23
|
+
if (Object.prototype.hasOwnProperty.call(fields, STYLES_KEY)) {
|
|
24
|
+
throw new Error(`smoodly: shared item "${input.name}" has a field named "${STYLES_KEY}" — that key is reserved for a visual item's styles.`);
|
|
25
|
+
}
|
|
26
|
+
return {
|
|
27
|
+
kind: "shared",
|
|
28
|
+
name: input.name,
|
|
29
|
+
title: input.title,
|
|
30
|
+
fields,
|
|
31
|
+
styles: input.section ? input.section.schema.styles : {},
|
|
32
|
+
...(input.section ? { section: input.section.schema.name } : {}),
|
|
33
|
+
...(input.section?.schema.sample ? { sample: input.section.schema.sample } : {}),
|
|
34
|
+
...(input.versions ? { versions: true } : {}),
|
|
35
|
+
};
|
|
36
|
+
}
|
package/dist/site.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { type EntryRecord, type EntryStore } from "./entry-store.ts";
|
|
|
3
3
|
import { type LocaleSet, type PathTarget } from "./paths.ts";
|
|
4
4
|
import type { SectionLike } from "./refs.ts";
|
|
5
5
|
import type { PageContext, PageLink, PageTree } from "./render.tsx";
|
|
6
|
+
import { type SharedSchema } from "./shared.ts";
|
|
6
7
|
import type { PageRecord, PageStore } from "./store.ts";
|
|
7
8
|
export type SiteOptions = {
|
|
8
9
|
stores: () => {
|
|
@@ -11,6 +12,7 @@ export type SiteOptions = {
|
|
|
11
12
|
};
|
|
12
13
|
collections: CollectionSchema<any>[];
|
|
13
14
|
sections: SectionLike[];
|
|
15
|
+
shared?: SharedSchema[];
|
|
14
16
|
locales: LocaleSet;
|
|
15
17
|
homePageSlug?: string;
|
|
16
18
|
};
|
|
@@ -82,6 +84,14 @@ export interface SmoodlySite {
|
|
|
82
84
|
getEntriesTree(collection: string, options?: {
|
|
83
85
|
locale?: string;
|
|
84
86
|
}): Promise<EntryTreeItem[]>;
|
|
87
|
+
/** A shared item's content in a locale (spec 2026-09-07 §4): an object
|
|
88
|
+
* item's merged fields, or a visual item's section props (fields plus
|
|
89
|
+
* `styles`), refs hydrated; null when the item has no row there or is
|
|
90
|
+
* unpublished (outside draft mode). */
|
|
91
|
+
getShared<V>(item: SharedSchema<V>, options?: {
|
|
92
|
+
locale?: string;
|
|
93
|
+
draft?: boolean;
|
|
94
|
+
}): Promise<V | null>;
|
|
85
95
|
}
|
|
86
96
|
/** The `page` prop for a loaded page. `toUrl` is the app's `href` seam
|
|
87
97
|
* bound to the render locale; the default keeps CMS paths as URLs. */
|
package/dist/site.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { entryFieldsIn, schemaOf } from "./entry-store.js";
|
|
2
2
|
import { buildPageTree, chainOf, entryPathRows, entrySegment, pagePath, } from "./paths.js";
|
|
3
3
|
import { resolveFields, resolveTree } from "./resolve.js";
|
|
4
|
+
import { STYLES_KEY } from "./shared.js";
|
|
4
5
|
/** The `page` prop for a loaded page. `toUrl` is the app's `href` seam
|
|
5
6
|
* bound to the render locale; the default keeps CMS paths as URLs. */
|
|
6
7
|
export function pageContextOf(hit, toUrl = (p) => p) {
|
|
@@ -51,7 +52,12 @@ export function createSmoodlySite(options) {
|
|
|
51
52
|
const raw = draft ? await pages.getDraftTree(id, locale) : row.status === "published" ? await pages.getPublishedTree(id, locale) : null;
|
|
52
53
|
if (!raw)
|
|
53
54
|
return null;
|
|
54
|
-
const tree = await resolveTree(raw, {
|
|
55
|
+
const tree = await resolveTree(raw, {
|
|
56
|
+
sections: options.sections,
|
|
57
|
+
collections: options.collections,
|
|
58
|
+
shared: options.shared,
|
|
59
|
+
fetch: fetcher(draft, locale),
|
|
60
|
+
});
|
|
55
61
|
const all = await pages.listPages();
|
|
56
62
|
const index = new Map(all.map((n) => [n.id, n]));
|
|
57
63
|
const byId = (x) => index.get(x);
|
|
@@ -171,6 +177,27 @@ export function createSmoodlySite(options) {
|
|
|
171
177
|
const build = (parentId) => items.filter((i) => i.entry.parentId === parentId).map((i) => ({ ...i, children: build(i.entry.id) }));
|
|
172
178
|
return build(null);
|
|
173
179
|
},
|
|
180
|
+
async getShared(item, o) {
|
|
181
|
+
const locale = localeOf(o);
|
|
182
|
+
const draft = o?.draft ?? false;
|
|
183
|
+
const { entries } = options.stores();
|
|
184
|
+
const [record] = await entries.list(item.name);
|
|
185
|
+
if (!record)
|
|
186
|
+
return null;
|
|
187
|
+
const row = record.locales[locale];
|
|
188
|
+
if (!row || (!draft && row.status !== "published"))
|
|
189
|
+
return null;
|
|
190
|
+
const raw = draft
|
|
191
|
+
? entryFieldsIn(record, locale)
|
|
192
|
+
: (await entries.getMany(item.name, [record.id], locale, { status: "published" }))[record.id];
|
|
193
|
+
if (!raw)
|
|
194
|
+
return null;
|
|
195
|
+
const fields = await resolveFields(raw, item.fields, { collections: options.collections, fetch: fetcher(draft, locale) });
|
|
196
|
+
if (!item.section)
|
|
197
|
+
return fields;
|
|
198
|
+
const { [STYLES_KEY]: styles, ...rest } = fields;
|
|
199
|
+
return { ...rest, styles: styles !== null && typeof styles === "object" ? styles : {} };
|
|
200
|
+
},
|
|
174
201
|
};
|
|
175
202
|
const site = {
|
|
176
203
|
locales,
|
package/dist/sql-space.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export declare const DEFAULT_SPACE = "'00000000-0000-0000-0000-000000000000'";
|
|
2
2
|
export declare const SPACE_COL = "\"space_id\" uuid not null default '00000000-0000-0000-0000-000000000000'";
|
|
3
|
-
export declare const CONTENT_TABLES: readonly ["pages", "page_locales", "page_versions", "
|
|
3
|
+
export declare const CONTENT_TABLES: readonly ["pages", "page_locales", "page_versions", "entries", "entry_locales", "entry_versions", "refs", "paths"];
|
|
4
4
|
export type ContentTable = (typeof CONTENT_TABLES)[number];
|
|
5
5
|
export declare function currentSpaceSQL(): string;
|
|
6
6
|
export declare function spaceLayerSQL(): string;
|
package/dist/sql-space.js
CHANGED
|
@@ -8,8 +8,6 @@ export const CONTENT_TABLES = [
|
|
|
8
8
|
"pages",
|
|
9
9
|
"page_locales",
|
|
10
10
|
"page_versions",
|
|
11
|
-
"saved_sections",
|
|
12
|
-
"global_sections",
|
|
13
11
|
"entries",
|
|
14
12
|
"entry_locales",
|
|
15
13
|
"entry_versions",
|
|
@@ -27,10 +25,9 @@ language sql stable as $$
|
|
|
27
25
|
$$;`.trim();
|
|
28
26
|
}
|
|
29
27
|
/** Which rows a read key (or the anon key, which has no claim) may see.
|
|
30
|
-
* null = write keys only — refs
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
* is fully readable once it's in-space) — distinct from null. */
|
|
28
|
+
* null = write keys only — refs is editor tooling; editors is admission
|
|
29
|
+
* data; nothing a browser needs. Shared content (spec 2026-09-07) rides
|
|
30
|
+
* the three entry predicates: it is stored as entries. */
|
|
34
31
|
const PUBLISHED_READ = {
|
|
35
32
|
pages: `exists (select 1 from "page_locales" l where l."page_id" = "pages"."id" and l."status" = 'published')`,
|
|
36
33
|
page_locales: `"status" = 'published'`,
|
|
@@ -38,15 +35,13 @@ const PUBLISHED_READ = {
|
|
|
38
35
|
entries: `exists (select 1 from "entry_locales" l where l."entry_id" = "entries"."id" and l."status" = 'published')`,
|
|
39
36
|
entry_locales: `"status" = 'published'`,
|
|
40
37
|
entry_versions: `exists (select 1 from "entry_locales" l where l."published_version_id" = "entry_versions"."id")`,
|
|
41
|
-
global_sections: ``,
|
|
42
|
-
saved_sections: null,
|
|
43
38
|
refs: null,
|
|
44
39
|
editors: null,
|
|
45
40
|
paths: `(("kind" = 'page' and exists (select 1 from "page_locales" l where l."page_id" = "paths"."target_id" and l."locale" = "paths"."locale" and l."status" = 'published')) or ("kind" = 'entry' and exists (select 1 from "entry_locales" l where l."entry_id" = "paths"."target_id" and l."locale" = "paths"."locale" and l."status" = 'published')))`,
|
|
46
41
|
};
|
|
47
42
|
/** Tables whose existing indexes do not lead with space_id. */
|
|
48
43
|
const NEEDS_SPACE_INDEX = [
|
|
49
|
-
"page_locales", "page_versions", "entry_locales", "entry_versions", "
|
|
44
|
+
"page_locales", "page_versions", "entry_locales", "entry_versions", "refs",
|
|
50
45
|
];
|
|
51
46
|
export function spaceLayerSQL() {
|
|
52
47
|
const statements = [];
|
package/dist/sql.js
CHANGED
|
@@ -132,22 +132,6 @@ create table if not exists "page_versions" (
|
|
|
132
132
|
create index if not exists "page_versions_page_locale_idx"
|
|
133
133
|
on "page_versions" ("page_id", "locale", "created_at");
|
|
134
134
|
|
|
135
|
-
create table if not exists "saved_sections" (
|
|
136
|
-
"id" uuid primary key default gen_random_uuid(),
|
|
137
|
-
${SPACE_COL},
|
|
138
|
-
"title" text not null,
|
|
139
|
-
"fragment" jsonb not null,
|
|
140
|
-
"created_at" timestamptz not null default now()
|
|
141
|
-
);
|
|
142
|
-
|
|
143
|
-
create table if not exists "global_sections" (
|
|
144
|
-
"id" uuid primary key default gen_random_uuid(),
|
|
145
|
-
${SPACE_COL},
|
|
146
|
-
"type" text not null,
|
|
147
|
-
"content" jsonb not null,
|
|
148
|
-
"updated_at" timestamptz not null default now()
|
|
149
|
-
);
|
|
150
|
-
|
|
151
135
|
create table if not exists "entries" (
|
|
152
136
|
"id" uuid primary key default gen_random_uuid(),
|
|
153
137
|
${SPACE_COL},
|
|
@@ -273,8 +257,6 @@ $$;
|
|
|
273
257
|
alter table "pages" enable row level security;
|
|
274
258
|
alter table "page_locales" enable row level security;
|
|
275
259
|
alter table "page_versions" enable row level security;
|
|
276
|
-
alter table "saved_sections" enable row level security;
|
|
277
|
-
alter table "global_sections" enable row level security;
|
|
278
260
|
alter table "entries" enable row level security;
|
|
279
261
|
alter table "entry_locales" enable row level security;
|
|
280
262
|
alter table "entry_versions" enable row level security;
|
|
@@ -3,9 +3,9 @@ import type { CollectionSchema } from "./collections.ts";
|
|
|
3
3
|
import type { AddEntryLocaleOptions, CreateEntryOptions, EntryRecord, EntryStore, EntryStoreOptions, EntryUsage, EntryVersion, ListOptions } from "./entry-store.ts";
|
|
4
4
|
export declare class SupabaseEntryStore implements EntryStore {
|
|
5
5
|
private db;
|
|
6
|
-
private collections;
|
|
7
6
|
private paths;
|
|
8
7
|
private locales;
|
|
8
|
+
private schemas;
|
|
9
9
|
constructor(db: SupabaseClient, collections: CollectionSchema<any>[], options?: EntryStoreOptions);
|
|
10
10
|
private fail;
|
|
11
11
|
private schema;
|
|
@@ -7,9 +7,9 @@
|
|
|
7
7
|
// calls the smoodly_reorder SQL function: one statement, updated_at
|
|
8
8
|
// untouched.
|
|
9
9
|
import { collectionDepth, collectionOrder } from "./collections.js";
|
|
10
|
-
import { DEFAULT_ENTRY_LOCALES, entryErrors, entryFieldsIn, entryLocaleRow, entryRefEdges, entryTitleIn, schemaOf, splitFields, } from "./entry-store.js";
|
|
10
|
+
import { DEFAULT_ENTRY_LOCALES, entryErrors, entryFieldsIn, entryLocaleRow, entryRefEdges, entryTitleIn, isSharedSchema, schemaOf, splitFields, } from "./entry-store.js";
|
|
11
11
|
import { SupabasePathIndex } from "./supabase-path-index.js";
|
|
12
|
-
import { assertDepth, assertSupportedLocale, entryPathRows } from "./paths.js";
|
|
12
|
+
import { assertDepth, assertSupportedLocale, entryPathRows, slugOf } from "./paths.js";
|
|
13
13
|
// entries.id is a Postgres uuid column: filtering on a non-UUID string
|
|
14
14
|
// throws invalid_text_representation. A non-UUID id can't exist in the
|
|
15
15
|
// table, so treat it the same as a missing one.
|
|
@@ -44,15 +44,15 @@ function toVersion(row) {
|
|
|
44
44
|
export class SupabaseEntryStore {
|
|
45
45
|
constructor(db, collections, options = {}) {
|
|
46
46
|
this.db = db;
|
|
47
|
-
this.collections = collections;
|
|
48
47
|
this.paths = options.paths ?? new SupabasePathIndex(db);
|
|
49
48
|
this.locales = options.locales ?? DEFAULT_ENTRY_LOCALES;
|
|
49
|
+
this.schemas = [...collections, ...(options.shared ?? [])];
|
|
50
50
|
}
|
|
51
51
|
fail(message) {
|
|
52
52
|
throw new Error(`smoodly: ${message}`);
|
|
53
53
|
}
|
|
54
54
|
schema(collection) {
|
|
55
|
-
return schemaOf(this.
|
|
55
|
+
return schemaOf(this.schemas, collection);
|
|
56
56
|
}
|
|
57
57
|
// ── reads ──
|
|
58
58
|
async fetch(collection, id) {
|
|
@@ -240,6 +240,15 @@ export class SupabaseEntryStore {
|
|
|
240
240
|
const schema = this.schema(collection);
|
|
241
241
|
assertSupportedLocale(options.locale, this.locales);
|
|
242
242
|
const parentId = options.parentId ?? null;
|
|
243
|
+
if (isSharedSchema(schema)) {
|
|
244
|
+
const existing = await this.db.from("entries").select("id").eq("collection", collection).limit(1);
|
|
245
|
+
if (existing.error)
|
|
246
|
+
this.fail(existing.error.message);
|
|
247
|
+
if (existing.data.length > 0)
|
|
248
|
+
throw entryErrors.singleton(collection);
|
|
249
|
+
if (slugOf(fields) !== null)
|
|
250
|
+
throw entryErrors.noSlug(collection);
|
|
251
|
+
}
|
|
243
252
|
const parent = await this.assertParent(collection, parentId, 1);
|
|
244
253
|
this.assertParentHasLocale(parent, options.locale);
|
|
245
254
|
const { shared, localized, slug } = splitFields(fields, schema);
|
|
@@ -327,6 +336,8 @@ export class SupabaseEntryStore {
|
|
|
327
336
|
const before = await this.must(collection, id);
|
|
328
337
|
const row = entryLocaleRow(before, locale);
|
|
329
338
|
const { shared, localized, slug } = splitFields(fields, schema);
|
|
339
|
+
if (isSharedSchema(schema) && slug !== null)
|
|
340
|
+
throw entryErrors.noSlug(collection);
|
|
330
341
|
await this.assertSiblingSlug(collection, before.parentId, locale, slug, id);
|
|
331
342
|
await this.updateNode(id, { fields: shared });
|
|
332
343
|
await this.updateLocale(id, locale, { slug, fields: localized });
|
|
@@ -393,6 +404,8 @@ export class SupabaseEntryStore {
|
|
|
393
404
|
}
|
|
394
405
|
async removeLocale(collection, id, locale) {
|
|
395
406
|
const schema = this.schema(collection);
|
|
407
|
+
if (isSharedSchema(schema))
|
|
408
|
+
throw entryErrors.everyLocale(collection);
|
|
396
409
|
const entry = await this.must(collection, id);
|
|
397
410
|
entryLocaleRow(entry, locale);
|
|
398
411
|
if (Object.keys(entry.locales).length === 1)
|
|
@@ -428,6 +441,8 @@ export class SupabaseEntryStore {
|
|
|
428
441
|
return res.data.map(toVersion);
|
|
429
442
|
}
|
|
430
443
|
async move(collection, id, to) {
|
|
444
|
+
if (isSharedSchema(this.schema(collection)))
|
|
445
|
+
throw entryErrors.owned(collection, "it has no tree position");
|
|
431
446
|
const entry = await this.must(collection, id);
|
|
432
447
|
const subtree = await this.subtree(entry);
|
|
433
448
|
if (to.parentId !== null && (to.parentId === id || subtree.some((e) => e.id === to.parentId)))
|
|
@@ -473,6 +488,8 @@ export class SupabaseEntryStore {
|
|
|
473
488
|
const entry = await this.fetch(collection, id);
|
|
474
489
|
if (!entry)
|
|
475
490
|
return;
|
|
491
|
+
if (isSharedSchema(this.schema(collection)))
|
|
492
|
+
throw entryErrors.owned(collection, "remove the registration to remove it");
|
|
476
493
|
const children = await this.db.from("entries").select("id").eq("parent_id", id).limit(1);
|
|
477
494
|
if (children.error)
|
|
478
495
|
this.fail(children.error.message);
|
|
@@ -498,7 +515,8 @@ export class SupabaseEntryStore {
|
|
|
498
515
|
return (await this.fetch(collection, id)) ? this.paths.pathsOf({ kind: "entry", id }) : {};
|
|
499
516
|
}
|
|
500
517
|
async reorder(collection, orderedIds) {
|
|
501
|
-
this.schema(collection)
|
|
518
|
+
if (isSharedSchema(this.schema(collection)))
|
|
519
|
+
throw entryErrors.owned(collection, "it has no order");
|
|
502
520
|
const res = await this.db.rpc("smoodly_reorder", { p_collection: collection, p_ids: orderedIds });
|
|
503
521
|
if (res.error)
|
|
504
522
|
this.fail(res.error.message);
|
package/dist/zones.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { SharedSchema } from "./shared.ts";
|
|
1
2
|
type WithSchema = {
|
|
2
3
|
schema: {
|
|
3
4
|
name: string;
|
|
@@ -11,9 +12,12 @@ export type ZonePolicy = {
|
|
|
11
12
|
kind: "freeform";
|
|
12
13
|
allow: string[] | "*";
|
|
13
14
|
rows: boolean;
|
|
14
|
-
}
|
|
15
|
+
}
|
|
16
|
+
/** `shared` names the item a bound zone shows (spec 2026-09-07 §4); `component` is its section either way. */
|
|
17
|
+
| {
|
|
15
18
|
kind: "locked";
|
|
16
19
|
component: string;
|
|
20
|
+
shared?: string;
|
|
17
21
|
};
|
|
18
22
|
export declare const z: {
|
|
19
23
|
sections(components: WithSchema[]): {
|
|
@@ -26,6 +30,6 @@ export declare const z: {
|
|
|
26
30
|
sections: WithSchema[] | "*";
|
|
27
31
|
rows?: boolean;
|
|
28
32
|
}): ZonePolicy;
|
|
29
|
-
locked(
|
|
33
|
+
locked(target: WithSchema | SharedSchema): ZonePolicy;
|
|
30
34
|
};
|
|
31
35
|
export {};
|
package/dist/zones.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Zone policies — the freedom dial. Policies reference paired components
|
|
2
2
|
// directly; only their registered names are stored (plain data).
|
|
3
|
+
const isShared = (target) => "kind" in target && target.kind === "shared";
|
|
3
4
|
const names = (components) => components.map((c) => c.schema.name);
|
|
4
5
|
export const z = {
|
|
5
6
|
sections(components) {
|
|
@@ -17,7 +18,12 @@ export const z = {
|
|
|
17
18
|
rows: opts.rows ?? false,
|
|
18
19
|
};
|
|
19
20
|
},
|
|
20
|
-
locked(
|
|
21
|
-
|
|
21
|
+
locked(target) {
|
|
22
|
+
if (isShared(target)) {
|
|
23
|
+
if (!target.section)
|
|
24
|
+
throw new Error(`smoodly: shared item "${target.name}" has no section — a locked zone needs a visual item.`);
|
|
25
|
+
return { kind: "locked", component: target.section, shared: target.name };
|
|
26
|
+
}
|
|
27
|
+
return { kind: "locked", component: target.schema.name };
|
|
22
28
|
},
|
|
23
29
|
};
|
package/package.json
CHANGED