@markuplint/parser-utils 5.4.0 → 5.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/CHANGELOG.md CHANGED
@@ -3,6 +3,17 @@
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.5.0](https://github.com/markuplint/markuplint/compare/v5.4.0...v5.5.0) (2026-11-10)
7
+
8
+ ### Bug Fixes
9
+
10
+ * **parser-utils:** skip a leading BOM before tokenization ([9fa8c85](https://github.com/markuplint/markuplint/commit/9fa8c8584b765667530e1d7224530ff7ce37474d)), closes [#4488](https://github.com/markuplint/markuplint/issues/4488)
11
+
12
+ ### Features
13
+
14
+ * **parser-utils:** read a literal in the code of an attribute value ([d83b4a0](https://github.com/markuplint/markuplint/commit/d83b4a091b1e78975ab12b81be7096356a95e9c0))
15
+
16
+
6
17
  # [5.4.0](https://github.com/markuplint/markuplint/compare/v5.3.0...v5.4.0) (2026-10-29)
7
18
 
8
19
  **Note:** Version bump only for package @markuplint/parser-utils
@@ -402,8 +402,8 @@ const idlContentMap = {
402
402
  * PerformanceElementTiming API
403
403
  *
404
404
  * @experimental
405
- * @see https://wicg.github.io/element-timing/#sec-modifications-DOM
406
- * @see https://wicg.github.io/element-timing/#sec-elements-exposed
405
+ * @see https://w3c.github.io/element-timing/#sec-modifications-DOM
406
+ * @see https://w3c.github.io/element-timing/#sec-elements-exposed
407
407
  */
408
408
  elementTiming: 'elementtiming',
409
409
  /**
package/lib/index.d.ts CHANGED
@@ -3,6 +3,8 @@ export * from './decision.js';
3
3
  export * from './enums.js';
4
4
  export * from './get-namespace.js';
5
5
  export * from './idl-attributes.js';
6
+ export * from './is-braced-expression.js';
7
+ export * from './parse-literal-expression.js';
6
8
  export * from './parser-error.js';
7
9
  export * from './parser.js';
8
10
  export * from './script-parser.js';
package/lib/index.js CHANGED
@@ -3,6 +3,8 @@ export * from './decision.js';
3
3
  export * from './enums.js';
4
4
  export * from './get-namespace.js';
5
5
  export * from './idl-attributes.js';
6
+ export * from './is-braced-expression.js';
7
+ export * from './parse-literal-expression.js';
6
8
  export * from './parser-error.js';
7
9
  export * from './parser.js';
8
10
  export * from './script-parser.js';
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Whether a text is one expression in braces and nothing else (`{false}`, `{ {a: 1} }`), and not
3
+ * several (`{a}{b}`) or an expression with text around it (`page/{p}`, `{a} text`). Svelte reads an
4
+ * attribute value in quotes that is one expression as the expression
5
+ * (`disabled="{false}"` is `disabled={false}`).
6
+ *
7
+ * The check is of the braces: the first `{` is closed by the last `}`, so no `}` closes it before
8
+ * the end. Strings and template literals are read as such, so a brace in them does not count. The
9
+ * code in the braces is not checked to be valid JavaScript (it may be TypeScript).
10
+ *
11
+ * The catch below does not use `isFatalError`, as for `parseLiteralExpression`: espree reports the
12
+ * author's code by a `SyntaxError`, which here means that the text is not one expression.
13
+ *
14
+ * @param raw - The text of an attribute value
15
+ * @returns `true` if the whole text is one expression in braces
16
+ */
17
+ export declare function isBracedExpression(raw: string): boolean;
@@ -0,0 +1,52 @@
1
+ import { scriptParser } from './script-parser.js';
2
+ /**
3
+ * Whether a text is one expression in braces and nothing else (`{false}`, `{ {a: 1} }`), and not
4
+ * several (`{a}{b}`) or an expression with text around it (`page/{p}`, `{a} text`). Svelte reads an
5
+ * attribute value in quotes that is one expression as the expression
6
+ * (`disabled="{false}"` is `disabled={false}`).
7
+ *
8
+ * The check is of the braces: the first `{` is closed by the last `}`, so no `}` closes it before
9
+ * the end. Strings and template literals are read as such, so a brace in them does not count. The
10
+ * code in the braces is not checked to be valid JavaScript (it may be TypeScript).
11
+ *
12
+ * The catch below does not use `isFatalError`, as for `parseLiteralExpression`: espree reports the
13
+ * author's code by a `SyntaxError`, which here means that the text is not one expression.
14
+ *
15
+ * @param raw - The text of an attribute value
16
+ * @returns `true` if the whole text is one expression in braces
17
+ */
18
+ export function isBracedExpression(raw) {
19
+ if (raw.length < 2 || !raw.startsWith('{') || !raw.endsWith('}')) {
20
+ return false;
21
+ }
22
+ const inner = raw.slice(1, -1);
23
+ if (inner.trim() === '') {
24
+ return false;
25
+ }
26
+ let tokens;
27
+ try {
28
+ tokens = scriptParser(inner);
29
+ }
30
+ catch (error) {
31
+ if (error instanceof SyntaxError) {
32
+ return false;
33
+ }
34
+ throw error;
35
+ }
36
+ let depth = 0;
37
+ for (const token of tokens) {
38
+ if (token.type !== 'Punctuator') {
39
+ continue;
40
+ }
41
+ if (token.value === '{') {
42
+ depth++;
43
+ }
44
+ else if (token.value === '}') {
45
+ depth--;
46
+ if (depth < 0) {
47
+ return false;
48
+ }
49
+ }
50
+ }
51
+ return depth === 0;
52
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * A JavaScript expression that is nothing but a literal, as {@link parseLiteralExpression} reads it.
3
+ */
4
+ export type LiteralExpression = {
5
+ readonly type: 'string';
6
+ readonly value: string;
7
+ readonly key: string;
8
+ } | {
9
+ readonly type: 'number';
10
+ readonly value: number;
11
+ readonly key: string;
12
+ } | {
13
+ readonly type: 'boolean';
14
+ readonly value: boolean;
15
+ readonly key: 'true' | 'false';
16
+ } | {
17
+ readonly type: 'null';
18
+ readonly value: null;
19
+ readonly key: 'null';
20
+ } | {
21
+ readonly type: 'undefined';
22
+ readonly value: undefined;
23
+ readonly key: 'undefined';
24
+ };
25
+ /**
26
+ * Reads a JavaScript expression and tells whether it is nothing but a literal: a string, a template
27
+ * literal without a substitution, a number, `true`, `false`, `null` or `undefined`, with a leading
28
+ * minus sign for a number, any number of balanced parentheses, and a TypeScript `as` / `satisfies`
29
+ * assertion after it. An identifier, a call, an object, an array, a template literal with a
30
+ * substitution, a regular expression, an operator, or `NaN` is not.
31
+ *
32
+ * The result carries the value that the code evaluates to, and `key`, the normal form of the literal:
33
+ * two literals of one value and one type have the same key however they are written (`'x'`, `"x"`
34
+ * and `` `x` ``; `0x0`, `-0` and `0`), and literals that differ in value or in type do not
35
+ * (`true` and `"true"`, `0` and `"0"` and `false`). A string's key is its JSON, a number's key is
36
+ * `String(value)`, and the rest are their keyword. The keys are those of `literalValues` of a spec.
37
+ *
38
+ * The code is expected to be an expression that a parser has already read, but a template language
39
+ * hands over whatever its author wrote (a Vue `:x="a b"`), so a code that is not JavaScript is
40
+ * `null` as well.
41
+ *
42
+ * JavaScript is parsed once and its tree says whether it is a literal. What only TypeScript has (`as` and
43
+ * `satisfies` after the literal) is not JavaScript, so a code that does not parse is read by its tokens
44
+ * ({@link parseLiteralByTokens}), which give the same answer for the code that does parse.
45
+ *
46
+ * The catches in this file do not use `isFatalError`: espree reports the code that it is given, which is
47
+ * the author's own, by a `SyntaxError`, and that is the answer "not a literal" here. Anything else is
48
+ * thrown again.
49
+ *
50
+ * @param code - The text of the expression, without the braces or quotes around it
51
+ * @returns The literal, or `null` when the code is not a literal
52
+ */
53
+ export declare function parseLiteralExpression(code: string): LiteralExpression | null;
54
+ /**
55
+ * Reads the code by its tokens: any number of `(`, one literal, any number of `)`, and a TypeScript
56
+ * `as` / `satisfies` assertion after it, which a parser of JavaScript does not read.
57
+ *
58
+ * It is what {@link parseLiteralExpression} falls back on, and is exported to be compared with the reading
59
+ * of the tree.
60
+ *
61
+ * @param code - The text of the expression
62
+ * @returns The literal, or `null` when the code is not a literal
63
+ */
64
+ export declare function parseLiteralByTokens(code: string): LiteralExpression | null;
@@ -0,0 +1,300 @@
1
+ // @ts-ignore
2
+ import { parse } from 'espree';
3
+ import { scriptParser } from './script-parser.js';
4
+ /**
5
+ * The characters a literal can start with: a quote, a digit, a dot (`.5`), a minus sign, an opening
6
+ * parenthesis, and the first letters of `true` / `false` / `null` / `undefined`. An expression that
7
+ * starts with anything else cannot be a literal, which spares the tokenizer for the many that are
8
+ * identifiers or calls.
9
+ */
10
+ const LITERAL_START = /^\s*["'`\d.\-(tfnu]/;
11
+ /**
12
+ * Reads a JavaScript expression and tells whether it is nothing but a literal: a string, a template
13
+ * literal without a substitution, a number, `true`, `false`, `null` or `undefined`, with a leading
14
+ * minus sign for a number, any number of balanced parentheses, and a TypeScript `as` / `satisfies`
15
+ * assertion after it. An identifier, a call, an object, an array, a template literal with a
16
+ * substitution, a regular expression, an operator, or `NaN` is not.
17
+ *
18
+ * The result carries the value that the code evaluates to, and `key`, the normal form of the literal:
19
+ * two literals of one value and one type have the same key however they are written (`'x'`, `"x"`
20
+ * and `` `x` ``; `0x0`, `-0` and `0`), and literals that differ in value or in type do not
21
+ * (`true` and `"true"`, `0` and `"0"` and `false`). A string's key is its JSON, a number's key is
22
+ * `String(value)`, and the rest are their keyword. The keys are those of `literalValues` of a spec.
23
+ *
24
+ * The code is expected to be an expression that a parser has already read, but a template language
25
+ * hands over whatever its author wrote (a Vue `:x="a b"`), so a code that is not JavaScript is
26
+ * `null` as well.
27
+ *
28
+ * JavaScript is parsed once and its tree says whether it is a literal. What only TypeScript has (`as` and
29
+ * `satisfies` after the literal) is not JavaScript, so a code that does not parse is read by its tokens
30
+ * ({@link parseLiteralByTokens}), which give the same answer for the code that does parse.
31
+ *
32
+ * The catches in this file do not use `isFatalError`: espree reports the code that it is given, which is
33
+ * the author's own, by a `SyntaxError`, and that is the answer "not a literal" here. Anything else is
34
+ * thrown again.
35
+ *
36
+ * @param code - The text of the expression, without the braces or quotes around it
37
+ * @returns The literal, or `null` when the code is not a literal
38
+ */
39
+ export function parseLiteralExpression(code) {
40
+ if (!LITERAL_START.test(code)) {
41
+ return null;
42
+ }
43
+ const fromTree = parseLiteralByTree(code);
44
+ return fromTree === undefined ? parseLiteralByTokens(code) : fromTree;
45
+ }
46
+ /**
47
+ * Reads the code as a JavaScript expression, in parentheses so that an object literal or a function is an
48
+ * expression too, and a line comment at the end does not take the parenthesis.
49
+ *
50
+ * @returns The literal, `null` when the code is JavaScript and is not a literal, or `undefined` when it is
51
+ * not JavaScript
52
+ */
53
+ function parseLiteralByTree(code) {
54
+ let program;
55
+ try {
56
+ program = parse(`(${code}\n)`, { ecmaVersion: 'latest' });
57
+ }
58
+ catch (error) {
59
+ if (error instanceof SyntaxError) {
60
+ return undefined;
61
+ }
62
+ throw error;
63
+ }
64
+ const statement = program.body.length === 1 ? program.body[0] : null;
65
+ return statement?.type === 'ExpressionStatement' ? literalOfNode(statement.expression) : null;
66
+ }
67
+ function literalOfNode(node) {
68
+ switch (node.type) {
69
+ case 'Literal': {
70
+ return literalOfValue(node, false);
71
+ }
72
+ case 'UnaryExpression': {
73
+ return node.operator === '-' && node.argument.type === 'Literal'
74
+ ? literalOfValue(node.argument, true)
75
+ : null;
76
+ }
77
+ case 'TemplateLiteral': {
78
+ const cooked = node.expressions.length === 0 ? node.quasis[0]?.value.cooked : undefined;
79
+ return typeof cooked === 'string' ? stringLiteral(cooked) : null;
80
+ }
81
+ case 'Identifier': {
82
+ return node.name === 'undefined' ? { type: 'undefined', value: undefined, key: 'undefined' } : null;
83
+ }
84
+ default: {
85
+ return null;
86
+ }
87
+ }
88
+ }
89
+ function literalOfValue(node, negative) {
90
+ const { value } = node;
91
+ if (node.regex !== undefined) {
92
+ return null;
93
+ }
94
+ if (typeof value === 'number' || typeof value === 'bigint') {
95
+ return numberLiteral(value, negative);
96
+ }
97
+ if (negative) {
98
+ return null;
99
+ }
100
+ switch (typeof value) {
101
+ case 'string': {
102
+ return stringLiteral(value);
103
+ }
104
+ case 'boolean': {
105
+ return value
106
+ ? { type: 'boolean', value: true, key: 'true' }
107
+ : { type: 'boolean', value: false, key: 'false' };
108
+ }
109
+ default: {
110
+ return value === null ? { type: 'null', value: null, key: 'null' } : null;
111
+ }
112
+ }
113
+ }
114
+ /**
115
+ * Reads the code by its tokens: any number of `(`, one literal, any number of `)`, and a TypeScript
116
+ * `as` / `satisfies` assertion after it, which a parser of JavaScript does not read.
117
+ *
118
+ * It is what {@link parseLiteralExpression} falls back on, and is exported to be compared with the reading
119
+ * of the tree.
120
+ *
121
+ * @param code - The text of the expression
122
+ * @returns The literal, or `null` when the code is not a literal
123
+ */
124
+ export function parseLiteralByTokens(code) {
125
+ let tokens;
126
+ try {
127
+ tokens = scriptParser(code);
128
+ }
129
+ catch (error) {
130
+ if (error instanceof SyntaxError) {
131
+ return null;
132
+ }
133
+ throw error;
134
+ }
135
+ let index = 0;
136
+ let opened = 0;
137
+ while (tokens[index]?.value === '(' && tokens[index]?.type === 'Punctuator') {
138
+ opened++;
139
+ index++;
140
+ }
141
+ const negative = tokens[index]?.type === 'Punctuator' && tokens[index]?.value === '-';
142
+ if (negative) {
143
+ index++;
144
+ }
145
+ const token = tokens[index];
146
+ if (!token) {
147
+ return null;
148
+ }
149
+ index++;
150
+ const literal = readLiteral(token.type, token.value, negative);
151
+ if (!literal) {
152
+ return null;
153
+ }
154
+ let closed = 0;
155
+ while (tokens[index]?.value === ')' && tokens[index]?.type === 'Punctuator') {
156
+ closed++;
157
+ index++;
158
+ }
159
+ const rest = tokens[index];
160
+ if (rest) {
161
+ // Tokens after an `as` / `satisfies` are a type and not an expression, which has to end the code
162
+ const isAssertion = rest.type === 'Identifier' && (rest.value === 'as' || rest.value === 'satisfies');
163
+ return isAssertion && isType(tokens.slice(index + 1), opened - closed) ? literal : null;
164
+ }
165
+ return closed === opened ? literal : null;
166
+ }
167
+ /**
168
+ * Tokens that a type is made of: names, keywords, literal types, and the punctuation of unions, generics,
169
+ * arrays, objects and parentheses. An operator (`+`, `||`, `?`, `,`) makes it code that goes on after the
170
+ * assertion, which is not a literal. `:` and `;` are the punctuation of an object type, so they are only
171
+ * inside its braces: `"x" as A : b` is not a type.
172
+ */
173
+ const TYPE_PUNCTUATORS = new Set(['|', '&', '<', '>', '>>', '[', ']', '.', '{', '}']);
174
+ const OBJECT_TYPE_PUNCTUATORS = new Set([':', ';']);
175
+ /**
176
+ * @param tokens - The tokens after `as` / `satisfies`
177
+ * @param depth - The number of parentheses that are still open
178
+ * @returns `true` if the tokens are a type that closes the parentheses that are open, and nothing else
179
+ */
180
+ function isType(tokens, depth) {
181
+ if (tokens.length === 0) {
182
+ return false;
183
+ }
184
+ let open = depth;
185
+ let braces = 0;
186
+ for (const token of tokens) {
187
+ if (token.type !== 'Punctuator') {
188
+ continue;
189
+ }
190
+ if (token.value === '(') {
191
+ open++;
192
+ }
193
+ else if (token.value === ')') {
194
+ open--;
195
+ if (open < 0) {
196
+ return false;
197
+ }
198
+ }
199
+ else if (token.value === '{') {
200
+ braces++;
201
+ }
202
+ else if (token.value === '}') {
203
+ braces--;
204
+ if (braces < 0) {
205
+ return false;
206
+ }
207
+ }
208
+ else if (!TYPE_PUNCTUATORS.has(token.value) && !(braces > 0 && OBJECT_TYPE_PUNCTUATORS.has(token.value))) {
209
+ return false;
210
+ }
211
+ }
212
+ return open === 0 && braces === 0;
213
+ }
214
+ function readLiteral(type, value, negative) {
215
+ if (negative) {
216
+ if (type !== 'Numeric') {
217
+ return null;
218
+ }
219
+ return readNumber(value, true);
220
+ }
221
+ switch (type) {
222
+ case 'String': {
223
+ const literal = evaluate(value);
224
+ return typeof literal === 'string' ? stringLiteral(literal) : null;
225
+ }
226
+ case 'Template': {
227
+ // A template literal with a substitution is split into several tokens, and its first one does not end with a backquote
228
+ if (value.length < 2 || !value.startsWith('`') || !value.endsWith('`')) {
229
+ return null;
230
+ }
231
+ const cooked = evaluate(value);
232
+ return typeof cooked === 'string' ? stringLiteral(cooked) : null;
233
+ }
234
+ case 'Numeric': {
235
+ return readNumber(value, false);
236
+ }
237
+ case 'Boolean': {
238
+ return value === 'true'
239
+ ? { type: 'boolean', value: true, key: 'true' }
240
+ : { type: 'boolean', value: false, key: 'false' };
241
+ }
242
+ case 'Null': {
243
+ return { type: 'null', value: null, key: 'null' };
244
+ }
245
+ case 'Identifier': {
246
+ return value === 'undefined' ? { type: 'undefined', value: undefined, key: 'undefined' } : null;
247
+ }
248
+ default: {
249
+ return null;
250
+ }
251
+ }
252
+ }
253
+ function readNumber(source, negative) {
254
+ const evaluated = evaluate(source);
255
+ return typeof evaluated === 'number' || typeof evaluated === 'bigint' ? numberLiteral(evaluated, negative) : null;
256
+ }
257
+ function numberLiteral(evaluated, negative) {
258
+ if (typeof evaluated === 'bigint') {
259
+ // A BigInt keeps its digits: the number is only the approximation of it
260
+ const signed = negative ? -evaluated : evaluated;
261
+ return { type: 'number', value: Number(signed), key: signed.toString() };
262
+ }
263
+ const value = negative ? -evaluated : evaluated;
264
+ // `String(-0)` is `'0'`, which is what the key of `0` is
265
+ return { type: 'number', value, key: String(value) };
266
+ }
267
+ function stringLiteral(value) {
268
+ return { type: 'string', value, key: JSON.stringify(value) };
269
+ }
270
+ /**
271
+ * Evaluates the source of one literal token with the parser of the language, which resolves the
272
+ * escapes (`\n`, `\x41`, `A`) and the notations of a number (`0x10`, `1e3`, `1_000`).
273
+ *
274
+ * The catch does not use `isFatalError`, for the reason that is given at {@link parseLiteralExpression}:
275
+ * the `SyntaxError` of espree is the answer "not a literal".
276
+ *
277
+ * @returns The value of the literal, or `undefined` when the source is not a single literal whose
278
+ * value the parser could tell (a template literal with an invalid escape has no cooked value)
279
+ */
280
+ function evaluate(source) {
281
+ let program;
282
+ try {
283
+ program = parse(`(${source})`, { ecmaVersion: 'latest' });
284
+ }
285
+ catch (error) {
286
+ if (error instanceof SyntaxError) {
287
+ return undefined;
288
+ }
289
+ throw error;
290
+ }
291
+ const statement = program.body[0];
292
+ const expression = statement?.type === 'ExpressionStatement' ? statement.expression : null;
293
+ if (expression?.type === 'Literal') {
294
+ return expression.value;
295
+ }
296
+ if (expression?.type === 'TemplateLiteral' && expression.expressions.length === 0) {
297
+ return expression.quasis[0]?.value.cooked ?? undefined;
298
+ }
299
+ return undefined;
300
+ }
package/lib/parser.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { Token, ChildToken, QuoteSet, ParseOptions, ParserOptions, Tokenized, ValueType } from './types.js';
2
- import type { AttrNameRoute, EndTagType, MLASTDocument, MLASTParentNode, MLParser, ParserAuthoredElementNameDistinguishing, MLASTElement, MLASTElementCloseTag, MLASTToken, MLASTNodeTreeItem, MLASTTag, MLASTText, MLASTAttr, MLASTChildNode, MLASTSpreadAttr, ElementType, Walker, MLASTHTMLAttr, MLASTBlockBehavior, MLASTParseError } from '@markuplint/ml-ast';
2
+ import type { AttrNameRoute, EndTagType, ExpressionLanguage, MLASTDocument, MLASTParentNode, MLParser, ParserAuthoredElementNameDistinguishing, MLASTElement, MLASTElementCloseTag, MLASTToken, MLASTNodeTreeItem, MLASTTag, MLASTText, MLASTAttr, MLASTChildNode, MLASTSpreadAttr, ElementType, Walker, MLASTHTMLAttr, MLASTBlockBehavior, MLASTParseError, LiteralValueDeclaration } from '@markuplint/ml-ast';
3
3
  import { AttrState } from './enums.js';
4
4
  import { ParserError } from './parser-error.js';
5
5
  /**
@@ -20,12 +20,25 @@ import { ParserError } from './parser-error.js';
20
20
  * - **Direct `Parser` subclass** only when the document structure itself
21
21
  * diverges from HTML (JSX, Vue SFC, Svelte, Pug, Astro).
22
22
  *
23
+ * A framework whose attribute values are code (`{…}`, `:x="…"`) says so for `@markuplint/ml-core`, which
24
+ * reads a value that is nothing but a literal and renders it as the framework does (`aria-busy={true}`
25
+ * is `aria-busy="true"`; `disabled={false}` is no attribute). The parser only records what is written
26
+ * (`isDynamicValue`) and declares the language of that code (`attrExpressionLanguage`); a template language
27
+ * that an HTML parser reads says it in a `DirectivePattern` of its spec (`expressionLanguage`). How the
28
+ * framework renders is `literalValues` of its spec, or of its parser when it has no spec package.
29
+ *
23
30
  * Direct subclasses should delegate tokenization to the framework's
24
31
  * established parser library rather than hand-rolling one, and those
25
32
  * libraries are chosen to span multiple major framework versions (e.g.
26
33
  * `vue-eslint-parser` covers both Vue 2 and Vue 3 template syntax) so the
27
34
  * parsers keep working across version ranges without frequent updates.
28
35
  *
36
+ * A direct subclass also decides what happens to a leading BOM (`leadingBom` of
37
+ * {@link ParserOptions}): `'mask'` when its tokenizer takes the BOM for content,
38
+ * `'strip'` when the library strips it itself or reads a leading space as
39
+ * meaning something, unset when it already ignores a BOM without moving any
40
+ * position. Check it with a BOM-prefixed source, positions included.
41
+ *
29
42
  * @template Node - The language-specific AST node type produced by the tokenizer
30
43
  * @template State - An optional parser state type that persists across tokenization
31
44
  */
@@ -55,6 +68,16 @@ export declare abstract class Parser<Node extends {} = {}, State extends unknown
55
68
  * In the above, the `aria-hidden` is `true`.
56
69
  */
57
70
  get booleanish(): boolean;
71
+ /**
72
+ * The language of the code that an attribute value is written in when it is written as code.
73
+ * See {@link MLParser.attrExpressionLanguage}.
74
+ */
75
+ get attrExpressionLanguage(): ExpressionLanguage | undefined;
76
+ /**
77
+ * How the framework renders a literal in an attribute value, where it differs from the default
78
+ * of `@markuplint/ml-core`. See {@link MLParser.literalValues}.
79
+ */
80
+ get literalValues(): LiteralValueDeclaration | undefined;
58
81
  /**
59
82
  * The end tag omittable type.
60
83
  *
@@ -112,6 +135,10 @@ export declare abstract class Parser<Node extends {} = {}, State extends unknown
112
135
  * two hooks must stay symmetric or position reporting for embedded code
113
136
  * fragments (e.g., a `<template>` block inside a `.vue` file) breaks.
114
137
  *
138
+ * A leading BOM is handled after this hook (`leadingBom` of {@link ParserOptions}),
139
+ * and only if the returned code still starts with it: an override that moves or
140
+ * drops it turns that handling off.
141
+ *
115
142
  * @param rawCode - The raw source code about to be parsed
116
143
  * @param options - Parse options that may specify offset positioning
117
144
  * @returns The preprocessed source code to be used for tokenization
@@ -122,9 +149,23 @@ export declare abstract class Parser<Node extends {} = {}, State extends unknown
122
149
  * traversal, flattening, ignore-block restoration, and post-processing.
123
150
  * Returns the complete markuplint AST document.
124
151
  *
152
+ * A leading U+FEFF (BOM) is not part of the document: the HTML Standard skips it
153
+ * before tokenization, so a parser that handed it on would see text before the
154
+ * doctype (parse5 then opens `<html><head><body>` implicitly) and a Svelte
155
+ * template would be counted from the character after it. A parser that sets
156
+ * `leadingBom` (see {@link ParserOptions}) has it replaced with one space
157
+ * (`'mask'`), or taken away while the positions are moved one character later
158
+ * afterwards (`'strip'`), so that every offset still points into the source that
159
+ * `fix` edits; the returned `raw` keeps it. Code that starts at an offset (an
160
+ * embedded block) does not start a document, and its U+FEFF is a character like
161
+ * any other. The BOM is handled after {@link Parser.beforeParse}, and only if that
162
+ * hook left it first.
163
+ *
125
164
  * @param rawCode - The raw source code to parse
126
165
  * @param options - Parse options controlling offsets, depth, front matter, and authored element names
127
166
  * @returns The parsed AST document containing the node list and fragment flag
167
+ * @see https://html.spec.whatwg.org/multipage/parsing.html#the-input-byte-stream
168
+ * > A leading Byte Order Mark (BOM) causes the character encoding argument to be ignored and will itself be skipped.
128
169
  */
129
170
  parse(rawCode: string, options?: ParseOptions): MLASTDocument;
130
171
  /**
@@ -333,7 +374,7 @@ export declare abstract class Parser<Node extends {} = {}, State extends unknown
333
374
  * Re-parses a text token to discover embedded HTML/XML tags within it,
334
375
  * splitting the content into a sequence of tag and text AST nodes.
335
376
  * Handles self-closing detection, depth tracking, void element recognition,
336
- * and the raw-text element body short-circuit (HTML LS §13.2.5.1).
377
+ * and the raw-text element body short-circuit (HTML LS §13.1.2.6).
337
378
  *
338
379
  * Raw-text element handling only covers the elements listed in
339
380
  * `rawTextElements` (default `['style', 'script']`). HTML LS *escapable* raw
package/lib/parser.js CHANGED
@@ -10,9 +10,20 @@ import { getEndCol, getEndLine, getOffsetsFromCode, getPosition } from './get-lo
10
10
  import { ignoreBlock, restoreNode } from './ignore-block.js';
11
11
  import { ignoreFrontMatter } from './ignore-front-matter.js';
12
12
  import { ParserError } from './parser-error.js';
13
+ import { shiftPositions } from './shift-leading-bom.js';
13
14
  import { sortNodes } from './sort-nodes.js';
14
15
  import { getNamespace } from './get-namespace.js';
15
16
  const timer = new PerformanceTimer();
17
+ /** U+FEFF as the first character of a source: a byte order mark, not content. */
18
+ const BOM = '\uFEFF';
19
+ /**
20
+ * Whether the code of a parse starts the source: no offset places it inside a
21
+ * larger one (an embedded block). `offsetLine` and `offsetColumn` are 1-based, so
22
+ * 1 is no offset.
23
+ */
24
+ function startsAtDocumentHead(options) {
25
+ return (options?.offsetOffset ?? 0) <= 0 && (options?.offsetLine ?? 1) <= 1 && (options?.offsetColumn ?? 1) <= 1;
26
+ }
16
27
  /**
17
28
  * Abstract base class for all markuplint parsers. Provides the core parsing pipeline
18
29
  * including tokenization, tree traversal, node flattening, and error handling.
@@ -31,17 +42,32 @@ const timer = new PerformanceTimer();
31
42
  * - **Direct `Parser` subclass** only when the document structure itself
32
43
  * diverges from HTML (JSX, Vue SFC, Svelte, Pug, Astro).
33
44
  *
45
+ * A framework whose attribute values are code (`{…}`, `:x="…"`) says so for `@markuplint/ml-core`, which
46
+ * reads a value that is nothing but a literal and renders it as the framework does (`aria-busy={true}`
47
+ * is `aria-busy="true"`; `disabled={false}` is no attribute). The parser only records what is written
48
+ * (`isDynamicValue`) and declares the language of that code (`attrExpressionLanguage`); a template language
49
+ * that an HTML parser reads says it in a `DirectivePattern` of its spec (`expressionLanguage`). How the
50
+ * framework renders is `literalValues` of its spec, or of its parser when it has no spec package.
51
+ *
34
52
  * Direct subclasses should delegate tokenization to the framework's
35
53
  * established parser library rather than hand-rolling one, and those
36
54
  * libraries are chosen to span multiple major framework versions (e.g.
37
55
  * `vue-eslint-parser` covers both Vue 2 and Vue 3 template syntax) so the
38
56
  * parsers keep working across version ranges without frequent updates.
39
57
  *
58
+ * A direct subclass also decides what happens to a leading BOM (`leadingBom` of
59
+ * {@link ParserOptions}): `'mask'` when its tokenizer takes the BOM for content,
60
+ * `'strip'` when the library strips it itself or reads a leading space as
61
+ * meaning something, unset when it already ignores a BOM without moving any
62
+ * position. Check it with a BOM-prefixed source, positions included.
63
+ *
40
64
  * @template Node - The language-specific AST node type produced by the tokenizer
41
65
  * @template State - An optional parser state type that persists across tokenization
42
66
  */
43
67
  export class Parser {
44
68
  #booleanish = false;
69
+ #attrExpressionLanguage;
70
+ #literalValues;
45
71
  #defaultState;
46
72
  #endTagType = 'omittable';
47
73
  #ignoreTags = [];
@@ -53,6 +79,7 @@ export class Parser {
53
79
  #selfCloseType = 'html';
54
80
  #spaceChars = defaultSpaces;
55
81
  #rawTextElements = ['style', 'script'];
82
+ #leadingBom;
56
83
  /**
57
84
  * Buffer for parse errors collected from **embedded** parse() calls
58
85
  * (e.g., Markdown's inline HTML blocks, Pug's raw HTML lines — these
@@ -86,6 +113,8 @@ export class Parser {
86
113
  */
87
114
  constructor(options, defaultState) {
88
115
  this.#booleanish = options?.booleanish ?? this.#booleanish;
116
+ this.#attrExpressionLanguage = options?.attrExpressionLanguage ?? this.#attrExpressionLanguage;
117
+ this.#literalValues = options?.literalValues ?? this.#literalValues;
89
118
  this.#endTagType = options?.endTagType ?? this.#endTagType;
90
119
  this.#ignoreTags = options?.ignoreTags ?? this.#ignoreTags;
91
120
  this.#maskChar = options?.maskChar ?? this.#maskChar;
@@ -96,6 +125,7 @@ export class Parser {
96
125
  this.#selfCloseType = options?.selfCloseType ?? this.#selfCloseType;
97
126
  this.#spaceChars = options?.spaceChars ?? this.#spaceChars;
98
127
  this.#rawTextElements = options?.rawTextElements ?? this.#rawTextElements;
128
+ this.#leadingBom = options?.leadingBom ?? this.#leadingBom;
99
129
  this.#defaultState = defaultState ?? null;
100
130
  this.state = structuredClone(this.#defaultState);
101
131
  }
@@ -119,6 +149,20 @@ export class Parser {
119
149
  get booleanish() {
120
150
  return this.#booleanish;
121
151
  }
152
+ /**
153
+ * The language of the code that an attribute value is written in when it is written as code.
154
+ * See {@link MLParser.attrExpressionLanguage}.
155
+ */
156
+ get attrExpressionLanguage() {
157
+ return this.#attrExpressionLanguage;
158
+ }
159
+ /**
160
+ * How the framework renders a literal in an attribute value, where it differs from the default
161
+ * of `@markuplint/ml-core`. See {@link MLParser.literalValues}.
162
+ */
163
+ get literalValues() {
164
+ return this.#literalValues;
165
+ }
122
166
  /**
123
167
  * The end tag omittable type.
124
168
  *
@@ -193,6 +237,10 @@ export class Parser {
193
237
  * two hooks must stay symmetric or position reporting for embedded code
194
238
  * fragments (e.g., a `<template>` block inside a `.vue` file) breaks.
195
239
  *
240
+ * A leading BOM is handled after this hook (`leadingBom` of {@link ParserOptions}),
241
+ * and only if the returned code still starts with it: an override that moves or
242
+ * drops it turns that handling off.
243
+ *
196
244
  * @param rawCode - The raw source code about to be parsed
197
245
  * @param options - Parse options that may specify offset positioning
198
246
  * @returns The preprocessed source code to be used for tokenization
@@ -206,25 +254,46 @@ export class Parser {
206
254
  * traversal, flattening, ignore-block restoration, and post-processing.
207
255
  * Returns the complete markuplint AST document.
208
256
  *
257
+ * A leading U+FEFF (BOM) is not part of the document: the HTML Standard skips it
258
+ * before tokenization, so a parser that handed it on would see text before the
259
+ * doctype (parse5 then opens `<html><head><body>` implicitly) and a Svelte
260
+ * template would be counted from the character after it. A parser that sets
261
+ * `leadingBom` (see {@link ParserOptions}) has it replaced with one space
262
+ * (`'mask'`), or taken away while the positions are moved one character later
263
+ * afterwards (`'strip'`), so that every offset still points into the source that
264
+ * `fix` edits; the returned `raw` keeps it. Code that starts at an offset (an
265
+ * embedded block) does not start a document, and its U+FEFF is a character like
266
+ * any other. The BOM is handled after {@link Parser.beforeParse}, and only if that
267
+ * hook left it first.
268
+ *
209
269
  * @param rawCode - The raw source code to parse
210
270
  * @param options - Parse options controlling offsets, depth, front matter, and authored element names
211
271
  * @returns The parsed AST document containing the node list and fragment flag
272
+ * @see https://html.spec.whatwg.org/multipage/parsing.html#the-input-byte-stream
273
+ * > A leading Byte Order Mark (BOM) causes the character encoding argument to be ignored and will itself be skipped.
212
274
  */
213
275
  parse(rawCode, options) {
276
+ let strippedBom = false;
214
277
  try {
215
278
  // Initialize raw code
216
279
  this.#setRawCode(rawCode, rawCode);
217
280
  timer.push('beforeParse');
218
281
  const beforeParsedCode = this.beforeParse(this.rawCode, options);
219
282
  // Override raw code
220
- this.#setRawCode(beforeParsedCode);
283
+ const bom = this.#leadingBom && startsAtDocumentHead(options) && beforeParsedCode.startsWith(BOM)
284
+ ? this.#leadingBom
285
+ : null;
286
+ const masked = bom === 'mask';
287
+ strippedBom = bom === 'strip';
288
+ this.#setRawCode(bom ? (masked ? ' ' : '') + beforeParsedCode.slice(BOM.length) : beforeParsedCode);
221
289
  this.#authoredElementName = options?.authoredElementName;
222
290
  let frontMatter = null;
223
291
  if (options?.ignoreFrontMatter) {
224
292
  timer.push('ignoreFrontMatter');
225
- const fm = ignoreFrontMatter(this.rawCode);
226
- this.#setRawCode(fm.code);
227
- frontMatter = fm.frontMatter;
293
+ // The masked BOM is part of the front matter's raw, not of what it is looked for in
294
+ const fm = ignoreFrontMatter(masked ? this.rawCode.slice(BOM.length) : this.rawCode);
295
+ this.#setRawCode(masked ? ' ' + fm.code : fm.code);
296
+ frontMatter = fm.frontMatter == null || !masked ? fm.frontMatter : BOM + fm.frontMatter;
228
297
  }
229
298
  timer.push('ignoreBlock');
230
299
  const blocks = ignoreBlock(this.rawCode, this.#ignoreTags, this.#maskChar);
@@ -288,6 +357,10 @@ export class Parser {
288
357
  const embeddedErrors = this.embeddedParseErrors;
289
358
  const mergedParseErrors = parseErrors || embeddedErrors.length > 0 ? [...(parseErrors ?? []), ...embeddedErrors] : undefined;
290
359
  this.#reset();
360
+ if (strippedBom) {
361
+ // Everything above counted from the character after the BOM
362
+ shiftPositions([nodeList, mergedParseErrors], BOM.length);
363
+ }
291
364
  return {
292
365
  raw: rawCode,
293
366
  nodeList,
@@ -296,7 +369,17 @@ export class Parser {
296
369
  };
297
370
  }
298
371
  catch (error) {
299
- throw this.parseError(error);
372
+ const parserError = this.parseError(error);
373
+ if (strippedBom) {
374
+ // Only the fields are put back; the message that the library wrote is left as it is.
375
+ // A column of `0` is a position that is not known.
376
+ Object.assign(parserError, {
377
+ ...(parserError.line === 1 && parserError.col > 0 ? { col: parserError.col + BOM.length } : {}),
378
+ // The source an error quotes by default is the one without the BOM
379
+ ...(parserError.raw === this.rawCode ? { raw: rawCode } : {}),
380
+ });
381
+ }
382
+ throw parserError;
300
383
  }
301
384
  }
302
385
  /**
@@ -744,7 +827,7 @@ export class Parser {
744
827
  * Re-parses a text token to discover embedded HTML/XML tags within it,
745
828
  * splitting the content into a sequence of tag and text AST nodes.
746
829
  * Handles self-closing detection, depth tracking, void element recognition,
747
- * and the raw-text element body short-circuit (HTML LS §13.2.5.1).
830
+ * and the raw-text element body short-circuit (HTML LS §13.1.2.6).
748
831
  *
749
832
  * Raw-text element handling only covers the elements listed in
750
833
  * `rawTextElements` (default `['style', 'script']`). HTML LS *escapable* raw
@@ -846,10 +929,10 @@ export class Parser {
846
929
  nodes.push(tag);
847
930
  }
848
931
  /**
849
- * Raw-text element body short-circuit (HTML Living Standard §13.2.5.1
850
- * — "Restrictions on the contents of raw text and escapable raw text
851
- * elements". The spec section anchor is `cdata-rcdata-restrictions`,
852
- * where RCDATA stands for "Raw text + Character REF data" — the broader
932
+ * Raw-text element body short-circuit (HTML Living Standard §13.1.2.6,
933
+ * Restrictions on the contents of raw text and escapable raw text
934
+ * elements. The spec section anchor is `cdata-rcdata-restrictions`,
935
+ * where RCDATA stands for 'Raw text + Character REF data' — the broader
853
936
  * class that covers escapable raw text such as `<title>` / `<textarea>`).
854
937
  *
855
938
  * Per spec, the contents of `<script>` / `<style>` (and any caller-supplied
@@ -1221,7 +1304,7 @@ export class Parser {
1221
1304
  }
1222
1305
  /**
1223
1306
  * The pattern matches `</tagName` followed by a tab/LF/FF/CR/space/`>`/`/`
1224
- * (HTML LS §13.2.5.1) ASCII-case-insensitively. Caching avoids recompiling the
1307
+ * (HTML LS §13.1.2.6) ASCII-case-insensitively. Caching avoids recompiling the
1225
1308
  * `RegExp` for every `<script>` / `<style>` start tag in large documents.
1226
1309
  */
1227
1310
  #getRawTextCloseTagPattern(tagName) {
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Moves every position found in `value` one character later, in place: the
3
+ * positions a tokenizer reports for a source whose leading BOM it did not see.
4
+ *
5
+ * A parser whose library strips the BOM itself (`pug-lexer`, `remark`) counts
6
+ * from the character after it. Parsing the source without the BOM keeps `raw`
7
+ * and the slices consistent, and this puts the positions back where the source
8
+ * that `fix` edits has them. The BOM itself belongs to no node, as the HTML
9
+ * Standard skips it.
10
+ *
11
+ * A token (`offset`, `line`, `col`) and a parse error (`startOffset` … `endCol`)
12
+ * are recognised by their keys, whatever plain object carries them, so it is only
13
+ * safe on what the parser itself produced (the node list and the parse errors); an
14
+ * instance of a class is not entered. The
15
+ * column moves only for a position on the first line, the one the BOM is on. A
16
+ * token that several nodes refer to is moved once.
17
+ *
18
+ * @param value - A node list, a node, a token or a list of parse errors
19
+ * @param length - How many characters later the positions move
20
+ * @see https://html.spec.whatwg.org/multipage/parsing.html#the-input-byte-stream
21
+ * > A leading Byte Order Mark (BOM) causes the character encoding argument to be ignored and will itself be skipped.
22
+ */
23
+ export declare function shiftPositions(value: unknown, length?: number): void;
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Moves every position found in `value` one character later, in place: the
3
+ * positions a tokenizer reports for a source whose leading BOM it did not see.
4
+ *
5
+ * A parser whose library strips the BOM itself (`pug-lexer`, `remark`) counts
6
+ * from the character after it. Parsing the source without the BOM keeps `raw`
7
+ * and the slices consistent, and this puts the positions back where the source
8
+ * that `fix` edits has them. The BOM itself belongs to no node, as the HTML
9
+ * Standard skips it.
10
+ *
11
+ * A token (`offset`, `line`, `col`) and a parse error (`startOffset` … `endCol`)
12
+ * are recognised by their keys, whatever plain object carries them, so it is only
13
+ * safe on what the parser itself produced (the node list and the parse errors); an
14
+ * instance of a class is not entered. The
15
+ * column moves only for a position on the first line, the one the BOM is on. A
16
+ * token that several nodes refer to is moved once.
17
+ *
18
+ * @param value - A node list, a node, a token or a list of parse errors
19
+ * @param length - How many characters later the positions move
20
+ * @see https://html.spec.whatwg.org/multipage/parsing.html#the-input-byte-stream
21
+ * > A leading Byte Order Mark (BOM) causes the character encoding argument to be ignored and will itself be skipped.
22
+ */
23
+ export function shiftPositions(value, length = 1) {
24
+ walk(value, length, new WeakSet());
25
+ }
26
+ function walk(value, length, seen) {
27
+ if (typeof value !== 'object' || value === null || seen.has(value)) {
28
+ return;
29
+ }
30
+ seen.add(value);
31
+ if (Array.isArray(value)) {
32
+ for (const item of value) {
33
+ walk(item, length, seen);
34
+ }
35
+ return;
36
+ }
37
+ // A node and a token are plain objects; an instance of a class is state that something else holds
38
+ const prototype = Object.getPrototypeOf(value);
39
+ if (prototype !== Object.prototype && prototype !== null) {
40
+ return;
41
+ }
42
+ const record = value;
43
+ shift(record, 'offset', 'line', 'col', length);
44
+ shift(record, 'startOffset', 'startLine', 'startCol', length);
45
+ shift(record, 'endOffset', 'endLine', 'endCol', length);
46
+ for (const child of Object.values(record)) {
47
+ walk(child, length, seen);
48
+ }
49
+ }
50
+ function shift(record, offsetKey, lineKey, colKey, length) {
51
+ const { [offsetKey]: offset, [lineKey]: line, [colKey]: col } = record;
52
+ if (typeof offset !== 'number' || typeof line !== 'number' || typeof col !== 'number') {
53
+ return;
54
+ }
55
+ record[offsetKey] = offset + length;
56
+ if (line === 1) {
57
+ record[colKey] = col + length;
58
+ }
59
+ }
package/lib/types.d.ts CHANGED
@@ -1,10 +1,12 @@
1
- import type { AttrNameRoute, EndTagType, MLASTParentNode, MLASTParseError, ParserOptions as ConfigParserOptions } from '@markuplint/ml-ast';
1
+ import type { AttrNameRoute, EndTagType, ExpressionLanguage, LiteralValueDeclaration, MLASTParentNode, MLASTParseError, ParserOptions as ConfigParserOptions } from '@markuplint/ml-ast';
2
2
  /**
3
3
  * Configuration options for initializing a Parser instance,
4
4
  * controlling how the parser handles tags, attributes, and whitespace.
5
5
  */
6
6
  export type ParserOptions = {
7
7
  readonly booleanish?: boolean;
8
+ readonly attrExpressionLanguage?: ExpressionLanguage;
9
+ readonly literalValues?: LiteralValueDeclaration;
8
10
  readonly endTagType?: EndTagType;
9
11
  readonly ignoreTags?: readonly IgnoreTag[];
10
12
  readonly maskChar?: string;
@@ -15,6 +17,22 @@ export type ParserOptions = {
15
17
  readonly selfCloseType?: SelfCloseType;
16
18
  readonly spaceChars?: readonly string[];
17
19
  readonly rawTextElements?: readonly string[];
20
+ /**
21
+ * What {@link Parser.parse} does with a leading U+FEFF (BOM) before tokenization.
22
+ * The HTML Standard skips it, so it is not content.
23
+ *
24
+ * - `'mask'`: it becomes one space. For a tokenizer that takes the BOM for text
25
+ * (parse5) or counts from the character after it (`svelte/compiler`) and that
26
+ * does not read a leading space as meaning anything.
27
+ * - `'strip'`: the tokenizer is given the source without it, and the positions
28
+ * are moved one character later afterwards. For a tokenizer that strips the
29
+ * BOM itself (`pug-lexer`, `remark`) or reads a leading space as meaning
30
+ * something (Pug's indentation, a Markdown front matter), where `'mask'`
31
+ * would change the tree.
32
+ *
33
+ * Leave it unset when the tokenizer already handles a BOM correctly.
34
+ */
35
+ readonly leadingBom?: 'mask' | 'strip';
18
36
  };
19
37
  /**
20
38
  * Options passed to a single parse invocation, extending the base config parser options
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markuplint/parser-utils",
3
- "version": "5.4.0",
3
+ "version": "5.5.0",
4
4
  "description": "Utility module for markuplint parser plugin",
5
5
  "repository": {
6
6
  "type": "git",
@@ -35,16 +35,16 @@
35
35
  "clean": "tsc --build --clean tsconfig.build.json"
36
36
  },
37
37
  "dependencies": {
38
- "@markuplint/ml-ast": "5.3.0",
39
- "@markuplint/ml-spec": "5.4.0",
40
- "@markuplint/shared": "5.4.0",
41
- "@markuplint/types": "5.4.0",
38
+ "@markuplint/ml-ast": "5.5.0",
39
+ "@markuplint/ml-spec": "5.5.0",
40
+ "@markuplint/shared": "5.5.0",
41
+ "@markuplint/types": "5.5.0",
42
42
  "debug": "^4.4.3",
43
43
  "espree": "^11.2.0",
44
44
  "type-fest": "^5.10.0"
45
45
  },
46
46
  "devDependencies": {
47
- "@typescript-eslint/typescript-estree": "8.71.2"
47
+ "@typescript-eslint/typescript-estree": "8.72.1"
48
48
  },
49
- "gitHead": "ab56aa8e12b67fbf793ddf1267aaba9fda90269d"
49
+ "gitHead": "fedebcfd56eaec4642e68643b2f9e10750d810e1"
50
50
  }