smoodly 0.0.27 → 0.0.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +75 -3
- package/dist/admin/client-ops.js +2 -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/EditorView.js +70 -15
- 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 +9 -1
- package/dist/admin/editor/PageInspector.js +4 -2
- package/dist/admin/editor/PageSettings.d.ts +4 -1
- package/dist/admin/editor/PageSettings.js +33 -15
- package/dist/admin/editor/UnknownNode.d.ts +7 -0
- package/dist/admin/editor/UnknownNode.js +8 -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/ops-impl.js +124 -14
- package/dist/admin/ops.d.ts +53 -1
- package/dist/admin/redirect-path.d.ts +6 -0
- package/dist/admin/redirect-path.js +33 -0
- package/dist/admin/shell/AddRedirectModal.d.ts +11 -0
- package/dist/admin/shell/AddRedirectModal.js +29 -0
- package/dist/admin/shell/AdminApp.js +2 -3
- 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 +32 -7
- 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/IconRail.d.ts +1 -1
- package/dist/admin/shell/IconRail.js +3 -2
- package/dist/admin/shell/PagesList.js +9 -7
- package/dist/admin/shell/RedirectsPage.d.ts +11 -0
- package/dist/admin/shell/RedirectsPage.js +75 -0
- package/dist/admin/shell/SettingsView.d.ts +4 -2
- package/dist/admin/shell/SettingsView.js +22 -14
- package/dist/admin/shell/health-list.d.ts +27 -0
- package/dist/admin/shell/health-list.js +47 -0
- package/dist/admin/shell/live-url.d.ts +10 -2
- package/dist/admin/shell/live-url.js +9 -4
- package/dist/admin/shell/members-list.js +1 -1
- package/dist/admin/shell/redirects-list.d.ts +12 -0
- package/dist/admin/shell/redirects-list.js +15 -0
- package/dist/admin/shell/settings.d.ts +16 -3
- package/dist/admin/shell/settings.js +16 -9
- package/dist/admin/tree-ops.d.ts +4 -0
- package/dist/admin/tree-ops.js +15 -1
- package/dist/admin/ui/Combobox.d.ts +8 -2
- package/dist/admin/ui/Combobox.js +48 -16
- package/dist/admin/ui/ConfirmDialog.d.ts +1 -1
- package/dist/admin/ui/ConfirmDialog.js +5 -4
- 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 +21 -2
- package/dist/admin/ui/confirm.js +26 -9
- package/dist/admin/ui/theme.js +32 -3
- package/dist/admin/validate.js +15 -47
- package/dist/entry-store.d.ts +31 -6
- package/dist/entry-store.js +39 -11
- package/dist/index.d.ts +2 -2
- package/dist/next/page-renderer.js +28 -9
- package/dist/path-index.d.ts +28 -4
- package/dist/path-index.js +37 -4
- package/dist/paths.d.ts +16 -0
- package/dist/paths.js +9 -0
- package/dist/render.js +7 -2
- package/dist/schema-version.d.ts +1 -1
- package/dist/schema-version.js +1 -1
- package/dist/site.d.ts +4 -1
- package/dist/site.js +3 -1
- package/dist/sql.js +19 -1
- package/dist/store.d.ts +39 -5
- package/dist/store.js +32 -8
- package/dist/supabase-entry-store.d.ts +9 -3
- package/dist/supabase-entry-store.js +39 -13
- package/dist/supabase-path-index.d.ts +10 -3
- package/dist/supabase-path-index.js +50 -17
- package/dist/supabase-store.d.ts +15 -3
- package/dist/supabase-store.js +42 -8
- package/migrations/20260926150000_smoodly_v4.sql +41 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -490,6 +490,38 @@ Implemented:
|
|
|
490
490
|
the public keys"); admin ops — `test/admin-auth.test.ts`,
|
|
491
491
|
`test/admin-auth.supabase.test.ts`, `test/admin-ops.test.ts` ("members
|
|
492
492
|
ops").
|
|
493
|
+
- Redirects (spec 2026-09-26 redirects): a `paths` row with `redirect`
|
|
494
|
+
set is an old address of its target (schema version 4). A published
|
|
495
|
+
rename or move keeps the old addresses as redirects unless the editor
|
|
496
|
+
unticks "Redirect the old address" on the live-URL confirm; the live
|
|
497
|
+
URL wins an address; nothing chains; `renderSmoodlyPath` answers a
|
|
498
|
+
redirect row with `permanentRedirect` through `href`. Settings ›
|
|
499
|
+
Redirects (every member) lists, adds by hand (validated old address,
|
|
500
|
+
target type-ahead) and removes. Tests: `test/path-index-contract.ts`
|
|
501
|
+
(the rule table, both adapters), the store contracts, `admin-ops`
|
|
502
|
+
("redirects on rename and move", "redirects ops"),
|
|
503
|
+
`next-page-renderer` ("redirects"), `examples/site/e2e/redirects.spec.ts`.
|
|
504
|
+
- Editor safety (spec 2026-09-27 editor safety): schema drift — stored
|
|
505
|
+
content the code no longer describes — is found by one pure walk
|
|
506
|
+
(`admin/drift.ts`) over the serialized registry. Broken findings
|
|
507
|
+
(an unknown section, element, page type or collection; a value that
|
|
508
|
+
doesn't fit its field; a zone rule broken) refuse publish for pages,
|
|
509
|
+
entries and global items; left-over ones (a removed field or zone, an
|
|
510
|
+
element outside its toolset, a localization change) never do. The
|
|
511
|
+
validator's one type check is `fits` (text and number checked for the
|
|
512
|
+
first time). Settings › Content health (every member) reports every
|
|
513
|
+
document, draft and live, grouped by the change; the editors mark the
|
|
514
|
+
open document (notice, Outline marks, a canvas placeholder, the
|
|
515
|
+
unknown-section inspector with Remove section, misfits as field
|
|
516
|
+
errors, a "No longer in the schema" group with Copy and Clear, Clear
|
|
517
|
+
zone). Turning `.localized()` on no longer deletes the other locales'
|
|
518
|
+
value (update, discard and restore keep a localized key's shared
|
|
519
|
+
value); the last `window.alert`s are `notify()`. No bulk writes
|
|
520
|
+
(OQ 35). Tests: `admin-drift`, `admin-drift-copy`, `admin-health`,
|
|
521
|
+
`admin-health-list`, `admin-health-page`, `admin-drift-markers`,
|
|
522
|
+
`admin-unknown-node`, the store contracts (`scanTrees`,
|
|
523
|
+
`scanEntries`, the pinning tests), `entry-store.supabase` (the
|
|
524
|
+
localized fix across two schemas), `examples/site/e2e/health.spec.ts`.
|
|
493
525
|
|
|
494
526
|
Not yet: canvas drag-and-drop (list fields, the outline, manual
|
|
495
527
|
collections and the pages tree drag since 2026-09-09 — spec
|
|
@@ -517,6 +549,42 @@ after both are on the registry. From the repo root:
|
|
|
517
549
|
`prepublishOnly` rebuilds each package. The Claude Code skill
|
|
518
550
|
`.claude/skills/releasing-smoodly` carries the same order with its checks.
|
|
519
551
|
|
|
552
|
+
## Follow-ups (logged 2026-09-27, editor safety spec)
|
|
553
|
+
|
|
554
|
+
- Scan paging: `health.scan` makes one bulk read per table (versions by
|
|
555
|
+
id in chunks of 100), and PostgREST's max-rows would cap a very large
|
|
556
|
+
space. Page it, and add a rail badge, when a real site's size asks.
|
|
557
|
+
- Refs inside unknown nodes are not indexed: without a schema nothing
|
|
558
|
+
says which values are refs, so safe-delete cannot see them. Publish
|
|
559
|
+
refuses such nodes and a live one renders nothing.
|
|
560
|
+
- Leftover shared values after `.localized()` is turned on stay on the
|
|
561
|
+
entry as every locale's fallback; removing them for good waits on
|
|
562
|
+
the fix tooling (OQ 35). `localization-on` is reported only where a
|
|
563
|
+
locale has no value of its own, so a fully translated entry no
|
|
564
|
+
longer lists it.
|
|
565
|
+
- A visual global item's settings misfits (`settings.x`) don't reach
|
|
566
|
+
their widgets in the Globals form: `GlobalFields` keys errors by the
|
|
567
|
+
bare field key, the same gap the validator's refusal keys had before.
|
|
568
|
+
The notice and the refusal list still name them.
|
|
569
|
+
- A CLI drift report was left out (the admin already has the config and
|
|
570
|
+
the database; a CLI needs the service key and a TS config loader).
|
|
571
|
+
|
|
572
|
+
## Follow-ups (logged 2026-09-26, redirects spec)
|
|
573
|
+
|
|
574
|
+
- Destinations outside Smoodly, extension and query-string addresses,
|
|
575
|
+
code-owned renames (a collection `path`, a fixed slug): the guide's
|
|
576
|
+
`next.config` recipe; a `redirects` table is OQ 34.
|
|
577
|
+
- The content API's `getPage("/old")` returns null for a redirect row
|
|
578
|
+
(spec §7); a `follow` option is a one-line addition if a site asks.
|
|
579
|
+
- A redirect row has no audit columns (who, when); add them with a
|
|
580
|
+
need. The Redirects page is not paged.
|
|
581
|
+
- The browser suite covers rename, the unticked box, no chains, the
|
|
582
|
+
Settings tab's list and the manual add/remove; a parent's MOVE
|
|
583
|
+
redirecting a child's URL is proven at the store contract and ops
|
|
584
|
+
level only (no tree drag in the suite yet).
|
|
585
|
+
- `remove` on the Supabase index deletes by target, one statement per
|
|
586
|
+
target (the RPC now spares redirect rows); fine at subtree sizes.
|
|
587
|
+
|
|
520
588
|
## Follow-ups (logged 2026-09-26, security pass)
|
|
521
589
|
|
|
522
590
|
- The bridge posts with `"*"` on both sides. Safe today: the admin
|
|
@@ -649,9 +717,13 @@ after both are on the registry. From the repo root:
|
|
|
649
717
|
section, a global or a page is a plain chip input.
|
|
650
718
|
- The picker's "open" link goes to `/admin/<collection>/<id>` without
|
|
651
719
|
the edited locale; the form opens in the URL's or the default locale.
|
|
652
|
-
- The Combobox list
|
|
653
|
-
(
|
|
654
|
-
|
|
720
|
+
- The Combobox list is portaled into the admin wrapper and fixed to the
|
|
721
|
+
viewport (2026-09-27), so a modal body or scrolling form no longer
|
|
722
|
+
clips it; it flips upwards when the viewport has no room under the
|
|
723
|
+
input (`listPlacement`) and follows the input on scroll. It does not
|
|
724
|
+
yet shrink when neither side has room for it. A single select (a
|
|
725
|
+
single `f.ref`, the redirect target) shows its pick inside the input;
|
|
726
|
+
a single ref's status, open link and remove sit in the input too.
|
|
655
727
|
- `RefOption.status` is the three-way `LocaleStatus` now; the old
|
|
656
728
|
"(draft)" suffix in option titles is gone with the native select.
|
|
657
729
|
- A `refList` on a global item's `settings` still loads no options in
|
package/dist/admin/client-ops.js
CHANGED
|
@@ -4,6 +4,8 @@ const GROUPS = {
|
|
|
4
4
|
globals: ["list", "get", "save", "setStatus", "discardDraft", "listVersions", "getVersion", "restore"],
|
|
5
5
|
assets: ["list", "get", "beginUpload", "finishUpload", "update", "delete", "usage"],
|
|
6
6
|
members: ["list", "me", "invite", "setRole", "remove"],
|
|
7
|
+
redirects: ["list", "targets", "add", "remove"],
|
|
8
|
+
health: ["scan"],
|
|
7
9
|
schema: ["status"],
|
|
8
10
|
preview: ["enable", "disable"],
|
|
9
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
|
+
}
|