@markuplint/parser-utils 5.3.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 +19 -0
- package/lib/idl-attributes.js +2 -2
- package/lib/index.d.ts +2 -0
- package/lib/index.js +2 -0
- package/lib/is-braced-expression.d.ts +17 -0
- package/lib/is-braced-expression.js +52 -0
- package/lib/parse-literal-expression.d.ts +64 -0
- package/lib/parse-literal-expression.js +300 -0
- package/lib/parser.d.ts +43 -2
- package/lib/parser.js +94 -11
- package/lib/shift-leading-bom.d.ts +23 -0
- package/lib/shift-leading-bom.js +59 -0
- package/lib/types.d.ts +19 -1
- package/package.json +7 -7
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,25 @@
|
|
|
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
|
+
|
|
17
|
+
# [5.4.0](https://github.com/markuplint/markuplint/compare/v5.3.0...v5.4.0) (2026-10-29)
|
|
18
|
+
|
|
19
|
+
**Note:** Version bump only for package @markuplint/parser-utils
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
|
|
6
25
|
# [5.3.0](https://github.com/markuplint/markuplint/compare/v5.2.0...v5.3.0) (2026-10-21)
|
|
7
26
|
|
|
8
27
|
### Bug Fixes
|
package/lib/idl-attributes.js
CHANGED
|
@@ -402,8 +402,8 @@ const idlContentMap = {
|
|
|
402
402
|
* PerformanceElementTiming API
|
|
403
403
|
*
|
|
404
404
|
* @experimental
|
|
405
|
-
* @see https://
|
|
406
|
-
* @see https://
|
|
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.
|
|
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.#
|
|
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
|
-
|
|
226
|
-
this
|
|
227
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
850
|
-
*
|
|
851
|
-
* elements
|
|
852
|
-
* where RCDATA stands for
|
|
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.
|
|
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.
|
|
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.
|
|
39
|
-
"@markuplint/ml-spec": "5.
|
|
40
|
-
"@markuplint/shared": "5.
|
|
41
|
-
"@markuplint/types": "5.
|
|
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.
|
|
47
|
+
"@typescript-eslint/typescript-estree": "8.72.1"
|
|
48
48
|
},
|
|
49
|
-
"gitHead": "
|
|
49
|
+
"gitHead": "fedebcfd56eaec4642e68643b2f9e10750d810e1"
|
|
50
50
|
}
|