smoodly 0.0.6 → 0.0.7
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 +231 -73
- package/dist/admin/client-ops.js +2 -2
- package/dist/admin/editor/EditorView.js +167 -24
- package/dist/admin/editor/PageSettings.d.ts +8 -4
- package/dist/admin/editor/PageSettings.js +25 -25
- package/dist/admin/fixed-nodes.d.ts +16 -10
- package/dist/admin/fixed-nodes.js +55 -41
- package/dist/admin/ops-impl.js +134 -35
- package/dist/admin/ops.d.ts +51 -11
- package/dist/admin/shell/AdminApp.js +3 -32
- package/dist/admin/shell/EntriesList.d.ts +7 -0
- package/dist/admin/shell/EntriesList.js +95 -0
- package/dist/admin/shell/EntryForm.js +167 -49
- package/dist/admin/shell/PagesList.js +86 -26
- package/dist/admin/shell/entries-list.d.ts +21 -0
- package/dist/admin/shell/entries-list.js +36 -0
- package/dist/admin/shell/pages-tree.d.ts +30 -12
- package/dist/admin/shell/pages-tree.js +48 -14
- package/dist/admin/ui/LocaleSwitcher.d.ts +11 -0
- package/dist/admin/ui/LocaleSwitcher.js +15 -0
- package/dist/collections.d.ts +6 -0
- package/dist/collections.js +16 -0
- package/dist/config.js +23 -3
- package/dist/entry-store.d.ts +132 -54
- package/dist/entry-store.js +294 -86
- package/dist/index.d.ts +6 -6
- package/dist/index.js +4 -4
- package/dist/localize.d.ts +0 -11
- package/dist/localize.js +11 -29
- package/dist/paths.d.ts +46 -27
- package/dist/paths.js +62 -29
- package/dist/revalidate.js +2 -1
- package/dist/site.d.ts +6 -2
- package/dist/site.js +65 -39
- package/dist/sql-space.d.ts +1 -1
- package/dist/sql-space.js +13 -5
- package/dist/sql.js +96 -53
- package/dist/store.d.ts +66 -32
- package/dist/store.js +205 -90
- package/dist/supabase-entry-store.d.ts +29 -8
- package/dist/supabase-entry-store.js +341 -177
- package/dist/supabase-store.d.ts +21 -9
- package/dist/supabase-store.js +202 -111
- package/package.json +1 -1
package/dist/paths.d.ts
CHANGED
|
@@ -5,8 +5,21 @@ export type LocaleSet = {
|
|
|
5
5
|
default: string;
|
|
6
6
|
supported: string[];
|
|
7
7
|
};
|
|
8
|
+
/** ONE text for both stores: a locale outside the config set. */
|
|
9
|
+
export declare const unsupportedLocale: (locale: string) => Error;
|
|
10
|
+
export declare function assertSupportedLocale(locale: string, set: LocaleSet): void;
|
|
8
11
|
/** A per-locale segment map: a string applies to every supported locale; an object is copied as declared. */
|
|
9
12
|
export declare function perLocaleSegments(value: string | Record<string, string>, supported: string[]): Record<string, string>;
|
|
13
|
+
/** The segments a FIXED PAGE registration claims, per locale. A
|
|
14
|
+
* registration declares its slugs in code, so a locale its map omits
|
|
15
|
+
* means "the default locale's segment", not "absent" — the
|
|
16
|
+
* page_locales presence rule is about records, not registrations
|
|
17
|
+
* (spec 2026-09-06 §3). The boot collision check (config.ts), the
|
|
18
|
+
* materializer (admin/fixed-nodes.ts) and the fixed path derivation
|
|
19
|
+
* (localize.ts) all fill through here, or a collision passes boot and
|
|
20
|
+
* only surfaces at materialization. Collection mounts are NOT filled:
|
|
21
|
+
* a per-locale path claims exactly the locales it names. */
|
|
22
|
+
export declare function fixedPageSegments(slug: string | Record<string, string>, locales: LocaleSet): Record<string, string>;
|
|
10
23
|
export type PathTarget = {
|
|
11
24
|
kind: "page" | "entry";
|
|
12
25
|
id: string;
|
|
@@ -17,18 +30,10 @@ export type PathRow = PathTarget & {
|
|
|
17
30
|
};
|
|
18
31
|
export declare const joinPath: (segments: string[]) => string;
|
|
19
32
|
export declare const splitPath: (path: string) => string[];
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
export declare function segmentFor(node: {
|
|
25
|
-
slug: string;
|
|
26
|
-
i18n?: SlugI18n;
|
|
27
|
-
}, locale: string, set: LocaleSet): string;
|
|
28
|
-
/** null = every supported locale; otherwise the intersection. */
|
|
29
|
-
export declare function localesOf(node: {
|
|
30
|
-
locales?: string[] | null;
|
|
31
|
-
}, set: LocaleSet): string[];
|
|
33
|
+
/** The node's segment in one locale. An absent locale is an error, never
|
|
34
|
+
* a fallback: the stores' parent rule guarantees a present node has a
|
|
35
|
+
* present ancestor chain, so a throw here is a broken invariant. */
|
|
36
|
+
export declare function segmentFor(node: PageLike, locale: string): string;
|
|
32
37
|
export declare function chainOf<T extends {
|
|
33
38
|
id: string;
|
|
34
39
|
parentId: string | null;
|
|
@@ -37,44 +42,58 @@ export declare function assertDepth(depth: number, max: number, what: string): v
|
|
|
37
42
|
export type PageLike = {
|
|
38
43
|
id: string;
|
|
39
44
|
parentId: string | null;
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
slug
|
|
43
|
-
|
|
44
|
-
}> | null;
|
|
45
|
-
locales: string[] | null;
|
|
45
|
+
/** The locales the node exists in, each with the node's OWN segment there. Presence IS the key (spec 2026-09-06 §3). */
|
|
46
|
+
locales: Record<string, {
|
|
47
|
+
slug: string;
|
|
48
|
+
}>;
|
|
46
49
|
};
|
|
47
50
|
export type PagePathOptions = {
|
|
48
51
|
locales: LocaleSet;
|
|
49
52
|
homePageSlug: string;
|
|
50
53
|
};
|
|
54
|
+
/** Home is judged by identity: the root node whose DEFAULT-locale slug is
|
|
55
|
+
* homePageSlug serves "/" in every locale it exists in. */
|
|
56
|
+
export declare const isHomeNode: (node: PageLike, opts: PagePathOptions) => boolean;
|
|
51
57
|
/** The node's path in one locale: `/` for the home node, else the
|
|
52
|
-
*
|
|
58
|
+
* locale's ancestor chain joined. THE derivation — pagePathRows,
|
|
53
59
|
* buildPageTree and the site layer all call this. */
|
|
54
60
|
export declare function pagePath(node: PageLike, byId: (id: string) => PageLike | undefined, locale: string, opts: PagePathOptions): string;
|
|
61
|
+
/** The same path, or null when the node or an ancestor is absent from the
|
|
62
|
+
* locale — what the Pages list shows as a dimmed "not in fi" row. */
|
|
63
|
+
export declare function pagePathIn(node: PageLike, byId: (id: string) => PageLike | undefined, locale: string, opts: PagePathOptions): string | null;
|
|
64
|
+
/** One row per locale the target EXISTS in. */
|
|
55
65
|
export declare function pagePathRows(targets: PageLike[], byId: (id: string) => PageLike | undefined, opts: PagePathOptions): PathRow[];
|
|
56
66
|
export type EntryLike = {
|
|
57
67
|
id: string;
|
|
58
68
|
parentId: string | null;
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
69
|
+
/** The locales the entry exists in, each with its OWN segment there (null =
|
|
70
|
+
* the entry claims no URL in that locale). Presence IS the key (spec
|
|
71
|
+
* 2026-09-06 §7). */
|
|
72
|
+
locales: Record<string, {
|
|
73
|
+
slug: string | null;
|
|
74
|
+
}>;
|
|
62
75
|
};
|
|
63
76
|
export declare function collectionSegment(path: string | Record<string, string> | undefined, locale: string, set: LocaleSet): string | null;
|
|
64
77
|
/** The "claims a URL" predicate (§1): a slug is only a real segment when
|
|
65
78
|
* it is a non-empty string — absent, null, or `""` all mean "no URL".
|
|
66
|
-
*
|
|
67
|
-
*
|
|
79
|
+
* There is no sibling-slug index: sibling uniqueness is the primary key
|
|
80
|
+
* of `paths` plus the pre-check each store makes per locale before it
|
|
81
|
+
* writes (spec 2026-09-06 §12), so Postgres and memory agree on which
|
|
68
82
|
* entries can collide. */
|
|
69
83
|
export declare const slugOf: (fields: Record<string, unknown>) => string | null;
|
|
70
|
-
|
|
84
|
+
/** The entry's segment in one locale: its row's slug. An absent locale
|
|
85
|
+
* or a slug-less row is null — no URL there, never a fallback. */
|
|
86
|
+
export declare function entrySegment(entry: EntryLike, locale: string): string | null;
|
|
87
|
+
/** One row per locale the target EXISTS in, where the whole chain has a
|
|
88
|
+
* segment in that locale. The stores' parent rule guarantees an ancestor
|
|
89
|
+
* is present wherever a descendant is; an ancestor without a slug there
|
|
90
|
+
* simply yields no URL for the subtree in that locale. */
|
|
71
91
|
export declare function entryPathRows(targets: EntryLike[], byId: (id: string) => EntryLike | undefined, collection: {
|
|
72
92
|
path?: string | Record<string, string>;
|
|
73
93
|
}, set: LocaleSet): PathRow[];
|
|
74
94
|
export type PageTreeNode<T extends PageLike> = {
|
|
75
95
|
record: T;
|
|
76
|
-
path: string;
|
|
96
|
+
path: string | null;
|
|
77
97
|
children: PageTreeNode<T>[];
|
|
78
98
|
};
|
|
79
99
|
export declare function buildPageTree<T extends PageLike>(records: T[], locale: string, opts: PagePathOptions): PageTreeNode<T>[];
|
|
80
|
-
export {};
|
package/dist/paths.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
// The one place a URL is derived (spec 2026-09-05 §1, §4
|
|
2
|
-
// framework-free: both store adapters and
|
|
3
|
-
// the `paths` index and every derived
|
|
1
|
+
// The one place a URL is derived (spec 2026-09-05 §1, §4; spec
|
|
2
|
+
// 2026-09-06 §3, §7). Pure and framework-free: both store adapters and
|
|
3
|
+
// the tree query call these, so the `paths` index and every derived
|
|
4
|
+
// `path` value agree by construction.
|
|
4
5
|
export const SEGMENT_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
5
6
|
export const isSegment = (s) => SEGMENT_RE.test(s);
|
|
6
7
|
export function assertSegment(s, what) {
|
|
@@ -8,24 +9,39 @@ export function assertSegment(s, what) {
|
|
|
8
9
|
throw new Error(`smoodly: ${what} "${s}" is not a valid slug — use lowercase letters, digits and single hyphens.`);
|
|
9
10
|
}
|
|
10
11
|
}
|
|
12
|
+
/** ONE text for both stores: a locale outside the config set. */
|
|
13
|
+
export const unsupportedLocale = (locale) => new Error(`smoodly: "${locale}" is not a supported locale.`);
|
|
14
|
+
export function assertSupportedLocale(locale, set) {
|
|
15
|
+
if (!set.supported.includes(locale))
|
|
16
|
+
throw unsupportedLocale(locale);
|
|
17
|
+
}
|
|
11
18
|
/** A per-locale segment map: a string applies to every supported locale; an object is copied as declared. */
|
|
12
19
|
export function perLocaleSegments(value, supported) {
|
|
13
20
|
return typeof value === "string" ? Object.fromEntries(supported.map((l) => [l, value])) : { ...value };
|
|
14
21
|
}
|
|
22
|
+
/** The segments a FIXED PAGE registration claims, per locale. A
|
|
23
|
+
* registration declares its slugs in code, so a locale its map omits
|
|
24
|
+
* means "the default locale's segment", not "absent" — the
|
|
25
|
+
* page_locales presence rule is about records, not registrations
|
|
26
|
+
* (spec 2026-09-06 §3). The boot collision check (config.ts), the
|
|
27
|
+
* materializer (admin/fixed-nodes.ts) and the fixed path derivation
|
|
28
|
+
* (localize.ts) all fill through here, or a collision passes boot and
|
|
29
|
+
* only surfaces at materialization. Collection mounts are NOT filled:
|
|
30
|
+
* a per-locale path claims exactly the locales it names. */
|
|
31
|
+
export function fixedPageSegments(slug, locales) {
|
|
32
|
+
const raw = perLocaleSegments(slug, locales.supported);
|
|
33
|
+
return Object.fromEntries(locales.supported.map((l) => [l, raw[l] || raw[locales.default]]));
|
|
34
|
+
}
|
|
15
35
|
export const joinPath = (segments) => (segments.length === 0 ? "/" : "/" + segments.join("/"));
|
|
16
36
|
export const splitPath = (path) => path.split("/").filter((s) => s.length > 0);
|
|
17
|
-
/** The
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
const own = node.
|
|
22
|
-
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export function localesOf(node, set) {
|
|
26
|
-
if (!node.locales)
|
|
27
|
-
return [...set.supported];
|
|
28
|
-
return set.supported.filter((l) => node.locales.includes(l));
|
|
37
|
+
/** The node's segment in one locale. An absent locale is an error, never
|
|
38
|
+
* a fallback: the stores' parent rule guarantees a present node has a
|
|
39
|
+
* present ancestor chain, so a throw here is a broken invariant. */
|
|
40
|
+
export function segmentFor(node, locale) {
|
|
41
|
+
const own = node.locales[locale];
|
|
42
|
+
if (!own)
|
|
43
|
+
throw new Error(`smoodly: page "${node.id}" does not exist in locale "${locale}".`);
|
|
44
|
+
return own.slug;
|
|
29
45
|
}
|
|
30
46
|
export function chainOf(node, byId) {
|
|
31
47
|
const chain = [node];
|
|
@@ -43,19 +59,30 @@ export function assertDepth(depth, max, what) {
|
|
|
43
59
|
if (depth > max)
|
|
44
60
|
throw new Error(`smoodly: ${what} would exceed the tree depth (${max}).`);
|
|
45
61
|
}
|
|
46
|
-
|
|
62
|
+
/** Home is judged by identity: the root node whose DEFAULT-locale slug is
|
|
63
|
+
* homePageSlug serves "/" in every locale it exists in. */
|
|
64
|
+
export const isHomeNode = (node, opts) => node.parentId === null && node.locales[opts.locales.default]?.slug === opts.homePageSlug;
|
|
47
65
|
/** The node's path in one locale: `/` for the home node, else the
|
|
48
|
-
*
|
|
66
|
+
* locale's ancestor chain joined. THE derivation — pagePathRows,
|
|
49
67
|
* buildPageTree and the site layer all call this. */
|
|
50
68
|
export function pagePath(node, byId, locale, opts) {
|
|
51
|
-
return
|
|
52
|
-
|
|
53
|
-
|
|
69
|
+
return isHomeNode(node, opts) ? "/" : joinPath(chainOf(node, byId).map((n) => segmentFor(n, locale)));
|
|
70
|
+
}
|
|
71
|
+
/** The same path, or null when the node or an ancestor is absent from the
|
|
72
|
+
* locale — what the Pages list shows as a dimmed "not in fi" row. */
|
|
73
|
+
export function pagePathIn(node, byId, locale, opts) {
|
|
74
|
+
if (!node.locales[locale])
|
|
75
|
+
return null;
|
|
76
|
+
if (isHomeNode(node, opts))
|
|
77
|
+
return "/";
|
|
78
|
+
const chain = chainOf(node, byId);
|
|
79
|
+
return chain.every((n) => n.locales[locale] !== undefined) ? joinPath(chain.map((n) => n.locales[locale].slug)) : null;
|
|
54
80
|
}
|
|
81
|
+
/** One row per locale the target EXISTS in. */
|
|
55
82
|
export function pagePathRows(targets, byId, opts) {
|
|
56
83
|
const rows = [];
|
|
57
84
|
for (const target of targets) {
|
|
58
|
-
for (const locale of
|
|
85
|
+
for (const locale of Object.keys(target.locales)) {
|
|
59
86
|
rows.push({ kind: "page", id: target.id, locale, path: pagePath(target, byId, locale, opts) });
|
|
60
87
|
}
|
|
61
88
|
}
|
|
@@ -70,26 +97,32 @@ export function collectionSegment(path, locale, set) {
|
|
|
70
97
|
}
|
|
71
98
|
/** The "claims a URL" predicate (§1): a slug is only a real segment when
|
|
72
99
|
* it is a non-empty string — absent, null, or `""` all mean "no URL".
|
|
73
|
-
*
|
|
74
|
-
*
|
|
100
|
+
* There is no sibling-slug index: sibling uniqueness is the primary key
|
|
101
|
+
* of `paths` plus the pre-check each store makes per locale before it
|
|
102
|
+
* writes (spec 2026-09-06 §12), so Postgres and memory agree on which
|
|
75
103
|
* entries can collide. */
|
|
76
104
|
export const slugOf = (fields) => {
|
|
77
105
|
const slug = fields.slug;
|
|
78
106
|
return typeof slug === "string" && slug.length > 0 ? slug : null;
|
|
79
107
|
};
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
108
|
+
/** The entry's segment in one locale: its row's slug. An absent locale
|
|
109
|
+
* or a slug-less row is null — no URL there, never a fallback. */
|
|
110
|
+
export function entrySegment(entry, locale) {
|
|
111
|
+
return entry.locales[locale]?.slug ?? null;
|
|
83
112
|
}
|
|
113
|
+
/** One row per locale the target EXISTS in, where the whole chain has a
|
|
114
|
+
* segment in that locale. The stores' parent rule guarantees an ancestor
|
|
115
|
+
* is present wherever a descendant is; an ancestor without a slug there
|
|
116
|
+
* simply yields no URL for the subtree in that locale. */
|
|
84
117
|
export function entryPathRows(targets, byId, collection, set) {
|
|
85
118
|
const rows = [];
|
|
86
119
|
for (const target of targets) {
|
|
87
120
|
const chain = chainOf(target, byId);
|
|
88
|
-
for (const locale of
|
|
121
|
+
for (const locale of Object.keys(target.locales)) {
|
|
89
122
|
const base = collectionSegment(collection.path, locale, set);
|
|
90
123
|
if (base === null)
|
|
91
124
|
continue;
|
|
92
|
-
const segments = chain.map((e) => entrySegment(e, locale
|
|
125
|
+
const segments = chain.map((e) => entrySegment(e, locale));
|
|
93
126
|
if (segments.some((s) => s === null))
|
|
94
127
|
continue;
|
|
95
128
|
rows.push({ kind: "entry", id: target.id, locale, path: joinPath([base, ...segments]) });
|
|
@@ -103,7 +136,7 @@ export function buildPageTree(records, locale, opts) {
|
|
|
103
136
|
.filter((r) => r.parentId === parentId)
|
|
104
137
|
.map((r) => ({
|
|
105
138
|
record: r,
|
|
106
|
-
path:
|
|
139
|
+
path: pagePathIn(r, byId, locale, opts),
|
|
107
140
|
children: build(r.id),
|
|
108
141
|
}));
|
|
109
142
|
return build(null);
|
package/dist/revalidate.js
CHANGED
|
@@ -32,7 +32,8 @@ export async function affectedTargets(stores, entryId, options = {}) {
|
|
|
32
32
|
const published = [];
|
|
33
33
|
for (const id of pageIds) {
|
|
34
34
|
const page = await stores.pages.getPage(id);
|
|
35
|
-
|
|
35
|
+
// Published in ANY locale: the tag covers every locale's cached unit.
|
|
36
|
+
if (page && Object.values(page.locales).some((l) => l.status === "published"))
|
|
36
37
|
published.push(id);
|
|
37
38
|
}
|
|
38
39
|
return { pageIds: published, entryIds };
|
package/dist/site.d.ts
CHANGED
|
@@ -23,13 +23,17 @@ export type SitePage = {
|
|
|
23
23
|
path: string;
|
|
24
24
|
ancestors: PageLink[];
|
|
25
25
|
children: PageLink[];
|
|
26
|
-
/** locale → path, for metadata alternates
|
|
26
|
+
/** locale → path, for metadata alternates: the locales the page is
|
|
27
|
+
* PUBLISHED in, since that is what may be advertised — draft mode
|
|
28
|
+
* carries every locale the page exists in. */
|
|
27
29
|
paths: Record<string, string>;
|
|
28
30
|
};
|
|
29
31
|
export type SiteEntry = {
|
|
30
32
|
kind: "entry";
|
|
31
33
|
collection: string;
|
|
32
|
-
/** `fields` are
|
|
34
|
+
/** `fields` are the locale's MERGED fields — shared + localized + slug —
|
|
35
|
+
* with refs resolved: the published snapshot on the public path, the
|
|
36
|
+
* live row in draft mode. */
|
|
33
37
|
entry: EntryRecord;
|
|
34
38
|
path: string;
|
|
35
39
|
paths: Record<string, string>;
|
package/dist/site.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import { schemaOf } from "./entry-store.js";
|
|
2
|
-
import {
|
|
3
|
-
import { buildPageTree, chainOf, entryPathRows, entrySegment, localesOf, pagePath, } from "./paths.js";
|
|
1
|
+
import { entryFieldsIn, schemaOf } from "./entry-store.js";
|
|
2
|
+
import { buildPageTree, chainOf, entryPathRows, entrySegment, pagePath, } from "./paths.js";
|
|
4
3
|
import { resolveFields, resolveTree } from "./resolve.js";
|
|
5
4
|
/** The `page` prop for a loaded page. `toUrl` is the app's `href` seam
|
|
6
5
|
* bound to the render locale; the default keeps CMS paths as URLs. */
|
|
@@ -20,42 +19,59 @@ export function createSmoodlySite(options) {
|
|
|
20
19
|
const homePageSlug = options.homePageSlug ?? "home";
|
|
21
20
|
const pathOpts = { locales, homePageSlug };
|
|
22
21
|
const mounted = options.collections.filter((c) => c.path !== undefined);
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
const
|
|
22
|
+
/** The resolver's fetcher, bound to a locale: the merged fields of that
|
|
23
|
+
* locale — published ones (a versioned collection's snapshot) on the
|
|
24
|
+
* public path, the live rows in draft mode. */
|
|
25
|
+
const fetcher = (draft, locale) => (collection, ids) => options.stores().entries.getMany(collection, ids, locale, draft ? undefined : { status: "published" });
|
|
26
|
+
/** Visible to the public in a locale: present there, and a folder or a
|
|
27
|
+
* published page. Draft mode sees every present node. Presence is the
|
|
28
|
+
* locale row — nothing falls back (spec 2026-09-06 §3). */
|
|
29
|
+
const visible = (node, locale, draft) => {
|
|
30
|
+
const row = node.locales[locale];
|
|
31
|
+
return row !== undefined && (draft || node.template === null || row.status === "published");
|
|
32
|
+
};
|
|
33
|
+
const hasPage = (node, locale, draft) => {
|
|
34
|
+
const row = node.locales[locale];
|
|
35
|
+
return node.template !== null && row !== undefined && (draft || row.status === "published");
|
|
36
|
+
};
|
|
37
|
+
/** Only ever called for a node PRESENT in the locale (visible() or an ancestor of one). */
|
|
27
38
|
const link = (node, byId, locale, draft) => ({
|
|
28
|
-
title:
|
|
39
|
+
title: node.locales[locale].title,
|
|
29
40
|
path: pagePath(node, byId, locale, pathOpts),
|
|
30
|
-
hasPage: hasPage(node, draft),
|
|
41
|
+
hasPage: hasPage(node, locale, draft),
|
|
31
42
|
});
|
|
32
43
|
async function loadPage(id, locale, path, draft) {
|
|
33
44
|
const { pages } = options.stores();
|
|
34
45
|
const record = await pages.getPage(id);
|
|
35
46
|
if (!record || record.template === null)
|
|
36
47
|
return null;
|
|
37
|
-
const
|
|
48
|
+
const row = record.locales[locale];
|
|
49
|
+
if (!row)
|
|
50
|
+
return null;
|
|
51
|
+
const raw = draft ? await pages.getDraftTree(id, locale) : row.status === "published" ? await pages.getPublishedTree(id, locale) : null;
|
|
38
52
|
if (!raw)
|
|
39
53
|
return null;
|
|
40
|
-
const tree = await resolveTree(raw, { sections: options.sections, collections: options.collections, fetch: fetcher(draft) });
|
|
54
|
+
const tree = await resolveTree(raw, { sections: options.sections, collections: options.collections, fetch: fetcher(draft, locale) });
|
|
41
55
|
const all = await pages.listPages();
|
|
42
56
|
const index = new Map(all.map((n) => [n.id, n]));
|
|
43
57
|
const byId = (x) => index.get(x);
|
|
44
58
|
const chain = chainOf(record, byId);
|
|
45
59
|
const ancestors = chain.slice(0, -1).map((n) => link(n, byId, locale, draft));
|
|
46
60
|
const children = all
|
|
47
|
-
.filter((n) => n.parentId === id && visible(n,
|
|
61
|
+
.filter((n) => n.parentId === id && visible(n, locale, draft))
|
|
48
62
|
.map((n) => link(n, byId, locale, draft));
|
|
63
|
+
// Alternates advertise PUBLISHED locales only; the editor (draft) sees every present one.
|
|
64
|
+
const paths = Object.fromEntries(Object.entries(await pages.pathsOf(id)).filter(([l]) => draft || record.locales[l]?.status === "published"));
|
|
49
65
|
return {
|
|
50
66
|
kind: "page",
|
|
51
67
|
record,
|
|
52
68
|
template: record.template,
|
|
53
69
|
tree,
|
|
54
|
-
title:
|
|
70
|
+
title: row.title,
|
|
55
71
|
path,
|
|
56
72
|
ancestors,
|
|
57
73
|
children,
|
|
58
|
-
paths
|
|
74
|
+
paths,
|
|
59
75
|
};
|
|
60
76
|
}
|
|
61
77
|
async function loadEntry(id, locale, path, draft) {
|
|
@@ -64,53 +80,63 @@ export function createSmoodlySite(options) {
|
|
|
64
80
|
const record = await entries.get(schema.name, id);
|
|
65
81
|
if (!record)
|
|
66
82
|
continue;
|
|
67
|
-
|
|
83
|
+
// No row, or a draft row outside draft mode, is a 404 for THIS locale.
|
|
84
|
+
const row = record.locales[locale];
|
|
85
|
+
if (!row)
|
|
68
86
|
return null;
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
};
|
|
87
|
+
if (!draft && row.status !== "published")
|
|
88
|
+
return null;
|
|
89
|
+
const raw = draft
|
|
90
|
+
? entryFieldsIn(record, locale)
|
|
91
|
+
: (await entries.getMany(schema.name, [id], locale, { status: "published" }))[id];
|
|
92
|
+
if (!raw)
|
|
93
|
+
return null;
|
|
94
|
+
const fields = await resolveFields(raw, schema.fields, { collections: options.collections, fetch: fetcher(draft, locale) });
|
|
95
|
+
// Alternates advertise PUBLISHED locales only; the editor (draft) sees every present one.
|
|
96
|
+
const paths = Object.fromEntries(Object.entries(await entries.pathsOf(schema.name, id)).filter(([l]) => draft || record.locales[l]?.status === "published"));
|
|
97
|
+
return { kind: "entry", collection: schema.name, entry: { ...record, fields }, path, paths };
|
|
80
98
|
}
|
|
81
99
|
return null;
|
|
82
100
|
}
|
|
83
101
|
const localeOf = (o) => o?.locale ?? locales.default;
|
|
84
|
-
/**
|
|
85
|
-
* `all` is the whole collection (
|
|
86
|
-
*
|
|
87
|
-
function entryItems(schema, wanted, all, locale) {
|
|
102
|
+
/** One locale's published fields and path for entries of one collection;
|
|
103
|
+
* `all` is the whole collection (a draft ancestor still contributes
|
|
104
|
+
* its segment). `fieldsById` is the published read for the locale. */
|
|
105
|
+
function entryItems(schema, wanted, all, locale, fieldsById) {
|
|
88
106
|
const index = new Map(all.map((e) => [e.id, e]));
|
|
89
107
|
const byId = (x) => index.get(x);
|
|
90
|
-
return wanted
|
|
91
|
-
|
|
108
|
+
return wanted
|
|
109
|
+
.filter((record) => fieldsById[record.id] !== undefined)
|
|
110
|
+
.map((record) => ({
|
|
111
|
+
entry: { ...record, fields: fieldsById[record.id] },
|
|
92
112
|
path: entryPathRows([record], byId, schema, locales).find((r) => r.locale === locale)?.path ?? null,
|
|
93
113
|
}));
|
|
94
114
|
}
|
|
95
|
-
|
|
115
|
+
/** Public in a locale: its row exists there and is published. */
|
|
116
|
+
const publicEntry = (e, locale) => e.locales[locale]?.status === "published";
|
|
96
117
|
async function getEntries(collection, o = {}) {
|
|
97
118
|
const schema = schemaOf(options.collections, collection);
|
|
98
119
|
const locale = localeOf(o);
|
|
99
120
|
const { entries } = options.stores();
|
|
100
121
|
const all = await entries.list(collection, { order: o.order });
|
|
101
122
|
const wanted = all.filter((e) => publicEntry(e, locale) && (o.parent === undefined || e.parentId === o.parent));
|
|
102
|
-
|
|
123
|
+
const published = await entries.getMany(collection, wanted.map((e) => e.id), locale, { status: "published" });
|
|
124
|
+
return entryItems(schema, wanted, all, locale, published);
|
|
103
125
|
}
|
|
104
126
|
const api = {
|
|
105
127
|
async getPagesTree(o) {
|
|
106
128
|
const locale = localeOf(o);
|
|
107
129
|
const all = await options.stores().pages.listPages();
|
|
108
|
-
const shown = all.filter((n) => visible(n,
|
|
130
|
+
const shown = all.filter((n) => visible(n, locale, false));
|
|
131
|
+
const index = new Map(shown.map((n) => [n.id, n]));
|
|
109
132
|
const toItem = (node) => ({
|
|
110
133
|
id: node.record.id,
|
|
111
|
-
title:
|
|
112
|
-
|
|
113
|
-
|
|
134
|
+
title: node.record.locales[locale].title,
|
|
135
|
+
// Every node the walk reaches is present with a present chain, so
|
|
136
|
+
// buildPageTree's nullable path is never null here; pagePath is
|
|
137
|
+
// the same derivation and keeps the type honest.
|
|
138
|
+
path: node.path ?? pagePath(node.record, (x) => index.get(x), locale, pathOpts),
|
|
139
|
+
hasPage: hasPage(node.record, locale, false),
|
|
114
140
|
children: node.children.map(toItem),
|
|
115
141
|
});
|
|
116
142
|
return buildPageTree(shown, locale, pathOpts).map(toItem);
|
|
@@ -138,7 +164,7 @@ export function createSmoodlySite(options) {
|
|
|
138
164
|
// A bare slug names a ROOT entry only (sibling-scoped slugs, spec §4):
|
|
139
165
|
// match the localized slug directly (paths.ts owns slug derivation),
|
|
140
166
|
// regardless of whether the collection even has a `path`.
|
|
141
|
-
return items.find((i) => i.entry.parentId === null && entrySegment(i.entry, locale
|
|
167
|
+
return items.find((i) => i.entry.parentId === null && entrySegment(i.entry, locale) === idOrPath) ?? null;
|
|
142
168
|
},
|
|
143
169
|
async getEntriesTree(collection, o) {
|
|
144
170
|
const items = await getEntries(collection, { locale: localeOf(o) });
|
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", "paths"];
|
|
3
|
+
export declare const CONTENT_TABLES: readonly ["pages", "page_locales", "page_versions", "saved_sections", "global_sections", "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
|
@@ -6,10 +6,13 @@ export const DEFAULT_SPACE = `'00000000-0000-0000-0000-000000000000'`;
|
|
|
6
6
|
export const SPACE_COL = `"space_id" uuid not null default ${DEFAULT_SPACE}`;
|
|
7
7
|
export const CONTENT_TABLES = [
|
|
8
8
|
"pages",
|
|
9
|
+
"page_locales",
|
|
9
10
|
"page_versions",
|
|
10
11
|
"saved_sections",
|
|
11
12
|
"global_sections",
|
|
12
13
|
"entries",
|
|
14
|
+
"entry_locales",
|
|
15
|
+
"entry_versions",
|
|
13
16
|
"refs",
|
|
14
17
|
"paths",
|
|
15
18
|
];
|
|
@@ -29,17 +32,22 @@ $$;`.trim();
|
|
|
29
32
|
* empty string means "read policy, no extra predicate" (global_sections
|
|
30
33
|
* is fully readable once it's in-space) — distinct from null. */
|
|
31
34
|
const PUBLISHED_READ = {
|
|
32
|
-
pages: `"status" = 'published'`,
|
|
33
|
-
|
|
34
|
-
page_versions: `exists (select 1 from "
|
|
35
|
+
pages: `exists (select 1 from "page_locales" l where l."page_id" = "pages"."id" and l."status" = 'published')`,
|
|
36
|
+
page_locales: `"status" = 'published'`,
|
|
37
|
+
page_versions: `exists (select 1 from "page_locales" l where l."published_version_id" = "page_versions"."id")`,
|
|
38
|
+
entries: `exists (select 1 from "entry_locales" l where l."entry_id" = "entries"."id" and l."status" = 'published')`,
|
|
39
|
+
entry_locales: `"status" = 'published'`,
|
|
40
|
+
entry_versions: `exists (select 1 from "entry_locales" l where l."published_version_id" = "entry_versions"."id")`,
|
|
35
41
|
global_sections: ``,
|
|
36
42
|
saved_sections: null,
|
|
37
43
|
refs: null,
|
|
38
44
|
editors: null,
|
|
39
|
-
paths: `(("kind" = 'page' and exists (select 1 from "
|
|
45
|
+
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')))`,
|
|
40
46
|
};
|
|
41
47
|
/** Tables whose existing indexes do not lead with space_id. */
|
|
42
|
-
const NEEDS_SPACE_INDEX = [
|
|
48
|
+
const NEEDS_SPACE_INDEX = [
|
|
49
|
+
"page_locales", "page_versions", "entry_locales", "entry_versions", "saved_sections", "global_sections", "refs",
|
|
50
|
+
];
|
|
43
51
|
export function spaceLayerSQL() {
|
|
44
52
|
const statements = [];
|
|
45
53
|
statements.push(`
|