smoodly 0.0.33 → 0.0.34

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 (62) hide show
  1. package/README.md +71 -17
  2. package/dist/addresses.d.ts +42 -0
  3. package/dist/addresses.js +51 -0
  4. package/dist/admin/editor/EditorView.js +4 -2
  5. package/dist/admin/editor/PageSettings.js +2 -1
  6. package/dist/admin/editor/useCanvasBridge.js +25 -2
  7. package/dist/admin/fixed-nodes.d.ts +9 -24
  8. package/dist/admin/fixed-nodes.js +36 -72
  9. package/dist/admin/health.js +9 -0
  10. package/dist/admin/next/create-admin.d.ts +4 -3
  11. package/dist/admin/next/create-admin.js +14 -3
  12. package/dist/admin/ops-impl.js +121 -12
  13. package/dist/admin/redirect-path.d.ts +3 -0
  14. package/dist/admin/redirect-path.js +9 -0
  15. package/dist/admin/serialize.d.ts +3 -0
  16. package/dist/admin/serialize.js +1 -0
  17. package/dist/admin/shell/AddRedirectModal.js +2 -2
  18. package/dist/admin/shell/PagesList.js +29 -12
  19. package/dist/admin/shell/live-url.d.ts +3 -1
  20. package/dist/admin/shell/live-url.js +5 -2
  21. package/dist/admin/shell/pages-tree.d.ts +19 -14
  22. package/dist/admin/shell/pages-tree.js +49 -16
  23. package/dist/admin/ui/theme.js +1 -0
  24. package/dist/anchors.d.ts +42 -0
  25. package/dist/anchors.js +72 -0
  26. package/dist/cli/export.js +3 -1
  27. package/dist/cli/import.d.ts +1 -0
  28. package/dist/cli/import.js +13 -2
  29. package/dist/cli/space-io.d.ts +1 -2
  30. package/dist/cli/space-io.js +5 -5
  31. package/dist/collections.js +5 -0
  32. package/dist/config.d.ts +6 -0
  33. package/dist/config.js +53 -18
  34. package/dist/current-space.d.ts +4 -0
  35. package/dist/current-space.js +9 -0
  36. package/dist/entry-store.js +4 -2
  37. package/dist/env.d.ts +4 -3
  38. package/dist/env.js +15 -17
  39. package/dist/index.d.ts +3 -1
  40. package/dist/index.js +3 -1
  41. package/dist/next/page-renderer.js +2 -2
  42. package/dist/path-index.d.ts +11 -0
  43. package/dist/path-index.js +52 -25
  44. package/dist/paths.d.ts +25 -12
  45. package/dist/paths.js +59 -21
  46. package/dist/schema-version.d.ts +1 -1
  47. package/dist/schema-version.js +1 -1
  48. package/dist/site.d.ts +7 -3
  49. package/dist/site.js +26 -15
  50. package/dist/space-transfer.d.ts +2 -0
  51. package/dist/space-transfer.js +8 -1
  52. package/dist/sql.js +35 -9
  53. package/dist/store.d.ts +15 -1
  54. package/dist/store.js +27 -6
  55. package/dist/supabase-entry-store.d.ts +0 -1
  56. package/dist/supabase-entry-store.js +6 -11
  57. package/dist/supabase-path-index.d.ts +12 -1
  58. package/dist/supabase-path-index.js +56 -19
  59. package/dist/supabase-store.d.ts +2 -0
  60. package/dist/supabase-store.js +30 -9
  61. package/migrations/20260929120000_smoodly_v5.sql +56 -0
  62. package/package.json +1 -1
@@ -1,66 +1,93 @@
1
+ import { candidates, fullPath, pickHit, sameUrlKeys } from "./anchors.js";
1
2
  const sameTarget = (a, b) => a.kind === b.kind && a.id === b.id;
2
- const key = (locale, path) => `${locale} ${path}`;
3
3
  export const pathTakenError = (locale, path) => new Error(`smoodly: the path "${path}" (${locale}) is already taken.`);
4
4
  export const pathLiveError = (locale, path) => new Error(`smoodly: "${path}" (${locale}) is a live address.`);
