smoodly 0.0.33 → 0.0.35

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 (82) hide show
  1. package/README.md +845 -1301
  2. package/dist/addresses.d.ts +42 -0
  3. package/dist/addresses.js +51 -0
  4. package/dist/admin/editor/EditorView.js +14 -5
  5. package/dist/admin/editor/PageSettings.js +2 -1
  6. package/dist/admin/editor/autosave.d.ts +6 -1
  7. package/dist/admin/editor/autosave.js +43 -3
  8. package/dist/admin/editor/useCanvasBridge.js +25 -2
  9. package/dist/admin/fixed-nodes.d.ts +9 -24
  10. package/dist/admin/fixed-nodes.js +36 -72
  11. package/dist/admin/forms/list.js +10 -1
  12. package/dist/admin/health.js +9 -0
  13. package/dist/admin/next/create-admin.d.ts +4 -3
  14. package/dist/admin/next/create-admin.js +14 -3
  15. package/dist/admin/ops-impl.js +121 -12
  16. package/dist/admin/redirect-path.d.ts +3 -0
  17. package/dist/admin/redirect-path.js +9 -0
  18. package/dist/admin/serialize.d.ts +3 -0
  19. package/dist/admin/serialize.js +1 -0
  20. package/dist/admin/shell/AddRedirectModal.js +2 -2
  21. package/dist/admin/shell/EntryForm.js +3 -2
  22. package/dist/admin/shell/GlobalForm.js +3 -2
  23. package/dist/admin/shell/PagesList.js +29 -12
  24. package/dist/admin/shell/live-url.d.ts +3 -1
  25. package/dist/admin/shell/live-url.js +5 -2
  26. package/dist/admin/shell/pages-tree.d.ts +19 -14
  27. package/dist/admin/shell/pages-tree.js +49 -16
  28. package/dist/admin/shell/router.d.ts +5 -0
  29. package/dist/admin/shell/router.js +13 -0
  30. package/dist/admin/ui/theme.js +1 -0
  31. package/dist/anchors.d.ts +42 -0
  32. package/dist/anchors.js +72 -0
  33. package/dist/cli/export.js +3 -1
  34. package/dist/cli/import.d.ts +1 -0
  35. package/dist/cli/import.js +13 -2
  36. package/dist/cli/space-io.d.ts +1 -2
  37. package/dist/cli/space-io.js +5 -5
  38. package/dist/collections.js +5 -0
  39. package/dist/config.d.ts +6 -0
  40. package/dist/config.js +53 -18
  41. package/dist/current-space.d.ts +4 -0
  42. package/dist/current-space.js +9 -0
  43. package/dist/entry-store.js +4 -2
  44. package/dist/env.d.ts +4 -3
  45. package/dist/env.js +15 -17
  46. package/dist/index.d.ts +3 -1
  47. package/dist/index.js +3 -1
  48. package/dist/next/canvas-slot.d.ts +7 -0
  49. package/dist/next/canvas-slot.js +9 -0
  50. package/dist/next/index.d.ts +2 -0
  51. package/dist/next/index.js +2 -0
  52. package/dist/next/page-renderer.d.ts +2 -0
  53. package/dist/next/page-renderer.js +14 -10
  54. package/dist/next/preview-pill.d.ts +9 -0
  55. package/dist/next/preview-pill.js +56 -0
  56. package/dist/next/preview-route.d.ts +1 -0
  57. package/dist/next/preview-route.js +30 -0
  58. package/dist/path-index.d.ts +11 -0
  59. package/dist/path-index.js +52 -25
  60. package/dist/paths.d.ts +25 -12
  61. package/dist/paths.js +59 -21
  62. package/dist/schema-version.d.ts +1 -1
  63. package/dist/schema-version.js +1 -1
  64. package/dist/site.d.ts +7 -3
  65. package/dist/site.js +26 -15
  66. package/dist/space-transfer.d.ts +2 -0
  67. package/dist/space-transfer.js +8 -1
  68. package/dist/sql-space.d.ts +14 -0
  69. package/dist/sql-space.js +51 -23
  70. package/dist/sql.d.ts +5 -0
  71. package/dist/sql.js +51 -9
  72. package/dist/store.d.ts +15 -1
  73. package/dist/store.js +27 -6
  74. package/dist/supabase-entry-store.d.ts +0 -1
  75. package/dist/supabase-entry-store.js +6 -11
  76. package/dist/supabase-path-index.d.ts +12 -1
  77. package/dist/supabase-path-index.js +56 -19
  78. package/dist/supabase-store.d.ts +2 -0
  79. package/dist/supabase-store.js +30 -9
  80. package/migrations/20260929120000_smoodly_v5.sql +56 -0
  81. package/migrations/20260929135523_smoodly_v6.sql +200 -0
  82. package/package.json +1 -5
@@ -6,26 +6,54 @@
6
6
  // an "Add" action. The shell is not DOM-tested; every decision that
7
7
  // matters lives here.
8
8
  import { buildPageTree } from "../../paths.js";
9
+ import { ownerCollection, pageKey } from "../../addresses.js";
9
10
  /** Registrations editors may create pages against: fixed ones are
10
11
  * materialized by Smoodly, never offered. */
11
12
  export function openTemplates(templates) {
12
13
  return templates.filter((t) => t.slug === undefined);
13
14
  }
