Skip to content

Commit af7aad0

Browse files
feat: add WebC template linting support
Add a custom ESLint processor plugin that extracts and lints inline JavaScript from WebC (.webc) template files, supporting <script>, <script webc:setup>, and <template webc:type="js|render"> blocks. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent e7c1636 commit af7aad0

File tree

9 files changed

+312
-1
lines changed

9 files changed

+312
-1
lines changed

.github/workflows/ci.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,4 +57,5 @@ jobs:
5757
test_config "Next.js" "test-files/nextjs/" && \
5858
test_config "Node.js" "test-files/nodejs/" && \
5959
test_config "Preact" "test-files/preact/" && \
60-
test_config "Web Components" "test-files/web-components/"
60+
test_config "Web Components" "test-files/web-components/" && \
61+
test_config "WebC" "-c test-files/webc/eslint.config.mjs test-files/webc/"

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"./preact": "./src/configs/environments/preact.mjs",
1717
"./angular": "./src/configs/environments/angular.mjs",
1818
"./web-components": "./src/configs/environments/web-components.mjs",
19+
"./webc": "./src/configs/environments/webc.mjs",
1920
"./nestjs": "./src/configs/environments/nestjs.mjs",
2021
"./package.json": "./package.json"
2122
},

src/configs/environments/webc.mjs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import base from '../../base.mjs';
2+
import json from '../json.mjs';
3+
import storybook from '../storybook.mjs';
4+
import webc from '../webc.mjs';
5+
import yaml from '../yaml.mjs';
6+
7+
/**
8+
* WebC ESLint configuration.
9+
* Includes: base + JSON + YAML + Storybook + WebC processor
10+
*
11+
* Usage:
12+
* ```js
13+
* import webc from '@etchteam/eslint-config/webc';
14+
* export default webc;
15+
* ```
16+
*/
17+
export default [...base, ...json, ...yaml, ...storybook, ...webc];

src/configs/webc.mjs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import webcPlugin from '../plugins/webc-processor.mjs';
2+
3+
/**
4+
* WebC template linting configuration.
5+
* Provides a processor for extracting and linting inline JavaScript in .webc files.
6+
*
7+
* Usage:
8+
* ```js
9+
* import { webc } from '@etchteam/eslint-config';
10+
* export default [...base, ...webc];
11+
* ```
12+
*/
13+
export default [
14+
{
15+
files: ['**/*.webc'],
16+
processor: webcPlugin.processors.webc,
17+
},
18+
];

src/index.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export { default as base } from './base.mjs';
99
export { default as json } from './configs/json.mjs';
1010
export { default as react } from './configs/react.mjs';
1111
export { default as storybook } from './configs/storybook.mjs';
12+
export { default as webc } from './configs/webc.mjs';
1213
export { default as yaml } from './configs/yaml.mjs';
1314

