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.
Files changed (44) hide show
  1. package/README.md +231 -73
  2. package/dist/admin/client-ops.js +2 -2
  3. package/dist/admin/editor/EditorView.js +167 -24
  4. package/dist/admin/editor/PageSettings.d.ts +8 -4
  5. package/dist/admin/editor/PageSettings.js +25 -25
  6. package/dist/admin/fixed-nodes.d.ts +16 -10
  7. package/dist/admin/fixed-nodes.js +55 -41
  8. package/dist/admin/ops-impl.js +134 -35
  9. package/dist/admin/ops.d.ts +51 -11
  10. package/dist/admin/shell/AdminApp.js +3 -32
  11. package/dist/admin/shell/EntriesList.d.ts +7 -0
  12. package/dist/admin/shell/EntriesList.js +95 -0
  13. package/dist/admin/shell/EntryForm.js +167 -49
  14. package/dist/admin/shell/PagesList.js +86 -26
  15. package/dist/admin/shell/entries-list.d.ts +21 -0
  16. package/dist/admin/shell/entries-list.js +36 -0
  17. package/dist/admin/shell/pages-tree.d.ts +30 -12
  18. package/dist/admin/shell/pages-tree.js +48 -14
  19. package/dist/admin/ui/LocaleSwitcher.d.ts +11 -0
  20. package/dist/admin/ui/LocaleSwitcher.js +15 -0
  21. package/dist/collections.d.ts +6 -0
  22. package/dist/collections.js +16 -0
  23. package/dist/config.js +23 -3
  24. package/dist/entry-store.d.ts +132 -54
  25. package/dist/entry-store.js +294 -86
  26. package/dist/index.d.ts +6 -6
  27. package/dist/index.js +4 -4
  28. package/dist/localize.d.ts +0 -11
  29. package/dist/localize.js +11 -29
  30. package/dist/paths.d.ts +46 -27
  31. package/dist/paths.js +62 -29
  32. package/dist/revalidate.js +2 -1
  33. package/dist/site.d.ts +6 -2
  34. package/dist/site.js +65 -39
  35. package/dist/sql-space.d.ts +1 -1
  36. package/dist/sql-space.js +13 -5
  37. package/dist/sql.js +96 -53
  38. package/dist/store.d.ts +66 -32
  39. package/dist/store.js +205 -90
  40. package/dist/supabase-entry-store.d.ts +29 -8
  41. package/dist/supabase-entry-store.js +341 -177
  42. package/dist/supabase-store.d.ts +21 -9
  43. package/dist/supabase-store.js +202 -111
  44. 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
- type SlugI18n = Record<string, {
21
- slug?: string;
22
- }> | null | undefined;
23
- /** The locale's own segment, or the default locale's (per-field fallback). */
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
- slug: string;
41
- i18n: Record<string, {
42
- slug?: string;
43
- title?: string;
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
- * localized ancestor chain joined. THE derivation — pagePathRows,
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
- fields: Record<string, unknown>;
60
- i18n: Record<string, Record<string, unknown>> | null;
61
- locales: string[] | null;
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
- * The sibling index predicate in sql.ts (`entries_sibling_slug_key`)
67
- * mirrors this rule exactly, so Postgres and memory agree on which
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
- export declare function entrySegment(entry: EntryLike, locale: string, set: LocaleSet): string | null;
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). Pure and
2
- // framework-free: both store adapters and the tree query call these, so
3
- // the `paths` index and every derived `path` value agree by construction.
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 locale's own segment, or the default locale's (per-field fallback). */
18
- export function segmentFor(node, locale, set) {
19
- if (locale === set.default)
20
- return node.slug;
21
- const own = node.i18n?.[locale]?.slug;
22
- return typeof own === "string" && own.length > 0 ? own : node.slug;
23
- }
24
- /** null = every supported locale; otherwise the intersection. */
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
- const isHome = (node, homePageSlug) => node.parentId === null && node.slug === homePageSlug;
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
- * localized ancestor chain joined. THE derivation — pagePathRows,
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 isHome(node, opts.homePageSlug)
52
- ? "/"
53
- : joinPath(chainOf(node, byId).map((n) => segmentFor(n, locale, opts.locales)));
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 localesOf(target, opts.locales)) {
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
- * The sibling index predicate in sql.ts (`entries_sibling_slug_key`)
74
- * mirrors this rule exactly, so Postgres and memory agree on which
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
- export function entrySegment(entry, locale, set) {
81
- const own = locale === set.default ? undefined : entry.i18n?.[locale]?.slug;
82
- return typeof own === "string" && own.length > 0 ? own : slugOf(entry.fields);
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 localesOf(target, set)) {
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, set));
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: pagePath(r, byId, locale, opts),
139
+ path: pagePathIn(r, byId, locale, opts),
107
140
  children: build(r.id),
108
141
  }));
109
142
  return build(null);
@@ -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
- if (page?.publishedVersionId)
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 localized for the requested locale with refs resolved. */
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 { localizeFields, titleFor } from "./localize.js";
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
- 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);
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: titleFor(node, locale, locales),
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 raw = draft ? await pages.getDraftTree(id) : record.publishedVersionId ? await pages.getPublishedTree(id) : null;
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, draft) && localesOf(n, locales).includes(locale))
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: titleFor(record, locale, locales),
70
+ title: row.title,
55
71
  path,
56
72
  ancestors,
57
73
  children,
58
- paths: await pages.pathsOf(id),
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
- if (!draft && record.status !== "published")
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
- 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
- };
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
- /** 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) {
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.map((record) => ({
91
- entry: { ...record, fields: localizeFields(record.fields, record.i18n, locale, locales) },
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
- const publicEntry = (e, locale) => e.status === "published" && localesOf(e, locales).includes(locale);
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
- return entryItems(schema, wanted, all, locale);
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, false) && localesOf(n, locales).includes(locale));
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: titleFor(node.record, locale, locales),
112
- path: node.path,
113
- hasPage: hasPage(node.record, false),
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, locales) === idOrPath) ?? null;
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) });
@@ -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
- entries: `"status" = 'published'`,
34
- page_versions: `exists (select 1 from "pages" p where p."published_version_id" = "page_versions"."id")`,
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 "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')))`,
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 = ["page_versions", "saved_sections", "global_sections", "refs"];
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(`