@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.
- package/LICENSE +13 -0
- package/README.md +362 -0
- package/bin/taproot-docs-conformance.js +20 -0
- package/bin/taproot-docs-validate.js +17 -0
- package/conformance.d.ts +11 -0
- package/fixtures/README.md +24 -0
- package/fixtures/conformance.json +1630 -0
- package/fixtures/invalid/duplicate-json-key.json +1 -0
- package/fixtures/invalid/hash-drift/taproot-docs/fragments/welcome.html +1 -0
- package/fixtures/invalid/size-drift/taproot-docs/fragments/welcome.html +1 -0
- package/fixtures/invalid/unsafe-markup/taproot-docs/fragments/welcome.html +1 -0
- package/fixtures/valid/complete/taproot-docs/assets/pixel.png.base64 +1 -0
- package/fixtures/valid/complete/taproot-docs/fragments/button.en-us.html +1 -0
- package/fixtures/valid/complete/taproot-docs/fragments/button.fr-fr.html +1 -0
- package/fixtures/valid/complete/taproot-docs/fragments/getting-started.en-us.html +3 -0
- package/fixtures/valid/complete/taproot-docs/fragments/getting-started.fr-fr.html +3 -0
- package/fixtures/valid/complete/taproot-docs-manifest.json +231 -0
- package/fixtures/valid/minimal/taproot-docs/fragments/welcome.html +1 -0
- package/fixtures/valid/minimal/taproot-docs-manifest.json +80 -0
- package/index.d.ts +204 -0
- package/node.d.ts +6 -0
- package/package.json +54 -0
- package/schema/taproot-docs-manifest.schema.json +487 -0
- package/src/artifact-validator.js +870 -0
- package/src/binary.js +67 -0
- package/src/conformance.js +578 -0
- package/src/constants.js +104 -0
- package/src/errors.js +139 -0
- package/src/index.js +18 -0
- package/src/json.js +516 -0
- package/src/manifest-validator.js +650 -0
- package/src/markup.js +578 -0
- package/src/node-internal.js +4 -0
- package/src/node.js +513 -0
- package/src/path.js +103 -0
- package/src/text.js +30 -0
|
@@ -0,0 +1,650 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ASSET_MEDIA_TYPES,
|
|
3
|
+
AUDIENCES,
|
|
4
|
+
FRAGMENT_MEDIA_TYPE,
|
|
5
|
+
FRAGMENT_ROLES,
|
|
6
|
+
HTML_FRAGMENT_CAPABILITY,
|
|
7
|
+
LIMITS,
|
|
8
|
+
RESOURCE_KINDS,
|
|
9
|
+
SCHEMA_VERSION,
|
|
10
|
+
SUPPORTED_CAPABILITIES,
|
|
11
|
+
} from "./constants.js";
|
|
12
|
+
import { compareCanonicalStrings, DocsArtifactValidationError, ValidationContext } from "./errors.js";
|
|
13
|
+
import { canonicalJson, canonicalJsonByteLength, classifyManifestInput, parseManifestJson, preflightManifestObject } from "./json.js";
|
|
14
|
+
import { normalizeArtifactPath, normalizeRoute, normalizeSourcePath } from "./path.js";
|
|
15
|
+
import {
|
|
16
|
+
classifySupportedLocale,
|
|
17
|
+
hasDisallowedStringCharacters,
|
|
18
|
+
SUPPORTED_LOCALE_MAX_LENGTH,
|
|
19
|
+
} from "./text.js";
|
|
20
|
+
|
|
21
|
+
const RESOURCE_KEY = /^[a-z0-9]+(?:[._:/-][a-z0-9]+)*$/;
|
|
22
|
+
const TOKEN = /^[a-z0-9]+(?:[._-][a-z0-9]+)*$/;
|
|
23
|
+
const HASH = /^sha256:[0-9a-f]{64}$/;
|
|
24
|
+
const SEMVER_IDENTIFIER = String.raw`(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)`;
|
|
25
|
+
const SEMVER = new RegExp(
|
|
26
|
+
String.raw`^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-${SEMVER_IDENTIFIER}(?:\.${SEMVER_IDENTIFIER})*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$`,
|
|
27
|
+
);
|
|
28
|
+
const REVISION = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
|
|
29
|
+
const REPOSITORY = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
|
30
|
+
const REF = /^refs\/(?:heads|tags)\/[A-Za-z0-9][A-Za-z0-9._/-]*$/;
|
|
31
|
+
|
|
32
|
+
function isRecord(value) {
|
|
33
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function checkObject(context, value, path, required, allowed) {
|
|
37
|
+
if (!isRecord(value)) {
|
|
38
|
+
context.add("type.object", path, "Expected an object.");
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
for (const key of required) {
|
|
42
|
+
if (!Object.hasOwn(value, key)) context.add("property.required", `${path}.${key}`, `Required property '${key}' is missing.`);
|
|
43
|
+
}
|
|
44
|
+
for (const key of Object.keys(value)) {
|
|
45
|
+
if (!allowed.includes(key)) context.add("property.unsupported", `${path}.${key}`, `Property '${key}' is not supported by schema v${SCHEMA_VERSION}.`);
|
|
46
|
+
}
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function stringValue(context, value, path, { min = 1, max = LIMITS.string, pattern, values } = {}) {
|
|
51
|
+
if (typeof value !== "string") {
|
|
52
|
+
context.add("type.string", path, "Expected a string.");
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
if (!value.isWellFormed()) {
|
|
56
|
+
context.add("string.invalid_unicode", path, "Strings must contain only well-formed Unicode scalar values.");
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
if (value.length > max * 2) {
|
|
60
|
+
context.add("string.length", path, `String length must be between ${min} and ${max} characters.`);
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
const scalarLength = [...value].length;
|
|
64
|
+
if (scalarLength < min || scalarLength > max) {
|
|
65
|
+
context.add("string.length", path, `String length must be between ${min} and ${max} characters.`);
|
|
66
|
+
}
|
|
67
|
+
if (value !== value.normalize("NFC")) context.add("string.not_normalized", path, "Strings must use Unicode NFC normalization.");
|
|
68
|
+
if (hasDisallowedStringCharacters(value)) {
|
|
69
|
+
context.add(
|
|
70
|
+
"string.control",
|
|
71
|
+
path,
|
|
72
|
+
"Strings may not contain C0, C1, or bidirectional formatting and override controls.",
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
if (pattern && !pattern.test(value)) context.add("string.pattern", path, "String does not match the required canonical form.");
|
|
76
|
+
if (values && !values.includes(value)) context.add("value.unsupported", path, `Unsupported value '${value}'.`);
|
|
77
|
+
return value;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function integerValue(context, value, path, { min = 0, max = Number.MAX_SAFE_INTEGER } = {}) {
|
|
81
|
+
if (!Number.isSafeInteger(value)) {
|
|
82
|
+
context.add("type.integer", path, "Expected a safe integer.");
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
if (value < min || value > max) context.add("number.range", path, `Integer must be between ${min} and ${max}.`);
|
|
86
|
+
return value;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function arrayValue(context, value, path, { min = 0, max }) {
|
|
90
|
+
if (!Array.isArray(value)) {
|
|
91
|
+
context.add("type.array", path, "Expected an array.");
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
if (value.length < min || value.length > max) context.add("array.length", path, `Array length must be between ${min} and ${max}.`);
|
|
95
|
+
return value;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function validateSortedUniqueStrings(context, value, path, options) {
|
|
99
|
+
const values = arrayValue(context, value, path, options);
|
|
100
|
+
if (!values) return [];
|
|
101
|
+
const seen = new Set();
|
|
102
|
+
const parsed = [];
|
|
103
|
+
for (let index = 0; index < Math.min(values.length, options.max); index += 1) {
|
|
104
|
+
const itemPath = `${path}[${index}]`;
|
|
105
|
+
const item = stringValue(context, values[index], itemPath, options.item ?? {});
|
|
106
|
+
if (item === undefined) continue;
|
|
107
|
+
if (seen.has(item)) context.add("duplicate.value", itemPath, `Duplicate value '${item}'.`);
|
|
108
|
+
seen.add(item);
|
|
109
|
+
parsed.push(item);
|
|
110
|
+
}
|
|
111
|
+
if (parsed.some((item, index) => index > 0 && compareCanonicalStrings(parsed[index - 1], item) >= 0)) {
|
|
112
|
+
context.add("array.not_sorted", path, "Array values must be unique and sorted lexicographically.");
|
|
113
|
+
}
|
|
114
|
+
return parsed;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function validateHttpsUrl(context, value, path) {
|
|
118
|
+
const candidate = stringValue(context, value, path, { max: LIMITS.url });
|
|
119
|
+
if (candidate === undefined) return;
|
|
120
|
+
try {
|
|
121
|
+
const url = new URL(candidate);
|
|
122
|
+
if (!candidate.startsWith("https://") || url.protocol !== "https:" || url.username !== "" || url.password !== "") {
|
|
123
|
+
context.add("url.unsafe", path, "URLs must start with canonical 'https://' and may not contain credentials.");
|
|
124
|
+
}
|
|
125
|
+
} catch {
|
|
126
|
+
context.add("url.invalid", path, "Expected an absolute HTTPS URL.");
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function validateHash(context, value, path) {
|
|
131
|
+
stringValue(context, value, path, { min: 71, max: 71, pattern: HASH });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function validateLocale(context, value, path) {
|
|
135
|
+
const candidate = stringValue(context, value, path, { max: SUPPORTED_LOCALE_MAX_LENGTH });
|
|
136
|
+
if (candidate === undefined) return undefined;
|
|
137
|
+
const classification = classifySupportedLocale(candidate);
|
|
138
|
+
if (classification === "not_canonical") {
|
|
139
|
+
context.add("locale.not_canonical", path, "Locale tags must use the canonical casing of the supported Docs v1 BCP 47 subset.");
|
|
140
|
+
} else if (classification === "unsupported") {
|
|
141
|
+
context.add("locale.invalid", path, "Expected a locale in the supported Docs v1 BCP 47 subset.");
|
|
142
|
+
}
|
|
143
|
+
return candidate;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function validateSourceLocation(context, value, path) {
|
|
147
|
+
if (!checkObject(context, value, path, ["path", "url"], ["path", "url", "startLine", "endLine"])) return;
|
|
148
|
+
const sourcePath = stringValue(context, value.path, `${path}.path`, { max: LIMITS.sourcePath });
|
|
149
|
+
if (sourcePath !== undefined) {
|
|
150
|
+
const result = normalizeSourcePath(sourcePath);
|
|
151
|
+
if (!result.ok) context.add(result.code, `${path}.path`, result.message);
|
|
152
|
+
}
|
|
153
|
+
validateHttpsUrl(context, value.url, `${path}.url`);
|
|
154
|
+
const startLine = value.startLine === undefined
|
|
155
|
+
? undefined
|
|
156
|
+
: integerValue(context, value.startLine, `${path}.startLine`, { min: 1, max: 10_000_000 });
|
|
157
|
+
const endLine = value.endLine === undefined
|
|
158
|
+
? undefined
|
|
159
|
+
: integerValue(context, value.endLine, `${path}.endLine`, { min: 1, max: 10_000_000 });
|
|
160
|
+
if ((startLine === undefined) !== (endLine === undefined)) {
|
|
161
|
+
context.add("source.line_pair", path, "startLine and endLine must be supplied together.");
|
|
162
|
+
} else if (startLine !== undefined && endLine < startLine) {
|
|
163
|
+
context.add("source.line_order", `${path}.endLine`, "endLine may not precede startLine.");
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function validateSemanticMetadata(context, value, path) {
|
|
168
|
+
if (!checkObject(context, value, path, ["kind", "audiences", "tags"], ["kind", "audiences", "tags"])) return;
|
|
169
|
+
stringValue(context, value.kind, `${path}.kind`, { values: RESOURCE_KINDS });
|
|
170
|
+
validateSortedUniqueStrings(context, value.audiences, `${path}.audiences`, {
|
|
171
|
+
min: 1,
|
|
172
|
+
max: AUDIENCES.length,
|
|
173
|
+
item: { values: AUDIENCES },
|
|
174
|
+
});
|
|
175
|
+
validateSortedUniqueStrings(context, value.tags, `${path}.tags`, {
|
|
176
|
+
min: 0,
|
|
177
|
+
max: 100,
|
|
178
|
+
item: { max: 100, pattern: TOKEN },
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function validateFragment(context, value, path, state) {
|
|
183
|
+
if (!checkObject(
|
|
184
|
+
context,
|
|
185
|
+
value,
|
|
186
|
+
path,
|
|
187
|
+
["key", "role", "path", "mediaType", "bytes", "sha256"],
|
|
188
|
+
["key", "role", "path", "mediaType", "bytes", "sha256"],
|
|
189
|
+
)) return;
|
|
190
|
+
stringValue(context, value.key, `${path}.key`, { max: 100, pattern: TOKEN });
|
|
191
|
+
stringValue(context, value.role, `${path}.role`, { values: FRAGMENT_ROLES });
|
|
192
|
+
const artifactPath = stringValue(context, value.path, `${path}.path`, { max: LIMITS.artifactPath });
|
|
193
|
+
if (artifactPath !== undefined) {
|
|
194
|
+
const result = normalizeArtifactPath(artifactPath);
|
|
195
|
+
if (!result.ok) context.add(result.code, `${path}.path`, result.message);
|
|
196
|
+
else if (!result.value.startsWith("taproot-docs/fragments/")) {
|
|
197
|
+
context.add("path.fragment_root", `${path}.path`, "Fragment files must live below 'taproot-docs/fragments/'.");
|
|
198
|
+
} else if (state.filePaths.has(result.value)) {
|
|
199
|
+
context.add("duplicate.file_path", `${path}.path`, `Artifact file path '${result.value}' is already declared.`);
|
|
200
|
+
} else {
|
|
201
|
+
state.filePaths.set(result.value, path);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
stringValue(context, value.mediaType, `${path}.mediaType`, { values: [FRAGMENT_MEDIA_TYPE] });
|
|
205
|
+
const bytes = integerValue(context, value.bytes, `${path}.bytes`, { min: 1, max: LIMITS.fragmentBytes });
|
|
206
|
+
if (bytes !== undefined) state.declaredBytes += bytes;
|
|
207
|
+
validateHash(context, value.sha256, `${path}.sha256`);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function validateVariant(context, value, path, state, variantLocales) {
|
|
211
|
+
if (!checkObject(
|
|
212
|
+
context,
|
|
213
|
+
value,
|
|
214
|
+
path,
|
|
215
|
+
["locale", "route", "title", "description", "headings", "source", "fragments"],
|
|
216
|
+
["locale", "route", "title", "description", "headings", "source", "fragments"],
|
|
217
|
+
)) return;
|
|
218
|
+
const locale = validateLocale(context, value.locale, `${path}.locale`);
|
|
219
|
+
if (locale !== undefined) {
|
|
220
|
+
variantLocales.add(locale);
|
|
221
|
+
if (!state.localeTags.has(locale)) {
|
|
222
|
+
context.add("resource.unknown_locale", `${path}.locale`, `Resource locale '${locale}' is not declared.`);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
const route = stringValue(context, value.route, `${path}.route`, { max: LIMITS.route });
|
|
226
|
+
if (route !== undefined) {
|
|
227
|
+
const result = normalizeRoute(route);
|
|
228
|
+
if (!result.ok) context.add(result.code, `${path}.route`, result.message);
|
|
229
|
+
else if (state.routes.has(result.value)) context.add("duplicate.route", `${path}.route`, `Route '${result.value}' is already assigned.`);
|
|
230
|
+
else state.routes.set(result.value, path);
|
|
231
|
+
}
|
|
232
|
+
stringValue(context, value.title, `${path}.title`, { max: LIMITS.title });
|
|
233
|
+
stringValue(context, value.description, `${path}.description`, { min: 0, max: LIMITS.description });
|
|
234
|
+
|
|
235
|
+
const headings = arrayValue(context, value.headings, `${path}.headings`, { min: 0, max: LIMITS.headingsPerVariant });
|
|
236
|
+
const headingIds = new Set();
|
|
237
|
+
if (headings) {
|
|
238
|
+
for (let index = 0; index < Math.min(headings.length, LIMITS.headingsPerVariant); index += 1) {
|
|
239
|
+
const headingPath = `${path}.headings[${index}]`;
|
|
240
|
+
const heading = headings[index];
|
|
241
|
+
if (!checkObject(context, heading, headingPath, ["id", "text", "level"], ["id", "text", "level"])) continue;
|
|
242
|
+
const id = stringValue(context, heading.id, `${headingPath}.id`, { max: 200, pattern: /^[a-z0-9]+(?:-[a-z0-9]+)*$/ });
|
|
243
|
+
if (id !== undefined) {
|
|
244
|
+
if (headingIds.has(id)) context.add("duplicate.heading_id", `${headingPath}.id`, `Heading id '${id}' is duplicated.`);
|
|
245
|
+
headingIds.add(id);
|
|
246
|
+
}
|
|
247
|
+
stringValue(context, heading.text, `${headingPath}.text`, { max: LIMITS.title });
|
|
248
|
+
integerValue(context, heading.level, `${headingPath}.level`, { min: 2, max: 6 });
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
validateSourceLocation(context, value.source, `${path}.source`);
|
|
253
|
+
const fragments = arrayValue(context, value.fragments, `${path}.fragments`, { min: 1, max: LIMITS.fragments });
|
|
254
|
+
const fragmentKeys = new Set();
|
|
255
|
+
let bodyCount = 0;
|
|
256
|
+
if (fragments) {
|
|
257
|
+
state.fragmentCount += fragments.length;
|
|
258
|
+
for (let index = 0; index < Math.min(fragments.length, LIMITS.fragments); index += 1) {
|
|
259
|
+
const fragmentPath = `${path}.fragments[${index}]`;
|
|
260
|
+
const fragment = fragments[index];
|
|
261
|
+
validateFragment(context, fragment, fragmentPath, state);
|
|
262
|
+
if (isRecord(fragment) && typeof fragment.key === "string") {
|
|
263
|
+
if (fragmentKeys.has(fragment.key)) context.add("duplicate.fragment_key", `${fragmentPath}.key`, `Fragment key '${fragment.key}' is duplicated in this variant.`);
|
|
264
|
+
fragmentKeys.add(fragment.key);
|
|
265
|
+
}
|
|
266
|
+
if (isRecord(fragment) && fragment.role === "body") bodyCount += 1;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
if (bodyCount !== 1) context.add("fragment.body_count", `${path}.fragments`, "Each resource variant must declare exactly one body fragment.");
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function validateResource(context, value, path, state) {
|
|
273
|
+
if (!checkObject(context, value, path, ["key", "semantic", "variants"], ["key", "semantic", "variants"])) return;
|
|
274
|
+
const key = stringValue(context, value.key, `${path}.key`, { max: LIMITS.resourceKey, pattern: RESOURCE_KEY });
|
|
275
|
+
if (key !== undefined) {
|
|
276
|
+
if (state.resources.has(key)) context.add("duplicate.resource_key", `${path}.key`, `Resource key '${key}' is duplicated.`);
|
|
277
|
+
else state.resources.set(key, value);
|
|
278
|
+
}
|
|
279
|
+
validateSemanticMetadata(context, value.semantic, `${path}.semantic`);
|
|
280
|
+
const variants = arrayValue(context, value.variants, `${path}.variants`, { min: 1, max: LIMITS.variants });
|
|
281
|
+
const variantLocales = new Set();
|
|
282
|
+
if (variants) {
|
|
283
|
+
state.variantCount += variants.length;
|
|
284
|
+
for (let index = 0; index < Math.min(variants.length, LIMITS.variants); index += 1) {
|
|
285
|
+
validateVariant(context, variants[index], `${path}.variants[${index}]`, state, variantLocales);
|
|
286
|
+
}
|
|
287
|
+
const validLocales = variants.filter(isRecord).map((variant) => variant.locale).filter((locale) => typeof locale === "string");
|
|
288
|
+
if (validLocales.some((locale, index) => index > 0 && compareCanonicalStrings(validLocales[index - 1], locale) >= 0)) {
|
|
289
|
+
context.add("array.not_sorted", `${path}.variants`, "Resource variants must have unique locale tags sorted lexicographically.");
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
if (state.defaultLocale !== undefined && !variantLocales.has(state.defaultLocale)) {
|
|
293
|
+
context.add("resource.default_locale", `${path}.variants`, `Resource must include the default locale '${state.defaultLocale}'.`);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function validateNavigationItems(context, items, path, state, locale, navigationResources, depth) {
|
|
298
|
+
if (state.navigationLimitExceeded) return;
|
|
299
|
+
if (depth > LIMITS.navigationDepth) {
|
|
300
|
+
context.add("navigation.too_deep", path, `Navigation may not exceed ${LIMITS.navigationDepth} levels.`);
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
for (let index = 0; index < items.length; index += 1) {
|
|
304
|
+
if (state.navigationLimitExceeded) return;
|
|
305
|
+
if (state.navigationNodeCount >= LIMITS.navigationNodes) {
|
|
306
|
+
context.add("limit.navigation_nodes", "$.navigation", `Artifact may not declare more than ${LIMITS.navigationNodes} navigation nodes.`);
|
|
307
|
+
state.navigationLimitExceeded = true;
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
state.navigationNodeCount += 1;
|
|
311
|
+
const nodePath = `${path}[${index}]`;
|
|
312
|
+
const node = items[index];
|
|
313
|
+
if (!checkObject(context, node, nodePath, ["label"], ["label", "resourceKey", "children"])) continue;
|
|
314
|
+
stringValue(context, node.label, `${nodePath}.label`, { max: LIMITS.title });
|
|
315
|
+
let hasResource = false;
|
|
316
|
+
if (node.resourceKey !== undefined) {
|
|
317
|
+
const resourceKey = stringValue(context, node.resourceKey, `${nodePath}.resourceKey`, { max: LIMITS.resourceKey, pattern: RESOURCE_KEY });
|
|
318
|
+
hasResource = resourceKey !== undefined;
|
|
319
|
+
if (resourceKey !== undefined) {
|
|
320
|
+
const resource = state.resources.get(resourceKey);
|
|
321
|
+
if (!resource) context.add("navigation.unknown_resource", `${nodePath}.resourceKey`, `Navigation references unknown resource '${resourceKey}'.`);
|
|
322
|
+
else if (!Array.isArray(resource.variants) || !resource.variants.some((variant) => variant?.locale === locale)) {
|
|
323
|
+
context.add("navigation.missing_locale", `${nodePath}.resourceKey`, `Resource '${resourceKey}' has no '${locale}' variant.`);
|
|
324
|
+
}
|
|
325
|
+
if (navigationResources.has(resourceKey)) context.add("navigation.duplicate_resource", `${nodePath}.resourceKey`, `Resource '${resourceKey}' appears more than once in '${locale}' navigation.`);
|
|
326
|
+
navigationResources.add(resourceKey);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
let children = [];
|
|
330
|
+
if (node.children !== undefined) {
|
|
331
|
+
children = arrayValue(context, node.children, `${nodePath}.children`, { min: 1, max: LIMITS.navigationNodes }) ?? [];
|
|
332
|
+
}
|
|
333
|
+
if (!hasResource && children.length === 0) context.add("navigation.empty_node", nodePath, "A navigation node must reference a resource or contain child nodes.");
|
|
334
|
+
if (children.length > 0) {
|
|
335
|
+
validateNavigationItems(context, children, `${nodePath}.children`, state, locale, navigationResources, depth + 1);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function validateRoot(value, supportedCapabilities) {
|
|
341
|
+
const context = new ValidationContext();
|
|
342
|
+
if (!checkObject(
|
|
343
|
+
context,
|
|
344
|
+
value,
|
|
345
|
+
"$",
|
|
346
|
+
["schemaVersion", "defaultLocale", "source", "build", "capabilities", "locales", "resources", "navigation", "redirects", "assets"],
|
|
347
|
+
["schemaVersion", "defaultLocale", "source", "build", "capabilities", "locales", "resources", "navigation", "redirects", "assets"],
|
|
348
|
+
)) return context.finish(value);
|
|
349
|
+
|
|
350
|
+
const schemaVersion = integerValue(context, value.schemaVersion, "$.schemaVersion", { min: SCHEMA_VERSION, max: SCHEMA_VERSION });
|
|
351
|
+
if (schemaVersion !== undefined && schemaVersion !== SCHEMA_VERSION) {
|
|
352
|
+
context.add("schema.unsupported", "$.schemaVersion", `Only schemaVersion ${SCHEMA_VERSION} is supported.`);
|
|
353
|
+
}
|
|
354
|
+
const defaultLocale = validateLocale(context, value.defaultLocale, "$.defaultLocale");
|
|
355
|
+
|
|
356
|
+
if (checkObject(context, value.source, "$.source", ["provider", "repositoryId", "repository", "repositoryUrl", "revision", "ref"], ["provider", "repositoryId", "repository", "repositoryUrl", "revision", "ref"])) {
|
|
357
|
+
stringValue(context, value.source.provider, "$.source.provider", { values: ["github"] });
|
|
358
|
+
stringValue(context, value.source.repositoryId, "$.source.repositoryId", { max: 200, pattern: /^[A-Za-z0-9_.:-]+$/ });
|
|
359
|
+
stringValue(context, value.source.repository, "$.source.repository", { max: 300, pattern: REPOSITORY });
|
|
360
|
+
validateHttpsUrl(context, value.source.repositoryUrl, "$.source.repositoryUrl");
|
|
361
|
+
stringValue(context, value.source.revision, "$.source.revision", { min: 40, max: 64, pattern: REVISION });
|
|
362
|
+
stringValue(context, value.source.ref, "$.source.ref", { max: 500, pattern: REF });
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
if (checkObject(context, value.build, "$.build", ["producer", "producerVersion", "configurationSha256", "sourceDateEpoch"], ["producer", "producerVersion", "configurationSha256", "sourceDateEpoch"])) {
|
|
366
|
+
stringValue(context, value.build.producer, "$.build.producer", { max: 200, pattern: /^(?:@[a-z0-9._-]+\/)?[a-z0-9._-]+$/ });
|
|
367
|
+
stringValue(context, value.build.producerVersion, "$.build.producerVersion", { max: 100, pattern: SEMVER });
|
|
368
|
+
validateHash(context, value.build.configurationSha256, "$.build.configurationSha256");
|
|
369
|
+
integerValue(context, value.build.sourceDateEpoch, "$.build.sourceDateEpoch", { min: 0, max: 253_402_300_799 });
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (checkObject(context, value.capabilities, "$.capabilities", ["required", "optional"], ["required", "optional"])) {
|
|
373
|
+
const required = validateSortedUniqueStrings(context, value.capabilities.required, "$.capabilities.required", {
|
|
374
|
+
min: 1,
|
|
375
|
+
max: 100,
|
|
376
|
+
item: { max: 200, pattern: RESOURCE_KEY },
|
|
377
|
+
});
|
|
378
|
+
const optional = validateSortedUniqueStrings(context, value.capabilities.optional, "$.capabilities.optional", {
|
|
379
|
+
min: 0,
|
|
380
|
+
max: 100,
|
|
381
|
+
item: { max: 200, pattern: RESOURCE_KEY },
|
|
382
|
+
});
|
|
383
|
+
if (!required.includes(HTML_FRAGMENT_CAPABILITY)) {
|
|
384
|
+
context.add("capability.missing", "$.capabilities.required", `Schema v${SCHEMA_VERSION} requires '${HTML_FRAGMENT_CAPABILITY}'.`);
|
|
385
|
+
}
|
|
386
|
+
for (let index = 0; index < required.length; index += 1) {
|
|
387
|
+
if (!supportedCapabilities.has(required[index])) {
|
|
388
|
+
context.add("capability.unsupported", `$.capabilities.required[${index}]`, `Required capability '${required[index]}' is not supported.`);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
for (const capability of optional) {
|
|
392
|
+
if (required.includes(capability)) context.add("duplicate.capability", "$.capabilities", `Capability '${capability}' cannot be both required and optional.`);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const locales = arrayValue(context, value.locales, "$.locales", { min: 1, max: 100 });
|
|
397
|
+
const localeTags = new Set();
|
|
398
|
+
if (locales) {
|
|
399
|
+
const order = [];
|
|
400
|
+
for (let index = 0; index < Math.min(locales.length, 100); index += 1) {
|
|
401
|
+
const localePath = `$.locales[${index}]`;
|
|
402
|
+
const locale = locales[index];
|
|
403
|
+
if (!checkObject(context, locale, localePath, ["tag", "label"], ["tag", "label"])) continue;
|
|
404
|
+
const tag = validateLocale(context, locale.tag, `${localePath}.tag`);
|
|
405
|
+
if (tag !== undefined) {
|
|
406
|
+
if (localeTags.has(tag)) context.add("duplicate.locale", `${localePath}.tag`, `Locale '${tag}' is duplicated.`);
|
|
407
|
+
localeTags.add(tag);
|
|
408
|
+
order.push(tag);
|
|
409
|
+
}
|
|
410
|
+
stringValue(context, locale.label, `${localePath}.label`, { max: 100 });
|
|
411
|
+
}
|
|
412
|
+
if (order.some((item, index) => index > 0 && compareCanonicalStrings(order[index - 1], item) >= 0)) {
|
|
413
|
+
context.add("array.not_sorted", "$.locales", "Locales must be unique and sorted by tag.");
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
if (defaultLocale !== undefined && !localeTags.has(defaultLocale)) {
|
|
417
|
+
context.add("locale.default_missing", "$.defaultLocale", `Default locale '${defaultLocale}' is not declared in locales.`);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const state = {
|
|
421
|
+
defaultLocale,
|
|
422
|
+
localeTags,
|
|
423
|
+
resources: new Map(),
|
|
424
|
+
routes: new Map(),
|
|
425
|
+
filePaths: new Map(),
|
|
426
|
+
variantCount: 0,
|
|
427
|
+
fragmentCount: 0,
|
|
428
|
+
declaredBytes: 0,
|
|
429
|
+
navigationNodeCount: 0,
|
|
430
|
+
navigationLimitExceeded: false,
|
|
431
|
+
};
|
|
432
|
+
const resources = arrayValue(context, value.resources, "$.resources", { min: 1, max: LIMITS.resources });
|
|
433
|
+
const resourceOrder = [];
|
|
434
|
+
if (resources) {
|
|
435
|
+
for (let index = 0; index < Math.min(resources.length, LIMITS.resources); index += 1) {
|
|
436
|
+
validateResource(context, resources[index], `$.resources[${index}]`, state);
|
|
437
|
+
if (isRecord(resources[index]) && typeof resources[index].key === "string") resourceOrder.push(resources[index].key);
|
|
438
|
+
}
|
|
439
|
+
if (resourceOrder.some((item, index) => index > 0 && compareCanonicalStrings(resourceOrder[index - 1], item) >= 0)) {
|
|
440
|
+
context.add("array.not_sorted", "$.resources", "Resources must have unique keys sorted lexicographically.");
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
if (state.variantCount > LIMITS.variants) context.add("limit.variants", "$.resources", `Artifact may not declare more than ${LIMITS.variants} resource variants.`);
|
|
444
|
+
if (state.fragmentCount > LIMITS.fragments) context.add("limit.fragments", "$.resources", `Artifact may not declare more than ${LIMITS.fragments} fragments.`);
|
|
445
|
+
|
|
446
|
+
const assets = arrayValue(context, value.assets, "$.assets", { min: 0, max: LIMITS.assets });
|
|
447
|
+
const assetKeys = new Set();
|
|
448
|
+
const assetOrder = [];
|
|
449
|
+
if (assets) {
|
|
450
|
+
for (let index = 0; index < Math.min(assets.length, LIMITS.assets); index += 1) {
|
|
451
|
+
const assetPath = `$.assets[${index}]`;
|
|
452
|
+
const asset = assets[index];
|
|
453
|
+
if (!checkObject(context, asset, assetPath, ["key", "path", "mediaType", "bytes", "sha256", "width", "height"], ["key", "path", "mediaType", "bytes", "sha256", "width", "height"])) continue;
|
|
454
|
+
const key = stringValue(context, asset.key, `${assetPath}.key`, { max: LIMITS.resourceKey, pattern: RESOURCE_KEY });
|
|
455
|
+
if (key !== undefined) {
|
|
456
|
+
if (assetKeys.has(key)) context.add("duplicate.asset_key", `${assetPath}.key`, `Asset key '${key}' is duplicated.`);
|
|
457
|
+
assetKeys.add(key);
|
|
458
|
+
assetOrder.push(key);
|
|
459
|
+
}
|
|
460
|
+
const path = stringValue(context, asset.path, `${assetPath}.path`, { max: LIMITS.artifactPath });
|
|
461
|
+
if (path !== undefined) {
|
|
462
|
+
const result = normalizeArtifactPath(path);
|
|
463
|
+
if (!result.ok) context.add(result.code, `${assetPath}.path`, result.message);
|
|
464
|
+
else if (!result.value.startsWith("taproot-docs/assets/")) context.add("path.asset_root", `${assetPath}.path`, "Asset files must live below 'taproot-docs/assets/'.");
|
|
465
|
+
else if (state.filePaths.has(result.value)) context.add("duplicate.file_path", `${assetPath}.path`, `Artifact file path '${result.value}' is already declared.`);
|
|
466
|
+
else state.filePaths.set(result.value, assetPath);
|
|
467
|
+
}
|
|
468
|
+
stringValue(context, asset.mediaType, `${assetPath}.mediaType`, { values: ASSET_MEDIA_TYPES });
|
|
469
|
+
const bytes = integerValue(context, asset.bytes, `${assetPath}.bytes`, { min: 1, max: LIMITS.assetBytes });
|
|
470
|
+
if (bytes !== undefined) state.declaredBytes += bytes;
|
|
471
|
+
validateHash(context, asset.sha256, `${assetPath}.sha256`);
|
|
472
|
+
const width = integerValue(context, asset.width, `${assetPath}.width`, { min: 1, max: 32_768 });
|
|
473
|
+
const height = integerValue(context, asset.height, `${assetPath}.height`, { min: 1, max: 32_768 });
|
|
474
|
+
if (width !== undefined && height !== undefined && width * height > LIMITS.decodedPixels) {
|
|
475
|
+
context.add("asset.decoded_pixels", assetPath, `Asset dimensions may not exceed ${LIMITS.decodedPixels} decoded pixels.`);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
if (assetOrder.some((item, index) => index > 0 && compareCanonicalStrings(assetOrder[index - 1], item) >= 0)) {
|
|
479
|
+
context.add("array.not_sorted", "$.assets", "Assets must have unique keys sorted lexicographically.");
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
if (state.filePaths.size > LIMITS.files) context.add("limit.files", "$", `Artifact may not declare more than ${LIMITS.files} files.`);
|
|
483
|
+
if (state.declaredBytes > LIMITS.artifactBytes) context.add("limit.artifact_bytes", "$", `Declared artifact bytes may not exceed ${LIMITS.artifactBytes}.`);
|
|
484
|
+
|
|
485
|
+
const navigation = arrayValue(context, value.navigation, "$.navigation", { min: 1, max: 100 });
|
|
486
|
+
const navigationLocales = new Set();
|
|
487
|
+
const navigationOrder = [];
|
|
488
|
+
if (navigation) {
|
|
489
|
+
for (let index = 0; index < Math.min(navigation.length, 100); index += 1) {
|
|
490
|
+
const navigationPath = `$.navigation[${index}]`;
|
|
491
|
+
const localeNavigation = navigation[index];
|
|
492
|
+
if (!checkObject(context, localeNavigation, navigationPath, ["locale", "items"], ["locale", "items"])) continue;
|
|
493
|
+
const locale = validateLocale(context, localeNavigation.locale, `${navigationPath}.locale`);
|
|
494
|
+
if (locale !== undefined) {
|
|
495
|
+
if (!localeTags.has(locale)) context.add("navigation.unknown_locale", `${navigationPath}.locale`, `Navigation locale '${locale}' is not declared.`);
|
|
496
|
+
if (navigationLocales.has(locale)) context.add("duplicate.navigation_locale", `${navigationPath}.locale`, `Navigation for '${locale}' is duplicated.`);
|
|
497
|
+
navigationLocales.add(locale);
|
|
498
|
+
navigationOrder.push(locale);
|
|
499
|
+
}
|
|
500
|
+
const items = arrayValue(context, localeNavigation.items, `${navigationPath}.items`, { min: 1, max: LIMITS.navigationNodes }) ?? [];
|
|
501
|
+
validateNavigationItems(context, items, `${navigationPath}.items`, state, locale, new Set(), 1);
|
|
502
|
+
}
|
|
503
|
+
if (navigationOrder.some((item, index) => index > 0 && compareCanonicalStrings(navigationOrder[index - 1], item) >= 0)) {
|
|
504
|
+
context.add("array.not_sorted", "$.navigation", "Navigation sets must have unique locale tags sorted lexicographically.");
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
for (const locale of localeTags) {
|
|
508
|
+
if (!navigationLocales.has(locale)) context.add("navigation.locale_missing", "$.navigation", `Locale '${locale}' must have a navigation tree.`);
|
|
509
|
+
}
|
|
510
|
+
const redirects = arrayValue(context, value.redirects, "$.redirects", { min: 0, max: LIMITS.redirects });
|
|
511
|
+
const redirectSources = new Set();
|
|
512
|
+
const redirectOrder = [];
|
|
513
|
+
if (redirects) {
|
|
514
|
+
for (let index = 0; index < Math.min(redirects.length, LIMITS.redirects); index += 1) {
|
|
515
|
+
const redirectPath = `$.redirects[${index}]`;
|
|
516
|
+
const redirect = redirects[index];
|
|
517
|
+
if (!checkObject(context, redirect, redirectPath, ["from", "toResourceKey", "locale", "status"], ["from", "toResourceKey", "locale", "status"])) continue;
|
|
518
|
+
const from = stringValue(context, redirect.from, `${redirectPath}.from`, { max: LIMITS.route });
|
|
519
|
+
if (from !== undefined) {
|
|
520
|
+
const result = normalizeRoute(from);
|
|
521
|
+
if (!result.ok) context.add(result.code, `${redirectPath}.from`, result.message);
|
|
522
|
+
else {
|
|
523
|
+
if (redirectSources.has(result.value)) context.add("duplicate.redirect", `${redirectPath}.from`, `Redirect source '${result.value}' is duplicated.`);
|
|
524
|
+
if (state.routes.has(result.value)) context.add("redirect.route_collision", `${redirectPath}.from`, `Redirect source '${result.value}' collides with a resource route.`);
|
|
525
|
+
redirectSources.add(result.value);
|
|
526
|
+
redirectOrder.push(result.value);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
const targetKey = stringValue(context, redirect.toResourceKey, `${redirectPath}.toResourceKey`, { max: LIMITS.resourceKey, pattern: RESOURCE_KEY });
|
|
530
|
+
const locale = validateLocale(context, redirect.locale, `${redirectPath}.locale`);
|
|
531
|
+
const target = targetKey === undefined ? undefined : state.resources.get(targetKey);
|
|
532
|
+
if (targetKey !== undefined && !target) context.add("redirect.unknown_resource", `${redirectPath}.toResourceKey`, `Redirect targets unknown resource '${targetKey}'.`);
|
|
533
|
+
if (locale !== undefined && !localeTags.has(locale)) context.add("redirect.unknown_locale", `${redirectPath}.locale`, `Redirect locale '${locale}' is not declared.`);
|
|
534
|
+
if (target && locale !== undefined && (!Array.isArray(target.variants) || !target.variants.some((variant) => variant?.locale === locale))) {
|
|
535
|
+
context.add("redirect.missing_locale", `${redirectPath}.locale`, `Redirect target '${targetKey}' has no '${locale}' variant.`);
|
|
536
|
+
}
|
|
537
|
+
integerValue(context, redirect.status, `${redirectPath}.status`, { min: 301, max: 308 });
|
|
538
|
+
if (redirect.status !== 301 && redirect.status !== 308) context.add("redirect.status", `${redirectPath}.status`, "Redirect status must be 301 or 308.");
|
|
539
|
+
}
|
|
540
|
+
if (redirectOrder.some((item, index) => index > 0 && compareCanonicalStrings(redirectOrder[index - 1], item) >= 0)) {
|
|
541
|
+
context.add("array.not_sorted", "$.redirects", "Redirects must have unique source routes sorted lexicographically.");
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
return context.finish(value);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function invalidSupportedCapabilityResult() {
|
|
549
|
+
return {
|
|
550
|
+
ok: false,
|
|
551
|
+
errors: [{
|
|
552
|
+
code: "capability.invalid_value",
|
|
553
|
+
path: "$options.supportedCapabilities",
|
|
554
|
+
message: `Supported capabilities must be canonical strings of at most ${LIMITS.resourceKey} characters.`,
|
|
555
|
+
}],
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function invalidSupportedCapabilitiesIterableResult() {
|
|
560
|
+
return {
|
|
561
|
+
ok: false,
|
|
562
|
+
errors: [{
|
|
563
|
+
code: "capability.invalid_iterable",
|
|
564
|
+
path: "$options.supportedCapabilities",
|
|
565
|
+
message: "Could not enumerate supported capabilities safely.",
|
|
566
|
+
}],
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
export function snapshotSupportedCapabilities(options) {
|
|
571
|
+
let iterable;
|
|
572
|
+
try {
|
|
573
|
+
iterable = options?.supportedCapabilities;
|
|
574
|
+
} catch {
|
|
575
|
+
return invalidSupportedCapabilitiesIterableResult();
|
|
576
|
+
}
|
|
577
|
+
if (iterable === undefined) return { ok: true, value: Object.freeze([]) };
|
|
578
|
+
if (typeof iterable === "string") return invalidSupportedCapabilitiesIterableResult();
|
|
579
|
+
const supported = [];
|
|
580
|
+
try {
|
|
581
|
+
for (const capability of iterable) {
|
|
582
|
+
if (supported.length >= LIMITS.supportedCapabilities) {
|
|
583
|
+
return {
|
|
584
|
+
ok: false,
|
|
585
|
+
errors: [{
|
|
586
|
+
code: "limit.supported_capabilities",
|
|
587
|
+
path: "$options.supportedCapabilities",
|
|
588
|
+
message: `Supported capabilities may not contain more than ${LIMITS.supportedCapabilities} entries.`,
|
|
589
|
+
}],
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
if (typeof capability !== "string") {
|
|
593
|
+
return invalidSupportedCapabilityResult();
|
|
594
|
+
}
|
|
595
|
+
if (capability.length > LIMITS.resourceKey * 2) {
|
|
596
|
+
return invalidSupportedCapabilityResult();
|
|
597
|
+
}
|
|
598
|
+
const validation = new ValidationContext();
|
|
599
|
+
stringValue(validation, capability, "$capability", { max: LIMITS.resourceKey, pattern: RESOURCE_KEY });
|
|
600
|
+
if (validation.errors.length > 0) {
|
|
601
|
+
return invalidSupportedCapabilityResult();
|
|
602
|
+
}
|
|
603
|
+
supported.push(capability);
|
|
604
|
+
}
|
|
605
|
+
} catch {
|
|
606
|
+
return invalidSupportedCapabilitiesIterableResult();
|
|
607
|
+
}
|
|
608
|
+
return { ok: true, value: Object.freeze(supported) };
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
export function validateManifest(input, options = {}) {
|
|
612
|
+
let manifest = input;
|
|
613
|
+
let objectPreflight;
|
|
614
|
+
const classification = classifyManifestInput(input);
|
|
615
|
+
if (["string", "uint8array", "arraybuffer", "unsupported_binary", "invalid"].includes(classification.kind)) {
|
|
616
|
+
const parsed = parseManifestJson(input, classification);
|
|
617
|
+
if (!parsed.ok) return parsed;
|
|
618
|
+
manifest = parsed.value;
|
|
619
|
+
} else if (classification.kind === "object") {
|
|
620
|
+
objectPreflight = preflightManifestObject(input);
|
|
621
|
+
if (!objectPreflight.ok) return objectPreflight;
|
|
622
|
+
manifest = objectPreflight.value;
|
|
623
|
+
}
|
|
624
|
+
const supportedResult = snapshotSupportedCapabilities(options);
|
|
625
|
+
if (!supportedResult.ok) return supportedResult;
|
|
626
|
+
const supported = new Set([...SUPPORTED_CAPABILITIES, ...supportedResult.value]);
|
|
627
|
+
const result = validateRoot(manifest, supported);
|
|
628
|
+
if (!result.ok) return result;
|
|
629
|
+
if (objectPreflight?.exceedsByteLimit || canonicalJsonByteLength(result.value, LIMITS.manifestBytes) > LIMITS.manifestBytes) {
|
|
630
|
+
return {
|
|
631
|
+
ok: false,
|
|
632
|
+
errors: [{
|
|
633
|
+
code: "manifest.too_large",
|
|
634
|
+
path: "$",
|
|
635
|
+
message: `Canonical manifest bytes may not exceed ${LIMITS.manifestBytes}.`,
|
|
636
|
+
}],
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
return result;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
export function assertValidManifest(input, options = {}) {
|
|
643
|
+
const result = validateManifest(input, options);
|
|
644
|
+
if (!result.ok) throw new DocsArtifactValidationError(result.errors);
|
|
645
|
+
return result.value;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
export function serializeManifest(input, options = {}) {
|
|
649
|
+
return canonicalJson(assertValidManifest(input, options));
|
|
650
|
+
}
|