@mui/internal-code-infra 0.0.4-canary.4 → 0.0.4-canary.40

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 (101) hide show
  1. package/README.md +19 -8
  2. package/build/babel-config.d.mts +11 -3
  3. package/build/brokenLinksChecker/crawlWorker.d.mts +1 -0
  4. package/build/brokenLinksChecker/index.d.mts +44 -2
  5. package/build/changelog/types.d.ts +1 -1
  6. package/build/cli/cmdArgosPush.d.mts +2 -2
  7. package/build/cli/cmdBuild.d.mts +2 -2
  8. package/build/cli/cmdCopyFiles.d.mts +2 -2
  9. package/build/cli/cmdExtractErrorCodes.d.mts +2 -2
  10. package/build/cli/cmdGenerateChangelog.d.mts +2 -2
  11. package/build/cli/cmdGithubAuth.d.mts +2 -2
  12. package/build/cli/cmdListWorkspaces.d.mts +4 -2
  13. package/build/cli/cmdNetlifyIgnore.d.mts +2 -2
  14. package/build/cli/cmdPublish.d.mts +4 -2
  15. package/build/cli/cmdPublishCanary.d.mts +3 -2
  16. package/build/cli/cmdPublishNewPackage.d.mts +4 -2
  17. package/build/cli/cmdSetVersionOverrides.d.mts +2 -2
  18. package/build/cli/cmdVale.d.mts +46 -0
  19. package/build/cli/cmdValidateBuiltTypes.d.mts +2 -2
  20. package/build/eslint/baseConfig.d.mts +3 -1
  21. package/build/eslint/mui/rules/disallow-react-api-in-server-components.d.mts +2 -2
  22. package/build/eslint/mui/rules/docgen-ignore-before-comment.d.mts +2 -2
  23. package/build/eslint/mui/rules/no-guarded-throw.d.mts +31 -0
  24. package/build/eslint/mui/rules/no-restricted-resolved-imports.d.mts +2 -2
  25. package/build/eslint/mui/rules/nodeEnvUtils.d.mts +18 -0
  26. package/build/markdownlint/duplicate-h1.d.mts +1 -1
  27. package/build/markdownlint/git-diff.d.mts +1 -1
  28. package/build/markdownlint/index.d.mts +1 -1
  29. package/build/markdownlint/straight-quotes.d.mts +1 -1
  30. package/build/markdownlint/table-alignment.d.mts +1 -1
  31. package/build/markdownlint/terminal-language.d.mts +1 -1
  32. package/build/remark/config.d.mts +43 -0
  33. package/build/remark/createLintTester.d.mts +10 -0
  34. package/build/remark/firstBlockHeading.d.mts +4 -0
  35. package/build/remark/gitDiff.d.mts +2 -0
  36. package/build/remark/noSpaceInLinks.d.mts +2 -0
  37. package/build/remark/straightQuotes.d.mts +2 -0
  38. package/build/remark/tableAlignment.d.mts +2 -0
  39. package/build/remark/terminalLanguage.d.mts +2 -0
  40. package/build/utils/build.d.mts +3 -3
  41. package/build/utils/github.d.mts +1 -1
  42. package/build/utils/pnpm.d.mts +68 -2
  43. package/build/utils/testUtils.d.mts +7 -0
  44. package/package.json +59 -32
  45. package/src/babel-config.mjs +9 -3
  46. package/src/brokenLinksChecker/__fixtures__/static-site/index.html +1 -0
  47. package/src/brokenLinksChecker/__fixtures__/static-site/invalid-html.html +15 -0
  48. package/src/brokenLinksChecker/crawlWorker.mjs +200 -0
  49. package/src/brokenLinksChecker/index.mjs +213 -164
  50. package/src/brokenLinksChecker/index.test.ts +63 -13
  51. package/src/changelog/categorizeCommits.test.ts +5 -5
  52. package/src/changelog/fetchChangelogs.mjs +6 -2
  53. package/src/changelog/parseCommitLabels.test.ts +5 -5
  54. package/src/changelog/renderChangelog.mjs +1 -1
  55. package/src/changelog/types.ts +1 -1
  56. package/src/cli/cmdListWorkspaces.mjs +9 -2
  57. package/src/cli/cmdNetlifyIgnore.mjs +4 -88
  58. package/src/cli/cmdPublish.mjs +51 -14
  59. package/src/cli/cmdPublishCanary.mjs +139 -107
  60. package/src/cli/cmdPublishNewPackage.mjs +27 -6
  61. package/src/cli/cmdVale.mjs +513 -0
  62. package/src/cli/cmdVale.test.mjs +644 -0
  63. package/src/cli/index.mjs +2 -0
  64. package/src/eslint/baseConfig.mjs +45 -20
  65. package/src/eslint/docsConfig.mjs +2 -1
  66. package/src/eslint/jsonConfig.mjs +2 -1
  67. package/src/eslint/mui/config.mjs +20 -1
  68. package/src/eslint/mui/index.mjs +2 -0
  69. package/src/eslint/mui/rules/no-guarded-throw.mjs +115 -0
  70. package/src/eslint/mui/rules/no-guarded-throw.test.mjs +206 -0
  71. package/src/eslint/mui/rules/nodeEnvUtils.mjs +52 -0
  72. package/src/eslint/mui/rules/require-dev-wrapper.mjs +25 -40
  73. package/src/eslint/testConfig.mjs +2 -1
  74. package/src/estree-typescript.d.ts +1 -1
  75. package/src/remark/config.mjs +157 -0
  76. package/src/remark/createLintTester.mjs +19 -0
  77. package/src/remark/firstBlockHeading.mjs +87 -0
  78. package/src/remark/firstBlockHeading.test.mjs +107 -0
  79. package/src/remark/gitDiff.mjs +43 -0
  80. package/src/remark/gitDiff.test.mjs +45 -0
  81. package/src/remark/noSpaceInLinks.mjs +42 -0
  82. package/src/remark/noSpaceInLinks.test.mjs +22 -0
  83. package/src/remark/straightQuotes.mjs +31 -0
  84. package/src/remark/straightQuotes.test.mjs +25 -0
  85. package/src/remark/tableAlignment.mjs +23 -0
  86. package/src/remark/tableAlignment.test.mjs +28 -0
  87. package/src/remark/terminalLanguage.mjs +19 -0
  88. package/src/remark/terminalLanguage.test.mjs +17 -0
  89. package/src/untyped-plugins.d.ts +11 -11
  90. package/src/utils/build.test.mjs +546 -575
  91. package/src/utils/pnpm.mjs +192 -3
  92. package/src/utils/pnpm.test.mjs +580 -0
  93. package/src/utils/testUtils.mjs +18 -0
  94. package/src/utils/typescript.test.mjs +249 -272
  95. package/vale/.vale.ini +1 -0
  96. package/vale/styles/MUI/CorrectReferenceAllCases.yml +43 -0
  97. package/vale/styles/MUI/CorrectRererenceCased.yml +14 -0
  98. package/vale/styles/MUI/GoogleLatin.yml +11 -0
  99. package/vale/styles/MUI/MuiBrandName.yml +22 -0
  100. package/vale/styles/MUI/NoBritish.yml +112 -0
  101. package/vale/styles/MUI/NoCompanyName.yml +17 -0
@@ -1,21 +1,17 @@
1
1
  /* eslint-disable no-console */
2
2
  import { execaCommand } from 'execa';
3
3
  import timers from 'node:timers/promises';
4
- import { parse } from 'node-html-parser';
5
4
  import * as fs from 'node:fs/promises';
6
5
  import * as path from 'node:path';
6
+ import { pathToFileURL } from 'node:url';
7
7
  import chalk from 'chalk';
8
8
  import { Transform } from 'node:stream';
9
- import contentType from 'content-type';
10
- import { unified } from 'unified';
11
- import remarkParse from 'remark-parse';
12
- import remarkGfm from 'remark-gfm';
13
- import remarkRehype from 'remark-rehype';
14
- import rehypeSlug from 'rehype-slug';
15
- import rehypeStringify from 'rehype-stringify';
9
+ import { Worker } from 'node:worker_threads';
16
10
 
17
11
  const DEFAULT_CONCURRENCY = 4;
18
12
 
13
+ const crawlWorkerUrl = new URL('./crawlWorker.mjs', import.meta.url);
14
+
19
15
  /**
20
16
  * Creates a Transform stream that prefixes each line with a given string.
21
17
  * Useful for distinguishing server logs from other output.
@@ -105,6 +101,30 @@ function deserializeLinkStructure(data) {
105
101
  return linkStructure;
106
102
  }
107
103
 
104
+ /**
105
+ * Input data passed to the crawl worker via workerData.
106
+ * @typedef {Object} CrawlWorkerInput
107
+ * @property {string} pageUrl - The page URL to crawl
108
+ * @property {ResolvedCrawlOptions} options - Fully resolved crawl options
109
+ */
110
+
111
+ /**
112
+ * Serialized page data returned by the crawl worker (uses arrays instead of Sets for structured clone).
113
+ * @typedef {Object} CrawlWorkerPageData
114
+ * @property {string} url - The normalized page URL
115
+ * @property {number} status - HTTP status code
116
+ * @property {string[]} targets - Array of anchor targets (e.g., '#intro')
117
+ * @property {string} contentType - Content-type of the page
118
+ */
119
+
120
+ /**
121
+ * Output message posted by the crawl worker.
122
+ * @typedef {Object} CrawlWorkerOutput
123
+ * @property {CrawlWorkerPageData} pageData - Serialized page data
124
+ * @property {Link[]} links - Links discovered on the page
125
+ * @property {{ pageUrl: string, results: import('html-validate').Result[] } | null} htmlValidateResults - HTML validation results, or null if validation was skipped/passed
126
+ */
127
+
108
128
  /**
109
129
  * Data about a crawled page including its URL, HTTP status, and available link targets.
110
130
  * @typedef {Object} PageData
@@ -131,77 +151,6 @@ async function writePagesToFile(pages, outPath) {
131
151
  await fs.writeFile(outPath, JSON.stringify(fileContent, null, 2), 'utf-8');
132
152
  }
133
153
 
134
- /**
135
- * Computes the accessible name of an element according to ARIA rules.
136
- * Polyfill for `node.computedName` available only in Chrome v112+.
137
- * Checks in order: aria-label, aria-labelledby, label[for], img alt, innerText.
138
- * @param {import('node-html-parser').HTMLElement | null} elm - Element to compute name for
139
- * @param {import('node-html-parser').HTMLElement} ownerDocument - Document containing the element
140
- * @returns {string} The computed accessible name, or empty string if none found
141
- */
142
- function getAccessibleName(elm, ownerDocument) {
143
- if (!elm) {
144
- return '';
145
- }
146
-
147
- // 1. aria-label
148
- const ariaLabel = elm.getAttribute('aria-label')?.trim();
149
- if (ariaLabel) {
150
- return ariaLabel;
151
- }
152
-
153
- // 2. aria-labelledby
154
- const labelledby = elm.getAttribute('aria-labelledby');
155
- if (labelledby) {
156
- const labels = [];
157
- for (const id of labelledby.split(/\s+/)) {
158
- const label = getAccessibleName(ownerDocument.getElementById(id), ownerDocument);
159
- if (label) {
160
- labels.push(label);
161
- }
162
- }
163
- const label = labels.join(' ').trim();
164
- if (label) {
165
- return label;
166
- }
167
- }
168
-
169
- // 3. <label for="id">
170
- if (elm.id) {
171
- const label = ownerDocument.querySelector(`label[for="${elm.id}"]`);
172
- if (label) {
173
- return getAccessibleName(label, ownerDocument);
174
- }
175
- }
176
-
177
- // 4. <img alt="">
178
- if (elm.tagName === 'IMG') {
179
- const alt = elm.getAttribute('alt')?.trim();
180
- if (alt) {
181
- return alt;
182
- }
183
- }
184
-
185
- // 5. Fallback: visible text
186
- return elm.innerText.trim();
187
- }
188
-
189
- /**
190
- * Converts markdown content to HTML using unified pipeline.
191
- * @param {string} markdown - Raw markdown content
192
- * @returns {Promise<string>} Converted HTML string
193
- */
194
- async function markdownToHtml(markdown) {
195
- const result = await unified()
196
- .use(remarkParse)
197
- .use(remarkGfm)
198
- .use(remarkRehype)
199
- .use(rehypeSlug)
200
- .use(rehypeStringify)
201
- .process(markdown);
202
- return String(result);
203
- }
204
-
205
154
  /**
206
155
  * Generic concurrent task queue with configurable concurrency limit.
207
156
  * Processes tasks in FIFO order with a maximum number of concurrent workers.
@@ -402,11 +351,29 @@ function shouldIgnoreLink(link, ignores) {
402
351
  * @property {number} [concurrency] - Number of concurrent page fetches (defaults to 4)
403
352
  * @property {string[]} [seedUrls] - Starting URLs for the crawl (defaults to ['/'])
404
353
  * @property {IgnoreRule[]} [ignores] - Rules to ignore broken links. Each rule can have path, href, contentType, and/or has properties. All specified properties must match (AND logic). Within a property, multiple values use OR logic.
354
+ * @property {HtmlValidateOption} [htmlValidate] - Enable HTML validation on crawled pages. `false` (default): disabled. `true`: validate with recommended rules. Object: use as html-validate config — `extends` defaults to `['mui:recommended']` when omitted, so most callers only need to set `rules`. Array: per-path config overrides — entries are walked in order and the **last** entry whose `path` matches the page URL wins; an entry without `path` matches every page (use as a default and put more specific overrides after it). If no entry matches, the page is not validated.
355
+ */
356
+
357
+ /**
358
+ * Per-page HTML validation override entry.
359
+ * @typedef {Object} HtmlValidateOverride
360
+ * @property {(string | RegExp) | (string | RegExp)[]} [path] - Pattern(s) to match the page URL. Strings use exact match. Omit to match every page.
361
+ * @property {true | import('html-validate').ConfigData} config - html-validate config (or `true` for `mui:recommended`).
362
+ */
363
+
364
+ /**
365
+ * Public shape of the htmlValidate option.
366
+ * @typedef {boolean | import('html-validate').ConfigData | HtmlValidateOverride[]} HtmlValidateOption
367
+ */
368
+
369
+ /**
370
+ * Resolved per-page HTML validation entry. Empty array means validation is disabled.
371
+ * @typedef {{ path: (string | RegExp)[] | undefined, config: import('html-validate').ConfigData }} ResolvedHtmlValidateEntry
405
372
  */