1415
/**

src/plugins/webc-processor.mjs

Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
// eslint-disable-next-line security/detect-unsafe-regex
2+
const SCRIPT_OPEN = /^(\s*)<script(\s[^>]*)?\s*>/i;
3+
const SCRIPT_CLOSE = /<\/script\s*>/i;
4+
const TEMPLATE_OPEN =
5+
// eslint-disable-next-line security/detect-unsafe-regex
6+
/^(\s*)<template\s+webc:type=["'](js|render)["'](\s[^>]*)?\s*>/i;
7+
const TEMPLATE_CLOSE = /<\/template\s*>/i;
8+
const WEBC_SETUP = /webc:setup/;
9+
10+
/** @type {Map<string, Array<{ startLine: number, charOffset: number, type: string }>>} */
11+
const blockMetadata = new Map();
12+
13+
/**
14+
* Extract JavaScript blocks from a WebC file.
15+
*
16+
* @param {string} text - The full file content
17+
* @returns {Array<{ code: string, startLine: number, charOffset: number, type: string }>}
18+
*/
19+
function extractBlocks(text) {
20+
const lines = text.split('\n');
21+
const blocks = [];
22+
23+
let insideBlock = false;
24+
let closePattern = null;
25+
let blockType = '';
26+
let blockLines = [];
27+
let blockStartLine = 0;
28+
let blockCharOffset = 0;
29+
30+
// Track character offset as we walk through lines
31+
let currentCharOffset = 0;
32+
33+
for (let i = 0; i < lines.length; i++) {
34+
const line = lines[i];
35+
36+
if (!insideBlock) {
37+
let match = line.match(SCRIPT_OPEN);
38+
39+
if (match) {
40+
insideBlock = true;
41+
closePattern = SCRIPT_CLOSE;
42+
blockType = WEBC_SETUP.test(line) ? 'script-setup' : 'script';
43+
blockLines = [];
44+
45+
// Content after the opening tag on the same line
46+
const tagEnd = line.indexOf('>', match.index) + 1;
47+
const closeMatch = line.match(SCRIPT_CLOSE);
48+
49+
if (closeMatch && closeMatch.index > match.index) {
50+
// Single-line block: <script>code</script>
51+
const content = line.slice(tagEnd, closeMatch.index);
52+
53+
blocks.push({
54+
code: content,
55+
startLine: i + 1,
56+
charOffset: currentCharOffset + tagEnd,
57+
type: blockType,
58+
});
59+
insideBlock = false;
60+
closePattern = null;
61+
} else {
62+
blockStartLine = i + 2; // Content starts on next line (1-indexed)
63+
blockCharOffset = currentCharOffset + line.length + 1; // +1 for newline
64+
65+
const afterTag = line.slice(tagEnd);
66+
67+
if (afterTag.trim()) {
68+
// Content on same line as opening tag
69+
blockStartLine = i + 1;
70+
blockCharOffset = currentCharOffset + tagEnd;
71+
blockLines.push(afterTag);
72+
}
73+
}
74+
} else {
75+
match = line.match(TEMPLATE_OPEN);
76+
77+
if (match) {
78+
insideBlock = true;
79+
closePattern = TEMPLATE_CLOSE;
80+
blockType = `template-${match[2]}`;
81+
blockLines = [];
82+
83+
const tagEnd = line.indexOf('>', match.index) + 1;
84+
const closeMatch = line.match(TEMPLATE_CLOSE);
85+
86+
if (closeMatch && closeMatch.index > match.index) {
87+
const content = line.slice(tagEnd, closeMatch.index);
88+
89+
blocks.push({
90+
code: content,
91+
startLine: i + 1,
92+
charOffset: currentCharOffset + tagEnd,
93+
type: blockType,
94+
});
95+
insideBlock = false;
96+
closePattern = null;
97+
} else {
98+
blockStartLine = i + 2;
99+
blockCharOffset = currentCharOffset + line.length + 1;
100+
101+
const afterTag = line.slice(tagEnd);
102+
103+
if (afterTag.trim()) {
104+
blockStartLine = i + 1;
105+
blockCharOffset = currentCharOffset + tagEnd;
106+
blockLines.push(afterTag);
107+
}
108+
}
109+
}
110+
}
111+
} else {
112+
const closeMatch = line.match(closePattern);
113+
114+
if (closeMatch) {
115+
// Content before closing tag on this line
116+
const beforeClose = line.slice(0, closeMatch.index);
117+
118+
if (beforeClose.trim()) {
119+
blockLines.push(beforeClose);
120+
}
121+
122+
const code = blockLines.join('\n');
123+
124+
if (code.trim()) {
125+
blocks.push({
126+
code: code + '\n',
127+
startLine: blockStartLine,
128+
charOffset: blockCharOffset,
129+
type: blockType,
130+
});
131+
}
132+
133+
insideBlock = false;
134+
closePattern = null;
135+
blockLines = [];
136+
} else {
137+
blockLines.push(line);
138+
}
139+
}
140+
141+
currentCharOffset += line.length + 1; // +1 for newline
142+
}
143+
144+
return blocks;
145+
}
146+
147+
/**
148+
* @param {string} text
149+
* @param {string} filename
150+
* @returns {Array<{ text: string, filename: string }>}
151+
*/
152+
function preprocess(text, filename) {
153+
const blocks = extractBlocks(text);
154+
const metadata = [];
155+
const result = [];
156+
157+
for (let i = 0; i < blocks.length; i++) {
158+
const block = blocks[i];
159+
const virtualFilename = `${filename}/${i}.${block.type}.js`;
160+
161+
metadata.push({
162+
startLine: block.startLine,
163+
charOffset: block.charOffset,
164+
type: block.type,
165+
});
166+
167+
result.push({
168+
text: block.code,
169+
filename: virtualFilename,
170+
});
171+
}
172+
173+
blockMetadata.set(filename, metadata);
174+
175+
return result;
176+
}
177+
178+
/**
179+
* @param {Array<Array<import('eslint').Linter.LintMessage>>} messages
180+
* @param {string} filename
181+
* @returns {Array<import('eslint').Linter.LintMessage>}
182+
*/
183+
function postprocess(messages, filename) {
184+
const metadata = blockMetadata.get(filename);
185+
186+
blockMetadata.delete(filename);
187+
188+
if (!metadata) {
189+
return messages.flat();
190+
}
191+
192+
const result = [];
193+
194+
for (let i = 0; i < messages.length; i++) {
195+
const blockMessages = messages[i];
196+
const meta = metadata[i];
197+
198+
if (!meta) {
199+
result.push(...blockMessages);
200+
continue;
201+
}
202+
203+
const lineOffset = meta.startLine - 1;
204+
205+
for (const message of blockMessages) {
206+
result.push({
207+
...message,
208+
line: message.line + lineOffset,
209+
endLine:
210+
message.endLine != null ? message.endLine + lineOffset : undefined,
211+
fix: message.fix
212+
? {
213+
range: [
214+
message.fix.range[0] + meta.charOffset,
215+
message.fix.range[1] + meta.charOffset,
216+
],
217+
text: message.fix.text,
218+
}
219+
: undefined,
220+
});
221+
}
222+
}
223+
224+
return result;
225+
}
226+
227+
export default {
228+
meta: {
229+
name: 'eslint-plugin-webc',
230+
version: '1.0.0',
231+
},
232+
processors: {
233+
webc: {
234+
preprocess,
235+
postprocess,
236+
supportsAutofix: true,
237+
},
238+
},
239+
};

