smoodly 0.0.28 → 0.0.30

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 (98) hide show
  1. package/README.md +123 -17
  2. package/dist/admin/client-ops.js +1 -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/CanvasWarning.d.ts +5 -0
  8. package/dist/admin/editor/CanvasWarning.js +10 -0
  9. package/dist/admin/editor/EditorView.js +113 -26
  10. package/dist/admin/editor/InlineRichtext.js +3 -1
  11. package/dist/admin/editor/Outline.d.ts +7 -0
  12. package/dist/admin/editor/Outline.js +8 -5
  13. package/dist/admin/editor/PageInspector.d.ts +6 -1
  14. package/dist/admin/editor/PageInspector.js +4 -2
  15. package/dist/admin/editor/PageSettings.js +26 -16
  16. package/dist/admin/editor/UnknownNode.d.ts +7 -0
  17. package/dist/admin/editor/UnknownNode.js +8 -0
  18. package/dist/admin/editor/canvas-diagnosis.d.ts +49 -0
  19. package/dist/admin/editor/canvas-diagnosis.js +106 -0
  20. package/dist/admin/editor/crash-copy.d.ts +7 -0
  21. package/dist/admin/editor/crash-copy.js +13 -0
  22. package/dist/admin/editor/protocol.d.ts +2 -0
  23. package/dist/admin/editor/useCanvasBridge.d.ts +3 -0
  24. package/dist/admin/editor/useCanvasBridge.js +14 -7
  25. package/dist/admin/editor/useCanvasDiagnosis.d.ts +7 -0
  26. package/dist/admin/editor/useCanvasDiagnosis.js +24 -0
  27. package/dist/admin/field-paths.d.ts +3 -0
  28. package/dist/admin/field-paths.js +26 -0
  29. package/dist/admin/forms/OrphanFields.d.ts +8 -0
  30. package/dist/admin/forms/OrphanFields.js +11 -0
  31. package/dist/admin/forms/RefPicker.js +6 -2
  32. package/dist/admin/forms/RichtextElementCard.js +9 -3
  33. package/dist/admin/forms/RichtextWidget.js +3 -1
  34. package/dist/admin/forms/TagsField.js +1 -1
  35. package/dist/admin/forms/richtext-form-context.d.ts +8 -0
  36. package/dist/admin/forms/richtext-form-context.js +5 -0
  37. package/dist/admin/health.d.ts +23 -0
  38. package/dist/admin/health.js +42 -0
  39. package/dist/admin/index.d.ts +1 -0
  40. package/dist/admin/next/bridge.js +4 -2
  41. package/dist/admin/next/index.d.ts +0 -1
  42. package/dist/admin/next/index.js +6 -1
  43. package/dist/admin/ops-impl.js +40 -9
  44. package/dist/admin/ops.d.ts +13 -0
  45. package/dist/admin/shell/AddRedirectModal.js +1 -1
  46. package/dist/admin/shell/ContentHealthPage.d.ts +10 -0
  47. package/dist/admin/shell/ContentHealthPage.js +37 -0
  48. package/dist/admin/shell/EntriesList.js +4 -4
  49. package/dist/admin/shell/EntryForm.js +20 -3
  50. package/dist/admin/shell/ErrorPane.d.ts +4 -2
  51. package/dist/admin/shell/ErrorPane.js +5 -4
  52. package/dist/admin/shell/GlobalForm.js +22 -2
  53. package/dist/admin/shell/PagesList.js +3 -3
  54. package/dist/admin/shell/SettingsView.js +6 -2
  55. package/dist/admin/shell/health-list.d.ts +27 -0
  56. package/dist/admin/shell/health-list.js +47 -0
  57. package/dist/admin/shell/settings.d.ts +4 -0
  58. package/dist/admin/shell/settings.js +5 -2
  59. package/dist/admin/tree-ops.d.ts +4 -0
  60. package/dist/admin/tree-ops.js +15 -1
  61. package/dist/admin/ui/ActionMenu.d.ts +20 -0
  62. package/dist/admin/ui/ActionMenu.js +33 -7
  63. package/dist/admin/ui/Combobox.d.ts +8 -2
  64. package/dist/admin/ui/Combobox.js +48 -16
  65. package/dist/admin/ui/ConfirmDialog.js +3 -3
  66. package/dist/admin/ui/DriftNotice.d.ts +6 -0
  67. package/dist/admin/ui/DriftNotice.js +10 -0
  68. package/dist/admin/ui/combo-box.d.ts +13 -0
  69. package/dist/admin/ui/combo-box.js +8 -0
  70. package/dist/admin/ui/confirm.d.ts +8 -0
  71. package/dist/admin/ui/confirm.js +17 -4
  72. package/dist/admin/ui/theme.js +29 -3
  73. package/dist/admin/validate.js +15 -47
  74. package/dist/custom/CustomSection.d.ts +1 -0
  75. package/dist/custom/CustomSection.js +4 -1
  76. package/dist/entry-store.d.ts +13 -0
  77. package/dist/entry-store.js +26 -3
  78. package/dist/index.d.ts +2 -2
  79. package/dist/marks.d.ts +12 -0
  80. package/dist/marks.js +14 -0
  81. package/dist/next/canvas-boundary.d.ts +16 -0
  82. package/dist/next/canvas-boundary.js +25 -0
  83. package/dist/next/page-renderer.d.ts +3 -1
  84. package/dist/next/page-renderer.js +33 -4
  85. package/dist/pairing.js +10 -3
  86. package/dist/render.d.ts +12 -1
  87. package/dist/render.js +15 -4
  88. package/dist/section-crash.d.ts +11 -0
  89. package/dist/section-crash.js +16 -0
  90. package/dist/section-guard.d.ts +27 -0
  91. package/dist/section-guard.js +41 -0
  92. package/dist/store.d.ts +9 -0
  93. package/dist/store.js +8 -0
  94. package/dist/supabase-entry-store.d.ts +2 -1
  95. package/dist/supabase-entry-store.js +27 -5
  96. package/dist/supabase-store.d.ts +2 -1
  97. package/dist/supabase-store.js +27 -0
  98. package/package.json +1 -1
