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
package/README.md CHANGED
@@ -252,7 +252,7 @@ Implemented:
252
252
  is stored as entries, spec 2026-09-07), not only through the generated
253
253
  views.
254
254
 
255
- - Admin login: `supabaseAdminAuth` + `gateAdminOp` around the ops seam;
255
+ - Admin login: `supabaseAdminAuth` (from `smoodly/admin`) + `gateAdminOp` around the ops seam;
256
256
  `LoginView` + `useAdminSession` in the shell; `createSmoodlyAdmin({ auth })`.
257
257
  Env: `smoodlyEnv()` / `createSmoodlyClient()`.
258
258
 
@@ -477,6 +477,26 @@ Implemented:
477
477
  what (a lock in the lists, a line under the top bar) and announces
478
478
  saves so a clean view refreshes and a dirty one freezes early. Schema
479
479
  version 2 adds the draft-pointer foreign keys and the channel policies.
480
+ Only the editor's own edits freeze a view (2026-09-27): two editors
481
+ opening a fresh page at once both fill its locked hero, and the fill
482
+ that lands second used to freeze its view under "Your unsaved changes
483
+ here will be discarded" with nothing changed; now that view loads the
484
+ other's quietly, on the refusal or the `saved` broadcast. This was the
485
+ local-only timeout of "the guard alone" (CI's slower sign-in let the
486
+ first fill land first). Tests: `concurrency.spec.ts` ("two editors
487
+ opening a new page at once", with the channel cut and on; the fill
488
+ save is held so the race is deterministic).
489
+ - ActionMenu near the screen's edge (2026-09-27): a row menu close to
490
+ the bottom opened below the viewport, where a fixed element cannot be
491
+ scrolled to, and the scroll event from bringing a row into view
492
+ arrived a frame after the click and closed the menu it had opened.
493
+ It now opens upward by the Combobox's `opensUp`, and closes only on a
494
+ scroll that moved its button. This was the Members test's flake (about
495
+ 1 in 8 runs; the list was long because every concurrency run left
496
+ its second member behind — `concurrency.spec.ts` removes it in
497
+ `afterAll` since 2026-09-28, and `members.spec.ts` its invited Auth
498
+ user, which Remove member leaves in place). Tests: `admin-action-menu`, `members.spec.ts` (a
499
+ late scroll event, a real move, a row at the bottom of the screen).
480
500
  - The security pass over the database boundary (spec 2026-09-26 security
481
501
  pass): the presence channel admits a signed-in member of the topic's