src/types.d.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,12 @@ export declare const preact: FlatConfigArray;
5252
*/
5353
export declare const angular: FlatConfigArray;
5454

55+
/**
56+
* WebC template linting configuration.
57+
* Provides a processor for extracting and linting inline JavaScript in .webc files.
58+
*/
59+
export declare const webc: FlatConfigArray;
60+
5561
/**
5662
* Web Components (Lit) ESLint configuration.
5763
* Includes: base + JSON + YAML + Storybook + Lit rules

test-files/webc/eslint.config.mjs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import webc from '../../src/configs/environments/webc.mjs';
2+
3+
export default webc;

test-files/webc/test.webc

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<script>
2+
var unused_variable = "test"
3+
var x = new Array()
4+
console.log("double quotes")
5+
</script>
6+
7+
<script webc:setup>
8+
var setup_unused = "test"
9+
const obj = {}
10+
obj[x] = 'detected'
11+
</script>
12+
13+
<template webc:type="js">
14+
var template_unused = "test"
15+
console.log("double quotes")
16+
</template>
17+
18+
<template webc:type="render">
19+
var render_unused = "test"
20+
console.log("double quotes")
21+
</template>
22+
23+
<div class="content">
24+
<slot></slot>
25+
</div>

0 commit comments

Comments
 (0)