@@ -0,0 +1,41 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { CrashPlaceholder } from "./section-crash.js";
3
+ /** The paired component's own view call, set by pair() and the custom section — only for a plain-function view. */
4
+ export const RENDER_VIEW = Symbol("smoodly.renderView");
5
+ /** A view the guard may call: a plain function — not a client reference or memo/forwardRef ($$typeof), not a class. */
6
+ export function isPlainView(View) {
7
+ if (typeof View !== "function")
8
+ return false;
9
+ const v = View;
10
+ return v.$$typeof === undefined && !v.prototype?.isReactComponent;
11
+ }
12
+ /** The placeholder shows in the canvas, and under `next dev` so a developer never misses their own crash. */
13
+ const placeholderShown = (editor) => editor || process.env.NODE_ENV === "development";
14
+ function failed(error, ctx) {
15
+ ctx.rethrow?.(error); // notFound(), redirects and bailouts are control flow, not crashes
16
+ console.error(`smoodly: section "${ctx.type}" (node ${ctx.id}) failed to render and is left out of the page.`, error);
17
+ return placeholderShown(ctx.editor) ? _jsx(CrashPlaceholder, { id: ctx.id, title: ctx.title, error: error, live: "section" }) : null;
18
+ }
19
+ const isThenable = (v) => typeof v === "object" && v !== null && typeof v.then === "function";
20
+ /** A section through the guard when its component offers the view call; any other component as an element, unguarded. */
21
+ export function renderGuarded(Component, props, ctx) {
22
+ const render = Component[RENDER_VIEW];
23
+ if (!render)
24
+ return _jsx(Component, { ...props });
25
+ try {
26
+ const out = render(props);
27
+ if (!isThenable(out))
28
+ return out;
29
+ // Settled up front, so a rejection is never unhandled while React gets to the guard.
30
+ const settled = Promise.resolve(out).then((value) => ({ ok: true, value }), (error) => ({ ok: false, error }));
31
+ return _jsx(SectionGuard, { settled: settled, ctx: ctx });
32
+ }
33
+ catch (error) {
34
+ return failed(error, ctx);
35
+ }
36
+ }
37
+ /** An async section, awaited in place — no <Suspense>, so it renders where it always did. */
38
+ export async function SectionGuard({ settled, ctx }) {
39
+ const result = await settled;
40
+ return result.ok ? result.value : failed(result.error, ctx);
41
+ }
package/dist/store.d.ts CHANGED
@@ -52,10 +52,18 @@ export type PageVersion = {
52
52
  tree: PageTree;
53
53
  savedAt: number;
54
54
  };