482
502
  space (`smoodly_presence_member()`, the socket carries the session
@@ -501,6 +521,71 @@ Implemented:
501
521
  (the rule table, both adapters), the store contracts, `admin-ops`
502
522
  ("redirects on rename and move", "redirects ops"),
503
523
  `next-page-renderer` ("redirects"), `examples/site/e2e/redirects.spec.ts`.
524
+ - Editor safety (spec 2026-09-27 editor safety): schema drift — stored
525
+ content the code no longer describes — is found by one pure walk
526
+ (`admin/drift.ts`) over the serialized registry. Broken findings
527
+ (an unknown section, element, page type or collection; a value that
528
+ doesn't fit its field; a zone rule broken) refuse publish for pages,
529
+ entries and global items; left-over ones (a removed field or zone, an
530
+ element outside its toolset, a localization change) never do. The
531
+ validator's one type check is `fits` (text and number checked for the
532
+ first time). Settings › Content health (every member) reports every
533
+ document, draft and live, grouped by the change; the editors mark the
534
+ open document (notice, Outline marks, a canvas placeholder, the
535
+ unknown-section inspector with Remove section, misfits as field
536
+ errors, a "No longer in the schema" group with Copy and Clear, Clear
537
+ zone). Turning `.localized()` on no longer deletes the other locales'
538
+ value (update, discard and restore keep a localized key's shared
539
+ value); the last `window.alert`s are `notify()`. No bulk writes
540
+ (OQ 35). Tests: `admin-drift`, `admin-drift-copy`, `admin-health`,
541
+ `admin-health-list`, `admin-health-page`, `admin-drift-markers`,
542
+ `admin-unknown-node`, the store contracts (`scanTrees`,
543
+ `scanEntries`, the pinning tests), `entry-store.supabase` (the
544
+ localized fix across two schemas), `examples/site/e2e/health.spec.ts`.
545
+ - Shadowed-page detector (spec 2026-09-27 shadowed-page detector): a
546
+ canvas whose bridge never speaks within 4 s of the frame's load is
547
+ diagnosed — the editor fetches the canvas URL once and
548
+ `admin/editor/canvas-diagnosis.ts` names another route, a route file
549
+ that drops `searchParams`, a crash, a 404, a redirect, a refusal, a
550
+ framing header or a script error, as an editor sentence and a "For
551
+ your developer" line (`editor/CanvasWarning.tsx`, in place of
552
+ "Selection unavailable"). The renderer writes
553
+ `<meta name="smoodly-draft">` in draft mode only. No reserved-path
554
+ list (OQ 23 closed). Tests: `admin-canvas-diagnosis`,
555
+ `admin-canvas-warning`, `next-page-renderer` (the marker),
556
+ `admin-bridge`, `examples/site/e2e/editor.spec.ts`.
557
+ - cacheComponents unsupported (spec 2026-09-27 cache components): under
558
+ Next's Cache Components the catch-all is served from a fallback shell
559
+ that commits 200, so 404s and 308s are lost; the renderer warns once
560
+ per renderer when `process.env.__NEXT_CACHE_COMPONENTS` is on, and the
561
+ Next.js guide says why. OQ 19 closed. Tests: `next-page-renderer`
562
+ ("cacheComponents").
563
+ - Section guard (spec 2026-09-27 section guard): `Zone` calls a
564
+ plain-function section view inside try/catch (an async one through
565
+ `SectionGuard`, in place), so a throw in a section's own code leaves it
566
+ out and the page renders — nothing live, a placeholder in the canvas
567
+ and under `next dev`; the adapter's `rethrow` (Next: `unstable_rethrow`)
568
+ lets control flow through. In editor mode the Next adapter also wraps
569
+ each section in `<Suspense>` + the client `CanvasBoundary`, the bridge
570
+ reports crashed sections, the page editor lists them, and Publish asks
571
+ first. A healthy page's HTML is unchanged. Tests: `section-crash`,
572
+ `render` ("the section guard"), `next-canvas-boundary`,
573
+ `next-page-renderer`, `admin-crash-copy`, `admin-canvas-diagnosis`,
574
+ `examples/site/e2e/sections.spec.ts`.
575
+ - No admin on the live site (2026-09-27): `supabaseAdminAuth` moved from
576
+ `smoodly/admin/next` to `smoodly/admin` (its `AuthConfig` type too).
577
+ `smoodly/server.ts`, which every site route imports, took it from the
578
+ `admin/next` barrel beside `createSmoodlyAdmin`, so the admin shell was
579
+ a client module of every site route. Confirmed by removing the import
580
+ and rebuilding; the starter's home page, production build, went from
581
+ 1,616,602 to 628,849 bytes of JavaScript (8 chunks either way, the
582
+ 1,050,601-byte chunk holding `AdminApp` gone). The rest of the page's
583
+ JavaScript is the site's own: of a 62,848-byte chunk, next-intl is
584
+ ~34 KB, `next/link` and `next/image` most of the rest, and the
585
+ editor-only modules ~3.4 KB (`EditorBridge` ~2.5 KB, the canvas net
586
+ ~0.9 KB) — too little to lazy-load. Tests: `site-entries` (every entry
587
+ but `smoodly/admin/next` reaches no client module except those two),
588
+ `examples/site/e2e/site-bundle.spec.ts` (CI only: production chunks).
504
589
 