406
373
 
407
374
  /**
408
375
  * Fully resolved configuration with all optional fields filled with defaults.
409
- * @typedef {Omit<Required<CrawlOptions>, 'ignores'> & { ignores: NormalizedIgnoreRule[] }} ResolvedCrawlOptions
376
+ * @typedef {Omit<Required<CrawlOptions>, 'ignores' | 'htmlValidate'> & { ignores: NormalizedIgnoreRule[], htmlValidate: ResolvedHtmlValidateEntry[] }} ResolvedCrawlOptions
410
377
  */
411
378
 
412
379
  /**
@@ -422,6 +389,40 @@ function validateIgnoreRule(rule) {
422
389
  }
423
390
  }
424
391
 
392
+ /**
393
+ * Normalizes a single config value to a non-null html-validate config object.
394
+ * Defaults `extends` to `['mui:recommended']` when the caller did not provide
395
+ * one, so overrides typically only need to specify the `rules` they want to
396
+ * change. To opt out of the default, pass `extends: []` explicitly.
397
+ * @param {true | import('html-validate').ConfigData} config
398
+ * @returns {import('html-validate').ConfigData}
399
+ */
400
+ function normalizeHtmlValidateConfig(config) {
401
+ if (config === true) {
402
+ return { extends: ['mui:recommended'] };
403
+ }
404
+ return { extends: ['mui:recommended'], ...config };
405
+ }
406
+
407
+ /**
408
+ * Resolves the htmlValidate option into an array of per-page entries.
409
+ * An empty array means validation is disabled.
410
+ * @param {HtmlValidateOption | undefined} option
411
+ * @returns {ResolvedHtmlValidateEntry[]}
412
+ */
413
+ function resolveHtmlValidateConfig(option) {
414
+ if (!option) {
415
+ return [];
416
+ }
417
+ if (option === true || !Array.isArray(option)) {
418
+ return [{ path: undefined, config: normalizeHtmlValidateConfig(option) }];
419
+ }
420
+ return option.map((entry) => ({
421
+ path: normalizeToArray(entry.path),
422
+ config: normalizeHtmlValidateConfig(entry.config),
423
+ }));
424
+ }
425
+
425
426
  /**
426
427
  * Resolves partial crawl options by filling in defaults for all optional fields.
427
428
  * @param {CrawlOptions} rawOptions - Partial options from user
@@ -447,6 +448,7 @@ function resolveOptions(rawOptions) {
447
448
  concurrency: rawOptions.concurrency ?? DEFAULT_CONCURRENCY,
448
449
  seedUrls: rawOptions.seedUrls ?? ['/'],
449
450
  ignores: normalizedIgnores,
451
+ htmlValidate: resolveHtmlValidateConfig(rawOptions.htmlValidate),
450
452
  };
451
453
  }
452
454
 
@@ -506,25 +508,42 @@ async function resolveKnownTargets(options) {
506
508
 
507
509
  /**
508
510
  * Represents a broken link or broken link target discovered during crawling.
509
- * @typedef {Object} Issue
511
+ * @typedef {Object} BrokenLinkIssue
510
512
  * @property {'broken-link' | 'broken-target'} type - Type of issue: 'broken-link' for 404 pages, 'broken-target' for missing anchors
511
513
  * @property {string} message - Human-readable description of the issue (e.g., 'Target not found', 'Page returned error 404')
512
514
  * @property {Link} link - The link object that has the issue
513
515
  */