5
- const strip = ({ redirect: _redirect, ...row }) => row;
5
+ const key = (locale, anchor, path) => `${locale} ${anchor} ${path}`;
6
+ const stored = (r) => ({ kind: r.kind, id: r.id, locale: r.locale, anchor: r.anchor ?? "", path: r.path, redirect: false });
6
7
  export class MemoryPathIndex {
7
- constructor() {
8
+ /** Held by reference: the config's addresses, read on every call (spec 2026-09-29 anchored paths §2). */
9
+ constructor(anchors = { addresses: {}, homePageSlug: "home", defaultLocale: "en" }) {
10
+ this.anchors = anchors;
8
11
  this.rows = [];
9
12
  }
13
+ /** A stored row as callers see it: the full URL, or nothing when its anchor has no address. */
14
+ shown(r) {
15
+ const path = fullPath(this.anchors, r.anchor, r.path, r.locale);
16
+ return path === null ? null : { kind: r.kind, id: r.id, locale: r.locale, path };
17
+ }
18
+ shownAll(rows) {
19
+ return rows.map((r) => this.shown(r)).filter((r) => r !== null);
20
+ }
10
21
  async rewrite(targets, rows) {
22
+ const incoming = rows.map(stored);
11
23
  const ofTarget = (r) => targets.find((t) => sameTarget(t, r));
12
- const newKeys = new Set(rows.map((r) => key(r.locale, r.path)));
24
+ // Every key a new row's full URL answers to: live wins a redirect there under any anchor (a redirect by
25
+ // hand has none), and an old row at the same full URL is the same address, not an old one.
26
+ const newKeys = new Set(incoming.flatMap((r) => sameUrlKeys(this.anchors, r.locale, r.anchor, r.path).map((k) => key(r.locale, k.anchor, k.path))));
13
27
  // 1. redirect rows at the new live addresses go, whoever they point at (live wins, rename-back reclaims)
14
- let kept = this.rows.filter((r) => !(r.redirect && newKeys.has(key(r.locale, r.path))));
28
+ let kept = this.rows.filter((r) => !(r.redirect && newKeys.has(key(r.locale, r.anchor, r.path))));
15
29
  // 2. the targets' old live rows: flipped to redirects in the named locales unless the address stays live
16
30
  kept = kept.map((r) => {
17
31
  const t = !r.redirect ? ofTarget(r) : undefined;
18
- return t && t.redirectLocales?.includes(r.locale) && !newKeys.has(key(r.locale, r.path)) ? { ...r, redirect: true } : r;
32
+ return t && t.redirectLocales?.includes(r.locale) && !newKeys.has(key(r.locale, r.anchor, r.path)) ? { ...r, redirect: true } : r;
19
33
  });
20
- // 3. the targets' remaining live rows go — and so do their redirect rows in a locale the
21
- // target has no new live row in (a removed translation: nothing could resolve them)
22
- const liveLocales = new Set(rows.map((r) => `${r.kind} ${r.id} ${r.locale}`));
34
+ // 3. the targets' remaining live rows go — and so do their redirect rows in a locale the target has no new live row in
35
+ const liveLocales = new Set(incoming.map((r) => `${r.kind} ${r.id} ${r.locale}`));
23
36
  kept = kept.filter((r) => !ofTarget(r) || (r.redirect && liveLocales.has(`${r.kind} ${r.id} ${r.locale}`)));
24
- // 4. the new live rows: a collision now is with another target's live row
25
- const taken = new Set(kept.map((r) => key(r.locale, r.path)));
26
- for (const row of rows) {
27
- const k = key(row.locale, row.path);
37
+ // 4. the new live rows: a collision now is with another target's live row under the same anchor
38
+ const taken = new Set(kept.map((r) => key(r.locale, r.anchor, r.path)));
39
+ for (const r of incoming) {
40
+ const k = key(r.locale, r.anchor, r.path);
28
41
  if (taken.has(k))
29
- throw pathTakenError(row.locale, row.path);
42
+ throw pathTakenError(r.locale, fullPath(this.anchors, r.anchor, r.path, r.locale) ?? r.path);
30
43
  taken.add(k);
31
44
  }
32
- this.rows = [...kept, ...rows.map((r) => ({ ...r, redirect: false }))];
45
+ this.rows = [...kept, ...incoming];
33
46
  }
34
47
  async remove(targets) {
35
48
  this.rows = this.rows.filter((r) => !targets.some((t) => sameTarget(t, r)));
36
49
  }
50
+ hit(locale, url) {
51
+ const { keys, coded } = candidates(this.anchors, locale, url);
52
+ return pickHit(this.rows.filter((r) => r.locale === locale), keys, coded);
53
+ }
37
54
  async resolve(locale, path) {
38
- const row = this.rows.find((r) => r.locale === locale && r.path === path);
55
+ const row = this.hit(locale, path);
39
56
  return row ? { kind: row.kind, id: row.id, redirect: row.redirect } : null;
40
57
  }
41
58
  async pathsOf(target) {
42
59
  const out = {};
43
- for (const r of this.rows)
44
- if (!r.redirect && sameTarget(r, target))
45
- out[r.locale] = r.path;
60
+ for (const r of this.shownAll(this.rows.filter((r) => !r.redirect && sameTarget(r, target))))
61
+ out[r.locale] = r.path;
46
62
  return out;
47
63
  }
48
64
  async redirectsOf(target) {
49
- return this.rows.filter((r) => r.redirect && sameTarget(r, target)).map(strip);
65
+ return this.shownAll(this.rows.filter((r) => r.redirect && sameTarget(r, target)));
50
66
  }
67
+ /** Every live URL in the locale; an editor's row under a code address is left out, since visitors never reach it (spec §4). */
51
68
  async listLive(locale) {
52
- return this.rows.filter((r) => !r.redirect && r.locale === locale).map(strip);
69
+ const served = (r) => !(r.anchor === "" && candidates(this.anchors, locale, r.path).coded);
70
+ return this.shownAll(this.rows.filter((r) => !r.redirect && r.locale === locale && served(r))).sort((a, b) => a.path.localeCompare(b.path));
53
71
  }
54
72
  async listRedirects(locale) {
55
- return this.rows.filter((r) => r.redirect && r.locale === locale).map(strip);
73
+ return this.shownAll(this.rows.filter((r) => r.redirect && r.locale === locale)).sort((a, b) => a.path.localeCompare(b.path));
56
74
  }
75
+ /** A redirect by hand stays absolute (anchor ''): refused where the URL resolves live; a redirect there is replaced. */
57
76
  async addRedirect(row) {
58
- const existing = this.rows.find((r) => r.locale === row.locale && r.path === row.path);
77
+ const existing = this.hit(row.locale, row.path);
59
78
  if (existing && !existing.redirect)
60
79
  throw pathLiveError(row.locale, row.path);
61
- this.rows = [...this.rows.filter((r) => r !== existing), { ...row, redirect: true }];
80
+ // An editor's page hidden under a code address still holds its row: a redirect never stacks on it.
81
+ if (this.rows.some((r) => !r.redirect && r.locale === row.locale && r.anchor === "" && r.path === row.path))
82
+ throw pathLiveError(row.locale, row.path);
83
+ // Every redirect the URL reaches goes, under any anchor: the new one replaces them all.
84
+ const { keys } = candidates(this.anchors, row.locale, row.path);
85
+ const replaced = (r) => r.redirect && r.locale === row.locale && keys.some((k) => k.anchor === r.anchor && k.path === r.path);
86
+ this.rows = [...this.rows.filter((r) => !replaced(r)), { ...stored({ ...row, anchor: "" }), redirect: true }];
62
87
  }
88
+ /** Deletes the redirect rows a URL reaches, under any candidate anchor; a live row there is left alone. */
63
89
  async removeRedirect(locale, path) {
64
- this.rows = this.rows.filter((r) => !(r.redirect && r.locale === locale && r.path === path));
90
+ const { keys } = candidates(this.anchors, locale, path);
91
+ this.rows = this.rows.filter((r) => !(r.redirect && r.locale === locale && keys.some((k) => k.anchor === r.anchor && k.path === r.path)));
65
92
  }
66
93
  }
package/dist/paths.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { Addresses } from "./addresses.ts";
1
2
  export declare const SEGMENT_RE: RegExp;
2
3
  export declare const isSegment: (s: string) => boolean;
3
4
  export declare function assertSegment(s: string, what: string): void;
@@ -24,18 +25,28 @@ export declare function perLocaleSegments(value: string | Record<string, string>
24
25
  * (spec 2026-09-06 §3). The boot collision check (config.ts), the
25
26
  * materializer (admin/fixed-nodes.ts) and the fixed path derivation
26
27
  * (localize.ts) all fill through here, or a collision passes boot and
27
- * only surfaces at materialization. Collection mounts are NOT filled:
28
- * a per-locale path claims exactly the locales it names. */
28
+ * only surfaces at materialization. Collection paths fill the same way
29
+ * (addresses.ts): their entries take the default path in a locale the
30
+ * path omits (collectionSegment), so the address is claimed there too. */
29
31
  export declare function fixedPageSegments(slug: string | Record<string, string>, locales: LocaleSet): Record<string, string>;
30
32
  export type PathTarget = {
31
33
  kind: "page" | "entry";
32
34
  id: string;
33
35
  };
36
+ /** A URL row: `anchor` names the code address it hangs under (spec 2026-09-29 anchored paths §1), absent = the editors' tree;
37
+ * `path` is the part editors made. Read back from the index, `path` is the full URL and `anchor` is absent. */
34
38
  export type PathRow = PathTarget & {
35
39
  locale: string;
36
40
  path: string;
41
+ anchor?: string;
37
42
  };
38
43
  export declare const joinPath: (segments: string[]) => string;
44
+ /** A request's segment or a typed address's, as stored: percent-decoded and NFC-composed, so
45
+ * a pasted `%C3%A4`, a typed `ä` and a decomposed `a\u0308` are one address. Next hands the
46
+ * catch-all its segments still encoded. A stray `%` that is no escape stays as it is, and so
47
+ * does a segment holding an encoded slash: decoded, it would become two segments and make
48
+ * `/a%2Fb` an alias of `/a/b`. Slugs are ASCII, so this changes nothing a slug path holds. */
49
+ export declare function decodeSegment(segment: string): string;
39
50
  export declare const splitPath: (path: string) => string[];
40
51
  /** The node's segment in one locale. An absent locale is an error, never
41
52
  * a fallback: the stores' parent rule guarantees a present node has a
@@ -49,6 +60,8 @@ export declare function assertDepth(depth: number, max: number, what: string): v
49
60
  export type PageLike = {
50
61
  id: string;
51
62
  parentId: string | null;
63
+ /** The owner key when code owns the record (spec 2026-09-29 nested paths §3); absent or null = an editor's page. */
64
+ fixed?: string | null;
52
65
  /** The locales the node exists in, each with the node's OWN segment there. Presence IS the key (spec 2026-09-06 §3). */
53
66
  locales: Record<string, {
54
67
  slug: string;
@@ -57,18 +70,22 @@ export type PageLike = {
57
70
  export type PagePathOptions = {
58
71
  locales: LocaleSet;
59
72
  homePageSlug: string;
73
+ addresses?: Addresses;
60
74
  };
61
75
  /** Home is judged by identity: the root node whose DEFAULT-locale slug is
62
76
  * homePageSlug serves "/" in every locale it exists in. */
63
77
  export declare const isHomeNode: (node: PageLike, opts: PagePathOptions) => boolean;
64
78
  /** The node's path in one locale: `/` for the home node, else the
65
- * locale's ancestor chain joined. THE derivation — pagePathRows,
79
+ * locale's ancestor chain joined. A code-owned root's path is its
80
+ * address (spec 2026-09-29). THE derivation — pagePathRows,
66
81
  * buildPageTree and the site layer all call this. */
67
82
  export declare function pagePath(node: PageLike, byId: (id: string) => PageLike | undefined, locale: string, opts: PagePathOptions): string;
68
83
  /** The same path, or null when the node or an ancestor is absent from the
69
- * locale — what the Pages list shows as a dimmed "not in fi" row. */
84
+ * locale — what the Pages list shows as a dimmed "not in fi" row — or
85
+ * when its code-owned root has no address there. */
70
86
  export declare function pagePathIn(node: PageLike, byId: (id: string) => PageLike | undefined, locale: string, opts: PagePathOptions): string | null;
71
- /** One row per locale the target EXISTS in. */
87
+ /** One row per locale the target EXISTS in (spec 2026-09-29 anchored paths §3): under a code-owned root, the root's
88
+ * owner key and the chain below it (`''` for the root itself); in the editors' tree, no anchor and the full path. */
72
89
  export declare function pagePathRows(targets: PageLike[], byId: (id: string) => PageLike | undefined, opts: PagePathOptions): PathRow[];
73
90
  export type EntryLike = {
74
91
  id: string;
@@ -91,13 +108,9 @@ export declare const slugOf: (fields: Record<string, unknown>) => string | null;
91
108
  /** The entry's segment in one locale: its row's slug. An absent locale
92
109
  * or a slug-less row is null — no URL there, never a fallback. */
93
110
  export declare function entrySegment(entry: EntryLike, locale: string): string | null;
94
- /** One row per locale the target EXISTS in, where the whole chain has a
95
- * segment in that locale. The stores' parent rule guarantees an ancestor
96
- * is present wherever a descendant is; an ancestor without a slug there
97
- * simply yields no URL for the subtree in that locale. */
98
- export declare function entryPathRows(targets: EntryLike[], byId: (id: string) => EntryLike | undefined, collection: {
99
- path?: string | Record<string, string>;
100
- }, set: LocaleSet): PathRow[];
111
+ /** One row per locale the target EXISTS in, where the whole chain has a slug there, under the collection's anchor
112
+ * (spec 2026-09-29 anchored paths §1): written whether or not the collection has a `path`, which the index reads. */
113
+ export declare function entryPathRows(targets: EntryLike[], byId: (id: string) => EntryLike | undefined, collection: string): PathRow[];
101
114
  /** The locales a node is published in — the ones whose old addresses may
102
115
  * stay as redirects (spec 2026-09-26 redirects §2: only published rows). */
103
116
  export declare function publishedLocales(node: {
package/dist/paths.js CHANGED
@@ -1,7 +1,3 @@
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.
5
1
  export const SEGMENT_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
6
2
  export const isSegment = (s) => SEGMENT_RE.test(s);
7
3
  export function assertSegment(s, what) {
@@ -47,13 +43,31 @@ export function perLocaleSegments(value, supported) {
47
43
  * (spec 2026-09-06 §3). The boot collision check (config.ts), the
48
44
  * materializer (admin/fixed-nodes.ts) and the fixed path derivation
49
45
  * (localize.ts) all fill through here, or a collision passes boot and
50
- * only surfaces at materialization. Collection mounts are NOT filled:
51
- * a per-locale path claims exactly the locales it names. */
46
+ * only surfaces at materialization. Collection paths fill the same way
47
+ * (addresses.ts): their entries take the default path in a locale the
48
+ * path omits (collectionSegment), so the address is claimed there too. */
52
49
  export function fixedPageSegments(slug, locales) {
53
50
  const raw = perLocaleSegments(slug, locales.supported);
54
51
  return Object.fromEntries(locales.supported.map((l) => [l, raw[l] || raw[locales.default]]));
55
52
  }
56
53
  export const joinPath = (segments) => (segments.length === 0 ? "/" : "/" + segments.join("/"));
54
+ /** A request's segment or a typed address's, as stored: percent-decoded and NFC-composed, so
55
+ * a pasted `%C3%A4`, a typed `ä` and a decomposed `a\u0308` are one address. Next hands the
56
+ * catch-all its segments still encoded. A stray `%` that is no escape stays as it is, and so
57
+ * does a segment holding an encoded slash: decoded, it would become two segments and make
58
+ * `/a%2Fb` an alias of `/a/b`. Slugs are ASCII, so this changes nothing a slug path holds. */
59
+ export function decodeSegment(segment) {
60
+ let out = segment;
61
+ try {
62
+ const decoded = decodeURIComponent(segment);
63
+ if (!decoded.includes("/"))
64
+ out = decoded;
65
+ }
66
+ catch {
67
+ // not an escape sequence: looked up as typed
68
+ }
69
+ return out.normalize("NFC");
70
+ }
57
71
  export const splitPath = (path) => path.split("/").filter((s) => s.length > 0);
58
72
  /** The node's segment in one locale. An absent locale is an error, never
59
73
  * a fallback: the stores' parent rule guarantees a present node has a
@@ -83,28 +97,56 @@ export function assertDepth(depth, max, what) {
83
97
  /** Home is judged by identity: the root node whose DEFAULT-locale slug is
84
98
  * homePageSlug serves "/" in every locale it exists in. */
85
99
  export const isHomeNode = (node, opts) => node.parentId === null && node.locales[opts.locales.default]?.slug === opts.homePageSlug;
100
+ /** A code-owned root's address parts in the locale from the config; null
101
+ * when no registration claims it there any more (an orphan). */
102
+ function addressParts(root, locale, opts) {
103
+ const address = opts.addresses?.[root.fixed]?.[locale];
104
+ return address === undefined ? null : address.split("/");
105
+ }
86
106
  /** The node's path in one locale: `/` for the home node, else the
87
- * locale's ancestor chain joined. THE derivation — pagePathRows,
107
+ * locale's ancestor chain joined. A code-owned root's path is its
108
+ * address (spec 2026-09-29). THE derivation — pagePathRows,
88
109
  * buildPageTree and the site layer all call this. */
89
110
  export function pagePath(node, byId, locale, opts) {
90
- return isHomeNode(node, opts) ? "/" : joinPath(chainOf(node, byId).map((n) => segmentFor(n, locale)));
111
+ if (isHomeNode(node, opts))
112
+ return "/";
113
+ const [root, ...rest] = chainOf(node, byId);
114
+ const own = segmentFor(root, locale); // an absent root is a broken invariant, code-owned or not
115
+ const head = root.fixed ? addressParts(root, locale, opts) : [own];
116
+ if (head === null)
117
+ throw new Error(`smoodly: page "${node.id}" has no address in locale "${locale}".`);
118
+ return joinPath([...head, ...rest.map((n) => segmentFor(n, locale))]);
91
119
  }
92
120
  /** The same path, or null when the node or an ancestor is absent from the
93
- * locale — what the Pages list shows as a dimmed "not in fi" row. */
121
+ * locale — what the Pages list shows as a dimmed "not in fi" row — or
122
+ * when its code-owned root has no address there. */
94
123
  export function pagePathIn(node, byId, locale, opts) {
95
124
  if (!node.locales[locale])
96
125
  return null;
97
126
  if (isHomeNode(node, opts))
98
127
  return "/";
99
128
  const chain = chainOf(node, byId);
100
- return chain.every((n) => n.locales[locale] !== undefined) ? joinPath(chain.map((n) => n.locales[locale].slug)) : null;
129
+ if (!chain.every((n) => n.locales[locale] !== undefined))
130
+ return null;
131
+ const [root, ...rest] = chain;
132
+ const head = root.fixed ? addressParts(root, locale, opts) : [root.locales[locale].slug];
133
+ return head === null ? null : joinPath([...head, ...rest.map((n) => n.locales[locale].slug)]);
101
134
  }
102
- /** One row per locale the target EXISTS in. */
135
+ /** One row per locale the target EXISTS in (spec 2026-09-29 anchored paths §3): under a code-owned root, the root's
136
+ * owner key and the chain below it (`''` for the root itself); in the editors' tree, no anchor and the full path. */
103
137
  export function pagePathRows(targets, byId, opts) {
104
138
  const rows = [];
105
139
  for (const target of targets) {
140
+ const chain = chainOf(target, byId);
141
+ const root = chain[0];
106
142
  for (const locale of Object.keys(target.locales)) {
107
- rows.push({ kind: "page", id: target.id, locale, path: pagePath(target, byId, locale, opts) });
143
+ if (root.fixed) {
144
+ const below = chain.slice(1).map((n) => segmentFor(n, locale));
145
+ rows.push({ kind: "page", id: target.id, locale, anchor: root.fixed, path: below.length === 0 ? "" : joinPath(below) });
146
+ }
147
+ else {
148
+ rows.push({ kind: "page", id: target.id, locale, anchor: "", path: pagePath(target, byId, locale, opts) });
149
+ }
108
150
  }
109
151
  }
110
152
  return rows;
@@ -131,22 +173,18 @@ export const slugOf = (fields) => {
131
173
  export function entrySegment(entry, locale) {
132
174
  return entry.locales[locale]?.slug ?? null;
133
175
  }
134
- /** One row per locale the target EXISTS in, where the whole chain has a
135
- * segment in that locale. The stores' parent rule guarantees an ancestor
136
- * is present wherever a descendant is; an ancestor without a slug there
137
- * simply yields no URL for the subtree in that locale. */
138
- export function entryPathRows(targets, byId, collection, set) {
176
+ /** One row per locale the target EXISTS in, where the whole chain has a slug there, under the collection's anchor
177
+ * (spec 2026-09-29 anchored paths §1): written whether or not the collection has a `path`, which the index reads. */
178
+ export function entryPathRows(targets, byId, collection) {
179
+ // `collection:<name>` is addresses.ts's collectionKey, spelled here: addresses.ts imports this module.
139
180
  const rows = [];
140
181
  for (const target of targets) {
141
182
  const chain = chainOf(target, byId);
142
183
  for (const locale of Object.keys(target.locales)) {
143
- const base = collectionSegment(collection.path, locale, set);
144
- if (base === null)
145
- continue;
146
184
  const segments = chain.map((e) => entrySegment(e, locale));
147
185
  if (segments.some((s) => s === null))
148
186
  continue;
149
- rows.push({ kind: "entry", id: target.id, locale, path: joinPath([base, ...segments]) });
187
+ rows.push({ kind: "entry", id: target.id, locale, anchor: `collection:${collection}`, path: joinPath(segments) });
150
188
  }
151
189
  }
152
190
  return rows;
@@ -1,7 +1,7 @@
1
1
  /** The minimum schema version the package needs. Moves ONLY in a release
2
2
  * that adds a table, column, function or policy the package reads; a
3
3
  * unit test holds it equal to the highest file in `migrations/`. */
4
- export declare const SCHEMA_VERSION = 4;
4
+ export declare const SCHEMA_VERSION = 5;
5
5
  export declare const SCHEMA_VERSION_KEY = "schema_version";
6
6
  export declare const CORE_FILE_RE: RegExp;
7
7
  export declare const VIEWS_FILE_RE: RegExp;
@@ -7,7 +7,7 @@
7
7
  /** The minimum schema version the package needs. Moves ONLY in a release
8
8
  * that adds a table, column, function or policy the package reads; a
9
9
  * unit test holds it equal to the highest file in `migrations/`. */
10
- export const SCHEMA_VERSION = 4;
10
+ export const SCHEMA_VERSION = 5;
11
11
  export const SCHEMA_VERSION_KEY = "schema_version";
12
12
  export const CORE_FILE_RE = /^(\d{14})_smoodly_v(\d+)\.sql$/;
13
13
  export const VIEWS_FILE_RE = /^(\d{14})_smoodly_views\.sql$/;
package/dist/site.d.ts CHANGED
@@ -27,14 +27,18 @@ export type SiteOptions = {
27
27
  styles?: StyleValuesInput;
28
28
  /** The custom section's options (spec 2026-09-14 §6); the defaults when absent. */
29
29
  custom?: CustomConfigInput;
30
- /** Page shapes (entry pages are ignored): the source of each template's page-field descriptors (spec 2026-09-10 page fields §3). */
31
- pages?: ReadonlyArray<SitePageSchema>;
30
+ /** Page shapes (entry pages are ignored): the source of each template's page-field descriptors (spec 2026-09-10 page fields §3)
31
+ * and of the fixed pages' addresses (spec 2026-09-29 nested paths §3) — `config.registry.pages`. Required: a site that
32
+ * missed one would take its code-owned record for an orphan and drop its URLs at the first lookup. */
33
+ pages: ReadonlyArray<SitePageSchema>;
32
34
  };
33
- /** A page registration as the site needs it: the template's page fields. */
35
+ /** A page registration as the site needs it: the template's page fields, and a fixed page's address. */
34
36
  export type SitePageSchema = {
35
37
  schema: {
36
38
  kind: string;
37
39
  name: string;
40
+ title?: string;
41
+ slug?: string | Record<string, string>;
38
42
  fields?: Record<string, Descriptor>;
39
43
  };
40
44
  };
package/dist/site.js CHANGED
@@ -6,6 +6,8 @@ import { globalNodeId, SETTINGS_KEY, STYLES_KEY, CSS_KEY } from "./global.js";
6
6
  import { SMD_KEY } from "./section-wrapper.js";
7
7
  import { resolveStyleValues } from "./styles.js";
8
8
  import { resolveCustom } from "./custom/index.js";
9
+ import { addressMap, codeAddresses } from "./addresses.js";
10
+ import { fullPath, servedAnchor } from "./anchors.js";
9
11
  import { withDefaults } from "./field-defaults.js";
10
12
  /** The `page` prop for a loaded page. `href` is the app's seam from a
11
13
  * locale-relative path to a site URL; the default keeps CMS paths as URLs. */
@@ -29,7 +31,11 @@ export function createSmoodlySite(options) {
29
31
  const styles = resolveStyleValues(options.styles);
30
32
  const custom = resolveCustom(options.custom);
31
33
  const elements = elementSchemas(options.elements);
32
- const pathOpts = { locales, homePageSlug };
34
+ // Code-owned addresses from the site's own registry (spec 2026-09-29 nested paths §3): a code-owned root's path.
35
+ const addresses = addressMap(codeAddresses({ locales, pages: options.pages, collections: options.collections }));
36
+ const pathOpts = { locales, homePageSlug, addresses };
37
+ /** The same addresses as the path index sees them: an entry's stored part joined to its collection's (spec 2026-09-29 anchored paths §2). */
38
+ const anchors = { addresses, homePageSlug, defaultLocale: locales.default };
33
39
  const mounted = options.collections.filter((c) => c.path !== undefined);
34
40
  /** The resolver's fetcher, bound to a locale: the merged fields of that
35
41
  * locale — published ones (a versioned collection's snapshot) on the
@@ -184,8 +190,11 @@ export function createSmoodlySite(options) {
184
190
  return wanted
185
191
  .filter((record) => fieldsById[record.id] !== undefined)
186
192
  .map((record) => {
187
- const rows = entryPathRows([record], byId, schema, locales);
188
- const pathIn = (l) => rows.find((r) => r.locale === l)?.path ?? null;
193
+ const rows = entryPathRows([record], byId, schema.name);
194
+ const pathIn = (l) => {
195
+ const row = rows.find((r) => r.locale === l);
196
+ return row ? fullPath(anchors, row.anchor ?? "", row.path, l) : null;
197
+ };
189
198
  return {
190
199
  entry: { ...record, fields: fieldsById[record.id] },
191
200
  path: pathIn(locale),
@@ -243,18 +252,20 @@ export function createSmoodlySite(options) {
243
252
  const shown = all.filter((n) => visible(n, locale, draft));
244
253
  const index = new Map(shown.map((n) => [n.id, n]));
245
254
  const fields = await linkFields(shown, locale, draft);
246
- const toItem = (node) => ({
247
- id: node.record.id,
248
- title: node.record.locales[locale].title,
249
- // Every node the walk reaches is present with a present chain, so
250
- // buildPageTree's nullable path is never null here; pagePath is
251
- // the same derivation and keeps the type honest.
252
- path: node.path ?? pagePath(node.record, (x) => index.get(x), locale, pathOpts),
253
- hasPage: hasPage(node.record, locale, draft),
254
- fields: fields.get(node.record.id) ?? {},
255
- children: node.children.map(toItem),
256
- });
257
- return buildPageTree(shown, locale, pathOpts).map(toItem);
255
+ // Every node the walk reaches is present with a present chain. Left
256
+ // out, with its subtree: a record no longer in code (no path, spec
257
+ // 2026-09-29 nested paths §5), and a page where another code address
258
+ // wins, which visitors never reach (anchored paths §4).
259
+ const hidden = (node) => node.path === null || servedAnchor(anchors, locale, node.path) !== (chainOf(node.record, (x) => index.get(x))[0].fixed ?? "");
260
+ const toItems = (nodes) => nodes.flatMap((node) => hidden(node) ? [] : [{
261
+ id: node.record.id,
262
+ title: node.record.locales[locale].title,
263
+ path: node.path,
264
+ hasPage: hasPage(node.record, locale, draft),
265
+ fields: fields.get(node.record.id) ?? {},
266
+ children: toItems(node.children),
267
+ }]);
268
+ return toItems(buildPageTree(shown, locale, pathOpts));
258
269
  },
259
270
  async getPage(pathOrId, o) {
260
271
  const locale = localeOf(o);
@@ -46,6 +46,8 @@ export declare function insertPlan(rows: TableRows): {
46
46
  export declare function brokenReferences(rows: TableRows): string[];
47
47
  /** The import's target check (spec §9.1): per table, how many of the target's row keys this export does not hold. All zero = empty, or an earlier run. */
48
48
  export declare function foreignRows(target: Record<ContentTable, string[]>, exported: TableRows): Record<ContentTable, number>;
49
+ /** The export's rows the target does not hold yet: zero on a re-run of a finished import. */
50
+ export declare function missingRows(target: Record<ContentTable, string[]>, exported: TableRows): number;
49
51
  export declare const countRows: (rows: TableRows) => Record<ContentTable, number>;
50
52
  export declare function formatBytes(n: number): string;
51
53
  /** "14 pages (41 versions), 32 entries (118 versions), 51 assets (38.2 MB), 9 redirects" (spec §8.5). */
@@ -23,7 +23,7 @@ export const ROW_KEY = {
23
23
  entry_locales: ["entry_id", "locale"],
24
24
  entry_versions: ["id"],
25
25
  refs: ["source_kind", "source_id", "source_field", "target_id"],
26
- paths: ["locale", "path"],
26
+ paths: ["locale", "anchor", "path"],
27
27
  assets: ["id"],
28
28
  };
29
29
  /** The primary key as Postgres declares it — what "on conflict do nothing" names. */
@@ -116,6 +116,13 @@ export function foreignRows(target, exported) {
116
116
  return [table, target[table].filter((key) => !own.has(key)).length];
117
117
  }));
118
118
  }
119
+ /** The export's rows the target does not hold yet: zero on a re-run of a finished import. */
120
+ export function missingRows(target, exported) {
121
+ return CONTENT_TABLES.reduce((n, table) => {
122
+ const held = new Set(target[table]);
123
+ return n + exported[table].filter((row) => !held.has(rowKey(table, row))).length;
124
+ }, 0);
125
+ }
119
126
  export const countRows = (rows) => Object.fromEntries(CONTENT_TABLES.map((t) => [t, rows[t].length]));
120
127
  const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
121
128
  const listJoin = (items) => (items.length <= 1 ? items.join("") : `${items.slice(0, -1).join(", ")} and ${items[items.length - 1]}`);
package/dist/sql.js CHANGED
@@ -83,6 +83,7 @@ create table if not exists "pages" (
83
83
  "parent_id" uuid references "pages" ("id") on delete restrict,
84
84
  "template" text,
85
85
  "sort" numeric,
86
+ "fixed" text,
86
87
  "created_at" timestamptz not null default now(),
87
88
  "updated_at" timestamptz not null default now()
88
89
  );
@@ -96,6 +97,12 @@ create table if not exists "pages" (
96
97
  create index if not exists "pages_parent_idx"
97
98
  on "pages" ("space_id", "parent_id", "sort");
98
99
 
100
+ -- The registration that owns a code-owned record (spec 2026-09-29 nested
101
+ -- paths §3): "collection:<name>" or "page:<name>"; null = an editor's
102
+ -- page. Its path is its address from the config, not its slug chain.
103
+ create unique index if not exists "pages_fixed_idx"
104
+ on "pages" ("space_id", "fixed") where "fixed" is not null;
105
+
99
106
  create table if not exists "page_locales" (
100
107
  ${SPACE_COL},
101
108
  "page_id" uuid not null references "pages" ("id") on delete cascade,
@@ -238,14 +245,18 @@ create table if not exists "refs" (
238
245
  -- A row with "redirect" set is an old address of its target (spec
239
246
  -- 2026-09-26 redirects §2): the same primary key, so a redirect can never
240
247
  -- sit where a live URL is and a live row taking the address replaces it.
248
+ -- "anchor" names the code address a row hangs under (spec 2026-09-29
249
+ -- anchored paths): "path" is only the part editors made, and the address
250
+ -- itself lives in the config; '' is the editors' own tree.
241
251
  create table if not exists "paths" (
242
252
  ${SPACE_COL},
243
253
  "locale" text not null,
254
+ "anchor" text not null default '',
244
255
  "path" text not null,
245
256
  "kind" text not null check ("kind" in ('page', 'entry')),
246
257
  "target_id" uuid not null,
247
258
  "redirect" boolean not null default false,
248
- primary key ("space_id", "locale", "path")
259
+ primary key ("space_id", "locale", "anchor", "path")
249
260
  );
250
261
 
251
262
  create index if not exists "paths_target_idx"
@@ -285,27 +296,42 @@ create index if not exists "assets_space_created_idx"
285
296
  -- rolls the whole rewrite back. Inserted rows get their space from the
286
297
  -- a_set_space trigger (the claim, or the default space).
287
298
  -- A target may name redirect_locales: its old live rows there stay as redirects.
299
+ -- A row carries "keys": every (anchor, path) that yields its full URL, from
300
+ -- the config's addresses (spec 2026-09-29 anchored paths §2). A redirect at
301
+ -- any of them goes (live wins, a redirect by hand has no anchor), and an old
302
+ -- row at one is the same address moving between anchors, never a redirect.
288
303
  create or replace function "smoodly_rewrite_paths"(p_targets jsonb, p_rows jsonb) returns void language sql set search_path = public as $$
289
304
  delete from "paths" p
290
- using jsonb_to_recordset(p_rows) as r(locale text, path text, kind text, id uuid)
291
- where p."redirect" and p."locale" = r.locale and p."path" = r.path
305
+ using jsonb_to_recordset(p_rows) as r(locale text, anchor text, path text, kind text, id uuid, keys jsonb)
306
+ where p."redirect" and p."locale" = r.locale
307
+ and exists (select 1 from jsonb_to_recordset(r.keys) as k(anchor text, path text) where k.anchor = p."anchor" and k.path = p."path")
292
308
  and p."space_id" = "smoodly_current_space"();
293
309
  update "paths" p set "redirect" = true
294
310
  from jsonb_to_recordset(p_targets) as t(kind text, id uuid, redirect_locales text[])
295
311
  where not p."redirect" and p."kind" = t.kind and p."target_id" = t.id
296
312
  and p."locale" = any (coalesce(t.redirect_locales, '{}'::text[]))
297
- and not exists (select 1 from jsonb_to_recordset(p_rows) as r(locale text, path text, kind text, id uuid)
298
- where r.locale = p."locale" and r.path = p."path")
313
+ and not exists (select 1 from jsonb_to_recordset(p_rows) as r(locale text, anchor text, path text, kind text, id uuid, keys jsonb)
314
+ cross join lateral jsonb_to_recordset(r.keys) as k(anchor text, path text)
315
+ where r.locale = p."locale" and k.anchor = p."anchor" and k.path = p."path")
299
316
  and p."space_id" = "smoodly_current_space"();
300
317
  delete from "paths" p
301
318
  using jsonb_to_recordset(p_targets) as t(kind text, id uuid, redirect_locales text[])
302
319
  where p."kind" = t.kind and p."target_id" = t.id
303
- and (not p."redirect" or not exists (select 1 from jsonb_to_recordset(p_rows) as r(locale text, path text, kind text, id uuid)
320
+ and (not p."redirect" or not exists (select 1 from jsonb_to_recordset(p_rows) as r(locale text, anchor text, path text, kind text, id uuid)
304
321
  where r.kind = p."kind" and r.id = p."target_id" and r.locale = p."locale"))
305
322
  and p."space_id" = "smoodly_current_space"();
306
- insert into "paths" ("locale", "path", "kind", "target_id")
307
- select r.locale, r.path, r.kind, r.id
308
- from jsonb_to_recordset(p_rows) as r(locale text, path text, kind text, id uuid);
323
+ insert into "paths" ("locale", "anchor", "path", "kind", "target_id")
324
+ select r.locale, r.anchor, r.path, r.kind, r.id
325
+ from jsonb_to_recordset(p_rows) as r(locale text, anchor text, path text, kind text, id uuid);
326
+ $$;
327
+
328
+ -- One call reads every candidate (anchor, path) of a request (spec
329
+ -- 2026-09-29 anchored paths §2); the caller picks by precedence. Invoker
330
+ -- rights: the paths read policy applies, as to a plain select.
331
+ create or replace function "smoodly_resolve_path"(p_locale text, p_keys jsonb) returns setof "paths" language sql stable set search_path = public as $$
332
+ select p.* from "paths" p
333
+ join jsonb_to_recordset(p_keys) as k(anchor text, path text) on p."anchor" = k.anchor and p."path" = k.path
334
+ where p."locale" = p_locale;
309
335
  $$;
310
336
 
311
337
  -- RLS on every core table. The service role bypasses it (self-host