14
- /** The default-locale slug that identifies a ROOT node to the registry;
15
- * a root that exists in another locale only is never code-owned. */
16
- const rootSlug = (record, registry) => record.parentId === null ? record.locales[registry.locales.default]?.slug : undefined;
17
- /** The collection a ROOT record is the mount of, if any — known from the
18
- * registry, never from a column (spec 2026-09-05 §4). */
15
+ /** The collection a record is the address of — by owner, never by slug, while code still gives
16
+ * it an address (spec 2026-09-29 nested paths §3). */
19
17
  export function mountOf(record, registry) {
20
- const slug = rootSlug(record, registry);
21
- if (slug === undefined)
22
- return null;
23
- return registry.collections.find((c) => c.path?.[registry.locales.default] === slug) ?? null;
18
+ const name = record.fixed && registry.addresses[record.fixed] ? ownerCollection(record.fixed) : null;
19
+ return name === null ? null : registry.collections.find((c) => c.name === name) ?? null;
24
20
  }
25
- /** Fixed pages and mounts: never renamed, moved, deleted or stripped of a locale by editors. */
21
+ /** Fixed pages and mounts: owned by a registration that still exists. Never renamed, moved,
22
+ * deleted or stripped of a locale by editors. */
26
23
  export function isFixedNode(record, registry) {
27
- const slug = rootSlug(record, registry);
28
- return slug !== undefined && (registry.pages.some((t) => t.slug === slug) || mountOf(record, registry) !== null);
24
+ return !!record.fixed && registry.addresses[record.fixed] !== undefined;
25
+ }
26
+ /** Who owns `path` in a locale: a code address other than the home page's, which serves "/". */
27
+ function ownerAt(path, registry, locale) {
28
+ for (const [key, segments] of Object.entries(registry.addresses)) {
29
+ const address = segments[locale];
30
+ if (address === undefined || segments[registry.locales.default] === registry.homePageSlug || `/${address}` !== path)
31
+ continue;
32
+ const collection = ownerCollection(key);
33
+ const title = collection
34
+ ? registry.collections.find((c) => c.name === collection)?.title ?? collection
35
+ : registry.pages.find((p) => pageKey(p.name) === key)?.title ?? key;
36
+ return { key, label: collection ? `the ${title} collection` : `the ${title} page` };
37
+ }
38
+ return null;
39
+ }
40
+ /** The notice on a page that sits on another code address, which visitors get instead (spec 2026-09-29 anchored paths §4). */
41
+ function conflictNote(path, registry, locale) {
42
+ if (path === null)
43
+ return null;
44
+ const owner = ownerAt(path, registry, locale);
45
+ if (!owner)
46
+ return null;
47
+ return `${path} is set in code for ${owner.label}, so visitors don't see this page. Rename or move it.`;
48
+ }
49
+ /** The rename/move confirm's extra line (spec 2026-09-29 nested paths §3): code addresses below a page stay put. */
50
+ export function codeAddressNote(path, registry, locale) {
51
+ if (path === null)
52
+ return undefined;
53
+ const below = Object.values(registry.addresses)
54
+ .map((s) => s[locale])
55
+ .filter((a) => a !== undefined && `/${a}`.startsWith(`${path}/`));
56
+ return below.length === 0 ? undefined : below.map((a) => `/${a} stays where it is — it is set in code.`).join(" ");
29
57
  }
30
58
  /** A node's title for display in a locale it may not exist in: its own,
31
59
  * else the default locale's, else any. Display only — never content. */
@@ -54,7 +82,7 @@ export function localeStatusOf(row) {
54
82
  return row.publishedVersionId === row.draftVersionId ? "published" : "draft edits";
55
83
  }
