@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
package/src/markup.js ADDED
@@ -0,0 +1,578 @@
1
+ import { LIMITS } from "./constants.js";
2
+ import { classifySupportedLocale, hasDisallowedStringCharacters } from "./text.js";
3
+
4
+ const ALLOWED_TAGS = new Set([
5
+ "a",
6
+ "abbr",
7
+ "b",
8
+ "blockquote",
9
+ "br",
10
+ "code",
11
+ "dd",
12
+ "del",
13
+ "details",
14
+ "div",
15
+ "dl",
16
+ "dt",
17
+ "em",
18
+ "figcaption",
19
+ "figure",
20
+ "h2",
21
+ "h3",
22
+ "h4",
23
+ "h5",
24
+ "h6",
25
+ "hr",
26
+ "img",
27
+ "kbd",
28
+ "li",
29
+ "mark",
30
+ "ol",
31
+ "p",
32
+ "pre",
33
+ "q",
34
+ "s",
35
+ "samp",
36
+ "small",
37
+ "span",
38
+ "strong",
39
+ "sub",
40
+ "summary",
41
+ "sup",
42
+ "table",
43
+ "tbody",
44
+ "td",
45
+ "th",
46
+ "thead",
47
+ "time",
48
+ "tr",
49
+ "ul",
50
+ "var",
51
+ ]);
52
+
53
+ const VOID_TAGS = new Set(["br", "hr", "img"]);
54
+ const PHRASING_TAGS = new Set([
55
+ "a",
56
+ "abbr",
57
+ "b",
58
+ "br",
59
+ "code",
60
+ "del",
61
+ "em",
62
+ "img",
63
+ "kbd",
64
+ "mark",
65
+ "q",
66
+ "s",
67
+ "samp",
68
+ "small",
69
+ "span",
70
+ "strong",
71
+ "sub",
72
+ "sup",
73
+ "time",
74
+ "var",
75
+ ]);
76
+ const PHRASING_CONTAINERS = new Set([
77
+ ...[...PHRASING_TAGS].filter((tag) => tag !== "a" && tag !== "del"),
78
+ "h2",
79
+ "h3",
80
+ "h4",
81
+ "h5",
82
+ "h6",
83
+ "p",
84
+ "pre",
85
+ "summary",
86
+ ]);
87
+ const TRANSPARENT_CONTAINERS = new Set(["a", "del"]);
88
+ const ELEMENT_ONLY_CONTAINERS = new Set(["dl", "ol", "table", "tbody", "thead", "tr", "ul"]);
89
+ const REQUIRED_PARENTS = new Map([
90
+ ["dd", new Set(["dl"])],
91
+ ["dt", new Set(["dl"])],
92
+ ["figcaption", new Set(["figure"])],
93
+ ["li", new Set(["ol", "ul"])],
94
+ ["summary", new Set(["details"])],
95
+ ["tbody", new Set(["table"])],
96
+ ["td", new Set(["tr"])],
97
+ ["th", new Set(["tr"])],
98
+ ["thead", new Set(["table"])],
99
+ ["tr", new Set(["tbody", "thead"])],
100
+ ]);
101
+ const ONLY_CHILDREN = new Map([
102
+ ["dl", new Set(["dd", "dt"])],
103
+ ["ol", new Set(["li"])],
104
+ ["table", new Set(["tbody", "thead"])],
105
+ ["tbody", new Set(["tr"])],
106
+ ["thead", new Set(["tr"])],
107
+ ["tr", new Set(["td", "th"])],
108
+ ["ul", new Set(["li"])],
109
+ ]);
110
+ const GLOBAL_ATTRIBUTES = new Set([
111
+ "aria-describedby",
112
+ "aria-hidden",
113
+ "aria-label",
114
+ "aria-labelledby",
115
+ "dir",
116
+ "lang",
117
+ "title",
118
+ ]);
119
+ const TAG_ATTRIBUTES = new Map([
120
+ ["a", new Set(["data-heading-id", "data-resource-key", "href"])],
121
+ ["code", new Set(["data-language"])],
122
+ ["details", new Set(["open"])],
123
+ ["h2", new Set(["id"])],
124
+ ["h3", new Set(["id"])],
125
+ ["h4", new Set(["id"])],
126
+ ["h5", new Set(["id"])],
127
+ ["h6", new Set(["id"])],
128
+ ["img", new Set(["alt", "data-asset-key", "height", "width"])],
129
+ ["li", new Set(["value"])],
130
+ ["ol", new Set(["reversed", "start"])],
131
+ ["td", new Set(["colspan", "rowspan"])],
132
+ ["th", new Set(["colspan", "rowspan", "scope"])],
133
+ ["time", new Set(["datetime"])],
134
+ ]);
135
+ const BOOLEAN_ATTRIBUTES = new Set(["open", "reversed"]);
136
+ const RESOURCE_KEY = /^[a-z0-9]+(?:[._:/-][a-z0-9]+)*$/;
137
+ const HEADING_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
138
+ const TOKEN = /^[a-z0-9]+(?:[._+-][a-z0-9]+)*$/;
139
+ const HTML_WHITESPACE_RUN = /[\t\n\f\r ]+/gu;
140
+
141
+ function isHtmlWhitespace(character) {
142
+ return character === "\t" || character === "\n" || character === "\f" || character === "\r" || character === " ";
143
+ }
144
+
145
+ function isOnlyHtmlWhitespace(value) {
146
+ for (const character of value) {
147
+ if (!isHtmlWhitespace(character)) return false;
148
+ }
149
+ return true;
150
+ }
151
+
152
+ function findTagEnd(markup, start) {
153
+ let quote;
154
+ for (let index = start; index < markup.length; index += 1) {
155
+ const char = markup[index];
156
+ if (quote) {
157
+ if (char === quote) quote = undefined;
158
+ } else if (char === '"' || char === "'") {
159
+ quote = char;
160
+ } else if (char === ">") {
161
+ return index;
162
+ }
163
+ }
164
+ return -1;
165
+ }
166
+
167
+ function decodeEntities(value, context, path) {
168
+ const named = new Map([
169
+ ["amp", "&"],
170
+ ["apos", "'"],
171
+ ["colon", ":"],
172
+ ["gt", ">"],
173
+ ["lt", "<"],
174
+ ["quot", '"'],
175
+ ]);
176
+ const decoded = [];
177
+ let offset = 0;
178
+ while (offset < value.length) {
179
+ const ampersand = value.indexOf("&", offset);
180
+ if (ampersand === -1) {
181
+ decoded.push(value.slice(offset));
182
+ break;
183
+ }
184
+ decoded.push(value.slice(offset, ampersand));
185
+ const match = /^&([^;\t\n\f\r <&]{1,32});/u.exec(value.slice(ampersand));
186
+ if (!match) {
187
+ const preview = /^&[^\t\n\f\r <&]{0,31}/u.exec(value.slice(ampersand))?.[0] ?? "&";
188
+ context.add("markup.unsupported_entity", path, `Raw or semicolonless ampersand sequence '${preview}' is not supported.`);
189
+ decoded.push("&");
190
+ offset = ampersand + 1;
191
+ continue;
192
+ }
193
+ const [entity, body] = match;
194
+ let codePoint;
195
+ if (body.startsWith("#x") || body.startsWith("#X")) {
196
+ if (/^[0-9a-fA-F]+$/u.test(body.slice(2))) codePoint = Number.parseInt(body.slice(2), 16);
197
+ } else if (body.startsWith("#")) {
198
+ if (/^[0-9]+$/u.test(body.slice(1))) codePoint = Number.parseInt(body.slice(1), 10);
199
+ } else if (named.has(body)) {
200
+ decoded.push(named.get(body));
201
+ offset = ampersand + entity.length;
202
+ continue;
203
+ }
204
+ const hasReplacementOrControlSemantics = !Number.isSafeInteger(codePoint)
205
+ || codePoint === 0
206
+ || codePoint > 0x10ffff
207
+ || (codePoint >= 0xd800 && codePoint <= 0xdfff)
208
+ || codePoint <= 0x1f
209
+ || (codePoint >= 0x7f && codePoint <= 0x9f);
210
+ if (codePoint !== undefined && !hasReplacementOrControlSemantics) {
211
+ decoded.push(String.fromCodePoint(codePoint));
212
+ offset = ampersand + entity.length;
213
+ continue;
214
+ }
215
+ context.add("markup.unsupported_entity", path, `Unsupported or invalid HTML entity '${entity}'.`);
216
+ decoded.push(entity);
217
+ offset = ampersand + entity.length;
218
+ }
219
+ return decoded.join("");
220
+ }
221
+
222
+ function parseAttributes(source, context, path) {
223
+ const attributes = new Map();
224
+ let offset = 0;
225
+ let selfClosing = false;
226
+ while (offset < source.length) {
227
+ while (isHtmlWhitespace(source[offset])) offset += 1;
228
+ if (offset >= source.length) break;
229
+ if (source[offset] === "/" && isOnlyHtmlWhitespace(source.slice(offset + 1))) {
230
+ selfClosing = true;
231
+ break;
232
+ }
233
+
234
+ const nameMatch = /^[A-Za-z][A-Za-z0-9:-]*/u.exec(source.slice(offset));
235
+ if (!nameMatch) {
236
+ context.add("markup.invalid_attribute", path, "Attribute names must use the supported HTML identifier form.");
237
+ return { attributes, selfClosing, invalid: true };
238
+ }
239
+ const name = nameMatch[0];
240
+ offset += name.length;
241
+ if (name !== name.toLowerCase()) context.add("markup.not_canonical", path, `Attribute '${name}' must be lowercase.`);
242
+ const canonicalName = name.toLowerCase();
243
+ if (attributes.has(canonicalName)) context.add("markup.duplicate_attribute", path, `Attribute '${canonicalName}' is duplicated.`);
244
+ while (isHtmlWhitespace(source[offset])) offset += 1;
245
+
246
+ let value = null;
247
+ if (source[offset] === "=") {
248
+ offset += 1;
249
+ while (isHtmlWhitespace(source[offset])) offset += 1;
250
+ const quote = source[offset];
251
+ if (quote !== '"' && quote !== "'") {
252
+ context.add("markup.unquoted_attribute", path, `Attribute '${canonicalName}' must have a quoted value.`);
253
+ return { attributes, selfClosing, invalid: true };
254
+ }
255
+ offset += 1;
256
+ const end = source.indexOf(quote, offset);
257
+ if (end === -1) {
258
+ context.add("markup.invalid_attribute", path, `Attribute '${canonicalName}' has an unterminated value.`);
259
+ return { attributes, selfClosing, invalid: true };
260
+ }
261
+ value = decodeEntities(source.slice(offset, end), context, path);
262
+ offset = end + 1;
263
+ } else if (!BOOLEAN_ATTRIBUTES.has(canonicalName)) {
264
+ context.add("markup.boolean_attribute", path, `Attribute '${canonicalName}' requires a value.`);
265
+ }
266
+ attributes.set(canonicalName, value);
267
+ }
268
+ return { attributes, selfClosing, invalid: false };
269
+ }
270
+
271
+ function validateHttpsUrl(value, context, path) {
272
+ try {
273
+ const url = new URL(value);
274
+ if (!value.startsWith("https://") || url.protocol !== "https:" || url.username !== "" || url.password !== "") {
275
+ context.add("markup.unsafe_url", path, "External fragment links must start with canonical 'https://' and omit credentials.");
276
+ }
277
+ } catch {
278
+ context.add("markup.invalid_url", path, "Expected an absolute HTTPS URL or a local heading reference.");
279
+ }
280
+ }
281
+
282
+ function validateCommonAttributes(tag, attributes, context, path) {
283
+ const tagAttributes = TAG_ATTRIBUTES.get(tag) ?? new Set();
284
+ for (const [name, value] of attributes) {
285
+ if (!GLOBAL_ATTRIBUTES.has(name) && !tagAttributes.has(name)) {
286
+ context.add("markup.unsupported_attribute", path, `Attribute '${name}' is not supported on <${tag}>.`);
287
+ continue;
288
+ }
289
+ if (value !== null && (value.length > 2_000 || hasDisallowedStringCharacters(value))) {
290
+ context.add("markup.invalid_attribute", path, `Attribute '${name}' contains unsupported data.`);
291
+ }
292
+ }
293
+
294
+ const direction = attributes.get("dir");
295
+ if (direction !== undefined && direction !== "ltr" && direction !== "rtl" && direction !== "auto") {
296
+ context.add("markup.attribute_value", path, "dir must be 'ltr', 'rtl', or 'auto'.");
297
+ }
298
+ const ariaHidden = attributes.get("aria-hidden");
299
+ if (ariaHidden !== undefined && ariaHidden !== "true" && ariaHidden !== "false") {
300
+ context.add("markup.attribute_value", path, "aria-hidden must be 'true' or 'false'.");
301
+ }
302
+ const language = attributes.get("lang");
303
+ if (language !== undefined && (language === null || classifySupportedLocale(language) !== "supported")) {
304
+ context.add("markup.attribute_value", path, "lang must use the canonical supported Docs v1 BCP 47 subset.");
305
+ }
306
+ }
307
+
308
+ function validateAnchor(attributes, context, path, model) {
309
+ const href = attributes.get("href");
310
+ const resourceKey = attributes.get("data-resource-key");
311
+ const targetHeading = attributes.get("data-heading-id");
312
+ if ((href === undefined) === (resourceKey === undefined)) {
313
+ context.add("markup.link_destination", path, "Links must have exactly one of href or data-resource-key.");
314
+ return;
315
+ }
316
+ if (href !== undefined) {
317
+ if (href === null || href.length === 0) {
318
+ context.add("markup.link_destination", path, "href may not be empty.");
319
+ } else if (hasDisallowedStringCharacters(href)) {
320
+ // validateCommonAttributes owns the single stable control diagnostic.
321
+ } else if (href.startsWith("#")) {
322
+ const headingId = href.slice(1);
323
+ if (!HEADING_ID.test(headingId) || !model.localHeadingIds.has(headingId)) {
324
+ context.add("markup.unknown_heading", path, `Local link targets unknown heading '${headingId}'.`);
325
+ }
326
+ } else {
327
+ validateHttpsUrl(href, context, path);
328
+ }
329
+ if (targetHeading !== undefined) context.add("markup.link_destination", path, "data-heading-id requires data-resource-key.");
330
+ return;
331
+ }
332
+
333
+ if (resourceKey === null || !RESOURCE_KEY.test(resourceKey)) {
334
+ context.add("markup.resource_key", path, "data-resource-key must be a canonical resource key.");
335
+ return;
336
+ }
337
+ const resource = model.resources.get(resourceKey);
338
+ const variant = resource?.variants.find((candidate) => candidate.locale === model.locale);
339
+ if (!variant) {
340
+ context.add("markup.unknown_resource", path, `Link target '${resourceKey}' has no '${model.locale}' variant.`);
341
+ return;
342
+ }
343
+ if (targetHeading !== undefined) {
344
+ if (targetHeading === null || !variant.headings.some((heading) => heading.id === targetHeading)) {
345
+ context.add("markup.unknown_heading", path, `Link targets unknown heading '${targetHeading}' on resource '${resourceKey}'.`);
346
+ }
347
+ }
348
+ }
349
+
350
+ function validateImage(attributes, context, path, model) {
351
+ const assetKey = attributes.get("data-asset-key");
352
+ if (assetKey === undefined || assetKey === null || !RESOURCE_KEY.test(assetKey)) {
353
+ context.add("markup.asset_key", path, "Images must reference one canonical data-asset-key.");
354
+ } else if (!model.assets.has(assetKey)) {
355
+ context.add("markup.unknown_asset", path, `Image references unknown asset '${assetKey}'.`);
356
+ }
357
+ if (!attributes.has("alt") || attributes.get("alt") === null) context.add("markup.image_alt", path, "Images must declare alt text; use an empty value for decorative images.");
358
+
359
+ for (const dimension of ["width", "height"]) {
360
+ const value = attributes.get(dimension);
361
+ if (value !== undefined && (value === null || !/^[1-9]\d{0,4}$/u.test(value))) {
362
+ context.add("markup.attribute_value", path, `${dimension} must be a positive decimal integer.`);
363
+ }
364
+ }
365
+ const asset = assetKey === null ? undefined : model.assets.get(assetKey);
366
+ if (asset) {
367
+ if (attributes.has("width") && Number(attributes.get("width")) !== asset.width) context.add("markup.asset_dimensions", path, `Image width must match asset '${assetKey}'.`);
368
+ if (attributes.has("height") && Number(attributes.get("height")) !== asset.height) context.add("markup.asset_dimensions", path, `Image height must match asset '${assetKey}'.`);
369
+ }
370
+ }
371
+
372
+ function validateSpecialAttributes(tag, attributes, context, path, model) {
373
+ if (tag === "a") validateAnchor(attributes, context, path, model);
374
+ if (tag === "img") validateImage(attributes, context, path, model);
375
+ if (/^h[2-6]$/u.test(tag)) {
376
+ const id = attributes.get("id");
377
+ if (id === undefined || id === null || !HEADING_ID.test(id)) context.add("markup.heading_id", path, "Headings must declare a canonical id.");
378
+ }
379
+ const language = attributes.get("data-language");
380
+ if (language !== undefined && (language === null || !TOKEN.test(language))) context.add("markup.attribute_value", path, "data-language must be a canonical token.");
381
+ for (const name of ["colspan", "rowspan", "start", "value"]) {
382
+ const value = attributes.get(name);
383
+ if (value !== undefined && (value === null || !/^-?(?:0|[1-9]\d{0,5})$/u.test(value))) context.add("markup.attribute_value", path, `${name} must be a decimal integer.`);
384
+ }
385
+ const scope = attributes.get("scope");
386
+ if (scope !== undefined && !["col", "colgroup", "row", "rowgroup"].includes(scope)) context.add("markup.attribute_value", path, "scope has an unsupported value.");
387
+ }
388
+
389
+ function normalizeHeadingText(value) {
390
+ return value.replace(HTML_WHITESPACE_RUN, " ").replace(/^ +| +$/gu, "");
391
+ }
392
+
393
+ function validateTextContent(text, parent, context, path) {
394
+ if (text.includes("\u0000")) {
395
+ context.add("markup.invalid_null", path, "HTML fragment text may not contain a literal U+0000 null character.");
396
+ }
397
+ if (parent && ELEMENT_ONLY_CONTAINERS.has(parent.tag) && !isOnlyHtmlWhitespace(text)) {
398
+ context.add("markup.content_model", path, `<${parent.tag}> may contain only its supported child elements, not text.`);
399
+ }
400
+ }
401
+
402
+ function validateElementContent(tag, stack, context, path) {
403
+ const parent = stack.at(-1);
404
+ const parentTag = parent?.tag;
405
+ const requiredParents = REQUIRED_PARENTS.get(tag);
406
+ if (requiredParents && !requiredParents.has(parentTag)) {
407
+ context.add("markup.content_model", path, `<${tag}> is not allowed below <${parentTag ?? "root"}>.`);
408
+ }
409
+ const onlyChildren = ONLY_CHILDREN.get(parentTag);
410
+ if (onlyChildren && !onlyChildren.has(tag)) {
411
+ context.add("markup.content_model", path, `<${parentTag}> may not contain <${tag}>.`);
412
+ }
413
+ const contentParentTag = parent?.contentParentTag;
414
+ if (contentParentTag && PHRASING_CONTAINERS.has(contentParentTag) && !PHRASING_TAGS.has(tag)) {
415
+ context.add("markup.content_model", path, `<${contentParentTag}> may contain only phrasing content, not <${tag}>.`);
416
+ }
417
+ if (tag === "a" && stack.some((entry) => entry.tag === "a")) {
418
+ context.add("markup.content_model", path, "Links may not be nested.");
419
+ }
420
+ if (tag === "summary" && parent && (parent.childElementCount !== 0 || parent.seenSummary)) {
421
+ context.add("markup.content_model", path, "<summary> must be the first child of <details> and may appear only once.");
422
+ }
423
+ if (tag === "figcaption" && parent) {
424
+ if (parent.seenFigcaption) {
425
+ context.add("markup.content_model", path, "<figure> may contain only one <figcaption>.");
426
+ } else if (parent.childElementCount !== 0) {
427
+ parent.figcaptionMustBeLast = true;
428
+ }
429
+ } else if (parent?.figcaptionMustBeLast) {
430
+ context.add("markup.content_model", path, "A non-leading <figcaption> must be the last child of <figure>.");
431
+ }
432
+ if (parent) {
433
+ parent.childElementCount += 1;
434
+ if (tag === "summary") parent.seenSummary = true;
435
+ if (tag === "figcaption") parent.seenFigcaption = true;
436
+ }
437
+ }
438
+
439
+ export function validateHtmlFragment(markup, context, path, model, maximumHeadings = LIMITS.headingsPerVariant + 1) {
440
+ if (typeof markup !== "string") {
441
+ context.add("markup.invalid", path, "HTML fragment must be text.");
442
+ return { headings: [], headingCount: 0 };
443
+ }
444
+ const stack = [];
445
+ const headings = [];
446
+ let headingCount = 0;
447
+ let elementCount = 0;
448
+ let currentHeading;
449
+ let offset = 0;
450
+ const result = () => ({
451
+ headings: headings.map(({ id, level, text }) => ({ id, level, text })),
452
+ headingCount,
453
+ });
454
+
455
+ while (offset < markup.length) {
456
+ const tagStart = markup.indexOf("<", offset);
457
+ const textEnd = tagStart === -1 ? markup.length : tagStart;
458
+ if (textEnd > offset) {
459
+ const text = markup.slice(offset, textEnd);
460
+ validateTextContent(text, stack.at(-1), context, path);
461
+ const decodedText = decodeEntities(text, context, path);
462
+ if (currentHeading) currentHeading.text += decodedText;
463
+ }
464
+ if (tagStart === -1) break;
465
+ if (markup.startsWith("<!--", tagStart) || markup.startsWith("<!", tagStart) || markup.startsWith("<?", tagStart)) {
466
+ context.add("markup.declaration", path, "Comments, declarations, and processing instructions are not supported in semantic fragments.");
467
+ return result();
468
+ }
469
+ const tagEnd = findTagEnd(markup, tagStart + 1);
470
+ if (tagEnd === -1) {
471
+ context.add("markup.unterminated_tag", path, "HTML fragment contains an unterminated tag.");
472
+ return result();
473
+ }
474
+ const token = markup.slice(tagStart + 1, tagEnd);
475
+ offset = tagEnd + 1;
476
+ if (token.length === 0 || isHtmlWhitespace(token[0])) {
477
+ context.add("markup.invalid_tag", path, "Whitespace is not allowed between '<' and a tag name.");
478
+ continue;
479
+ }
480
+
481
+ if (token.startsWith("/")) {
482
+ const closeMatch = /^\/([A-Za-z][A-Za-z0-9]*)$/u.exec(token);
483
+ if (!closeMatch) {
484
+ context.add("markup.invalid_close", path, "Closing tags may not contain attributes or unsupported syntax.");
485
+ continue;
486
+ }
487
+ const tag = closeMatch[1];
488
+ if (tag !== tag.toLowerCase()) context.add("markup.not_canonical", path, `Tag <${tag}> must be lowercase.`);
489
+ const canonicalTag = tag.toLowerCase();
490
+ if (VOID_TAGS.has(canonicalTag)) {
491
+ context.add("markup.void_close", path, `Void element <${canonicalTag}> may not have a closing tag.`);
492
+ continue;
493
+ }
494
+ const opened = stack.pop();
495
+ if (opened?.tag !== canonicalTag) {
496
+ context.add("markup.nesting", path, `Closing </${canonicalTag}> does not match open <${opened?.tag ?? "none"}>.`);
497
+ return result();
498
+ }
499
+ if (currentHeading?.tag === canonicalTag) {
500
+ currentHeading.text = normalizeHeadingText(currentHeading.text);
501
+ headingCount += 1;
502
+ if (headings.length < maximumHeadings) headings.push(currentHeading);
503
+ currentHeading = undefined;
504
+ }
505
+ continue;
506
+ }
507
+
508
+ const openMatch = /^([A-Za-z][A-Za-z0-9]*)([^]*)$/u.exec(token);
509
+ if (!openMatch) {
510
+ context.add("markup.invalid_tag", path, "HTML fragment contains invalid tag syntax.");
511
+ continue;
512
+ }
513
+ const tag = openMatch[1];
514
+ if (tag !== tag.toLowerCase()) context.add("markup.not_canonical", path, `Tag <${tag}> must be lowercase.`);
515
+ const canonicalTag = tag.toLowerCase();
516
+ if (!ALLOWED_TAGS.has(canonicalTag)) {
517
+ context.add("markup.unsupported_tag", path, `Element <${canonicalTag}> is not supported in semantic fragments.`);
518
+ return result();
519
+ }
520
+ if (elementCount >= LIMITS.markupElements) {
521
+ context.add("limit.markup_elements", path, `HTML fragments may not contain more than ${LIMITS.markupElements} elements.`);
522
+ return result();
523
+ }
524
+ elementCount += 1;
525
+ if (!VOID_TAGS.has(canonicalTag) && stack.length >= LIMITS.markupDepth) {
526
+ context.add("limit.markup_depth", path, `HTML fragment nesting may not exceed ${LIMITS.markupDepth} elements.`);
527
+ return result();
528
+ }
529
+ validateElementContent(canonicalTag, stack, context, path);
530
+ const parsed = parseAttributes(openMatch[2], context, path);
531
+ validateCommonAttributes(canonicalTag, parsed.attributes, context, path);
532
+ validateSpecialAttributes(canonicalTag, parsed.attributes, context, path, model);
533
+ if (parsed.selfClosing && !VOID_TAGS.has(canonicalTag)) {
534
+ context.add("markup.self_closing", path, `Non-void element <${canonicalTag}> may not use self-closing syntax.`);
535
+ }
536
+ if (!VOID_TAGS.has(canonicalTag)) {
537
+ const parent = stack.at(-1);
538
+ stack.push({
539
+ tag: canonicalTag,
540
+ contentParentTag: TRANSPARENT_CONTAINERS.has(canonicalTag)
541
+ ? parent?.contentParentTag
542
+ : canonicalTag,
543
+ childElementCount: 0,
544
+ seenFigcaption: false,
545
+ seenSummary: false,
546
+ figcaptionMustBeLast: false,
547
+ });
548
+ }
549
+ if (/^h[2-6]$/u.test(canonicalTag)) {
550
+ if (currentHeading) context.add("markup.heading_nesting", path, "Headings may not be nested.");
551
+ currentHeading = {
552
+ id: parsed.attributes.get("id") ?? "",
553
+ level: Number(canonicalTag.slice(1)),
554
+ text: "",
555
+ };
556
+ currentHeading.tag = canonicalTag;
557
+ } else if (currentHeading && canonicalTag === "img") {
558
+ currentHeading.text += parsed.attributes.get("alt") ?? "";
559
+ }
560
+ }
561
+
562
+ if (stack.length > 0) context.add("markup.unclosed_tag", path, `HTML fragment has unclosed <${stack.at(-1).tag}> markup.`);
563
+ return result();
564
+ }
565
+
566
+ export function compareFragmentHeadings(actual, expected, context, path, actualCount = actual.length) {
567
+ if (actualCount !== expected.length) {
568
+ context.add("markup.heading_drift", path, `Fragment contains ${actualCount} headings but the manifest declares ${expected.length}.`);
569
+ return;
570
+ }
571
+ for (let index = 0; index < actual.length; index += 1) {
572
+ const actualHeading = actual[index];
573
+ const expectedHeading = expected[index];
574
+ if (actualHeading.id !== expectedHeading.id || actualHeading.level !== expectedHeading.level || actualHeading.text !== normalizeHeadingText(expectedHeading.text)) {
575
+ context.add("markup.heading_drift", path, `Heading ${index + 1} does not match its manifest id, level, and text.`);
576
+ }
577
+ }
578
+ }
@@ -0,0 +1,4 @@
1
+ // Package exports intentionally do not expose this test-only override.
2
+ export const DIRECTORY_LIMITS_OVERRIDE = Symbol("taproot.docs.directoryLimitsOverride");
3
+ export const FILE_OPEN_RACE_HOOK = Symbol("taproot.docs.fileOpenRaceHook");
4
+ export const FILE_READ_RACE_HOOK = Symbol("taproot.docs.fileReadRaceHook");