• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

ota-meshi / astro-eslint-parser / 27600724045

16 Jun 2026 07:13AM UTC coverage: 81.279% (-0.5%) from 81.805%
27600724045

Pull #435

github

web-flow
Merge 8602d87cb into ce311c59d
Pull Request #435: feat!: use `@astrojs/compiler-rs` instead of `@astrojs/compiler`

648 of 870 branches covered (74.48%)

Branch coverage included in aggregate %.

350 of 398 new or added lines in 9 files covered. (87.94%)

9 existing lines in 4 files now uncovered.

1258 of 1475 relevant lines covered (85.29%)

52150.81 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

88.18
/src/parser/process-template.ts
1
import type {
4,240!
2
  AstroRootNode,
3
  AttributeNode,
4
  JSXElementNode,
5
  JSXNameNode,
6
  LocatedNode,
7
  TemplateNode,
8
  ParseResult,
9
  AstroFrontmatterNode,
10
  UnknownNode,
11
  JSXAttributeNode,
12
  JSXSpreadAttributeNode,
13
  LiteralNode,
14
  JSXExpressionContainerNode,
15
} from "../astro/types";
16
import { AST_TOKEN_TYPES, AST_NODE_TYPES } from "@typescript-eslint/types";
1✔
17
import type { TSESTree } from "@typescript-eslint/types";
18
import type { Context } from "../context";
19
import { VirtualScriptContext } from "../context/script";
1✔
20
import type {
21
  AstroDoctype,
22
  AstroFragment,
23
  AstroHTMLComment,
24
  AstroProgram,
25
  AstroRawText,
26
  AstroShorthandAttribute,
27
  AstroTemplateLiteralAttribute,
28
  JSXElement,
29
} from "../ast";
30
import { removeAllScopeAndVariableAndReference } from "./scope";
1✔
31
import {
32
  isJSXElementOrFragment,
33
  isShorthandAttribute,
34
  isSyntheticFragment,
35
} from "../astro/node";
1✔
36
import { walk } from "../astro/walker";
1✔
37

38
type AnalyzedAttributeData =
39
  | AnalyzedSpreadAttributeData
40
  | AnalyzedEmptyAttributeData
41
  | AnalyzedShorthandAttributeData
42
  | AnalyzedLiteralValueAttributeData
43
  | AnalyzedTemplateLiteralAttributeData
44
  | AnalyzedExpressionAttributeData;
45
type AnalyzedSpreadAttributeData = {
46
  kind: "spread";
47
  node: JSXSpreadAttributeNode;
48
};
49
type AnalyzedEmptyAttributeData = {
50
  kind: "empty";
51
  node: JSXAttributeNode & { value: null };
52
};
53
type AnalyzedShorthandAttributeData = {
54
  kind: "shorthand";
55
  node: JSXAttributeNode & { value: JSXExpressionContainerNode };
56
};
57
type AnalyzedLiteralValueAttributeData = {
58
  kind: "literal-value";
59
  node: JSXAttributeNode & { value: LiteralNode };
60
};
61
type AnalyzedTemplateLiteralAttributeData = {
62
  kind: "template-literal";
63
  node: JSXAttributeNode & { value: JSXExpressionContainerNode };
64
};
65
type AnalyzedExpressionAttributeData = {
66
  kind: "expression";
67
  node: JSXAttributeNode & { value: JSXExpressionContainerNode };
68
};
69

70
/**
71
 * Process the template to generate a ScriptContext.
72
 */