514
516
 
517
+ /**
518
+ * Represents an HTML validation issue found on a crawled page.
519
+ * @typedef {Object} HtmlValidateIssue
520
+ * @property {'html-validate'} type - Issue type discriminator
521
+ * @property {string} message - Human-readable description of the issue
522
+ * @property {string} pageUrl - The page URL where the issue was found
523
+ * @property {string} ruleId - The html-validate rule that triggered this issue (e.g., 'no-dup-id')
524
+ * @property {number} severity - Severity level (1 = warning, 2 = error)
525
+ * @property {{ line: number, column: number }} location - Source location of the issue
526
+ * @property {string | null} selector - DOM selector for the element, or null
527
+ */
528
+
529
+ /**
530
+ * Any issue discovered during crawling.
531
+ * @typedef {BrokenLinkIssue | HtmlValidateIssue} Issue
532
+ */
533
+
515
534
  /**
516
535
  * Results from a complete crawl operation.
517
536
  * @typedef {Object} CrawlResult
518
537
  * @property {Set<Link>} links - All links discovered during the crawl
519
538
  * @property {Map<string, PageData>} pages - All pages crawled, keyed by normalized URL
520
- * @property {Issue[]} issues - All broken links and broken targets found
539
+ * @property {Issue[]} issues - All issues found (broken links, broken targets, and HTML validation issues)
521
540
  */
522
541
 