56
84
  export function pagesTreeRows(records, registry, locale, collapsed) {
57
- const opts = { locales: registry.locales, homePageSlug: registry.homePageSlug };
85
+ const opts = { locales: registry.locales, homePageSlug: registry.homePageSlug, addresses: registry.addresses };
58
86
  const templateTitle = (name) => registry.pages.find((t) => t.name === name)?.title ?? name;
59
87
  const rows = [];
60
88
  const walk = (nodes, depth, parentId) => {
@@ -65,6 +93,7 @@ export function pagesTreeRows(records, registry, locale, collapsed) {
65
93
  const mount = mountOf(r, registry);
66
94
  const isHome = r.parentId === null && r.locales[registry.locales.default]?.slug === registry.homePageSlug;
67
95
  const expanded = !collapsed.has(r.id);
96
+ const orphan = !!r.fixed && registry.addresses[r.fixed] === undefined;
68
97
  rows.push({
69
98
  id: r.id,
70
99
  parentId,
@@ -80,6 +109,8 @@ export function pagesTreeRows(records, registry, locale, collapsed) {
80
109
  kind: mount ? "mount" : r.template === null ? "folder" : "page",
81
110
  collection: mount?.name ?? null,
82
111
  fixed: isFixedNode(r, registry),
112
+ conflict: r.fixed ? null : conflictNote(node.path, registry, locale),
113
+ orphan,
83
114
  isHome,
84
115
  template: r.template,
85
116
  templateTitle: mount
@@ -87,7 +118,8 @@ export function pagesTreeRows(records, registry, locale, collapsed) {
87
118
  : r.template === null ? "Path" : templateTitle(r.template),
88
119
  status: !row || r.template === null ? null : localeStatusOf(row),
89
120
  // A child needs its parent in the locale (the parent rule), so an absent node offers none.
90
- canHaveChildren: present && !mount && !isHome && depth + 1 < registry.pageDepth,
121
+ // A record no longer in code has no URL to give a child (spec 2026-09-29 nested paths §5).
122
+ canHaveChildren: present && !mount && !isHome && !orphan && depth + 1 < registry.pageDepth,
91
123
  record: r,
92
124
  });
93
125
  if (expanded)
@@ -99,8 +131,9 @@ export function pagesTreeRows(records, registry, locale, collapsed) {
99
131
  }
100
132
  /** Every row drags: root order is the listing's order, nothing code owns.
101
133
  * Fixed nodes and the home page stay at the root — projectDrop refuses
102
- * them any deeper slot, the move op refuses any other parent. */
103
- export const canDragRow = (_row) => true;
134
+ * them any deeper slot, the move op refuses any other parent. A record
135
+ * no longer in code does not move at all: deleting it is all that is open. */
136
+ export const canDragRow = (row) => !row.orphan;
104
137
  /** Whether a row may only reorder among the root siblings. */
105
138
  export const rootOnly = (row) => row.fixed || row.isHome;
106
139
  /** Levels below a node: 0 for a leaf. From the records, because the
@@ -39,3 +39,8 @@ export declare function useRoute(base: string): Route;
39
39
  /** Register the calling view as dirty: navigate() asks before leaving,
40
40
  * and beforeunload covers reload, closing the tab and a real link out. */
41
41
  export declare function useLeaveGuard(dirty: boolean): void;
42
+ /** The tab-close half of useLeaveGuard alone, for a view that saves on
43
+ * unmount (the page editor, spec 2026-09-29 pre-install fixes §4): an
44
+ * in-app navigation loses nothing, so only reload, closing the tab and a
45
+ * real link out ask, while a change or a save is pending. */
46
+ export declare function useUnloadGuard(pending: boolean): void;
@@ -94,3 +94,16 @@ export function useLeaveGuard(dirty) {
94
94
  return () => { guards.delete(guard); window.removeEventListener("beforeunload", onBeforeUnload); };
95
95
  }, [dirty]);
96
96
  }
97
+ /** The tab-close half of useLeaveGuard alone, for a view that saves on
98
+ * unmount (the page editor, spec 2026-09-29 pre-install fixes §4): an
99
+ * in-app navigation loses nothing, so only reload, closing the tab and a
100
+ * real link out ask, while a change or a save is pending. */
101
+ export function useUnloadGuard(pending) {
102
+ useEffect(() => {
103
+ if (!pending)
104
+ return;
105
+ const onBeforeUnload = (e) => { e.preventDefault(); e.returnValue = ""; };
106
+ window.addEventListener("beforeunload", onBeforeUnload);
107
+ return () => window.removeEventListener("beforeunload", onBeforeUnload);
108
+ }, [pending]);
109
+ }
@@ -667,6 +667,7 @@ body:has(.sm-admin){margin:0}
667
667
  .sm-row--clickable:hover{background:var(--surface-inset)}
668
668
  .sm-cell{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}
669
669
  .sm-cell--muted{color:var(--text-muted);font-size:var(--meta-size)}
670
+ .sm-cell--warn{color:var(--warn);font-size:var(--meta-size);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
670
671
  .sm-cell--mono{font-family:var(--font-mono);font-size:var(--text-sm);color:var(--text-muted)}
671
672
  .sm-cell--title{font-weight:500}
672
673
  .sm-cell--grip{overflow:visible;display:flex;align-items:center;margin-left:-4px}
@@ -0,0 +1,42 @@
1
+ import { codeAddresses, type Addresses } from "./addresses.ts";
2
+ import type { LocaleSet } from "./paths.ts";
3
+ export type Anchors = {
4
+ addresses: Addresses;
5
+ homePageSlug: string;
6
+ defaultLocale: string;
7
+ };
8
+ export type AnchorKey = {
9
+ anchor: string;
10
+ path: string;
11
+ };
12
+ type PageLike = Parameters<typeof codeAddresses>[0]["pages"][number];
13
+ type CollectionLike = Parameters<typeof codeAddresses>[0]["collections"][number];
14
+ export declare function anchorsFor(input: {
15
+ locales: LocaleSet;
16
+ pages?: ReadonlyArray<PageLike>;
17
+ collections?: ReadonlyArray<CollectionLike>;
18
+ homePageSlug?: string;
19
+ }): Anchors;
20
+ /** The URL an anchor's own record serves in a locale; the home page's is "/". Null when the config names no address there. */
21
+ export declare function anchorUrl(anchors: Anchors, anchor: string, locale: string): string | null;
22
+ /** A stored row's full URL; null when its anchor has no address (a record no longer in code, a collection without `path`). */
23
+ export declare function fullPath(anchors: Anchors, anchor: string, path: string, locale: string): string | null;
24
+ /** What a request URL may hit, best first: every code address it equals or lies below (the longest first), then the
25
+ * editors' tree. `coded`: the URL is code's, so a row without an anchor counts only as a redirect (spec §2). */
26
+ export declare function candidates(anchors: Anchors, locale: string, url: string): {
27
+ keys: AnchorKey[];
28
+ coded: boolean;
29
+ };
30
+ /** The anchor a URL is served under: its longest code address, or `''` for the editors' tree (spec §2). A page
31
+ * whose own anchor (its chain root's owner key, `''` in the editors' tree) is another one is hidden there: code wins
32
+ * the address (spec §4). */
33
+ export declare const servedAnchor: (anchors: Anchors, locale: string, url: string) => string;
34
+ /** Every stored key that yields the same full URL as (anchor, path): a row moving between the editors' tree and a
35
+ * code anchor keeps its URL, so it must be the same address to the rewrite (no redirect to itself). An anchor with
36
+ * no address has only its own key. */
37
+ export declare function sameUrlKeys(anchors: Anchors, locale: string, anchor: string, path: string): AnchorKey[];
38
+ /** The winning row among those found for `keys`, by key order (spec §2's precedence). */
39
+ export declare function pickHit<R extends AnchorKey & {
40
+ redirect: boolean;
41
+ }>(found: R[], keys: AnchorKey[], coded: boolean): R | null;
42
+ export {};
@@ -0,0 +1,72 @@
1
+ // Anchored paths (spec 2026-09-29 anchored paths): a `paths` row stores
2
+ // the part editors made, under the owner key of the code address it hangs
3
+ // on (`collection:news`, `page:about`; `''` for the editors' tree). The
4
+ // address itself lives only in the config, so a code change moves its
5
+ // URLs at the deploy with no write. Pure: both path index adapters join
6
+ // the address back on through here.
7
+ import { addressMap, codeAddresses } from "./addresses.js";
8
+ export function anchorsFor(input) {
9
+ return {
10
+ addresses: addressMap(codeAddresses({ locales: input.locales, pages: input.pages ?? [], collections: input.collections ?? [] })),
11
+ homePageSlug: input.homePageSlug ?? "home",
12
+ defaultLocale: input.locales.default,
13
+ };
14
+ }
15
+ /** The URL an anchor's own record serves in a locale; the home page's is "/". Null when the config names no address there. */
16
+ export function anchorUrl(anchors, anchor, locale) {
17
+ const byLocale = anchors.addresses[anchor];
18
+ const address = byLocale?.[locale];
19
+ if (address === undefined)
20
+ return null;
21
+ return byLocale[anchors.defaultLocale] === anchors.homePageSlug ? "/" : `/${address}`;
22
+ }
23
+ /** A stored row's full URL; null when its anchor has no address (a record no longer in code, a collection without `path`). */
24
+ export function fullPath(anchors, anchor, path, locale) {
25
+ if (anchor === "")
26
+ return path;
27
+ const base = anchorUrl(anchors, anchor, locale);
28
+ if (base === null)
29
+ return null;
30
+ if (path === "")
31
+ return base;
32
+ return base === "/" ? path : `${base}${path}`;
33
+ }
34
+ /** What a request URL may hit, best first: every code address it equals or lies below (the longest first), then the
35
+ * editors' tree. `coded`: the URL is code's, so a row without an anchor counts only as a redirect (spec §2). */
36
+ export function candidates(anchors, locale, url) {
37
+ const keys = [];
38
+ for (const anchor of Object.keys(anchors.addresses)) {
39
+ const base = anchorUrl(anchors, anchor, locale);
40
+ if (base === null)
41
+ continue;
42
+ if (url === base)
43
+ keys.push({ anchor, path: "" });
44
+ else if (base !== "/" && url.startsWith(`${base}/`))
45
+ keys.push({ anchor, path: url.slice(base.length) });
46
+ }
47
+ keys.sort((a, b) => a.path.length - b.path.length);
48
+ return { keys: [...keys, { anchor: "", path: url }], coded: keys.length > 0 };
49
+ }
50
+ /** The anchor a URL is served under: its longest code address, or `''` for the editors' tree (spec §2). A page
51
+ * whose own anchor (its chain root's owner key, `''` in the editors' tree) is another one is hidden there: code wins
52
+ * the address (spec §4). */
53
+ export const servedAnchor = (anchors, locale, url) => candidates(anchors, locale, url).keys[0].anchor;
54
+ /** Every stored key that yields the same full URL as (anchor, path): a row moving between the editors' tree and a
55
+ * code anchor keeps its URL, so it must be the same address to the rewrite (no redirect to itself). An anchor with
56
+ * no address has only its own key. */
57
+ export function sameUrlKeys(anchors, locale, anchor, path) {
58
+ const url = fullPath(anchors, anchor, path, locale);
59
+ return url === null ? [{ anchor, path }] : candidates(anchors, locale, url).keys;
60
+ }
61
+ /** The winning row among those found for `keys`, by key order (spec §2's precedence). */
62
+ export function pickHit(found, keys, coded) {
63
+ for (const key of keys) {
64
+ const row = found.find((r) => r.anchor === key.anchor && r.path === key.path);
65
+ if (!row)
66
+ continue;
67
+ if (coded && key.anchor === "" && !row.redirect)
68
+ continue;
69
+ return row;
70
+ }
71
+ return null;
72
+ }
@@ -11,6 +11,7 @@ import { schemaBehind, schemaBehindMessage } from "../schema-version.js";
11
11
  import { CONTENT_TABLES } from "../sql-space.js";
12
12
  import { FORMAT, brokenReferences, countRows, describeExport, emptyTables, fromSpace, sortRows, } from "../space-transfer.js";
13
13
  import { MEDIA_BUCKET, connectFromEnv, isNotFound, packageVersion, readSchemaVersion, readSpaceRows, schemaRefusal, } from "./space-io.js";
14
+ import { readCurrentSpace } from "../current-space.js";
14
15
  export const defaultExportDir = (now) => `smoodly-export-${now.toISOString().slice(0, 10)}`;
15
16
  export const folderExists = (dir) => `Refusing: ${dir} already exists. Remove it, or pass --out <dir>.`;
16
17
  export const CHANGED_DURING_EXPORT = "Content changed during export — run it again.";
@@ -26,12 +27,13 @@ export async function runExport(args, out, deps = {}) {
26
27
  out(folderExists(shown));
27
28
  return 1;
28
29
  }
29
- const { db, space } = deps.connection ?? connectFromEnv(cwd);
30
+ const { db } = deps.connection ?? connectFromEnv(cwd);
30
31
  const schema = await readSchemaVersion(db);
31
32
  if ("missing" in schema) {
32
33
  out(schemaRefusal(schema.missing, "Export"));
33
34
  return 1;
34
35
  }
36
+ const space = await readCurrentSpace(db);
35
37
  if (schemaBehind(schema.version)) {
36
38
  out(schemaBehindMessage(schema.version));
37
39
  return 1;
@@ -15,5 +15,6 @@ export declare const notAnExport: (dir: string) => string;
15
15
  export declare const formatMismatch: (format: unknown) => string;
16
16
  export declare const RUN_AGAIN = "Run the same command again to continue: rows and files that landed are skipped.";
17
17
  export declare const MEMBERS_HEADING = "Members in the source \u2014 invite them in Settings \u203A Members:";
18
+ export declare const nothingNew: (space: string) => string;
18
19
  export declare const CACHE_NOTE = "Restart or redeploy the app: it may have cached pages from while the space was empty.";
19
20
  export declare function runImport(args: ImportArgs, out: (line: string) => void, deps?: ImportDeps): Promise<number>;
@@ -9,13 +9,15 @@ import { readFile } from "node:fs/promises";
9
9
  import { join, resolve } from "node:path";
10
10
  import { MEDIA_CACHE_SECONDS } from "../media.js";
11
11
  import { CONTENT_TABLES } from "../sql-space.js";
12
- import { CONFLICT_COLUMNS, FORMAT, ROW_KEY, describeImport, emptyTables, foreignRows, insertPlan, intoSpace, notEmptyMessage, rowKey, versionMismatchMessage, } from "../space-transfer.js";
12
+ import { CONFLICT_COLUMNS, FORMAT, ROW_KEY, describeImport, emptyTables, foreignRows, insertPlan, intoSpace, missingRows, notEmptyMessage, rowKey, versionMismatchMessage, } from "../space-transfer.js";
13
13
  import { MEDIA_BUCKET, connectFromEnv, isAlreadyExists, readSchemaVersion, readSpaceRows, schemaRefusal, } from "./space-io.js";
14
+ import { readCurrentSpace } from "../current-space.js";
14
15
  export const BATCH_ROWS = 500;
15
16
  export const notAnExport = (dir) => `Refusing: ${dir} is not a Smoodly export (no manifest.json).`;
16
17
  export const formatMismatch = (format) => `Refusing: this export's format is ${String(format)}; this smoodly reads format ${FORMAT}. Update smoodly.`;
17
18
  export const RUN_AGAIN = "Run the same command again to continue: rows and files that landed are skipped.";
18
19
  export const MEMBERS_HEADING = "Members in the source — invite them in Settings › Members:";
20
+ export const nothingNew = (space) => `Nothing new: space ${space} already holds everything in this export.`;
19
21
  export const CACHE_NOTE = "Restart or redeploy the app: it may have cached pages from while the space was empty.";
20
22
  export async function runImport(args, out, deps = {}) {
21
23
  const cwd = deps.cwd ?? process.cwd();
@@ -30,12 +32,13 @@ export async function runImport(args, out, deps = {}) {
30
32
  out(formatMismatch(manifest.format));
31
33
  return 1;
32
34
  }
33
- const { db, space } = deps.connection ?? connectFromEnv(cwd);
35
+ const { db } = deps.connection ?? connectFromEnv(cwd);
34
36
  const schema = await readSchemaVersion(db);
35
37
  if ("missing" in schema) {
36
38
  out(schemaRefusal(schema.missing, "Import"));
37
39
  return 1;
38
40
  }
41
+ const space = await readCurrentSpace(db);
39
42
  if (schema.version !== manifest.schemaVersion) {
40
43
  out(versionMismatchMessage(schema.version, manifest.schemaVersion));
41
44
  return 1;
@@ -52,6 +55,8 @@ export async function runImport(args, out, deps = {}) {
52
55
  out(notEmptyMessage(foreign));
53
56
  return 1;
54
57
  }
58
+ const newRows = missingRows(target, rows);
59
+ let newFiles = 0;
55
60
  try {
56
61
  // Media first, so no ready asset row ever points at a missing file.
57
62
  for (const asset of rows.assets) {
@@ -66,6 +71,8 @@ export async function runImport(args, out, deps = {}) {
66
71
  });
67
72
  if (up.error && !isAlreadyExists(up.error))
68
73
  throw new Error(`uploading ${path}: ${up.error.message}`);
74
+ if (!up.error)
75
+ newFiles++;
69
76
  }
70
77
  const size = deps.batchSize ?? BATCH_ROWS;
71
78
  for (const { table, rows: list } of insertPlan(rows)) {
@@ -83,6 +90,10 @@ export async function runImport(args, out, deps = {}) {
83
90
  out(RUN_AGAIN);
84
91
  return 1;
85
92
  }
93
+ if (newRows === 0 && newFiles === 0) {
94
+ out(nothingNew(space));
95
+ return 0;
96
+ }
86
97
  out(`Imported ${describeImport(rows)} into space ${space}.`);
87
98
  if (manifest.members.length > 0) {
88
99
  out(MEMBERS_HEADING);
@@ -3,14 +3,13 @@ import type { ContentTable } from "../sql-space.ts";
3
3
  import { type Row } from "../space-transfer.ts";
4
4
  export type SpaceConnection = {
5
5
  db: SupabaseClient;
6
- space: string;
7
6
  };
8
7
  export declare const MEDIA_BUCKET = "media";
9
8
  /** Rows per request. A host may cap below it, so a read stops at the first empty page, not the first short one. */
10
9
  export declare const PAGE_ROWS = 1000;
11
10
  /** `.env.local`, then `.env`, as Next reads them; a variable already set wins (spec §5). */
12
11
  export declare function loadAppEnv(cwd: string, env?: Record<string, string | undefined>): void;
13
- /** The app's content client and the space its secret key names: a cloud key's claim, or the default space. */
12
+ /** The app's content client, from its env. */
14
13
  export declare function connectFromEnv(cwd: string): SpaceConnection;
15
14
  /** The policies show `smoodly_meta` to a write key only, so no visible row means the key is not a secret key (spec §5). */
16
15
  export declare function readSchemaVersion(db: SupabaseClient): Promise<{
@@ -1,13 +1,14 @@
1
1
  // packages/smoodly/src/cli/space-io.ts
2
2
  // What export and import share (spec 2026-09-28 export/import §5, §8):
3
- // the app's env, the client and the space its key names, the schema-
3
+ // the app's env and client (the space is the database's answer, read
4
+ // after the schema check so a missing schema is refused first), the schema-
4
5
  // version read that doubles as the secret-key check, paged reads of one
5
6
  // space's rows, and the two storage error tests.
6
7
  import { existsSync, readFileSync } from "node:fs";
7
8
  import { join } from "node:path";
8
9
  import { parseEnv } from "node:util";
9
10
  import { createSmoodlyClient } from "../client.js";
10
- import { resolveSmoodlyEnv, spaceIdOfKey } from "../env.js";
11
+ import { resolveSmoodlyEnv } from "../env.js";
11
12
  import { SCHEMA_VERSION_KEY } from "../schema-version.js";
12
13
  import { ROW_KEY } from "../space-transfer.js";
13
14
  export const MEDIA_BUCKET = "media";
@@ -25,11 +26,10 @@ export function loadAppEnv(cwd, env = process.env) {
25
26
  }
26
27
  }
27
28
  }
28
- /** The app's content client and the space its secret key names: a cloud key's claim, or the default space. */
29
+ /** The app's content client, from its env. */
29
30
  export function connectFromEnv(cwd) {
30
31
  loadAppEnv(cwd);
31
- const env = resolveSmoodlyEnv(process.env);
32
- return { db: createSmoodlyClient(env), space: spaceIdOfKey(env.secretKey) };
32
+ return { db: createSmoodlyClient(resolveSmoodlyEnv(process.env)) };
33
33
  }
34
34
  /** The policies show `smoodly_meta` to a write key only, so no visible row means the key is not a secret key (spec §5). */
35
35
  export async function readSchemaVersion(db) {
@@ -36,7 +36,12 @@ export function collection(input) {
36
36
  }
37
37
  // f.slug({ from }) follows a field in the entry form (spec 2026-09-28
38
38
  // ASCII slugs §5): a typo would silently never follow, so it fails here.
39
+ // Only the key `slug` is the entry's URL; under another key the field
40
+ // would look like one and never be it.
39
41
  for (const [key, builder] of Object.entries(input.fields)) {
42
+ if (builder.descriptor.type === "slug" && key !== "slug") {
43
+ throw new Error(`smoodly: collection "${input.name}" declares f.slug() as "${key}" — name it "slug": only the slug field is the entry's URL.`);
44
+ }
40
45
  const from = builder.descriptor.type === "slug" ? builder.descriptor.from : undefined;
41
46
  if (from === key)
42
47
  throw new Error(`smoodly: collection "${input.name}" slug field "${key}" cannot follow itself.`);
package/dist/config.d.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import type { Descriptor } from "./fields.ts";
2
2
  import type { CollectionSchema } from "./collections.ts";
3
3
  import type { GlobalSchema } from "./global.ts";
4
+ import { type Addresses } from "./addresses.ts";
5
+ import type { Anchors } from "./anchors.ts";
4
6
  import { type StyleValues, type StyleValuesInput } from "./styles.ts";
5
7
  import { type CustomConfigInput, type ResolvedCustom } from "./custom/index.ts";
6
8
  type Registered = {
@@ -87,6 +89,10 @@ export type ResolvedConfig = Omit<SmoodlyConfig, "styles" | "custom" | "admin">
87
89
  admin: {
88
90
  path: string;
89
91
  };
92
+ /** Code-owned addresses by owner key (spec 2026-09-29 nested paths §3). */
93
+ addresses: Addresses;
94
+ /** The path index's view of the code-owned addresses (spec 2026-09-29 anchored paths §2): pass it to the index. */
95
+ anchors: Anchors;
90
96
  };
91
97
  /** `global`: the placement node type and an admin route; `asset`: the refs
92
98
  * target of every media edge; `media`: the admin's Media screen route. */
package/dist/config.js CHANGED
@@ -1,4 +1,5 @@
1
- import { assertSegment, fixedPageSegments, perLocaleSegments } from "./paths.js";
1
+ import { assertSegment, fixedPageSegments } from "./paths.js";
2
+ import { addressMap, assertAddress, codeAddresses, lastPart } from "./addresses.js";
2
3
  import { DEFAULT_MEDIA } from "./media.js";
3
4
  import { DEFAULT_ADMIN_PATH, assertAdminPath, isAdminSlug } from "./admin-path.js";
4
5
  import { resolveStyleValues, styleDescriptors } from "./styles.js";
@@ -54,17 +55,18 @@ function assertGlobalSections(config) {
54
55
  }
55
56
  }
56
57
  }
57
- /** Every root-level segment a registration claims, per locale: fixed page
58
- * slugs and collection paths. Two claims on one (locale, segment) are a
59
- * conflict — except a fixed page sitting at a collection's segment in
60
- * EVERY locale of that path, which is the collection's index page. The
61
- * admin's own segment is refused outright (spec 2026-09-17 slug guard §5). */
58
+ /** Every address a registration claims, per locale: fixed page slugs and
59
+ * collection paths, each one or more segments (spec 2026-09-29 nested
60
+ * paths §2). Two claims on one (locale, address) are a conflict — except
61
+ * a fixed page sitting at a collection's address in EVERY locale of that
62
+ * path, which is the collection's index page — and nothing may sit
63
+ * inside a collection's address. An address starting at the admin's
64
+ * segment is refused outright (spec 2026-09-17 slug guard §5). */
62
65
  function assertRootSegments(config, adminPath) {
63
- const { default: def, supported } = config.locales;
64
- // Mounts claim exactly the locales their path names; fixed pages fill an
65
- // omitted locale from the default one, so the check must fill the same
66
- // way the materializer does or the clash only shows up at materialization.
67
- const perLocale = (v) => perLocaleSegments(v, supported);
66
+ const { default: def } = config.locales;
67
+ // Fixed slugs and collection paths both fill an omitted locale from the
68
+ // default one — where the materializer puts the record and the entry
69
+ // derivation puts the entries — so the checks see every clash at boot.
68
70
  const pageSegments = (v) => fixedPageSegments(v, config.locales);
69
71
  const pages = (config.registry.pages ?? []).filter((p) => p.schema.kind === "page" && p.schema.slug !== undefined);
70
72
  const owners = new Map(); // "<locale> <segment>" → page name
@@ -74,8 +76,8 @@ function assertRootSegments(config, adminPath) {
74
76
  throw new Error(`smoodly: page "${p.schema.name}" declares a per-locale slug without the default locale "${def}".`);
75
77
  }
76
78
  for (const [locale, segment] of Object.entries(pageSegments(slug))) {
77
- assertSegment(segment, `page "${p.schema.name}" fixed slug`);
78
- if (isAdminSlug(segment, adminPath)) {
79
+ assertAddress(segment, `page "${p.schema.name}" fixed slug`);
80
+ if (isAdminSlug(segment.split("/")[0], adminPath)) {
79
81
  throw new Error(`smoodly: page "${p.schema.name}" has the fixed slug "${segment}", where the admin lives — change its slug or admin.path.`);
80
82
  }
81
83
  const owner = owners.get(`${locale} ${segment}`);
@@ -96,15 +98,15 @@ function assertRootSegments(config, adminPath) {
96
98
  if (typeof c.path !== "string" && c.path[def] === undefined) {
97
99
  throw new Error(`smoodly: collection "${c.name}" declares a per-locale path without the default locale "${def}".`);
98
100
  }
99
- const segments = perLocale(c.path);
101
+ const segments = pageSegments(c.path);
100
102
  for (const [locale, segment] of Object.entries(segments)) {
101
- assertSegment(segment, `collection "${c.name}" path`);
102
- if (isAdminSlug(segment, adminPath)) {
103
+ assertAddress(segment, `collection "${c.name}" path`);
104
+ if (isAdminSlug(segment.split("/")[0], adminPath)) {
103
105
  throw new Error(`smoodly: collection "${c.name}" is mounted at "${adminPath}", where the admin lives — change its path or admin.path.`);
104
106
  }
105
107
  const other = collectionOwners.get(`${locale} ${segment}`);
106
108
  if (other) {
107
- throw new Error(`smoodly: collections "${other}" and "${c.name}" both claim the root segment "${segment}" (${locale}).`);
109
+ throw new Error(`smoodly: collections "${other}" and "${c.name}" both claim the address "${segment}" (${locale}).`);
108
110
  }
109
111
  collectionOwners.set(`${locale} ${segment}`, c.name);
110
112
  }
@@ -116,10 +118,30 @@ function assertRootSegments(config, adminPath) {
116
118
  const page = pages.find((p) => p.schema.name === pageName);
117
119
  const everywhere = Object.entries(segments).every(([l, s]) => pageSegments(page.schema.slug)[l] === s);
118
120
  if (!everywhere) {
119
- throw new Error(`smoodly: collection "${c.name}" and page registration "${pageName}" both claim the root segment "${segment}" (${locale}).`);
121
+ throw new Error(`smoodly: collection "${c.name}" and page registration "${pageName}" both claim the address "${segment}" (${locale}).`);
120
122
  }
121
123
  }
122
124
  }
125
+ // A collection's entries live below its address: nothing else may sit there (spec 2026-09-29 nested paths §2).
126
+ const claims = [];
127
+ for (const p of pages)
128
+ for (const [locale, address] of Object.entries(pageSegments(p.schema.slug)))
129
+ claims.push({ what: `page "${p.schema.name}"`, locale, address });
130
+ for (const c of config.registry.collections ?? []) {
131
+ if (c.path === undefined)
132
+ continue;
133
+ for (const [locale, address] of Object.entries(pageSegments(c.path)))
134
+ claims.push({ what: `collection "${c.name}"`, locale, address });
135
+ }
136
+ for (const c of config.registry.collections ?? []) {
137
+ if (c.path === undefined)
138
+ continue;
139
+ for (const [locale, base] of Object.entries(pageSegments(c.path))) {
140
+ const inside = claims.find((x) => x.locale === locale && x.address.startsWith(`${base}/`));
141
+ if (inside)
142
+ throw new Error(`smoodly: ${inside.what} at "${inside.address}" (${locale}) sits inside collection "${c.name}" — its entries live there.`);
143
+ }
144
+ }
123
145
  }
124
146
  /** A language subtag, optionally a script or region subtag (`en`, `fi`,
125
147
  * `en-US`, `zh-Hant`). Codes are otherwise opaque strings all the way
@@ -204,6 +226,16 @@ export function defineConfig(config) {
204
226
  throw new Error(`smoodly: homePageSlug "${homePageSlug}" is also the path of collection "${c.name}".`);
205
227
  }
206
228
  }
229
+ // A code-owned record keeps its address's last part as its slug, and the
230
+ // root record slugged homePageSlug IS the home page: a nested address
231
+ // ending in it would serve "/" (spec 2026-09-29 nested paths §3).
232
+ for (const a of codeAddresses({ locales: config.locales, pages: config.registry.pages ?? [], collections: config.registry.collections ?? [] })) {
233
+ for (const [locale, address] of Object.entries(a.segments)) {
234
+ if (address.includes("/") && lastPart(address) === homePageSlug) {
235
+ throw new Error(`smoodly: the address "${address}" (${locale}) ends in homePageSlug "${homePageSlug}" — its record would be the home page; end it in another segment.`);
236
+ }
237
+ }
238
+ }
207
239
  const styles = resolveStyleValues(config.styles);
208
240
  const custom = resolveCustom(config.custom);
209
241
  const byName = new Map((config.registry.sections ?? []).map((s) => [s.schema.name, s.schema]));
@@ -214,12 +246,15 @@ export function defineConfig(config) {
214
246
  if (!Number.isInteger(media.maxSize) || media.maxSize <= 0) {
215
247
  throw new Error("smoodly: media.maxSize must be a positive integer of bytes.");
216
248
  }
249
+ const addresses = addressMap(codeAddresses({ locales: config.locales, pages: config.registry.pages ?? [], collections: config.registry.collections ?? [] }));
217
250
  return {
218
251
  ...config,
219
252
  homePageSlug,
220
253
  pageDepth: depth,
221
254
  media,
222
255
  admin: { path: adminPath },
256
+ addresses,
257
+ anchors: { addresses, homePageSlug, defaultLocale: config.locales.default },
223
258
  styles,
224
259
  css: config.css === true,
225
260
  custom,
@@ -0,0 +1,4 @@
1
+ import type { SupabaseClient } from "@supabase/supabase-js";
2
+ type RpcClient = Pick<SupabaseClient, "rpc">;
3
+ export declare function readCurrentSpace(db: RpcClient): Promise<string>;
4
+ export {};
@@ -0,0 +1,9 @@
1
+ export async function readCurrentSpace(db) {
2
+ const res = await db.rpc("smoodly_current_space");
3
+ if (res.error)
4
+ throw new Error(`smoodly: reading the space: ${res.error.message}`);
5
+ if (typeof res.data !== "string" || !res.data) {
6
+ throw new Error("smoodly: smoodly_current_space() returned no space — is the schema up to date?");
7
+ }
8
+ return res.data;
9
+ }
@@ -13,6 +13,7 @@ import { collectionDepth, collectionOrder } from "./collections.js";
13
13
  import { collectEntryRefs } from "./refs.js";
14
14
  import { MemoryRefIndex } from "./ref-index.js";
15
15
  import { MemoryPathIndex } from "./path-index.js";
16
+ import { anchorsFor } from "./anchors.js";
16
17
  import { groupRedirects } from "./store.js";
17
18
  import { assertFresh } from "./draft-token.js";
18
19
  import { globalEntryId } from "./global-id.js";
@@ -183,9 +184,10 @@ export class MemoryEntryStore {
183
184
  this.seq = 0;
184
185
  this.order = new Map();
185
186
  this.byId = (id) => this.entries.get(id);
186
- this.paths = options.paths ?? new MemoryPathIndex();
187
187
  this.locales = options.locales ?? DEFAULT_ENTRY_LOCALES;
188
188
  this.schemas = [...collections, ...(options.globals ?? [])];
189
+ // Alone, the store's own index reads its collections' addresses (spec 2026-09-29 anchored paths §2).
190
+ this.paths = options.paths ?? new MemoryPathIndex(anchorsFor({ locales: this.locales, collections }));
189
191
  this.elements = options.elements;
190
192
  }
191
193
  schema(collection) {
@@ -283,7 +285,7 @@ export class MemoryEntryStore {
283
285
  return sets;
284
286
  }
285
287
  async writePaths(collection, nodes, redirect = true) {
286
- await this.paths.rewrite(rewriteTargets("entry", nodes, redirect), entryPathRows(nodes, this.byId, this.schema(collection), this.locales));
288
+ await this.paths.rewrite(rewriteTargets("entry", nodes, redirect), entryPathRows(nodes, this.byId, collection));
287
289
  }
288
290
  writeRefs(entry) {
289
291
  this.refs.rewrite({ sourceKind: "entry", sourceId: entry.id }, entryRefEdges(this.liveFieldSets(entry), this.schema(entry.collection), this.elements));