@expressivecss/mcp-server 0.4.1 → 0.5.0

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/contract.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
3
  "generatedBy": "scripts/gen-expressivecss-skill.mjs",
4
- "frameworkVersion": "0.15.0",
5
- "skillVersion": "0.11.0",
6
- "matchingTag": "v0.15.0",
4
+ "frameworkVersion": "0.16.0",
5
+ "skillVersion": "0.12.0",
6
+ "matchingTag": "v0.16.0",
7
7
  "releaseTags": [
8
8
  "v0.10.0",
9
9
  "v0.10.1",
@@ -14,6 +14,7 @@
14
14
  "v0.14.0",
15
15
  "v0.14.1",
16
16
  "v0.15.0",
17
+ "v0.16.0",
17
18
  "v0.5.0",
18
19
  "v0.6.0",
19
20
  "v0.7.0",
@@ -30,5 +31,5 @@
30
31
  "package.json",
31
32
  "CHANGELOG.md"
32
33
  ],
33
- "sourceHash": "e1c777a1bcd53d5a6ed4615075a7a500fb6f488050e727487804f8cc421b5902"
34
+ "sourceHash": "d005fbd1fc75538ac5f20a4024adbd044ab3d31d3a3ff9c34b268a74c26b1d5a"
34
35
  }
package/lint.mjs CHANGED
@@ -9,9 +9,8 @@
9
9
  * it is a markup file, exit 2 on findings
10
10
  * so the message goes back to the agent.
11
11
  */
12
- import { readFileSync, realpathSync } from 'node:fs';
12
+ import { readFileSync } from 'node:fs';
13
13
  import path from 'node:path';
14
- import { pathToFileURL } from 'node:url';
15
14
  import { inspectAuthoringRules, readInspectionFile } from './server.js';
16
15
 
17
16
  // Markdown is left out: a doc concatenates many independent examples, so page-level rules misfire.
@@ -28,7 +27,12 @@ export async function lintFile(file) {
28
27
  return [problem('file-not-inspected', `Skipped: ${error.message}. Files must be regular, inside the working directory, and at most ${MAX_BYTES} bytes.`)];
29
28
  }
30
29
  // ponytail: JSX/Astro/Vue are parsed as HTML after `className` -> `class`; expression-heavy markup can hide from selector rules.
31
- const issues = inspectAuthoringRules(text.replace(/\bclassName=/gu, 'class='));
30
+ let issues;
31
+ try {
32
+ issues = inspectAuthoringRules(text.replace(/\bclassName=/gu, 'class='));
33
+ } catch (error) {
34
+ return [problem('file-not-inspected', `Static inspection failed: ${error.message}.`)];
35
+ }
32
36
  if (issues.truncatedReason) issues.push(problem('inspection-truncated', `Static inspection stopped early: ${issues.truncatedReason}. Split the file or reduce its markup.`));
33
37
  return issues;
34
38
  }
@@ -63,6 +67,6 @@ async function main(argv) {
63
67
  return total ? 1 : 0;
64
68
  }
65
69
 
66
- if (process.argv[1] && pathToFileURL(realpathSync(process.argv[1])).href === import.meta.url) {
70
+ if (import.meta.main) {
67
71
  process.exitCode = await main(process.argv.slice(2));
68
72
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@expressivecss/mcp-server",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "description": "ExpressiveCSS MCP server with Design→QA workflow tools.",
5
5
  "homepage": "https://www.expressivecss.com",
6
6
  "repository": {
@@ -32,6 +32,8 @@
32
32
  "semantics-data.json",
33
33
  "smoke.mjs",
34
34
  "scripts/resolve-version.mjs",
35
+ "scripts/bounded-file.mjs",
36
+ "scripts/semantics-rules.mjs",
35
37
  "README.md",
36
38
  "mcp.json",
37
39
  ".claude-plugin/plugin.json",
@@ -45,6 +47,6 @@
45
47
  "zod": "^4.6.5"
46
48
  },
47
49
  "overrides": {
48
- "qs": "6.16.0"
50
+ "qs": "6.16.1"
49
51
  }
50
52
  }
@@ -0,0 +1,73 @@
1
+ // Generated from scripts/lib/bounded-file.mjs. Do not edit.
2
+ import { constants as fsConstants } from 'node:fs';
3
+ import { lstat, open, realpath } from 'node:fs/promises';
4
+ import path from 'node:path';
5
+
6
+ function isPathInside(root, candidate) {
7
+ const relative = path.relative(root, candidate);
8
+ return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative));
9
+ }
10
+
11
+ // Codes let callers that report a status instead of throwing map each refusal.
12
+ const fail = (message, code) => Object.assign(new Error(message), { code });
13
+
14
+ export async function readBoundedRegularFile(filePath, byteLimit, label, expectedRoot = null, encoding = 'utf8') {
15
+ const target = path.resolve(filePath);
16
+ let resolvedRoot = null;
17
+ if (expectedRoot) {
18
+ const rootPrefix = path.join(path.resolve(expectedRoot), path.sep);
19
+ if (!target.startsWith(rootPrefix)) {
20
+ throw fail(`${label} file is outside the repository`, 'outside-root');
21
+ }
22
+ resolvedRoot = await realpath(expectedRoot);
23
+ const relative = path.relative(rootPrefix, target);
24
+ let current = resolvedRoot;
25
+ for (const segment of relative.split(path.sep)) {
26
+ current = path.join(current, segment);
27
+ const entry = await lstat(current, { bigint: true });
28
+ if (entry.isSymbolicLink()) throw fail(`${label} file path contains a symbolic link`, 'symbolic-link');
29
+ }
30
+ }
31
+
32
+ const noFollow = Number.isInteger(fsConstants.O_NOFOLLOW) ? fsConstants.O_NOFOLLOW : 0;
33
+ const nonBlocking = Number.isInteger(fsConstants.O_NONBLOCK) ? fsConstants.O_NONBLOCK : 0;
34
+ const handle = await open(target, fsConstants.O_RDONLY | noFollow | nonBlocking);
35
+ try {
36
+ const before = await handle.stat({ bigint: true });
37
+ const pathBefore = await lstat(target, { bigint: true });
38
+ if (pathBefore.isSymbolicLink()) throw fail(`${label} file is a symbolic link`, 'symbolic-link');
39
+ if (!before.isFile() || !pathBefore.isFile()) throw fail(`${label} file is not a regular file`, 'not-regular-file');
40
+ if (before.dev !== pathBefore.dev || before.ino !== pathBefore.ino) {
41
+ throw fail(`${label} file identity changed before reading`, 'identity-changed');
42
+ }
43
+ if (resolvedRoot) {
44
+ const openedPath = await realpath(`/proc/self/fd/${handle.fd}`).catch(() => realpath(target));
45
+ if (!isPathInside(resolvedRoot, openedPath)) throw fail(`${label} file is outside the repository`, 'outside-root');
46
+ }
47
+ if (before.size > BigInt(byteLimit)) throw fail(`${label} file exceeds ${byteLimit} bytes`, 'oversized');
48
+
49
+ // Size the buffer to the file, not the limit. The spare byte exposes growth
50
+ // and files such as /proc entries that report a smaller size than they hold.
51
+ const capacity = Number(before.size) + 1;
52
+ const bytes = Buffer.allocUnsafe(capacity);
53
+ let total = 0;
54
+ while (total < capacity) {
55
+ const chunk = await handle.read(bytes, total, capacity - total, total);
56
+ if (chunk.bytesRead === 0) break;
57
+ total += chunk.bytesRead;
58
+ }
59
+ if (total > before.size) throw fail(`${label} file changed while reading`, 'identity-changed');
60
+
61
+ const after = await handle.stat({ bigint: true });
62
+ const pathAfter = await lstat(target, { bigint: true });
63
+ const handleChanged = before.dev !== after.dev || before.ino !== after.ino
64
+ || before.size !== after.size || before.mtimeNs !== after.mtimeNs || before.ctimeNs !== after.ctimeNs;
65
+ const pathChanged = pathAfter.isSymbolicLink() || !pathAfter.isFile()
66
+ || after.dev !== pathAfter.dev || after.ino !== pathAfter.ino;
67
+ if (handleChanged || pathChanged) throw fail(`${label} file changed while reading`, 'identity-changed');
68
+ const content = bytes.subarray(0, total);
69
+ return encoding === null ? content : content.toString(encoding);
70
+ } finally {
71
+ await handle.close();
72
+ }
73
+ }
@@ -1,8 +1,8 @@
1
1
  // Generated from scripts/lib/resolve-expressivecss-version.mjs. Do not edit.
2
- import { constants as fsConstants } from 'node:fs';
3
- import { lstat, open, realpath } from 'node:fs/promises';
4
2
  import path from 'node:path';
5
3
  import { fileURLToPath } from 'node:url';
4
+ import { parseArgs } from 'node:util';
5
+ import { readBoundedRegularFile } from './bounded-file.mjs';
6
6
 
7
7
  const PACKAGE_NAME = '@expressivecss/expressive';
8
8
  const MAX_MANIFEST_BYTES = 1 * 1024 * 1024;
@@ -16,46 +16,17 @@ function readLimit(filePath) {
16
16
  return MAX_MANIFEST_BYTES;
17
17
  }
18
18
 
19
- function sameFile(left, right) {
20
- return left.dev === right.dev && left.ino === right.ino && left.size === right.size
21
- && left.mtimeNs === right.mtimeNs && left.ctimeNs === right.ctimeNs;
22
- }
19
+ const READ_REFUSALS = new Set(['symbolic-link', 'not-regular-file', 'identity-changed', 'oversized']);
20
+ const OPEN_LINK_ERRORS = new Set(['ELOOP', 'EMLINK']);
23
21
 
22
+ // No root: node_modules may be a link farm (pnpm), so only the file itself must not be a link.
24
23
  async function readBounded(filePath) {
25
- let handle;
26
24
  try {
27
- const pathBefore = await lstat(filePath, { bigint: true });
28
- if (pathBefore.isSymbolicLink()) return { error: 'symbolic-link' };
29
- if (!pathBefore.isFile()) return { error: 'not-regular-file' };
30
- const noFollow = fsConstants.O_NOFOLLOW ?? 0;
31
- handle = await open(filePath, fsConstants.O_RDONLY | noFollow);
32
- const before = await handle.stat({ bigint: true });
33
- if (!before.isFile() || !sameFile(before, pathBefore)) return { error: 'identity-changed' };
34
- const limit = readLimit(filePath);
35
- if (before.size > BigInt(limit)) return { error: 'oversized' };
36
- // Size the buffer to the file, not the limit. The spare byte exposes growth.
37
- const buffer = Buffer.allocUnsafe(Number(before.size) + 1);
38
- let offset = 0;
39
- while (offset < buffer.length) {
40
- const { bytesRead } = await handle.read(buffer, offset, buffer.length - offset, offset);
41
- if (bytesRead === 0) break;
42
- offset += bytesRead;
43
- }
44
- if (offset > before.size) return { error: 'identity-changed' };
45
- const after = await handle.stat({ bigint: true });
46
- const pathAfter = await lstat(filePath, { bigint: true });
47
- const currentResolved = await realpath(filePath);
48
- const openedResolved = await realpath(`/proc/self/fd/${handle.fd}`).catch(() => currentResolved);
49
- if (!sameFile(before, after) || !sameFile(after, pathAfter) || currentResolved !== openedResolved) {
50
- return { error: 'identity-changed' };
51
- }
52
- return { text: buffer.subarray(0, offset).toString('utf8') };
25
+ return { text: await readBoundedRegularFile(filePath, readLimit(filePath), path.basename(filePath)) };
53
26
  } catch (error) {
54
27
  if (error?.code === 'ENOENT') return { missing: true };
55
- if (error?.code === 'ELOOP' || error?.code === 'EMLINK') return { error: 'symbolic-link' };
56
- return { error: 'unreadable' };
57
- } finally {
58
- await handle?.close().catch(() => {});
28
+ if (OPEN_LINK_ERRORS.has(error?.code)) return { error: 'symbolic-link' };
29
+ return { error: READ_REFUSALS.has(error?.code) ? error.code : 'unreadable' };
59
30
  }
60
31
  }
61
32
 
