smoodly 0.0.4 → 0.0.6
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 +408 -34
- package/dist/admin/client-ops.js +2 -2
- package/dist/admin/editor/EditorView.d.ts +3 -2
- package/dist/admin/editor/EditorView.js +133 -80
- package/dist/admin/editor/PageSettings.d.ts +13 -0
- package/dist/admin/editor/PageSettings.js +40 -0
- package/dist/admin/fixed-nodes.d.ts +23 -0
- package/dist/admin/fixed-nodes.js +83 -0
- package/dist/admin/forms/FieldWidget.d.ts +2 -1
- package/dist/admin/forms/FieldWidget.js +16 -16
- package/dist/admin/index.d.ts +2 -1
- package/dist/admin/index.js +1 -0
- package/dist/admin/next/create-admin.js +7 -2
- package/dist/admin/next/op-handler.d.ts +1 -0
- package/dist/admin/next/op-handler.js +7 -2
- package/dist/admin/ops-impl.d.ts +4 -1
- package/dist/admin/ops-impl.js +190 -48
- package/dist/admin/ops.d.ts +39 -11
- package/dist/admin/serialize.d.ts +10 -1
- package/dist/admin/serialize.js +13 -2
- package/dist/admin/shell/AdminApp.js +36 -74
- package/dist/admin/shell/EntryForm.js +34 -10
- package/dist/admin/shell/ErrorPane.d.ts +4 -0
- package/dist/admin/shell/ErrorPane.js +9 -0
- package/dist/admin/shell/ListView.d.ts +8 -5
- package/dist/admin/shell/ListView.js +12 -11
- package/dist/admin/shell/LoginView.js +6 -2
- package/dist/admin/shell/PagesList.d.ts +6 -0
- package/dist/admin/shell/PagesList.js +148 -0
- package/dist/admin/shell/base.d.ts +1 -0
- package/dist/admin/shell/base.js +4 -0
- package/dist/admin/shell/pages-tree.d.ts +39 -0
- package/dist/admin/shell/pages-tree.js +65 -0
- package/dist/admin/ui/format.d.ts +1 -0
- package/dist/admin/ui/format.js +25 -0
- package/dist/admin/ui/primitives.d.ts +89 -0
- package/dist/admin/ui/primitives.js +115 -0
- package/dist/admin/ui/theme.d.ts +5 -0
- package/dist/admin/ui/theme.js +396 -0
- package/dist/collections.d.ts +23 -2
- package/dist/collections.js +19 -0
- package/dist/config.d.ts +11 -1
- package/dist/config.js +71 -13
- package/dist/entry-page.d.ts +16 -0
- package/dist/entry-page.js +6 -0
- package/dist/entry-store.d.ts +94 -7
- package/dist/entry-store.js +175 -26
- package/dist/index.d.ts +24 -8
- package/dist/index.js +14 -3
- package/dist/localize.d.ts +19 -0
- package/dist/localize.js +42 -0
- package/dist/next/index.d.ts +2 -2
- package/dist/next/index.js +1 -1
- package/dist/next/meta.d.ts +0 -6
- package/dist/next/meta.js +0 -11
- package/dist/next/page-renderer.d.ts +26 -18
- package/dist/next/page-renderer.js +79 -56
- package/dist/page.d.ts +10 -3
- package/dist/page.js +12 -2
- package/dist/pairing.d.ts +7 -6
- package/dist/path-index.d.ts +16 -0
- package/dist/path-index.js +33 -0
- package/dist/paths.d.ts +80 -0
- package/dist/paths.js +110 -0
- package/dist/render.d.ts +34 -5
- package/dist/render.js +9 -2
- package/dist/resolve.d.ts +8 -0
- package/dist/resolve.js +19 -7
- package/dist/revalidate.d.ts +10 -2
- package/dist/revalidate.js +13 -8
- package/dist/site.d.ts +85 -0
- package/dist/site.js +164 -0
- package/dist/sql-space.d.ts +1 -1
- package/dist/sql-space.js +2 -0
- package/dist/sql.d.ts +1 -1
- package/dist/sql.js +92 -15
- package/dist/store.d.ts +106 -30
- package/dist/store.js +242 -66
- package/dist/supabase-entry-store.d.ts +17 -3
- package/dist/supabase-entry-store.js +198 -22
- package/dist/supabase-path-index.d.ts +11 -0
- package/dist/supabase-path-index.js +52 -0
- package/dist/supabase-store.d.ts +46 -16
- package/dist/supabase-store.js +322 -110
- package/package.json +5 -4
- package/dist/admin/page-path.d.ts +0 -1
- package/dist/admin/page-path.js +0 -6
- package/dist/admin/shell/pages-list.d.ts +0 -22
- package/dist/admin/shell/pages-list.js +0 -31
package/dist/resolve.js
CHANGED
|
@@ -26,12 +26,8 @@ export async function resolveTree(tree, options) {
|
|
|
26
26
|
const schema = "schema" in s ? s.schema : s;
|
|
27
27
|
return [schema.name, schema];
|
|
28
28
|
}));
|
|
29
|
-
const collectionSchemas = new Map(options.collections.map((c) => [c.name, c]));
|
|
30
29
|
const resolved = structuredClone(tree);
|
|
31
|
-
|
|
32
|
-
const cache = new Map();
|
|
33
|
-
// seed tasks from every node's ref-typed fields
|
|
34
|
-
let tasks = [];
|
|
30
|
+
const tasks = [];
|
|
35
31
|
for (const nodes of Object.values(resolved.zones ?? {})) {
|
|
36
32
|
for (const node of nodes) {
|
|
37
33
|
const schema = sectionSchemas.get(node.type);
|
|
@@ -40,7 +36,24 @@ export async function resolveTree(tree, options) {
|
|
|
40
36
|
collectTasks(node.fields, schema.fields, tasks);
|
|
41
37
|
}
|
|
42
38
|
}
|
|
43
|
-
|
|
39
|
+
await runResolution(tasks, { collections: options.collections, fetch: options.fetch, maxDepth });
|
|
40
|
+
return resolved;
|
|
41
|
+
}
|
|
42
|
+
/** One entry's fields, refs hydrated — the entry page's counterpart of
|
|
43
|
+
* resolveTree. Same rules, same batching, same cycle cut. */
|
|
44
|
+
export async function resolveFields(fields, descriptors, options) {
|
|
45
|
+
const resolved = structuredClone(fields);
|
|
46
|
+
const tasks = [];
|
|
47
|
+
collectTasks(resolved, descriptors, tasks);
|
|
48
|
+
await runResolution(tasks, { ...options, maxDepth: options.maxDepth ?? Infinity });
|
|
49
|
+
return resolved;
|
|
50
|
+
}
|
|
51
|
+
async function runResolution(seeds, options) {
|
|
52
|
+
const collectionSchemas = new Map(options.collections.map((c) => [c.name, c]));
|
|
53
|
+
// every entry is fetched once; cycle cuts reuse cached ancestors
|
|
54
|
+
const cache = new Map();
|
|
55
|
+
let tasks = seeds;
|
|
56
|
+
for (let round = 0; round < options.maxDepth && tasks.length > 0; round++) {
|
|
44
57
|
// batch: one fetch per collection with unique uncached ids
|
|
45
58
|
const wanted = new Map();
|
|
46
59
|
for (const task of tasks) {
|
|
@@ -111,7 +124,6 @@ export async function resolveTree(tree, options) {
|
|
|
111
124
|
}
|
|
112
125
|
tasks = next;
|
|
113
126
|
}
|
|
114
|
-
return resolved;
|
|
115
127
|
}
|
|
116
128
|
function collectTasks(values, descriptors, out) {
|
|
117
129
|
for (const [key, d] of Object.entries(descriptors)) {
|
package/dist/revalidate.d.ts
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
import type { EntryStore } from "./entry-store.ts";
|
|
2
2
|
import type { PageStore } from "./store.ts";
|
|
3
|
-
export
|
|
3
|
+
export type AffectedTargets = {
|
|
4
|
+
/** Every PUBLISHED page whose tree references the entry, directly or through other entries. */
|
|
5
|
+
pageIds: string[];
|
|
6
|
+
/** The entry itself, then every entry that references it, transitively — the entry-page cache units. */
|
|
7
|
+
entryIds: string[];
|
|
8
|
+
};
|
|
9
|
+
export declare const pageTag: (id: string) => string;
|
|
10
|
+
export declare const entryTag: (id: string) => string;
|
|
11
|
+
export declare function affectedTargets(stores: {
|
|
4
12
|
pages: PageStore;
|
|
5
13
|
entries: EntryStore;
|
|
6
14
|
}, entryId: string, options?: {
|
|
7
15
|
maxDepth?: number;
|
|
8
|
-
}): Promise<
|
|
16
|
+
}): Promise<AffectedTargets>;
|
package/dist/revalidate.js
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
// The revalidation fan-out (DESIGN.md §3): an entry changed — which
|
|
2
|
-
//
|
|
2
|
+
// cached units must re-render? Walks the refs index in REVERSE
|
|
3
3
|
// (entry ← entry ← page), breadth-first with a visited set so cycles
|
|
4
4
|
// terminate; `maxDepth` is an optional safety valve like resolveTree's.
|
|
5
|
-
// Framework-free on purpose: returns
|
|
6
|
-
//
|
|
7
|
-
|
|
5
|
+
// Framework-free on purpose: returns ids, and the Next glue maps them to
|
|
6
|
+
// cache tags (`pageTag`/`entryTag`) — by id, because slugs and paths
|
|
7
|
+
// change (spec 2026-09-05 §5).
|
|
8
|
+
export const pageTag = (id) => `page:${id}`;
|
|
9
|
+
export const entryTag = (id) => `entry:${id}`;
|
|
10
|
+
export async function affectedTargets(stores, entryId, options = {}) {
|
|
8
11
|
const maxDepth = options.maxDepth ?? Infinity;
|
|
9
12
|
const seen = new Set([entryId]);
|
|
13
|
+
const entryIds = [entryId];
|
|
10
14
|
const pageIds = new Set();
|
|
11
15
|
let frontier = [entryId];
|
|
12
16
|
for (let depth = 0; depth < maxDepth && frontier.length > 0; depth++) {
|
|
@@ -18,17 +22,18 @@ export async function affectedPageSlugs(stores, entryId, options = {}) {
|
|
|
18
22
|
}
|
|
19
23
|
else if (source.sourceKind === "entry" && !seen.has(source.sourceId)) {
|
|
20
24
|
seen.add(source.sourceId);
|
|
25
|
+
entryIds.push(source.sourceId);
|
|
21
26
|
next.push(source.sourceId);
|
|
22
27
|
}
|
|
23
28
|
}
|
|
24
29
|
}
|
|
25
30
|
frontier = next;
|
|
26
31
|
}
|
|
27
|
-
const
|
|
32
|
+
const published = [];
|
|
28
33
|
for (const id of pageIds) {
|
|
29
|
-
const page = await stores.pages.
|
|
34
|
+
const page = await stores.pages.getPage(id);
|
|
30
35
|
if (page?.publishedVersionId)
|
|
31
|
-
|
|
36
|
+
published.push(id);
|
|
32
37
|
}
|
|
33
|
-
return
|
|
38
|
+
return { pageIds: published, entryIds };
|
|
34
39
|
}
|
package/dist/site.d.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { CollectionSchema, EntryOrder } from "./collections.ts";
|
|
2
|
+
import { type EntryRecord, type EntryStore } from "./entry-store.ts";
|
|
3
|
+
import { type LocaleSet, type PathTarget } from "./paths.ts";
|
|
4
|
+
import type { SectionLike } from "./refs.ts";
|
|
5
|
+
import type { PageContext, PageLink, PageTree } from "./render.tsx";
|
|
6
|
+
import type { PageRecord, PageStore } from "./store.ts";
|
|
7
|
+
export type SiteOptions = {
|
|
8
|
+
stores: () => {
|
|
9
|
+
pages: PageStore;
|
|
10
|
+
entries: EntryStore;
|
|
11
|
+
};
|
|
12
|
+
collections: CollectionSchema<any>[];
|
|
13
|
+
sections: SectionLike[];
|
|
14
|
+
locales: LocaleSet;
|
|
15
|
+
homePageSlug?: string;
|
|
16
|
+
};
|
|
17
|
+
export type SitePage = {
|
|
18
|
+
kind: "page";
|
|
19
|
+
record: PageRecord;
|
|
20
|
+
template: string;
|
|
21
|
+
tree: PageTree;
|
|
22
|
+
title: string;
|
|
23
|
+
path: string;
|
|
24
|
+
ancestors: PageLink[];
|
|
25
|
+
children: PageLink[];
|
|
26
|
+
/** locale → path, for metadata alternates. */
|
|
27
|
+
paths: Record<string, string>;
|
|
28
|
+
};
|
|
29
|
+
export type SiteEntry = {
|
|
30
|
+
kind: "entry";
|
|
31
|
+
collection: string;
|
|
32
|
+
/** `fields` are localized for the requested locale with refs resolved. */
|
|
33
|
+
entry: EntryRecord;
|
|
34
|
+
path: string;
|
|
35
|
+
paths: Record<string, string>;
|
|
36
|
+
};
|
|
37
|
+
export type SiteHit = SitePage | SiteEntry;
|
|
38
|
+
export type PageTreeItem = {
|
|
39
|
+
id: string;
|
|
40
|
+
title: string;
|
|
41
|
+
path: string;
|
|
42
|
+
hasPage: boolean;
|
|
43
|
+
children: PageTreeItem[];
|
|
44
|
+
};
|
|
45
|
+
export type SiteEntryItem = {
|
|
46
|
+
entry: EntryRecord;
|
|
47
|
+
path: string | null;
|
|
48
|
+
};
|
|
49
|
+
export type EntryTreeItem = SiteEntryItem & {
|
|
50
|
+
children: EntryTreeItem[];
|
|
51
|
+
};
|
|
52
|
+
export type EntryQuery = {
|
|
53
|
+
locale?: string;
|
|
54
|
+
parent?: string | null;
|
|
55
|
+
order?: EntryOrder;
|
|
56
|
+
};
|
|
57
|
+
export type LoadOptions = {
|
|
58
|
+
draft?: boolean;
|
|
59
|
+
};
|
|
60
|
+
export interface SmoodlySite {
|
|
61
|
+
readonly locales: LocaleSet;
|
|
62
|
+
readonly homePageSlug: string;
|
|
63
|
+
/** One index hit: what lives at (locale, path). Uncached by design — the id it returns is the cache tag. */
|
|
64
|
+
locate(locale: string, path: string): Promise<PathTarget | null>;
|
|
65
|
+
/** Everything the renderer needs for one target: null when it is a folder, unpublished (outside draft), or gone. */
|
|
66
|
+
load(target: PathTarget, locale: string, path: string, options?: LoadOptions): Promise<SiteHit | null>;
|
|
67
|
+
resolve(locale: string, path: string, options?: LoadOptions): Promise<SiteHit | null>;
|
|
68
|
+
getPagesTree(options?: {
|
|
69
|
+
locale?: string;
|
|
70
|
+
}): Promise<PageTreeItem[]>;
|
|
71
|
+
getPage(pathOrId: string, options?: {
|
|
72
|
+
locale?: string;
|
|
73
|
+
}): Promise<SitePage | null>;
|
|
74
|
+
getEntries(collection: string, options?: EntryQuery): Promise<SiteEntryItem[]>;
|
|
75
|
+
getEntry(collection: string, idOrPath: string, options?: {
|
|
76
|
+
locale?: string;
|
|
77
|
+
}): Promise<SiteEntryItem | null>;
|
|
78
|
+
getEntriesTree(collection: string, options?: {
|
|
79
|
+
locale?: string;
|
|
80
|
+
}): Promise<EntryTreeItem[]>;
|
|
81
|
+
}
|
|
82
|
+
/** The `page` prop for a loaded page. `toUrl` is the app's `href` seam
|
|
83
|
+
* bound to the render locale; the default keeps CMS paths as URLs. */
|
|
84
|
+
export declare function pageContextOf(hit: SitePage, toUrl?: (path: string) => string): PageContext;
|
|
85
|
+
export declare function createSmoodlySite(options: SiteOptions): SmoodlySite;
|
package/dist/site.js
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { schemaOf } from "./entry-store.js";
|
|
2
|
+
import { localizeFields, titleFor } from "./localize.js";
|
|
3
|
+
import { buildPageTree, chainOf, entryPathRows, entrySegment, localesOf, pagePath, } from "./paths.js";
|
|
4
|
+
import { resolveFields, resolveTree } from "./resolve.js";
|
|
5
|
+
/** The `page` prop for a loaded page. `toUrl` is the app's `href` seam
|
|
6
|
+
* bound to the render locale; the default keeps CMS paths as URLs. */
|
|
7
|
+
export function pageContextOf(hit, toUrl = (p) => p) {
|
|
8
|
+
const withUrl = (l) => ({ ...l, url: toUrl(l.path) });
|
|
9
|
+
return {
|
|
10
|
+
id: hit.record.id,
|
|
11
|
+
title: hit.title,
|
|
12
|
+
path: hit.path,
|
|
13
|
+
url: toUrl(hit.path),
|
|
14
|
+
ancestors: hit.ancestors.map(withUrl),
|
|
15
|
+
children: hit.children.map(withUrl),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
export function createSmoodlySite(options) {
|
|
19
|
+
const locales = options.locales;
|
|
20
|
+
const homePageSlug = options.homePageSlug ?? "home";
|
|
21
|
+
const pathOpts = { locales, homePageSlug };
|
|
22
|
+
const mounted = options.collections.filter((c) => c.path !== undefined);
|
|
23
|
+
const fetcher = (draft) => (collection, ids) => options.stores().entries.getMany(collection, ids, draft ? undefined : { status: "published" });
|
|
24
|
+
/** Visible to the public: folders and published pages. Draft mode sees every node. */
|
|
25
|
+
const visible = (node, draft) => draft || node.template === null || node.publishedVersionId !== null;
|
|
26
|
+
const hasPage = (node, draft) => node.template !== null && (draft || node.publishedVersionId !== null);
|
|
27
|
+
const link = (node, byId, locale, draft) => ({
|
|
28
|
+
title: titleFor(node, locale, locales),
|
|
29
|
+
path: pagePath(node, byId, locale, pathOpts),
|
|
30
|
+
hasPage: hasPage(node, draft),
|
|
31
|
+
});
|
|
32
|
+
async function loadPage(id, locale, path, draft) {
|
|
33
|
+
const { pages } = options.stores();
|
|
34
|
+
const record = await pages.getPage(id);
|
|
35
|
+
if (!record || record.template === null)
|
|
36
|
+
return null;
|
|
37
|
+
const raw = draft ? await pages.getDraftTree(id) : record.publishedVersionId ? await pages.getPublishedTree(id) : null;
|
|
38
|
+
if (!raw)
|
|
39
|
+
return null;
|
|
40
|
+
const tree = await resolveTree(raw, { sections: options.sections, collections: options.collections, fetch: fetcher(draft) });
|
|
41
|
+
const all = await pages.listPages();
|
|
42
|
+
const index = new Map(all.map((n) => [n.id, n]));
|
|
43
|
+
const byId = (x) => index.get(x);
|
|
44
|
+
const chain = chainOf(record, byId);
|
|
45
|
+
const ancestors = chain.slice(0, -1).map((n) => link(n, byId, locale, draft));
|
|
46
|
+
const children = all
|
|
47
|
+
.filter((n) => n.parentId === id && visible(n, draft) && localesOf(n, locales).includes(locale))
|
|
48
|
+
.map((n) => link(n, byId, locale, draft));
|
|
49
|
+
return {
|
|
50
|
+
kind: "page",
|
|
51
|
+
record,
|
|
52
|
+
template: record.template,
|
|
53
|
+
tree,
|
|
54
|
+
title: titleFor(record, locale, locales),
|
|
55
|
+
path,
|
|
56
|
+
ancestors,
|
|
57
|
+
children,
|
|
58
|
+
paths: await pages.pathsOf(id),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
async function loadEntry(id, locale, path, draft) {
|
|
62
|
+
const { entries } = options.stores();
|
|
63
|
+
for (const schema of mounted) {
|
|
64
|
+
const record = await entries.get(schema.name, id);
|
|
65
|
+
if (!record)
|
|
66
|
+
continue;
|
|
67
|
+
if (!draft && record.status !== "published")
|
|
68
|
+
return null;
|
|
69
|
+
const fields = await resolveFields(localizeFields(record.fields, record.i18n, locale, locales), schema.fields, {
|
|
70
|
+
collections: options.collections,
|
|
71
|
+
fetch: fetcher(draft),
|
|
72
|
+
});
|
|
73
|
+
return {
|
|
74
|
+
kind: "entry",
|
|
75
|
+
collection: schema.name,
|
|
76
|
+
entry: { ...record, fields },
|
|
77
|
+
path,
|
|
78
|
+
paths: await entries.pathsOf(schema.name, id),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
const localeOf = (o) => o?.locale ?? locales.default;
|
|
84
|
+
/** Localized fields and the locale's path for entries of one collection;
|
|
85
|
+
* `all` is the whole collection (drafts included: a draft ancestor
|
|
86
|
+
* still contributes its segment). */
|
|
87
|
+
function entryItems(schema, wanted, all, locale) {
|
|
88
|
+
const index = new Map(all.map((e) => [e.id, e]));
|
|
89
|
+
const byId = (x) => index.get(x);
|
|
90
|
+
return wanted.map((record) => ({
|
|
91
|
+
entry: { ...record, fields: localizeFields(record.fields, record.i18n, locale, locales) },
|
|
92
|
+
path: entryPathRows([record], byId, schema, locales).find((r) => r.locale === locale)?.path ?? null,
|
|
93
|
+
}));
|
|
94
|
+
}
|
|
95
|
+
const publicEntry = (e, locale) => e.status === "published" && localesOf(e, locales).includes(locale);
|
|
96
|
+
async function getEntries(collection, o = {}) {
|
|
97
|
+
const schema = schemaOf(options.collections, collection);
|
|
98
|
+
const locale = localeOf(o);
|
|
99
|
+
const { entries } = options.stores();
|
|
100
|
+
const all = await entries.list(collection, { order: o.order });
|
|
101
|
+
const wanted = all.filter((e) => publicEntry(e, locale) && (o.parent === undefined || e.parentId === o.parent));
|
|
102
|
+
return entryItems(schema, wanted, all, locale);
|
|
103
|
+
}
|
|
104
|
+
const api = {
|
|
105
|
+
async getPagesTree(o) {
|
|
106
|
+
const locale = localeOf(o);
|
|
107
|
+
const all = await options.stores().pages.listPages();
|
|
108
|
+
const shown = all.filter((n) => visible(n, false) && localesOf(n, locales).includes(locale));
|
|
109
|
+
const toItem = (node) => ({
|
|
110
|
+
id: node.record.id,
|
|
111
|
+
title: titleFor(node.record, locale, locales),
|
|
112
|
+
path: node.path,
|
|
113
|
+
hasPage: hasPage(node.record, false),
|
|
114
|
+
children: node.children.map(toItem),
|
|
115
|
+
});
|
|
116
|
+
return buildPageTree(shown, locale, pathOpts).map(toItem);
|
|
117
|
+
},
|
|
118
|
+
async getPage(pathOrId, o) {
|
|
119
|
+
const locale = localeOf(o);
|
|
120
|
+
const path = pathOrId.startsWith("/") ? pathOrId : (await options.stores().pages.pathsOf(pathOrId))[locale];
|
|
121
|
+
if (!path)
|
|
122
|
+
return null;
|
|
123
|
+
const hit = await site.resolve(locale, path);
|
|
124
|
+
return hit?.kind === "page" ? hit : null;
|
|
125
|
+
},
|
|
126
|
+
getEntries,
|
|
127
|
+
async getEntry(collection, idOrPath, o) {
|
|
128
|
+
schemaOf(options.collections, collection); // an unknown collection is a wiring error on every branch
|
|
129
|
+
const locale = localeOf(o);
|
|
130
|
+
if (idOrPath.startsWith("/")) {
|
|
131
|
+
const hit = await site.resolve(locale, idOrPath);
|
|
132
|
+
return hit?.kind === "entry" && hit.collection === collection ? { entry: hit.entry, path: hit.path } : null;
|
|
133
|
+
}
|
|
134
|
+
const items = await getEntries(collection, { locale });
|
|
135
|
+
const byId = items.find((i) => i.entry.id === idOrPath);
|
|
136
|
+
if (byId)
|
|
137
|
+
return byId;
|
|
138
|
+
// A bare slug names a ROOT entry only (sibling-scoped slugs, spec §4):
|
|
139
|
+
// match the localized slug directly (paths.ts owns slug derivation),
|
|
140
|
+
// regardless of whether the collection even has a `path`.
|
|
141
|
+
return items.find((i) => i.entry.parentId === null && entrySegment(i.entry, locale, locales) === idOrPath) ?? null;
|
|
142
|
+
},
|
|
143
|
+
async getEntriesTree(collection, o) {
|
|
144
|
+
const items = await getEntries(collection, { locale: localeOf(o) });
|
|
145
|
+
const build = (parentId) => items.filter((i) => i.entry.parentId === parentId).map((i) => ({ ...i, children: build(i.entry.id) }));
|
|
146
|
+
return build(null);
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
const site = {
|
|
150
|
+
locales,
|
|
151
|
+
homePageSlug,
|
|
152
|
+
locate: (locale, path) => options.stores().pages.resolvePath(locale, path),
|
|
153
|
+
load(target, locale, path, opts = {}) {
|
|
154
|
+
const draft = opts.draft ?? false;
|
|
155
|
+
return target.kind === "page" ? loadPage(target.id, locale, path, draft) : loadEntry(target.id, locale, path, draft);
|
|
156
|
+
},
|
|
157
|
+
async resolve(locale, path, opts) {
|
|
158
|
+
const target = await site.locate(locale, path);
|
|
159
|
+
return target ? site.load(target, locale, path, opts) : null;
|
|
160
|
+
},
|
|
161
|
+
...api,
|
|
162
|
+
};
|
|
163
|
+
return site;
|
|
164
|
+
}
|
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_versions", "saved_sections", "global_sections", "entries", "refs"];
|
|
3
|
+
export declare const CONTENT_TABLES: readonly ["pages", "page_versions", "saved_sections", "global_sections", "entries", "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
|
@@ -11,6 +11,7 @@ export const CONTENT_TABLES = [
|
|
|
11
11
|
"global_sections",
|
|
12
12
|
"entries",
|
|
13
13
|
"refs",
|
|
14
|
+
"paths",
|
|
14
15
|
];
|
|
15
16
|
export function currentSpaceSQL() {
|
|
16
17
|
return `
|
|
@@ -35,6 +36,7 @@ const PUBLISHED_READ = {
|
|
|
35
36
|
saved_sections: null,
|
|
36
37
|
refs: null,
|
|
37
38
|
editors: null,
|
|
39
|
+
paths: `(("kind" = 'page' and exists (select 1 from "pages" p where p."id" = "paths"."target_id" and p."status" = 'published')) or ("kind" = 'entry' and exists (select 1 from "entries" e where e."id" = "paths"."target_id" and e."status" = 'published')))`,
|
|
38
40
|
};
|
|
39
41
|
/** Tables whose existing indexes do not lead with space_id. */
|
|
40
42
|
const NEEDS_SPACE_INDEX = ["page_versions", "saved_sections", "global_sections", "refs"];
|
package/dist/sql.d.ts
CHANGED
package/dist/sql.js
CHANGED
|
@@ -4,15 +4,18 @@
|
|
|
4
4
|
// disposable guardrails — a published-only read view, created with
|
|
5
5
|
// security_invoker so it runs under the caller's own RLS policies
|
|
6
6
|
// rather than the view owner's privileges (PostgREST-native reads,
|
|
7
|
-
// SQL-queryable eject story) — and partial expression
|
|
8
|
-
//
|
|
9
|
-
//
|
|
7
|
+
// SQL-queryable eject story) — and a partial expression index for a
|
|
8
|
+
// declared order field, on the jsonb VALUE (slug uniqueness is a core
|
|
9
|
+
// index, scoped to siblings, not per collection). Views project the
|
|
10
|
+
// default locale; the `i18n` overlay is the resolver/API's business.
|
|
11
|
+
import { BUILT_IN_ORDER_FIELDS } from "./collections.js";
|
|
10
12
|
import { currentSpaceSQL, SPACE_COL, spaceLayerSQL } from "./sql-space.js";
|
|
11
13
|
const snake = (s) => s.replace(/[A-Z]/g, (c) => "_" + c.toLowerCase());
|
|
12
14
|
const q = (s) => `"${s}"`;
|
|
13
15
|
// Casts are fine in views (unlike index expressions, which must be
|
|
14
|
-
// immutable —
|
|
15
|
-
//
|
|
16
|
+
// immutable — the order field's index stays on the raw jsonb
|
|
17
|
+
// expression: jsonb ordering compares numbers numerically and strings,
|
|
18
|
+
// ISO dates included, alphabetically, matching the memory comparator).
|
|
16
19
|
function fieldExpr(key, d) {
|
|
17
20
|
switch (d.type) {
|
|
18
21
|
case "text":
|
|
@@ -44,11 +47,15 @@ export function collectionSQL(collection) {
|
|
|
44
47
|
`where "collection" = '${name}' and "status" = 'published';`,
|
|
45
48
|
].join("\n"),
|
|
46
49
|
];
|
|
47
|
-
// slug uniqueness is the generic core index
|
|
48
|
-
//
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
50
|
+
// slug uniqueness is the generic core index, scoped to siblings
|
|
51
|
+
// (space_id, collection, parent_id, slug); only a declared order field
|
|
52
|
+
// needs a per-collection expression index — the built-in timestamps are
|
|
53
|
+
// real columns already. The expression is the jsonb value (`->`), the
|
|
54
|
+
// same one the adapter orders by, so the index actually serves the sort.
|
|
55
|
+
const order = collection.order;
|
|
56
|
+
if (order !== undefined && order !== "manual" && !BUILT_IN_ORDER_FIELDS.includes(order.by)) {
|
|
57
|
+
statements.push(`create index if not exists ${q(`entries_${name}_${snake(order.by)}_idx`)}\n` +
|
|
58
|
+
` on "entries" (("fields"->'${order.by}')) where "collection" = '${name}';`);
|
|
52
59
|
}
|
|
53
60
|
return statements.join("\n\n");
|
|
54
61
|
}
|
|
@@ -63,18 +70,37 @@ export function coreTablesSQL() {
|
|
|
63
70
|
create table if not exists "pages" (
|
|
64
71
|
"id" uuid primary key default gen_random_uuid(),
|
|
65
72
|
${SPACE_COL},
|
|
73
|
+
"parent_id" uuid references "pages" ("id") on delete restrict,
|
|
66
74
|
"slug" text not null,
|
|
67
|
-
"
|
|
75
|
+
"title" text not null,
|
|
76
|
+
"i18n" jsonb,
|
|
77
|
+
"template" text,
|
|
68
78
|
"status" text not null default 'draft',
|
|
69
79
|
"sort" numeric,
|
|
70
80
|
"locales" text[],
|
|
71
81
|
"draft_version_id" uuid,
|
|
72
82
|
"published_version_id" uuid,
|
|
73
83
|
"created_at" timestamptz not null default now(),
|
|
74
|
-
"updated_at" timestamptz not null default now()
|
|
75
|
-
unique ("space_id", "slug")
|
|
84
|
+
"updated_at" timestamptz not null default now()
|
|
76
85
|
);
|
|
77
86
|
|
|
87
|
+
-- The tree is the URL (spec 2026-09-05 §1): a node stores its own segment
|
|
88
|
+
-- and its parent. Siblings are unique per segment; NULLS NOT DISTINCT so
|
|
89
|
+
-- two root nodes cannot share one either. template null = folder.
|
|
90
|
+
create unique index if not exists "pages_sibling_slug_key"
|
|
91
|
+
on "pages" ("space_id", "parent_id", "slug") nulls not distinct;
|
|
92
|
+
|
|
93
|
+
create index if not exists "pages_parent_idx"
|
|
94
|
+
on "pages" ("space_id", "parent_id", "sort");
|
|
95
|
+
|
|
96
|
+
-- Sibling reorder for pages: same shape as smoodly_reorder for entries.
|
|
97
|
+
create or replace function "smoodly_reorder_pages"(p_ids uuid[]) returns void language sql as $$
|
|
98
|
+
update "pages" pg set "sort" = u.ord
|
|
99
|
+
from unnest(p_ids) with ordinality as u(id, ord)
|
|
100
|
+
where pg."id" = u.id
|
|
101
|
+
and pg."space_id" = "smoodly_current_space"();
|
|
102
|
+
$$;
|
|
103
|
+
|
|
78
104
|
create table if not exists "page_versions" (
|
|
79
105
|
"id" uuid primary key default gen_random_uuid(),
|
|
80
106
|
${SPACE_COL},
|
|
@@ -104,6 +130,7 @@ create table if not exists "entries" (
|
|
|
104
130
|
"id" uuid primary key default gen_random_uuid(),
|
|
105
131
|
${SPACE_COL},
|
|
106
132
|
"collection" text not null,
|
|
133
|
+
"parent_id" uuid references "entries" ("id") on delete restrict,
|
|
107
134
|
"fields" jsonb not null default '{}'::jsonb,
|
|
108
135
|
"i18n" jsonb,
|
|
109
136
|
"status" text not null default 'draft',
|
|
@@ -113,8 +140,22 @@ create table if not exists "entries" (
|
|
|
113
140
|
"updated_at" timestamptz not null default now()
|
|
114
141
|
);
|
|
115
142
|
|
|
116
|
-
|
|
117
|
-
|
|
143
|
+
-- Entry slugs are unique among SIBLINGS, like pages: one rule for every
|
|
144
|
+
-- node in the tree, and the URL is the tree. NULLS NOT DISTINCT so two
|
|
145
|
+
-- ROOT entries (parent_id null) cannot share a slug either. The partial
|
|
146
|
+
-- predicate keeps absent, null, empty or non-string slugs out of the
|
|
147
|
+
-- index entirely: with NULLS NOT DISTINCT a null slug would otherwise
|
|
148
|
+
-- collide with the next null slug, and a collection with no slug field
|
|
149
|
+
-- at all could hold only one entry per parent. jsonb_typeof(...) =
|
|
150
|
+
-- 'string' is required because ->> renders ANY JSON scalar as text —
|
|
151
|
+
-- a number, boolean or null slug would otherwise print as non-empty
|
|
152
|
+
-- text and claim the index; only a non-empty string slug claims a URL,
|
|
153
|
+
-- absent, null, empty or non-string slugs never conflict — the same
|
|
154
|
+
-- rule as slugOf in paths.ts. A flat collection (every entry a root)
|
|
155
|
+
-- therefore behaves exactly as per-collection uniqueness did.
|
|
156
|
+
create unique index if not exists "entries_sibling_slug_key"
|
|
157
|
+
on "entries" ("space_id", "collection", "parent_id", ("fields"->>'slug')) nulls not distinct
|
|
158
|
+
where (jsonb_typeof("fields"->'slug') = 'string' and "fields"->>'slug' <> '');
|
|
118
159
|
|
|
119
160
|
create index if not exists "entries_collection_status_idx"
|
|
120
161
|
on "entries" ("space_id", "collection", "status");
|
|
@@ -122,6 +163,9 @@ create index if not exists "entries_collection_status_idx"
|
|
|
122
163
|
create index if not exists "entries_collection_sort_idx"
|
|
123
164
|
on "entries" ("space_id", "collection", "sort");
|
|
124
165
|
|
|
166
|
+
create index if not exists "entries_parent_idx"
|
|
167
|
+
on "entries" ("space_id", "collection", "parent_id");
|
|
168
|
+
|
|
125
169
|
-- v1 reorder (DESIGN.md §3 Manual ordering): rewrite sort 1..N in ONE
|
|
126
170
|
-- statement. Deliberately does not touch updated_at — a drag must not
|
|
127
171
|
-- make the whole collection look freshly edited. Scoped to the current
|
|
@@ -147,6 +191,38 @@ create table if not exists "refs" (
|
|
|
147
191
|
primary key ("source_kind", "source_id", "source_field", "target_id")
|
|
148
192
|
);
|
|
149
193
|
|
|
194
|
+
-- The URL index (spec 2026-09-05 §4): the refs counterpart for paths.
|
|
195
|
+
-- Derived — the stores rewrite a target's rows on every create, rename,
|
|
196
|
+
-- move and delete — so one primary-key hit resolves any URL in any locale
|
|
197
|
+
-- and no page or entry can share a URL with another in any locale.
|
|
198
|
+
-- Rows exist for drafts too (the editor needs them); the read policy
|
|
199
|
+
-- admits a row only when its target is published.
|
|
200
|
+
create table if not exists "paths" (
|
|
201
|
+
${SPACE_COL},
|
|
202
|
+
"locale" text not null,
|
|
203
|
+
"path" text not null,
|
|
204
|
+
"kind" text not null check ("kind" in ('page', 'entry')),
|
|
205
|
+
"target_id" uuid not null,
|
|
206
|
+
primary key ("space_id", "locale", "path")
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
create index if not exists "paths_target_idx"
|
|
210
|
+
on "paths" ("space_id", "kind", "target_id");
|
|
211
|
+
|
|
212
|
+
-- Replace the targets' rows with the given rows in ONE call: a SQL
|
|
213
|
+
-- function runs in the caller's transaction, so a primary-key collision
|
|
214
|
+
-- rolls the whole rewrite back. Inserted rows get their space from the
|
|
215
|
+
-- a_set_space trigger (the claim, or the default space).
|
|
216
|
+
create or replace function "smoodly_rewrite_paths"(p_targets jsonb, p_rows jsonb) returns void language sql as $$
|
|
217
|
+
delete from "paths" p
|
|
218
|
+
using jsonb_to_recordset(p_targets) as t(kind text, id uuid)
|
|
219
|
+
where p."kind" = t.kind and p."target_id" = t.id
|
|
220
|
+
and p."space_id" = "smoodly_current_space"();
|
|
221
|
+
insert into "paths" ("locale", "path", "kind", "target_id")
|
|
222
|
+
select r.locale, r.path, r.kind, r.id
|
|
223
|
+
from jsonb_to_recordset(p_rows) as r(locale text, path text, kind text, id uuid);
|
|
224
|
+
$$;
|
|
225
|
+
|
|
150
226
|
-- RLS on every core table. The service role bypasses it (self-host
|
|
151
227
|
-- adapters untouched). The policy set that follows (space layer) admits
|
|
152
228
|
-- a key to its own space: everything for a write key, published rows for
|
|
@@ -160,6 +236,7 @@ alter table "saved_sections" enable row level security;
|
|
|
160
236
|
alter table "global_sections" enable row level security;
|
|
161
237
|
alter table "entries" enable row level security;
|
|
162
238
|
alter table "refs" enable row level security;
|
|
239
|
+
alter table "paths" enable row level security;
|
|
163
240
|
`.trim(),
|
|
164
241
|
spaceLayerSQL(),
|
|
165
242
|
].join("\n\n");
|