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

ota-meshi / astro-eslint-parser / 27493511548

14 Jun 2026 08:39AM UTC coverage: 81.714% (-0.09%) from 81.805%
27493511548

Pull #435

github

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

636 of 842 branches covered (75.53%)

Branch coverage included in aggregate %.

306 of 346 new or added lines in 8 files covered. (88.44%)

9 existing lines in 4 files now uncovered.

1214 of 1422 relevant lines covered (85.37%)

54058.43 hits per line

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

88.5
/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-parser/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
  isNode,
34
  isShorthandAttribute,
35
  isSyntheticFragment,
36
} from "./astro-parser/node";
1✔
37
import { getKeys } from "../traverse";
1✔
38

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

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

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

84
  let fragmentOpened = false;
1,404✔
85

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

505
  return script;
1,404✔
506

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

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

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

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

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

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

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

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

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

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

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

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

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

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

805
/** Walk one compiler child node. */
806
function walkChild(
1✔
807
  node: AstroFrontmatterNode | TemplateNode,
808
  enter: (node: AstroFrontmatterNode | TemplateNode) => void,
809
  leave: (node: AstroFrontmatterNode | TemplateNode) => void,
810
) {
811
  enter(node);
13,905✔
812
  if (isJSXElementOrFragment(node)) {
13,905✔
813
    for (const child of node.children) {
4,981✔
814
      walkChild(child, enter, leave);
9,587✔
815
    }
816
  } else if (node.type === "JSXExpressionContainer") {
8,924✔
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,905✔
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
  const buffer: UnknownNode[] = [node];
757✔
833
  while (buffer.length > 0) {
757✔
834
    const current = buffer.pop()!;
2,872✔
835
    if (walked.has(current)) {
2,872!
NEW
836
      continue;
×
837
    }
838
    walked.add(current);
2,872✔
839

840
    if (isWalkableNode(current)) {
2,872✔
841
      // If the node is a walkable template node,
842
      // walk it with the same walker as the main traversal
843
      // so that all template-shaped nodes inside it are also passed through the enter/leave hooks.
844
      walkChild(current, enter, leave);
212✔
845
    } else {
846
      const keys = getKeys(current);
2,660✔
847
      const children: UnknownNode[] = [];
2,660✔
848
      for (const key of keys) {
2,660✔
849
        const value: unknown = (current as any)[key];
2,070✔
850
        if (Array.isArray(value)) {
2,070✔
851
          for (const element of value) {
441✔
852
            if (isNode(element)) {
486✔
853
              children.push(element);
486✔
854
            }
855
          }
856
        } else if (isNode(value)) {
1,629✔
857
          children.push(value);
1,629✔
858
        }
859
      }
860

861
      // Add child nodes to the buffer.
862
      // A stack (LIFO) is used because child nodes need to be processed before the next sibling nodes.
863
      buffer.push(...children.sort((a, b) => b.start - a.start));
2,660✔
864
    }
865
  }
866
}
867

868
/**
869
 * Check whether the given node is a walkable template node that should be passed through the enter/leave hooks.
870
 */
871
function isWalkableNode(
1✔
872
  node: UnknownNode,
873
): node is AstroFrontmatterNode | TemplateNode {
874
  return (
2,872✔
875
    isJSXElementOrFragment(node) ||
876
    node.type === "AstroComment" ||
877
    node.type === "AstroDoctype" ||
878
    node.type === "JSXExpressionContainer" ||
879
    node.type === "JSXText"
880
  );
881
}
882

883
/**
884
 * Check whether the frontmatter is empty (i.e. contains no characters).
885
 * In this case, the frontmatter node is still generated by the compiler,
886
 * but it has no source range. We can identify such empty frontmatter by checking if the start and end offsets are the same.
887
 */
888
function isEmptyFrontmatter(node: AstroFrontmatterNode): boolean {
1✔
889
  return node.start === node.end;
1,404✔
890
}
891

892
/** Convert a compiler JSX name node to source text. */
893
function getJsxName(nameNode: JSXNameNode): string {
1✔
894
  if (nameNode.type === "JSXIdentifier") {
16,209✔
895
    return nameNode.name;
16,179✔
896
  }
897
  if (nameNode.type === "JSXMemberExpression") {
30✔
898
    return `${getJsxName(nameNode.object)}.${getJsxName(nameNode.property)}`;
30✔
899
  }
NEW
900
  if (nameNode.type === "JSXNamespacedName") {
×
NEW
901
    return `${getJsxName(nameNode.namespace)}:${getJsxName(nameNode.name)}`;
×
902
  }
NEW
903
  return "";
×
904
}
905

906
/** Get the Astro tag category for a traversal node. */
907
function getTagType(
1✔
908
  node: JSXElementNode,
909
): "element" | "component" | "custom-element" {
910
  const name = getJsxName(node.openingElement.name);
4,921✔
911
  if (/^[A-Z]/u.test(name) || name.includes(".")) {
4,921✔
912
    return "component";
931✔
913
  }
914
  if (name.includes("-")) {
3,990!
NEW
915
    return "custom-element";
×
916
  }
917
  return "element";
3,990✔
918
}
919

920
/** Calculate where an element without a closing tag should end. */
921
function calcContentEndOffset(node: JSXElementNode): number {
1✔
NEW
922
  const children = node.children;
×
NEW
923
  const lastChild = children[children.length - 1];
×
NEW
924
  if (lastChild) {
×
NEW
925
    return lastChild.end;
×
926
  }
NEW
927
  return node.openingElement.end;
×
928
}
929

930
/** Get an Astro attribute name. */
931
function getAttributeName(
2!
932
  attr:
933
    | AnalyzedEmptyAttributeData
934
    | AnalyzedShorthandAttributeData
935
    | AnalyzedLiteralValueAttributeData
936
    | AnalyzedTemplateLiteralAttributeData
937
    | AnalyzedExpressionAttributeData,
938
): string {
939
  if (attr.kind === "shorthand") {
6,307✔
940
    return getJsxName(attr.node.name).replace(/\}$/u, "");
90✔
941
  }
942
  return getJsxName(attr.node.name);
6,217✔
943
}
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