505
590
  Not yet: canvas drag-and-drop (list fields, the outline, manual
506
591
  collections and the pages tree drag since 2026-09-09 — spec
@@ -528,6 +613,36 @@ after both are on the registry. From the repo root:
528
613
  `prepublishOnly` rebuilds each package. The Claude Code skill
529
614
  `.claude/skills/releasing-smoodly` carries the same order with its checks.
530
615
 
616
+ ## Follow-ups (logged 2026-09-27, shadowed-page detector spec)
617
+
618
+ - A section's crash on a live page after data changed outside the page
619
+ editor (an entry or a global item edited in its form): the guard
620
+ catches a throw in the section's own view, but one inside a component
621
+ it renders, in a richtext element's view or in a `'use client'`
622
+ section still fails the page, and no canvas previews those forms.
623
+ Catching it live needs `<Suspense>` (spec 2026-09-27 section guard,
624
+ measured). Revisit if a real site hits it.
625
+
626
+ ## Follow-ups (logged 2026-09-27, editor safety spec)
627
+
628
+ - Scan paging: `health.scan` makes one bulk read per table (versions by
629
+ id in chunks of 100), and PostgREST's max-rows would cap a very large
630
+ space. Page it, and add a rail badge, when a real site's size asks.
631
+ - Refs inside unknown nodes are not indexed: without a schema nothing
632
+ says which values are refs, so safe-delete cannot see them. Publish
633
+ refuses such nodes and a live one renders nothing.
634
+ - Leftover shared values after `.localized()` is turned on stay on the
635
+ entry as every locale's fallback; removing them for good waits on
636
+ the fix tooling (OQ 35). `localization-on` is reported only where a
637
+ locale has no value of its own, so a fully translated entry no
638
+ longer lists it.
639
+ - A visual global item's settings misfits (`settings.x`) don't reach
640
+ their widgets in the Globals form: `GlobalFields` keys errors by the
641
+ bare field key, the same gap the validator's refusal keys had before.
642
+ The notice and the refusal list still name them.
643
+ - A CLI drift report was left out (the admin already has the config and
644
+ the database; a CLI needs the service key and a TS config loader).
645
+
531
646
  ## Follow-ups (logged 2026-09-26, redirects spec)
532
647
 
533
648
  - Destinations outside Smoodly, extension and query-string addresses,
@@ -543,11 +658,6 @@ after both are on the registry. From the repo root:
543
658
  level only (no tree drag in the suite yet).
544
659
  - `remove` on the Supabase index deletes by target, one statement per
545
660
  target (the RPC now spares redirect rows); fine at subtree sizes.
546
- - Pressing Enter in the page Settings tab's slug input commits once,
547
- then the confirm's autofocus blurs the input and `commit` runs again,
548
- so a second identical question queues behind the first and its Cancel
549
- restores the input from a stale row closure (pre-existing, seen on the
550
- redirects walk). A re-entrancy guard on `commit` fixes it.
551
661
 
552
662
  ## Follow-ups (logged 2026-09-26, security pass)
553
663
 
@@ -681,9 +791,13 @@ after both are on the registry. From the repo root:
681
791
  section, a global or a page is a plain chip input.
682
792
  - The picker's "open" link goes to `/admin/<collection>/<id>` without
683
793
  the edited locale; the form opens in the URL's or the default locale.
684
- - The Combobox list flips upwards when it does not fit under the input
685
- (`opensUp`, measured against the nearest clipping ancestor); it does
686
- not yet shrink when neither side has room for it.
794
+ - The Combobox list is portaled into the admin wrapper and fixed to the
795
+ viewport (2026-09-27), so a modal body or scrolling form no longer
796
+ clips it; it flips upwards when the viewport has no room under the
797
+ input (`listPlacement`) and follows the input on scroll. It does not
798
+ yet shrink when neither side has room for it. A single select (a
799
+ single `f.ref`, the redirect target) shows its pick inside the input;
800
+ a single ref's status, open link and remove sit in the input too.
687
801
  - `RefOption.status` is the three-way `LocaleStatus` now; the old
688
802
  "(draft)" suffix in option titles is gone with the native select.
689
803
  - A `refList` on a global item's `settings` still loads no options in
@@ -1289,14 +1403,6 @@ the toolbar instead of pushing the content.
1289
1403
  the vocabulary once instead of per field, dedupe there.
1290
1404
  - The entry form's column is narrower than the inspector, so the default
1291
1405
  toolbar wraps to two rows there; the inspector shows it on one.
1292
- - The example site's production bundle loads one shared chunk (~750 KB)
1293
- on every site page holding the package's admin client modules — the
1294
- shell, the Supabase client and now the TipTap widget — because
1295
- `next/page-renderer.tsx` imports the editor bridge from
1296
- `admin/next/bridge.tsx` and Turbopack bundles the package's client
1297
- modules together. Measured on the 2026-09-08 build; whether the shell
1298
- was already there before the widget is unverified. The fix is to give
1299
- the bridge its own entry outside the admin tree and re-measure.
1300
1406
 
1301
1407
  ## Follow-ups (logged 2026-09-07, starter)
1302
1408
 
@@ -5,6 +5,7 @@ const GROUPS = {
5
5
  assets: ["list", "get", "beginUpload", "finishUpload", "update", "delete", "usage"],
6
6
  members: ["list", "me", "invite", "setRole", "remove"],
7
7
  redirects: ["list", "targets", "add", "remove"],
8
+ health: ["scan"],
8
9
  schema: ["status"],
9
10
  preview: ["enable", "disable"],
10
11
  };
@@ -0,0 +1,19 @@
1
+ import type { FieldError } from "./validate.ts";
2
+ import { type Finding } from "./drift.ts";
3
+ import type { AdminRegistry } from "./serialize.ts";
4
+ /** A stored value as its JSON, cut at 60 characters. */
5
+ export declare function valuePreview(value: unknown): string;
6
+ /** One line: what a publish refusal lists and what a marker says. */
7
+ export declare function driftMessage(f: Finding): string;
8
+ /** The refusal's list (§6): the validator's errors, then each broken finding once per key. */
9
+ export declare function publishErrors(validator: FieldError[], findings: Finding[]): FieldError[];
10
+ /** The report's group line (§4): what the developer changed. */
11
+ export declare function driftHeadline(f: Finding, _registry: AdminRegistry): string;
12
+ /** The document notice under the top bar (§5): the counts, the live-only line, and what a localization change does on save. */
13
+ export declare function driftNotice(draft: Finding[], live: Finding[]): {
14
+ summary: string | null;
15
+ liveOnly: string | null;
16
+ sentences: string[];
17
+ };
18
+ /** The markers' field-error map (§5): each misfit's message at its path, `strip.` removed. */
19
+ export declare function misfitErrors(findings: Finding[], strip?: string): Record<string, string>;
@@ -0,0 +1,89 @@
1
+ import { isBroken } from "./drift.js";
2
+ /** A stored value as its JSON, cut at 60 characters. */
3
+ export function valuePreview(value) {
4
+ let text;
5
+ try {
6
+ text = JSON.stringify(value) ?? String(value);
7
+ }
8
+ catch {
9
+ text = String(value);
10
+ }
11
+ return text.length > 60 ? `${text.slice(0, 60)}…` : text;
12
+ }
13
+ /** One line: what a publish refusal lists and what a marker says. */
14
+ export function driftMessage(f) {
15
+ switch (f.kind) {
16
+ case "unknown-section": return f.subject.scope === "global" ? "The code no longer registers this global item." : "The code no longer registers this section.";
17
+ case "unknown-element": return "The code no longer registers this element.";
18
+ case "unknown-shape": return "The code no longer registers this page type.";
19
+ case "unknown-collection": return "The code no longer registers this collection.";
20
+ case "misfit": return `The stored value doesn't fit this field: ${valuePreview(f.value)}`;
21
+ case "zone-disallowed": return `Zone \`${f.subject.name}\` no longer allows \`${f.subject.field}\`. Remove or move it to publish.`;
22
+ case "zone-over-max": return `Zone \`${f.subject.name}\` allows ${f.detail?.max} sections and has ${f.detail?.count}.`;
23
+ case "orphan-field": return "No longer in the schema.";
24
+ case "orphan-zone": return `Removed zone \`${f.subject.name}\` · ${f.detail?.count} ${f.detail?.count === 1 ? "section" : "sections"}`;
25
+ case "element-outside-toolset": return "Not in this field's toolset";
26
+ case "localization-on": return `\`${f.subject.field}\` is now translated per language. Its shared value shows in every language until it's saved there.`;
27
+ case "localization-off": return `\`${f.subject.field}\` is now shared. Saving here makes this language's value the one every language shows.`;
28
+ }
29
+ }
30
+ /** The refusal's list (§6): the validator's errors, then each broken finding once per key. */
31
+ export function publishErrors(validator, findings) {
32
+ const out = [...validator];
33
+ const seen = new Set(out.map((e) => e.field));
34
+ for (const f of findings) {
35
+ if (!isBroken(f) || seen.has(f.path))
36
+ continue;
37
+ seen.add(f.path);
38
+ out.push({ field: f.path, message: driftMessage(f) });
39
+ }
40
+ return out;
41
+ }
42
+ const SCOPE_WORD = { section: "section", element: "element", collection: "collection", global: "global item", page: "page type", zone: "zone" };
43
+ /** The report's group line (§4): what the developer changed. */
44
+ export function driftHeadline(f, _registry) {
45
+ const s = f.subject;
46
+ const owner = `${SCOPE_WORD[s.scope]} \`${s.name}\``;
47
+ switch (f.kind) {
48
+ case "unknown-section": return s.scope === "global" ? `Unknown global item \`${s.name}\`` : `Unknown section \`${s.name}\``;
49
+ case "unknown-element": return `Unknown element \`${s.name}\``;
50
+ case "unknown-shape": return `Unknown page type \`${s.name}\``;
51
+ case "unknown-collection": return `Collection \`${s.name}\` no longer registered`;
52
+ case "misfit": return `\`${s.field}\` in ${owner} doesn't fit f.${f.detail?.type}()`;
53
+ case "zone-disallowed": return `Zone \`${s.name}\` of \`${s.within}\` no longer allows \`${s.field}\``;
54
+ case "zone-over-max": return `Zone \`${s.name}\` of \`${s.within}\` holds more than its max`;
55
+ case "orphan-field": return `Field \`${s.field}\` no longer in ${owner}`;
56
+ case "orphan-zone": return `Zone \`${s.name}\` no longer on page type \`${s.within}\``;
57
+ case "element-outside-toolset": return `Element \`${s.name}\` not in the toolset of \`${s.field}\``;
58
+ case "localization-on": return `\`${s.field}\` in ${owner} is now translated — a shared value is left`;
59
+ case "localization-off": return `\`${s.field}\` in ${owner} is now shared — languages still hold their own`;
60
+ }
61
+ }
62
+ const n = (count, one, many) => `${count} ${count === 1 ? one : many}`;
63
+ /** The document notice under the top bar (§5): the counts, the live-only line, and what a localization change does on save. */
64
+ export function driftNotice(draft, live) {
65
+ const broken = draft.filter(isBroken).length;
66
+ const orphans = draft.length - broken;
67
+ const parts = [
68
+ broken > 0 ? `${n(broken, "problem blocks", "problems block")} publishing` : null,
69
+ orphans > 0 ? `${orphans} left over from an older schema` : null,
70
+ ].filter(Boolean);
71
+ const inDraft = new Set(draft.map((f) => `${f.kind}|${f.path}`));
72
+ const liveOnly = live.filter((f) => !inDraft.has(`${f.kind}|${f.path}`)).length;
73
+ return {
74
+ summary: parts.length > 0 ? parts.join(" · ") : null,
75
+ liveOnly: liveOnly > 0 ? `The live version has ${n(liveOnly, "problem", "problems")} the draft has already fixed — publish to clear it.` : null,
76
+ sentences: draft.filter((f) => f.kind === "localization-on" || f.kind === "localization-off").map(driftMessage),
77
+ };
78
+ }
79
+ /** The markers' field-error map (§5): each misfit's message at its path, `strip.` removed. */
80
+ export function misfitErrors(findings, strip) {
81
+ const out = {};
82
+ for (const f of findings) {
83
+ if (f.kind !== "misfit")
84
+ continue;
85
+ const path = strip && f.path.startsWith(`${strip}.`) ? f.path.slice(strip.length + 1) : f.path;
86
+ out[path] = driftMessage(f);
87
+ }
88
+ return out;
89
+ }
@@ -0,0 +1,76 @@
1
+ import type { AdminField, AdminRegistry } from "./serialize.ts";
2
+ import type { PageTree } from "../render.tsx";
3
+ export type DriftKind = "unknown-section" | "unknown-element" | "unknown-shape" | "unknown-collection" | "misfit" | "zone-disallowed" | "zone-over-max" | "orphan-field" | "orphan-zone" | "element-outside-toolset" | "localization-on" | "localization-off";
4
+ export type Severity = "broken" | "orphan";
5
+ export declare const SEVERITY: Record<DriftKind, Severity>;
6
+ /** Where the change the developer made lives — the report groups by it (§4). */
7
+ export type DriftScope = "section" | "element" | "collection" | "global" | "page" | "zone";
8
+ /** `field` is index-free (`points[].icon`); `within` names an outer owner (a zone's page type, an element's section). */
9
+ export type DriftSubject = {
10
+ scope: DriftScope;
11
+ name: string;
12
+ field?: string;
13
+ within?: string;
14
+ };
15
+ /** `path` is the error key the publish refusal and the markers use (§6); indices stay in it. */
16
+ export type Finding = {
17
+ kind: DriftKind;
18
+ path: string;
19
+ subject: DriftSubject;
20
+ /** A misfit's or an orphan's stored value — the marker's read-only copy. */
21
+ value?: unknown;
22
+ /** A misfit's field type; a zone's max and its node count. */
23
+ detail?: {
24
+ type?: string;
25
+ max?: number;
26
+ count?: number;
27
+ };
28
+ };
29
+ export declare const isBroken: (f: Finding) => boolean;
30
+ /** The validator's empty: an empty value is `required`'s question, never drift's. */
31
+ export declare const isEmptyValue: (v: unknown) => boolean;
32
+ /** Whether a stored value is the shape its field kind stores (§2). Empty always fits; a kind
33
+ * this list does not know fits (the custom section's `spans`, whose structure is rowsProblems'). */
34
+ export declare function fits(d: {
35
+ type: string;
36
+ options?: unknown;
37
+ }, value: unknown): boolean;
38
+ /** What the validator says when `fits` is false — its long-standing words, plus text and number. */
39
+ export declare function misfitMessage(d: {
40
+ type: string;
41
+ options?: unknown;
42
+ }, value: unknown): string;
43
+ export type Holder = {
44
+ fields?: unknown;
45
+ settings?: unknown;
46
+ styles?: unknown;
47
+ };
48
+ export type HolderSchema = {
49
+ fields: Record<string, AdminField>;
50
+ settings?: Record<string, AdminField>;
51
+ styles?: Record<string, AdminField>;
52
+ };
53
+ /** A node-shaped value — a tree node, a visual global item — against a section-shaped schema:
54
+ * fields at `prefix`, settings and styles under `prefix.settings` / `prefix.styles` (§1). */
55
+ export declare function holderDrift(registry: AdminRegistry, subject: DriftSubject, prefix: string, holder: Holder, schema: HolderSchema): Finding[];
56
+ /** A page's draft or live tree against its page type (§1): the type itself, each declared zone's
57
+ * rules and nodes, stored zones the type no longer declares (orphans, never walked), page fields. */
58
+ export declare function pageDrift(registry: AdminRegistry, template: string | null, tree: PageTree | null): Finding[];
59
+ export type EntryInput = {
60
+ /** The merged view the editor sees (shared with the locale row on top). */
61
+ fields: Record<string, unknown>;
62
+ /** The node's shared fields and the locale row's own — both present to check localization. */
63
+ shared?: Record<string, unknown>;
64
+ own?: Record<string, unknown>;
65
+ };
66
+ /** An entry's or a global item's fields against its registration (§1). `name` is the stored
67
+ * collection name: a collection, a global item, or neither — an unregistered collection. */
68
+ export declare function entryDrift(registry: AdminRegistry, name: string, input: EntryInput): Finding[];
69
+ /** The report's group (§4): what changed, not where — kind, scope, owner, name, index-free field. */
70
+ export declare const groupKey: (f: Finding) => string;
71
+ export type VersionedFinding = Finding & {
72
+ draft: boolean;
73
+ live: boolean;
74
+ };
75
+ /** One list per document and locale: a finding in both versions is listed once with both tags (§3). */
76
+ export declare function mergeVersions(draft: Finding[], live: Finding[]): VersionedFinding[];
@@ -0,0 +1,264 @@
1
+ import { optionValues } from "../fields.js";
2
+ import { isHidden } from "../conditions.js";
3
+ import { allowedOf } from "../toolset.js";
4
+ import { CUSTOM_NODE_TYPE } from "../custom/schema.js";
5
+ import { CSS_KEY, GLOBAL_NODE_TYPE, SETTINGS_KEY, STYLES_KEY } from "../global.js";
6
+ export const SEVERITY = {
7
+ "unknown-section": "broken", "unknown-element": "broken", "unknown-shape": "broken", "unknown-collection": "broken",
8
+ misfit: "broken", "zone-disallowed": "broken", "zone-over-max": "broken",
9
+ "orphan-field": "orphan", "orphan-zone": "orphan", "element-outside-toolset": "orphan",
10
+ "localization-on": "orphan", "localization-off": "orphan",
11
+ };
12
+ export const isBroken = (f) => SEVERITY[f.kind] === "broken";
13
+ /** The validator's empty: an empty value is `required`'s question, never drift's. */
14
+ export const isEmptyValue = (v) => v === undefined || v === null || v === "" || (Array.isArray(v) && v.length === 0);
15
+ const isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
16
+ const isStrings = (v) => Array.isArray(v) && v.every((x) => typeof x === "string");
17
+ /** Whether a stored value is the shape its field kind stores (§2). Empty always fits; a kind
18
+ * this list does not know fits (the custom section's `spans`, whose structure is rowsProblems'). */
19
+ export function fits(d, value) {
20
+ if (isEmptyValue(value))
21
+ return true;
22
+ switch (d.type) {
23
+ case "text":
24
+ case "link":
25
+ case "slug":
26
+ case "date":
27
+ case "datetime": return typeof value === "string";
28
+ case "tags": return isStrings(value);
29
+ case "number": return typeof value === "number" && Number.isFinite(value);
30
+ case "boolean": return typeof value === "boolean";
31
+ case "select":
32
+ case "radio": {
33
+ const allowed = optionValues(d.options);
34
+ return typeof value === "string" && (allowed.length === 0 || allowed.includes(value));
35
+ }
36
+ case "checkboxes": {
37
+ const allowed = optionValues(d.options);
38
+ return Array.isArray(value) && value.every((v) => typeof v === "string" && allowed.includes(v));
39
+ }
40
+ case "image":
41
+ case "video":
42
+ case "file": return isObject(value) && typeof value.asset === "string";
43
+ case "ref": return typeof value === "string";
44
+ case "refList": return Array.isArray(value) && value.every((v) => typeof v === "string" && v !== "");
45
+ case "object": return isObject(value);
46
+ case "list": return Array.isArray(value);
47
+ case "richtext": return isObject(value) && value.type === "doc";
48
+ default: return true;
49
+ }
50
+ }
51
+ /** What the validator says when `fits` is false — its long-standing words, plus text and number. */
52
+ export function misfitMessage(d, value) {
53
+ const oneOf = () => `Must be one of: ${optionValues(d.options).join(", ")}.`;
54
+ switch (d.type) {
55
+ case "text":
56
+ case "link":
57
+ case "slug": return "Must be text.";
58
+ case "number": return "Must be a number.";
59
+ case "boolean": return "Must be on or off.";
60
+ case "date": return "Not a date (2026-09-02).";
61
+ case "datetime": return "Not a date and time.";
62
+ case "image":
63
+ case "video":
64
+ case "file": return "Must be an uploaded file.";
65
+ case "list": return "Must be a list.";
66
+ case "object": return "Must be an object.";
67
+ case "ref": return "Must be an entry.";
68
+ case "refList": return "Must be a list of entries.";
69
+ case "tags": return "Must be a list of tags.";
70
+ case "richtext": return "Must be rich text.";
71
+ case "select":
72
+ case "radio": return oneOf();
73
+ case "checkboxes": return Array.isArray(value) ? oneOf() : "Must be a list.";
74
+ default: return "Doesn't fit this field.";
75
+ }
76
+ }
77
+ const join = (a, b) => (a === "" ? b : `${a}.${b}`);
78
+ /** One value map against its descriptors (§1): a stored key no descriptor names is an orphan;
79
+ * a declared, visible value is checked. `path` is the error-key prefix, `schema` the index-free
80
+ * path the report groups by. */
81
+ function walkMap(path, schema, values, descs, w, scope) {
82
+ for (const [key, value] of Object.entries(values)) {
83
+ if (!(key in descs))
84
+ w.out.push({ kind: "orphan-field", path: join(path, key), subject: { ...w.subject, field: join(schema, key) }, value });
85
+ }
86
+ for (const [key, d] of Object.entries(descs)) {
87
+ if (isHidden(d, scope))
88
+ continue;
89
+ walkValue(join(path, key), join(schema, key), values[key], d, w, scope);
90
+ }
91
+ }
92
+ function walkValue(path, schema, value, d, w, scope) {
93
+ if (isEmptyValue(value))
94
+ return;
95
+ if (!fits(d, value)) {
96
+ w.out.push({ kind: "misfit", path, subject: { ...w.subject, field: schema }, value, detail: { type: d.type } });
97
+ return;
98
+ }
99
+ if (d.type === "object") {
100
+ walkMap(path, schema, value, d.fields ?? {}, w, { ...scope, self: value });
101
+ }
102
+ else if (d.type === "list") {
103
+ // As the validator: an item descriptor that is present and not hidden.
104
+ if (d.item && !isHidden(d.item, scope))
105
+ value.forEach((v, i) => walkValue(`${path}.${i}`, `${schema}[]`, v, d.item, w, scope));
106
+ }
107
+ else if (d.type === "richtext") {
108
+ walkDoc(path, schema, value, d, w);
109
+ }
110
+ }
111
+ /** Element nodes' attrs in document order at any depth — the validator's `elements.<i>`. Never mutates. */
112
+ function elementAttrs(doc) {
113
+ const out = [];
114
+ const walk = (n) => {
115
+ if (!isObject(n))
116
+ return;
117
+ if (n.type === "element")
118
+ out.push(isObject(n.attrs) ? n.attrs : {});
119
+ if (Array.isArray(n.content))
120
+ n.content.forEach(walk);
121
+ };
122
+ walk(doc);
123
+ return out;
124
+ }
125
+ function walkDoc(path, schema, doc, d, w) {
126
+ const allowed = allowedOf(d.toolset).elements;
127
+ elementAttrs(doc).forEach((a, i) => {
128
+ const at = `${path}.elements.${i}`;
129
+ const name = typeof a.name === "string" ? a.name : "";
130
+ const el = w.registry.elements.find((e) => e.name === name);
131
+ if (!el) {
132
+ w.out.push({ kind: "unknown-element", path: at, subject: { scope: "element", name } });
133
+ return;
134
+ }
135
+ if (!allowed.includes(name) || !el.contexts.includes("richtext")) {
136
+ w.out.push({ kind: "element-outside-toolset", path: at, subject: { scope: "element", name, field: schema, within: `${w.subject.scope}:${w.subject.name}` } });
137
+ }
138
+ const fields = isObject(a.fields) ? a.fields : {};
139
+ const settings = isObject(a.settings) ? a.settings : {};
140
+ const inner = { ...w, subject: { scope: "element", name } };
141
+ walkMap(at, "", fields, el.fields, inner, { self: fields, fields, settings });
142
+ walkMap(`${at}.settings`, "settings", settings, el.settings, inner, { self: settings, fields, settings });
143
+ });
144
+ }
145
+ /** A node-shaped value — a tree node, a visual global item — against a section-shaped schema:
146
+ * fields at `prefix`, settings and styles under `prefix.settings` / `prefix.styles` (§1). */
147
+ export function holderDrift(registry, subject, prefix, holder, schema) {
148
+ const w = { registry, out: [], subject };
149
+ const fields = isObject(holder.fields) ? holder.fields : {};
150
+ const settings = isObject(holder.settings) ? holder.settings : {};
151
+ const styles = isObject(holder.styles) ? holder.styles : {};
152
+ walkMap(prefix, "", fields, schema.fields, w, { self: fields, fields, settings });
153
+ walkMap(join(prefix, "settings"), "settings", settings, schema.settings ?? {}, w, { self: settings, fields, settings });
154
+ walkMap(join(prefix, "styles"), "styles", styles, schema.styles ?? {}, w, { self: styles, fields, settings });
155
+ return w.out;
156
+ }
157
+ /** Whether a zone's policy still takes a node of this type (§1). Global placements are not checked here. */
158
+ function zoneAllows(policy, type) {
159
+ if (policy.kind === "locked")
160
+ return type === policy.component;
161
+ if (policy.kind === "sections")
162
+ return policy.allow.includes(type);
163
+ if (type === CUSTOM_NODE_TYPE)
164
+ return policy.custom;
165
+ return policy.allow === "*" || policy.allow.includes(type);
166
+ }
167
+ function nodeDrift(registry, shape, zone, policy, node, out) {
168
+ if (node.type === GLOBAL_NODE_TYPE) {
169
+ const name = typeof node.fields?.item === "string" ? node.fields.item : "";
170
+ if (!registry.globals.some((g) => g.name === name))
171
+ out.push({ kind: "unknown-section", path: node.id, subject: { scope: "global", name } });
172
+ return;
173
+ }
174
+ const section = node.type === CUSTOM_NODE_TYPE ? registry.custom : registry.sections.find((s) => s.name === node.type);
175
+ if (!section) {
176
+ out.push({ kind: "unknown-section", path: node.id, subject: { scope: "section", name: node.type } });
177
+ return;
178
+ }
179
+ if (!zoneAllows(policy, node.type)) {
180
+ out.push({ kind: "zone-disallowed", path: node.id, subject: { scope: "zone", name: zone, field: node.type, within: shape } });
181
+ }
182
+ out.push(...holderDrift(registry, { scope: "section", name: section.name }, node.id, node, section));
183
+ }
184
+ /** A page's draft or live tree against its page type (§1): the type itself, each declared zone's
185
+ * rules and nodes, stored zones the type no longer declares (orphans, never walked), page fields. */
186
+ export function pageDrift(registry, template, tree) {
187
+ if (template === null)
188
+ return [];
189
+ const shape = registry.pages.find((p) => p.name === template);
190
+ if (!shape)
191
+ return [{ kind: "unknown-shape", path: "page", subject: { scope: "page", name: template } }];
192
+ if (!tree)
193
+ return [];
194
+ const out = [];
195
+ for (const [zone, stored] of Object.entries(tree.zones ?? {})) {
196
+ const nodes = Array.isArray(stored) ? stored : [];
197
+ const policy = shape.zones[zone];
198
+ if (!policy) {
199
+ if (nodes.length > 0)
200
+ out.push({ kind: "orphan-zone", path: `zone:${zone}`, subject: { scope: "zone", name: zone, within: shape.name }, detail: { count: nodes.length } });
201
+ continue;
202
+ }
203
+ if (policy.kind === "sections" && typeof policy.max === "number" && nodes.length > policy.max) {
204
+ out.push({ kind: "zone-over-max", path: `zone:${zone}`, subject: { scope: "zone", name: zone, within: shape.name }, detail: { max: policy.max, count: nodes.length } });
205
+ }
206
+ for (const node of nodes)
207
+ nodeDrift(registry, shape.name, zone, policy, node, out);
208
+ }
209
+ out.push(...holderDrift(registry, { scope: "page", name: shape.name }, "page.fields", { fields: tree.fields ?? {} }, { fields: shape.fields }));
210
+ return out;
211
+ }
212
+ const RESERVED = new Set(["slug", SETTINGS_KEY, STYLES_KEY, CSS_KEY]);
213
+ function localization(subject, descs, input, out) {
214
+ if (!input.shared || !input.own)
215
+ return;
216
+ for (const [key, d] of Object.entries(descs)) {
217
+ if (RESERVED.has(key))
218
+ continue;
219
+ // On only where this locale still shows the shared value: once the row has its own, the
220
+ // leftover shared value is only a fallback (the leftover itself waits on OQ 35).
221
+ if (d.localized === true && key in input.shared && !(key in input.own))
222
+ out.push({ kind: "localization-on", path: key, subject: { ...subject, field: key }, value: input.shared[key] });
223
+ if (d.localized !== true && key in input.own)
224
+ out.push({ kind: "localization-off", path: key, subject: { ...subject, field: key }, value: input.own[key] });
225
+ }
226
+ }
227
+ /** An entry's or a global item's fields against its registration (§1). `name` is the stored
228
+ * collection name: a collection, a global item, or neither — an unregistered collection. */
229
+ export function entryDrift(registry, name, input) {
230
+ const out = [];
231
+ const collection = registry.collections.find((c) => c.name === name);
232
+ if (collection) {
233
+ const subject = { scope: "collection", name };
234
+ const { slug: _slug, ...fields } = input.fields;
235
+ out.push(...holderDrift(registry, subject, "", { fields }, { fields: collection.fields }));
236
+ localization(subject, collection.fields, input, out);
237
+ return out;
238
+ }
239
+ const item = registry.globals.find((g) => g.name === name);
240
+ if (item) {
241
+ const subject = { scope: "global", name };
242
+ const { [SETTINGS_KEY]: settings, [STYLES_KEY]: styles, [CSS_KEY]: _css, slug: _slug, ...fields } = input.fields;
243
+ out.push(...holderDrift(registry, subject, "", { fields, settings, styles }, item));
244
+ localization(subject, item.fields, input, out);
245
+ return out;
246
+ }
247
+ return [{ kind: "unknown-collection", path: "collection", subject: { scope: "collection", name } }];
248
+ }
249
+ /** The report's group (§4): what changed, not where — kind, scope, owner, name, index-free field. */
250
+ export const groupKey = (f) => [f.kind, f.subject.scope, f.subject.within ?? "", f.subject.name, f.subject.field ?? ""].join("|");
251
+ /** One list per document and locale: a finding in both versions is listed once with both tags (§3). */
252
+ export function mergeVersions(draft, live) {
253
+ const out = new Map();
254
+ for (const f of draft)
255
+ out.set(`${f.kind}|${f.path}`, { ...f, draft: true, live: false });
256
+ for (const f of live) {
257
+ const hit = out.get(`${f.kind}|${f.path}`);
258
+ if (hit)
259
+ hit.live = true;
260
+ else
261
+ out.set(`${f.kind}|${f.path}`, { ...f, draft: false, live: true });
262
+ }
263
+ return [...out.values()];
264
+ }
@@ -0,0 +1,5 @@
1
+ import { type CanvasCause } from "./canvas-diagnosis.ts";
2
+ export declare function CanvasWarning({ cause, path }: {
3
+ cause: CanvasCause;
4
+ path: string;
5
+ }): import("react").JSX.Element;