55
+ /** One page for the drift scan (spec 2026-09-27 editor safety §3): locale → draft and live tree; null where there is none. */
56
+ export type PageScan = {
57
+ record: PageRecord;
58
+ draft: Record<string, PageTree | null>;
59
+ live: Record<string, PageTree | null>;
60
+ };
55
61
  export interface PageStore {
56
62
  createPage(input: CreatePageInput, by?: string): Promise<PageRecord>;
57
63
  getPage(id: string): Promise<PageRecord | null>;
58
64
  listPages(): Promise<PageRecord[]>;
65
+ /** Every page with each locale's draft and live tree, in few reads (spec 2026-09-27 editor safety §3). */
66
+ scanTrees(): Promise<PageScan[]>;
59
67
  resolvePath(locale: string, path: string): Promise<PathHit | null>;
60
68
  /** The database's schema version from `smoodly_meta`; null when the table is missing (older than v1). Memory: always current (spec 2026-09-26 §5). */
61
69
  schemaVersion(): Promise<number | null>;
@@ -212,6 +220,7 @@ export declare class MemoryPageStore implements PageStore {
212
220
  private dropVersions;
213
221
  createPage(input: CreatePageInput, by?: string): Promise<PageRecord>;
214
222
  getPage(id: string): Promise<PageRecord | null>;
223
+ scanTrees(): Promise<PageScan[]>;
215
224
  listPages(): Promise<PageRecord[]>;
216
225
  resolvePath(locale: string, path: string): Promise<PathHit | null>;
217
226
  schemaVersion(): Promise<number>;
package/dist/store.js CHANGED
@@ -277,6 +277,14 @@ export class MemoryPageStore {
277
277
  const page = this.pages.get(id);
278
278
  return page ? this.snapshot(page) : null;
279
279
  }
280
+ async scanTrees() {
281
+ const tree = (id) => (id ? structuredClone(this.versions.get(id)?.tree ?? null) : null);
282
+ return (await this.listPages()).map((record) => ({
283
+ record,
284
+ draft: Object.fromEntries(Object.entries(record.locales).map(([l, row]) => [l, tree(row.draftVersionId)])),
285
+ live: Object.fromEntries(Object.entries(record.locales).map(([l, row]) => [l, tree(row.publishedVersionId)])),
286
+ }));
287
+ }
280
288
  async listPages() {
281
289
  return [...this.pages.values()]
282
290
  // null sorts last; the SQL equivalent is ORDER BY sort ASC NULLS LAST, created_at ASC.
@@ -1,6 +1,6 @@
1
1
  import type { SupabaseClient } from "@supabase/supabase-js";
2
2
  import type { CollectionSchema } from "./collections.ts";
3
- import type { AddEntryLocaleOptions, CreateEntryOptions, EntryRecord, EntryStore, EntryStoreOptions, EntryUsage, EntryVersion, ListOptions } from "./entry-store.ts";
3
+ import type { AddEntryLocaleOptions, CreateEntryOptions, EntryRecord, EntryStore, EntryStoreOptions, EntryScan, EntryUsage, EntryVersion, ListOptions } from "./entry-store.ts";
4
4
  import { type DraftToken } from "./draft-token.ts";
5
5
  export declare class SupabaseEntryStore implements EntryStore {
6
6
  private db;
@@ -68,6 +68,7 @@ export declare class SupabaseEntryStore implements EntryStore {
68
68
  discardDraft(collection: string, id: string, locale: string, by?: string, expected?: DraftToken | null): Promise<EntryRecord>;
69
69
  addLocale(collection: string, id: string, locale: string, options: AddEntryLocaleOptions, by?: string): Promise<EntryRecord>;
70
70
  removeLocale(collection: string, id: string, locale: string): Promise<EntryRecord>;
71
+ scanEntries(): Promise<EntryScan[]>;
71
72
  listVersions(collection: string, id: string, locale: string): Promise<EntryVersion[]>;
72
73
  restore(collection: string, id: string, locale: string, versionId: string, by?: string, expected?: DraftToken | null): Promise<EntryRecord>;
73
74
  getVersion(collection: string, versionId: string): Promise<EntryVersion | null>;
@@ -7,7 +7,7 @@
7
7
  // calls the smoodly_reorder SQL function: one statement, updated_at
8
8
  // untouched.
9
9
  import { collectionDepth, collectionOrder } from "./collections.js";
10
- import { ARRAY_FIELD_TYPES, DEFAULT_ENTRY_LOCALES, entryErrors, entryFieldsIn, entryLocaleRow, entryRefEdges, entryTitleIn, isGlobalSchema, schemaOf, splitFields, } from "./entry-store.js";
10
+ import { ARRAY_FIELD_TYPES, DEFAULT_ENTRY_LOCALES, entryErrors, entryFieldsIn, entryLocaleRow, entryRefEdges, entryTitleIn, isGlobalSchema, keepLocalizedShared, schemaOf, splitFields, } from "./entry-store.js";
11
11
  import { globalEntryId } from "./global-id.js";
12
12
  import { SupabasePathIndex } from "./supabase-path-index.js";
13
13
  import { groupRedirects } from "./store.js";
@@ -473,11 +473,12 @@ export class SupabaseEntryStore {
473
473
  if (isGlobalSchema(schema) && slug !== null)
474
474
  throw entryErrors.noSlug(collection);
475
475
  await this.assertSiblingSlug(collection, before.parentId, locale, slug, id);
476
+ const nodeFields = keepLocalizedShared(before.fields, shared, schema);
476
477
  // The snapshot of what the row is about to hold, written under the guard BEFORE the live
477
478
  // columns change (spec 2026-09-26 concurrency §3): a stale save leaves nothing behind.
478
- const next = { ...before, fields: shared, locales: { ...before.locales, [locale]: { ...row, fields: localized, slug } } };
479
+ const next = { ...before, fields: nodeFields, locales: { ...before.locales, [locale]: { ...row, fields: localized, slug } } };
479
480
  const { created } = await this.recordVersion(next, locale, expected !== undefined);
480
- await this.updateNode(id, { fields: shared });
481
+ await this.updateNode(id, { fields: nodeFields });
481
482
  await this.updateLocale(id, locale, { slug, fields: localized }, { by });
482
483
  const record = await this.must(collection, id);
483
484
  try {
@@ -535,7 +536,7 @@ export class SupabaseEntryStore {
535
536
  const matched = await this.guardedUpdateLocale(id, locale, { fields: localized, draft_version_id: row.publishedVersionId }, { by, guard: expected !== undefined ? row.draftVersionId : undefined });
536
537
  if (!matched)
537
538
  throw await this.stale(collection, id, locale);
538
- await this.updateNode(id, { fields: shared });
539
+ await this.updateNode(id, { fields: keepLocalizedShared(record.fields, shared, schema) });
539
540
  if (row.draftVersionId && row.draftVersionId !== row.publishedVersionId) {
540
541
  // The pointer moved first, so a failure here leaves a stray row, never a dangling pointer.
541
542
  const gone = await this.db.from("entry_versions").delete().eq("id", row.draftVersionId);
@@ -598,6 +599,26 @@ export class SupabaseEntryStore {
598
599
  await this.writeRefs(final);
599
600
  return final;
600
601
  }
602
+ async scanEntries() {
603
+ const res = await this.db.from("entries").select(SELECT).order("created_at", { ascending: true }).order("id", { ascending: true });
604
+ if (res.error)
605
+ this.fail(res.error.message);
606
+ const records = res.data.map(toRecord);
607
+ const ids = records.flatMap((r) => Object.values(r.locales).flatMap((row) => (row.status === "published" && row.publishedVersionId ? [row.publishedVersionId] : [])));
608
+ // In chunks: one in() over hundreds of ids would overflow the request URL.
609
+ const snaps = new Map();
610
+ for (let i = 0; i < ids.length; i += 100) {
611
+ for (const [id, fields] of await this.versionFields(ids.slice(i, i + 100)))
612
+ snaps.set(id, fields);
613
+ }
614
+ return records.map((record) => ({
615
+ record,
616
+ live: Object.fromEntries(Object.entries(record.locales).map(([l, row]) => [
617
+ l,
618
+ row.status !== "published" ? null : (row.publishedVersionId ? snaps.get(row.publishedVersionId) : undefined) ?? entryFieldsIn(record, l),
619
+ ])),
620
+ }));
621
+ }
601
622
  async listVersions(collection, id, locale) {
602
623
  await this.must(collection, id);
603
624
  const res = await this.db
@@ -621,7 +642,8 @@ export class SupabaseEntryStore {
621
642
  assertFresh(row, expected);
622
643
  if (versionId === row.publishedVersionId)
623
644
  return this.discardDraft(collection, id, locale, by, expected);
624
- const { shared, localized } = splitFields(version.fields, schema);
645
+ const { shared: split, localized } = splitFields(version.fields, schema);
646
+ const shared = keepLocalizedShared(entry.fields, split, schema);
625
647
  // The snapshot first, under the guard, as in update.
626
648
  const next = { ...entry, fields: shared, locales: { ...entry.locales, [locale]: { ...row, fields: localized } } };
627
649
  await this.recordVersion(next, locale, expected !== undefined);
@@ -1,5 +1,5 @@
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
4
  import type { PathRow } from "./path-index.ts";
5
5
  import { type DraftToken } from "./draft-token.ts";
@@ -58,6 +58,7 @@ export declare class SupabasePageStore implements PageStore {
58
58
  private hardDelete;
59
59
  createPage(input: CreatePageInput, by?: string): Promise<PageRecord>;
60
60
  getPage(id: string): Promise<PageRecord | null>;
61
+ scanTrees(): Promise<PageScan[]>;
61
62
  listPages(): Promise<PageRecord[]>;
62
63
  /** The site's one warning per process (spec 2026-09-26 §5): fired by the first locate, never blocking. */
63
64
  private schemaChecked;
@@ -322,6 +322,33 @@ export class SupabasePageStore {
322
322
  getPage(id) {
323
323
  return this.row(id);
324
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
+ }
325
352
  async listPages() {
326
353
  const res = await this.db
327
354
  .from("pages")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "smoodly",
3
- "version": "0.0.28",
3
+ "version": "0.0.30",
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": {