73
export function processTemplate(
1✔
74
  ctx: Context,
75
  resultTemplate: ParseResult,
76
): VirtualScriptContext {
77
  let uniqueIdSeq = 0;
1,404✔
78
  const usedUniqueIds = new Set<string>();
1,404✔
79

80
  const script = new VirtualScriptContext(ctx);
1,404✔
81
  const code = ctx.code;
1,404✔
82

83
  let fragmentOpened = false;
1,404✔
84

85
  /** Open astro root fragment */
86
  function openRootFragment(startOffset: number) {
1,404✔
87
    script.appendVirtualScript("<>");
1,371✔
88
    fragmentOpened = true;
1,371✔
89
    script.restoreContext.addRestoreNodeProcess((scriptNode, { result }) => {
1,371✔
90
      if (
14,580✔
91
        scriptNode.type === AST_NODE_TYPES.ExpressionStatement &&
18,693✔
92
        scriptNode.expression.type === AST_NODE_TYPES.JSXFragment &&
93
        scriptNode.range[0] === startOffset &&
94
        result.ast.body.includes(scriptNode)
95
      ) {
96
        const index = result.ast.body.indexOf(scriptNode);
1,371✔
97
        const rootFragment = ((result.ast as AstroProgram).body[index] =
1,371✔
98
          scriptNode.expression as unknown as AstroFragment);
99
        delete (rootFragment as any).closingFragment;
1,371✔
100
        delete (rootFragment as any).openingFragment;
1,371✔
101
        rootFragment.type = "AstroFragment";
1,371✔
102

103
        return true;
1,371✔
104
      }
105
      return false;
13,209✔
106
    });
107
  }
108

109
  walkElements(
1,404✔
110
    resultTemplate.ast,
111
    // eslint-disable-next-line complexity -- Template generation handles several Astro node forms.
112
    (node) => {
113
      if (node.type === "AstroFrontmatter") {
13,590✔
114
        if (fragmentOpened) {
968!
UNCOV
115
          script.appendVirtualScript("</>;");
×
UNCOV
116
          fragmentOpened = false;
×
117
        }
118
        let start = node.start;
968✔
119

120
        // Skip until a front matter fence is found.
121
        // If there is whitespace before the fence,
122
        // the Node's start method will return the first whitespace, so this needs to be adjusted.
123
        while (code[start] !== "-") {
968✔
124
          start++;
114✔
125
        }
126

127
        script.appendOriginal(start);
968✔
128
        script.skipOriginalOffset(3);
968✔
129
        const end = node.end;
968✔
130
        const scriptStart = start + 3;
968✔
131
        let scriptEnd = end - 3;
968✔
132
        let endChar: string;
133
        while (
968✔
134
          scriptStart < scriptEnd - 1 &&
5,783✔
135
          (endChar = code[scriptEnd - 1]) &&
136
          !endChar.trim()
137
        ) {
138
          scriptEnd--;
1,021✔
139
        }
140
        script.appendOriginal(scriptEnd);
968✔
141

142
        script.appendVirtualScript("\n;");
968✔
143
        script.skipOriginalOffset(end - scriptEnd);
968✔
144

145
        script.restoreContext.addRestoreNodeProcess(
968✔
146
          (_scriptNode, { result }) => {
147
            for (let index = 0; index < result.ast.body.length; index++) {
968✔
148
              const st = result.ast.body[index] as TSESTree.Node;
2,719✔
149
              if (st.type === AST_NODE_TYPES.EmptyStatement) {
2,719✔
150
                if (st.range[0] === scriptEnd && st.range[1] === scriptEnd) {
693✔
151
                  result.ast.body.splice(index, 1);
693✔
152
                  break;
693✔
153
                }
154
              }
155
            }
156
            return true;
968✔
157
          },
158
        );
159

160
        script.restoreContext.addToken(AST_TOKEN_TYPES.Punctuator, [
968✔
161
          start,
162
          start + 3,
163
        ]);
164
        script.restoreContext.addToken(AST_TOKEN_TYPES.Punctuator, [
968✔
165
          end - 3,
166
          end,
167
        ]);
168
      } else if (isJSXElementOrFragment(node)) {
12,622✔
169
        const start = node.start;
4,981✔
170
        script.appendOriginal(start);
4,981✔
171
        if (!fragmentOpened) {
4,981✔
172
          openRootFragment(start);
1,112✔
173
        }
174

175
        if (node.type === "JSXFragment") {
4,981✔
176
          if (isSyntheticFragment(node)) {
60✔
177
            const start = node.start;
30✔
178
            script.appendOriginal(start);
30✔
179
            script.appendVirtualScript("<>");
30✔
180
            script.restoreContext.addRestoreNodeProcess((scriptNode) => {
30✔
181
              if (
360✔
182
                scriptNode.range[0] === start &&
390✔
183
                scriptNode.type === AST_NODE_TYPES.JSXFragment
184
              ) {
185
                delete (scriptNode as any).openingFragment;
30✔
186
                delete (scriptNode as any).closingFragment;
30✔
187
                const fragmentNode = scriptNode as unknown as AstroFragment;
30✔
188
                fragmentNode.type = "AstroFragment";
30✔
189
                const last =
190
                  fragmentNode.children[fragmentNode.children.length - 1];
30✔
191
                if (last && fragmentNode.range[1] < last.range[1]) {
30!
NEW
192
                  fragmentNode.range[1] = last.range[1];
×
NEW
193
                  fragmentNode.loc.end = ctx.getLocFromIndex(
×
194
                    fragmentNode.range[1],
195
                  );
196
                }
197
                return true;
30✔
198
              }
199
              return false;
330✔
200
            });
201
          }
202
        } else {
203
          const tagType = getTagType(node);
4,921✔
204

205
          // Process for attributes
206
          for (const attr of node.openingElement.attributes) {
4,921✔
207
            const analyzed = analyzeAttribute(attr);
3,220✔
208

209
            if (
3,220✔
210
              analyzed.kind === "literal-value" ||
5,092✔
211
              analyzed.kind === "empty" ||
212
              analyzed.kind === "expression" ||
213
              analyzed.kind === "template-literal"
214
            ) {
215
              const attrName = getAttributeName(analyzed);
3,115✔
216
              const needPunctuatorsProcess =
217
                tagType === "component"
3,115✔
218
                  ? /[.:@]/u.test(attrName)
219
                  : /[.@]/u.test(attrName) || attrName.startsWith(":");
4,516✔
220

221
              if (needPunctuatorsProcess) {
3,115✔
222
                processAttributePunctuators(analyzed);
272✔
223
              }
224
            }
225
            if (analyzed.kind === "literal-value") {
3,220✔
226
              const raw = code.slice(
2,209✔
227
                analyzed.node.value.start,
228
                analyzed.node.value.end,
229
              );
230
              if (raw && !raw.startsWith('"') && !raw.startsWith("'")) {
2,209✔
231
                // If the literal value is not quoted in the source,
232
                // quote it in the virtual script so that it can be parsed as a valid attribute.
233
                const attrStart = analyzed.node.start;
45✔
234
                const valueStart = analyzed.node.value.start;
45✔
235
                const attrEnd = analyzed.node.end;
45✔
236
                script.appendOriginal(valueStart);
45✔
237
                script.appendVirtualScript('"');
45✔
238
                script.appendOriginal(attrEnd);
45✔
239
                script.appendVirtualScript('"');
45✔
240

241
                script.restoreContext.addRestoreNodeProcess(
45✔
242
                  (scriptNode, context) => {
243
                    if (
555✔
244
                      scriptNode.type === AST_NODE_TYPES.JSXAttribute &&
615✔
245
                      scriptNode.range[0] === attrStart
246
                    ) {
247
                      const attrNode = scriptNode;
45✔
248
                      if (
45✔
249
                        attrNode.value?.type === "Literal" &&
90✔
250
                        typeof attrNode.value.value === "string"
251
                      ) {
252
                        const raw = code.slice(valueStart, attrEnd);
45✔
253
                        attrNode.value.raw = raw;
45✔
254
                        context.findToken(valueStart)!.value = raw;
45✔
255
                        return true;
45✔
256
                      }
257
                    }
258
                    return false;
510✔
259
                  },
260
                );
261
              }
262
            } else if (analyzed.kind === "shorthand") {
1,011✔
263
              const attrName = getAttributeName(analyzed);
45✔
264
              const start = getShorthandAttributeOpeningBraceOffset(
45✔
265
                analyzed.node,
266
              );
267
              script.appendOriginal(start);
45✔
268
              const jsxName = /[\s"'[\]{}]/u.test(attrName)
45!
269
                ? generateUniqueId(attrName)
270
                : attrName;
271
              script.appendVirtualScript(`${jsxName}=`);
45✔
272

273
              script.restoreContext.addRestoreNodeProcess((scriptNode) => {
45✔
274
                if (
2,325✔
275
                  scriptNode.type === AST_NODE_TYPES.JSXAttribute &&
2,400✔
276
                  scriptNode.range[0] === start
277
                ) {
278
                  const attrNode =
279
                    scriptNode as unknown as AstroShorthandAttribute;
45✔
280
                  attrNode.type = "AstroShorthandAttribute";
45✔
281

282
                  const locs = ctx.getLocations(
45✔
283
                    ...attrNode.value.expression.range,
284
                  );
285
                  if (jsxName !== attrName) {
45!
NEW
286
                    attrNode.name.name = attrName;
×
287
                  }
288
                  attrNode.name.range = locs.range;
45✔
289
                  attrNode.name.loc = locs.loc;
45✔
290
                  return true;
45✔
291
                }
292
                return false;
2,280✔
293
              });
294
            } else if (analyzed.kind === "template-literal") {
966✔
295
              const attrStart = analyzed.node.start;
15✔
296
              const valueStart = analyzed.node.value.start;
15✔
297
              const attrEnd = analyzed.node.end;
15✔
298
              script.appendOriginal(valueStart);
15✔
299
              script.appendVirtualScript("{");
15✔
300
              script.appendOriginal(attrEnd);
15✔
301
              script.appendVirtualScript("}");
15✔
302

303
              script.restoreContext.addRestoreNodeProcess((scriptNode) => {
15✔
304
                if (
315✔
305
                  scriptNode.type === AST_NODE_TYPES.JSXAttribute &&
345✔
306
                  scriptNode.range[0] === attrStart
307
                ) {
308
                  const attrNode =
309
                    scriptNode as unknown as AstroTemplateLiteralAttribute;
15✔
310
                  attrNode.type = "AstroTemplateLiteralAttribute";
15✔
311
                  return true;
15✔
312
                }
313
                return false;
300✔
314
              });
315
            }
316
          }
317

318
          // Process for start tag close
319
          const closing = getSelfClosingTag(node);
4,921✔
320
          if (closing && closing.end === ">") {
4,921✔
321
            script.appendOriginal(closing.offset - 1);
244✔
322
            script.appendVirtualScript("/");
244✔
323
          }
324

325
          const tagName = getJsxName(node.openingElement.name);
4,921✔
326

327
          // Process for raw text
328
          if (
4,921✔
329
            tagName === "script" ||
13,788✔
330
            tagName === "style" ||
331
            node.openingElement.attributes.some((attr) => {
332
              const analyzed = analyzeAttribute(attr);
2,935✔
333
              if (analyzed.kind === "spread") return false;
2,935✔
334
              return getAttributeName(analyzed) === "is:raw";
2,875✔
335
            })
336
          ) {
337
            const text = getRawTextContent(node);
690✔
338
            if (text && text.value) {
690✔
339
              const styleNodeStart = node.start;
645✔
340
              script.appendOriginal(text.start);
645✔
341
              script.skipOriginalOffset(text.value.length);
645✔
342

343
              script.restoreContext.addRestoreNodeProcess((scriptNode) => {
645✔
344
                if (
40,980✔
345
                  scriptNode.type === AST_NODE_TYPES.JSXElement &&
44,835✔
346
                  scriptNode.range[0] === styleNodeStart
347
                ) {
348
                  const textNode: AstroRawText = {
645✔
349
                    type: "AstroRawText",
350
                    value: text.value,
351
                    raw: text.value,
352
                    parent: scriptNode as JSXElement,
353
                    ...ctx.getLocations(text.start, text.end),
354
                  };
355
                  scriptNode.children = [
645✔
356
                    textNode as unknown as TSESTree.JSXText,
357
                  ];
358
                  return true;
645✔
359
                }
360
                return false;
40,335✔
361
              });
362
              script.restoreContext.addToken(AST_TOKEN_TYPES.JSXText, [
645✔
363
                text.start,
364
                text.end,
365
              ]);
366
            }
367
          }
368
        }
369
      } else if (node.type === "AstroComment") {
7,641✔
370
        const start = node.start;
300✔
371
        const end = node.end;
300✔
372
        const length = end - start;
300✔
373
        script.appendOriginal(start);
300✔
374
        if (!fragmentOpened) {
300✔
375
          openRootFragment(start);
105✔
376
        }
377
        script.appendOriginal(start + 1);
300✔
378
        script.appendVirtualScript(`></`);
300✔
379
        script.skipOriginalOffset(length - 2);
300✔
380
        script.appendOriginal(end);
300✔
381

382
        script.restoreContext.addRestoreNodeProcess((scriptNode, context) => {
300✔
383
          if (
11,940✔
384
            scriptNode.range[0] === start &&
12,555✔
385
            scriptNode.type === AST_NODE_TYPES.JSXFragment
386
          ) {
387
            delete (scriptNode as any).children;
300✔
388
            delete (scriptNode as any).openingFragment;
300✔
389
            delete (scriptNode as any).closingFragment;
300✔
390
            delete (scriptNode as any).expression;
300✔
391
            const commentNode = scriptNode as unknown as AstroHTMLComment;
300✔
392
            commentNode.type = "AstroHTMLComment";
300✔
393
            commentNode.value = node.value;
300✔
394

395
            context.addRemoveToken(
300✔
396
              (token: TSESTree.Token) =>
397
                token.value === "<" && token.range[0] === scriptNode.range[0],
10,365✔
398
            );
399
            context.addRemoveToken(
300✔
400
              (token: TSESTree.Token) =>
401
                token.value === ">" && token.range[1] === scriptNode.range[1],
10,065✔
402
            );
403
            return true;
300✔
404
          }
405
          return false;
11,640✔
406
        });
407
        script.restoreContext.addToken("HTMLComment" as AST_TOKEN_TYPES, [
300✔
408
          start,
409
          start + length,
410
        ]);
411
      } else if (node.type === "AstroDoctype") {
7,341✔
412
        const start = node.start;
60✔
413
        const end = node.end;
60✔
414
        const length = end - start;
60✔
415
        script.appendOriginal(start);
60✔
416
        if (!fragmentOpened) {
60✔
417
          openRootFragment(start);
45✔
418
        }
419
        script.appendOriginal(start + 1);
60✔
420
        script.appendVirtualScript(`></`);
60✔
421
        script.skipOriginalOffset(length - 2);
60✔
422
        script.appendOriginal(end);
60✔
423

424
        script.restoreContext.addRestoreNodeProcess((scriptNode, context) => {
60✔
425
          if (
420✔
426
            scriptNode.range[0] === start &&
615✔
427
            scriptNode.type === AST_NODE_TYPES.JSXFragment
428
          ) {
429
            delete (scriptNode as any).children;
60✔
430
            delete (scriptNode as any).openingFragment;
60✔
431
            delete (scriptNode as any).closingFragment;
60✔
432
            delete (scriptNode as any).expression;
60✔
433
            const doctypeNode = scriptNode as unknown as AstroDoctype;
60✔
434
            doctypeNode.type = "AstroDoctype";
60✔
435

436
            context.addRemoveToken(
60✔
437
              (token: TSESTree.Token) =>
438
                token.value === "<" && token.range[0] === scriptNode.range[0],
2,355✔
439
            );
440
            context.addRemoveToken(
60✔
441
              (token: TSESTree.Token) =>
442
                token.value === ">" && token.range[1] === scriptNode.range[1],
2,295✔
443
            );
444
            return true;
60✔
445
          }
446
          return false;
360✔
447
        });
448
        script.restoreContext.addToken("HTMLDocType" as AST_TOKEN_TYPES, [
60✔
449
          start,
450
          end,
451
        ]);
452
      } else {
453
        const start = node.start;
7,281✔
454
        script.appendOriginal(start);
7,281✔
455
        if (!fragmentOpened) {
7,281✔
456
          openRootFragment(start);
109✔
457
        }
458
      }
459
    },
460
    (node) => {
461
      if (node.type === "JSXElement") {
13,590✔
462
        const closing = getSelfClosingTag(node);
4,921✔
463
        if (!closing && node.closingElement == null) {
4,921!
NEW
464
          const offset = calcContentEndOffset(node);
×
NEW
465
          script.appendOriginal(offset);
×
NEW
466
          script.appendVirtualScript(
×
467
            `</${getJsxName(node.openingElement.name)}>`,
468
          );
NEW
469
          script.restoreContext.addRestoreNodeProcess((scriptNode, context) => {
×
NEW
470
            const parent = context.getParent(scriptNode)!;
×
NEW
471
            if (
×
472
              scriptNode.range[0] === offset &&
×
473
              scriptNode.type === AST_NODE_TYPES.JSXClosingElement &&
474
              parent.type === AST_NODE_TYPES.JSXElement
NEW
475
            ) {
×
476
              removeAllScopeAndVariableAndReference(scriptNode, {
477
                visitorKeys: context.result.visitorKeys,
478
                scopeManager: context.result.scopeManager!,
479
              });
NEW
480
              parent.closingElement = null;
×
NEW
481
              return true;
×
482
            }
NEW
483
            return false;
×
484
          });
485
        }
486
      }
487

488
      if (node.type === "JSXFragment" && isSyntheticFragment(node)) {
13,590✔
489
        script.appendOriginal(node.end);
30✔
490
        script.appendVirtualScript("</>");
30✔
491
      }
492
    },
493
  );
494
  if (fragmentOpened) {
1,404✔
495
    const last = findLastJSXNode(resultTemplate.ast);
1,371✔
496
    if (last) {
1,371✔
497
      script.appendOriginal(last.end);
1,371✔
498
    }
499
    script.appendVirtualScript("</>;");
1,371✔
500
  }
501

502
  script.appendOriginal(code.length);
1,404✔
503

504
  return script;
1,404✔
505

506
  /**
507
   * Walk template nodes in source order from the compiler AST.
508
   *
509
   * The traversal starts with `AstroRoot`. Root-only source ranges such as
510
   * frontmatter fences are handled in the root branch so they do not become
511
   * node-shaped intermediate children.
512
   */
NEW
513
  function walkElements(
×
514
    parent: AstroRootNode,
515
    enter: (node: AstroFrontmatterNode | TemplateNode) => void,
516
    leave: (node: AstroFrontmatterNode | TemplateNode) => void,
517
  ): void {
518
    const nodes: (TemplateNode | AstroFrontmatterNode)[] = [...parent.body];
1,404✔
519

520
    const frontmatter = parent.frontmatter;
1,404✔
521
    if (frontmatter && !isEmptyFrontmatter(frontmatter)) {
1,404✔
522
      // Walk children in source order, inserting the frontmatter node when we reach its position.
523
      let insertIndex = nodes.findIndex(
968✔
524
        (child) => frontmatter.start <= child.start,
950✔
525
      );
526

527
      if (insertIndex < 0) {
968✔
528
        insertIndex = nodes.length;
18✔
529
      }
530
      // Remove whitespace-only text nodes before the frontmatter node.
531
      while (insertIndex > 0 && isWhitespaceJSXText(nodes[insertIndex - 1])) {
968!
NEW
532
        nodes.splice(insertIndex - 1, 1);
×
NEW
533
        insertIndex--;
×
534
      }
535
      // Remove whitespace-only text nodes after the frontmatter node.
536
      while (
968✔
537
        insertIndex < nodes.length &&
2,524✔
538
        isWhitespaceJSXText(nodes[insertIndex])
539
      ) {
540
        nodes.splice(insertIndex, 1);
303✔
541
      }
542
      nodes.splice(insertIndex, 0, frontmatter);
968✔
543
    }
544

545
    // Remove whitespace-only text nodes at the start of the root.
546
    while (nodes.length > 0 && isWhitespaceJSXText(nodes[0])) {
1,404✔
547
      nodes.shift();
75✔
548
    }
549
    // Remove whitespace-only text nodes at the end of the root.
550
    while (nodes.length > 0 && isWhitespaceJSXText(nodes[nodes.length - 1])) {
1,404✔
551
      nodes.pop();
1,220✔
552
    }
553

554
    for (const child of nodes) {
1,404✔
555
      walkChild(child, enter, leave);
4,106✔
556
    }
557
  }
558

559
  /** Get raw text content for script, style, and raw nodes. */
NEW
560
  function getRawTextContent(
×
561
    node: JSXElementNode,
562
  ): { start: number; end: number; value: string } | null {
563
    if (node.closingElement == null) {
690✔
564
      return null;
15✔
565
    }
566
    const start = node.openingElement.end;
675✔
567
    const end = node.closingElement.start;
675✔
568
    if (start >= end) {
675✔
569
      return null;
30✔
570
    }
571
    return {
645✔
572
      start,
573
      end,
574
      value: code.slice(start, end),
575
    };
576
  }
577

578
  /** Get self-closing tag metadata. */
NEW
579
  function getSelfClosingTag(node: JSXElementNode): null | {
×
580
    offset: number;
581
    end: "/>" | ">";
582
  } {
583
    if (!node.openingElement?.selfClosing || node.closingElement) {
9,842✔
584
      return null;
7,148✔
585
    }
586
    const offset = node.openingElement.end;
2,694✔
587
    return {
2,694✔
588
      offset,
589
      end: code.startsWith("/>", offset - 2) ? "/>" : ">",
2,694✔
590
    };
591
  }
592

593
  /** Get the Astro attribute kind represented by a compiler node. */
NEW
594
  function analyzeAttribute(attr: AttributeNode): AnalyzedAttributeData {
×
595
    if (attr.type === "JSXSpreadAttribute") {
6,155✔
596
      return {
120✔
597
        kind: "spread",
598
        node: attr,
599
      };
600
    }
601
    if (!attr.value) {
6,035✔
602
      return {
360✔
603
        kind: "empty",
604
        node: attr as JSXAttributeNode & { value: null },
605
      };
606
    }
607
    if (isShorthandAttribute(attr)) {
5,675✔
608
      return {
90✔
609
        kind: "shorthand",
610
        node: attr,
611
      };
612
    }
613
    if (attr.value.type === "Literal") {
5,585✔
614
      return {
4,373✔
615
        kind: "literal-value",
616
        node: attr as JSXAttributeNode & { value: LiteralNode },
617
      };
618
    }
619
    if (
1,212✔
620
      attr.value.type === "JSXExpressionContainer" &&
2,424✔
621
      code[attr.value.start] === "`"
622
    ) {
623
      return {
30✔
624
        kind: "template-literal",
625
        node: attr as JSXAttributeNode & { value: JSXExpressionContainerNode },
626
      };
627
    }
628
    return {
1,182✔
629
      kind: "expression",
630
      node: attr as JSXAttributeNode & { value: JSXExpressionContainerNode },
631
    };
632
  }
633

634
  /**
635
   * Get the source offset of the opening `{` for an Astro shorthand attribute.
636
   *
637
   * The compiler represents `<img {src}>` as a JSXAttribute that starts at
638
   * `src`, while the end offset still includes `}`. The virtual JSX needs to
639
   * insert `src=` before the original `{src}`, so shorthand processing must use
640
   * the brace offset instead of `attr.start`.
641
   */
NEW
642
  function getShorthandAttributeOpeningBraceOffset(
×
643
    attr: JSXAttributeNode,
644
  ): number {
645
    return code[attr.start - 1] === "{" ? attr.start - 1 : attr.start;
45!
646
  }
647

648
  /** Check whether a node is whitespace-only text. */
NEW
649
  function isWhitespaceJSXText(
×
650
    node: TemplateNode | AstroFrontmatterNode,
651
  ): boolean {
652
    return (
6,697✔
653
      node.type === "JSXText" && code.slice(node.start, node.end).trim() === ""
9,515✔
654
    );
655
  }
656

657
  /**
658
   * Process for attribute punctuators
659
   */
NEW
660
  function processAttributePunctuators(
×
661
    attr:
662
      | AnalyzedEmptyAttributeData
663
      | AnalyzedLiteralValueAttributeData
664
      | AnalyzedTemplateLiteralAttributeData
665
      | AnalyzedExpressionAttributeData,
666
  ) {
667
    const name = getAttributeName(attr);
272✔
668
    const start = attr.node.name.start;
272✔
669
    let targetIndex = start;
272✔
670
    let colonOffset: number | undefined;
671
    for (let index = 0; index < name.length; index++) {
272✔
672
      const char = name[index];
3,121✔
673
      if (char !== ":" && char !== "." && char !== "@") {
3,121✔
674
        continue;
2,789✔
675
      }
676
      if (index === 0) {
332✔
677
        targetIndex++;
60✔
678
      }
679
      const punctuatorIndex = start + index;
332✔
680
      script.appendOriginal(punctuatorIndex);
332✔
681
      script.skipOriginalOffset(1);
332✔
682
      script.appendVirtualScript(`_`);
332✔
683

684
      if (char === ":" && index !== 0 && colonOffset == null) {
332✔
685
        colonOffset = index;
212✔
686
      }
687
    }
688
    if (colonOffset != null) {
272✔
689
      const punctuatorIndex = start + colonOffset;
212✔
690
      script.restoreContext.addToken(AST_TOKEN_TYPES.JSXIdentifier, [
212✔
691
        start,
692
        punctuatorIndex,
693
      ]);
694
      script.restoreContext.addToken(AST_TOKEN_TYPES.Punctuator, [
212✔
695
        punctuatorIndex,
696
        punctuatorIndex + 1,
697
      ]);
698
      script.restoreContext.addToken(AST_TOKEN_TYPES.JSXIdentifier, [
212✔
699
        punctuatorIndex + 1,
700
        start + name.length,
701
      ]);
702
    } else {
703
      script.restoreContext.addToken(AST_TOKEN_TYPES.JSXIdentifier, [
60✔
704
        start,
705
        start + name.length,
706
      ]);
707
    }
708
    script.restoreContext.addRestoreNodeProcess((scriptNode, context) => {
272✔
709
      if (
6,806✔
710
        scriptNode.type === AST_NODE_TYPES.JSXAttribute &&
7,318✔
711
        scriptNode.range[0] === targetIndex
712
      ) {
713
        const baseNameNode = scriptNode.name;
272✔
714
        if (colonOffset != null) {
272✔
715
          const nameNode = baseNameNode as TSESTree.JSXNamespacedName;
212✔
716
          nameNode.type = AST_NODE_TYPES.JSXNamespacedName;
212✔
717
          nameNode.namespace = {
212✔
718
            type: AST_NODE_TYPES.JSXIdentifier,
719
            name: name.slice(0, colonOffset),
720
            ...ctx.getLocations(
721
              baseNameNode.range[0],
722
              baseNameNode.range[0] + colonOffset,
723
            ),
724
            parent: undefined as never,
725
          };
726
          nameNode.name = {
212✔
727
            type: AST_NODE_TYPES.JSXIdentifier,
728
            name: name.slice(colonOffset + 1),
729
            ...ctx.getLocations(
730
              baseNameNode.range[0] + colonOffset + 1,
731
              baseNameNode.range[1],
732
            ),
733
            parent: undefined as never,
734
          };
735
          scriptNode.name = nameNode;
212✔
736
          nameNode.namespace.parent = nameNode;
212✔
737
          nameNode.name.parent = nameNode;
212✔
738
        } else {
739
          if (baseNameNode.type === AST_NODE_TYPES.JSXIdentifier) {
60!
740
            const nameNode = baseNameNode;
60✔
741
            nameNode.name = name;
60✔
742
            scriptNode.name = nameNode;
60✔
743
          } else {
744
            const nameNode = baseNameNode;
×
NEW
745
            nameNode.namespace.name = name.slice(
×
746
              baseNameNode.namespace.range[0] - start,
747
              baseNameNode.namespace.range[1] - start,
748
            );
NEW
749
            nameNode.name.name = name.slice(
×
750
              baseNameNode.name.range[0] - start,
751
              baseNameNode.name.range[1] - start,
752
            );
753
            scriptNode.name = nameNode;
×
754
            nameNode.namespace.parent = nameNode;
×
755
            nameNode.name.parent = nameNode;
×
756
          }
757
        }
758
        context.addRemoveToken(
272✔
759
          (token) =>
760
            token.range[0] === baseNameNode.range[0] &&
4,751✔
761
            token.range[1] === baseNameNode.range[1],
762
        );
763
        return true;
272✔
764
      }
765
      return false;
6,534✔
766
    });
767
  }
768

769
  /**
770
   * Generate unique id
771
   */
772
  function generateUniqueId(base: string) {
×
UNCOV
773
    let candidate = `$_${base.replace(/\W/g, "_")}${uniqueIdSeq++}`;
×
NEW
774
    while (usedUniqueIds.has(candidate) || code.includes(candidate)) {
×
775
      candidate = `$_${base.replace(/\W/g, "_")}${uniqueIdSeq++}`;
×
776
    }
UNCOV
777
    usedUniqueIds.add(candidate);
×
UNCOV
778
    return candidate;
×
779
  }
780

781
  /**
782
   * Find the last JSXNode.
783
   * Basically, it returns the last element of AstroRootNode.body.
784
   * However, if the last element is a JSXTextNode, and its text is whitespace,
785
   * and all preceding elements are AstroCommentNode, it returns the last AstroCommentNode.
786
   */
NEW
787
  function findLastJSXNode(ast: AstroRootNode): LocatedNode | null {
×
788
    const body = ast.body;
1,371✔
789
    if (body.length === 0) {
1,371!
NEW
790
      return null;
×
791
    }
792
    const lastNode = body[body.length - 1];
1,371✔
793
    if (
1,371✔
794
      isWhitespaceJSXText(lastNode) &&
3,811✔
795
      body.length >= 2 &&
796
      body.slice(0, -1).every((node) => node.type === "AstroComment")
1,265✔
797
    ) {
798
      return body[body.length - 2];
30✔
799
    }
800
    return lastNode;
1,341✔
801
  }
802
}
803

804
/** Walk one compiler child node. */
805
function walkChild(
1✔
806
  node: AstroFrontmatterNode | TemplateNode,
807
  enter: (node: AstroFrontmatterNode | TemplateNode) => void,
808
  leave: (node: AstroFrontmatterNode | TemplateNode) => void,
809
) {
810
  enter(node);
13,590✔
811
  if (isJSXElementOrFragment(node)) {
13,590✔
812
    for (const child of node.children) {
4,981✔
813
      if (child.type === "AstroScript") continue;
9,587✔
814
      walkChild(child, enter, leave);
9,272✔
815
    }
816
  } else if (node.type === "JSXExpressionContainer") {
8,609✔
817
    // The compiler AST keeps template nodes that appear inside `{...}` under
818
    // the ESTree expression subtree, so pass only template-shaped descendants
819
    // through the same enter/leave hooks.
820
    walkExpression(node.expression, enter, leave);
757✔
821
  }
822
  leave(node);
13,590✔
823
}
824

825
/** Walk one compiler expression node. */
826
function walkExpression(
1✔
827
  node: UnknownNode,
828
  enter: (node: AstroFrontmatterNode | TemplateNode) => void,
829
  leave: (node: AstroFrontmatterNode | TemplateNode) => void,
830
) {
831
  const walked = new Set<UnknownNode>();
757✔
832
  walk(node, (child, _parents, ctx) => {
833
    if (walked.has(child)) {
2,115!
NEW
834
      return;
×
835
    }
836
    walked.add(child);
2,115✔
837
    if (isWalkableNode(child)) {
2,115✔
838
      walkChild(child, enter, leave);
212✔
839
      ctx.skipChildren();
212✔
840
    }
841
  });
842
}
843

844
/**
845
 * Check whether the given node is a walkable template node that should be passed through the enter/leave hooks.
846
 */
847
function isWalkableNode(
1✔
848
  node: UnknownNode,
849
): node is AstroFrontmatterNode | TemplateNode {
850
  return (
2,115✔
851
    isJSXElementOrFragment(node) ||
852
    node.type === "AstroComment" ||
853
    node.type === "AstroDoctype" ||
854
    node.type === "JSXExpressionContainer" ||
855
    node.type === "JSXText"
856
  );
857
}
858

859
/**
860
 * Check whether the frontmatter is empty (i.e. contains no characters).
861
 * In this case, the frontmatter node is still generated by the compiler,
862
 * but it has no source range. We can identify such empty frontmatter by checking if the start and end offsets are the same.
863
 */
864
function isEmptyFrontmatter(node: AstroFrontmatterNode): boolean {
1✔
865
  return node.start === node.end;
1,404✔
866
}
867

868
/** Convert a compiler JSX name node to source text. */
869
function getJsxName(nameNode: JSXNameNode): string {
1✔
870
  if (nameNode.type === "JSXIdentifier") {
16,209✔
871
    return nameNode.name;
16,179✔
872
  }
873
  if (nameNode.type === "JSXMemberExpression") {
30✔
874
    return `${getJsxName(nameNode.object)}.${getJsxName(nameNode.property)}`;
30✔
875
  }
NEW
876
  if (nameNode.type === "JSXNamespacedName") {
×
NEW
877
    return `${getJsxName(nameNode.namespace)}:${getJsxName(nameNode.name)}`;
×
878
  }
NEW
879
  return "";
×
880
}
881

882
/** Get the Astro tag category for a traversal node. */
883
function getTagType(
1✔
884
  node: JSXElementNode,
885
): "element" | "component" | "custom-element" {
886
  const name = getJsxName(node.openingElement.name);
4,921✔
887
  if (/^[A-Z]/u.test(name) || name.includes(".")) {
4,921✔
888
    return "component";
931✔
889
  }
890
  if (name.includes("-")) {
3,990!
NEW
891
    return "custom-element";
×
892
  }
893
  return "element";
3,990✔
894
}
895

896
/** Calculate where an element without a closing tag should end. */
897
function calcContentEndOffset(node: JSXElementNode): number {
1✔
NEW
898
  const children = node.children;
×
NEW
899
  const lastChild = children[children.length - 1];
×
NEW
900
  if (lastChild) {
×
NEW
901
    return lastChild.end;
×
902
  }
NEW
903
  return node.openingElement.end;
×
904
}
905

906
/** Get an Astro attribute name. */
907
function getAttributeName(
2!
908
  attr:
909
    | AnalyzedEmptyAttributeData
910
    | AnalyzedShorthandAttributeData
911
    | AnalyzedLiteralValueAttributeData
912
    | AnalyzedTemplateLiteralAttributeData
913
    | AnalyzedExpressionAttributeData,
914
): string {
915
  if (attr.kind === "shorthand") {
6,307✔
916
    return getJsxName(attr.node.name).replace(/\}$/u, "");
90✔
917
  }
918
  return getJsxName(attr.node.name);
6,217✔
919
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc