markuplint 5.2.0 → 5.3.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/CHANGELOG.md CHANGED
@@ -3,6 +3,23 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ # [5.3.0](https://github.com/markuplint/markuplint/compare/v5.2.0...v5.3.0) (2026-10-21)
7
+
8
+ ### Bug Fixes
9
+
10
+ * make published packages loadable from require() and put types first ([723106f](https://github.com/markuplint/markuplint/commit/723106f51aac4e60dc02db2c1a894617368b0253)), closes [#4198](https://github.com/markuplint/markuplint/issues/4198)
11
+ * **markuplint:** build the lint AST mapping only when debug is on ([abdbfeb](https://github.com/markuplint/markuplint/commit/abdbfeb9f88ca11e07b47c279a9d793cc46c47c2))
12
+ * **markuplint:** honor the debug option in the MLEngine constructor ([d33d3c8](https://github.com/markuplint/markuplint/commit/d33d3c87a1c8e0c04ee921945ed7488a51ab79c4)), closes [this.#options](https://github.com/this./issues/options)
13
+ * **markuplint:** match a suppression scope of the form tag[attr] with a capital or a digit ([372f313](https://github.com/markuplint/markuplint/commit/372f3138addfbd700b076871e38e1b4fd64e4430))
14
+ * **markuplint:** write and read a scope of a suppression for a name with a `.` ([62c0f3c](https://github.com/markuplint/markuplint/commit/62c0f3c451b45a74491b46be5dc645cbe8b60f18))
15
+ * **markuplint:** write and read the names of a scope as identifiers of CSS ([447fee3](https://github.com/markuplint/markuplint/commit/447fee3362774f273b045fa7996846aa4294360a))
16
+
17
+ ### Performance Improvements
18
+
19
+ * **markuplint:** group the engine debug calls under one guard ([15c0888](https://github.com/markuplint/markuplint/commit/15c088879400cf1aa0355c260c63a8eb3af4e86a))
20
+ * **markuplint:** skip debug maps without lint listeners ([205bff7](https://github.com/markuplint/markuplint/commit/205bff712bb4a7d42d92b6927ebef5eeb7401dd5))
21
+
22
+
6
23
  # [5.2.0](https://github.com/markuplint/markuplint/compare/v5.1.1...v5.2.0) (2026-10-13)
7
24
 
8
25
  ### Bug Fixes
@@ -6,6 +6,9 @@ import type { Document, RuleConfigValue } from '@markuplint/ml-core';
6
6
  import { ConfigProvider } from '@markuplint/file-resolver';
7
7
  import { Emitter } from 'strict-event-emitter';
8
8
  type MLEngineOptions = {
9
+ /**
10
+ * Enables verbose logging and populates the `debug` payload of the `lint` event.
11
+ */
9
12
  readonly debug?: boolean;
10
13
  readonly watch?: boolean;
11
14
  /**
@@ -132,7 +132,7 @@ export class MLEngine extends Emitter {
132
132
  #rerunAgain = false;
133
133
  constructor(file, options) {
134
134
  super();
135
- if (this.#options?.debug) {
135
+ if (options?.debug) {
136
136
  verbosely();
137
137
  }
138
138
  this.#file = file;
@@ -224,7 +224,10 @@ export class MLEngine extends Emitter {
224
224
  };
225
225
  }
226
226
  const { violations, fixedCode, fixSummary } = verifyResult;
227
- const debugMap = 'debugMap' in core.document ? core.document.debugMap() : null;
227
+ // The AST mapping is a debugging aid: the language server listens to every
228
+ // lint but only reads the payload when the user asked for debugging, so
229
+ // a listener alone is no reason to walk the whole document.
230
+ const debugMap = this.#options?.debug && this.listenerCount('lint') > 0 ? (this.document?.debugMap() ?? null) : null;
228
231
  const resolvedFixedCode = fixedCode ?? sourceCode;
229
232
  this.emit('lint', this.#file.path, sourceCode, violations, resolvedFixedCode, debugMap, fixSummary ?? null);
230
233
  log('exec: end');
@@ -333,9 +336,10 @@ export class MLEngine extends Emitter {
333
336
  async #createCore(fabric) {
334
337
  fileLog('Get source code');
335
338
  const sourceCode = await this.#file.getCode();
336
- fileLog('Source code path: %s', this.#file.path);
337
- // cspell: disable-next-line
338
- fileLog('Source code size: %dbyte', sourceCode.length);
339
+ if (fileLog.enabled) {
340
+ fileLog('Source code path: %s', this.#file.path);
341
+ fileLog('Source code size: %dbyte', sourceCode.length);
342
+ }
339
343
  this.emit('code', this.#file.path, sourceCode);
340
344
  const core = new MLCore({
341
345
  sourceCode,
@@ -522,10 +526,12 @@ export class MLEngine extends Emitter {
522
526
  }
523
527
  return this.#failConfig(error);
524
528
  }
525
- fileLog('Fetched Config files: %O', configSet.files);
526
- fileLog('Resolved Config: %O', configSet.config);
527
- fileLog('Resolved Plugins: %O', configSet.plugins);
528
- fileLog('Resolve Errors: %O', configSet.errs);
529
+ if (fileLog.enabled) {
530
+ fileLog('Fetched Config files: %O', configSet.files);
531
+ fileLog('Resolved Config: %O', configSet.config);
532
+ fileLog('Resolved Plugins: %O', configSet.plugins);
533
+ fileLog('Resolve Errors: %O', configSet.errs);
534
+ }
529
535
  const loadError = configSet.errs.find(error => error instanceof ConfigLoadError);
530
536
  if (loadError) {
531
537
  return this.#failConfig(loadError);
@@ -37,6 +37,13 @@ export type MLEngineEventMap = {
37
37
  sourceCode: string,
38
38
  violations: readonly Violation[],
39
39
  fixedCode: string,
40
+ /**
41
+ * The AST mapping dump. It is built only when the engine's `debug`
42
+ * option is on and a `lint` listener is registered at the time the
43
+ * engine emits, because the dump costs a full walk of the document
44
+ * that nobody reads unless debugging was requested. Otherwise, or when
45
+ * the engine has no parsed document, it is `null`.
46
+ */
40
47
  debug: readonly string[] | null,
41
48
  fixSummary: FixSummary | null
42
49
  ];
package/lib/debug.d.ts CHANGED
@@ -1,3 +1,7 @@
1
1
  import debug from 'debug';
2
+ /**
3
+ * Read `.enabled` at call time; never cache it at module load. `debug.enable()` (including via `verbosely()` /
4
+ * `enableDebug()`) can switch a namespace on after import.
5
+ */
2
6
  export declare const log: debug.Debugger;
3
7
  export declare function verbosely(): void;
package/lib/debug.js CHANGED
@@ -1,5 +1,9 @@
1
1
  import { enableDebug } from '@markuplint/ml-core';
2
2
  import debug from 'debug';
3
+ /**
4
+ * Read `.enabled` at call time; never cache it at module load. `debug.enable()` (including via `verbosely()` /
5
+ * `enableDebug()`) can switch a namespace on after import.
6
+ */
3
7
  export const log = debug('markuplint-cli');
4
8
  export function verbosely() {
5
9
  if (!log.enabled) {
package/lib/index.d.ts CHANGED
@@ -4,6 +4,12 @@
4
4
  * Main package entry point for markuplint.
5
5
  * Re-exports the public API surface including the lint engine, types,
6
6
  * internationalization utilities, testing tools, and version information.
7
+ *
8
+ * Invariant for every published package: the ESM-only build must stay
9
+ * loadable from CommonJS through `require()`. Node does that synchronously only
10
+ * while the static module graph has no top-level `await`, so none may be
11
+ * introduced. `packages/@markuplint/test-tools/src/package-exports.spec.ts`
12
+ * enforces it.
7
13
  */
8
14
  /** The primary linting engine and options for linting from source code strings. */
9
15
  export { MLEngine, FromCodeOptions } from './api/index.js';
package/lib/index.js CHANGED
@@ -4,6 +4,12 @@
4
4
  * Main package entry point for markuplint.
5
5
  * Re-exports the public API surface including the lint engine, types,
6
6
  * internationalization utilities, testing tools, and version information.
7
+ *
8
+ * Invariant for every published package: the ESM-only build must stay
9
+ * loadable from CommonJS through `require()`. Node does that synchronously only
10
+ * while the static module graph has no top-level `await`, so none may be
11
+ * introduced. `packages/@markuplint/test-tools/src/package-exports.spec.ts`
12
+ * enforces it.
7
13
  */
8
14
  /** The primary linting engine and options for linting from source code strings. */
9
15
  export { MLEngine } from './api/index.js';
@@ -1,28 +1,58 @@
1
- import { findNodeAtPosition } from './compute-scope.js';
1
+ import { findNodeAtPosition, splitScopeAttr, splitScopeIdents, splitScopeNth, unescapeIdentifier, } from './compute-scope.js';
2
2
  import { toRelativePath } from './suppressions-file.js';
3
3
  function isViolationInScope(
4
4
  // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
5
- nodeList, violation, scope) {
5
+ nodeList, violation, segments) {
6
6
  const violationNode = findNodeAtPosition(nodeList, violation.line, violation.col);
7
7
  if (!violationNode) {
8
8
  return false;
9
9
  }
10
10
  // Check the node itself and walk up ancestors
11
- if (matchesScopeSelector(violationNode, scope)) {
11
+ if (matchesScopeSelector(violationNode, segments)) {
12
12
  return true;
13
13
  }
14
14
  let current = violationNode.parentElement;
15
15
  while (current) {
16
- if (matchesScopeSelector(current, scope)) {
16
+ if (matchesScopeSelector(current, segments)) {
17
17
  return true;
18
18
  }
19
19
  current = current.parentElement;
20
20
  }
21
21
  return false;
22
22
  }
23
+ function parseSegment(segment) {
24
+ if (segment.length === 0) {
25
+ return null;
26
+ }
27
+ // #id
28
+ if (segment.startsWith('#')) {
29
+ return { kind: 'id', id: unescapeIdentifier(segment.slice(1)) };
30
+ }
31
+ // tag[attr="value"]: the tag is the `localName` with a capital letter (`FooBar`), a digit (`h1`) or an escaped character
32
+ // A segment with no `[` has no attribute, so it is not scanned for one
33
+ const attr = segment.includes('[') ? splitScopeAttr(segment) : null;
34
+ if (attr) {
35
+ return {
36
+ kind: 'attr',
37
+ tag: splitScopeIdents(attr.tag).join('.'),
38
+ attrName: attr.attrName,
39
+ attrValue: attr.attrValue,
40
+ };
41
+ }
42
+ // Extract :nth-of-type(n) if present and unescaped
43
+ const { base: baseSegment, nth } = segment.includes(':') ? splitScopeNth(segment) : { base: segment, nth: null };
44
+ // tag.classA.classB
45
+ // A `.` of a name is escaped (`Tabs\.Root`), so only an unescaped one starts a class.
46
+ const [tag, ...classes] = splitScopeIdents(baseSegment);
47
+ return {
48
+ kind: 'compound',
49
+ tag: tag,
50
+ classes,
51
+ nth,
52
+ };
53
+ }
23
54
  // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
24
- function matchesScopeSelector(node, scope) {
25
- const segments = scope.split(' > ');
55
+ function matchesScopeSelector(node, segments) {
26
56
  let current = node;
27
57
  // Match segments from right (most specific) to left (ancestors)
28
58
  for (let i = segments.length - 1; i >= 0; i--) {
@@ -38,49 +68,30 @@ function matchesScopeSelector(node, scope) {
38
68
  }
39
69
  // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
40
70
  function matchesSegment(node, segment) {
41
- if (segment.length === 0) {
71
+ if (segment === null) {
42
72
  return false;
43
73
  }
44
- // #id
45
- if (segment.startsWith('#')) {
46
- const id = segment.slice(1);
47
- return id.length > 0 && node.id === id;
74
+ if (segment.kind === 'id') {
75
+ return segment.id.length > 0 && node.id === segment.id;
48
76
  }
49
- // tag[attr="value"]
50
- const attrMatch = /^([a-z-]+)\[([a-z-]+)="([^"]+)"\]$/.exec(segment);
51
- if (attrMatch) {
52
- const [, tag, attrName, attrValue] = attrMatch;
53
- if (node.localName !== tag) {
77
+ if (segment.kind === 'attr') {
78
+ if (node.localName !== segment.tag) {
54
79
  return false;
55
80
  }
56
81
  for (const attr of node.attributes) {
57
- if (attr.name === attrName && attr.value === attrValue) {
82
+ if (attr.name === segment.attrName && attr.value === segment.attrValue) {
58
83
  return true;
59
84
  }
60
85
  }
61
86
  return false;
62
87
  }
63
- // Extract :nth-of-type(n) if present
64
- const nthMatch = /:nth-of-type\((\d+)\)$/.exec(segment);
65
- const baseSegment = nthMatch ? segment.slice(0, nthMatch.index) : segment;
66
- // tag.classA.classB
67
- const dotIndex = baseSegment.indexOf('.');
68
- let tagMatches;
69
- if (dotIndex === -1) {
70
- // tag only
71
- tagMatches = node.localName === baseSegment;
72
- }
73
- else {
74
- const tag = baseSegment.slice(0, dotIndex);
75
- const classes = baseSegment.slice(dotIndex + 1).split('.');
76
- tagMatches = node.localName === tag && classes.every(cls => node.classList.contains(cls));
77
- }
88
+ const tagMatches = node.localName === segment.tag && segment.classes.every(cls => node.classList.contains(cls));
78
89
  if (!tagMatches) {
79
90
  return false;
80
91
  }
81
92
  // Check nth-of-type position
82
- if (nthMatch) {
83
- const expectedPosition = Number.parseInt(nthMatch[1], 10);
93
+ if (segment.nth !== null) {
94
+ const expectedPosition = segment.nth;
84
95
  const parent = node.parentElement;
85
96
  if (!parent) {
86
97
  return false;
@@ -187,13 +198,17 @@ function getScopedErrorInfo(violations, ruleId, entry,
187
198
  nodeList) {
188
199
  let count = 0;
189
200
  const indices = [];
201
+ // The scope is read once, not once per violation.
202
+ // It is split at ` > ` without looking at escapes: `serializeIdentifier` writes every `>` of a name as `\>`
203
+ // (and a space as `\ `), so the only ` > ` of a scope is a separator.
204
+ const segments = entry.scope && nodeList ? entry.scope.split(' > ').map(segment => parseSegment(segment)) : undefined;
190
205
  for (const [i, violation] of violations.entries()) {
191
206
  const v = violation;
192
207
  if (v.severity !== 'error' || v.ruleId !== ruleId) {
193
208
  continue;
194
209
  }
195
- if (entry.scope && nodeList) {
196
- if (isViolationInScope(nodeList, v, entry.scope)) {
210
+ if (segments && nodeList) {
211
+ if (isViolationInScope(nodeList, v, segments)) {
197
212
  count++;
198
213
  indices.push(i);
199
214
  }
@@ -51,6 +51,65 @@ export interface PositionedNode extends ScopedNode {
51
51
  readonly startLine: number;
52
52
  readonly startCol: number;
53
53
  }
54
+ /**
55
+ * @experimental
56
+ * Writes a tag name, a class name or an id into a scope, by the CSSOM algorithm "serialize an identifier"
57
+ * (the one `CSS.escape()` is specified by), in which a code point that is not an ident code point of CSS
58
+ * Syntax is escaped, so the output is always one identifier. A NUL is written as U+FFFD, as the algorithm
59
+ * has it, so it does not read back as NUL. A scope is a CSS selector, and CSS lets any code point be part
60
+ * of an identifier by escaping it, "even ones that CSS uses in its syntax". So `Tabs.Root` (a member tag of JSX, MDX or Svelte) is `Tabs\.Root` and not the element
61
+ * `Tabs` with the class `Root`, `py-1.5` is `py-1\.5`, `hover:bg-x` is `hover\:bg-x`, `2xl:flex` is
62
+ * `\32 xl\:flex`, and a class that ends with `:nth-of-type(2)` is no position. What this writes and what
63
+ * {@link unescapeIdentifier} reads are one form, so a scope matches the element it was made for.
64
+ *
65
+ * A scope file written before the escape holds names unescaped, and they still read as the syntax,
66
+ * because every scope that ever matched was written so (`unescapeIdentifier` leaves a character that is
67
+ * not escaped as it is). A scope for a name with a `.` never matched, except in the form
68
+ * `tag[attr="value"]`, whose tag has always been read as a name. Likewise a scope for a name that ends
69
+ * with `:nth-of-type(n)` (`div.a:nth-of-type(2)`) never matched, and is still read as a position. A `\`
70
+ * that such a file holds is read as CSS reads it: `\ba` is the hex escape U+00BA.
71
+ *
72
+ * @see https://drafts.csswg.org/cssom/#serialize-an-identifier
73
+ */
74
+ export declare function serializeIdentifier(name: string): string;
75
+ /**
76
+ * @experimental
77
+ * Reads an identifier of a scope, the reading side of {@link serializeIdentifier}: the escapes of CSS
78
+ * (`\` and 1 to 6 hex digits with one whitespace, or `\` and any other character), and a character that
79
+ * is not escaped as it is.
80
+ */
81
+ export declare function unescapeIdentifier(escaped: string): string;
82
+ /**
83
+ * @experimental
84
+ * Splits a compound scope segment (`tag.classA.classB`) at its unescaped `.` and unescapes each part,
85
+ * the reading side of {@link generateUniqueSelector}. The first part is the tag, the rest are classes.
86
+ */
87
+ export declare function splitScopeIdents(segment: string): string[];
88
+ /**
89
+ * @experimental
90
+ * Splits the form `tag[attr="value"]`. The tag is what comes before the first `[` that is not escaped,
91
+ * and it holds no unescaped `]` or `"`, so `Foo\[x="y"]` is no attribute: its `[` is escaped.
92
+ * The tag is returned as it is written.
93
+ */
94
+ export declare function splitScopeAttr(segment: string): {
95
+ readonly tag: string;
96
+ readonly attrName: string;
97
+ readonly attrValue: string;
98
+ } | null;
99
+ /**
100
+ * @experimental
101
+ * Splits the position `:nth-of-type(n)` that ends a compound scope segment from the rest of it.
102
+ * The `:` of the suffix is a position only when it is not escaped, which a regex on the end of the
103
+ * segment cannot tell alone: a name that ends with `\` is written `\\`, so `a\\:nth-of-type(2)` is the
104
+ * class `a\` at the position 2, and `a\:nth-of-type(2)` is the class `a:nth-of-type(2)`. The escapes
105
+ * are scanned from the start, as {@link splitScopeIdents} does.
106
+ *
107
+ * @see https://www.w3.org/TR/css-syntax-3/#escaping
108
+ */
109
+ export declare function splitScopeNth(segment: string): {
110
+ readonly base: string;
111
+ readonly nth: number | null;
112
+ };
54
113
  export declare function getAncestorChain(node: ScopedNode): ScopedNode[];
55
114
  /**
56
115
  * @experimental
@@ -21,6 +21,190 @@
21
21
  * @see https://github.com/markuplint/markuplint/issues/3509
22
22
  */
23
23
  const BODY_HTML_TAGS = new Set(['body', 'html']);
24
+ /**
25
+ * The position that ends a segment of a scope: `:nth-of-type(n)`.
26
+ * A name never ends with it unescaped, because the `:` and the `()` of a name are written escaped
27
+ * ({@link serializeIdentifier}), so what ends a segment so is the position.
28
+ */
29
+ const NTH_OF_TYPE = /^:nth-of-type\((\d+)\)$/;
30
+ /**
31
+ * The non-ASCII ident code points of CSS, inclusive: the same as HTML's valid custom element names.
32
+ * U+200C and U+200D (zero width joiners) are the other two, and are checked apart: a joiner in a character
33
+ * class makes the linter read the class as a joined character sequence.
34
+ * @see https://www.w3.org/TR/css-syntax-3/#non-ascii-ident-code-point
35
+ */
36
+ const NON_ASCII_IDENT_CODE_POINT = /^[\u00B7\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u037D\u037F-\u1FFF\u203F\u2040\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\u{10000}-\u{10FFFF}]$/u;
37
+ /**
38
+ * @experimental
39
+ * Writes a tag name, a class name or an id into a scope, by the CSSOM algorithm "serialize an identifier"
40
+ * (the one `CSS.escape()` is specified by), in which a code point that is not an ident code point of CSS
41
+ * Syntax is escaped, so the output is always one identifier. A NUL is written as U+FFFD, as the algorithm
42
+ * has it, so it does not read back as NUL. A scope is a CSS selector, and CSS lets any code point be part
43
+ * of an identifier by escaping it, "even ones that CSS uses in its syntax". So `Tabs.Root` (a member tag of JSX, MDX or Svelte) is `Tabs\.Root` and not the element
44
+ * `Tabs` with the class `Root`, `py-1.5` is `py-1\.5`, `hover:bg-x` is `hover\:bg-x`, `2xl:flex` is
45
+ * `\32 xl\:flex`, and a class that ends with `:nth-of-type(2)` is no position. What this writes and what
46
+ * {@link unescapeIdentifier} reads are one form, so a scope matches the element it was made for.
47
+ *
48
+ * A scope file written before the escape holds names unescaped, and they still read as the syntax,
49
+ * because every scope that ever matched was written so (`unescapeIdentifier` leaves a character that is
50
+ * not escaped as it is). A scope for a name with a `.` never matched, except in the form
51
+ * `tag[attr="value"]`, whose tag has always been read as a name. Likewise a scope for a name that ends
52
+ * with `:nth-of-type(n)` (`div.a:nth-of-type(2)`) never matched, and is still read as a position. A `\`
53
+ * that such a file holds is read as CSS reads it: `\ba` is the hex escape U+00BA.
54
+ *
55
+ * @see https://drafts.csswg.org/cssom/#serialize-an-identifier
56
+ */
57
+ export function serializeIdentifier(name) {
58
+ let result = '';
59
+ let index = 0;
60
+ for (const char of name) {
61
+ const isDigit = char >= '0' && char <= '9';
62
+ if (char === '\u0000') {
63
+ result += '\uFFFD';
64
+ }
65
+ else if (
66
+ // U+0001 to U+001F and U+007F
67
+ (char.codePointAt(0) >= 1 && char.codePointAt(0) <= 31) ||
68
+ char.codePointAt(0) === 127 ||
69
+ (index === 0 && isDigit) ||
70
+ (index === 1 && isDigit && name.startsWith('-'))) {
71
+ result += `\\${char.codePointAt(0).toString(16)} `;
72
+ }
73
+ else if (index === 0 && char === '-' && name.length === 1) {
74
+ result += String.raw `\-`;
75
+ }
76
+ else if (char === '-' ||
77
+ char === '_' ||
78
+ isDigit ||
79
+ /^[a-z]$/i.test(char) ||
80
+ NON_ASCII_IDENT_CODE_POINT.test(char) ||
81
+ char === '\u200C' ||
82
+ char === '\u200D') {
83
+ result += char;
84
+ }
85
+ else {
86
+ result += `\\${char}`;
87
+ }
88
+ index++;
89
+ }
90
+ return result;
91
+ }
92
+ /** 1 to 6 hex digits of an escape, and the one whitespace that ends them. Sticky: it matches at `lastIndex`. */
93
+ const HEX_ESCAPE = /[\dA-F]{1,6}(?:\r\n|[\t\n\f\r ])?/iy;
94
+ /**
95
+ * The one scanner of a scope segment, which {@link unescapeIdentifier}, {@link splitScopeIdents},
96
+ * {@link splitScopeAttr} and {@link splitScopeNth} read with: yields each character (an escape is one
97
+ * character) with the index it starts at, and whether a `\` escaped it. The escapes are the ones of CSS.
98
+ *
99
+ * @see https://www.w3.org/TR/css-syntax-3/#consume-escaped-code-point
100
+ */
101
+ function* scanScope(segment) {
102
+ for (let i = 0; i < segment.length; i++) {
103
+ const char = segment[i];
104
+ const next = segment[i + 1];
105
+ if (char !== '\\' || next === '\n' || next === '\r' || next === '\f') {
106
+ // A `\` before a newline is no escape
107
+ yield { index: i, char, escaped: false };
108
+ continue;
109
+ }
110
+ if (next === undefined) {
111
+ yield { index: i, char: '\uFFFD', escaped: true };
112
+ continue;
113
+ }
114
+ HEX_ESCAPE.lastIndex = i + 1;
115
+ const hex = HEX_ESCAPE.exec(segment);
116
+ if (hex) {
117
+ const codePoint = Number.parseInt(hex[0], 16);
118
+ // Zero, a surrogate (U+D800 to U+DFFF) and a value over U+10FFFF are U+FFFD
119
+ const isValid = codePoint !== 0 && !(codePoint >= 55_296 && codePoint <= 57_343) && codePoint <= 1_114_111;
120
+ yield { index: i, char: isValid ? String.fromCodePoint(codePoint) : '\uFFFD', escaped: true };
121
+ i += hex[0].length;
122
+ continue;
123
+ }
124
+ const escapedChar = String.fromCodePoint(segment.codePointAt(i + 1));
125
+ yield { index: i, char: escapedChar, escaped: true };
126
+ i += escapedChar.length;
127
+ }
128
+ }
129
+ /**
130
+ * @experimental
131
+ * Reads an identifier of a scope, the reading side of {@link serializeIdentifier}: the escapes of CSS
132
+ * (`\` and 1 to 6 hex digits with one whitespace, or `\` and any other character), and a character that
133
+ * is not escaped as it is.
134
+ */
135
+ export function unescapeIdentifier(escaped) {
136
+ let result = '';
137
+ for (const { char } of scanScope(escaped)) {
138
+ result += char;
139
+ }
140
+ return result;
141
+ }
142
+ /**
143
+ * @experimental
144
+ * Splits a compound scope segment (`tag.classA.classB`) at its unescaped `.` and unescapes each part,
145
+ * the reading side of {@link generateUniqueSelector}. The first part is the tag, the rest are classes.
146
+ */
147
+ export function splitScopeIdents(segment) {
148
+ const parts = [''];
149
+ for (const { char, escaped } of scanScope(segment)) {
150
+ if (!escaped && char === '.') {
151
+ parts.push('');
152
+ }
153
+ else {
154
+ parts[parts.length - 1] += char;
155
+ }
156
+ }
157
+ return parts;
158
+ }
159
+ /**
160
+ * @experimental
161
+ * Splits the form `tag[attr="value"]`. The tag is what comes before the first `[` that is not escaped,
162
+ * and it holds no unescaped `]` or `"`, so `Foo\[x="y"]` is no attribute: its `[` is escaped.
163
+ * The tag is returned as it is written.
164
+ */
165
+ export function splitScopeAttr(segment) {
166
+ for (const { index, char, escaped } of scanScope(segment)) {
167
+ if (escaped) {
168
+ continue;
169
+ }
170
+ if (char === ']' || char === '"') {
171
+ return null;
172
+ }
173
+ if (char === '[') {
174
+ const match = /^\[([a-z-]+)="([^"]+)"\]$/.exec(segment.slice(index));
175
+ return index > 0 && match
176
+ ? { tag: segment.slice(0, index), attrName: match[1], attrValue: match[2] }
177
+ : null;
178
+ }
179
+ }
180
+ return null;
181
+ }
182
+ /**
183
+ * @experimental
184
+ * Splits the position `:nth-of-type(n)` that ends a compound scope segment from the rest of it.
185
+ * The `:` of the suffix is a position only when it is not escaped, which a regex on the end of the
186
+ * segment cannot tell alone: a name that ends with `\` is written `\\`, so `a\\:nth-of-type(2)` is the
187
+ * class `a\` at the position 2, and `a\:nth-of-type(2)` is the class `a:nth-of-type(2)`. The escapes
188
+ * are scanned from the start, as {@link splitScopeIdents} does.
189
+ *
190
+ * @see https://www.w3.org/TR/css-syntax-3/#escaping
191
+ */
192
+ export function splitScopeNth(segment) {
193
+ // Only an unescaped `:` is looked at: an escaped one is read with its `\` before it, as one character
194
+ for (const { index, char, escaped } of scanScope(segment)) {
195
+ if (!escaped && char === ':') {
196
+ const match = NTH_OF_TYPE.exec(segment.slice(index));
197
+ if (match) {
198
+ return { base: segment.slice(0, index), nth: Number.parseInt(match[1], 10) };
199
+ }
200
+ }
201
+ }
202
+ return { base: segment, nth: null };
203
+ }
204
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
205
+ function tagWithClasses(node, classes) {
206
+ return [node.localName, ...classes].map(name => serializeIdentifier(name)).join('.');
207
+ }
24
208
  // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
25
209
  export function getAncestorChain(node) {
26
210
  const chain = [];
@@ -97,12 +281,12 @@ export function generateUniqueSelector(node) {
97
281
  }
98
282
  // If this node has an id, just use it
99
283
  if (node.id) {
100
- return `#${node.id}`;
284
+ return `#${serializeIdentifier(node.id)}`;
101
285
  }
102
286
  // If this node has classes, use tag.classA.classB (all classes)
103
287
  const classes = [...node.classList];
104
288
  if (classes.length > 0) {
105
- return `${node.localName}.${classes.join('.')}`;
289
+ return tagWithClasses(node, classes);
106
290
  }
107
291
  // If this node has a distinguishing attribute (role, or type for input),
108
292
  // use tag[attr="value"] — more readable than nth-of-type and more stable
@@ -116,12 +300,12 @@ export function generateUniqueSelector(node) {
116
300
  let current = node.parentElement;
117
301
  while (current && !BODY_HTML_TAGS.has(current.localName)) {
118
302
  if (current.id) {
119
- parts.unshift(`#${current.id}`);
303
+ parts.unshift(`#${serializeIdentifier(current.id)}`);
120
304
  break;
121
305
  }
122
306
  const currentClasses = [...current.classList];
123
307
  if (currentClasses.length > 0) {
124
- parts.unshift(`${current.localName}.${currentClasses.join('.')}`);
308
+ parts.unshift(tagWithClasses(current, currentClasses));
125
309
  break;
126
310
  }
127
311
  const currentAttr = getDistinguishingAttrSelector(current);
@@ -150,8 +334,9 @@ function getDistinguishingAttrSelector(node) {
150
334
  const attrsToCheck = [...universalAttrs, ...elementAttrs];
151
335
  for (const attrName of attrsToCheck) {
152
336
  for (const attr of node.attributes) {
153
- if (attr.name === attrName && attr.value) {
154
- return `${node.localName}[${attrName}="${attr.value}"]`;
337
+ // A value with `"` or ` > ` cannot be held by `tag[attr="value"]`: a scope ends the value at `"` and splits at ` > `.
338
+ if (attr.name === attrName && attr.value && !attr.value.includes('"') && !attr.value.includes(' > ')) {
339
+ return `${serializeIdentifier(node.localName)}[${attrName}="${attr.value}"]`;
155
340
  }
156
341
  }
157
342
  }
@@ -160,8 +345,9 @@ function getDistinguishingAttrSelector(node) {
160
345
  // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
161
346
  function buildSegmentWithNth(node) {
162
347
  const parent = node.parentElement;
348
+ const name = serializeIdentifier(node.localName);
163
349
  if (!parent) {
164
- return node.localName;
350
+ return name;
165
351
  }
166
352
  // Count same-tag siblings and find this node's position
167
353
  let sameTagCount = 0;
@@ -175,9 +361,9 @@ function buildSegmentWithNth(node) {
175
361
  }
176
362
  }
177
363
  if (sameTagCount > 1) {
178
- return `${node.localName}:nth-of-type(${position})`;
364
+ return `${name}:nth-of-type(${position})`;
179
365
  }
180
- return node.localName;
366
+ return name;
181
367
  }
182
368
  /**
183
369
  * @experimental
@@ -94,6 +94,6 @@ _nodeList) {
94
94
  // fall back to file-level filtering (treats all violations as in-scope).
95
95
  // This is the safe direction — more violations may be downgraded than intended,
96
96
  // but no new violations are hidden.
97
- // TODO(#3536): Export isViolationInScope from apply-suppressions.ts and reuse here.
97
+ // TODO(#3536): Export the scope matching of apply-suppressions.ts (`parseSegment` and `isViolationInScope`) and reuse here.
98
98
  return group;
99
99
  }
@@ -10,6 +10,9 @@ export type SuppressionEntry = {
10
10
  * CSS selector identifying the LCA (Lowest Common Ancestor) subtree
11
11
  * that contains all violations for this rule. Uses id/class/attr for
12
12
  * precise matching. When absent, suppression applies to the entire file.
13
+ *
14
+ * A `.`, `#`, `[` or `\` in a tag name or a class name is escaped with a `\` (`Tabs\.Root`),
15
+ * as CSS does. A scope written before the escape has none, and is read as it was written.
13
16
  */
14
17
  readonly scope?: string;
15
18
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "markuplint",
3
- "version": "5.2.0",
3
+ "version": "5.3.0",
4
4
  "description": "An HTML linter for all markup developers",
5
5
  "author": "Yusuke Hirao",
6
6
  "license": "MIT",
@@ -15,11 +15,10 @@
15
15
  "type": "module",
16
16
  "exports": {
17
17
  ".": {
18
- "import": "./lib/index.js",
19
- "types": "./lib/index.d.ts"
18
+ "types": "./lib/index.d.ts",
19
+ "default": "./lib/index.js"
20
20
  },
21
21
  "./suppressions": {
22
- "import": "./lib/suppressions/index.js",
23
22
  "types": "./lib/suppressions/index.d.ts",
24
23
  "default": "./lib/suppressions/index.js"
25
24
  },
@@ -35,17 +34,17 @@
35
34
  "clean": "tsc --build --clean tsconfig.build.json"
36
35
  },
37
36
  "dependencies": {
38
- "@markuplint/cli-utils": "5.2.0",
39
- "@markuplint/file-resolver": "5.2.0",
40
- "@markuplint/html-parser": "5.2.0",
41
- "@markuplint/html-spec": "5.2.0",
42
- "@markuplint/i18n": "5.1.1",
43
- "@markuplint/ml-ast": "5.1.1",
44
- "@markuplint/ml-config": "5.2.0",
45
- "@markuplint/ml-core": "5.2.0",
46
- "@markuplint/ml-spec": "5.2.0",
47
- "@markuplint/rules": "5.2.0",
48
- "@markuplint/shared": "5.2.0",
37
+ "@markuplint/cli-utils": "5.3.0",
38
+ "@markuplint/file-resolver": "5.3.0",
39
+ "@markuplint/html-parser": "5.3.0",
40
+ "@markuplint/html-spec": "5.3.0",
41
+ "@markuplint/i18n": "5.3.0",
42
+ "@markuplint/ml-ast": "5.3.0",
43
+ "@markuplint/ml-config": "5.3.0",
44
+ "@markuplint/ml-core": "5.3.0",
45
+ "@markuplint/ml-spec": "5.3.0",
46
+ "@markuplint/rules": "5.3.0",
47
+ "@markuplint/shared": "5.3.0",
49
48
  "@types/debug": "^4.1.13",
50
49
  "chokidar": "^5.0.0",
51
50
  "debug": "^4.4.3",
@@ -55,5 +54,5 @@
55
54
  "strip-ansi": "^7.2.0",
56
55
  "type-fest": "^5.10.0"
57
56
  },
58
- "gitHead": "d8b2feab77c9c862513615bea6b80d2817431878"
57
+ "gitHead": "f587cf0206c8426e57369a48d6d5fb22306eafde"
59
58
  }