@drawnagency/primitives 0.1.115 → 0.1.117
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/dist/chunk-26V4YNOR.js +108 -0
- package/dist/chunk-DKZCHCV3.js +64 -0
- package/dist/chunk-FGQXHUAE.js +39 -0
- package/dist/{chunk-JXATNKLV.js → chunk-HUOW3ZV2.js} +5 -5
- package/dist/{chunk-FHTKSELX.js → chunk-LNQUUC2S.js} +5 -5
- package/dist/{chunk-T26HAKW3.js → chunk-MSBHMY3C.js} +34 -34
- package/dist/components/editor/index.js +8 -8
- package/dist/components/primitives/index.js +7 -7
- package/dist/components/sections/brand-guide.js +5 -5
- package/dist/components/sections/register-schemas.js +12 -12
- package/dist/components/sections/register.js +12 -12
- package/dist/components/shell/EditorShell.d.ts.map +1 -1
- package/dist/deck-builder/index.js +8 -8
- package/dist/hooks/useEditorPublish.d.ts +9 -1
- package/dist/hooks/useEditorPublish.d.ts.map +1 -1
- package/dist/index.js +12 -12
- package/dist/lib/document-refs.d.ts +8 -0
- package/dist/lib/document-refs.d.ts.map +1 -1
- package/dist/lib/document-refs.js +14 -0
- package/dist/lib/index-ops.js +3 -3
- package/dist/lib/index.js +12 -12
- package/dist/lib/reference-validation.js +4 -33
- package/dist/lib/section-check.d.ts +49 -0
- package/dist/lib/section-check.d.ts.map +1 -0
- package/dist/lib/section-check.js +14 -0
- package/dist/lib/site-validation.d.ts +123 -0
- package/dist/lib/site-validation.d.ts.map +1 -0
- package/dist/lib/site-validation.js +230 -0
- package/package.json +14 -1
- package/src/components/shell/EditorShell.tsx +21 -0
- package/src/hooks/useEditorPublish.ts +18 -1
- package/src/lib/document-refs.ts +30 -0
- package/src/lib/section-check.ts +166 -0
- package/src/lib/site-validation.ts +269 -0
- package/dist/{chunk-NX7TFHA4.js → chunk-CEFFP3CH.js} +7 -7
- package/dist/{chunk-YV3YSZEB.js → chunk-L7WTERU5.js} +3 -3
- package/dist/{chunk-WYOFQHKI.js → chunk-W72BPGR3.js} +3 -3
|
@@ -274,6 +274,11 @@ export default function EditorShell({
|
|
|
274
274
|
useEffect(() => {
|
|
275
275
|
savedShaRef.current = savedSha;
|
|
276
276
|
}, [savedSha]);
|
|
277
|
+
// Same for main: a no-draft save sends it as `mainVersion`.
|
|
278
|
+
const mainShaRef = useRef<string | null>(null);
|
|
279
|
+
useEffect(() => {
|
|
280
|
+
mainShaRef.current = mainSha;
|
|
281
|
+
}, [mainSha]);
|
|
277
282
|
const [changedSectionIds, setChangedSectionIds] = useState<Set<string>>(new Set());
|
|
278
283
|
const [mainIndex, setMainIndex] = useState<SiteIndex | null>(null);
|
|
279
284
|
// Ref mirrors of the diff state, so save-time callbacks (which run inside a
|
|
@@ -652,6 +657,13 @@ export default function EditorShell({
|
|
|
652
657
|
: null;
|
|
653
658
|
if (rebasedManifest) setMediaManifest(rebasedManifest);
|
|
654
659
|
if (remote.mediaManifest) baseMediaManifestRef.current = remote.mediaManifest;
|
|
660
|
+
// The document sidecar only ever holds committed entries (staged uploads
|
|
661
|
+
// and pending deletions live in the pipeline), so the remote one replaces
|
|
662
|
+
// it outright — no rebase. Skipping it left a peer's documents missing
|
|
663
|
+
// from this tab and, via the retry's cache write, from every reload.
|
|
664
|
+
if (remote.documentManifest) setDocumentManifest(remote.documentManifest);
|
|
665
|
+
// No draft: the fetch read main, which is now what this view is based on.
|
|
666
|
+
if (remote.savedBranchSha === null) setMainSha(remote.sha);
|
|
655
667
|
|
|
656
668
|
setSections(mergedSections);
|
|
657
669
|
setSiteIndex(mergedIndex);
|
|
@@ -663,7 +675,9 @@ export default function EditorShell({
|
|
|
663
675
|
// (and onBaselineCommitted reads the diff refs right after it), before
|
|
664
676
|
// React flushes the setState calls above into their mirror effects.
|
|
665
677
|
savedShaRef.current = remote.savedBranchSha;
|
|
678
|
+
if (remote.savedBranchSha === null) mainShaRef.current = remote.sha;
|
|
666
679
|
if (rebasedManifest) mediaManifestRef.current = rebasedManifest;
|
|
680
|
+
if (remote.documentManifest) documentManifestRef.current = remote.documentManifest;
|
|
667
681
|
sectionsRef.current = mergedSections;
|
|
668
682
|
siteIndexRef.current = mergedIndex;
|
|
669
683
|
changedSectionIdsRef.current = new Set(remote.changedSectionIds);
|
|
@@ -774,6 +788,11 @@ export default function EditorShell({
|
|
|
774
788
|
mediaManifestRef.current = rebasedManifest;
|
|
775
789
|
baseMediaManifestRef.current = remote.mediaManifest;
|
|
776
790
|
}
|
|
791
|
+
// Committed-only, so replaced outright — see recoverConflict.
|
|
792
|
+
if (remote.documentManifest) {
|
|
793
|
+
setDocumentManifest(remote.documentManifest);
|
|
794
|
+
documentManifestRef.current = remote.documentManifest;
|
|
795
|
+
}
|
|
777
796
|
setSections(result.sections);
|
|
778
797
|
setSiteIndex(result.index);
|
|
779
798
|
setTagsDegraded(remote.tagsDegraded);
|
|
@@ -999,6 +1018,7 @@ export default function EditorShell({
|
|
|
999
1018
|
mediaManifest,
|
|
1000
1019
|
mediaManifestRef,
|
|
1001
1020
|
documentManifest: documentManifest ?? {},
|
|
1021
|
+
documentManifestRef,
|
|
1002
1022
|
pendingDocuments: mediaPipeline.pendingDocuments,
|
|
1003
1023
|
onDocumentsPublished: (ids) => { void mediaPipeline.publishDocuments(ids); },
|
|
1004
1024
|
pendingDocumentDeletions: mediaPipeline.pendingDocumentDeletions,
|
|
@@ -1054,6 +1074,7 @@ export default function EditorShell({
|
|
|
1054
1074
|
},
|
|
1055
1075
|
onPublishComplete: buildStatus.startTracking,
|
|
1056
1076
|
getBaseVersion: () => savedShaRef.current,
|
|
1077
|
+
getMainVersion: () => mainShaRef.current,
|
|
1057
1078
|
recoverConflict,
|
|
1058
1079
|
// C1: re-prime the clean content-cache baseline after every successful plain
|
|
1059
1080
|
// save (discardSavedChanges just cleared it). The hook hands us the COMMITTED
|
|
@@ -93,6 +93,10 @@ interface PublishDeps {
|
|
|
93
93
|
// no draft exists yet). Sent with saves so the server can 409 on a concurrent
|
|
94
94
|
// edit instead of silently overwriting it. Read at save time (latest value).
|
|
95
95
|
getBaseVersion: () => string | null;
|
|
96
|
+
/** The main commit this editor's content was read at. Sent as `mainVersion`
|
|
97
|
+
* with a no-draft save, so a publish landed by someone else since this tab
|
|
98
|
+
* loaded 409s into recoverConflict instead of committing a stale view. */
|
|
99
|
+
getMainVersion?: () => string | null;
|
|
96
100
|
/** Injected by EditorShell. On a 409 (concurrent edit) the save calls this to
|
|
97
101
|
* re-fetch the fresh draft, rebase local unsaved work on top, and refresh the
|
|
98
102
|
* optimistic-concurrency base (savedShaRef). Returns true when recovery
|
|
@@ -125,6 +129,10 @@ interface PublishDeps {
|
|
|
125
129
|
/** Committed document manifest, merged with the staged entries this save
|
|
126
130
|
* commits. Optional so pre-documents callers keep compiling. */
|
|
127
131
|
documentManifest?: DocumentManifest;
|
|
132
|
+
/** Optional ref mirror of `documentManifest`, read at save time — same
|
|
133
|
+
* contract as mediaManifestRef: the 409 retry sees recoverConflict's
|
|
134
|
+
* refreshed sidecar instead of this render's pre-conflict copy. */
|
|
135
|
+
documentManifestRef?: React.RefObject<DocumentManifest | null>;
|
|
128
136
|
pendingDocuments?: { id: string; entry: DocumentManifestEntry }[];
|
|
129
137
|
/** Ids of COMMITTED documents the editor has deleted but not yet saved. They
|
|
130
138
|
* ride the final request as `deletedDocumentIds`; a staged-but-uncommitted
|
|
@@ -166,6 +174,7 @@ export function useEditorPublish({
|
|
|
166
174
|
onShasUpdated,
|
|
167
175
|
onPublishComplete,
|
|
168
176
|
getBaseVersion,
|
|
177
|
+
getMainVersion,
|
|
169
178
|
recoverConflict,
|
|
170
179
|
onBaselineCommitted,
|
|
171
180
|
onSaveWarnings,
|
|
@@ -173,6 +182,7 @@ export function useEditorPublish({
|
|
|
173
182
|
savingRef,
|
|
174
183
|
getEditGeneration,
|
|
175
184
|
documentManifest = {},
|
|
185
|
+
documentManifestRef,
|
|
176
186
|
pendingDocuments = [],
|
|
177
187
|
pendingDocumentDeletions = [],
|
|
178
188
|
onDocumentsPublished,
|
|
@@ -358,6 +368,9 @@ export function useEditorPublish({
|
|
|
358
368
|
}
|
|
359
369
|
|
|
360
370
|
let baseVersion = getBaseVersion();
|
|
371
|
+
const mainVersion = getMainVersion?.() ?? null;
|
|
372
|
+
// Only the request that forks the draft carries it; later chunks have a base.
|
|
373
|
+
const pinMain = () => (baseVersion === null && mainVersion !== null ? { mainVersion } : {});
|
|
361
374
|
const uploadedItems: MediaItem[] = [];
|
|
362
375
|
const totalUploads = gathered.mediaUploads.length;
|
|
363
376
|
|
|
@@ -413,6 +426,7 @@ export function useEditorPublish({
|
|
|
413
426
|
}
|
|
414
427
|
const data = await postSave({
|
|
415
428
|
baseVersion,
|
|
429
|
+
...pinMain(),
|
|
416
430
|
media: {
|
|
417
431
|
uploads: chunk.map(({ id, path, base64 }) => ({
|
|
418
432
|
// content-ops ignores `item` for blob emission; the folder segment
|
|
@@ -441,6 +455,7 @@ export function useEditorPublish({
|
|
|
441
455
|
for (const { id, entry } of manifestOnly) committedDocuments[id] = entry;
|
|
442
456
|
const data = await postSave({
|
|
443
457
|
baseVersion,
|
|
458
|
+
...pinMain(),
|
|
444
459
|
documentManifest: documentManifestToSend(),
|
|
445
460
|
});
|
|
446
461
|
baseVersion = data.sha;
|
|
@@ -461,6 +476,7 @@ export function useEditorPublish({
|
|
|
461
476
|
};
|
|
462
477
|
const data = await postSave({
|
|
463
478
|
baseVersion,
|
|
479
|
+
...pinMain(),
|
|
464
480
|
media: { uploads: chunk, deletions: [], manifest },
|
|
465
481
|
});
|
|
466
482
|
baseVersion = data.sha;
|
|
@@ -486,6 +502,7 @@ export function useEditorPublish({
|
|
|
486
502
|
sections: dirty.map(({ sectionId, content }) => ({ id: sectionId, content })),
|
|
487
503
|
siteIndex,
|
|
488
504
|
baseVersion,
|
|
505
|
+
...pinMain(),
|
|
489
506
|
...(args.deletedSectionIds?.length ? { deletedSectionIds: args.deletedSectionIds } : {}),
|
|
490
507
|
...(args.isConfigDirty() ? { siteConfig: args.siteConfig } : {}),
|
|
491
508
|
...(gathered.hasMediaChanges ? {
|
|
@@ -607,7 +624,7 @@ export function useEditorPublish({
|
|
|
607
624
|
// Read through the ref (when the shell supplies one) so the 409 retry
|
|
608
625
|
// below sees recoverConflict's rebased manifest, not this render's copy.
|
|
609
626
|
mediaManifest: mediaManifestRef?.current ?? mediaManifest,
|
|
610
|
-
documentManifest,
|
|
627
|
+
documentManifest: documentManifestRef?.current ?? documentManifest,
|
|
611
628
|
pendingDocuments,
|
|
612
629
|
pendingDocumentDeletions,
|
|
613
630
|
saveChunkBudget,
|
package/src/lib/document-refs.ts
CHANGED
|
@@ -81,3 +81,33 @@ export function clearDocumentRefs(
|
|
|
81
81
|
if (!changed) return { changed: false, section };
|
|
82
82
|
return { changed: true, section: { ...section, content: { ...section.content, children: next } } };
|
|
83
83
|
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The non-empty `documentId`s referenced by unvalidated section JSON, including
|
|
87
|
+
* ones nested in a container's children — `tallyDocumentIds` for a body that
|
|
88
|
+
* has not been through a schema, so every step is shape-checked. Walks by node
|
|
89
|
+
* type like the rest of this module, so an unrelated field called `doc` is
|
|
90
|
+
* never mistaken for a reference.
|
|
91
|
+
*/
|
|
92
|
+
export function collectDocumentIds(value: unknown, out: Set<string> = new Set()): Set<string> {
|
|
93
|
+
if (Array.isArray(value)) {
|
|
94
|
+
for (const item of value) collectDocumentIds(item, out);
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
if (typeof value !== "object" || value === null) return out;
|
|
98
|
+
const node = value as { type?: unknown; content?: unknown };
|
|
99
|
+
const content = node.content;
|
|
100
|
+
if (typeof content !== "object" || content === null || Array.isArray(content)) return out;
|
|
101
|
+
const record = content as Record<string, unknown>;
|
|
102
|
+
|
|
103
|
+
if (node.type === "document") {
|
|
104
|
+
const doc = record.doc;
|
|
105
|
+
const documentId =
|
|
106
|
+
typeof doc === "object" && doc !== null ? (doc as Record<string, unknown>).documentId : undefined;
|
|
107
|
+
// Empty string is the routine unconfigured slot (the schema defaults to
|
|
108
|
+
// it), not a broken reference.
|
|
109
|
+
if (typeof documentId === "string" && documentId !== "") out.add(documentId);
|
|
110
|
+
}
|
|
111
|
+
if (Array.isArray(record.children)) collectDocumentIds(record.children, out);
|
|
112
|
+
return out;
|
|
113
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { decodeHTMLStrict } from "entities";
|
|
2
|
+
import type { ZodError } from "zod";
|
|
3
|
+
import { blockDepth, MAX_BLOCK_DEPTH, LayoutEnvelopeSchema } from "../schemas/block";
|
|
4
|
+
import { getSchema, getRichTextFields } from "./registry";
|
|
5
|
+
import { REFERENCE_TYPE } from "./reference-type";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The first thing wrong with one section body: schema, then layout envelope,
|
|
9
|
+
* then nesting depth, then a Reference nested in a container. Only ever one — a
|
|
10
|
+
* body failing its schema is not also judged on its layout — so the write
|
|
11
|
+
* path's `errors[0]` stays the first failure it has always reported.
|
|
12
|
+
*/
|
|
13
|
+
export type SectionCheckError =
|
|
14
|
+
| { code: "schema"; zodError: ZodError }
|
|
15
|
+
| { code: "layout"; zodError: ZodError }
|
|
16
|
+
| { code: "depth" }
|
|
17
|
+
| { code: "reference-in-container" };
|
|
18
|
+
|
|
19
|
+
/** HTML entities found in one plain-text field, by path relative to the section. */
|
|
20
|
+
export interface EntityFinding {
|
|
21
|
+
path: string;
|
|
22
|
+
entities: string[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface SectionCheck {
|
|
26
|
+
/**
|
|
27
|
+
* No schema is registered for the type, so only the nesting bound was
|
|
28
|
+
* checked. Whether that is acceptable (a site-local type) or an unknown type
|
|
29
|
+
* is the caller's call.
|
|
30
|
+
*/
|
|
31
|
+
unregistered: boolean;
|
|
32
|
+
error: SectionCheckError | null;
|
|
33
|
+
/** Empty whenever `error` is set. */
|
|
34
|
+
entities: EntityFinding[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Judge one section body against `type`'s registered schema — the write path's
|
|
39
|
+
* (`validateContent`) per-section rules, kept here so the site validators can
|
|
40
|
+
* apply the same ones.
|
|
41
|
+
* Reads the schema registry only: callers register the schemas first.
|
|
42
|
+
*/
|
|
43
|
+
export function checkSection(type: string, body: unknown): SectionCheck {
|
|
44
|
+
const schema = getSchema(type);
|
|
45
|
+
if (!schema) {
|
|
46
|
+
// Nesting is still bounded: MAX_BLOCK_DEPTH guards the renderer, and an
|
|
47
|
+
// unvalidated type can still carry a `children` tree.
|
|
48
|
+
return { unregistered: true, error: blockDepth(body) > MAX_BLOCK_DEPTH ? { code: "depth" } : null, entities: [] };
|
|
49
|
+
}
|
|
50
|
+
const fail = (error: SectionCheckError): SectionCheck => ({ unregistered: false, error, entities: [] });
|
|
51
|
+
|
|
52
|
+
const result = schema.safeParse(body);
|
|
53
|
+
if (!result.success) return fail({ code: "schema", zodError: result.error });
|
|
54
|
+
// The per-type schema doesn't declare the top-level `layout` envelope (it
|
|
55
|
+
// lives beside `type`/`content`/`options` in the section file), so validate
|
|
56
|
+
// it separately — the write path preserves it.
|
|
57
|
+
const rawLayout = (body as { layout?: unknown } | null)?.layout;
|
|
58
|
+
if (rawLayout !== undefined) {
|
|
59
|
+
const layoutResult = LayoutEnvelopeSchema.safeParse(rawLayout);
|
|
60
|
+
if (!layoutResult.success) return fail({ code: "layout", zodError: layoutResult.error });
|
|
61
|
+
}
|
|
62
|
+
if (blockDepth(result.data) > MAX_BLOCK_DEPTH) return fail({ code: "depth" });
|
|
63
|
+
// A Reference is expanded into its sources only at the top level of a page;
|
|
64
|
+
// as a container child it would silently render nothing.
|
|
65
|
+
const children = (result.data as { content?: { children?: unknown } }).content?.children;
|
|
66
|
+
if (Array.isArray(children) && children.some((child) => (child as { type?: unknown })?.type === REFERENCE_TYPE)) {
|
|
67
|
+
return fail({ code: "reference-in-container" });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const entities: EntityFinding[] = [];
|
|
71
|
+
walkSectionForEntities(type, result.data as { content?: unknown; options?: unknown }, "", (path, tokens) => {
|
|
72
|
+
entities.push({ path, entities: tokens });
|
|
73
|
+
});
|
|
74
|
+
return { unregistered: false, error: null, entities };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Plain-text fields render HTML entities LITERALLY (only registry-declared
|
|
78
|
+
// rich-text fields are HTML) — an agent pasting "’" into a heading ships
|
|
79
|
+
// it verbatim to viewers. A token counts as an entity when it is a numeric
|
|
80
|
+
// character reference or when the HTML5 strict decoder recognizes it — strict
|
|
81
|
+
// decoding requires an exact known name + semicolon, so legitimate text like
|
|
82
|
+
// "R&D;" never matches.
|
|
83
|
+
const ENTITY_RE = /&(#\d{1,7}|#[xX][0-9a-fA-F]{1,6}|[a-zA-Z][a-zA-Z0-9]{1,31});/g;
|
|
84
|
+
|
|
85
|
+
function entitiesIn(value: string): string[] {
|
|
86
|
+
const found: string[] = [];
|
|
87
|
+
for (const m of value.matchAll(ENTITY_RE)) {
|
|
88
|
+
const token = m[0];
|
|
89
|
+
if ((m[1].startsWith("#") || decodeHTMLStrict(token) !== token) && !found.includes(token)) {
|
|
90
|
+
found.push(token);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return found;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function visitPlainLeaf(value: unknown, path: string, report: (path: string, entities: string[]) => void): void {
|
|
97
|
+
if (typeof value === "string") {
|
|
98
|
+
const tokens = entitiesIn(value);
|
|
99
|
+
if (tokens.length > 0) report(path, tokens);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (Array.isArray(value)) {
|
|
103
|
+
value.forEach((v, i) => visitPlainLeaf(v, `${path}[${i}]`, report));
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (typeof value === "object" && value !== null) {
|
|
107
|
+
for (const [k, v] of Object.entries(value)) visitPlainLeaf(v, `${path}.${k}`, report);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Mirrors sanitizeSectionContent's traversal: rich-text fields are top-level
|
|
112
|
+
// keys of a block's `content` (per type), and `content.children` holds nested
|
|
113
|
+
// blocks that carry their own type. Everything else — including the whole
|
|
114
|
+
// `options` subtree (plain-text config like the colors section's label) — is
|
|
115
|
+
// plain text. Reported paths are relative to the section object itself
|
|
116
|
+
// ("content.heading", "options.label", "content.children[0].content.heading").
|
|
117
|
+
function walkSectionForEntities(
|
|
118
|
+
type: string,
|
|
119
|
+
block: { content?: unknown; options?: unknown },
|
|
120
|
+
prefix: string,
|
|
121
|
+
report: (path: string, entities: string[]) => void,
|
|
122
|
+
): void {
|
|
123
|
+
const { content, options } = block;
|
|
124
|
+
if (typeof content === "object" && content !== null) {
|
|
125
|
+
const rich = new Set(getRichTextFields(type));
|
|
126
|
+
for (const [key, value] of Object.entries(content as Record<string, unknown>)) {
|
|
127
|
+
if (rich.has(key)) continue;
|
|
128
|
+
if (key === "children" && Array.isArray(value)) {
|
|
129
|
+
value.forEach((child, i) => {
|
|
130
|
+
const c = child as { type?: unknown } | null;
|
|
131
|
+
if (c && typeof c === "object" && typeof c.type === "string") {
|
|
132
|
+
walkSectionForEntities(c.type, c as { content?: unknown; options?: unknown }, `${prefix}content.children[${i}].`, report);
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
visitPlainLeaf(value, `${prefix}content.${key}`, report);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (typeof options === "object" && options !== null) {
|
|
141
|
+
visitPlainLeaf(options, `${prefix}options`, report);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The write path's sentence for a section check's error, shared with
|
|
147
|
+
* validate_site so a dry run words a failure exactly as the save it predicts.
|
|
148
|
+
*/
|
|
149
|
+
export function sectionCheckMessage(sectionId: string, type: string, code: SectionCheckError["code"]): string {
|
|
150
|
+
switch (code) {
|
|
151
|
+
case "schema":
|
|
152
|
+
return `Validation failed: section "${sectionId}" (${type})`;
|
|
153
|
+
case "layout":
|
|
154
|
+
return `Validation failed: section "${sectionId}" has an invalid layout envelope`;
|
|
155
|
+
case "depth":
|
|
156
|
+
return `Validation failed: section "${sectionId}" exceeds max nesting depth (${MAX_BLOCK_DEPTH})`;
|
|
157
|
+
case "reference-in-container":
|
|
158
|
+
return `Validation failed: section "${sectionId}" holds a Reference, which can only sit at the top level of a page, not inside a container`;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** The write path's warning for one entity finding, shared with validate_site. */
|
|
163
|
+
export function entityWarning(sectionId: string, { path, entities }: EntityFinding): string {
|
|
164
|
+
const noun = entities.length > 1 ? "entities" : "entity";
|
|
165
|
+
return `section "${sectionId}": "${path}" contains HTML ${noun} ${entities.join(", ")} — this is a plain-text field, so entities render literally; if a character was intended, use the character itself.`;
|
|
166
|
+
}
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import type { ZodType } from "zod";
|
|
2
|
+
import { IndexSchema, SiteConfigSchema, type SiteIndex } from "../schemas/site-config";
|
|
3
|
+
import { ImageManifestSchema } from "../schemas/media";
|
|
4
|
+
import { DOCUMENT_MANIFEST_PATH, DocumentManifestSchema } from "../schemas/documents";
|
|
5
|
+
import { NAV, TAGS, readSidecarText, type SidecarDefinition, type SidecarDiagnostic } from "./sidecars";
|
|
6
|
+
import { checkSection } from "./section-check";
|
|
7
|
+
import { upgradeLegacySection } from "./migrate-sections-transform";
|
|
8
|
+
import { collectImageIds } from "./image-refs";
|
|
9
|
+
import { collectDocumentIds } from "./document-refs";
|
|
10
|
+
import { referenceProblems, type ReferenceProblem } from "./reference-validation";
|
|
11
|
+
|
|
12
|
+
const INDEX_PATH = "src/content/index.json";
|
|
13
|
+
const SITE_CONFIG_PATH = "src/content/site-config.json";
|
|
14
|
+
const IMAGE_MANIFEST_PATH = "src/content/image-manifest.json";
|
|
15
|
+
const sectionPath = (id: string): string => `src/content/sections/${id}.json`;
|
|
16
|
+
|
|
17
|
+
/** What the caller can vouch for. Never how severe anything is — that is SEVERITY. */
|
|
18
|
+
export interface SiteValidationPolicy {
|
|
19
|
+
/**
|
|
20
|
+
* A section type with no registered schema: `trust` reads it as a real type
|
|
21
|
+
* this caller cannot check (the connector, whose branch index is the proof; a
|
|
22
|
+
* CLI whose site sections module failed to load), `reject` as an unknown type.
|
|
23
|
+
*/
|
|
24
|
+
unregisteredTypes: "trust" | "reject";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface SchemaIssue {
|
|
28
|
+
path: readonly PropertyKey[];
|
|
29
|
+
message: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
type ProblemBody =
|
|
33
|
+
| { code: "index-missing" }
|
|
34
|
+
| { code: "index-invalid"; detail: string }
|
|
35
|
+
| { code: "site-id-empty" }
|
|
36
|
+
| { code: "site-config-missing" }
|
|
37
|
+
| { code: "site-config-invalid"; detail: string }
|
|
38
|
+
| { code: "sidecar-degraded"; sidecar: "nav" | "tags" }
|
|
39
|
+
| { code: "sidecar-repaired"; sidecar: "nav" | "tags"; diagnostic: SidecarDiagnostic }
|
|
40
|
+
| { code: "image-manifest-invalid"; detail: string }
|
|
41
|
+
| { code: "document-manifest-invalid"; detail: string }
|
|
42
|
+
| { code: "section-missing"; sectionId: string }
|
|
43
|
+
| { code: "section-unparseable"; sectionId: string; detail: string }
|
|
44
|
+
| { code: "section-id-mismatch"; sectionId: string; fileId: unknown }
|
|
45
|
+
| { code: "section-type-mismatch"; sectionId: string; fileType: unknown; indexType: string }
|
|
46
|
+
| { code: "unknown-type"; sectionId: string; type: string }
|
|
47
|
+
| { code: "unvalidated-type"; sectionId: string; type: string }
|
|
48
|
+
| { code: "section-invalid"; sectionId: string; type: string; issues: SchemaIssue[] }
|
|
49
|
+
| { code: "layout-invalid"; sectionId: string; issues: SchemaIssue[] }
|
|
50
|
+
| { code: "too-deep"; sectionId: string }
|
|
51
|
+
| { code: "reference-in-container"; sectionId: string }
|
|
52
|
+
| { code: "html-entities"; sectionId: string; path: string; entities: string[] }
|
|
53
|
+
| { code: "dangling-image"; sectionId: string; imageId: string }
|
|
54
|
+
| { code: "dangling-document"; sectionId: string; documentId: string }
|
|
55
|
+
| { code: "reference-problem"; problem: ReferenceProblem };
|
|
56
|
+
|
|
57
|
+
export type SiteProblemCode = ProblemBody["code"];
|
|
58
|
+
export type Severity = "error" | "warning";
|
|
59
|
+
export type SiteProblem = ProblemBody & { severity: Severity };
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The one severity table every caller shares. An ERROR is something the site
|
|
63
|
+
* cannot load, or authored content it silently loses or rejects. A WARNING is
|
|
64
|
+
* something the site renders through — dropping or repairing it itself — and
|
|
65
|
+
* that is fixable in-product, so it must not fail a build.
|
|
66
|
+
*/
|
|
67
|
+
export const SEVERITY: Readonly<Record<SiteProblemCode, Severity>> = {
|
|
68
|
+
"index-missing": "error",
|
|
69
|
+
"index-invalid": "error",
|
|
70
|
+
"site-id-empty": "error",
|
|
71
|
+
"site-config-missing": "error",
|
|
72
|
+
"site-config-invalid": "error",
|
|
73
|
+
// The loader drops what it cannot read, so authored grouping or tags vanish.
|
|
74
|
+
"sidecar-degraded": "error",
|
|
75
|
+
// What reconcile repairs on every load: the runtime has already fixed it.
|
|
76
|
+
"sidecar-repaired": "warning",
|
|
77
|
+
"image-manifest-invalid": "error",
|
|
78
|
+
"document-manifest-invalid": "error",
|
|
79
|
+
"section-missing": "error",
|
|
80
|
+
"section-unparseable": "error",
|
|
81
|
+
"section-id-mismatch": "error",
|
|
82
|
+
"section-type-mismatch": "error",
|
|
83
|
+
"unknown-type": "error",
|
|
84
|
+
"unvalidated-type": "warning",
|
|
85
|
+
"section-invalid": "error",
|
|
86
|
+
"layout-invalid": "error",
|
|
87
|
+
"too-deep": "error",
|
|
88
|
+
"reference-in-container": "error",
|
|
89
|
+
"html-entities": "warning",
|
|
90
|
+
"dangling-image": "warning",
|
|
91
|
+
"dangling-document": "warning",
|
|
92
|
+
"reference-problem": "warning",
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
export interface SiteValidation {
|
|
96
|
+
problems: SiteProblem[];
|
|
97
|
+
/** The section ids the index places on pages; null when the index could not be read. */
|
|
98
|
+
placedIds: string[] | null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function parseJson(text: string): { ok: true; value: unknown } | { ok: false; detail: string } {
|
|
102
|
+
try {
|
|
103
|
+
return { ok: true, value: JSON.parse(text) };
|
|
104
|
+
} catch (e) {
|
|
105
|
+
return { ok: false, detail: e instanceof Error ? e.message : String(e) };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The text parsed and schema-checked, or why it could not be. */
|
|
110
|
+
function parseFile<T>(text: string, schema: ZodType<T>): { ok: true; data: T } | { ok: false; detail: string } {
|
|
111
|
+
const json = parseJson(text);
|
|
112
|
+
if (!json.ok) return json;
|
|
113
|
+
const parsed = schema.safeParse(json.value);
|
|
114
|
+
return parsed.success ? { ok: true, data: parsed.data } : { ok: false, detail: parsed.error.message };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Validate a site's committed content. `files` maps repo paths under
|
|
119
|
+
* `src/content/` to their text; an absent path is an absent file. Pure: it reads
|
|
120
|
+
* the schema registry, so callers register built-in (and any site-local)
|
|
121
|
+
* schemas first. Only sections the index places on a page are judged — orphan
|
|
122
|
+
* files are the caller's concern.
|
|
123
|
+
*/
|
|
124
|
+
export function validateSite(files: Readonly<Record<string, string>>, policy: SiteValidationPolicy): SiteValidation {
|
|
125
|
+
const problems: SiteProblem[] = [];
|
|
126
|
+
const report = (body: ProblemBody) => problems.push({ ...body, severity: SEVERITY[body.code] });
|
|
127
|
+
|
|
128
|
+
const indexText = files[INDEX_PATH];
|
|
129
|
+
if (indexText === undefined) {
|
|
130
|
+
report({ code: "index-missing" });
|
|
131
|
+
return { problems, placedIds: null };
|
|
132
|
+
}
|
|
133
|
+
const indexRead = parseFile(indexText, IndexSchema);
|
|
134
|
+
if (!indexRead.ok) {
|
|
135
|
+
report({ code: "index-invalid", detail: indexRead.detail });
|
|
136
|
+
return { problems, placedIds: null };
|
|
137
|
+
}
|
|
138
|
+
const index: SiteIndex = indexRead.data;
|
|
139
|
+
const placedIds = [...new Set(index.pages.flatMap((p) => p.order))];
|
|
140
|
+
|
|
141
|
+
if (!index.siteId) report({ code: "site-id-empty" });
|
|
142
|
+
|
|
143
|
+
const configText = files[SITE_CONFIG_PATH];
|
|
144
|
+
if (configText === undefined) {
|
|
145
|
+
report({ code: "site-config-missing" });
|
|
146
|
+
} else {
|
|
147
|
+
const configRead = parseFile(configText, SiteConfigSchema);
|
|
148
|
+
if (!configRead.ok) report({ code: "site-config-invalid", detail: configRead.detail });
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const checkSidecar = <E>(def: SidecarDefinition<E>): E[] | null => {
|
|
152
|
+
const text = files[def.path];
|
|
153
|
+
if (text === undefined) return [];
|
|
154
|
+
const read = readSidecarText(def, text);
|
|
155
|
+
if (read.degraded) {
|
|
156
|
+
report({ code: "sidecar-degraded", sidecar: def.name });
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
for (const diagnostic of def.reconcile(read.entries, index).diagnostics) {
|
|
160
|
+
report({ code: "sidecar-repaired", sidecar: def.name, diagnostic });
|
|
161
|
+
}
|
|
162
|
+
return read.entries;
|
|
163
|
+
};
|
|
164
|
+
checkSidecar(NAV);
|
|
165
|
+
// The tag ids a Reference may name: none when the file is absent, unknown
|
|
166
|
+
// (null, so tag ids go unchecked) when it cannot be read in full.
|
|
167
|
+
const tags = checkSidecar(TAGS);
|
|
168
|
+
const tagIds = tags === null ? null : new Set(tags.map((t) => t.id));
|
|
169
|
+
|
|
170
|
+
// The ids in each manifest; null when it is present but unreadable — nothing
|
|
171
|
+
// can be judged against a manifest we could not read, and reporting every
|
|
172
|
+
// reference as dangling would bury the one diagnostic that matters. Absent is
|
|
173
|
+
// the normal state of a site with nothing uploaded: an empty manifest.
|
|
174
|
+
let imageIds: Set<string> | null = new Set();
|
|
175
|
+
const imageText = files[IMAGE_MANIFEST_PATH];
|
|
176
|
+
if (imageText !== undefined) {
|
|
177
|
+
const read = parseFile(imageText, ImageManifestSchema);
|
|
178
|
+
if (read.ok) imageIds = new Set(Object.keys(read.data.images));
|
|
179
|
+
else {
|
|
180
|
+
report({ code: "image-manifest-invalid", detail: read.detail });
|
|
181
|
+
imageIds = null;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
let documentIds: Set<string> | null = new Set();
|
|
185
|
+
const documentText = files[DOCUMENT_MANIFEST_PATH];
|
|
186
|
+
if (documentText !== undefined) {
|
|
187
|
+
const read = parseFile(documentText, DocumentManifestSchema);
|
|
188
|
+
if (read.ok) documentIds = new Set(Object.keys(read.data));
|
|
189
|
+
else {
|
|
190
|
+
report({ code: "document-manifest-invalid", detail: read.detail });
|
|
191
|
+
documentIds = null;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const bodies: { id: string; content: unknown }[] = [];
|
|
196
|
+
for (const id of placedIds) {
|
|
197
|
+
const text = files[sectionPath(id)];
|
|
198
|
+
if (text === undefined) {
|
|
199
|
+
report({ code: "section-missing", sectionId: id });
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
const json = parseJson(text);
|
|
203
|
+
if (!json.ok) {
|
|
204
|
+
report({ code: "section-unparseable", sectionId: id, detail: json.detail });
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
const onDisk = (typeof json.value === "object" && json.value !== null ? json.value : {}) as Record<string, unknown>;
|
|
208
|
+
if (onDisk.id !== id) report({ code: "section-id-mismatch", sectionId: id, fileId: onDisk.id });
|
|
209
|
+
// Retired types are rewritten at every load before the site validates them,
|
|
210
|
+
// so validate what the site runs, by the type it runs as.
|
|
211
|
+
const body = upgradeLegacySection(json.value);
|
|
212
|
+
bodies.push({ id, content: body });
|
|
213
|
+
const upgradedType = typeof body === "object" && body !== null ? (body as { type?: unknown }).type : undefined;
|
|
214
|
+
const type = typeof upgradedType === "string" ? upgradedType : "";
|
|
215
|
+
|
|
216
|
+
// The index may name either form of a legacy section: the retired type
|
|
217
|
+
// both still hold, or the type it upgrades to once a writer has reconciled
|
|
218
|
+
// the index. Either renders; only an index type matching neither is wrong.
|
|
219
|
+
const indexType = index.sections[id].type;
|
|
220
|
+
if (indexType !== onDisk.type && indexType !== upgradedType) {
|
|
221
|
+
report({ code: "section-type-mismatch", sectionId: id, fileType: onDisk.type, indexType });
|
|
222
|
+
}
|
|
223
|
+
// A body with no type has just been reported as a mismatch; there is no
|
|
224
|
+
// type to judge it by, and "unknown type \"\"" would say nothing more.
|
|
225
|
+
if (type === "") continue;
|
|
226
|
+
|
|
227
|
+
const check = checkSection(type, body);
|
|
228
|
+
if (check.unregistered) {
|
|
229
|
+
if (policy.unregisteredTypes === "reject") {
|
|
230
|
+
report({ code: "unknown-type", sectionId: id, type });
|
|
231
|
+
} else {
|
|
232
|
+
report({ code: "unvalidated-type", sectionId: id, type });
|
|
233
|
+
if (check.error?.code === "depth") report({ code: "too-deep", sectionId: id });
|
|
234
|
+
}
|
|
235
|
+
} else if (check.error) {
|
|
236
|
+
const error = check.error;
|
|
237
|
+
switch (error.code) {
|
|
238
|
+
case "schema":
|
|
239
|
+
report({ code: "section-invalid", sectionId: id, type, issues: error.zodError.issues });
|
|
240
|
+
break;
|
|
241
|
+
case "layout":
|
|
242
|
+
report({ code: "layout-invalid", sectionId: id, issues: error.zodError.issues });
|
|
243
|
+
break;
|
|
244
|
+
case "depth":
|
|
245
|
+
report({ code: "too-deep", sectionId: id });
|
|
246
|
+
break;
|
|
247
|
+
case "reference-in-container":
|
|
248
|
+
report({ code: "reference-in-container", sectionId: id });
|
|
249
|
+
break;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
for (const { path, entities } of check.entities) report({ code: "html-entities", sectionId: id, path, entities });
|
|
253
|
+
|
|
254
|
+
if (imageIds) {
|
|
255
|
+
for (const imageId of collectImageIds(body)) {
|
|
256
|
+
if (!imageIds.has(imageId)) report({ code: "dangling-image", sectionId: id, imageId });
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
if (documentIds) {
|
|
260
|
+
for (const documentId of collectDocumentIds(body)) {
|
|
261
|
+
if (!documentIds.has(documentId)) report({ code: "dangling-document", sectionId: id, documentId });
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
for (const problem of referenceProblems(bodies, index, tagIds)) report({ code: "reference-problem", problem });
|
|
267
|
+
|
|
268
|
+
return { problems, placedIds };
|
|
269
|
+
}
|
|
@@ -1,10 +1,3 @@
|
|
|
1
|
-
import {
|
|
2
|
-
Kbd,
|
|
3
|
-
Tooltip
|
|
4
|
-
} from "./chunk-JCDZAFX2.js";
|
|
5
|
-
import {
|
|
6
|
-
Button
|
|
7
|
-
} from "./chunk-GGYY3JQJ.js";
|
|
8
1
|
import {
|
|
9
2
|
Checkbox,
|
|
10
3
|
Tabs
|
|
@@ -18,6 +11,13 @@ import {
|
|
|
18
11
|
useMediaLibrary,
|
|
19
12
|
useResolvedMedia
|
|
20
13
|
} from "./chunk-RIITGTV3.js";
|
|
14
|
+
import {
|
|
15
|
+
Kbd,
|
|
16
|
+
Tooltip
|
|
17
|
+
} from "./chunk-JCDZAFX2.js";
|
|
18
|
+
import {
|
|
19
|
+
Button
|
|
20
|
+
} from "./chunk-GGYY3JQJ.js";
|
|
21
21
|
import {
|
|
22
22
|
IconButton
|
|
23
23
|
} from "./chunk-IIDKW7AV.js";
|
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
import {
|
|
2
|
-
computeReorderTarget
|
|
3
|
-
} from "./chunk-DIMDRBFK.js";
|
|
4
1
|
import {
|
|
5
2
|
DragHandle,
|
|
6
3
|
DropEdgeIndicator,
|
|
7
4
|
useMediaLibrary,
|
|
8
5
|
useResolvedMedia
|
|
9
6
|
} from "./chunk-RIITGTV3.js";
|
|
7
|
+
import {
|
|
8
|
+
computeReorderTarget
|
|
9
|
+
} from "./chunk-DIMDRBFK.js";
|
|
10
10
|
import {
|
|
11
11
|
IconButton
|
|
12
12
|
} from "./chunk-IIDKW7AV.js";
|