@markuplint/tagged-template-literal-parser 5.0.0-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.ja.md +102 -0
- package/ARCHITECTURE.md +102 -0
- package/CHANGELOG.md +10 -0
- package/LICENSE +21 -0
- package/README.md +55 -0
- package/SKILL.md +57 -0
- package/docs/maintenance.ja.md +96 -0
- package/docs/maintenance.md +96 -0
- package/lib/find-template-literals.d.ts +37 -0
- package/lib/find-template-literals.js +116 -0
- package/lib/index.d.ts +7 -0
- package/lib/index.js +7 -0
- package/lib/parser.d.ts +36 -0
- package/lib/parser.js +109 -0
- package/package.json +34 -0
- package/src/find-template-literals.spec.ts +161 -0
- package/src/find-template-literals.ts +160 -0
- package/src/index.spec.ts +423 -0
- package/src/index.ts +8 -0
- package/src/parser.ts +122 -0
- package/tsconfig.build.json +9 -0
- package/tsconfig.build.tsbuildinfo +1 -0
- package/tsconfig.json +17 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { AST_NODE_TYPES, parse } from '@typescript-eslint/typescript-estree';
|
|
2
|
+
/** Length of the `${` expression start delimiter */
|
|
3
|
+
const EXPR_START_LEN = 2;
|
|
4
|
+
/** Length of the `}` expression end delimiter */
|
|
5
|
+
const EXPR_END_LEN = 1;
|
|
6
|
+
/**
|
|
7
|
+
* Resolves the tag name from a TaggedTemplateExpression's tag node.
|
|
8
|
+
* For identifiers (e.g., `html`), returns the name directly.
|
|
9
|
+
* For member expressions (e.g., `LitElement.html`), returns the property name.
|
|
10
|
+
* Returns an empty string for unrecognized tag forms.
|
|
11
|
+
*
|
|
12
|
+
* @param tag - The AST node representing the tag expression
|
|
13
|
+
* @returns The resolved tag name, or an empty string if unresolvable
|
|
14
|
+
*/
|
|
15
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
16
|
+
function resolveTagName(tag) {
|
|
17
|
+
switch (tag.type) {
|
|
18
|
+
case AST_NODE_TYPES.Identifier: {
|
|
19
|
+
return tag.name;
|
|
20
|
+
}
|
|
21
|
+
case AST_NODE_TYPES.MemberExpression: {
|
|
22
|
+
if (tag.property.type === AST_NODE_TYPES.Identifier) {
|
|
23
|
+
return tag.property.name;
|
|
24
|
+
}
|
|
25
|
+
return '';
|
|
26
|
+
}
|
|
27
|
+
default: {
|
|
28
|
+
return '';
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Recursively searches an AST for TaggedTemplateExpression nodes matching
|
|
34
|
+
* the specified tag names.
|
|
35
|
+
*
|
|
36
|
+
* @param node - The AST node to search
|
|
37
|
+
* @param tagNames - Set of tag names to match (e.g., `html`, `svg`)
|
|
38
|
+
* @param results - Accumulator for found template literals
|
|
39
|
+
* @param sourceCode - The original source code string
|
|
40
|
+
*/
|
|
41
|
+
function searchTaggedTemplates(
|
|
42
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
43
|
+
node,
|
|
44
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
45
|
+
tagNames,
|
|
46
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
47
|
+
results, sourceCode) {
|
|
48
|
+
if (node.type === AST_NODE_TYPES.TaggedTemplateExpression) {
|
|
49
|
+
const tagName = resolveTagName(node.tag);
|
|
50
|
+
if (tagNames.has(tagName)) {
|
|
51
|
+
const quasi = node.quasi;
|
|
52
|
+
const contentStart = quasi.range[0] + 1; // skip opening backtick
|
|
53
|
+
const contentEnd = quasi.range[1] - 1; // skip closing backtick
|
|
54
|
+
const htmlContent = sourceCode.slice(contentStart, contentEnd);
|
|
55
|
+
const expressions = quasi.expressions.map(expr => ({
|
|
56
|
+
raw: sourceCode.slice(expr.range[0] - EXPR_START_LEN, expr.range[1] + EXPR_END_LEN),
|
|
57
|
+
start: expr.range[0] - EXPR_START_LEN,
|
|
58
|
+
end: expr.range[1] + EXPR_END_LEN,
|
|
59
|
+
}));
|
|
60
|
+
results.push({
|
|
61
|
+
tagName,
|
|
62
|
+
htmlContent,
|
|
63
|
+
contentStart,
|
|
64
|
+
contentEnd,
|
|
65
|
+
expressions,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
// Manual AST traversal via Object.keys instead of typescript-estree's simpleTraverse,
|
|
70
|
+
// because simpleTraverse does not expose enough control over which nodes are visited
|
|
71
|
+
// and would add an additional import dependency for minimal benefit.
|
|
72
|
+
for (const key of Object.keys(node)) {
|
|
73
|
+
if (key === 'parent') {
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
const value = node[key];
|
|
77
|
+
if (value && typeof value === 'object') {
|
|
78
|
+
if (Array.isArray(value)) {
|
|
79
|
+
for (const item of value) {
|
|
80
|
+
if (item && typeof item === 'object' && 'type' in item) {
|
|
81
|
+
searchTaggedTemplates(item, tagNames, results, sourceCode);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
else if ('type' in value) {
|
|
86
|
+
searchTaggedTemplates(value, tagNames, results, sourceCode);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Finds all tagged template literals in a TypeScript/JavaScript source file
|
|
93
|
+
* that match the given tag names. Uses `@typescript-eslint/typescript-estree`
|
|
94
|
+
* to parse the source and recursively searches the AST for
|
|
95
|
+
* `TaggedTemplateExpression` nodes whose tag resolves to one of the specified names.
|
|
96
|
+
*
|
|
97
|
+
* @param sourceCode - The raw TypeScript/JavaScript source code to search
|
|
98
|
+
* @param tagNames - Array of tag function names to match (default: `['html']`)
|
|
99
|
+
* @returns Array of template literal information objects, ordered by their position in the source
|
|
100
|
+
*/
|
|
101
|
+
export function findTemplateLiterals(sourceCode, tagNames = ['html']) {
|
|
102
|
+
const ast = parse(sourceCode, {
|
|
103
|
+
comment: false,
|
|
104
|
+
errorOnUnknownASTType: false,
|
|
105
|
+
jsx: false,
|
|
106
|
+
loc: true,
|
|
107
|
+
range: true,
|
|
108
|
+
tokens: false,
|
|
109
|
+
});
|
|
110
|
+
const tagNameSet = new Set(tagNames);
|
|
111
|
+
const results = [];
|
|
112
|
+
for (const statement of ast.body) {
|
|
113
|
+
searchTaggedTemplates(statement, tagNameSet, results, sourceCode);
|
|
114
|
+
}
|
|
115
|
+
return results;
|
|
116
|
+
}
|
package/lib/index.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module @markuplint/tagged-template-literal-parser
|
|
3
|
+
* Tagged template literal parser for markuplint. Extracts HTML from tagged
|
|
4
|
+
* template literals (e.g., `html\`<div>...</div>\``) in TypeScript/JavaScript
|
|
5
|
+
* files and parses the HTML content for linting.
|
|
6
|
+
*/
|
|
7
|
+
export { TaggedTemplateLiteralParser, parser } from './parser.js';
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module @markuplint/tagged-template-literal-parser
|
|
3
|
+
* Tagged template literal parser for markuplint. Extracts HTML from tagged
|
|
4
|
+
* template literals (e.g., `html\`<div>...</div>\``) in TypeScript/JavaScript
|
|
5
|
+
* files and parses the HTML content for linting.
|
|
6
|
+
*/
|
|
7
|
+
export { TaggedTemplateLiteralParser, parser } from './parser.js';
|
package/lib/parser.d.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { MLASTDocument } from '@markuplint/ml-ast';
|
|
2
|
+
import type { ParseOptions } from '@markuplint/parser-utils';
|
|
3
|
+
import { HtmlParser } from '@markuplint/html-parser';
|
|
4
|
+
/**
|
|
5
|
+
* Parser for tagged template literals containing HTML.
|
|
6
|
+
* Extracts HTML content from tagged template expressions (e.g., `html\`<div>...</div>\``)
|
|
7
|
+
* and delegates HTML parsing to the standard HtmlParser with `${...}` expressions
|
|
8
|
+
* masked as preprocessor-specific blocks.
|
|
9
|
+
*/
|
|
10
|
+
export declare class TaggedTemplateLiteralParser extends HtmlParser {
|
|
11
|
+
#private;
|
|
12
|
+
/**
|
|
13
|
+
* Creates a new parser for tagged template literals.
|
|
14
|
+
*
|
|
15
|
+
* @param tagNames - Tag function names to recognize as HTML templates (default: `['html']`).
|
|
16
|
+
* For example, `['html', 'svg']` would match both `html\`...\`` and `svg\`...\``.
|
|
17
|
+
* Member expressions are resolved to their property name, so `LitElement.html\`...\``
|
|
18
|
+
* matches the tag name `'html'`.
|
|
19
|
+
*/
|
|
20
|
+
constructor(tagNames?: readonly string[]);
|
|
21
|
+
/**
|
|
22
|
+
* Parses a TypeScript/JavaScript source file, extracts tagged template literals
|
|
23
|
+
* matching the configured tag names, and parses their HTML content.
|
|
24
|
+
* Each `${...}` expression within the template is preserved as a
|
|
25
|
+
* `#ps:ttl-expression` preprocessor-specific block in the resulting AST.
|
|
26
|
+
*
|
|
27
|
+
* @param rawCode - The full TypeScript/JavaScript source code
|
|
28
|
+
* @param options - Parse options forwarded to the underlying HTML parser
|
|
29
|
+
* @returns The parsed AST document containing nodes from all matched template literals
|
|
30
|
+
*/
|
|
31
|
+
parse(rawCode: string, options?: ParseOptions): MLASTDocument;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Default singleton parser instance configured to match the `html` tag name.
|
|
35
|
+
*/
|
|
36
|
+
export declare const parser: TaggedTemplateLiteralParser;
|
package/lib/parser.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { HtmlParser } from '@markuplint/html-parser';
|
|
2
|
+
import { ParserError } from '@markuplint/parser-utils';
|
|
3
|
+
import { findTemplateLiterals } from './find-template-literals.js';
|
|
4
|
+
/**
|
|
5
|
+
* Parser for tagged template literals containing HTML.
|
|
6
|
+
* Extracts HTML content from tagged template expressions (e.g., `html\`<div>...</div>\``)
|
|
7
|
+
* and delegates HTML parsing to the standard HtmlParser with `${...}` expressions
|
|
8
|
+
* masked as preprocessor-specific blocks.
|
|
9
|
+
*/
|
|
10
|
+
export class TaggedTemplateLiteralParser extends HtmlParser {
|
|
11
|
+
#tagNames;
|
|
12
|
+
/**
|
|
13
|
+
* Creates a new parser for tagged template literals.
|
|
14
|
+
*
|
|
15
|
+
* @param tagNames - Tag function names to recognize as HTML templates (default: `['html']`).
|
|
16
|
+
* For example, `['html', 'svg']` would match both `html\`...\`` and `svg\`...\``.
|
|
17
|
+
* Member expressions are resolved to their property name, so `LitElement.html\`...\``
|
|
18
|
+
* matches the tag name `'html'`.
|
|
19
|
+
*/
|
|
20
|
+
constructor(tagNames = ['html']) {
|
|
21
|
+
super({
|
|
22
|
+
ignoreTags: [
|
|
23
|
+
{
|
|
24
|
+
type: 'ttl-expression',
|
|
25
|
+
start: '${',
|
|
26
|
+
end: '}',
|
|
27
|
+
},
|
|
28
|
+
],
|
|
29
|
+
});
|
|
30
|
+
this.#tagNames = tagNames;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Parses a TypeScript/JavaScript source file, extracts tagged template literals
|
|
34
|
+
* matching the configured tag names, and parses their HTML content.
|
|
35
|
+
* Each `${...}` expression within the template is preserved as a
|
|
36
|
+
* `#ps:ttl-expression` preprocessor-specific block in the resulting AST.
|
|
37
|
+
*
|
|
38
|
+
* @param rawCode - The full TypeScript/JavaScript source code
|
|
39
|
+
* @param options - Parse options forwarded to the underlying HTML parser
|
|
40
|
+
* @returns The parsed AST document containing nodes from all matched template literals
|
|
41
|
+
*/
|
|
42
|
+
parse(rawCode, options) {
|
|
43
|
+
let templateLiterals;
|
|
44
|
+
try {
|
|
45
|
+
templateLiterals = findTemplateLiterals(rawCode, this.#tagNames);
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
if (error instanceof Error && 'location' in error) {
|
|
49
|
+
const loc = error.location;
|
|
50
|
+
if (loc.start) {
|
|
51
|
+
throw new ParserError(error.message, {
|
|
52
|
+
line: loc.start.line,
|
|
53
|
+
col: loc.start.column,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
if (templateLiterals.length === 0) {
|
|
60
|
+
return {
|
|
61
|
+
raw: rawCode,
|
|
62
|
+
nodeList: [],
|
|
63
|
+
isFragment: true,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
// Parse all matched template literals and merge their node lists.
|
|
67
|
+
const allNodeLists = [];
|
|
68
|
+
for (const tpl of templateLiterals) {
|
|
69
|
+
const { line: offsetLine, col: offsetColumn } = getLineAndColumn(rawCode, tpl.contentStart);
|
|
70
|
+
const doc = super.parse(tpl.htmlContent, {
|
|
71
|
+
...options,
|
|
72
|
+
offsetOffset: tpl.contentStart,
|
|
73
|
+
offsetLine,
|
|
74
|
+
offsetColumn,
|
|
75
|
+
});
|
|
76
|
+
allNodeLists.push(...doc.nodeList);
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
raw: rawCode,
|
|
80
|
+
nodeList: allNodeLists,
|
|
81
|
+
isFragment: true,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Computes the 1-based line number and 1-based column for a given offset in a string.
|
|
87
|
+
*
|
|
88
|
+
* @param source - The source string
|
|
89
|
+
* @param offset - The character offset (0-based)
|
|
90
|
+
* @returns An object with 1-based `line` and `col` values
|
|
91
|
+
*/
|
|
92
|
+
function getLineAndColumn(source, offset) {
|
|
93
|
+
let line = 1;
|
|
94
|
+
let col = 1;
|
|
95
|
+
for (let i = 0; i < offset; i++) {
|
|
96
|
+
if (source[i] === '\n') {
|
|
97
|
+
line++;
|
|
98
|
+
col = 1;
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
col++;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return { line, col };
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Default singleton parser instance configured to match the `html` tag name.
|
|
108
|
+
*/
|
|
109
|
+
export const parser = new TaggedTemplateLiteralParser();
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@markuplint/tagged-template-literal-parser",
|
|
3
|
+
"version": "5.0.0-alpha.0",
|
|
4
|
+
"description": "Tagged template literal parser for markuplint",
|
|
5
|
+
"repository": "git@github.com:markuplint/markuplint.git",
|
|
6
|
+
"author": "Yusuke Hirao <yusukehirao@me.com>",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"engines": {
|
|
9
|
+
"node": ">=22"
|
|
10
|
+
},
|
|
11
|
+
"type": "module",
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"import": "./lib/index.js",
|
|
15
|
+
"types": "./lib/index.d.ts"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"publishConfig": {
|
|
19
|
+
"access": "public"
|
|
20
|
+
},
|
|
21
|
+
"scripts": {
|
|
22
|
+
"build": "tsc --project tsconfig.build.json",
|
|
23
|
+
"dev": "tsc --watch --project tsconfig.build.json",
|
|
24
|
+
"clean": "tsc --build --clean tsconfig.build.json"
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"@markuplint/html-parser": "5.0.0-alpha.0",
|
|
28
|
+
"@markuplint/ml-ast": "5.0.0-alpha.0",
|
|
29
|
+
"@markuplint/parser-utils": "5.0.0-alpha.0",
|
|
30
|
+
"@typescript-eslint/types": "8.56.0",
|
|
31
|
+
"@typescript-eslint/typescript-estree": "8.56.0"
|
|
32
|
+
},
|
|
33
|
+
"gitHead": "13dcfc84ec83d87360c720e253383b60767e1b56"
|
|
34
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { describe, test, expect } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { findTemplateLiterals } from './find-template-literals.js';
|
|
4
|
+
|
|
5
|
+
describe('findTemplateLiterals', () => {
|
|
6
|
+
test('finds a simple html tagged template', () => {
|
|
7
|
+
const results = findTemplateLiterals('const t = html`<div></div>`;');
|
|
8
|
+
expect(results).toHaveLength(1);
|
|
9
|
+
expect(results[0].tagName).toBe('html');
|
|
10
|
+
expect(results[0].htmlContent).toBe('<div></div>');
|
|
11
|
+
expect(results[0].contentStart).toBe(15);
|
|
12
|
+
expect(results[0].contentEnd).toBe(26);
|
|
13
|
+
expect(results[0].expressions).toHaveLength(0);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test('extracts expression positions', () => {
|
|
17
|
+
const code = 'const t = html`<div>${name}</div>`;';
|
|
18
|
+
const results = findTemplateLiterals(code);
|
|
19
|
+
expect(results).toHaveLength(1);
|
|
20
|
+
expect(results[0].expressions).toHaveLength(1);
|
|
21
|
+
expect(results[0].expressions[0].raw).toBe('${name}');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('extracts multiple expressions', () => {
|
|
25
|
+
const code = 'const t = html`${a} ${b} ${c}`;';
|
|
26
|
+
const results = findTemplateLiterals(code);
|
|
27
|
+
expect(results[0].expressions).toHaveLength(3);
|
|
28
|
+
expect(results[0].expressions[0].raw).toBe('${a}');
|
|
29
|
+
expect(results[0].expressions[1].raw).toBe('${b}');
|
|
30
|
+
expect(results[0].expressions[2].raw).toBe('${c}');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('ignores untagged template literals', () => {
|
|
34
|
+
const results = findTemplateLiterals('const t = `<div></div>`;');
|
|
35
|
+
expect(results).toHaveLength(0);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('ignores non-matching tag names', () => {
|
|
39
|
+
const results = findTemplateLiterals('const t = css`div {}`;');
|
|
40
|
+
expect(results).toHaveLength(0);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('supports custom tag names', () => {
|
|
44
|
+
const results = findTemplateLiterals('const t = svg`<circle />`;', ['svg']);
|
|
45
|
+
expect(results).toHaveLength(1);
|
|
46
|
+
expect(results[0].tagName).toBe('svg');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('supports multiple custom tag names simultaneously', () => {
|
|
50
|
+
const code = 'const a = html`<div></div>`; const b = svg`<circle />`;';
|
|
51
|
+
const results = findTemplateLiterals(code, ['html', 'svg']);
|
|
52
|
+
expect(results).toHaveLength(2);
|
|
53
|
+
expect(results[0].tagName).toBe('html');
|
|
54
|
+
expect(results[1].tagName).toBe('svg');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('supports member expression tags', () => {
|
|
58
|
+
const results = findTemplateLiterals('const t = LitElement.html`<div></div>`;');
|
|
59
|
+
expect(results).toHaveLength(1);
|
|
60
|
+
expect(results[0].tagName).toBe('html');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('computed member expression tag resolves property name', () => {
|
|
64
|
+
// obj[html] is a MemberExpression with computed=true, but the property
|
|
65
|
+
// is an Identifier with name 'html', so it resolves to 'html'
|
|
66
|
+
const results = findTemplateLiterals('const t = obj[html]`<div></div>`;');
|
|
67
|
+
expect(results).toHaveLength(1);
|
|
68
|
+
expect(results[0].tagName).toBe('html');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('call expression tag is ignored', () => {
|
|
72
|
+
const results = findTemplateLiterals('const t = getTag()`<div></div>`;');
|
|
73
|
+
expect(results).toHaveLength(0);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('finds multiple template literals', () => {
|
|
77
|
+
const code = `const a = html\`<div></div>\`;
|
|
78
|
+
const b = html\`<span></span>\`;`;
|
|
79
|
+
const results = findTemplateLiterals(code);
|
|
80
|
+
expect(results).toHaveLength(2);
|
|
81
|
+
expect(results[0].htmlContent).toBe('<div></div>');
|
|
82
|
+
expect(results[1].htmlContent).toBe('<span></span>');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('handles multiline template literal', () => {
|
|
86
|
+
const code = `const t = html\`
|
|
87
|
+
<div>
|
|
88
|
+
<span>text</span>
|
|
89
|
+
</div>
|
|
90
|
+
\`;`;
|
|
91
|
+
const results = findTemplateLiterals(code);
|
|
92
|
+
expect(results).toHaveLength(1);
|
|
93
|
+
expect(results[0].htmlContent).toContain('<div>');
|
|
94
|
+
expect(results[0].htmlContent).toContain('</div>');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('returns empty for non-JS content', () => {
|
|
98
|
+
expect(() => findTemplateLiterals('not valid js @#$')).toThrow();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('handles no template literals', () => {
|
|
102
|
+
const results = findTemplateLiterals('const x = 42;');
|
|
103
|
+
expect(results).toHaveLength(0);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('handles empty string input', () => {
|
|
107
|
+
const results = findTemplateLiterals('');
|
|
108
|
+
expect(results).toHaveLength(0);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('expression start/end include ${ and }', () => {
|
|
112
|
+
const code = 'const t = html`${name}`;';
|
|
113
|
+
const results = findTemplateLiterals(code);
|
|
114
|
+
const expr = results[0].expressions[0];
|
|
115
|
+
expect(code.slice(expr.start, expr.end)).toBe('${name}');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('complex expression', () => {
|
|
119
|
+
const code = 'const t = html`${items.map(i => i.name)}`;';
|
|
120
|
+
const results = findTemplateLiterals(code);
|
|
121
|
+
expect(results[0].expressions[0].raw).toBe('${items.map(i => i.name)}');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('template literal in function body', () => {
|
|
125
|
+
const code = `function render() {
|
|
126
|
+
return html\`<div></div>\`;
|
|
127
|
+
}`;
|
|
128
|
+
const results = findTemplateLiterals(code);
|
|
129
|
+
expect(results).toHaveLength(1);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('template literal in arrow function', () => {
|
|
133
|
+
const code = 'const render = () => html`<div></div>`;';
|
|
134
|
+
const results = findTemplateLiterals(code);
|
|
135
|
+
expect(results).toHaveLength(1);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('template literal in class method', () => {
|
|
139
|
+
const code = `class MyElement {
|
|
140
|
+
render() {
|
|
141
|
+
return html\`<div></div>\`;
|
|
142
|
+
}
|
|
143
|
+
}`;
|
|
144
|
+
const results = findTemplateLiterals(code);
|
|
145
|
+
expect(results).toHaveLength(1);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('nested tagged template literal', () => {
|
|
149
|
+
const code = 'const t = html`<ul>${items.map(i => html`<li>${i}</li>`)}</ul>`;';
|
|
150
|
+
const results = findTemplateLiterals(code);
|
|
151
|
+
// Both outer and inner html`` should be found
|
|
152
|
+
expect(results.length).toBeGreaterThanOrEqual(2);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('TypeScript source with generics', () => {
|
|
156
|
+
const code = 'const t: TemplateResult<1> = html`<div></div>`;';
|
|
157
|
+
const results = findTemplateLiterals(code);
|
|
158
|
+
expect(results).toHaveLength(1);
|
|
159
|
+
expect(results[0].htmlContent).toBe('<div></div>');
|
|
160
|
+
});
|
|
161
|
+
});
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import type { TSESTree } from '@typescript-eslint/types';
|
|
2
|
+
|
|
3
|
+
import { AST_NODE_TYPES, parse } from '@typescript-eslint/typescript-estree';
|
|
4
|
+
|
|
5
|
+
/** Length of the `${` expression start delimiter */
|
|
6
|
+
const EXPR_START_LEN = 2;
|
|
7
|
+
/** Length of the `}` expression end delimiter */
|
|
8
|
+
const EXPR_END_LEN = 1;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Represents a template literal expression (`${...}`) within a tagged template.
|
|
12
|
+
*/
|
|
13
|
+
export interface TemplateExpression {
|
|
14
|
+
/** The raw source text of the expression including `${` and `}` */
|
|
15
|
+
readonly raw: string;
|
|
16
|
+
/** Start offset in the original source */
|
|
17
|
+
readonly start: number;
|
|
18
|
+
/** End offset in the original source */
|
|
19
|
+
readonly end: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Represents a tagged template literal found in the source code.
|
|
24
|
+
*/
|
|
25
|
+
export interface TemplateLiteralInfo {
|
|
26
|
+
/** The tag name (e.g., 'html') */
|
|
27
|
+
readonly tagName: string;
|
|
28
|
+
/** The full raw content between the backticks (excluding the backticks themselves) */
|
|
29
|
+
readonly htmlContent: string;
|
|
30
|
+
/** Start offset of the content (after the opening backtick) */
|
|
31
|
+
readonly contentStart: number;
|
|
32
|
+
/** End offset of the content (before the closing backtick) */
|
|
33
|
+
readonly contentEnd: number;
|
|
34
|
+
/** The expressions (`${...}`) found within the template literal */
|
|
35
|
+
readonly expressions: readonly TemplateExpression[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Resolves the tag name from a TaggedTemplateExpression's tag node.
|
|
40
|
+
* For identifiers (e.g., `html`), returns the name directly.
|
|
41
|
+
* For member expressions (e.g., `LitElement.html`), returns the property name.
|
|
42
|
+
* Returns an empty string for unrecognized tag forms.
|
|
43
|
+
*
|
|
44
|
+
* @param tag - The AST node representing the tag expression
|
|
45
|
+
* @returns The resolved tag name, or an empty string if unresolvable
|
|
46
|
+
*/
|
|
47
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
48
|
+
function resolveTagName(tag: TSESTree.Expression): string {
|
|
49
|
+
switch (tag.type) {
|
|
50
|
+
case AST_NODE_TYPES.Identifier: {
|
|
51
|
+
return tag.name;
|
|
52
|
+
}
|
|
53
|
+
case AST_NODE_TYPES.MemberExpression: {
|
|
54
|
+
if (tag.property.type === AST_NODE_TYPES.Identifier) {
|
|
55
|
+
return tag.property.name;
|
|
56
|
+
}
|
|
57
|
+
return '';
|
|
58
|
+
}
|
|
59
|
+
default: {
|
|
60
|
+
return '';
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Recursively searches an AST for TaggedTemplateExpression nodes matching
|
|
67
|
+
* the specified tag names.
|
|
68
|
+
*
|
|
69
|
+
* @param node - The AST node to search
|
|
70
|
+
* @param tagNames - Set of tag names to match (e.g., `html`, `svg`)
|
|
71
|
+
* @param results - Accumulator for found template literals
|
|
72
|
+
* @param sourceCode - The original source code string
|
|
73
|
+
*/
|
|
74
|
+
function searchTaggedTemplates(
|
|
75
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
76
|
+
node: TSESTree.Node,
|
|
77
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
78
|
+
tagNames: ReadonlySet<string>,
|
|
79
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
80
|
+
results: TemplateLiteralInfo[],
|
|
81
|
+
sourceCode: string,
|
|
82
|
+
) {
|
|
83
|
+
if (node.type === AST_NODE_TYPES.TaggedTemplateExpression) {
|
|
84
|
+
const tagName = resolveTagName(node.tag);
|
|
85
|
+
if (tagNames.has(tagName)) {
|
|
86
|
+
const quasi = node.quasi;
|
|
87
|
+
const contentStart = quasi.range[0] + 1; // skip opening backtick
|
|
88
|
+
const contentEnd = quasi.range[1] - 1; // skip closing backtick
|
|
89
|
+
const htmlContent = sourceCode.slice(contentStart, contentEnd);
|
|
90
|
+
|
|
91
|
+
const expressions: TemplateExpression[] = quasi.expressions.map(expr => ({
|
|
92
|
+
raw: sourceCode.slice(expr.range[0] - EXPR_START_LEN, expr.range[1] + EXPR_END_LEN),
|
|
93
|
+
start: expr.range[0] - EXPR_START_LEN,
|
|
94
|
+
end: expr.range[1] + EXPR_END_LEN,
|
|
95
|
+
}));
|
|
96
|
+
|
|
97
|
+
results.push({
|
|
98
|
+
tagName,
|
|
99
|
+
htmlContent,
|
|
100
|
+
contentStart,
|
|
101
|
+
contentEnd,
|
|
102
|
+
expressions,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Manual AST traversal via Object.keys instead of typescript-estree's simpleTraverse,
|
|
108
|
+
// because simpleTraverse does not expose enough control over which nodes are visited
|
|
109
|
+
// and would add an additional import dependency for minimal benefit.
|
|
110
|
+
for (const key of Object.keys(node)) {
|
|
111
|
+
if (key === 'parent') {
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
const value = (node as unknown as Record<string, unknown>)[key];
|
|
115
|
+
if (value && typeof value === 'object') {
|
|
116
|
+
if (Array.isArray(value)) {
|
|
117
|
+
for (const item of value) {
|
|
118
|
+
if (item && typeof item === 'object' && 'type' in item) {
|
|
119
|
+
searchTaggedTemplates(item as TSESTree.Node, tagNames, results, sourceCode);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
} else if ('type' in value) {
|
|
123
|
+
searchTaggedTemplates(value as TSESTree.Node, tagNames, results, sourceCode);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Finds all tagged template literals in a TypeScript/JavaScript source file
|
|
131
|
+
* that match the given tag names. Uses `@typescript-eslint/typescript-estree`
|
|
132
|
+
* to parse the source and recursively searches the AST for
|
|
133
|
+
* `TaggedTemplateExpression` nodes whose tag resolves to one of the specified names.
|
|
134
|
+
*
|
|
135
|
+
* @param sourceCode - The raw TypeScript/JavaScript source code to search
|
|
136
|
+
* @param tagNames - Array of tag function names to match (default: `['html']`)
|
|
137
|
+
* @returns Array of template literal information objects, ordered by their position in the source
|
|
138
|
+
*/
|
|
139
|
+
export function findTemplateLiterals(
|
|
140
|
+
sourceCode: string,
|
|
141
|
+
tagNames: readonly string[] = ['html'],
|
|
142
|
+
): readonly TemplateLiteralInfo[] {
|
|
143
|
+
const ast = parse(sourceCode, {
|
|
144
|
+
comment: false,
|
|
145
|
+
errorOnUnknownASTType: false,
|
|
146
|
+
jsx: false,
|
|
147
|
+
loc: true,
|
|
148
|
+
range: true,
|
|
149
|
+
tokens: false,
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
const tagNameSet = new Set(tagNames);
|
|
153
|
+
const results: TemplateLiteralInfo[] = [];
|
|
154
|
+
|
|
155
|
+
for (const statement of ast.body) {
|
|
156
|
+
searchTaggedTemplates(statement, tagNameSet, results, sourceCode);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return results;
|
|
160
|
+
}
|