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.
- package/README.md +123 -17
- package/dist/admin/client-ops.js +1 -0
- package/dist/admin/drift-copy.d.ts +19 -0
- package/dist/admin/drift-copy.js +89 -0
- package/dist/admin/drift.d.ts +76 -0
- package/dist/admin/drift.js +264 -0
- package/dist/admin/editor/CanvasWarning.d.ts +5 -0
- package/dist/admin/editor/CanvasWarning.js +10 -0
- package/dist/admin/editor/EditorView.js +113 -26
- package/dist/admin/editor/InlineRichtext.js +3 -1
- package/dist/admin/editor/Outline.d.ts +7 -0
- package/dist/admin/editor/Outline.js +8 -5
- package/dist/admin/editor/PageInspector.d.ts +6 -1
- package/dist/admin/editor/PageInspector.js +4 -2
- package/dist/admin/editor/PageSettings.js +26 -16
- package/dist/admin/editor/UnknownNode.d.ts +7 -0
- package/dist/admin/editor/UnknownNode.js +8 -0
- package/dist/admin/editor/canvas-diagnosis.d.ts +49 -0
- package/dist/admin/editor/canvas-diagnosis.js +106 -0
- package/dist/admin/editor/crash-copy.d.ts +7 -0
- package/dist/admin/editor/crash-copy.js +13 -0
- package/dist/admin/editor/protocol.d.ts +2 -0
- package/dist/admin/editor/useCanvasBridge.d.ts +3 -0
- package/dist/admin/editor/useCanvasBridge.js +14 -7
- package/dist/admin/editor/useCanvasDiagnosis.d.ts +7 -0
- package/dist/admin/editor/useCanvasDiagnosis.js +24 -0
- package/dist/admin/field-paths.d.ts +3 -0
- package/dist/admin/field-paths.js +26 -0
- package/dist/admin/forms/OrphanFields.d.ts +8 -0
- package/dist/admin/forms/OrphanFields.js +11 -0
- package/dist/admin/forms/RefPicker.js +6 -2
- package/dist/admin/forms/RichtextElementCard.js +9 -3
- package/dist/admin/forms/RichtextWidget.js +3 -1
- package/dist/admin/forms/TagsField.js +1 -1
- package/dist/admin/forms/richtext-form-context.d.ts +8 -0
- package/dist/admin/forms/richtext-form-context.js +5 -0
- package/dist/admin/health.d.ts +23 -0
- package/dist/admin/health.js +42 -0
- package/dist/admin/index.d.ts +1 -0
- package/dist/admin/next/bridge.js +4 -2
- package/dist/admin/next/index.d.ts +0 -1
- package/dist/admin/next/index.js +6 -1
- package/dist/admin/ops-impl.js +40 -9
- package/dist/admin/ops.d.ts +13 -0
- package/dist/admin/shell/AddRedirectModal.js +1 -1
- package/dist/admin/shell/ContentHealthPage.d.ts +10 -0
- package/dist/admin/shell/ContentHealthPage.js +37 -0
- package/dist/admin/shell/EntriesList.js +4 -4
- package/dist/admin/shell/EntryForm.js +20 -3
- package/dist/admin/shell/ErrorPane.d.ts +4 -2
- package/dist/admin/shell/ErrorPane.js +5 -4
- package/dist/admin/shell/GlobalForm.js +22 -2
- package/dist/admin/shell/PagesList.js +3 -3
- package/dist/admin/shell/SettingsView.js +6 -2
- package/dist/admin/shell/health-list.d.ts +27 -0
- package/dist/admin/shell/health-list.js +47 -0
- package/dist/admin/shell/settings.d.ts +4 -0
- package/dist/admin/shell/settings.js +5 -2
- package/dist/admin/tree-ops.d.ts +4 -0
- package/dist/admin/tree-ops.js +15 -1
- package/dist/admin/ui/ActionMenu.d.ts +20 -0
- package/dist/admin/ui/ActionMenu.js +33 -7
- package/dist/admin/ui/Combobox.d.ts +8 -2
- package/dist/admin/ui/Combobox.js +48 -16
- package/dist/admin/ui/ConfirmDialog.js +3 -3
- package/dist/admin/ui/DriftNotice.d.ts +6 -0
- package/dist/admin/ui/DriftNotice.js +10 -0
- package/dist/admin/ui/combo-box.d.ts +13 -0
- package/dist/admin/ui/combo-box.js +8 -0
- package/dist/admin/ui/confirm.d.ts +8 -0
- package/dist/admin/ui/confirm.js +17 -4
- package/dist/admin/ui/theme.js +29 -3
- package/dist/admin/validate.js +15 -47
- package/dist/custom/CustomSection.d.ts +1 -0
- package/dist/custom/CustomSection.js +4 -1
- package/dist/entry-store.d.ts +13 -0
- package/dist/entry-store.js +26 -3
- package/dist/index.d.ts +2 -2
- package/dist/marks.d.ts +12 -0
- package/dist/marks.js +14 -0
- package/dist/next/canvas-boundary.d.ts +16 -0
- package/dist/next/canvas-boundary.js +25 -0
- package/dist/next/page-renderer.d.ts +3 -1
- package/dist/next/page-renderer.js +33 -4
- package/dist/pairing.js +10 -3
- package/dist/render.d.ts +12 -1
- package/dist/render.js +15 -4
- package/dist/section-crash.d.ts +11 -0
- package/dist/section-crash.js +16 -0
- package/dist/section-guard.d.ts +27 -0
- package/dist/section-guard.js +41 -0
- package/dist/store.d.ts +9 -0
- package/dist/store.js +8 -0
- package/dist/supabase-entry-store.d.ts +2 -1
- package/dist/supabase-entry-store.js +27 -5
- package/dist/supabase-store.d.ts +2 -1
- package/dist/supabase-store.js +27 -0
- 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
|
|
685
|
-
(
|
|
686
|
-
|
|
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
|
|
package/dist/admin/client-ops.js
CHANGED
|
@@ -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
|
+
}
|