@markuplint/svelte-parser 5.4.0 → 5.6.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.
@@ -115,7 +115,8 @@ function extractAttrs(el) {
115
115
  }
116
116
  const value = attr.value.raw;
117
117
  if (attr.isDynamicValue) {
118
- const expression = value.trim();
118
+ // `disabled="{false}"` is the expression `false` in quotes, which the parser keeps as `potentialValue`
119
+ const expression = (attr.potentialValue ?? value).trim();
119
120
  addAttr(attrs, { name: attr.nodeName, dynamic: true, ...(expression === '' ? {} : { expression }) });
120
121
  }
121
122
  else if (INTERPOLATION.test(value)) {
package/lib/parser.d.ts CHANGED
@@ -56,6 +56,7 @@ export declare class SvelteParser extends Parser<SvelteNode> {
56
56
  visitAttr(token: Token): (import("@markuplint/ml-ast").MLASTSpreadAttr & {
57
57
  __rightText?: string;
58
58
  }) | {
59
+ potentialValue?: string;
59
60
  isDynamicValue: true | undefined;
60
61
  potentialName: string | undefined;
61
62
  type: "attr";
@@ -69,7 +70,6 @@ export declare class SvelteParser extends Parser<SvelteNode> {
69
70
  value: import("@markuplint/ml-ast").MLASTToken;
70
71
  endQuote: import("@markuplint/ml-ast").MLASTToken;
71
72
  isDirective?: true;
72
- potentialValue?: string;
73
73
  valueType?: "string" | "number" | "boolean" | "code";
74
74
  candidate?: string;
75
75
  isDuplicatable: boolean;
package/lib/parser.js CHANGED
@@ -1,6 +1,20 @@
1
- import { ParserError, Parser, AttrState } from '@markuplint/parser-utils';
1
+ import { ParserError, Parser, AttrState, isBracedExpression } from '@markuplint/parser-utils';
2
2
  import { parseBlock } from './parse-block.js';
3
- import { svelteParse } from './svelte-parser/index.js';
3
+ import { svelteParseComponent } from './svelte-parser/index.js';
4
+ /**
5
+ * The opening of an attachment (`{@attach expr}`), up to the keyword, with the braces and spaces as
6
+ * Svelte allows. A shorthand (`{attach}`) is not one: the keyword is followed by a space.
7
+ */
8
+ const ATTACHMENT = /^(\s*\{\s*)@attach(?=\s)/;
9
+ /**
10
+ * Replaces the keyword of an attachment with spaces of the same length.
11
+ *
12
+ * @param raw - The source that starts with an attachment
13
+ * @returns The source whose keyword is spaces
14
+ */
15
+ function maskAttachKeyword(raw) {
16
+ return raw.replace(ATTACHMENT, (_match, opening) => opening + ' '.repeat('@attach'.length));
17
+ }
4
18
  /**
5
19
  * Parser implementation for Svelte component templates.
6
20
  * Extends the base Parser to handle Svelte elements, text, comments,
@@ -13,26 +27,34 @@ export class SvelteParser extends Parser {
13
27
  super({
14
28
  endTagType: 'xml',
15
29
  tagNameCaseSensitive: true,
30
+ // A value in `{…}` is JavaScript
31
+ attrExpressionLanguage: 'javascript',
16
32
  // A static attribute is in the HTML string that the compiler hands to the browser's parser,
17
33
  // and a dynamic one is set by `set_attribute` under the name as written.
18
34
  attrNameRoute: 'dom-api-when-dynamic',
19
- ignoreTags: [
20
- // Fixed(Resolved) #2505
21
- // The script tag needs to be passed with the lang attribute to the parser,
22
- // so instead of excluding it, we convert it to PSBlock in visitText.
23
- // Keep the style tag as is for now.
24
- {
25
- type: 'Style',
26
- start: '<style',
27
- end: '</style>',
28
- },
29
- ],
30
- maskChar: '-',
35
+ // `<slot>` is replaced with what the parent component passes to it, not the HTML `slot` element
36
+ // of a shadow tree
37
+ // @see https://svelte.dev/docs/svelte/legacy-slots
38
+ slotElement: 'framework',
39
+ // No `ignoreTags`:
40
+ // - The script tag needs to be passed with the lang attribute to the parser, so instead of
41
+ // masking it, it is converted to PSBlock in visitText.
42
+ // - The top-level style is kept out of Svelte's CSS parse in `svelteParseComponent`; nested ones stay elements.
43
+ // `svelte/compiler` strips a leading BOM and counts positions from the next character
44
+ leadingBom: 'mask',
31
45
  });
32
46
  }
47
+ /**
48
+ * A top-level `<style>` is masked as a comment before Svelte parses it (see `svelteParseComponent`),
49
+ * so `nodeize` needs to know which comments stand for one. The parser object is reused, so this is
50
+ * set again by every `tokenize`.
51
+ */
52
+ #topLevelStyleStarts = new Set();
33
53
  tokenize() {
54
+ const { nodes, topLevelStyleStarts } = svelteParseComponent(this.rawCode);
55
+ this.#topLevelStyleStarts = topLevelStyleStarts;
34
56
  return {
35
- ast: svelteParse(this.rawCode),
57
+ ast: nodes,
36
58
  isFragment: true,
37
59
  };
38
60
  }
@@ -63,6 +85,16 @@ export class SvelteParser extends Parser {
63
85
  });
64
86
  }
65
87
  case 'Comment': {
88
+ // The mask of a top-level `<style>`, the CSS of the component (see `svelteParseComponent`)
89
+ if (this.#topLevelStyleStarts.has(originNode.start)) {
90
+ return this.visitPsBlock({
91
+ ...token,
92
+ depth,
93
+ parentNode,
94
+ nodeName: 'Style',
95
+ isFragment: false,
96
+ });
97
+ }
66
98
  return this.visitComment({
67
99
  ...token,
68
100
  depth,
@@ -273,7 +305,11 @@ export class SvelteParser extends Parser {
273
305
  * @returns The parsed attribute node with Svelte-specific metadata
274
306
  */
275
307
  visitAttr(token) {
276
- const attr = super.visitAttr(token, {
308
+ // `@attach` is not JavaScript, so the tokenizer, which finds the end of `{…}` by reading it as
309
+ // JavaScript, cannot read it. Spaces of the same length stand for it, which makes the attachment
310
+ // a shorthand (`{expr}`) whose end the tokenizer finds, and every offset stays the source's.
311
+ const isAttachment = ATTACHMENT.test(token.raw);
312
+ const attr = super.visitAttr(isAttachment ? { ...token, raw: maskAttachKeyword(token.raw) } : token, {
277
313
  quoteSet: [
278
314
  { start: '"', end: '"', type: 'string' },
279
315
  { start: "'", end: "'", type: 'string' },
@@ -286,8 +322,25 @@ export class SvelteParser extends Parser {
286
322
  if (attr.type === 'spread') {
287
323
  return attr;
288
324
  }
325
+ // `{@attach expr}`: "Attachments are functions that run in an effect when an
326
+ // element is mounted". The function gets the element and can do anything to it, its attributes
327
+ // included, so it is read as a spread.
328
+ if (isAttachment) {
329
+ // The token of the attribute starts at its `{`: the spaces before it are not in `attr.raw`, nor in
330
+ // `source`, so the length of the attribute is the length of the text of `source` that it takes
331
+ const source = token.raw.trimStart();
332
+ const attachmentLength = attr.raw.length;
333
+ const rightText = source.slice(attachmentLength);
334
+ return {
335
+ ...this.createToken(source.slice(0, attachmentLength), attr.offset, attr.line, attr.col),
336
+ type: 'spread',
337
+ nodeName: '#spread',
338
+ ...(rightText === '' ? {} : { __rightText: rightText }),
339
+ };
340
+ }
289
341
  let isDynamicValue = attr.startQuote.raw === '{' || undefined;
290
342
  let potentialName;
343
+ let potentialValue;
291
344
  // Shorthand {name} → potentialName = value
292
345
  if (isDynamicValue && attr.name.raw === '') {
293
346
  potentialName = attr.value.raw;
@@ -296,10 +349,29 @@ export class SvelteParser extends Parser {
296
349
  if (attr.startQuote.raw === '{' && attr.endQuote.raw === '}') {
297
350
  isDynamicValue = true;
298
351
  }
352
+ // A single expression in quotes is the expression: `disabled="{false}"` is `disabled={false}`:
353
+ //
354
+ // > Quoting a singular expression does not affect how the value is parsed, but in Svelte 6 it
355
+ // > will cause the value to be coerced to a string:
356
+ //
357
+ // @see https://svelte.dev/docs/svelte/basic-markup#Element-attributes
358
+ //
359
+ // A value with text around the expression (`page/{p}`) or several expressions is a string. The
360
+ // tokens stay those of the source (the quotes are the quotes), and the expression is
361
+ // `potentialValue`.
362
+ if (!isDynamicValue &&
363
+ attr.name.raw !== '' &&
364
+ (attr.startQuote.raw === '"' || attr.startQuote.raw === "'") &&
365
+ attr.endQuote.raw === attr.startQuote.raw &&
366
+ isBracedExpression(attr.value.raw)) {
367
+ isDynamicValue = true;
368
+ potentialValue = attr.value.raw.slice(1, -1);
369
+ }
299
370
  return {
300
371
  ...attr,
301
372
  isDynamicValue,
302
373
  potentialName,
374
+ ...(potentialValue == null ? {} : { potentialValue }),
303
375
  };
304
376
  }
305
377
  /**
@@ -9,6 +9,33 @@ export type SvelteAwaitBlock = AST.AwaitBlock;
9
9
  * `SnippetBlock`, `RenderTag`, and `SvelteBoundary`.
10
10
  */
11
11
  export declare function svelteParse(template: string): SvelteNode[];
12
+ /**
13
+ * Parses a component, keeping the top-level `<style>` out of Svelte's CSS parser and the nested one in
14
+ * the markup.
15
+ *
16
+ * > There can only be one top-level <style> tag per component.
17
+ * >
18
+ * > However, it is possible to have a <style> tag nested inside other elements or logic blocks.
19
+ * >
20
+ * > In that case, the <style> tag will be inserted as-is into the DOM; no scoping or processing will
21
+ * > be done on the <style> tag.
22
+ *
23
+ * The top-level one is the CSS of the component, and Svelte reads it as CSS (`lang="scss"` is not CSS and
24
+ * fails: "Expected a valid CSS identifier"), so it is masked. The nested one is an element whose content
25
+ * is text, which Svelte reads without parsing it. What is top-level cannot be told from the source by a
26
+ * pattern. The source is tried with Svelte as-is first (`ast.css` for the top-level one). When CSS parsing
27
+ * fails (`css_*`), only that style block is masked and parsing is retried. When that is still not enough,
28
+ * every `<style>` is masked and the ones that are children of the fragment are the top-level ones.
29
+ *
30
+ * @see https://svelte.dev/docs/svelte/nested-style-elements#main
31
+ * @param template - The source of the component
32
+ * @returns The nodes, and the offsets in `template` where a top-level `<style>` starts: the nodes have
33
+ * a comment (`Comment`) there, and the positions of every node are positions in `template`
34
+ */
35
+ export declare function svelteParseComponent(template: string): {
36
+ readonly nodes: SvelteNode[];
37
+ readonly topLevelStyleStarts: ReadonlySet<number>;
38
+ };
12
39
  export type SvelteDirective = Directive | AST.Attribute | AST.SpreadAttribute;
13
40
  export type SvelteBlock = AST.EachBlock | AST.IfBlock | AST.AwaitBlock | AST.KeyBlock | AST.SnippetBlock | AST.SvelteBoundary;
14
41
  type Directive = AST.AnimateDirective | AST.BindDirective | AST.ClassDirective | AST.LetDirective | AST.OnDirective | AST.StyleDirective | AST.TransitionDirective | AST.UseDirective;
@@ -1,3 +1,4 @@
1
+ import { isFatalError } from '@markuplint/shared';
1
2
  import { parse } from 'svelte/compiler';
2
3
  /**
3
4
  * The `modern: true` option opts into Svelte 5's modern AST format,
@@ -8,3 +9,212 @@ export function svelteParse(template) {
8
9
  const ast = parse(template, { modern: true });
9
10
  return ast.fragment.nodes ?? [];
10
11
  }
12
+ /**
13
+ * What the source is read in order as, so that a `<style` is a `<style>` block only where one can start: a
14
+ * comment (`<!-- … -->`) and a `<script>` block are consumed whole, and a `<style` in their text is not
15
+ * one. A `<style>` block runs from `<style` to the first `</style>`, or to the end of the source when it
16
+ * is not closed; the name ends there (`<style-guide>` is a custom element).
17
+ *
18
+ * It does not read an attribute value or an expression (`{…}`), so a `<style` in one is still taken as
19
+ * a block. `svelteParseComponent` tries Svelte first so that case is handled without this mask; only the
20
+ * last fallback still uses `SOURCE_BLOCK`.
21
+ */
22
+ const SOURCE_BLOCK = /<!--[\s\S]*?(?:-->|$)|<script(?=[\s/>])[\s\S]+?(?:<\/script>|$)|<style(?=[\s/>])[\s\S]+?(?:<\/style>|$)/g;
23
+ /**
24
+ * Replaces each `<style>` block of the source with what `replacer` returns, and leaves everything else.
25
+ *
26
+ * @param template - The source of the component
27
+ * @param replacer - Gives the replacement of a `<style>` block, from the block and its offset in `template`
28
+ * @returns The source with the replaced blocks
29
+ */
30
+ function replaceStyleBlocks(template, replacer) {
31
+ return template.replaceAll(SOURCE_BLOCK, (block, offset) => block.startsWith('<style') ? replacer(block, offset) : block);
32
+ }
33
+ /**
34
+ * The shortest block that a comment (`<!---->`) can mask. A shorter one is `<style` cut off by the end of
35
+ * the source, which no comment of the same length can stand for.
36
+ */
37
+ const MIN_MASKABLE_LENGTH = '<!---->'.length;
38
+ /**
39
+ * Replaces a `<style>` block with a comment (`<!---…--->`) of the same length and line breaks, so that
40
+ * every offset in the source stays the offset in the result. A block that is too short to be a comment
41
+ * is left as it is, so that Svelte reports the syntax error of the source, not one of a mask.
42
+ *
43
+ * @param block - A `<style>` block
44
+ * @returns The comment
45
+ */
46
+ function maskStyleBlock(block) {
47
+ if (block.length < MIN_MASKABLE_LENGTH) {
48
+ return block;
49
+ }
50
+ return '<!' + block.slice(2, -1).replaceAll(/[^\n]/g, '-') + '>';
51
+ }
52
+ /**
53
+ * `afterCssError` must not treat markup or script errors as a failed top-level style block: only Svelte's
54
+ * own CSS parse step (`css_*`) qualifies.
55
+ */
56
+ function isSvelteCssParseError(error) {
57
+ return (typeof error === 'object' &&
58
+ error !== null &&
59
+ 'code' in error &&
60
+ typeof error.code === 'string' &&
61
+ error.code.startsWith('css_'));
62
+ }
63
+ const STYLE_OPENER = /<style(?=[\s/>])/g;
64
+ /**
65
+ * The `<style>` block that contains `index`. When a `<style` in an attribute value or an expression opens
66
+ * a spurious range to the next `</style>`, the real block is the one that starts last among the openers
67
+ * before `index`.
68
+ *
69
+ * @param template - The source of the component
70
+ * @param index - A character offset in `template`
71
+ * @returns The block span, if any
72
+ */
73
+ function styleBlockAroundIndex(template, index) {
74
+ let best = null;
75
+ for (const match of template.matchAll(STYLE_OPENER)) {
76
+ if (match.index === undefined) {
77
+ continue;
78
+ }
79
+ const start = match.index;
80
+ const close = template.indexOf('</style>', start);
81
+ const end = close === -1 ? template.length : close + '</style>'.length;
82
+ if (index >= start && index < end && (best === null || start > best.start)) {
83
+ best = { start, end };
84
+ }
85
+ }
86
+ return best;
87
+ }
88
+ /**
89
+ * Masks the top-level `<style>` that made Svelte throw `css_*`, then parses again with
90
+ * {@link svelteParseComponentDirect}.
91
+ *
92
+ * @param template - The source of the component
93
+ * @param error - The error from the first `parse` of `template`
94
+ * @returns The nodes when masking one block is enough, otherwise `null`
95
+ */
96
+ function svelteParseComponentAfterCssError(template, error) {
97
+ if (!isSvelteCssParseError(error) || !('start' in error)) {
98
+ return null;
99
+ }
100
+ const { character } = error.start;
101
+ const block = styleBlockAroundIndex(template, character);
102
+ if (!block) {
103
+ return null;
104
+ }
105
+ const masked = template.slice(0, block.start) +
106
+ maskStyleBlock(template.slice(block.start, block.end)) +
107
+ template.slice(block.end);
108
+ const parsed = svelteParseComponentDirect(masked);
109
+ if (!parsed) {
110
+ return null;
111
+ }
112
+ return {
113
+ nodes: parsed.nodes,
114
+ topLevelStyleStarts: new Set([...parsed.topLevelStyleStarts, block.start]),
115
+ };
116
+ }
117
+ /**
118
+ * Builds nodes from an already-parsed component. The top-level `<style>` is `ast.css`, not in
119
+ * `ast.fragment`, so a `Comment` at the same span stands in for `SvelteParser` `nodeize`.
120
+ *
121
+ * @param ast - The component root from `svelte/compiler` `parse`
122
+ * @returns The fragment nodes and top-level style offsets in the source
123
+ */
124
+ function nodesFromParsedComponent(ast) {
125
+ const topLevelStyleStarts = new Set();
126
+ let nodes = ast.fragment.nodes ?? [];
127
+ if (ast.css) {
128
+ topLevelStyleStarts.add(ast.css.start);
129
+ const styleNode = {
130
+ type: 'Comment',
131
+ start: ast.css.start,
132
+ end: ast.css.end,
133
+ data: '',
134
+ };
135
+ const index = nodes.findIndex((node) => node.start > ast.css.start);
136
+ if (index === -1) {
137
+ nodes = [...nodes, styleNode];
138
+ }
139
+ else {
140
+ nodes = [...nodes.slice(0, index), styleNode, ...nodes.slice(index)];
141
+ }
142
+ }
143
+ return { nodes, topLevelStyleStarts };
144
+ }
145
+ /**
146
+ * Parses `template` with Svelte when the source is valid markup and CSS.
147
+ *
148
+ * @param template - The source of the component
149
+ * @returns The nodes and offsets, or `null` when `parse` fails and the failure is not fatal
150
+ */
151
+ function svelteParseComponentDirect(template) {
152
+ try {
153
+ return nodesFromParsedComponent(parse(template, { modern: true }));
154
+ }
155
+ catch (error) {
156
+ if (isFatalError(error)) {
157
+ throw error;
158
+ }
159
+ return null;
160
+ }
161
+ }
162
+ /**
163
+ * Parses a component, keeping the top-level `<style>` out of Svelte's CSS parser and the nested one in
164
+ * the markup.
165
+ *
166
+ * > There can only be one top-level <style> tag per component.
167
+ * >
168
+ * > However, it is possible to have a <style> tag nested inside other elements or logic blocks.
169
+ * >
170
+ * > In that case, the <style> tag will be inserted as-is into the DOM; no scoping or processing will
171
+ * > be done on the <style> tag.
172
+ *
173
+ * The top-level one is the CSS of the component, and Svelte reads it as CSS (`lang="scss"` is not CSS and
174
+ * fails: "Expected a valid CSS identifier"), so it is masked. The nested one is an element whose content
175
+ * is text, which Svelte reads without parsing it. What is top-level cannot be told from the source by a
176
+ * pattern. The source is tried with Svelte as-is first (`ast.css` for the top-level one). When CSS parsing
177
+ * fails (`css_*`), only that style block is masked and parsing is retried. When that is still not enough,
178
+ * every `<style>` is masked and the ones that are children of the fragment are the top-level ones.
179
+ *
180
+ * @see https://svelte.dev/docs/svelte/nested-style-elements#main
181
+ * @param template - The source of the component
182
+ * @returns The nodes, and the offsets in `template` where a top-level `<style>` starts: the nodes have
183
+ * a comment (`Comment`) there, and the positions of every node are positions in `template`
184
+ */
185
+ export function svelteParseComponent(template) {
186
+ let caught;
187
+ try {
188
+ return nodesFromParsedComponent(parse(template, { modern: true }));
189
+ }
190
+ catch (error) {
191
+ if (isFatalError(error)) {
192
+ throw error;
193
+ }
194
+ caught = error;
195
+ }
196
+ const afterCssError = svelteParseComponentAfterCssError(template, caught);
197
+ if (afterCssError) {
198
+ return afterCssError;
199
+ }
200
+ const blocks = [];
201
+ const everyStyleMasked = replaceStyleBlocks(template, (block, offset) => {
202
+ blocks.push({ start: offset, end: offset + block.length });
203
+ return maskStyleBlock(block);
204
+ });
205
+ if (blocks.length === 0) {
206
+ return { nodes: svelteParse(template), topLevelStyleStarts: new Set() };
207
+ }
208
+ const topLevelStyleStarts = new Set();
209
+ const nodesOfEveryMasked = svelteParse(everyStyleMasked);
210
+ for (const node of nodesOfEveryMasked) {
211
+ if (node.type === 'Comment' && blocks.some(block => block.start === node.start && block.end === node.end)) {
212
+ topLevelStyleStarts.add(node.start);
213
+ }
214
+ }
215
+ if (topLevelStyleStarts.size === blocks.length) {
216
+ return { nodes: nodesOfEveryMasked, topLevelStyleStarts };
217
+ }
218
+ const onlyTopLevelMasked = replaceStyleBlocks(template, (block, offset) => topLevelStyleStarts.has(offset) ? maskStyleBlock(block) : block);
219
+ return { nodes: svelteParse(onlyTopLevelMasked), topLevelStyleStarts };
220
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markuplint/svelte-parser",
3
- "version": "5.4.0",
3
+ "version": "5.6.0",
4
4
  "description": "Svelte parser for markuplint",
5
5
  "repository": {
6
6
  "type": "git",
@@ -40,11 +40,11 @@
40
40
  "clean": "tsc --build --clean tsconfig.build.json"
41
41
  },
42
42
  "dependencies": {
43
- "@markuplint/html-parser": "5.4.0",
44
- "@markuplint/ml-ast": "5.3.0",
45
- "@markuplint/parser-utils": "5.4.0",
46
- "@markuplint/shared": "5.4.0",
47
- "svelte": "^5.57.2"
43
+ "@markuplint/html-parser": "5.6.0",
44
+ "@markuplint/ml-ast": "5.6.0",
45
+ "@markuplint/parser-utils": "5.6.0",
46
+ "@markuplint/shared": "5.6.0",
47
+ "svelte": "^5.57.3"
48
48
  },
49
- "gitHead": "647f6fb5d5743f060678599c97e9c84b58970747"
49
+ "gitHead": "eade3d7202682e567f3c1b6985478f1d4b5c5481"
50
50
  }