smoodly 0.0.32 → 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 (76) hide show
  1. package/README.md +77 -15
  2. package/dist/addresses.d.ts +42 -0
  3. package/dist/addresses.js +51 -0
  4. package/dist/admin/editor/EditorView.js +5 -3
  5. package/dist/admin/editor/PageSettings.js +7 -2
  6. package/dist/admin/editor/autosave.d.ts +2 -1
  7. package/dist/admin/editor/autosave.js +6 -0
  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/FieldWidget.d.ts +5 -1
  12. package/dist/admin/forms/FieldWidget.js +6 -3
  13. package/dist/admin/forms/slug-field.d.ts +17 -0
  14. package/dist/admin/forms/slug-field.js +39 -0
  15. package/dist/admin/health.js +9 -0
  16. package/dist/admin/next/create-admin.d.ts +4 -3
  17. package/dist/admin/next/create-admin.js +14 -3
  18. package/dist/admin/ops-impl.js +144 -14
  19. package/dist/admin/redirect-path.d.ts +3 -0
  20. package/dist/admin/redirect-path.js +9 -0
  21. package/dist/admin/serialize.d.ts +3 -0
  22. package/dist/admin/serialize.js +1 -0
  23. package/dist/admin/shell/AddRedirectModal.js +2 -2
  24. package/dist/admin/shell/EntryForm.js +46 -6
  25. package/dist/admin/shell/GlobalForm.js +1 -1
  26. package/dist/admin/shell/PageDialogs.js +11 -5
  27. package/dist/admin/shell/PagesList.js +29 -12
  28. package/dist/admin/shell/live-url.d.ts +3 -1
  29. package/dist/admin/shell/live-url.js +5 -2
  30. package/dist/admin/shell/pages-tree.d.ts +19 -14
  31. package/dist/admin/shell/pages-tree.js +49 -16
  32. package/dist/admin/ui/primitives.js +1 -0
  33. package/dist/admin/ui/theme.js +1 -0
  34. package/dist/admin/validate.d.ts +2 -0
  35. package/dist/admin/validate.js +9 -0
  36. package/dist/anchors.d.ts +42 -0
  37. package/dist/anchors.js +72 -0
  38. package/dist/cli/export.js +3 -1
  39. package/dist/cli/import.d.ts +1 -0
  40. package/dist/cli/import.js +13 -2
  41. package/dist/cli/space-io.d.ts +1 -2
  42. package/dist/cli/space-io.js +5 -5
  43. package/dist/collections.js +15 -0
  44. package/dist/config.d.ts +6 -0
  45. package/dist/config.js +53 -18
  46. package/dist/current-space.d.ts +4 -0
  47. package/dist/current-space.js +9 -0
  48. package/dist/entry-store.js +4 -2
  49. package/dist/env.d.ts +4 -3
  50. package/dist/env.js +15 -17
  51. package/dist/index.d.ts +4 -2
  52. package/dist/index.js +4 -2
  53. package/dist/next/page-renderer.js +2 -2
  54. package/dist/path-index.d.ts +11 -0
  55. package/dist/path-index.js +52 -25
  56. package/dist/paths.d.ts +32 -12
  57. package/dist/paths.js +80 -21
  58. package/dist/schema-version.d.ts +1 -1
  59. package/dist/schema-version.js +1 -1
  60. package/dist/site.d.ts +7 -3
  61. package/dist/site.js +26 -15
  62. package/dist/space-transfer.d.ts +2 -0
  63. package/dist/space-transfer.js +8 -1
  64. package/dist/sql.js +35 -9
  65. package/dist/store.d.ts +15 -1
  66. package/dist/store.js +27 -6
  67. package/dist/supabase-entry-store.d.ts +0 -1
  68. package/dist/supabase-entry-store.js +6 -11
  69. package/dist/supabase-path-index.d.ts +12 -1
  70. package/dist/supabase-path-index.js +56 -19
  71. package/dist/supabase-store.d.ts +2 -0
  72. package/dist/supabase-store.js +30 -9
  73. package/migrations/20260929120000_smoodly_v5.sql +56 -0
  74. package/package.json +1 -1
  75. package/dist/admin/shell/page-dialogs.d.ts +0 -9
  76. package/dist/admin/shell/page-dialogs.js +0 -19
