@drawnagency/primitives 0.1.114 → 0.1.116
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-OAGJBCSH.js → chunk-4WQ36ZP7.js} +19 -3
- package/dist/{chunk-PTY2RG2K.js → chunk-67P34WNW.js} +75 -130
- package/dist/chunk-APIJPTFG.js +64 -0
- package/dist/chunk-CDBURDPX.js +90 -0
- package/dist/{chunk-PLANHXS7.js → chunk-CEFFP3CH.js} +2 -2
- package/dist/chunk-DFKTF3CH.js +0 -0
- package/dist/chunk-DJABADOO.js +131 -0
- package/dist/{chunk-IP2SEBAJ.js → chunk-DJOGPSRU.js} +31 -72
- package/dist/chunk-DKZCHCV3.js +64 -0
- package/dist/chunk-FGQXHUAE.js +39 -0
- package/dist/chunk-GNF6KFQ7.js +18 -0
- package/dist/{chunk-QHMKIODQ.js → chunk-HHNPSKPT.js} +2 -89
- package/dist/{chunk-PZPGJZRG.js → chunk-HUOW3ZV2.js} +3 -3
- package/dist/{chunk-Y5QECJX7.js → chunk-L7WTERU5.js} +2 -2
- package/dist/{chunk-N3G7DOU3.js → chunk-LNQUUC2S.js} +2 -2
- package/dist/{chunk-7SAAJVDE.js → chunk-MSBHMY3C.js} +17 -15
- package/dist/{chunk-PNSCDBU7.js → chunk-O37AV27P.js} +6 -49
- package/dist/{chunk-5KSAS4VY.js → chunk-RIITGTV3.js} +1 -1
- package/dist/chunk-S7QRHUWO.js +8 -0
- package/dist/chunk-VY3IFGE3.js +70 -0
- package/dist/{chunk-VUYCRBJK.js → chunk-W72BPGR3.js} +7 -5
- package/dist/collab/rebase.d.ts.map +1 -1
- package/dist/collab/rebase.js +15 -15
- package/dist/components/editor/index.js +8 -8
- package/dist/components/primitives/index.js +14 -9
- package/dist/components/sections/brand-guide.js +12 -7
- package/dist/components/sections/register-schemas.js +17 -12
- package/dist/components/sections/register.js +17 -12
- package/dist/components/shell/EditorShell.d.ts.map +1 -1
- package/dist/deck-builder/index.js +6 -6
- package/dist/hooks/useEditorPublish.d.ts +4 -1
- package/dist/hooks/useEditorPublish.d.ts.map +1 -1
- package/dist/hooks/useMediaPipeline.d.ts.map +1 -1
- package/dist/index.js +78 -49
- package/dist/lib/dexie.d.ts +7 -1
- package/dist/lib/dexie.d.ts.map +1 -1
- package/dist/lib/dexie.js +3 -2
- 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/image-variant-plan.d.ts +42 -0
- package/dist/lib/image-variant-plan.d.ts.map +1 -0
- package/dist/lib/image-variant-plan.js +53 -0
- package/dist/lib/index-ops.d.ts.map +1 -1
- package/dist/lib/index-ops.js +20 -16
- package/dist/lib/index.d.ts +2 -1
- package/dist/lib/index.d.ts.map +1 -1
- package/dist/lib/index.js +36 -15
- package/dist/lib/loader.d.ts +16 -17
- package/dist/lib/loader.d.ts.map +1 -1
- package/dist/lib/media-ops.d.ts +73 -0
- package/dist/lib/media-ops.d.ts.map +1 -0
- package/dist/lib/media-ops.js +60 -0
- package/dist/lib/nav-tree.d.ts +6 -0
- package/dist/lib/nav-tree.d.ts.map +1 -1
- package/dist/lib/pages.d.ts +2 -1
- package/dist/lib/pages.d.ts.map +1 -1
- 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/sidecar-diagnostics.d.ts +20 -0
- package/dist/lib/sidecar-diagnostics.d.ts.map +1 -0
- package/dist/lib/sidecars.d.ts +118 -0
- package/dist/lib/sidecars.d.ts.map +1 -0
- package/dist/lib/sidecars.js +23 -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/dist/lib/slug.d.ts +2 -0
- package/dist/lib/slug.d.ts.map +1 -0
- package/dist/lib/storage-tier.d.ts +17 -0
- package/dist/lib/storage-tier.d.ts.map +1 -0
- package/dist/lib/storage-tier.js +29 -0
- package/dist/lib/tags.d.ts +6 -0
- package/dist/lib/tags.d.ts.map +1 -1
- package/dist/media/index.js +15 -15
- package/dist/media/queue.d.ts +2 -0
- package/dist/media/queue.d.ts.map +1 -1
- package/dist/media/worker.d.ts +1 -35
- package/dist/media/worker.d.ts.map +1 -1
- package/dist/schemas/index.js +18 -12
- package/dist/schemas/media.d.ts +4 -0
- package/dist/schemas/media.d.ts.map +1 -1
- package/dist/storage/index.js +2 -1
- package/dist/storage/memory.d.ts +21 -0
- package/dist/storage/memory.d.ts.map +1 -0
- package/dist/storage/memory.js +129 -0
- package/dist/storage/types.d.ts +9 -0
- package/dist/storage/types.d.ts.map +1 -1
- package/package.json +34 -1
- package/src/collab/rebase.ts +12 -16
- package/src/components/shell/EditorShell.tsx +7 -4
- package/src/hooks/useEditorPublish.ts +9 -4
- package/src/hooks/useMediaPipeline.ts +29 -23
- package/src/lib/dexie.ts +9 -1
- package/src/lib/document-refs.ts +30 -0
- package/src/lib/image-variant-plan.ts +92 -0
- package/src/lib/index-ops.ts +9 -18
- package/src/lib/index.ts +15 -0
- package/src/lib/loader.ts +18 -77
- package/src/lib/media-ops.ts +112 -0
- package/src/lib/nav-tree.ts +33 -4
- package/src/lib/pages.ts +2 -3
- package/src/lib/section-check.ts +166 -0
- package/src/lib/sidecar-diagnostics.ts +29 -0
- package/src/lib/sidecars.ts +221 -0
- package/src/lib/site-validation.ts +269 -0
- package/src/lib/slug.ts +6 -0
- package/src/lib/storage-tier.ts +38 -0
- package/src/lib/tags.ts +20 -3
- package/src/media/queue.ts +3 -0
- package/src/media/utils.ts +1 -1
- package/src/media/worker.ts +17 -8
- package/src/schemas/media.ts +7 -2
- package/src/storage/memory.ts +178 -0
- package/src/storage/types.ts +15 -2
- /package/dist/{chunk-45FA7Z55.js → chunk-UUUSJDIG.js} +0 -0
|
@@ -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
|
+
}
|
package/src/lib/slug.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// The one slugifier for page slugs and media names. Its own leaf, with no
|
|
2
|
+
// imports, so the media worker can use it without bundling the schemas
|
|
3
|
+
// lib/pages pulls in.
|
|
4
|
+
export function slugifyPageSlug(input: string): string {
|
|
5
|
+
return input.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
6
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { DOCUMENT_GIT_MAX_BYTES, DOCUMENT_BUCKET_MAX_BYTES } from "../schemas/documents";
|
|
2
|
+
import { MEDIA_GIT_MAX_BYTES, MEDIA_BUCKET_MAX_BYTES, type MediaConfig } from "../schemas/media";
|
|
3
|
+
|
|
4
|
+
// Where a file's bytes live, decided by its size: committed to the site repo
|
|
5
|
+
// (git), stored in the optional bucket, or refused. Every upload path and the
|
|
6
|
+
// write path's backstop decide this the same way, and every message quotes the
|
|
7
|
+
// limits through formatBytes, so a limit changes in one place.
|
|
8
|
+
|
|
9
|
+
export { DOCUMENT_GIT_MAX_BYTES, DOCUMENT_BUCKET_MAX_BYTES, MEDIA_GIT_MAX_BYTES, MEDIA_BUCKET_MAX_BYTES };
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The MCP connector's own document ceiling, half the bucket cap. The binding
|
|
13
|
+
* constraint is memory, not time: a presigned PUT cannot stream, so the whole
|
|
14
|
+
* body is buffered (peak about 2x the file). The browser path has no such
|
|
15
|
+
* limit because those bytes never pass through a function.
|
|
16
|
+
*/
|
|
17
|
+
export const DOCUMENT_CONNECTOR_MAX_BYTES = 104_857_600;
|
|
18
|
+
|
|
19
|
+
export type StorageTier = "git" | "bucket" | "too_large";
|
|
20
|
+
|
|
21
|
+
type MediaLimits = Pick<MediaConfig, "maxFileSize" | "maxAssetSize">;
|
|
22
|
+
|
|
23
|
+
/** Media limits come from the site's `media` config; documents' are fixed. */
|
|
24
|
+
export function tierFor(
|
|
25
|
+
kind: "document" | "media",
|
|
26
|
+
bytes: number,
|
|
27
|
+
media: MediaLimits = { maxFileSize: MEDIA_GIT_MAX_BYTES, maxAssetSize: MEDIA_BUCKET_MAX_BYTES },
|
|
28
|
+
): StorageTier {
|
|
29
|
+
const [gitMax, bucketMax] =
|
|
30
|
+
kind === "document" ? [DOCUMENT_GIT_MAX_BYTES, DOCUMENT_BUCKET_MAX_BYTES] : [media.maxFileSize, media.maxAssetSize];
|
|
31
|
+
if (bytes <= gitMax) return "git";
|
|
32
|
+
return bytes <= bucketMax ? "bucket" : "too_large";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** "2.5 MB", "100 MB" — one decimal at most, in binary megabytes. */
|
|
36
|
+
export function formatBytes(bytes: number): string {
|
|
37
|
+
return `${Math.round((bytes / 1024 / 1024) * 10) / 10} MB`;
|
|
38
|
+
}
|
package/src/lib/tags.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { TagEntry } from "../schemas/tags";
|
|
2
2
|
import type { SiteIndex } from "../schemas/site-config";
|
|
3
|
+
import { diagnosticsCollector, type SidecarDiagnostic } from "./sidecar-diagnostics";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Repair the tag sidecar against the current section set.
|
|
@@ -19,6 +20,15 @@ import type { SiteIndex } from "../schemas/site-config";
|
|
|
19
20
|
* React state, where a shared array would let a later edit mutate prior state).
|
|
20
21
|
*/
|
|
21
22
|
export function reconcileTags(entries: TagEntry[], sections: SiteIndex["sections"]): TagEntry[] {
|
|
23
|
+
return reconcileTagsReport(entries, sections).entries;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** reconcileTags plus what the repair changed — what a validator reports. */
|
|
27
|
+
export function reconcileTagsReport(
|
|
28
|
+
entries: TagEntry[],
|
|
29
|
+
sections: SiteIndex["sections"],
|
|
30
|
+
): { entries: TagEntry[]; diagnostics: SidecarDiagnostic[] } {
|
|
31
|
+
const report = diagnosticsCollector();
|
|
22
32
|
const entryList = Array.isArray(entries) ? entries : [];
|
|
23
33
|
const validIds = new Set(sections && typeof sections === "object" ? Object.keys(sections) : []);
|
|
24
34
|
|
|
@@ -26,17 +36,24 @@ export function reconcileTags(entries: TagEntry[], sections: SiteIndex["sections
|
|
|
26
36
|
const result: TagEntry[] = [];
|
|
27
37
|
for (const entry of entryList) {
|
|
28
38
|
if (!entry || typeof entry !== "object") continue;
|
|
29
|
-
if (typeof entry.id !== "string" || entry.id === ""
|
|
39
|
+
if (typeof entry.id !== "string" || entry.id === "") continue;
|
|
40
|
+
if (seenTagIds.has(entry.id)) {
|
|
41
|
+
report.add("duplicate-id", entry.id);
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
30
44
|
seenTagIds.add(entry.id);
|
|
31
45
|
|
|
32
46
|
const claimed = Array.isArray(entry.sectionIds) ? entry.sectionIds : [];
|
|
33
47
|
const sectionIds: string[] = [];
|
|
34
48
|
for (const id of claimed) {
|
|
35
|
-
if (typeof id
|
|
49
|
+
if (typeof id !== "string") continue;
|
|
50
|
+
if (!validIds.has(id)) report.add("dangling", id);
|
|
51
|
+
else if (sectionIds.includes(id)) report.add("duplicate-member", id);
|
|
52
|
+
else sectionIds.push(id);
|
|
36
53
|
}
|
|
37
54
|
result.push({ id: entry.id, label: typeof entry.label === "string" ? entry.label : "", sectionIds });
|
|
38
55
|
}
|
|
39
|
-
return result;
|
|
56
|
+
return { entries: result, diagnostics: report.list() };
|
|
40
57
|
}
|
|
41
58
|
|
|
42
59
|
/**
|
package/src/media/queue.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { MediaKind } from "./types";
|
|
2
|
+
import type { ImageVariantPlan } from "../lib/image-variant-plan";
|
|
2
3
|
|
|
3
4
|
export interface QueueInput {
|
|
4
5
|
buffer: ArrayBuffer;
|
|
@@ -23,6 +24,7 @@ export interface QueueItem {
|
|
|
23
24
|
posterBlob?: Blob;
|
|
24
25
|
width: number;
|
|
25
26
|
height: number;
|
|
27
|
+
plan?: ImageVariantPlan;
|
|
26
28
|
};
|
|
27
29
|
}
|
|
28
30
|
|
|
@@ -116,6 +118,7 @@ export class ProcessingQueue {
|
|
|
116
118
|
posterBlob: msg.posterBlob,
|
|
117
119
|
width: msg.width,
|
|
118
120
|
height: msg.height,
|
|
121
|
+
plan: msg.plan,
|
|
119
122
|
};
|
|
120
123
|
this.options.onEvent({ type: "complete", item: { ...item } });
|
|
121
124
|
// The event above carries the only reference anyone reads. Holding ours
|
package/src/media/utils.ts
CHANGED
package/src/media/worker.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { planImageVariants, type ImageVariantPlan } from "../lib/image-variant-plan";
|
|
2
|
+
|
|
1
3
|
interface ProcessMessage {
|
|
2
4
|
type: "process";
|
|
3
5
|
id: string;
|
|
@@ -24,6 +26,8 @@ interface CompleteMessage {
|
|
|
24
26
|
posterBlob?: Blob;
|
|
25
27
|
width: number;
|
|
26
28
|
height: number;
|
|
29
|
+
/** Static images only — the shared plan the variants were rendered from. */
|
|
30
|
+
plan?: ImageVariantPlan;
|
|
27
31
|
}
|
|
28
32
|
|
|
29
33
|
interface ErrorMessage {
|
|
@@ -77,13 +81,17 @@ self.onmessage = async (e: MessageEvent<ProcessMessage>) => {
|
|
|
77
81
|
|
|
78
82
|
post({ type: "progress", id: msg.id, percent: 25 });
|
|
79
83
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
84
|
+
// Widths, names and paths come from the shared plan (the sharp path uses
|
|
85
|
+
// the same one); this worker only draws. Encoded largest first so the
|
|
86
|
+
// primary blob is the largest variant.
|
|
87
|
+
const plan = planImageVariants({
|
|
88
|
+
hash: msg.hash,
|
|
89
|
+
fileName: msg.originalName,
|
|
90
|
+
width: origWidth,
|
|
91
|
+
height: origHeight,
|
|
92
|
+
media: { sizes: msg.sizes, quality: msg.quality },
|
|
93
|
+
});
|
|
94
|
+
const applicableSizes = plan.variants.map((v) => v.width).reverse();
|
|
87
95
|
|
|
88
96
|
const variants: { width: number; height: number; size: number; blob: Blob }[] = [];
|
|
89
97
|
const totalVariants = applicableSizes.length;
|
|
@@ -97,7 +105,7 @@ self.onmessage = async (e: MessageEvent<ProcessMessage>) => {
|
|
|
97
105
|
const ctx = canvas.getContext("2d")!;
|
|
98
106
|
ctx.drawImage(bitmap, 0, 0, targetWidth, targetHeight);
|
|
99
107
|
|
|
100
|
-
const blob = await canvas.convertToBlob({ type: "image/webp", quality:
|
|
108
|
+
const blob = await canvas.convertToBlob({ type: "image/webp", quality: plan.quality / 100 });
|
|
101
109
|
variants.push({ width: targetWidth, height: targetHeight, size: blob.size, blob });
|
|
102
110
|
|
|
103
111
|
const percent = 25 + Math.round(((i + 1) / totalVariants) * 75);
|
|
@@ -114,6 +122,7 @@ self.onmessage = async (e: MessageEvent<ProcessMessage>) => {
|
|
|
114
122
|
primaryBlob,
|
|
115
123
|
width: origWidth,
|
|
116
124
|
height: origHeight,
|
|
125
|
+
plan,
|
|
117
126
|
});
|
|
118
127
|
} catch (err) {
|
|
119
128
|
const message = err instanceof Error ? err.message : "Processing failed";
|
package/src/schemas/media.ts
CHANGED
|
@@ -55,13 +55,18 @@ export const ImageManifestSchema = z.object({
|
|
|
55
55
|
|
|
56
56
|
export type MediaManifest = z.infer<typeof ImageManifestSchema>;
|
|
57
57
|
|
|
58
|
+
/** Default git-tier cap for media (bytes); a site may override it as `media.maxFileSize`. */
|
|
59
|
+
export const MEDIA_GIT_MAX_BYTES = 5_242_880;
|
|
60
|
+
/** Default bucket-tier cap for media (bytes); a site may override it as `media.maxAssetSize`. */
|
|
61
|
+
export const MEDIA_BUCKET_MAX_BYTES = 209_715_200;
|
|
62
|
+
|
|
58
63
|
export const MediaConfigSchema = z.object({
|
|
59
64
|
sizes: z.array(z.number()).default([640, 1080, 1920]),
|
|
60
|
-
maxFileSize: z.number().default(
|
|
65
|
+
maxFileSize: z.number().default(MEDIA_GIT_MAX_BYTES),
|
|
61
66
|
quality: z.number().min(1).max(100).default(85),
|
|
62
67
|
// Per-file cap for bucket-backed video uploads (bytes). The git-path cap is
|
|
63
68
|
// maxFileSize; anything between the two routes to the AssetStore.
|
|
64
|
-
maxAssetSize: z.number().default(
|
|
69
|
+
maxAssetSize: z.number().default(MEDIA_BUCKET_MAX_BYTES),
|
|
65
70
|
});
|
|
66
71
|
|
|
67
72
|
export type MediaConfig = z.infer<typeof MediaConfigSchema>;
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import {
|
|
2
|
+
IDEMPOTENCY_TRAILER,
|
|
3
|
+
NothingToPublishError,
|
|
4
|
+
StorageConflictError,
|
|
5
|
+
type FileWrite,
|
|
6
|
+
type StorageProvider,
|
|
7
|
+
} from "./types";
|
|
8
|
+
|
|
9
|
+
// An in-memory StorageProvider with real commit history: a `main` branch, an
|
|
10
|
+
// optional draft branch, and commits addressable by sha. It exists so the write
|
|
11
|
+
// path can be tested end to end without mocking it, which is only worth
|
|
12
|
+
// anything if it behaves like the GitHub adapter — the storage contract suite
|
|
13
|
+
// runs the same cases against both. Anything that differs from GitHub here is a
|
|
14
|
+
// bug in this file.
|
|
15
|
+
|
|
16
|
+
type Tree = Map<string, Uint8Array>;
|
|
17
|
+
type Commit = { tree: Tree; parent: string | null; message: string };
|
|
18
|
+
type Files = Record<string, string | Uint8Array | null>;
|
|
19
|
+
|
|
20
|
+
// Mirrors promoteDraft in the GitHub adapter: only these subtrees publish;
|
|
21
|
+
// everything else on main is inherited from main.
|
|
22
|
+
const PUBLISHED_PREFIXES = ["src/content/", "assets/"];
|
|
23
|
+
|
|
24
|
+
export type MemoryStorage = StorageProvider & {
|
|
25
|
+
/**
|
|
26
|
+
* Commit straight to a branch as another writer would — a concurrent editor
|
|
27
|
+
* save on the draft (created from main if absent), or a merge onto main.
|
|
28
|
+
* Returns the new commit's sha.
|
|
29
|
+
*/
|
|
30
|
+
commit(branch: "main" | "draft", files: Files, message?: string): string;
|
|
31
|
+
/**
|
|
32
|
+
* Run `fn` once at the start of the next `writeFiles`, before its
|
|
33
|
+
* `baseVersion` check: a writer landing between a caller's read and its write.
|
|
34
|
+
*/
|
|
35
|
+
beforeNextWrite(fn: () => void): void;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export function createMemoryStorage(seed: { main?: Files; draft?: Files } = {}): MemoryStorage {
|
|
39
|
+
const commits = new Map<string, Commit>();
|
|
40
|
+
let counter = 0;
|
|
41
|
+
let mainHead: string;
|
|
42
|
+
let draftHead: string | null = null;
|
|
43
|
+
let pendingHook: (() => void) | null = null;
|
|
44
|
+
|
|
45
|
+
function newCommit(tree: Tree, parent: string | null, message: string): string {
|
|
46
|
+
counter += 1;
|
|
47
|
+
const sha = counter.toString(16).padStart(40, "0");
|
|
48
|
+
commits.set(sha, { tree, parent, message });
|
|
49
|
+
return sha;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function applyFiles(base: Tree, files: Files): Tree {
|
|
53
|
+
const tree = new Map(base);
|
|
54
|
+
for (const [path, content] of Object.entries(files)) {
|
|
55
|
+
if (content === null) tree.delete(path);
|
|
56
|
+
else tree.set(path, toBytes(content));
|
|
57
|
+
}
|
|
58
|
+
return tree;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function commitTo(branch: "main" | "draft", files: Files, message = "commit"): string {
|
|
62
|
+
if (branch === "main") {
|
|
63
|
+
mainHead = newCommit(applyFiles(commits.get(mainHead)!.tree, files), mainHead, message);
|
|
64
|
+
return mainHead;
|
|
65
|
+
}
|
|
66
|
+
const parent = draftHead ?? mainHead;
|
|
67
|
+
draftHead = newCommit(applyFiles(commits.get(parent)!.tree, files), parent, message);
|
|
68
|
+
return draftHead;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Unknown branch or sha reads as absent, as GitHub's 404 does.
|
|
72
|
+
function treeFor(options?: { draft?: boolean; at?: string }): Tree | null {
|
|
73
|
+
if (options?.at !== undefined) return commits.get(options.at)?.tree ?? null;
|
|
74
|
+
if (options?.draft) return draftHead === null ? null : commits.get(draftHead)!.tree;
|
|
75
|
+
return commits.get(mainHead)!.tree;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function checkBase(options?: { baseVersion?: string | null }): void {
|
|
79
|
+
if (options && "baseVersion" in options) {
|
|
80
|
+
const expected = options.baseVersion ?? null;
|
|
81
|
+
if (expected !== draftHead) throw new StorageConflictError(draftHead);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
mainHead = newCommit(applyFiles(new Map(), seed.main ?? {}), null, "initial");
|
|
86
|
+
if (seed.draft) commitTo("draft", seed.draft, "seed draft");
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
commit: commitTo,
|
|
90
|
+
|
|
91
|
+
beforeNextWrite(fn) {
|
|
92
|
+
pendingHook = fn;
|
|
93
|
+
},
|
|
94
|
+
|
|
95
|
+
async writeFiles(files: FileWrite[], message: string, options?: { baseVersion?: string | null }) {
|
|
96
|
+
const hook = pendingHook;
|
|
97
|
+
pendingHook = null;
|
|
98
|
+
hook?.();
|
|
99
|
+
|
|
100
|
+
checkBase(options);
|
|
101
|
+
if (files.length === 0) return { version: draftHead ?? mainHead };
|
|
102
|
+
|
|
103
|
+
const parentTree = commits.get(draftHead ?? mainHead)!.tree;
|
|
104
|
+
const changes: Files = {};
|
|
105
|
+
for (const file of files) {
|
|
106
|
+
// GitHub's createTree rejects deleting a path its base tree does not hold.
|
|
107
|
+
if (file.content === null && !parentTree.has(file.path)) {
|
|
108
|
+
throw new Error(`Cannot delete ${file.path}: it is not on the branch`);
|
|
109
|
+
}
|
|
110
|
+
changes[file.path] =
|
|
111
|
+
file.content === null || file.content instanceof Uint8Array || file.encoding !== "base64"
|
|
112
|
+
? file.content
|
|
113
|
+
: fromBase64(file.content);
|
|
114
|
+
}
|
|
115
|
+
return { version: commitTo("draft", changes, message) };
|
|
116
|
+
},
|
|
117
|
+
|
|
118
|
+
async readFile(path, options) {
|
|
119
|
+
return treeFor(options)?.get(path) ?? null;
|
|
120
|
+
},
|
|
121
|
+
|
|
122
|
+
async listDirectory(path, options) {
|
|
123
|
+
const tree = treeFor(options);
|
|
124
|
+
if (!tree) return [];
|
|
125
|
+
const prefix = path.endsWith("/") ? path : `${path}/`;
|
|
126
|
+
const names: string[] = [];
|
|
127
|
+
for (const key of tree.keys()) {
|
|
128
|
+
if (!key.startsWith(prefix)) continue;
|
|
129
|
+
const rest = key.slice(prefix.length);
|
|
130
|
+
if (!rest.includes("/")) names.push(rest);
|
|
131
|
+
}
|
|
132
|
+
return names;
|
|
133
|
+
},
|
|
134
|
+
|
|
135
|
+
async getVersion() {
|
|
136
|
+
return mainHead;
|
|
137
|
+
},
|
|
138
|
+
|
|
139
|
+
async getDraftVersion() {
|
|
140
|
+
return draftHead;
|
|
141
|
+
},
|
|
142
|
+
|
|
143
|
+
async promoteDraft() {
|
|
144
|
+
if (draftHead === null) throw new NothingToPublishError();
|
|
145
|
+
const draft = commits.get(draftHead)!.tree;
|
|
146
|
+
const main = commits.get(mainHead)!.tree;
|
|
147
|
+
const published = (p: string) => PUBLISHED_PREFIXES.some((prefix) => p.startsWith(prefix));
|
|
148
|
+
if (![...draft.keys(), ...main.keys()].some(published)) {
|
|
149
|
+
throw new Error("No editor-managed content found on the saved branch — refusing to publish.");
|
|
150
|
+
}
|
|
151
|
+
const tree = new Map([...main].filter(([p]) => !published(p)));
|
|
152
|
+
for (const [p, bytes] of draft) if (published(p)) tree.set(p, bytes);
|
|
153
|
+
mainHead = newCommit(tree, mainHead, "Publish content via Brand Portal");
|
|
154
|
+
draftHead = null;
|
|
155
|
+
return { version: mainHead };
|
|
156
|
+
},
|
|
157
|
+
|
|
158
|
+
async findCommitByIdempotencyKey(key, options) {
|
|
159
|
+
const limit = Math.min(Math.max(options?.limit ?? 30, 1), 100);
|
|
160
|
+
const trailer = `${IDEMPOTENCY_TRAILER}: ${key}`;
|
|
161
|
+
let sha = draftHead;
|
|
162
|
+
for (let seen = 0; sha !== null && seen < limit; seen += 1) {
|
|
163
|
+
const commit = commits.get(sha)!;
|
|
164
|
+
if (commit.message.split("\n").some((line) => line.trim() === trailer)) return sha;
|
|
165
|
+
sha = commit.parent;
|
|
166
|
+
}
|
|
167
|
+
return null;
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function toBytes(content: string | Uint8Array): Uint8Array {
|
|
173
|
+
return typeof content === "string" ? new TextEncoder().encode(content) : content;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function fromBase64(content: string): Uint8Array {
|
|
177
|
+
return Uint8Array.from(atob(content), (c) => c.charCodeAt(0));
|
|
178
|
+
}
|