smoodly 0.0.7 → 0.0.9
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 +224 -4
- package/dist/admin/client-ops.js +1 -0
- package/dist/admin/editor/EditorView.js +64 -8
- package/dist/admin/forms/FieldWidget.d.ts +7 -3
- package/dist/admin/forms/FieldWidget.js +85 -7
- package/dist/admin/forms/list.d.ts +18 -0
- package/dist/admin/forms/list.js +81 -0
- 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/richtext.d.ts +13 -11
- package/dist/admin/richtext.js +94 -13
- package/dist/admin/serialize.d.ts +15 -0
- package/dist/admin/serialize.js +11 -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/EntryForm.js +1 -1
- 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/admin/ui/theme.d.ts +1 -1
- package/dist/admin/ui/theme.js +13 -0
- package/dist/admin/validate.d.ts +7 -2
- package/dist/admin/validate.js +43 -13
- 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 +32 -4
- package/dist/fields.d.ts +16 -1
- package/dist/fields.js +49 -4
- package/dist/index.d.ts +8 -1
- package/dist/index.js +7 -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 +13 -0
- package/dist/resolve.d.ts +3 -0
- package/dist/resolve.js +41 -0
- package/dist/revalidate.d.ts +4 -0
- package/dist/revalidate.js +4 -0
- package/dist/richtext-render.d.ts +3 -0
- package/dist/richtext-render.js +62 -0
- package/dist/shared-id.d.ts +3 -0
- package/dist/shared-id.js +36 -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 +5 -1
- package/dist/sql-space.js +9 -10
- package/dist/sql.js +1 -19
- package/dist/supabase-entry-store.d.ts +9 -1
- package/dist/supabase-entry-store.js +60 -7
- package/dist/zones.d.ts +6 -2
- package/dist/zones.js +8 -2
- package/package.json +1 -1
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
// The site-side richtext renderer (spec 2026-09-07, list field §5):
|
|
3
|
+
// TipTap JSON → React, a fragment with no wrapper so the developer owns
|
|
4
|
+
// the surrounding element and its class. Pure React, no Node imports, so
|
|
5
|
+
// it works in shared, server and client components alike. Unknown nodes
|
|
6
|
+
// render their children — a document from a later, richer editor degrades
|
|
7
|
+
// instead of crashing. Elements as node views are Phase 3.
|
|
8
|
+
import { Fragment, createElement } from "react";
|
|
9
|
+
/** Only schemes an editor's link may carry reach the page: http(s), mailto, tel,
|
|
10
|
+
* and site-relative targets. Anything else (javascript:, data:, an empty href,
|
|
11
|
+
* a protocol-relative `//host` or a backslash form `/\host` that a browser
|
|
12
|
+
* treats the same way) renders the text without an anchor. */
|
|
13
|
+
const SAFE_HREF = /^(https?:|mailto:|tel:|\/(?![/\\])|#|\?|\.)/i;
|
|
14
|
+
function safeHref(raw) {
|
|
15
|
+
if (typeof raw !== "string")
|
|
16
|
+
return null;
|
|
17
|
+
// URL parsers drop tab, LF and CR, so normalise before testing to see what the parser sees.
|
|
18
|
+
const href = raw.replace(/[\t\n\r]/g, "").trim();
|
|
19
|
+
return href !== "" && SAFE_HREF.test(href) ? href : null;
|
|
20
|
+
}
|
|
21
|
+
export function RichText({ doc }) {
|
|
22
|
+
const d = doc;
|
|
23
|
+
if (!d || typeof d !== "object" || d.type !== "doc" || !Array.isArray(d.content))
|
|
24
|
+
return null;
|
|
25
|
+
return _jsx(_Fragment, { children: renderNodes(d.content) });
|
|
26
|
+
}
|
|
27
|
+
function renderNodes(nodes) {
|
|
28
|
+
return nodes.map((n, i) => _jsx(Fragment, { children: renderNode(n) }, i));
|
|
29
|
+
}
|
|
30
|
+
function renderNode(node) {
|
|
31
|
+
if (!node || typeof node !== "object")
|
|
32
|
+
return null;
|
|
33
|
+
const children = Array.isArray(node.content) ? renderNodes(node.content) : null;
|
|
34
|
+
switch (node.type) {
|
|
35
|
+
case "text": return withMarks(node.text ?? "", node.marks ?? []);
|
|
36
|
+
case "paragraph": return _jsx("p", { children: children });
|
|
37
|
+
case "heading": {
|
|
38
|
+
const level = Math.min(6, Math.max(1, Math.round(Number(node.attrs?.level ?? 1) || 1)));
|
|
39
|
+
return createElement(`h${level}`, null, children);
|
|
40
|
+
}
|
|
41
|
+
case "bulletList": return _jsx("ul", { children: children });
|
|
42
|
+
case "orderedList": return _jsx("ol", { children: children });
|
|
43
|
+
case "listItem": return _jsx("li", { children: children });
|
|
44
|
+
case "blockquote": return _jsx("blockquote", { children: children });
|
|
45
|
+
case "hardBreak": return _jsx("br", {});
|
|
46
|
+
default: return children;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/** Marks wrap from the first outward: [bold, italic] → <em><strong>…</strong></em>. */
|
|
50
|
+
function withMarks(text, marks) {
|
|
51
|
+
return marks.reduce((inner, m) => {
|
|
52
|
+
switch (m.type) {
|
|
53
|
+
case "bold": return _jsx("strong", { children: inner });
|
|
54
|
+
case "italic": return _jsx("em", { children: inner });
|
|
55
|
+
case "link": {
|
|
56
|
+
const href = safeHref(m.attrs?.href);
|
|
57
|
+
return href === null ? inner : _jsx("a", { href: href, children: inner });
|
|
58
|
+
}
|
|
59
|
+
default: return inner;
|
|
60
|
+
}
|
|
61
|
+
}, text);
|
|
62
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// The row id of a shared item (spec 2026-09-07 §10 "§3 row id"): uuid v5
|
|
2
|
+
// of (space id as the namespace, item name as the name). Deterministic in
|
|
3
|
+
// both adapters, so a second concurrent materialization of the same item
|
|
4
|
+
// hits the `entries` primary key instead of adding an unremovable
|
|
5
|
+
// duplicate — `delete` refuses every shared row. The space is part of it
|
|
6
|
+
// because the primary key is `id` alone and, in the cloud, many spaces
|
|
7
|
+
// share one table: a name-only id would make space B's footer collide
|
|
8
|
+
// with space A's.
|
|
9
|
+
//
|
|
10
|
+
// No imports — Web Crypto (`globalThis.crypto.subtle`) exists in Node
|
|
11
|
+
// >= 20 and in every browser, so this module carries no node builtin
|
|
12
|
+
// into the package root's graph. It matters because every scaffolded
|
|
13
|
+
// section does `import { f, smoodly } from "smoodly"`, and a "use client"
|
|
14
|
+
// section's browser build would fail on `node:crypto`.
|
|
15
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
16
|
+
/** The namespace's 16 raw bytes — RFC 4122 hashes the bytes, not the text. */
|
|
17
|
+
function uuidBytes(uuid) {
|
|
18
|
+
if (!UUID_RE.test(uuid))
|
|
19
|
+
throw new Error(`smoodly: "${uuid}" is not a uuid — a shared item's namespace is the space id.`);
|
|
20
|
+
const hex = uuid.replace(/-/g, "");
|
|
21
|
+
const bytes = new Uint8Array(16);
|
|
22
|
+
for (let i = 0; i < 16; i++)
|
|
23
|
+
bytes[i] = parseInt(hex.substring(i * 2, i * 2 + 2), 16);
|
|
24
|
+
return bytes;
|
|
25
|
+
}
|
|
26
|
+
/** RFC 4122 §4.3 uuid v5: sha1(namespace bytes ‖ name bytes), version 5,
|
|
27
|
+
* variant 10xx, first 16 bytes of the digest. */
|
|
28
|
+
export async function sharedEntryId(space, name) {
|
|
29
|
+
const input = new Uint8Array([...uuidBytes(space), ...new TextEncoder().encode(name)]);
|
|
30
|
+
const digest = new Uint8Array(await globalThis.crypto.subtle.digest("SHA-1", input));
|
|
31
|
+
const bytes = digest.subarray(0, 16);
|
|
32
|
+
bytes[6] = (bytes[6] & 0x0f) | 0x50; // version 5 in the high nibble of octet 6
|
|
33
|
+
bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 10xx in the top bits of octet 8
|
|
34
|
+
const hex = [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
35
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
36
|
+
}
|
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,10 @@
|
|
|
1
|
+
/** The one space a self-host install has; the space a request with no
|
|
2
|
+
* claim resolves to. The bare uuid — code needs the value (a shared
|
|
3
|
+
* item's id namespace), SQL needs it quoted. */
|
|
4
|
+
export declare const DEFAULT_SPACE_ID = "00000000-0000-0000-0000-000000000000";
|
|
1
5
|
export declare const DEFAULT_SPACE = "'00000000-0000-0000-0000-000000000000'";
|
|
2
6
|
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", "
|
|
7
|
+
export declare const CONTENT_TABLES: readonly ["pages", "page_locales", "page_versions", "entries", "entry_locales", "entry_versions", "refs", "paths"];
|
|
4
8
|
export type ContentTable = (typeof CONTENT_TABLES)[number];
|
|
5
9
|
export declare function currentSpaceSQL(): string;
|
|
6
10
|
export declare function spaceLayerSQL(): string;
|
package/dist/sql-space.js
CHANGED
|
@@ -2,14 +2,16 @@
|
|
|
2
2
|
// is one cloud environment; a self-host install is one space with the
|
|
3
3
|
// default id. Everything that needs "which space is this request in"
|
|
4
4
|
// reads smoodly_current_space() — the adapters never mention spaces.
|
|
5
|
-
|
|
5
|
+
/** The one space a self-host install has; the space a request with no
|
|
6
|
+
* claim resolves to. The bare uuid — code needs the value (a shared
|
|
7
|
+
* item's id namespace), SQL needs it quoted. */
|
|
8
|
+
export const DEFAULT_SPACE_ID = "00000000-0000-0000-0000-000000000000";
|
|
9
|
+
export const DEFAULT_SPACE = `'${DEFAULT_SPACE_ID}'`;
|
|
6
10
|
export const SPACE_COL = `"space_id" uuid not null default ${DEFAULT_SPACE}`;
|
|
7
11
|
export const CONTENT_TABLES = [
|
|
8
12
|
"pages",
|
|
9
13
|
"page_locales",
|
|
10
14
|
"page_versions",
|
|
11
|
-
"saved_sections",
|
|
12
|
-
"global_sections",
|
|
13
15
|
"entries",
|
|
14
16
|
"entry_locales",
|
|
15
17
|
"entry_versions",
|
|
@@ -27,10 +29,9 @@ language sql stable as $$
|
|
|
27
29
|
$$;`.trim();
|
|
28
30
|
}
|
|
29
31
|
/** 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. */
|
|
32
|
+
* null = write keys only — refs is editor tooling; editors is admission
|
|
33
|
+
* data; nothing a browser needs. Shared content (spec 2026-09-07) rides
|
|
34
|
+
* the three entry predicates: it is stored as entries. */
|
|
34
35
|
const PUBLISHED_READ = {
|
|
35
36
|
pages: `exists (select 1 from "page_locales" l where l."page_id" = "pages"."id" and l."status" = 'published')`,
|
|
36
37
|
page_locales: `"status" = 'published'`,
|
|
@@ -38,15 +39,13 @@ const PUBLISHED_READ = {
|
|
|
38
39
|
entries: `exists (select 1 from "entry_locales" l where l."entry_id" = "entries"."id" and l."status" = 'published')`,
|
|
39
40
|
entry_locales: `"status" = 'published'`,
|
|
40
41
|
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
42
|
refs: null,
|
|
44
43
|
editors: null,
|
|
45
44
|
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
45
|
};
|
|
47
46
|
/** Tables whose existing indexes do not lead with space_id. */
|
|
48
47
|
const NEEDS_SPACE_INDEX = [
|
|
49
|
-
"page_locales", "page_versions", "entry_locales", "entry_versions", "
|
|
48
|
+
"page_locales", "page_versions", "entry_locales", "entry_versions", "refs",
|
|
50
49
|
];
|
|
51
50
|
export function spaceLayerSQL() {
|
|
52
51
|
const statements = [];
|
package/dist/sql.js
CHANGED
|
@@ -31,7 +31,7 @@ function fieldExpr(key, d, source) {
|
|
|
31
31
|
case "date":
|
|
32
32
|
return `(${source}->>'${key}')::timestamptz`;
|
|
33
33
|
default:
|
|
34
|
-
return `${source}->'${key}'`; // image, video, file, object, richtext, dataAttributes, refList
|
|
34
|
+
return `${source}->'${key}'`; // image, video, file, object, list, richtext, dataAttributes, refList
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
37
|
export function collectionSQL(collection) {
|
|
@@ -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,12 +3,20 @@ 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
|
+
private spaceId?;
|
|
9
10
|
constructor(db: SupabaseClient, collections: CollectionSchema<any>[], options?: EntryStoreOptions);
|
|
10
11
|
private fail;
|
|
11
12
|
private schema;
|
|
13
|
+
/** The space this client acts in — the ONE function the policies and
|
|
14
|
+
* the insert trigger read (src/sql-space.ts): a key's claim in the
|
|
15
|
+
* cloud, the default space under the service role. Constant for the
|
|
16
|
+
* life of the client, so the PROMISE is cached and concurrent creates
|
|
17
|
+
* share one round trip; a failed lookup is not cached. */
|
|
18
|
+
private space;
|
|
19
|
+
private fetchSpace;
|
|
12
20
|
private fetch;
|
|
13
21
|
private must;
|
|
14
22
|
private siblings;
|
|
@@ -7,9 +7,10 @@
|
|
|
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
|
+
import { sharedEntryId } from "./shared-id.js";
|
|
11
12
|
import { SupabasePathIndex } from "./supabase-path-index.js";
|
|
12
|
-
import { assertDepth, assertSupportedLocale, entryPathRows } from "./paths.js";
|
|
13
|
+
import { assertDepth, assertSupportedLocale, entryPathRows, slugOf } from "./paths.js";
|
|
13
14
|
// entries.id is a Postgres uuid column: filtering on a non-UUID string
|
|
14
15
|
// throws invalid_text_representation. A non-UUID id can't exist in the
|
|
15
16
|
// table, so treat it the same as a missing one.
|
|
@@ -44,15 +45,38 @@ function toVersion(row) {
|
|
|
44
45
|
export class SupabaseEntryStore {
|
|
45
46
|
constructor(db, collections, options = {}) {
|
|
46
47
|
this.db = db;
|
|
47
|
-
this.collections = collections;
|
|
48
48
|
this.paths = options.paths ?? new SupabasePathIndex(db);
|
|
49
49
|
this.locales = options.locales ?? DEFAULT_ENTRY_LOCALES;
|
|
50
|
+
this.schemas = [...collections, ...(options.shared ?? [])];
|
|
50
51
|
}
|
|
51
52
|
fail(message) {
|
|
52
53
|
throw new Error(`smoodly: ${message}`);
|
|
53
54
|
}
|
|
54
55
|
schema(collection) {
|
|
55
|
-
return schemaOf(this.
|
|
56
|
+
return schemaOf(this.schemas, collection);
|
|
57
|
+
}
|
|
58
|
+
/** The space this client acts in — the ONE function the policies and
|
|
59
|
+
* the insert trigger read (src/sql-space.ts): a key's claim in the
|
|
60
|
+
* cloud, the default space under the service role. Constant for the
|
|
61
|
+
* life of the client, so the PROMISE is cached and concurrent creates
|
|
62
|
+
* share one round trip; a failed lookup is not cached. */
|
|
63
|
+
space() {
|
|
64
|
+
if (!this.spaceId) {
|
|
65
|
+
this.spaceId = this.fetchSpace().catch((e) => {
|
|
66
|
+
// A failed lookup is not cached: the next write retries it.
|
|
67
|
+
this.spaceId = undefined;
|
|
68
|
+
throw e;
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
return this.spaceId;
|
|
72
|
+
}
|
|
73
|
+
async fetchSpace() {
|
|
74
|
+
const res = await this.db.rpc("smoodly_current_space");
|
|
75
|
+
if (res.error)
|
|
76
|
+
this.fail(res.error.message);
|
|
77
|
+
if (typeof res.data !== "string")
|
|
78
|
+
this.fail("smoodly_current_space() returned no space — is the schema up to date?");
|
|
79
|
+
return res.data;
|
|
56
80
|
}
|
|
57
81
|
// ── reads ──
|
|
58
82
|
async fetch(collection, id) {
|
|
@@ -240,13 +264,33 @@ export class SupabaseEntryStore {
|
|
|
240
264
|
const schema = this.schema(collection);
|
|
241
265
|
assertSupportedLocale(options.locale, this.locales);
|
|
242
266
|
const parentId = options.parentId ?? null;
|
|
267
|
+
if (isSharedSchema(schema)) {
|
|
268
|
+
const existing = await this.db.from("entries").select("id").eq("collection", collection).limit(1);
|
|
269
|
+
if (existing.error)
|
|
270
|
+
this.fail(existing.error.message);
|
|
271
|
+
if (existing.data.length > 0)
|
|
272
|
+
throw entryErrors.singleton(collection);
|
|
273
|
+
if (slugOf(fields) !== null)
|
|
274
|
+
throw entryErrors.noSlug(collection);
|
|
275
|
+
}
|
|
243
276
|
const parent = await this.assertParent(collection, parentId, 1);
|
|
244
277
|
this.assertParentHasLocale(parent, options.locale);
|
|
245
278
|
const { shared, localized, slug } = splitFields(fields, schema);
|
|
246
279
|
await this.assertSiblingSlug(collection, parentId, options.locale, slug);
|
|
247
|
-
|
|
248
|
-
|
|
280
|
+
// A shared item's row id is DERIVED from (space, name), not generated
|
|
281
|
+
// (spec 2026-09-07 §10): the SELECT above is a fast path two racing
|
|
282
|
+
// materializations can both pass, so the primary key is the backstop
|
|
283
|
+
// — a duplicate would be unremovable, since `delete` refuses every
|
|
284
|
+
// shared row.
|
|
285
|
+
const row = { collection, parent_id: parentId, fields: shared };
|
|
286
|
+
if (isSharedSchema(schema))
|
|
287
|
+
row.id = await sharedEntryId(await this.space(), collection);
|
|
288
|
+
const inserted = await this.db.from("entries").insert(row).select("id").single();
|
|
289
|
+
if (inserted.error) {
|
|
290
|
+
if (isSharedSchema(schema) && inserted.error.code === UNIQUE_VIOLATION)
|
|
291
|
+
throw entryErrors.singleton(collection);
|
|
249
292
|
this.fail(inserted.error.message);
|
|
293
|
+
}
|
|
250
294
|
const id = inserted.data.id;
|
|
251
295
|
try {
|
|
252
296
|
await this.insertLocale(id, options.locale, { slug, fields: localized });
|
|
@@ -327,6 +371,8 @@ export class SupabaseEntryStore {
|
|
|
327
371
|
const before = await this.must(collection, id);
|
|
328
372
|
const row = entryLocaleRow(before, locale);
|
|
329
373
|
const { shared, localized, slug } = splitFields(fields, schema);
|
|
374
|
+
if (isSharedSchema(schema) && slug !== null)
|
|
375
|
+
throw entryErrors.noSlug(collection);
|
|
330
376
|
await this.assertSiblingSlug(collection, before.parentId, locale, slug, id);
|
|
331
377
|
await this.updateNode(id, { fields: shared });
|
|
332
378
|
await this.updateLocale(id, locale, { slug, fields: localized });
|
|
@@ -393,6 +439,8 @@ export class SupabaseEntryStore {
|
|
|
393
439
|
}
|
|
394
440
|
async removeLocale(collection, id, locale) {
|
|
395
441
|
const schema = this.schema(collection);
|
|
442
|
+
if (isSharedSchema(schema))
|
|
443
|
+
throw entryErrors.everyLocale(collection);
|
|
396
444
|
const entry = await this.must(collection, id);
|
|
397
445
|
entryLocaleRow(entry, locale);
|
|
398
446
|
if (Object.keys(entry.locales).length === 1)
|
|
@@ -428,6 +476,8 @@ export class SupabaseEntryStore {
|
|
|
428
476
|
return res.data.map(toVersion);
|
|
429
477
|
}
|
|
430
478
|
async move(collection, id, to) {
|
|
479
|
+
if (isSharedSchema(this.schema(collection)))
|
|
480
|
+
throw entryErrors.owned(collection, "it has no tree position");
|
|
431
481
|
const entry = await this.must(collection, id);
|
|
432
482
|
const subtree = await this.subtree(entry);
|
|
433
483
|
if (to.parentId !== null && (to.parentId === id || subtree.some((e) => e.id === to.parentId)))
|
|
@@ -473,6 +523,8 @@ export class SupabaseEntryStore {
|
|
|
473
523
|
const entry = await this.fetch(collection, id);
|
|
474
524
|
if (!entry)
|
|
475
525
|
return;
|
|
526
|
+
if (isSharedSchema(this.schema(collection)))
|
|
527
|
+
throw entryErrors.owned(collection, "remove the registration to remove it");
|
|
476
528
|
const children = await this.db.from("entries").select("id").eq("parent_id", id).limit(1);
|
|
477
529
|
if (children.error)
|
|
478
530
|
this.fail(children.error.message);
|
|
@@ -498,7 +550,8 @@ export class SupabaseEntryStore {
|
|
|
498
550
|
return (await this.fetch(collection, id)) ? this.paths.pathsOf({ kind: "entry", id }) : {};
|
|
499
551
|
}
|
|
500
552
|
async reorder(collection, orderedIds) {
|
|
501
|
-
this.schema(collection)
|
|
553
|
+
if (isSharedSchema(this.schema(collection)))
|
|
554
|
+
throw entryErrors.owned(collection, "it has no order");
|
|
502
555
|
const res = await this.db.rpc("smoodly_reorder", { p_collection: collection, p_ids: orderedIds });
|
|
503
556
|
if (res.error)
|
|
504
557
|
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