smoodly 0.0.27 → 0.0.29

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 (96) hide show
  1. package/README.md +75 -3
  2. package/dist/admin/client-ops.js +2 -0
  3. package/dist/admin/drift-copy.d.ts +19 -0
  4. package/dist/admin/drift-copy.js +89 -0
  5. package/dist/admin/drift.d.ts +76 -0
  6. package/dist/admin/drift.js +264 -0
  7. package/dist/admin/editor/EditorView.js +70 -15
  8. package/dist/admin/editor/InlineRichtext.js +3 -1
  9. package/dist/admin/editor/Outline.d.ts +7 -0
  10. package/dist/admin/editor/Outline.js +8 -5
  11. package/dist/admin/editor/PageInspector.d.ts +9 -1
  12. package/dist/admin/editor/PageInspector.js +4 -2
  13. package/dist/admin/editor/PageSettings.d.ts +4 -1
  14. package/dist/admin/editor/PageSettings.js +33 -15
  15. package/dist/admin/editor/UnknownNode.d.ts +7 -0
  16. package/dist/admin/editor/UnknownNode.js +8 -0
  17. package/dist/admin/field-paths.d.ts +3 -0
  18. package/dist/admin/field-paths.js +26 -0
  19. package/dist/admin/forms/OrphanFields.d.ts +8 -0
  20. package/dist/admin/forms/OrphanFields.js +11 -0
  21. package/dist/admin/forms/RefPicker.js +6 -2
  22. package/dist/admin/forms/RichtextElementCard.js +9 -3
  23. package/dist/admin/forms/RichtextWidget.js +3 -1
  24. package/dist/admin/forms/TagsField.js +1 -1
  25. package/dist/admin/forms/richtext-form-context.d.ts +8 -0
  26. package/dist/admin/forms/richtext-form-context.js +5 -0
  27. package/dist/admin/health.d.ts +23 -0
  28. package/dist/admin/health.js +42 -0
  29. package/dist/admin/ops-impl.js +124 -14
  30. package/dist/admin/ops.d.ts +53 -1
  31. package/dist/admin/redirect-path.d.ts +6 -0
  32. package/dist/admin/redirect-path.js +33 -0
  33. package/dist/admin/shell/AddRedirectModal.d.ts +11 -0
  34. package/dist/admin/shell/AddRedirectModal.js +29 -0
  35. package/dist/admin/shell/AdminApp.js +2 -3
  36. package/dist/admin/shell/ContentHealthPage.d.ts +10 -0
  37. package/dist/admin/shell/ContentHealthPage.js +37 -0
  38. package/dist/admin/shell/EntriesList.js +4 -4
  39. package/dist/admin/shell/EntryForm.js +32 -7
  40. package/dist/admin/shell/ErrorPane.d.ts +4 -2
  41. package/dist/admin/shell/ErrorPane.js +5 -4
  42. package/dist/admin/shell/GlobalForm.js +22 -2
  43. package/dist/admin/shell/IconRail.d.ts +1 -1
  44. package/dist/admin/shell/IconRail.js +3 -2
  45. package/dist/admin/shell/PagesList.js +9 -7
  46. package/dist/admin/shell/RedirectsPage.d.ts +11 -0
  47. package/dist/admin/shell/RedirectsPage.js +75 -0
  48. package/dist/admin/shell/SettingsView.d.ts +4 -2
  49. package/dist/admin/shell/SettingsView.js +22 -14
  50. package/dist/admin/shell/health-list.d.ts +27 -0
  51. package/dist/admin/shell/health-list.js +47 -0
  52. package/dist/admin/shell/live-url.d.ts +10 -2
  53. package/dist/admin/shell/live-url.js +9 -4
  54. package/dist/admin/shell/members-list.js +1 -1
  55. package/dist/admin/shell/redirects-list.d.ts +12 -0
  56. package/dist/admin/shell/redirects-list.js +15 -0
  57. package/dist/admin/shell/settings.d.ts +16 -3
  58. package/dist/admin/shell/settings.js +16 -9
  59. package/dist/admin/tree-ops.d.ts +4 -0
  60. package/dist/admin/tree-ops.js +15 -1
  61. package/dist/admin/ui/Combobox.d.ts +8 -2
  62. package/dist/admin/ui/Combobox.js +48 -16
  63. package/dist/admin/ui/ConfirmDialog.d.ts +1 -1
  64. package/dist/admin/ui/ConfirmDialog.js +5 -4
  65. package/dist/admin/ui/DriftNotice.d.ts +6 -0
  66. package/dist/admin/ui/DriftNotice.js +10 -0
  67. package/dist/admin/ui/combo-box.d.ts +13 -0
  68. package/dist/admin/ui/combo-box.js +8 -0
  69. package/dist/admin/ui/confirm.d.ts +21 -2
  70. package/dist/admin/ui/confirm.js +26 -9
  71. package/dist/admin/ui/theme.js +32 -3
  72. package/dist/admin/validate.js +15 -47
  73. package/dist/entry-store.d.ts +31 -6
  74. package/dist/entry-store.js +39 -11
  75. package/dist/index.d.ts +2 -2
  76. package/dist/next/page-renderer.js +28 -9
  77. package/dist/path-index.d.ts +28 -4
  78. package/dist/path-index.js +37 -4
  79. package/dist/paths.d.ts +16 -0
  80. package/dist/paths.js +9 -0
  81. package/dist/render.js +7 -2
  82. package/dist/schema-version.d.ts +1 -1
  83. package/dist/schema-version.js +1 -1
  84. package/dist/site.d.ts +4 -1
  85. package/dist/site.js +3 -1
  86. package/dist/sql.js +19 -1
  87. package/dist/store.d.ts +39 -5
  88. package/dist/store.js +32 -8
  89. package/dist/supabase-entry-store.d.ts +9 -3
  90. package/dist/supabase-entry-store.js +39 -13
  91. package/dist/supabase-path-index.d.ts +10 -3
  92. package/dist/supabase-path-index.js +50 -17
  93. package/dist/supabase-store.d.ts +15 -3
  94. package/dist/supabase-store.js +42 -8
  95. package/migrations/20260926150000_smoodly_v4.sql +41 -0
  96. package/package.json +1 -1
