@taprootio/docs-artifact 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/LICENSE +13 -0
  2. package/README.md +362 -0
  3. package/bin/taproot-docs-conformance.js +20 -0
  4. package/bin/taproot-docs-validate.js +17 -0
  5. package/conformance.d.ts +11 -0
  6. package/fixtures/README.md +24 -0
  7. package/fixtures/conformance.json +1630 -0
  8. package/fixtures/invalid/duplicate-json-key.json +1 -0
  9. package/fixtures/invalid/hash-drift/taproot-docs/fragments/welcome.html +1 -0
  10. package/fixtures/invalid/size-drift/taproot-docs/fragments/welcome.html +1 -0
  11. package/fixtures/invalid/unsafe-markup/taproot-docs/fragments/welcome.html +1 -0
  12. package/fixtures/valid/complete/taproot-docs/assets/pixel.png.base64 +1 -0
  13. package/fixtures/valid/complete/taproot-docs/fragments/button.en-us.html +1 -0
  14. package/fixtures/valid/complete/taproot-docs/fragments/button.fr-fr.html +1 -0
  15. package/fixtures/valid/complete/taproot-docs/fragments/getting-started.en-us.html +3 -0
  16. package/fixtures/valid/complete/taproot-docs/fragments/getting-started.fr-fr.html +3 -0
  17. package/fixtures/valid/complete/taproot-docs-manifest.json +231 -0
  18. package/fixtures/valid/minimal/taproot-docs/fragments/welcome.html +1 -0
  19. package/fixtures/valid/minimal/taproot-docs-manifest.json +80 -0
  20. package/index.d.ts +204 -0
  21. package/node.d.ts +6 -0
  22. package/package.json +54 -0
  23. package/schema/taproot-docs-manifest.schema.json +487 -0
  24. package/src/artifact-validator.js +870 -0
  25. package/src/binary.js +67 -0
  26. package/src/conformance.js +578 -0
  27. package/src/constants.js +104 -0
  28. package/src/errors.js +139 -0
  29. package/src/index.js +18 -0
  30. package/src/json.js +516 -0
  31. package/src/manifest-validator.js +650 -0
  32. package/src/markup.js +578 -0
  33. package/src/node-internal.js +4 -0
  34. package/src/node.js +513 -0
  35. package/src/path.js +103 -0
  36. package/src/text.js +30 -0