@@ -76,10 +47,10 @@ async function readJson(filePath) {
76
47
 
77
48
  function cleanVersion(value) {
78
49
  if (typeof value !== 'string') return null;
79
- const cleaned = value.trim().replace(/^['"]|['"]$/gu, '').replace(/\(.+\)$/u, '');
80
- return /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/u.test(cleaned)
81
- ? cleaned
82
- : null;
50
+ // One anchored pass: a free-floating /\(.+\)$/ retried from every "(" and
51
+ // went quadratic on a long run of them.
52
+ const cleaned = value.trim().replace(/^['"]|['"]$/gu, '');
53
+ return /^(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)(?:\(.+\))?$/u.exec(cleaned)?.[1] ?? null;
83
54
  }
84
55
 
85
56
  export function parseSemver(value) {
@@ -312,8 +283,9 @@ function pnpmLockResolution(text, declaredRange) {
312
283
  if (targetLines[index].trim() && /^\s*/u.exec(targetLines[index])[0].length <= 6) break;
313
284
  dependencyLines.push(targetLines[index]);
314
285
  }
315
- const specifier = /^\s+specifier:\s*['"]?([^'"\s]+)['"]?\s*$/mu.exec(dependencyLines.join('\n'))?.[1] ?? null;
316
- const rawVersion = /^\s+version:\s*['"]?([^'"\s]+)['"]?\s*$/mu.exec(dependencyLines.join('\n'))?.[1] ?? null;
286
+ // [ \t], not \s: \s crossed newlines, so blank lines made the scan quadratic.
287
+ const specifier = /^[ \t]+specifier:[ \t]*['"]?([^'"\s]+)['"]?[ \t]*$/mu.exec(dependencyLines.join('\n'))?.[1] ?? null;
288
+ const rawVersion = /^[ \t]+version:[ \t]*['"]?([^'"\s]+)['"]?[ \t]*$/mu.exec(dependencyLines.join('\n'))?.[1] ?? null;
317
289
  if (specifier !== declaredRange) {
318
290
  return {
319
291
  version: null,
@@ -689,16 +661,13 @@ export async function resolveExpressiveVersion({
689
661
  projectRoot: root,
690
662
  packageManager: lock.packageManager,
691
663
  declaredRange,
692
- declaredVersion: declaredRange,
693
664
  resolvedVersion,
694
665
  exactInstalledVersion: resolutionSource === 'installed-package' ? resolvedVersion : null,
695
666
  resolutionSource,
696
667
  skillVersion: normalizedSkillVersion,
697
- skillContractVersion: normalizedContract,
698
668
  contractVersion: normalizedContract,
699
669
  contractSourceHash: contract?.sourceHash ?? null,
700
670
  matchingTag,
701
- matchingCommit: null,
702
671
  status,
703
672
  contractStatus: status,
704
673
  documentationMode,
@@ -712,22 +681,10 @@ export async function resolveExpressiveVersion({
712
681
  };
713
682
  }
714
683
 
715
- function cliArguments(argv) {
716
- const values = {};
717
- for (let index = 0; index < argv.length; index += 1) {
718
- const argument = argv[index];
719
- if (!argument.startsWith('--')) continue;
720
- const [key, ...rest] = argument.slice(2).split('=');
721
- if (rest.length) values[key] = rest.join('=');
722
- else if (argv[index + 1] && !argv[index + 1].startsWith('--')) values[key] = argv[index += 1];
723
- else values[key] = true;
724
- }
725
- return values;
726
- }
727
-
728
684
  const invokedPath = process.argv[1] ? path.resolve(process.argv[1]) : null;
729
685
  if (invokedPath === fileURLToPath(import.meta.url)) {
730
- const args = cliArguments(process.argv.slice(2));
686
+ const option = { type: 'string' };
687
+ const { values: args } = parseArgs({ strict: false, allowPositionals: true, options: { 'project-root': option, 'contract-version': option, 'skill-version': option } });
731
688
  const result = await resolveExpressiveVersion({
732
689
  projectRoot: typeof args['project-root'] === 'string' ? args['project-root'] : process.cwd(),
733
690
  contractVersion: typeof args['contract-version'] === 'string' ? args['contract-version'] : null,
@@ -0,0 +1,184 @@
1
+ // Generated from scripts/semantics-rules.mjs. Do not edit.
2
+ /**
3
+ * The semantics.json rule engine, shared by the two seams that run it.
4
+ *
5
+ * tests/semantics.test.js runs it over authored fragments -- the examples a
6
+ * reader copies, in llm.md, the documentation pages and tests/fixtures.js.
7
+ * scripts/verify-site.mjs runs it over the built pages, where chrome and
8
+ * content finally meet and document-level rules like main-not-nested become
9
+ * answerable at all.
10
+ *
11
+ * It lives under scripts/ rather than tests/ because a test file cannot be
12
+ * imported without running its tests. The MCP server ships a generated copy
13
+ * and walks the hits itself, so it can stop early and report locations.
14
+ */
15
+
16
+ import { createRequire } from 'node:module';
17
+
18
+ // Loaded on first parse: jsdom takes ~0.3 s, and most MCP server starts never inspect markup.
19
+ const parse = (html) => new (createRequire(import.meta.url)('jsdom').JSDOM)(html).window.document;
20
+
21
+ export const FORBID_KINDS = ['forbid', 'forbid-composite-roles'];
22
+
23
+ /**
24
+ * What a rule actually matches.
25
+ *
26
+ * `forbid-composite-roles` states the component's own selector and is expanded
27
+ * over the vocabulary, so the ten roles are named once in semantics.json rather
28
+ * than once per component - and adding one tightens every such rule instead of
29
+ * leaving each a role short. Expansion is root-only on purpose: a slide may
30
+ * legitimately contain a menu, it just may not *be* one.
31
+ */
32
+ export function expandedSelector(rule, compositeRoles) {
33
+ if (rule.kind !== 'forbid-composite-roles') return rule.selector;
34
+ return compositeRoles.map((r) => `${rule.selector}[role="${r}"]`).join(', ');
35
+ }
36
+
37
+ /** Every rule of every component the sweep has reached. */
38
+ export function enforcedRules(data) {
39
+ return Object.entries(data.rows)
40
+ .filter(([, c]) => c.status === 'enforced')
41
+ .flatMap(([name, c]) => c.rules.map((r) => ({ ...r, component: name })));
42
+ }
43
+
44
+ // ponytail: a fixed cap on aria-labelledby ids. A real label names a handful;
45
+ // raise it if a page ever needs more.
46
+ const MAX_LABEL_REFS = 32;
47
+
48
+ /**
49
+ * The name an element is given outright, by `aria-label` or `aria-labelledby`.
50
+ * Empty when it has neither.
51
+ *
52
+ * The two callers below diverge on what happens next, which is why this stops
53
+ * here: buttons can fall back to their own text, while landmarks and selects
54
+ * need a name apart from the links or options they contain.
55
+ */
56
+ export function authoredName(el, document) {
57
+ const label = el.getAttribute('aria-label');
58
+ if (label && label.trim()) return label.trim();
59
+ const ref = el.getAttribute('aria-labelledby');
60
+ if (!ref) return '';
61
+ // Each id once, and only the first few: repeating one id thousands of times
62
+ // made a small snippet walk a large subtree thousands of times.
63
+ return [...new Set(ref.split(/\s+/))]
64
+ .slice(0, MAX_LABEL_REFS)
65
+ .map((id) => document.getElementById(id)?.textContent ?? '')
66
+ .join(' ')
67
+ .trim();
68
+ }
69
+
70
+ /**
71
+ * Enough of the accessible-name computation to answer "is this control
72
+ * nameless?" - the authored name, then the text that is left once the
73
+ * aria-hidden subtrees are taken out. Not the full algorithm: no title, no
74
+ * alt on a descendant image, because a control relying on those is
75
+ * not what this is looking for.
76
+ */
77
+ function accessibleName(el, document) {
78
+ const authored = authoredName(el, document);
79
+ if (authored) return authored;
80
+ const labels = [...(el.labels ?? [])].map((label) => accessibleName(label, document)).join(' ').trim();
81
+ if (labels) return labels;
82
+ // Landmarks and selects are not named by their descendant links or options.
83
+ if (el.matches('nav, select')) return '';
84
+ const clone = el.cloneNode(true);
85
+ clone.querySelectorAll('[aria-hidden="true"], select').forEach((n) => n.remove());
86
+ return clone.textContent.trim();
87
+ }
88
+
89
+ /**
90
+ * An authored fragment, parsed into a body of its own.
91
+ *
92
+ * Never for a whole document: nesting one inside <body> reparses its <head> in
93
+ * body context, so a rule keyed on anything up there would silently match
94
+ * nothing. Whole pages go through `checkPage`.
95
+ */
96
+ export function violations(html, rules, compositeRoles, opts) {
97
+ return violationsIn(parse(`<!doctype html><body>${html}</body>`), rules, compositeRoles, opts);
98
+ }
99
+
100
+ const idrefTargets = (rule, el, document) => {
101
+ const container = rule.container ? el.closest(rule.container) : document;
102
+ return new Set(container?.querySelectorAll(rule.targetSelector));
103
+ };
104
+
105
+ const ownedBy = (rule, el) => (candidate) => {
106
+ if (candidate.closest(rule.ownerSelector) !== el) return false;
107
+ const excludedAncestor = rule.excludeAncestorSelector && candidate.closest(rule.excludeAncestorSelector);
108
+ return !excludedAncestor || !el.contains(excludedAncestor);
109
+ };
110
+
111
+ /** Per rule kind: does this hit break the rule? */
112
+ const BROKEN = {
113
+ forbid: () => true,
114
+ 'forbid-composite-roles': () => true,
115
+ 'require-attr': (rule, el) => {
116
+ const v = el.getAttribute(rule.attr);
117
+ return rule.equals ? v !== rule.equals : v === null || v === '';
118
+ },
119
+ 'require-idref': (rule, el, document) => {
120
+ const ids = (el.getAttribute(rule.attr) ?? '').trim().split(/\s+/).filter(Boolean);
121
+ const targets = idrefTargets(rule, el, document);
122
+ return !ids.length || !ids.every((id) => targets.has(document.getElementById(id)));
123
+ },
124
+ 'require-owned-descendant': (rule, el) => ![...el.querySelectorAll(rule.descendantSelector)].some(ownedBy(rule, el)),
125
+ // The one thing a selector cannot ask. "Has no accessible name" depends
126
+ // on text *nodes*, and CSS cannot see them: `:has(> .icon:only-child)`
127
+ // counts elements, so it flags <a><span icon/>Five</a> - a link that is
128
+ // perfectly well named. This reads the content instead.
129
+ 'require-accessible-name': (rule, el, document) => !accessibleName(el, document),
130
+ };
131
+
132
+ /** The predicate for one rule: `(element, document) => true` when that hit breaks it. */
133
+ export function ruleBreach(rule) {
134
+ const broken = BROKEN[rule.kind];
135
+ if (!broken) throw new Error(`unknown rule kind: ${rule.kind}`);
136
+ return (el, document) => broken(rule, el, document);
137
+ }
138
+
139
+ export function violationsIn(document, rules, compositeRoles, { fragmentSafe = false } = {}) {
140
+ const found = [];
141
+ for (const rule of rules) {
142
+ if (fragmentSafe && !rule.fragmentSafe) continue;
143
+ const breaks = ruleBreach(rule);
144
+ for (const el of document.querySelectorAll(expandedSelector(rule, compositeRoles))) {
145
+ if (breaks(el, document)) found.push({ rule, tag: el.outerHTML.slice(0, 90) });
146
+ }
147
+ }
148
+ return found;
149
+ }
150
+
151
+ /**
152
+ * The landmarks on one page that share a name.
153
+ *
154
+ * No semantics.json rule can state this: a name is only ambiguous relative to
155
+ * the other landmarks on the same page, and there is no fragment to write it
156
+ * against. Two <nav>s both called "Main" is a landmark menu with two identical
157
+ * rows, which is the problem labelling them was meant to solve. Shipped once.
158
+ */
159
+ function duplicateLandmarkNames(document) {
160
+ const seen = new Set();
161
+ const dupes = [];
162
+ for (const nav of document.querySelectorAll('nav')) {
163
+ const name = authoredName(nav, document);
164
+ if (!name) continue;
165
+ if (seen.has(name)) dupes.push(name);
166
+ seen.add(name);
167
+ }
168
+ return dupes;
169
+ }
170
+
171
+ /**
172
+ * One built page, checked as the document it is: every enforced rule, plus the
173
+ * landmark names, which are only ambiguous relative to each other.
174
+ *
175
+ * Parsed once, and as a document rather than a fragment - `<!doctype html>` and
176
+ * `<head>` survive, so a rule may be written against either.
177
+ */
178
+ export function checkPage(html, rules, compositeRoles) {
179
+ const document = parse(html);
180
+ return {
181
+ violations: violationsIn(document, rules, compositeRoles),
182
+ duplicateNames: duplicateLandmarkNames(document)
183
+ };
184
+ }