@@ -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) {
@@ -34,6 +34,21 @@ export function collection(input) {
34
34
  input.fields[input.order.by]?.descriptor.localized === true) {
35
35
  throw new Error(`smoodly: collection "${input.name}" orders by "${input.order.by}", which is localized — order fields are shared across locales.`);
36
36
  }
37
+ // f.slug({ from }) follows a field in the entry form (spec 2026-09-28
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.
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
+ }
45
+ const from = builder.descriptor.type === "slug" ? builder.descriptor.from : undefined;
46
+ if (from === key)
47
+ throw new Error(`smoodly: collection "${input.name}" slug field "${key}" cannot follow itself.`);
48
+ if (typeof from === "string" && !Object.prototype.hasOwnProperty.call(input.fields, from)) {
49
+ throw new Error(`smoodly: collection "${input.name}" slug field "${key}" follows an unknown field "${from}".`);
50
+ }
51
+ }
37
52
  return {
38
53
  kind: "collection",
39
54
  name: input.name,
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));
package/dist/env.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { readCurrentSpace } from "./current-space.ts";
1
2
  export type SmoodlyEnv = {
2
3
  /** The content project. */
3
4
  url: string;
@@ -20,6 +21,6 @@ export type ContentConfig = {
20
21
  key: string;
21
22
  spaceId: string;
22
23
  };
23
- /** The space a key acts in: its `space_id` claim when it is a JWT that carries one, else the default space. */
24
- export declare function spaceIdOfKey(key: string): string;
25
- export declare function publicContentConfig(env: SmoodlyEnv): ContentConfig;
24
+ /** The public config, with the space the database says the secret key acts in —
25
+ * asked, never read out of the key, so it cannot disagree with where rows land. */
26
+ export declare function publicContentConfig(env: SmoodlyEnv, db?: Parameters<typeof readCurrentSpace>[0]): Promise<ContentConfig>;
package/dist/env.js CHANGED
@@ -1,7 +1,8 @@
1
1
  // The one config shape (spec 2026-09-04 §4/§6). Self-host sets three
2
2
  // names; the cloud sets five. The two defaults are what keep a single
3
3
  // code path: a default for an absent value is not a mode branch.
4
- import { DEFAULT_SPACE_ID } from "./sql-space.js";
4
+ import { createSmoodlyClient } from "./client.js";
5
+ import { readCurrentSpace } from "./current-space.js";
5
6
  const REQUIRED = ["SMOODLY_URL", "SMOODLY_SECRET_KEY", "SMOODLY_PUBLISHABLE_KEY"];
6
7
  export function resolveSmoodlyEnv(env) {
7
8
  const missing = REQUIRED.filter((name) => !env[name]);
@@ -21,21 +22,18 @@ export function resolveSmoodlyEnv(env) {
21
22
  /** `resolveSmoodlyEnv(process.env)` — call lazily, never at module load,
22
23
  * so `next build` without env still succeeds. */
23
24
  export const smoodlyEnv = () => resolveSmoodlyEnv(process.env);
24
- /** The space a key acts in: its `space_id` claim when it is a JWT that carries one, else the default space. */
25
- export function spaceIdOfKey(key) {
26
- const parts = key.split(".");
27
- if (parts.length !== 3)
28
- return DEFAULT_SPACE_ID;
29
- try {
30
- // base64url → base64, padded; atob exists in Node 22 and every browser, so no Buffer here.
31
- const b64 = parts[1].replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(parts[1].length / 4) * 4, "=");
32
- const claims = JSON.parse(atob(b64));
33
- return typeof claims.space_id === "string" && claims.space_id ? claims.space_id : DEFAULT_SPACE_ID;
25
+ /** One lookup per process per content project and key: the space never
26
+ * changes for a key. A failed lookup is not cached, so the next call retries. */
27
+ const spaces = new Map();
28
+ /** The public config, with the space the database says the secret key acts in —
29
+ * asked, never read out of the key, so it cannot disagree with where rows land. */
30
+ export function publicContentConfig(env, db) {
31
+ const cacheKey = `${env.url}\n${env.secretKey}`;
32
+ let space = spaces.get(cacheKey);
33
+ if (!space) {
34
+ space = readCurrentSpace(db ?? createSmoodlyClient(env));
35
+ spaces.set(cacheKey, space);
36
+ space.catch(() => spaces.delete(cacheKey));
34
37
  }
35
- catch {
36
- return DEFAULT_SPACE_ID;
37
- }
38
- }
39
- export function publicContentConfig(env) {
40
- return { url: env.url, key: env.publishableKey, spaceId: spaceIdOfKey(env.secretKey) };
38
+ return space.then((spaceId) => ({ url: env.url, key: env.publishableKey, spaceId }));
41
39
  }
package/dist/index.d.ts CHANGED
@@ -57,9 +57,11 @@ export { affectedTargets, pageTag, entryTag, globalTag, pagesTreeTag, collection
57
57
  export type { AffectedTargets } from "./revalidate.ts";
58
58
  export { resolveTree, resolveFields, resolveMedia } from "./resolve.ts";
59
59
  export type { EntryFetcher, ResolveFieldsOptions, AssetFetcher } from "./resolve.ts";
60
- export { SEGMENT_RE, isSegment, assertSegment, joinPath, splitPath, segmentFor, isHomeNode, pagePathIn, chainOf, assertDepth, pagePath, pagePathRows, entryPathRows, collectionSegment, buildPageTree, perLocaleSegments, assertSupportedLocale, unsupportedLocale, pageAlternates, entryAlternates, } from "./paths.ts";
60
+ export { SEGMENT_RE, isSegment, assertSegment, slugify, joinPath, splitPath, segmentFor, isHomeNode, pagePathIn, chainOf, assertDepth, pagePath, pagePathRows, entryPathRows, collectionSegment, buildPageTree, perLocaleSegments, assertSupportedLocale, unsupportedLocale, pageAlternates, entryAlternates, } from "./paths.ts";
61
61
  export type { LocaleSet, PathTarget, PathRow, PageLike, EntryLike, PageTreeNode, PagePathOptions, LocaleAlternate, PageAlternate } from "./paths.ts";
62
62
  export { fixedPathFor } from "./localize.ts";
63
+ export { addressMap, assertAddress, codeAddresses, collectionKey, lastPart, ownerCollection, pageKey, type Addresses, type CodeAddress } from "./addresses.ts";
64
+ export { anchorUrl, anchorsFor, candidates, fullPath, pickHit, type AnchorKey, type Anchors } from "./anchors.ts";
63
65
  export type { PageStore, PageRecord, PageLocale, PageVersion, PageScan, PageStoreOptions, CreatePageInput, AddLocaleOptions } from "./store.ts";
64
66
  export { pathOptions, pageErrors, localeRow, pageRefEdges, DEFAULT_LOCALES } from "./store.ts";
65
67
  export type { SmoodlyConfig, ResolvedConfig } from "./config.ts";
@@ -155,7 +157,7 @@ export declare const smoodly: {
155
157
  };
156
158
  export { NODE_ATTR, ZONE_ATTR, EMPTY_ATTR, BLANK_ATTR, FIELD_ATTR, ITEM_ATTR, EDITOR_ATTR, resolveMarkPath } from "./marks.ts";
157
159
  export type { MarkNode, MarkPath, Marks, Scope } from "./marks.ts";
158
- export { resolveSmoodlyEnv, smoodlyEnv, publicContentConfig, spaceIdOfKey } from "./env.ts";
160
+ export { resolveSmoodlyEnv, smoodlyEnv, publicContentConfig } from "./env.ts";
159
161
  export type { SmoodlyEnv, ContentConfig } from "./env.ts";
160
162
  export { clientOptions, createSmoodlyClient } from "./client.ts";
161
163
  export { createSmoodlySite, pageContextOf } from "./site.ts";
package/dist/index.js CHANGED
@@ -41,8 +41,10 @@ export { SupabasePathIndex } from "./supabase-path-index.js";
41
41
  export { entryErrors, compareEntries, DEFAULT_ENTRY_LOCALES, localizedKeys, splitFields, entryLocaleRow, entryFieldsIn, entryRefEdges, entryTitleIn, matchesWhere, } from "./entry-store.js";
42
42
  export { affectedTargets, pageTag, entryTag, globalTag, pagesTreeTag, collectionTag } from "./revalidate.js";
43
43
  export { resolveTree, resolveFields, resolveMedia } from "./resolve.js";
44
- export { SEGMENT_RE, isSegment, assertSegment, joinPath, splitPath, segmentFor, isHomeNode, pagePathIn, chainOf, assertDepth, pagePath, pagePathRows, entryPathRows, collectionSegment, buildPageTree, perLocaleSegments, assertSupportedLocale, unsupportedLocale, pageAlternates, entryAlternates, } from "./paths.js";
44
+ export { SEGMENT_RE, isSegment, assertSegment, slugify, joinPath, splitPath, segmentFor, isHomeNode, pagePathIn, chainOf, assertDepth, pagePath, pagePathRows, entryPathRows, collectionSegment, buildPageTree, perLocaleSegments, assertSupportedLocale, unsupportedLocale, pageAlternates, entryAlternates, } from "./paths.js";
45
45
  export { fixedPathFor } from "./localize.js";
46
+ export { addressMap, assertAddress, codeAddresses, collectionKey, lastPart, ownerCollection, pageKey } from "./addresses.js";
47
+ export { anchorUrl, anchorsFor, candidates, fullPath, pickHit } from "./anchors.js";
46
48
  export { pathOptions, pageErrors, localeRow, pageRefEdges, DEFAULT_LOCALES } from "./store.js";
47
49
  export { DEFAULT_TOOLSET } from "./toolset.js";
48
50
  export { collectionOrder, collectionDepth, DEFAULT_ORDER } from "./collections.js";
@@ -64,6 +66,6 @@ export const smoodly = {
64
66
  marks,
65
67
  };
66
68
  export { NODE_ATTR, ZONE_ATTR, EMPTY_ATTR, BLANK_ATTR, FIELD_ATTR, ITEM_ATTR, EDITOR_ATTR, resolveMarkPath } from "./marks.js";
67
- export { resolveSmoodlyEnv, smoodlyEnv, publicContentConfig, spaceIdOfKey } from "./env.js";
69
+ export { resolveSmoodlyEnv, smoodlyEnv, publicContentConfig } from "./env.js";
68
70
  export { clientOptions, createSmoodlyClient } from "./client.js";
69
71
  export { createSmoodlySite, pageContextOf } from "./site.js";
@@ -13,7 +13,7 @@ import { EditorBridge } from "../admin/next/bridge.js";
13
13
  import { CanvasBoundary } from "./canvas-boundary.js";
14
14
  import { DRAFT_META_NAME } from "../admin/editor/canvas-diagnosis.js";
15
15
  import { fixedPathFor } from "../localize.js";
16
- import { joinPath } from "../paths.js";
16
+ import { decodeSegment, joinPath } from "../paths.js";
17
17
  import { renderPage } from "../render.js";
18
18
  import { entryTag, pageTag, globalTag } from "../revalidate.js";
19
19
  import { pageContextOf } from "../site.js";
@@ -137,7 +137,7 @@ export function createPageRenderer(options) {
137
137
  /** Resolved AFTER connection(), inside page()/metadata(), so a wiring
138
138
  * error surfaces as a rejected promise like every other failure. */
139
139
  const fixedPath = (Page) => (locale) => fixedPathFor(Page.schema, locale, site.locales, site.homePageSlug);
140
- const literal = (segments) => () => joinPath(segments);
140
+ const literal = (segments) => () => joinPath(segments.map(decodeSegment));
141
141
  /** A global item for a layout: cached per (item, locale) under the
142
142
  * global tag outside draft mode, the live draft inside it. */
143
143
  async function getSmoodlyGlobal(item, opts) {
@@ -1,4 +1,5 @@
1
1
  import type { PathRow, PathTarget } from "./paths.ts";
2
+ import { type Anchors } from "./anchors.ts";
2
3
  export type { PathRow, PathTarget } from "./paths.ts";
3
4
  /** A rewrite target; `redirectLocales` names the locales whose old live rows stay as redirects (none = drop them). */
4
5
  export type RewriteTarget = PathTarget & {
@@ -27,14 +28,24 @@ export interface PathIndex {
27
28
  export declare const pathTakenError: (locale: string, path: string) => Error;
28
29
  export declare const pathLiveError: (locale: string, path: string) => Error;
29
30
  export declare class MemoryPathIndex implements PathIndex {
31
+ private anchors;
30
32
  private rows;
33
+ /** Held by reference: the config's addresses, read on every call (spec 2026-09-29 anchored paths §2). */
34
+ constructor(anchors?: Anchors);
35
+ /** A stored row as callers see it: the full URL, or nothing when its anchor has no address. */
36
+ private shown;
37
+ private shownAll;
31
38
  rewrite(targets: RewriteTarget[], rows: PathRow[]): Promise<void>;
32
39
  remove(targets: PathTarget[]): Promise<void>;
40
+ private hit;
33
41
  resolve(locale: string, path: string): Promise<PathHit | null>;
34
42
  pathsOf(target: PathTarget): Promise<Record<string, string>>;
35
43
  redirectsOf(target: PathTarget): Promise<PathRow[]>;
44
+ /** Every live URL in the locale; an editor's row under a code address is left out, since visitors never reach it (spec §4). */
36
45
  listLive(locale: string): Promise<PathRow[]>;
37
46
  listRedirects(locale: string): Promise<PathRow[]>;
47
+ /** A redirect by hand stays absolute (anchor ''): refused where the URL resolves live; a redirect there is replaced. */
38
48
  addRedirect(row: PathRow): Promise<void>;
49
+ /** Deletes the redirect rows a URL reaches, under any candidate anchor; a live row there is left alone. */
39
50
  removeRedirect(locale: string, path: string): Promise<void>;
40
51
  }
@@ -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
  }