523
542
  /**
524
543
  * Reports broken links to stderr, grouped by source page for better readability.
525
- * @param {Issue[]} issuesList - Array of issues to report
544
+ * @param {BrokenLinkIssue[]} issuesList - Array of broken link issues to report
526
545
  */
527
- function reportIssues(issuesList) {
546
+ function reportBrokenLinks(issuesList) {
528
547
  if (issuesList.length === 0) {
529
548
  return;
530
549
  }
@@ -532,7 +551,7 @@ function reportIssues(issuesList) {
532
551
  console.error('\nBroken links found:\n');
533
552
 
534
553
  // Group issues by source URL
535
- /** @type {Map<string, Issue[]>} */
554
+ /** @type {Map<string, BrokenLinkIssue[]>} */
536
555
  const issuesBySource = new Map();
537
556
  for (const issue of issuesList) {
538
557
  const sourceUrl = issue.link.src ?? '(unknown)';
@@ -553,6 +572,39 @@ function reportIssues(issuesList) {
553
572
  }
554
573
  }
555
574
 
575
+ /**
576
+ * Reports HTML validation issues to stderr, grouped by page URL.
577
+ * @param {HtmlValidateIssue[]} htmlIssues - Array of HTML validation issues to report
578
+ */
579
+ function reportHtmlValidation(htmlIssues) {
580
+ if (htmlIssues.length === 0) {
581
+ return;
582
+ }
583
+
584
+ console.error('\nHTML validation issues:\n');
585
+
586
+ // Group by page URL
587
+ /** @type {Map<string, HtmlValidateIssue[]>} */
588
+ const issuesByPage = new Map();
589
+ for (const issue of htmlIssues) {
590
+ const pageIssues = issuesByPage.get(issue.pageUrl) ?? [];
591
+ if (pageIssues.length === 0) {
592
+ issuesByPage.set(issue.pageUrl, pageIssues);
593
+ }
594
+ pageIssues.push(issue);
595
+ }
596
+
597
+ for (const [pageUrl, pageIssues] of issuesByPage.entries()) {
598
+ console.error(`Page ${chalk.cyan(pageUrl)}:`);
599
+ for (const issue of pageIssues) {
600
+ const severityLabel = issue.severity === 2 ? chalk.red('error') : chalk.yellow('warning');
601
+ console.error(
602
+ ` ${issue.location.line}:${issue.location.column} ${severityLabel} ${issue.message} ${chalk.gray(issue.ruleId)}`,
603
+ );
604
+ }
605
+ }
606
+ }
607
+
556
608
  /**
557
609
  * Crawls a website starting from seed URLs, discovering all internal links and checking for broken links/targets.
558
610
  * @param {CrawlOptions} rawOptions - Configuration options for the crawl
@@ -594,6 +646,35 @@ export async function crawl(rawOptions) {
594
646
  const crawledPages = new Map();
595
647
  /** @type {Set<Link>} */
596
648
  const crawledLinks = new Set();
649
+ /** @type {Issue[]} */
650
+ const issues = [];
651
+ /**
652
+ * Spawns a crawl worker for a page URL.
653
+ * @param {string} pageUrl - The page URL to crawl
654
+ * @returns {Promise<{ pageData: PageData, links: Link[], htmlValidateResults: CrawlWorkerOutput['htmlValidateResults'] }>}
655
+ */
656
+ function crawlInWorker(pageUrl) {
657
+ return new Promise((resolve, reject) => {
658
+ /** @type {CrawlWorkerInput} */
659
+ const input = { pageUrl, options };
660
+ const worker = new Worker(crawlWorkerUrl, {
661
+ workerData: input,
662
+ });
663
+ worker.on('message', (/** @type {CrawlWorkerOutput} */ msg) => {
664
+ resolve({
665
+ pageData: {
666
+ url: msg.pageData.url,
667
+ status: msg.pageData.status,
668
+ targets: new Set(msg.pageData.targets),
669
+ contentType: msg.pageData.contentType,
670
+ },
671
+ links: msg.links,
672
+ htmlValidateResults: msg.htmlValidateResults,
673
+ });
674
+ });
675
+ worker.on('error', (err) => reject(err));
676
+ });
677
+ }
597
678
 
598
679
  const queue = new Queue(async (/** @type {Link} */ link) => {
599
680
  crawledLinks.add(link);
@@ -611,78 +692,30 @@ export async function crawl(rawOptions) {
611
692
  return;
612
693
  }
613
694
 
614
- const pagePromise = Promise.resolve().then(async () => {
615
- console.log(`Crawling ${chalk.cyan(pageUrl)}...`);
616
- const res = await fetch(new URL(pageUrl, options.host));
617
-
618
- const contentTypeHeader = res.headers.get('content-type');
619
- let type = 'text/html';
620
-
621
- if (contentTypeHeader) {
622
- try {
623
- const parsed = contentType.parse(contentTypeHeader);
624
- type = parsed.type;
625
- } catch {
626
- console.warn(
627
- chalk.yellow(`Warning: ${pageUrl} returned invalid content-type: ${contentTypeHeader}`),
628
- );
629
- }
630
- }
631
-
632
- /** @type {PageData} */
633
- const pageData = {
634
- url: pageUrl,
635
- status: res.status,
636
- targets: new Set(),
637
- contentType: type,
638
- };
639
-
640
- if (pageData.status < 200 || pageData.status >= 400) {
641
- console.warn(chalk.yellow(`Warning: ${pageUrl} returned status ${pageData.status}`));
642
- return pageData;
643
- }
644
-
645
- if (type.startsWith('image/')) {
646
- // Skip images
647
- return pageData;
648
- }
649
-
650
- if (type !== 'text/html' && type !== 'text/markdown') {
651
- console.warn(chalk.yellow(`Warning: ${pageUrl} returned non-HTML content-type: ${type}`));
652
- return pageData;
653
- }
654
-
655
- const rawContent = await res.text();
656
- const content = type === 'text/markdown' ? await markdownToHtml(rawContent) : rawContent;
657
-
658
- const dom = parse(content, { parseNoneClosedTags: true });
659
-
660
- let ignoredSelector = ':not(*)'; // matches nothing
661
- if (options.ignoredContent.length > 0) {
662
- ignoredSelector = Array.from(options.ignoredContent)
663
- .flatMap((selector) => [selector, `${selector} *`])
664
- .join(',');
665
- }
666
- const linksSelector = `a[href]:not(${ignoredSelector})`;
667
-
668
- const pageLinks = dom.querySelectorAll(linksSelector).map((a) => ({
669
- src: pageUrl,
670
- text: getAccessibleName(a, dom),
671
- href: a.getAttribute('href') ?? '',
672
- contentType: type,
673
- }));
674
-
675
- for (const target of dom.querySelectorAll('*[id]')) {
676
- if (!options.ignoredTargets.has(target.id)) {
677
- pageData.targets.add(`#${target.id}`);
695
+ console.log(`Crawling ${chalk.cyan(pageUrl)}...`);
696
+ const workerPromise = crawlInWorker(pageUrl);
697
+ const pagePromise = workerPromise.then((result) => {
698
+ if (result.htmlValidateResults) {
699
+ for (const validationResult of result.htmlValidateResults.results) {
700
+ for (const msg of validationResult.messages) {
701
+ issues.push({
702
+ type: 'html-validate',
703
+ message: msg.message,
704
+ pageUrl: result.htmlValidateResults.pageUrl,
705
+ ruleId: msg.ruleId,
706
+ severity: msg.severity,
707
+ location: { line: msg.line, column: msg.column },
708
+ selector: msg.selector,
709
+ });
710
+ }
678
711
  }
679
712
  }
680
713
 
681
- for (const pageLink of pageLinks) {
682
- queue.add(pageLink);
714
+ for (const discoveredLink of result.links) {
715
+ queue.add(discoveredLink);
683
716
  }
684
717
 
685
- return pageData;
718
+ return result.pageData;
686
719
  });
687
720
 
688
721
  crawledPages.set(pageUrl, pagePromise);
@@ -711,10 +744,6 @@ export async function crawl(rawOptions) {
711
744
  await writePagesToFile(results, options.outPath);
712
745
  }
713
746
 
714
- /** Array to collect all issues found during validation */
715
- /** @type {Issue[]} */
716
- const issues = [];
717
-
718
747
  /** Count of links ignored due to ignores configuration */
719
748
  let ignoredCount = 0;
720
749
 
@@ -771,11 +800,24 @@ export async function crawl(rawOptions) {
771
800
  }
772
801
  }
773
802
 
774
- reportIssues(issues);
803
+ // Split issues by type for reporting
804
+ /** @type {BrokenLinkIssue[]} */
805
+ const brokenLinkIssues = /** @type {BrokenLinkIssue[]} */ (
806
+ issues.filter((issue) => issue.type === 'broken-link' || issue.type === 'broken-target')
807
+ );
808
+ /** @type {HtmlValidateIssue[]} */
809
+ const htmlValidateIssues = /** @type {HtmlValidateIssue[]} */ (
810
+ issues.filter((issue) => issue.type === 'html-validate')
811
+ );
812
+
813
+ reportBrokenLinks(brokenLinkIssues);
814
+ reportHtmlValidation(htmlValidateIssues);
775
815
 
776
816
  // Derive counts from issues
777
- const brokenLinks = issues.filter((issue) => issue.type === 'broken-link').length;
778
- const brokenLinkTargets = issues.filter((issue) => issue.type === 'broken-target').length;
817
+ const brokenLinks = brokenLinkIssues.filter((issue) => issue.type === 'broken-link').length;
818
+ const brokenLinkTargets = brokenLinkIssues.filter(
819
+ (issue) => issue.type === 'broken-target',
820
+ ).length;
779
821
 
780
822
  const endTime = Date.now();
781
823
  const durationSeconds = (endTime - startTime) / 1000;
@@ -784,14 +826,21 @@ export async function crawl(rawOptions) {
784
826
  unit: 'second',
785
827
  maximumFractionDigits: 2,
786
828
  }).format(durationSeconds);
829
+ const fmt = new Intl.NumberFormat('en-US').format;
787
830
  console.log(chalk.blue(`\nCrawl completed in ${duration}`));
788
- console.log(` Total links found: ${chalk.cyan(crawledLinks.size)}`);
789
- console.log(` Total broken links: ${chalk.cyan(brokenLinks)}`);
790
- console.log(` Total broken link targets: ${chalk.cyan(brokenLinkTargets)}`);
791
- console.log(` Total ignored: ${chalk.cyan(ignoredCount)}`);
831
+ console.log(` Total links found: ${chalk.cyan(fmt(crawledLinks.size))}`);
832
+ console.log(` Total broken links: ${chalk.cyan(fmt(brokenLinks))}`);
833
+ console.log(` Total broken link targets: ${chalk.cyan(fmt(brokenLinkTargets))}`);
834
+ console.log(` Total ignored: ${chalk.cyan(fmt(ignoredCount))}`);
835
+ if (options.htmlValidate.length > 0) {
836
+ const pagesWithHtmlIssues = new Set(htmlValidateIssues.map((issue) => issue.pageUrl)).size;
837
+ console.log(
838
+ ` HTML validation issues: ${chalk.cyan(fmt(htmlValidateIssues.length))} across ${chalk.cyan(fmt(pagesWithHtmlIssues))} ${pagesWithHtmlIssues === 1 ? 'page' : 'pages'}`,
839
+ );
840
+ }
792
841
 
793
842
  if (options.outPath) {
794
- console.log(chalk.blue(`Output written to: ${options.outPath}`));
843
+ console.log(chalk.blue(`Output written to: ${pathToFileURL(options.outPath)}`));
795
844
  }
796
845
 
797
846
  return { links: crawledLinks, pages: results, issues };
@@ -2,12 +2,18 @@ import path from 'node:path';
2
2
  import getPort from 'get-port';
3
3
  import { describe, expect, it } from 'vitest';
4
4
 
5
- // eslint-disable-next-line import/extensions
6
- import { crawl, Issue, Link } from './index.mjs';
7
-
8
- type ExpectedIssue = Omit<Partial<Issue>, 'link'> & { link?: Partial<Link> };
9
-
10
- function objectMatchingIssue(expectedIssue: ExpectedIssue) {
5
+ import {
6
+ crawl,
7
+ type BrokenLinkIssue,
8
+ type HtmlValidateIssue,
9
+ type Issue,
10
+ type Link,
11
+ // eslint-disable-next-line import/extensions
12
+ } from './index.mjs';
13
+
14
+ type ExpectedBrokenLinkIssue = Omit<Partial<BrokenLinkIssue>, 'link'> & { link?: Partial<Link> };
15
+
16
+ function objectMatchingIssue(expectedIssue: ExpectedBrokenLinkIssue) {
11
17
  return expect.objectContaining({
12
18
  ...expectedIssue,
13
19
  ...(expectedIssue.link ? { link: expect.objectContaining(expectedIssue.link) } : {}),
@@ -15,16 +21,16 @@ function objectMatchingIssue(expectedIssue: ExpectedIssue) {
15
21
  }
16
22
 
17
23
  /**
18
- * Helper to assert that an issue with matching properties exists in the issues array
24
+ * Helper to assert that a broken link issue with matching properties exists in the issues array
19
25
  */
20
- function expectIssue(issues: Issue[], expectedIssue: ExpectedIssue) {
26
+ function expectIssue(issues: Issue[], expectedIssue: ExpectedBrokenLinkIssue) {
21
27
  expect(issues).toEqual(expect.arrayContaining([objectMatchingIssue(expectedIssue)]));
22
28
  }
23
29
 
24
30
  /**
25
- * Helper to assert that no issue with matching properties exists in the issues array
31
+ * Helper to assert that no broken link issue with matching properties exists in the issues array
26
32
  */
27
- function expectNotIssue(issues: Issue[], notExpectedIssue: ExpectedIssue) {
33
+ function expectNotIssue(issues: Issue[], notExpectedIssue: ExpectedBrokenLinkIssue) {
28
34
  expect(issues).not.toEqual(expect.arrayContaining([objectMatchingIssue(notExpectedIssue)]));
29
35
  }
30
36
 
@@ -56,12 +62,29 @@ describe('Broken Links Checker', () => {
56
62
  // Test href-only rule (matches from any page) - note: matches the actual href value
57
63
  { href: 'broken-relative.html' },
58
64
  ],
65
+ // Exercise the array form. Three entries all match /invalid-html.html;
66
+ // the last one wins, so its rules apply (no-dup-id ON, no-raw-characters
67
+ // OFF). The middle entry's `no-dup-id: off` is shadowed by the regex
68
+ // entry below it, demonstrating last-match-wins. The default entry
69
+ // applies to every other page (markdown, etc.) since the regex only
70
+ // matches `.html`.
71
+ htmlValidate: [
72
+ { config: { rules: { 'no-raw-characters': 'off' } } },
73
+ {
74
+ path: '/invalid-html.html',
75
+ config: { rules: { 'no-dup-id': 'off', 'no-raw-characters': 'off' } },
76
+ },
77
+ { path: /\.html$/, config: { rules: { 'no-raw-characters': 'off' } } },
78
+ ],
59
79
  });
60
80
 
61
- expect(result.links).toHaveLength(66);
62
- // Issue count: original 11, minus ignored ones (broken-from-markdown via contentType,
81
+ expect(result.links).toHaveLength(67);
82
+ // Broken link issue count: original 11, minus ignored ones (broken-from-markdown via contentType,
63
83
  // broken-relative via href-only rule)
64
- expect(result.issues).toHaveLength(9);
84
+ const brokenLinkIssues = result.issues.filter(
85
+ (issue) => issue.type === 'broken-link' || issue.type === 'broken-target',
86
+ );
87
+ expect(brokenLinkIssues).toHaveLength(9);
65
88
 
66
89
  // Test ignores: these broken links should be ignored (not in issues)
67
90
  expectNotIssue(result.issues, {
@@ -257,5 +280,32 @@ describe('Broken Links Checker', () => {
257
280
  // Test contentType is stored on pageData
258
281
  expect(result.pages.get('/example.md')?.contentType).toBe('text/markdown');
259
282
  expect(result.pages.get('/')?.contentType).toBe('text/html');
283
+
284
+ // Test htmlValidate: invalid-html.html has duplicate IDs which should be reported
285
+ const htmlValidateIssues = result.issues.filter(
286
+ (issue): issue is HtmlValidateIssue => issue.type === 'html-validate',
287
+ );
288
+ const invalidHtmlIssues = htmlValidateIssues.filter(
289
+ (issue) => issue.pageUrl === '/invalid-html.html',
290
+ );
291
+ expect(invalidHtmlIssues.length).toBeGreaterThan(0);
292
+ expect(invalidHtmlIssues).toEqual(
293
+ expect.arrayContaining([
294
+ expect.objectContaining({
295
+ type: 'html-validate',
296
+ pageUrl: '/invalid-html.html',
297
+ ruleId: 'no-dup-id',
298
+ }),
299
+ ]),
300
+ );
301
+
302
+ // Test htmlValidate override: no-raw-characters is off, so raw & should NOT be reported
303
+ expect(invalidHtmlIssues).not.toEqual(
304
+ expect.arrayContaining([
305
+ expect.objectContaining({
306
+ ruleId: 'no-raw-characters',
307
+ }),
308
+ ]),
309
+ );
260
310
  }, 30000);
261
311
  });
@@ -84,15 +84,15 @@ describe('categorizeCommits', () => {
84
84
  labels: {
85
85
  ...baseLabelConfig,
86
86
  categoryOverrides: {
87
- 'all components': 'General changes',
87
+ 'scope: all components': 'General changes',
88
88
  },
89
89
  },
90
90
  };
91
91
 
92
92
  const commits = [
93
- createCommit(1, ['component: Button', 'all components']),
93
+ createCommit(1, ['component: Button', 'scope: all components']),
94
94
  createCommit(2, ['component: Checkbox']),
95
- createCommit(3, ['component: Button', 'all components']),
95
+ createCommit(3, ['component: Button', 'scope: all components']),
96
96
  ];
97
97
 
98
98
  const result = categorizeCommits(commits, configWithOverrides);
@@ -229,13 +229,13 @@ describe('categorizeCommits', () => {
229
229
  labels: {
230
230
  ...baseLabelConfig,
231
231
  categoryOverrides: {
232
- 'all packages': 'General changes',
232
+ 'scope: all components': 'General changes',
233
233
  },
234
234
  },
235
235
  };
236
236
 
237
237
  const commits = [
238
- createCommit(1, ['scope: data grid', 'all packages']),
238
+ createCommit(1, ['scope: data grid', 'scope: all components']),
239
239
  createCommit(2, ['scope: charts']),
240
240
  ];
241
241
 
@@ -93,8 +93,12 @@ async function fetchCommitsRest({ octokit, repo, lastRelease, release, org = 'mu
93
93
  }
94
94
 
95
95
  const promises = results.map(async (commit) => {
96
- const prMatch = commit.commit.message.match(/#(\d+)/);
97
- if (prMatch === null) {
96
+ const matches = [...commit.commit.message.matchAll(/#(\d+)/g)];
97
+ // The PR number is always the last match.
98
+ // Sometimes the PR titles include an issue number like this:
99
+ // [tag] PR title (#00001) (#00002)
100
+ const prMatch = matches.at(-1);
101
+ if (!prMatch) {
98
102
  return null;
99
103
  }
100
104