@@ -1,5 +1,6 @@
1
- import { pathTakenError } from "./path-index.js";
1
+ import { pathLiveError, pathTakenError } from "./path-index.js";
2
2
  const UNIQUE_VIOLATION = "23505";
3
+ const toRow = (r) => ({ kind: r.kind, id: r.target_id, locale: r.locale, path: r.path });
3
4
  export class SupabasePathIndex {
4
5
  constructor(db) {
5
6
  this.db = db;
@@ -9,7 +10,7 @@ export class SupabasePathIndex {
9
10
  }
10
11
  async rewrite(targets, rows) {
11
12
  const res = await this.db.rpc("smoodly_rewrite_paths", {
12
- p_targets: targets.map((t) => ({ kind: t.kind, id: t.id })),
13
+ p_targets: targets.map((t) => ({ kind: t.kind, id: t.id, ...(t.redirectLocales ? { redirect_locales: [...t.redirectLocales] } : {}) })),
13
14
  p_rows: rows.map((r) => ({ locale: r.locale, path: r.path, kind: r.kind, id: r.id })),
14
15
  });
15
16
  if (res.error) {
@@ -22,31 +23,63 @@ export class SupabasePathIndex {
22
23
  }
23
24
  }
24
25
  async remove(targets) {
25
- await this.rewrite(targets, []);
26
+ // Every row of the targets, redirects included: a delete on the target index, not the RPC (which spares redirect rows).
27
+ for (const t of targets) {
28
+ const res = await this.db.from("paths").delete().eq("kind", t.kind).eq("target_id", t.id);
29
+ if (res.error)
30
+ this.fail(res.error.message);
31
+ }
26
32
  }
27
33
  async resolve(locale, path) {
28
- const res = await this.db
29
- .from("paths")
30
- .select("kind, target_id")
31
- .eq("locale", locale)
32
- .eq("path", path)
33
- .maybeSingle();
34
+ const res = await this.db.from("paths").select("kind, target_id, redirect").eq("locale", locale).eq("path", path).maybeSingle();
34
35
  if (res.error)
35
36
  this.fail(res.error.message);
36
37
  const row = res.data;
37
- return row ? { kind: row.kind, id: row.target_id } : null;
38
+ return row ? { kind: row.kind, id: row.target_id, redirect: row.redirect } : null;
38
39
  }
39
- async pathsOf(target) {
40
- const res = await this.db
41
- .from("paths")
42
- .select("locale, path")
43
- .eq("kind", target.kind)
44
- .eq("target_id", target.id);
40
+ async rowsOf(target, redirect) {
41
+ const res = await this.db.from("paths").select("locale, path, kind, target_id").eq("kind", target.kind).eq("target_id", target.id).eq("redirect", redirect);
42
+ if (res.error)
43
+ this.fail(res.error.message);
44
+ return res.data.map(toRow);
45
+ }
46
+ async rowsIn(locale, redirect) {
47
+ const res = await this.db.from("paths").select("locale, path, kind, target_id").eq("locale", locale).eq("redirect", redirect).order("path");
45
48
  if (res.error)
46
49
  this.fail(res.error.message);
50
+ return res.data.map(toRow);
51
+ }
52
+ async pathsOf(target) {
47
53
  const out = {};
48
- for (const r of res.data)
54
+ for (const r of await this.rowsOf(target, false))
49
55
  out[r.locale] = r.path;
50
56
  return out;
51
57
  }
58
+ redirectsOf(target) {
59
+ return this.rowsOf(target, true);
60
+ }
61
+ listLive(locale) {
62
+ return this.rowsIn(locale, false);
63
+ }
64
+ listRedirects(locale) {
65
+ return this.rowsIn(locale, true);
66
+ }
67
+ async addRedirect(row) {
68
+ const existing = await this.resolve(row.locale, row.path);
69
+ if (existing && !existing.redirect)
70
+ throw pathLiveError(row.locale, row.path);
71
+ if (existing)
72
+ await this.removeRedirect(row.locale, row.path);
73
+ const res = await this.db.from("paths").insert({ locale: row.locale, path: row.path, kind: row.kind, target_id: row.id, redirect: true });
74
+ if (res.error) {
75
+ if (res.error.code === UNIQUE_VIOLATION)
76
+ throw pathLiveError(row.locale, row.path);
77
+ this.fail(res.error.message);
78
+ }
79
+ }
80
+ async removeRedirect(locale, path) {
81
+ const res = await this.db.from("paths").delete().eq("locale", locale).eq("path", path).eq("redirect", true);
82
+ if (res.error)
83
+ this.fail(res.error.message);
84
+ }
52
85
  }
@@ -1,6 +1,7 @@
1
1
  import type { SupabaseClient } from "@supabase/supabase-js";
2
- import { type AddLocaleOptions, type CreatePageInput, type PageRecord, type PageStore, type PageStoreOptions, type PageVersion } from "./store.ts";
2
+ import { type AddLocaleOptions, type CreatePageInput, type PageRecord, type PageStore, type PageStoreOptions, type PageVersion, type PageScan } from "./store.ts";
3
3
  import type { PageTree } from "./render.tsx";
4
+ import type { PathRow } from "./path-index.ts";
4
5
  import { type DraftToken } from "./draft-token.ts";
5
6
  import { type PathTarget } from "./paths.ts";
6
7
  export declare class SupabasePageStore implements PageStore {
@@ -57,22 +58,33 @@ export declare class SupabasePageStore implements PageStore {
57
58
  private hardDelete;
58
59
  createPage(input: CreatePageInput, by?: string): Promise<PageRecord>;
59
60
  getPage(id: string): Promise<PageRecord | null>;
61
+ scanTrees(): Promise<PageScan[]>;
60
62
  listPages(): Promise<PageRecord[]>;
61
63
  /** The site's one warning per process (spec 2026-09-26 §5): fired by the first locate, never blocking. */
62
64
  private schemaChecked;
63
- resolvePath(locale: string, path: string): Promise<PathTarget | null>;
65
+ resolvePath(locale: string, path: string): Promise<import("./path-index.ts").PathHit | null>;
64
66
  schemaVersion(): Promise<number | null>;
65
67
  pathsOf(id: string): Promise<{}>;
68
+ targetPaths(target: PathTarget): Promise<Record<string, string>>;
69
+ redirectsOf(id: string): Promise<Record<string, string[]>>;
70
+ listLive(locale: string): Promise<PathRow[]>;
71
+ listRedirects(locale: string): Promise<PathRow[]>;
72
+ addRedirect(row: PathRow): Promise<void>;
73
+ removeRedirect(locale: string, path: string): Promise<void>;
66
74
  addLocale(id: string, locale: string, options: AddLocaleOptions, by?: string): Promise<PageRecord>;
67
75
  removeLocale(id: string, locale: string): Promise<PageRecord>;
68
76
  rename(id: string, patch: {
69
77
  locale: string;
70
78
  slug?: string;
71
79
  title?: string;
72
- }, by?: string): Promise<PageRecord>;
80
+ }, by?: string, options?: {
81
+ redirect?: boolean;
82
+ }): Promise<PageRecord>;
73
83
  move(id: string, to: {
74
84
  parentId: string | null;
75
85
  index?: number;
86
+ }, options?: {
87
+ redirect?: boolean;
76
88
  }): Promise<PageRecord>;
77
89
  setTemplate(id: string, template: string, by?: string): Promise<PageRecord>;
78
90
  saveDraft(id: string, locale: string, tree: PageTree, by?: string, expected?: DraftToken | null): Promise<PageVersion>;
@@ -9,8 +9,9 @@
9
9
  import { assertPublishable, assertSupportedLocale, localeRow, pageErrors, pageRefEdges, pathOptions, refSections, } from "./store.js";
10
10
  import { SupabasePathIndex } from "./supabase-path-index.js";
11
11
  import { SCHEMA_VERSION_KEY, schemaBehind, schemaBehindMessage } from "./schema-version.js";
12
+ import { groupRedirects } from "./store.js";
12
13
  import { assertFresh, draftToken, StaleDraftError } from "./draft-token.js";
13
- import { assertDepth, assertSegment, isHomeNode, pagePathRows } from "./paths.js";
14
+ import { assertDepth, assertSegment, isHomeNode, pagePathRows, rewriteTargets } from "./paths.js";
14
15
  const SELECT = "*, page_locales(*, draft:page_versions!page_locales_draft_version_fkey(saved_at))";
15
16
  const UNIQUE_VIOLATION = "23505";
16
17
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
@@ -121,11 +122,11 @@ export class SupabasePageStore {
121
122
  }
122
123
  /** Rewrite path rows for a subtree: ancestors are loaded once so the
123
124
  * chain lookup is a map, then ONE RPC replaces every target's rows. */
124
- async writePaths(top) {
125
+ async writePaths(top, redirect = true) {
125
126
  const ancestors = await this.chain(top);
126
127
  const nodes = await this.subtree(top);
127
128
  const byId = new Map([...ancestors, ...nodes].map((n) => [n.id, n]));
128
- await this.paths.rewrite(nodes.map((n) => ({ kind: "page", id: n.id })), pagePathRows(nodes, (id) => byId.get(id), this.opts));
129
+ await this.paths.rewrite(rewriteTargets("page", nodes, redirect), pagePathRows(nodes, (id) => byId.get(id), this.opts));
129
130
  }
130
131
  /** `touch` defaults to true (the normal edit path bumps updated_at).
131
132
  * A rollback restoring prior column values passes `{ touch: false }`,
@@ -321,6 +322,33 @@ export class SupabasePageStore {
321
322
  getPage(id) {
322
323
  return this.row(id);
323
324
  }
325
+ async scanTrees() {
326
+ const records = await this.listPages();
327
+ const ids = new Set();
328
+ for (const r of records)
329
+ for (const row of Object.values(r.locales)) {
330
+ if (row.draftVersionId)
331
+ ids.add(row.draftVersionId);
332
+ if (row.publishedVersionId)
333
+ ids.add(row.publishedVersionId);
334
+ }
335
+ // In chunks: one in() over hundreds of ids would overflow the request URL.
336
+ const trees = new Map();
337
+ const all = [...ids];
338
+ for (let i = 0; i < all.length; i += 100) {
339
+ const res = await this.db.from("page_versions").select("id, tree").in("id", all.slice(i, i + 100));
340
+ if (res.error)
341
+ this.fail(res.error.message);
342
+ for (const v of res.data)
343
+ trees.set(v.id, v.tree);
344
+ }
345
+ const tree = (id) => (id ? trees.get(id) ?? null : null);
346
+ return records.map((record) => ({
347
+ record,
348
+ draft: Object.fromEntries(Object.entries(record.locales).map(([l, row]) => [l, tree(row.draftVersionId)])),
349
+ live: Object.fromEntries(Object.entries(record.locales).map(([l, row]) => [l, tree(row.publishedVersionId)])),
350
+ }));
351
+ }
324
352
  async listPages() {
325
353
  const res = await this.db
326
354
  .from("pages")
@@ -358,6 +386,12 @@ export class SupabasePageStore {
358
386
  pathsOf(id) {
359
387
  return UUID_RE.test(id) ? this.paths.pathsOf({ kind: "page", id }) : Promise.resolve({});
360
388
  }
389
+ targetPaths(target) { return this.paths.pathsOf(target); }
390
+ async redirectsOf(id) { return UUID_RE.test(id) ? groupRedirects(await this.paths.redirectsOf({ kind: "page", id })) : {}; }
391
+ listLive(locale) { return this.paths.listLive(locale); }
392
+ listRedirects(locale) { return this.paths.listRedirects(locale); }
393
+ addRedirect(row) { return this.paths.addRedirect(row); }
394
+ removeRedirect(locale, path) { return this.paths.removeRedirect(locale, path); }
361
395
  async addLocale(id, locale, options, by) {
362
396
  assertSupportedLocale(locale, this.opts.locales);
363
397
  const page = await this.must(id);
@@ -403,11 +437,11 @@ export class SupabasePageStore {
403
437
  // content — until the next write of this node fixes it.
404
438
  await this.deleteLocale(id, locale);
405
439
  const remaining = await this.must(id);
406
- await this.writePaths(remaining);
440
+ await this.writePaths(remaining, false);
407
441
  await this.writeRefs(remaining);
408
442
  return remaining;
409
443
  }
410
- async rename(id, patch, by) {
444
+ async rename(id, patch, by, options) {
411
445
  const page = await this.must(id);
412
446
  const row = localeRow(page, patch.locale);
413
447
  if (patch.slug !== undefined)
@@ -431,7 +465,7 @@ export class SupabasePageStore {
431
465
  columns.title = patch.title;
432
466
  const renamed = await this.updateLocale(id, patch.locale, columns, { by });
433
467
  try {
434
- await this.writePaths(renamed);
468
+ await this.writePaths(renamed, options?.redirect ?? true);
435
469
  }
436
470
  catch (e) {
437
471
  // The audit pair goes back too: a rejected rename is not an edit.
@@ -440,7 +474,7 @@ export class SupabasePageStore {
440
474
  }
441
475
  return renamed;
442
476
  }
443
- async move(id, to) {
477
+ async move(id, to, options) {
444
478
  const page = await this.must(id);
445
479
  const subtree = await this.subtree(page);
446
480
  if (to.parentId !== null) {
@@ -485,7 +519,7 @@ export class SupabasePageStore {
485
519
  ordered.splice(index, 0, id);
486
520
  try {
487
521
  await this.reorder(ordered);
488
- await this.writePaths(moved);
522
+ await this.writePaths(moved, options?.redirect ?? true);
489
523
  }
490
524
  catch (e) {
491
525
  await this.updateNode(id, { parent_id: page.parentId, sort: page.sort }, { touch: false });
@@ -0,0 +1,41 @@
1
+ -- Smoodly core schema, version 4 (docs/superpowers/specs/2026-09-26-redirects-design.md).
2
+ -- Shipped in the smoodly package; `npx smoodly migrate` copies it into supabase/migrations/.
3
+ -- Never edit a published file: the next change is the next file.
4
+ -- The delta over version 3: a `redirect` flag on the paths index — a row
5
+ -- with it set is an old address of its target — and the rewrite function
6
+ -- keeps the published old rows as redirects when a target names their
7
+ -- locales. The read policy is unchanged: a redirect row is visible when
8
+ -- its target is published in that locale.
9
+
10
+ set client_min_messages = warning;
11
+
12
+ alter table "paths" add column if not exists "redirect" boolean not null default false;
13
+
14
+ create or replace function "smoodly_rewrite_paths"(p_targets jsonb, p_rows jsonb) returns void language sql set search_path = public as $$
15
+ delete from "paths" p
16
+ using jsonb_to_recordset(p_rows) as r(locale text, path text, kind text, id uuid)
17
+ where p."redirect" and p."locale" = r.locale and p."path" = r.path
18
+ and p."space_id" = "smoodly_current_space"();
19
+ update "paths" p set "redirect" = true
20
+ from jsonb_to_recordset(p_targets) as t(kind text, id uuid, redirect_locales text[])
21
+ where not p."redirect" and p."kind" = t.kind and p."target_id" = t.id
22
+ and p."locale" = any (coalesce(t.redirect_locales, '{}'::text[]))
23
+ and not exists (select 1 from jsonb_to_recordset(p_rows) as r(locale text, path text, kind text, id uuid)
24
+ where r.locale = p."locale" and r.path = p."path")
25
+ and p."space_id" = "smoodly_current_space"();
26
+ delete from "paths" p
27
+ using jsonb_to_recordset(p_targets) as t(kind text, id uuid, redirect_locales text[])
28
+ where p."kind" = t.kind and p."target_id" = t.id
29
+ 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)
30
+ where r.kind = p."kind" and r.id = p."target_id" and r.locale = p."locale"))
31
+ and p."space_id" = "smoodly_current_space"();
32
+ insert into "paths" ("locale", "path", "kind", "target_id")
33
+ select r.locale, r.path, r.kind, r.id
34
+ from jsonb_to_recordset(p_rows) as r(locale text, path text, kind text, id uuid);
35
+ $$;
36
+
37
+ alter function "smoodly_rewrite_paths"(jsonb, jsonb) set search_path = public;
38
+ revoke execute on function "smoodly_rewrite_paths"(jsonb, jsonb) from public, anon;
39
+
40
+ insert into "smoodly_meta" ("key", "value") values ('schema_version', '4')
41
+ on conflict ("key") do update set "value" = excluded."value";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "smoodly",
3
- "version": "0.0.27",
3
+ "version": "0.0.29",
4
4
  "description": "Open-source, code-first CMS for Next.js on Supabase: define a component once, it becomes a typed, editable building block.",
5
5
  "license": "MIT",
6
6
  "repository": {