@@ -0,0 +1,870 @@
1
+ import { LIMITS } from "./constants.js";
2
+ import { snapshotBinaryInput } from "./binary.js";
3
+ import { DocsArtifactValidationError, ValidationContext } from "./errors.js";
4
+ import { validateManifest } from "./manifest-validator.js";
5
+ import { compareFragmentHeadings, validateHtmlFragment } from "./markup.js";
6
+ import { normalizeArtifactPath } from "./path.js";
7
+
8
+ function inspectStringContent(content, remainingArtifactBytes) {
9
+ let byteLength = 0;
10
+ for (let offset = 0; offset < content.length;) {
11
+ const first = content.charCodeAt(offset);
12
+ if (first >= 0xd800 && first <= 0xdbff) {
13
+ const second = content.charCodeAt(offset + 1);
14
+ if (!(second >= 0xdc00 && second <= 0xdfff)) return { error: "file.invalid_unicode" };
15
+ byteLength += 4;
16
+ offset += 2;
17
+ } else {
18
+ if (first >= 0xdc00 && first <= 0xdfff) return { error: "file.invalid_unicode" };
19
+ byteLength += first <= 0x7f ? 1 : first <= 0x7ff ? 2 : 3;
20
+ offset += 1;
21
+ }
22
+ if (byteLength > remainingArtifactBytes) return { kind: "string", content, byteLength };
23
+ }
24
+ return { kind: "string", content, byteLength };
25
+ }
26
+
27
+ function inspectContent(content, maximumBytes, remainingArtifactBytes) {
28
+ if (typeof content === "string") return inspectStringContent(content, remainingArtifactBytes);
29
+ const snapshotLimit = Math.min(maximumBytes, remainingArtifactBytes);
30
+ const snapshot = snapshotBinaryInput(content, snapshotLimit);
31
+ if (snapshot.kind === "too_large") return { kind: "bytes", byteLength: snapshot.byteLength };
32
+ if (snapshot.kind === "bytes") return snapshot;
33
+ return undefined;
34
+ }
35
+
36
+ function materializeContent(content) {
37
+ if (content.kind === "string") return new TextEncoder().encode(content.content);
38
+ return content.bytes;
39
+ }
40
+
41
+ async function sha256(bytes) {
42
+ const digest = await globalThis.crypto.subtle.digest("SHA-256", bytes);
43
+ return `sha256:${[...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("")}`;
44
+ }
45
+
46
+ function ascii(bytes, start, length) {
47
+ return String.fromCharCode(...bytes.subarray(start, start + length));
48
+ }
49
+
50
+ function readUint24Le(bytes, offset) {
51
+ return bytes[offset] | (bytes[offset + 1] << 8) | (bytes[offset + 2] << 16);
52
+ }
53
+
54
+ function readUint16Be(bytes, offset) {
55
+ return (bytes[offset] << 8) | bytes[offset + 1];
56
+ }
57
+
58
+ function readUint16Le(bytes, offset) {
59
+ return bytes[offset] | (bytes[offset + 1] << 8);
60
+ }
61
+
62
+ function readUint32Be(bytes, offset) {
63
+ return ((bytes[offset] * 0x1000000) + (bytes[offset + 1] << 16) + (bytes[offset + 2] << 8) + bytes[offset + 3]) >>> 0;
64
+ }
65
+
66
+ function readUint32Le(bytes, offset) {
67
+ return (bytes[offset] + (bytes[offset + 1] << 8) + (bytes[offset + 2] << 16) + (bytes[offset + 3] * 0x1000000)) >>> 0;
68
+ }
69
+
70
+ const CRC32_TABLE = Uint32Array.from({ length: 256 }, (_, value) => {
71
+ let crc = value;
72
+ for (let bit = 0; bit < 8; bit += 1) crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1));
73
+ return crc >>> 0;
74
+ });
75
+
76
+ function crc32(bytes, start, end) {
77
+ let crc = 0xffffffff;
78
+ for (let index = start; index < end; index += 1) {
79
+ crc = CRC32_TABLE[(crc ^ bytes[index]) & 0xff] ^ (crc >>> 8);
80
+ }
81
+ return (crc ^ 0xffffffff) >>> 0;
82
+ }
83
+
84
+ function checkedImageDimensions(width, height, frames = 1) {
85
+ if (width * height > LIMITS.decodedPixels) return { error: "asset.decoded_pixels" };
86
+ if (frames > LIMITS.animationFrames) return { error: "asset.animation_frames" };
87
+ return { width, height };
88
+ }
89
+
90
+ function readPngDimensions(bytes) {
91
+ if (bytes.length < 45 || ascii(bytes, 0, 8) !== "\u0089PNG\r\n\u001a\n") return undefined;
92
+ let offset = 8;
93
+ let dimensions;
94
+ let bitDepth;
95
+ let colorType;
96
+ let imageDataBytes = 0;
97
+ let sawImageData = false;
98
+ let leftImageData = false;
99
+ let sawPalette = false;
100
+ let animationDeclaredFrames;
101
+ let animationFrameControls = 0;
102
+ let decodedAnimationPixels = 0;
103
+ let completedAnimationFrames = 0;
104
+ let nextAnimationSequence = 0;
105
+ let currentAnimationFrame;
106
+ let chunkIndex = 0;
107
+ while (offset + 12 <= bytes.length) {
108
+ const dataLength = readUint32Be(bytes, offset);
109
+ const dataStart = offset + 8;
110
+ const dataEnd = dataStart + dataLength;
111
+ const chunkEnd = dataEnd + 4;
112
+ if (dataEnd < dataStart || chunkEnd > bytes.length) return undefined;
113
+ const type = ascii(bytes, offset + 4, 4);
114
+ if (!/^[A-Za-z]{4}$/u.test(type) || (bytes[offset + 6] & 0x20) !== 0) return undefined;
115
+ if (readUint32Be(bytes, dataEnd) !== crc32(bytes, offset + 4, dataEnd)) return undefined;
116
+
117
+ if (chunkIndex === 0 && type !== "IHDR") return undefined;
118
+ if (sawImageData && type !== "IDAT") leftImageData = true;
119
+ if (currentAnimationFrame?.dataBytes > 0) {
120
+ const expectedDataType = currentAnimationFrame.mode === "idat" ? "IDAT" : "fdAT";
121
+ if (type !== expectedDataType && type !== "fcTL" && type !== "IEND") currentAnimationFrame.dataEnded = true;
122
+ }
123
+ if (type === "IHDR") {
124
+ if (chunkIndex !== 0 || dataLength !== 13 || dimensions) return undefined;
125
+ const width = readUint32Be(bytes, dataStart);
126
+ const height = readUint32Be(bytes, dataStart + 4);
127
+ bitDepth = bytes[dataStart + 8];
128
+ colorType = bytes[dataStart + 9];
129
+ const validDepths = new Map([
130
+ [0, new Set([1, 2, 4, 8, 16])],
131
+ [2, new Set([8, 16])],
132
+ [3, new Set([1, 2, 4, 8])],
133
+ [4, new Set([8, 16])],
134
+ [6, new Set([8, 16])],
135
+ ]);
136
+ if (width === 0 || height === 0 || !validDepths.get(colorType)?.has(bitDepth)) return undefined;
137
+ if (bytes[dataStart + 10] !== 0 || bytes[dataStart + 11] !== 0 || bytes[dataStart + 12] > 1) return undefined;
138
+ dimensions = checkedImageDimensions(width, height);
139
+ if (dimensions.error) return dimensions;
140
+ } else if (type === "acTL") {
141
+ if (!dimensions || animationDeclaredFrames !== undefined || sawImageData || currentAnimationFrame || dataLength !== 8) return undefined;
142
+ animationDeclaredFrames = readUint32Be(bytes, dataStart);
143
+ if (animationDeclaredFrames === 0) return undefined;
144
+ if (animationDeclaredFrames > LIMITS.animationFrames) return { error: "asset.animation_frames" };
145
+ } else if (type === "fcTL") {
146
+ if (!dimensions || animationDeclaredFrames === undefined || dataLength !== 26) return undefined;
147
+ if (currentAnimationFrame) {
148
+ if (currentAnimationFrame.dataBytes === 0) return undefined;
149
+ completedAnimationFrames += 1;
150
+ }
151
+ if (readUint32Be(bytes, dataStart) !== nextAnimationSequence) return undefined;
152
+ nextAnimationSequence += 1;
153
+ const frameWidth = readUint32Be(bytes, dataStart + 4);
154
+ const frameHeight = readUint32Be(bytes, dataStart + 8);
155
+ const frameLeft = readUint32Be(bytes, dataStart + 12);
156
+ const frameTop = readUint32Be(bytes, dataStart + 16);
157
+ if (
158
+ frameWidth === 0 || frameHeight === 0 ||
159
+ frameLeft + frameWidth > dimensions.width || frameTop + frameHeight > dimensions.height ||
160
+ bytes[dataStart + 24] > 2 || bytes[dataStart + 25] > 1
161
+ ) return undefined;
162
+ decodedAnimationPixels += frameWidth * frameHeight;
163
+ if (decodedAnimationPixels > LIMITS.decodedAnimationPixels) return { error: "asset.decoded_animation_pixels" };
164
+ animationFrameControls += 1;
165
+ if (animationFrameControls > LIMITS.animationFrames) return { error: "asset.animation_frames" };
166
+ const mode = sawImageData ? "fdat" : "idat";
167
+ if (mode === "idat" && (
168
+ animationFrameControls !== 1 || frameWidth !== dimensions.width || frameHeight !== dimensions.height || frameLeft !== 0 || frameTop !== 0
169
+ )) return undefined;
170
+ currentAnimationFrame = { mode, dataBytes: 0, dataEnded: false };
171
+ } else if (type === "PLTE") {
172
+ const entries = dataLength / 3;
173
+ if (
174
+ !dimensions || sawPalette || sawImageData || ![2, 3, 6].includes(colorType) ||
175
+ dataLength === 0 || dataLength > 768 || dataLength % 3 !== 0 ||
176
+ (colorType === 3 && entries > 2 ** bitDepth)
177
+ ) return undefined;
178
+ sawPalette = true;
179
+ } else if (type === "IDAT") {
180
+ if (!dimensions || leftImageData || (colorType === 3 && !sawPalette) || currentAnimationFrame?.mode === "fdat") return undefined;
181
+ if (currentAnimationFrame?.dataEnded) return undefined;
182
+ sawImageData = true;
183
+ imageDataBytes += dataLength;
184
+ if (currentAnimationFrame?.mode === "idat") currentAnimationFrame.dataBytes += dataLength;
185
+ } else if (type === "fdAT") {
186
+ if (
187
+ !dimensions || animationDeclaredFrames === undefined || !currentAnimationFrame || currentAnimationFrame.mode !== "fdat" ||
188
+ currentAnimationFrame.dataEnded || dataLength <= 4 || readUint32Be(bytes, dataStart) !== nextAnimationSequence
189
+ ) return undefined;
190
+ nextAnimationSequence += 1;
191
+ currentAnimationFrame.dataBytes += dataLength - 4;
192
+ } else if (type === "IEND") {
193
+ if (dataLength !== 0 || !dimensions || imageDataBytes === 0 || chunkEnd !== bytes.length) return undefined;
194
+ if (colorType === 3 && !sawPalette) return undefined;
195
+ if (currentAnimationFrame) {
196
+ if (currentAnimationFrame.dataBytes === 0) return undefined;
197
+ completedAnimationFrames += 1;
198
+ }
199
+ if (animationDeclaredFrames !== undefined && (
200
+ animationFrameControls === 0 || animationDeclaredFrames !== completedAnimationFrames
201
+ )) return undefined;
202
+ return dimensions;
203
+ } else {
204
+ if ((type.charCodeAt(0) & 0x20) === 0) return undefined;
205
+ if (sawImageData) leftImageData = true;
206
+ }
207
+ offset = chunkEnd;
208
+ chunkIndex += 1;
209
+ }
210
+ return undefined;
211
+ }
212
+
213
+ function skipGifSubBlocks(bytes, offset, requireData) {
214
+ let sawData = false;
215
+ while (offset < bytes.length) {
216
+ const length = bytes[offset];
217
+ offset += 1;
218
+ if (length === 0) return !requireData || sawData ? offset : undefined;
219
+ if (offset + length > bytes.length) return undefined;
220
+ sawData = true;
221
+ offset += length;
222
+ }
223
+ return undefined;
224
+ }
225
+
226
+ function skipGifExtension(bytes, offset) {
227
+ const label = bytes[offset];
228
+ const blockStart = offset + 1;
229
+ if (label === 0xf9) {
230
+ if (blockStart + 6 > bytes.length || bytes[blockStart] !== 4 || bytes[blockStart + 5] !== 0) return undefined;
231
+ const packed = bytes[blockStart + 1];
232
+ if ((packed & 0xe0) !== 0 || ((packed >>> 2) & 0x07) > 3) return undefined;
233
+ return blockStart + 6;
234
+ }
235
+ if (label === 0xff) {
236
+ if (blockStart + 12 > bytes.length || bytes[blockStart] !== 11) return undefined;
237
+ return skipGifSubBlocks(bytes, blockStart + 12, false);
238
+ }
239
+ if (label === 0x01) {
240
+ if (blockStart + 13 > bytes.length || bytes[blockStart] !== 12) return undefined;
241
+ return skipGifSubBlocks(bytes, blockStart + 13, false);
242
+ }
243
+ if (label === 0xfe) return skipGifSubBlocks(bytes, blockStart, false);
244
+ return undefined;
245
+ }
246
+
247
+ function readGifDimensions(bytes) {
248
+ if (bytes.length < 15 || !["GIF87a", "GIF89a"].includes(ascii(bytes, 0, 6))) return undefined;
249
+ const width = readUint16Le(bytes, 6);
250
+ const height = readUint16Le(bytes, 8);
251
+ if (width === 0 || height === 0) return undefined;
252
+ const dimensions = checkedImageDimensions(width, height);
253
+ if (dimensions.error) return dimensions;
254
+ let offset = 13;
255
+ const hasGlobalColorTable = (bytes[10] & 0x80) !== 0;
256
+ if (hasGlobalColorTable) offset += 3 * (2 ** ((bytes[10] & 0x07) + 1));
257
+ if (offset > bytes.length) return undefined;
258
+ let frameCount = 0;
259
+ let decodedAnimationPixels = 0;
260
+ while (offset < bytes.length) {
261
+ const introducer = bytes[offset];
262
+ if (introducer === 0x3b) return frameCount > 0 && offset + 1 === bytes.length ? dimensions : undefined;
263
+ if (introducer === 0x21) {
264
+ if (offset + 2 > bytes.length) return undefined;
265
+ offset = skipGifExtension(bytes, offset + 1);
266
+ if (offset === undefined) return undefined;
267
+ continue;
268
+ }
269
+ if (introducer !== 0x2c || offset + 10 > bytes.length) return undefined;
270
+ const imageLeft = readUint16Le(bytes, offset + 1);
271
+ const imageTop = readUint16Le(bytes, offset + 3);
272
+ const imageWidth = readUint16Le(bytes, offset + 5);
273
+ const imageHeight = readUint16Le(bytes, offset + 7);
274
+ if (imageWidth === 0 || imageHeight === 0 || imageLeft + imageWidth > width || imageTop + imageHeight > height) return undefined;
275
+ decodedAnimationPixels += imageWidth * imageHeight;
276
+ if (decodedAnimationPixels > LIMITS.decodedAnimationPixels) return { error: "asset.decoded_animation_pixels" };
277
+ frameCount += 1;
278
+ if (frameCount > LIMITS.animationFrames) return { error: "asset.animation_frames" };
279
+ const packed = bytes[offset + 9];
280
+ if ((packed & 0x18) !== 0) return undefined;
281
+ offset += 10;
282
+ const hasLocalColorTable = (packed & 0x80) !== 0;
283
+ if (!hasGlobalColorTable && !hasLocalColorTable) return undefined;
284
+ if (hasLocalColorTable) offset += 3 * (2 ** ((packed & 0x07) + 1));
285
+ if (offset > bytes.length) return undefined;
286
+ if (offset >= bytes.length || bytes[offset] < 2 || bytes[offset] > 8) return undefined;
287
+ offset = skipGifSubBlocks(bytes, offset + 1, true);
288
+ if (offset === undefined) return undefined;
289
+ }
290
+ return undefined;
291
+ }
292
+
293
+ function findJpegMarkerAfterScan(bytes, offset, restartInterval, expectedRestartMarkers) {
294
+ let sawEntropySinceRestart = false;
295
+ let expectedRestart = 0;
296
+ let restartMarkers = 0;
297
+ while (offset < bytes.length) {
298
+ if (bytes[offset] !== 0xff) {
299
+ sawEntropySinceRestart = true;
300
+ offset += 1;
301
+ continue;
302
+ }
303
+ const markerStart = offset;
304
+ while (offset < bytes.length && bytes[offset] === 0xff) offset += 1;
305
+ if (offset >= bytes.length) return undefined;
306
+ const marker = bytes[offset];
307
+ if (marker === 0x00) {
308
+ if (offset - markerStart !== 1) return undefined;
309
+ sawEntropySinceRestart = true;
310
+ offset += 1;
311
+ continue;
312
+ }
313
+ if (marker >= 0xd0 && marker <= 0xd7) {
314
+ if (!sawEntropySinceRestart || restartInterval === 0 || marker !== 0xd0 + expectedRestart) return undefined;
315
+ expectedRestart = (expectedRestart + 1) & 0x07;
316
+ restartMarkers += 1;
317
+ if (restartMarkers > expectedRestartMarkers) return undefined;
318
+ sawEntropySinceRestart = false;
319
+ offset += 1;
320
+ continue;
321
+ }
322
+ return sawEntropySinceRestart && restartMarkers === expectedRestartMarkers ? markerStart : undefined;
323
+ }
324
+ return undefined;
325
+ }
326
+
327
+ function isValidJpegHuffmanTable(bytes, countsOffset, symbolsOffset, symbolCount, tableClass) {
328
+ if (symbolCount === 0 || symbolCount > 256) return false;
329
+ let remainingCodeSpace = 1;
330
+ for (let index = 0; index < 16; index += 1) {
331
+ remainingCodeSpace = (remainingCodeSpace * 2) - bytes[countsOffset + index];
332
+ if (remainingCodeSpace < 0) return false;
333
+ }
334
+ if (remainingCodeSpace === 0) return false;
335
+ for (let index = 0; index < symbolCount; index += 1) {
336
+ const symbol = bytes[symbolsOffset + index];
337
+ if (tableClass === 0 && symbol > 11) return false;
338
+ if (tableClass === 1) {
339
+ const run = symbol >>> 4;
340
+ const size = symbol & 0x0f;
341
+ if (size === 0 ? run !== 0 && run !== 15 : size > 10) return false;
342
+ }
343
+ }
344
+ return true;
345
+ }
346
+
347
+ function readJpegDimensions(bytes) {
348
+ if (bytes.length < 8 || bytes[0] !== 0xff || bytes[1] !== 0xd8) return undefined;
349
+ const frameMarkers = new Set([0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf]);
350
+ const quantizationTables = new Set();
351
+ const huffmanTables = new Set();
352
+ const components = new Map();
353
+ const scannedComponents = new Set();
354
+ let offset = 2;
355
+ let dimensions;
356
+ let restartInterval = 0;
357
+ let maxHorizontalSampling = 0;
358
+ let maxVerticalSampling = 0;
359
+ while (offset < bytes.length) {
360
+ if (bytes[offset] !== 0xff) return undefined;
361
+ while (offset < bytes.length && bytes[offset] === 0xff) offset += 1;
362
+ if (offset >= bytes.length) return undefined;
363
+ const marker = bytes[offset];
364
+ offset += 1;
365
+ if (marker === 0x00 || marker === 0xd8 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) return undefined;
366
+ if (marker === 0xd9) {
367
+ return dimensions && scannedComponents.size === components.size && offset === bytes.length ? dimensions : undefined;
368
+ }
369
+ if (marker === 0xcc) return undefined;
370
+ if (offset + 2 > bytes.length) return undefined;
371
+ const length = readUint16Be(bytes, offset);
372
+ const segmentEnd = offset + length;
373
+ if (length < 2 || segmentEnd > bytes.length) return undefined;
374
+ if (marker === 0xdb) {
375
+ let tableOffset = offset + 2;
376
+ while (tableOffset < segmentEnd) {
377
+ const information = bytes[tableOffset];
378
+ const precision = information >>> 4;
379
+ const tableId = information & 0x0f;
380
+ const coefficientsStart = tableOffset + 1;
381
+ const tableEnd = coefficientsStart + 64;
382
+ if (precision !== 0 || tableId > 3 || tableEnd > segmentEnd) return undefined;
383
+ for (let index = coefficientsStart; index < tableEnd; index += 1) {
384
+ if (bytes[index] === 0) return undefined;
385
+ }
386
+ quantizationTables.add(tableId);
387
+ tableOffset = tableEnd;
388
+ }
389
+ if (tableOffset !== segmentEnd) return undefined;
390
+ } else if (marker === 0xc4) {
391
+ let tableOffset = offset + 2;
392
+ while (tableOffset < segmentEnd) {
393
+ if (tableOffset + 17 > segmentEnd) return undefined;
394
+ const information = bytes[tableOffset];
395
+ const tableClass = information >>> 4;
396
+ const tableId = information & 0x0f;
397
+ if (tableClass > 1 || tableId > 3) return undefined;
398
+ const countsOffset = tableOffset + 1;
399
+ let symbols = 0;
400
+ for (let index = 0; index < 16; index += 1) symbols += bytes[countsOffset + index];
401
+ const symbolsOffset = countsOffset + 16;
402
+ const tableEnd = symbolsOffset + symbols;
403
+ if (
404
+ tableEnd > segmentEnd ||
405
+ !isValidJpegHuffmanTable(bytes, countsOffset, symbolsOffset, symbols, tableClass)
406
+ ) return undefined;
407
+ huffmanTables.add(`${tableClass}:${tableId}`);
408
+ tableOffset = tableEnd;
409
+ }
410
+ if (tableOffset !== segmentEnd) return undefined;
411
+ } else if (frameMarkers.has(marker)) {
412
+ if (marker !== 0xc0 || dimensions || length < 11 || bytes[offset + 2] !== 8) return undefined;
413
+ const componentCount = bytes[offset + 7];
414
+ if (![1, 3].includes(componentCount) || length !== 8 + (3 * componentCount)) return undefined;
415
+ const height = readUint16Be(bytes, offset + 3);
416
+ const width = readUint16Be(bytes, offset + 5);
417
+ if (width === 0 || height === 0) return undefined;
418
+ let samplingUnits = 0;
419
+ for (let index = 0; index < componentCount; index += 1) {
420
+ const componentOffset = offset + 8 + (3 * index);
421
+ const componentId = bytes[componentOffset];
422
+ const sampling = bytes[componentOffset + 1];
423
+ const horizontalSampling = sampling >>> 4;
424
+ const verticalSampling = sampling & 0x0f;
425
+ const quantizationTable = bytes[componentOffset + 2];
426
+ if (
427
+ components.has(componentId) || horizontalSampling === 0 || horizontalSampling > 4 ||
428
+ verticalSampling === 0 || verticalSampling > 4 || quantizationTable > 3
429
+ ) return undefined;
430
+ samplingUnits += horizontalSampling * verticalSampling;
431
+ if (samplingUnits > 10) return undefined;
432
+ maxHorizontalSampling = Math.max(maxHorizontalSampling, horizontalSampling);
433
+ maxVerticalSampling = Math.max(maxVerticalSampling, verticalSampling);
434
+ components.set(componentId, { horizontalSampling, verticalSampling, quantizationTable });
435
+ }
436
+ const candidateDimensions = checkedImageDimensions(width, height);
437
+ if (candidateDimensions.error) return candidateDimensions;
438
+ dimensions = candidateDimensions;
439
+ } else if (marker === 0xdd) {
440
+ if (length !== 4) return undefined;
441
+ restartInterval = readUint16Be(bytes, offset + 2);
442
+ } else if (marker === 0xda) {
443
+ const componentCount = bytes[offset + 2];
444
+ if (
445
+ !dimensions || componentCount === 0 || componentCount > components.size || length !== 6 + (2 * componentCount)
446
+ ) return undefined;
447
+ const scanComponents = new Set();
448
+ let singleScanComponent;
449
+ for (let index = 0; index < componentCount; index += 1) {
450
+ const componentOffset = offset + 3 + (2 * index);
451
+ const componentId = bytes[componentOffset];
452
+ const selectors = bytes[componentOffset + 1];
453
+ const dcTable = selectors >>> 4;
454
+ const acTable = selectors & 0x0f;
455
+ const component = components.get(componentId);
456
+ if (
457
+ !component || scanComponents.has(componentId) || scannedComponents.has(componentId) ||
458
+ !quantizationTables.has(component.quantizationTable) ||
459
+ !huffmanTables.has(`0:${dcTable}`) || !huffmanTables.has(`1:${acTable}`)
460
+ ) return undefined;
461
+ scanComponents.add(componentId);
462
+ singleScanComponent = component;
463
+ }
464
+ const spectralOffset = offset + 3 + (2 * componentCount);
465
+ if (bytes[spectralOffset] !== 0 || bytes[spectralOffset + 1] !== 63 || bytes[spectralOffset + 2] !== 0) return undefined;
466
+ const mcuColumns = componentCount === 1
467
+ ? Math.ceil((dimensions.width * singleScanComponent.horizontalSampling) / (8 * maxHorizontalSampling))
468
+ : Math.ceil(dimensions.width / (8 * maxHorizontalSampling));
469
+ const mcuRows = componentCount === 1
470
+ ? Math.ceil((dimensions.height * singleScanComponent.verticalSampling) / (8 * maxVerticalSampling))
471
+ : Math.ceil(dimensions.height / (8 * maxVerticalSampling));
472
+ const mcuCount = mcuColumns * mcuRows;
473
+ const expectedRestartMarkers = restartInterval === 0 ? 0 : Math.floor((mcuCount - 1) / restartInterval);
474
+ offset = findJpegMarkerAfterScan(bytes, segmentEnd, restartInterval, expectedRestartMarkers);
475
+ if (offset === undefined) return undefined;
476
+ for (const componentId of scanComponents) scannedComponents.add(componentId);
477
+ continue;
478
+ } else {
479
+ const allowedMetadata = marker === 0xfe || (marker >= 0xe0 && marker <= 0xef);
480
+ if (!allowedMetadata) return undefined;
481
+ }
482
+ offset = segmentEnd;
483
+ }
484
+ return undefined;
485
+ }
486
+
487
+ function readVp8Dimensions(bytes, dataStart, dataLength) {
488
+ if (dataLength < 11 || ascii(bytes, dataStart + 3, 3) !== "\u009d\u0001*") return undefined;
489
+ const frameTag = bytes[dataStart] | (bytes[dataStart + 1] << 8) | (bytes[dataStart + 2] << 16);
490
+ const firstPartitionSize = frameTag >>> 5;
491
+ if (
492
+ (frameTag & 0x01) !== 0 || ((frameTag >>> 1) & 0x07) > 3 || (frameTag & 0x10) === 0 ||
493
+ firstPartitionSize === 0 || dataLength <= 10 + firstPartitionSize
494
+ ) return undefined;
495
+ const width = readUint16Le(bytes, dataStart + 6) & 0x3fff;
496
+ const height = readUint16Le(bytes, dataStart + 8) & 0x3fff;
497
+ return width > 0 && height > 0 ? checkedImageDimensions(width, height) : undefined;
498
+ }
499
+
500
+ function readVp8lDimensions(bytes, dataStart, dataLength) {
501
+ if (dataLength <= 5 || bytes[dataStart] !== 0x2f || (bytes[dataStart + 4] & 0xe0) !== 0) return undefined;
502
+ const dimensions = checkedImageDimensions(
503
+ 1 + bytes[dataStart + 1] + ((bytes[dataStart + 2] & 0x3f) << 8),
504
+ 1 + ((bytes[dataStart + 2] & 0xc0) >> 6) + (bytes[dataStart + 3] << 2) + ((bytes[dataStart + 4] & 0x0f) << 10),
505
+ );
506
+ return dimensions.error ? dimensions : { ...dimensions, alphaUsed: (bytes[dataStart + 4] & 0x10) !== 0 };
507
+ }
508
+
509
+ function validateWebpAlpha(bytes, dataStart, dataLength, width, height) {
510
+ if (dataLength <= 1) return false;
511
+ const header = bytes[dataStart];
512
+ const compression = header & 0x03;
513
+ const preprocessing = (header >>> 4) & 0x03;
514
+ if ((header & 0xc0) !== 0 || compression > 1 || preprocessing > 1) return false;
515
+ if (compression === 0 && dataLength !== 1 + (width * height)) return false;
516
+ return true;
517
+ }
518
+
519
+ function validateWebpFrame(bytes, start, end, width, height) {
520
+ let offset = start;
521
+ let dimensions;
522
+ let sawAlpha = false;
523
+ while (offset + 8 <= end) {
524
+ const type = ascii(bytes, offset, 4);
525
+ const length = readUint32Le(bytes, offset + 4);
526
+ const dataStart = offset + 8;
527
+ const dataEnd = dataStart + length;
528
+ const chunkEnd = dataEnd + (length & 1);
529
+ if (dataEnd < dataStart || chunkEnd > end) return undefined;
530
+ if ((length & 1) !== 0 && bytes[dataEnd] !== 0) return undefined;
531
+ if (type === "ALPH") {
532
+ if (sawAlpha || dimensions || !validateWebpAlpha(bytes, dataStart, length, width, height)) return undefined;
533
+ sawAlpha = true;
534
+ } else if (type === "VP8 ") {
535
+ if (dimensions) return undefined;
536
+ dimensions = readVp8Dimensions(bytes, dataStart, length);
537
+ } else if (type === "VP8L") {
538
+ if (sawAlpha || dimensions) return undefined;
539
+ dimensions = readVp8lDimensions(bytes, dataStart, length);
540
+ } else {
541
+ return undefined;
542
+ }
543
+ if ((type === "VP8 " || type === "VP8L") && !dimensions) return undefined;
544
+ if (dimensions?.error) return dimensions;
545
+ offset = chunkEnd;
546
+ }
547
+ return offset === end && dimensions
548
+ ? { ...dimensions, alphaObserved: sawAlpha || dimensions.alphaUsed === true }
549
+ : undefined;
550
+ }
551
+
552
+ function validateWebpAnimationFrame(bytes, dataStart, dataEnd, canvasDimensions) {
553
+ if (!canvasDimensions || dataEnd - dataStart < 16 || (bytes[dataStart + 15] & 0xfc) !== 0) return undefined;
554
+ const x = readUint24Le(bytes, dataStart) * 2;
555
+ const y = readUint24Le(bytes, dataStart + 3) * 2;
556
+ const width = readUint24Le(bytes, dataStart + 6) + 1;
557
+ const height = readUint24Le(bytes, dataStart + 9) + 1;
558
+ const dimensions = checkedImageDimensions(width, height);
559
+ if (dimensions.error) return dimensions;
560
+ if (x + width > canvasDimensions.width || y + height > canvasDimensions.height) return undefined;
561
+ const embeddedDimensions = validateWebpFrame(bytes, dataStart + 16, dataEnd, width, height);
562
+ if (!embeddedDimensions) return undefined;
563
+ if (embeddedDimensions.error) return embeddedDimensions;
564
+ if (embeddedDimensions.width !== width || embeddedDimensions.height !== height) return undefined;
565
+ return { ...dimensions, alphaObserved: embeddedDimensions.alphaObserved };
566
+ }
567
+
568
+ function readWebpDimensions(bytes) {
569
+ if (bytes.length < 26 || ascii(bytes, 0, 4) !== "RIFF" || ascii(bytes, 8, 4) !== "WEBP") return undefined;
570
+ if (readUint32Le(bytes, 4) !== bytes.length - 8) return undefined;
571
+ let offset = 12;
572
+ let firstChunk = true;
573
+ let canvasDimensions;
574
+ let payloadDimensions;
575
+ let extendedFlags;
576
+ let sawIccp = false;
577
+ let sawTopLevelAlpha = false;
578
+ let pendingTopLevelAlpha = false;
579
+ let sawExif = false;
580
+ let sawXmp = false;
581
+ let sawAnimationHeader = false;
582
+ let animationAlphaObserved = false;
583
+ let animationFrameCount = 0;
584
+ let decodedAnimationPixels = 0;
585
+ let animationFrameRunEnded = false;
586
+ let chunkIndex = 0;
587
+ while (offset + 8 <= bytes.length) {
588
+ const type = ascii(bytes, offset, 4);
589
+ const length = readUint32Le(bytes, offset + 4);
590
+ const dataStart = offset + 8;
591
+ const dataEnd = dataStart + length;
592
+ const chunkEnd = dataEnd + (length & 1);
593
+ if (dataEnd < dataStart || chunkEnd > bytes.length) return undefined;
594
+ if ((length & 1) !== 0 && bytes[dataEnd] !== 0) return undefined;
595
+ if (firstChunk && !["VP8 ", "VP8L", "VP8X"].includes(type)) return undefined;
596
+ firstChunk = false;
597
+ if (pendingTopLevelAlpha && type !== "VP8 ") return undefined;
598
+ if (sawAnimationHeader && animationFrameCount === 0 && type !== "ANMF") return undefined;
599
+ if (animationFrameCount > 0 && type !== "ANMF") animationFrameRunEnded = true;
600
+ if (type === "VP8X") {
601
+ if (chunkIndex !== 0 || canvasDimensions || length !== 10 || (bytes[dataStart] & 0xc1) !== 0) return undefined;
602
+ if (bytes[dataStart + 1] !== 0 || bytes[dataStart + 2] !== 0 || bytes[dataStart + 3] !== 0) return undefined;
603
+ extendedFlags = bytes[dataStart];
604
+ canvasDimensions = checkedImageDimensions(
605
+ readUint24Le(bytes, dataStart + 4) + 1,
606
+ readUint24Le(bytes, dataStart + 7) + 1,
607
+ );
608
+ if (canvasDimensions.error) return canvasDimensions;
609
+ } else if (type === "ICCP") {
610
+ if (!canvasDimensions || sawIccp || payloadDimensions || sawTopLevelAlpha || sawAnimationHeader || sawExif || sawXmp || length < 1) {
611
+ return undefined;
612
+ }
613
+ sawIccp = true;
614
+ } else if (type === "ALPH") {
615
+ if (
616
+ !canvasDimensions || sawTopLevelAlpha || payloadDimensions || sawAnimationHeader || sawExif || sawXmp ||
617
+ !validateWebpAlpha(bytes, dataStart, length, canvasDimensions.width, canvasDimensions.height)
618
+ ) return undefined;
619
+ sawTopLevelAlpha = true;
620
+ pendingTopLevelAlpha = true;
621
+ } else if (type === "VP8 ") {
622
+ if (payloadDimensions || sawAnimationHeader || animationFrameCount > 0 || sawExif || sawXmp || (extendedFlags & 0x02) !== 0) return undefined;
623
+ payloadDimensions = readVp8Dimensions(bytes, dataStart, length);
624
+ if (!payloadDimensions) return undefined;
625
+ if (payloadDimensions.error) return payloadDimensions;
626
+ pendingTopLevelAlpha = false;
627
+ } else if (type === "VP8L") {
628
+ if (payloadDimensions || sawTopLevelAlpha || sawAnimationHeader || animationFrameCount > 0 || sawExif || sawXmp || (extendedFlags & 0x02) !== 0) {
629
+ return undefined;
630
+ }
631
+ payloadDimensions = readVp8lDimensions(bytes, dataStart, length);
632
+ if (!payloadDimensions) return undefined;
633
+ if (payloadDimensions.error) return payloadDimensions;
634
+ } else if (type === "ANIM") {
635
+ if (!canvasDimensions || (extendedFlags & 0x02) === 0 || sawAnimationHeader || animationFrameCount > 0 || payloadDimensions || sawExif || sawXmp || length !== 6) {
636
+ return undefined;
637
+ }
638
+ sawAnimationHeader = true;
639
+ } else if (type === "ANMF") {
640
+ if (!canvasDimensions || (extendedFlags & 0x02) === 0 || !sawAnimationHeader || animationFrameRunEnded || payloadDimensions || sawExif || sawXmp || length < 16) {
641
+ return undefined;
642
+ }
643
+ animationFrameCount += 1;
644
+ if (animationFrameCount > LIMITS.animationFrames) return { error: "asset.animation_frames" };
645
+ const frameDimensions = validateWebpAnimationFrame(bytes, dataStart, dataEnd, canvasDimensions);
646
+ if (!frameDimensions) return undefined;
647
+ if (frameDimensions.error) return frameDimensions;
648
+ decodedAnimationPixels += frameDimensions.width * frameDimensions.height;
649
+ if (decodedAnimationPixels > LIMITS.decodedAnimationPixels) return { error: "asset.decoded_animation_pixels" };
650
+ animationAlphaObserved ||= frameDimensions.alphaObserved;
651
+ } else if (type === "EXIF") {
652
+ if (!canvasDimensions || sawExif || sawXmp || (!payloadDimensions && animationFrameCount === 0) || length < 1) return undefined;
653
+ sawExif = true;
654
+ } else if (type === "XMP ") {
655
+ if (!canvasDimensions || sawXmp || (!payloadDimensions && animationFrameCount === 0) || length < 1) return undefined;
656
+ sawXmp = true;
657
+ } else {
658
+ return undefined;
659
+ }
660
+ offset = chunkEnd;
661
+ chunkIndex += 1;
662
+ }
663
+ if (offset !== bytes.length) return undefined;
664
+ if (pendingTopLevelAlpha) return undefined;
665
+ if (extendedFlags === undefined) return payloadDimensions;
666
+ const animated = (extendedFlags & 0x02) !== 0;
667
+ if (animated) {
668
+ if (!sawAnimationHeader || animationFrameCount === 0 || payloadDimensions) return undefined;
669
+ } else if (sawAnimationHeader || animationFrameCount > 0 || !payloadDimensions) {
670
+ return undefined;
671
+ }
672
+ const alphaObserved = sawTopLevelAlpha || animationAlphaObserved || payloadDimensions?.alphaUsed === true;
673
+ const observedFeatures = (sawIccp ? 0x20 : 0)
674
+ | (alphaObserved ? 0x10 : 0)
675
+ | (sawExif ? 0x08 : 0)
676
+ | (sawXmp ? 0x04 : 0)
677
+ | (animated ? 0x02 : 0);
678
+ if ((extendedFlags & 0x3e) !== observedFeatures) return undefined;
679
+ if (payloadDimensions && (canvasDimensions.width !== payloadDimensions.width || canvasDimensions.height !== payloadDimensions.height)) return undefined;
680
+ return canvasDimensions;
681
+ }
682
+
683
+ function readImageDimensions(mediaType, bytes) {
684
+ if (mediaType === "image/png") return readPngDimensions(bytes);
685
+ if (mediaType === "image/gif") return readGifDimensions(bytes);
686
+ if (mediaType === "image/jpeg") return readJpegDimensions(bytes);
687
+ if (mediaType === "image/webp") return readWebpDimensions(bytes);
688
+ return undefined;
689
+ }
690
+
691
+ function expectedFiles(manifest) {
692
+ const expected = new Map();
693
+ const variants = [];
694
+ for (let resourceIndex = 0; resourceIndex < manifest.resources.length; resourceIndex += 1) {
695
+ const resource = manifest.resources[resourceIndex];
696
+ for (let variantIndex = 0; variantIndex < resource.variants.length; variantIndex += 1) {
697
+ const variant = resource.variants[variantIndex];
698
+ const fragments = [];
699
+ variants.push({ resource, variant, fragments });
700
+ for (let fragmentIndex = 0; fragmentIndex < variant.fragments.length; fragmentIndex += 1) {
701
+ const fragment = variant.fragments[fragmentIndex];
702
+ const descriptor = {
703
+ kind: "fragment",
704
+ descriptor: fragment,
705
+ resource,
706
+ variant,
707
+ path: `$.resources[${resourceIndex}].variants[${variantIndex}].fragments[${fragmentIndex}]`,
708
+ };
709
+ expected.set(fragment.path, descriptor);
710
+ fragments.push(descriptor);
711
+ }
712
+ }
713
+ }
714
+ for (let assetIndex = 0; assetIndex < manifest.assets.length; assetIndex += 1) {
715
+ const asset = manifest.assets[assetIndex];
716
+ expected.set(asset.path, {
717
+ kind: "asset",
718
+ descriptor: asset,
719
+ path: `$.assets[${assetIndex}]`,
720
+ });
721
+ }
722
+ return { expected, variants };
723
+ }
724
+
725
+ export async function validateArtifact(manifestInput, fileEntries, options = {}) {
726
+ const manifestResult = validateManifest(manifestInput, options);
727
+ if (!manifestResult.ok) return manifestResult;
728
+ const manifest = manifestResult.value;
729
+ const context = new ValidationContext();
730
+ const { expected, variants } = expectedFiles(manifest);
731
+ const files = new Map();
732
+ const seenPaths = new Set();
733
+ let entryCount = 0;
734
+ let totalBytes = 0;
735
+ let artifactByteLimitExceeded = false;
736
+
737
+ try {
738
+ for (const entry of fileEntries) {
739
+ entryCount += 1;
740
+ if (entryCount > LIMITS.files) {
741
+ context.add("limit.files", "$files", `Artifact may not contain more than ${LIMITS.files} semantic files.`);
742
+ break;
743
+ }
744
+ const entryPath = `$files[${entryCount - 1}]`;
745
+ if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
746
+ context.add("file.invalid_entry", entryPath, "File entries must be objects with path and content.");
747
+ continue;
748
+ }
749
+ const normalized = normalizeArtifactPath(entry.path);
750
+ if (!normalized.ok) {
751
+ context.add(normalized.code, `${entryPath}.path`, normalized.message);
752
+ continue;
753
+ }
754
+ if (seenPaths.has(normalized.value)) {
755
+ context.add("duplicate.file_entry", `${entryPath}.path`, `File '${normalized.value}' appears more than once.`);
756
+ continue;
757
+ }
758
+ seenPaths.add(normalized.value);
759
+ const expectedFile = expected.get(normalized.value);
760
+ if (!expectedFile) {
761
+ context.add("file.unexpected", `${entryPath}.path`, `File '${normalized.value}' is not declared by the semantic manifest.`);
762
+ continue;
763
+ }
764
+ const maximumBytes = expectedFile.kind === "fragment" ? LIMITS.fragmentBytes : LIMITS.assetBytes;
765
+ const remainingArtifactBytes = LIMITS.artifactBytes - totalBytes;
766
+ const content = inspectContent(entry.content, maximumBytes, remainingArtifactBytes);
767
+ if (!content) {
768
+ context.add("file.invalid_content", `${entryPath}.content`, "File content must be a string, Uint8Array, or ArrayBuffer.");
769
+ continue;
770
+ }
771
+ if (content.error) {
772
+ context.add(content.error, `${entryPath}.content`, "String file content must contain only well-formed Unicode scalar values.");
773
+ continue;
774
+ }
775
+ if (content.byteLength > remainingArtifactBytes) {
776
+ context.add("limit.artifact_bytes", "$files", `Artifact bytes may not exceed ${LIMITS.artifactBytes}.`);
777
+ artifactByteLimitExceeded = true;
778
+ break;
779
+ }
780
+ totalBytes += content.byteLength;
781
+ if (content.byteLength > maximumBytes) {
782
+ context.add("file.too_large", `${expectedFile.path}.path`, `File '${normalized.value}' exceeds its ${maximumBytes}-byte content bound.`);
783
+ continue;
784
+ }
785
+ if (content.byteLength !== expectedFile.descriptor.bytes) {
786
+ context.add("file.size_drift", `${expectedFile.path}.bytes`, `File '${normalized.value}' has ${content.byteLength} bytes; manifest declares ${expectedFile.descriptor.bytes}.`);
787
+ if (content.byteLength > expectedFile.descriptor.bytes) {
788
+ continue;
789
+ }
790
+ }
791
+ const bytes = materializeContent(content);
792
+ files.set(normalized.value, bytes);
793
+ }
794
+ } catch {
795
+ context.add("file.invalid_iterable", "$files", "Could not enumerate semantic files safely.");
796
+ }
797
+
798
+ if (artifactByteLimitExceeded) return context.finish({ manifest, fileCount: files.size, totalBytes });
799
+
800
+ const resources = new Map(manifest.resources.map((resource) => [resource.key, resource]));
801
+ const assets = new Map(manifest.assets.map((asset) => [asset.key, asset]));
802
+ for (const [path, expectedFile] of expected) {
803
+ const bytes = files.get(path);
804
+ if (!bytes) {
805
+ if (!seenPaths.has(path)) {
806
+ context.add("file.missing", `${expectedFile.path}.path`, `Declared file '${path}' is missing.`);
807
+ }
808
+ continue;
809
+ }
810
+ const actualHash = await sha256(bytes);
811
+ if (actualHash !== expectedFile.descriptor.sha256) {
812
+ context.add("file.hash_drift", `${expectedFile.path}.sha256`, `File '${path}' does not match its declared SHA-256.`);
813
+ }
814
+ if (expectedFile.kind === "asset") {
815
+ const dimensions = readImageDimensions(expectedFile.descriptor.mediaType, bytes);
816
+ if (!dimensions) {
817
+ context.add("asset.media_mismatch", `${expectedFile.path}.mediaType`, `File '${path}' is not a supported ${expectedFile.descriptor.mediaType} image.`);
818
+ } else if (dimensions.error === "asset.decoded_pixels") {
819
+ context.add(dimensions.error, expectedFile.path, `File '${path}' exceeds the ${LIMITS.decodedPixels}-pixel decoded image bound.`);
820
+ } else if (dimensions.error === "asset.animation_frames") {
821
+ context.add(dimensions.error, expectedFile.path, `File '${path}' exceeds the ${LIMITS.animationFrames}-frame animation bound.`);
822
+ } else if (dimensions.error === "asset.decoded_animation_pixels") {
823
+ context.add(dimensions.error, expectedFile.path, `File '${path}' exceeds the ${LIMITS.decodedAnimationPixels}-pixel cumulative animation bound.`);
824
+ } else if (dimensions.width !== expectedFile.descriptor.width || dimensions.height !== expectedFile.descriptor.height) {
825
+ context.add("asset.dimension_drift", expectedFile.path, `File '${path}' dimensions do not match the manifest.`);
826
+ }
827
+ }
828
+ }
829
+
830
+ for (const { variant, fragments } of variants) {
831
+ const actualHeadings = [];
832
+ let actualHeadingCount = 0;
833
+ const maximumCollectedHeadings = variant.headings.length + 1;
834
+ const model = {
835
+ resources,
836
+ assets,
837
+ locale: variant.locale,
838
+ localHeadingIds: new Set(variant.headings.map((heading) => heading.id)),
839
+ };
840
+ for (const fragment of fragments) {
841
+ const bytes = files.get(fragment.descriptor.path);
842
+ if (!bytes) continue;
843
+ let markup;
844
+ try {
845
+ markup = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
846
+ } catch {
847
+ context.add("fragment.invalid_utf8", `${fragment.path}.path`, `Fragment '${fragment.descriptor.path}' must be valid UTF-8.`);
848
+ continue;
849
+ }
850
+ const headingResult = validateHtmlFragment(
851
+ markup,
852
+ context,
853
+ `${fragment.path}.path`,
854
+ model,
855
+ Math.max(0, maximumCollectedHeadings - actualHeadings.length),
856
+ );
857
+ actualHeadings.push(...headingResult.headings);
858
+ actualHeadingCount += headingResult.headingCount;
859
+ }
860
+ compareFragmentHeadings(actualHeadings, variant.headings, context, `resource:${variant.route}`, actualHeadingCount);
861
+ }
862
+
863
+ return context.finish({ manifest, fileCount: files.size, totalBytes });
864
+ }
865
+
866
+ export async function assertValidArtifact(manifestInput, fileEntries, options = {}) {
867
+ const result = await validateArtifact(manifestInput, fileEntries, options);
868
+ if (!result.ok) throw new DocsArtifactValidationError(result.errors);
869
+ return result.value;
870
+ }