Skip to content

Commit 9b27bb3

Browse files
authored
Merge pull request #549 from etchteam/webc-support
feat: add WebC template linting support
2 parents 6b5fa37 + 069302f commit 9b27bb3

File tree

10 files changed

+363
-1
lines changed

10 files changed

+363
-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/base.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ export default [
5858
],
5959
'security/detect-object-injection': 'off',
6060
'spaced-comment': 'error',
61+
curly: 'error',
6162
},
6263
settings: {
6364
'import/resolver': {

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: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,289 @@
1+
/**
2+
* Block definitions for each type of inline JavaScript in WebC files.
3+
* Each entry maps an opening tag pattern to its closing pattern and type resolver.
4+
*/
5+
const BLOCK_DEFS = [
6+
{
7+
open: /^(\s*)<script(\s[^>]*)?\s*>/i,
8+
close: /<\/script\s*>/i,
9+
getType: (line) => (/webc:setup/.test(line) ? 'script-setup' : 'script'),
10+
},
11+
{
12+
open: /^(\s*)<template\s+webc:type=["'](js|render)["'](\s[^>]*)?\s*>/i,
13+
close: /<\/template\s*>/i,
14+
getType: (_line, match) => `template-${match[2]}`,
15+
},
16+
];
17+
18+
/** @type {Map<string, Array<{ startLine: number, charOffset: number, type: string }>>} */
19+
const blockMetadata = new Map();
20+
21+
/**
22+
* Try to match a line against all block definitions.
23+
*
24+
* @param {string} line
25+
* @returns {{ match: RegExpMatchArray, close: RegExp, type: string } | null}
26+
*/
27+
function matchOpenTag(line) {
28+
for (const def of BLOCK_DEFS) {
29+
const match = def.open.exec(line);
30+
31+
if (match) {
32+
return { match, close: def.close, type: def.getType(line, match) };
33+
}
34+
}
35+
36+
return null;
37+
}
38+
39+
/**
40+
* Handle a line that opens a new block. Returns the block if it's a
41+
* single-line block, or the parser state for a multi-line block.
42+
*
43+
* @param {string} line
44+
* @param {{ match: RegExpMatchArray, close: RegExp, type: string }} opened
45+
* @param {number} lineIndex
46+
* @param {number} currentCharOffset
47+
* @returns {{ block: object } | { state: object }}
48+
*/
49+
function handleOpenTag(line, opened, lineIndex, currentCharOffset) {
50+
const tagEnd = line.indexOf('>', opened.match.index) + 1;
51+
const closeMatch = opened.close.exec(line);
52+
53+
if (closeMatch && closeMatch.index > opened.match.index) {
54+
return {
55+
block: {
56+
code: line.slice(tagEnd, closeMatch.index),
57+
startLine: lineIndex + 1,
58+
charOffset: currentCharOffset + tagEnd,
59+
type: opened.type,
60+
},
61+
};
62+
}
63+
64+
const afterTag = line.slice(tagEnd);
65+
const hasContentOnSameLine = afterTag.trim();
66+
67+
return {
68+
state: {
69+
closePattern: opened.close,
70+
blockType: opened.type,
71+
blockLines: hasContentOnSameLine ? [afterTag] : [],
72+
blockStartLine: hasContentOnSameLine ? lineIndex + 1 : lineIndex + 2,
73+
blockCharOffset: hasContentOnSameLine
74+
? currentCharOffset + tagEnd
75+
: currentCharOffset + line.length + 1,
76+
},
77+
};
78+
}
79+
80+
/**
81+
* Handle a line while inside a block. Returns the completed block if the
82+
* closing tag is found, or null to continue accumulating.
83+
*
84+
* @param {string} line
85+
* @param {RegExp} closePattern
86+
* @param {string[]} blockLines
87+
* @param {number} blockStartLine
88+
* @param {number} blockCharOffset
89+
* @param {string} blockType
90+
* @returns {{ code: string, startLine: number, charOffset: number, type: string } | null}
91+
*/
92+
function handleBlockLine(
93+
line,
94+
closePattern,
95+
blockLines,
96+
blockStartLine,
97+
blockCharOffset,
98+
blockType,
99+
) {
100+
const closeMatch = closePattern.exec(line);
101+
102+
if (!closeMatch) {
103+
blockLines.push(line);
104+
return null;
105+
}
106+
107+
const beforeClose = line.slice(0, closeMatch.index);
108+
109+
if (beforeClose.trim()) {
110+
blockLines.push(beforeClose);
111+
}
112+
113+
const code = blockLines.join('\n');
114+
115+
if (!code.trim()) {
116+
return null;
117+
}
118+
119+
return {
120+
code: code + '\n',
121+
startLine: blockStartLine,
122+
charOffset: blockCharOffset,
123+
type: blockType,
124+
};
125+
}
126+
127+
/**
128+
* Extract JavaScript blocks from a WebC file.
129+
*
130+
* @param {string} text - The full file content
131+
* @returns {Array<{ code: string, startLine: number, charOffset: number, type: string }>}
132+
*/
133+
function extractBlocks(text) {
134+
const lines = text.split('\n');
135+
const blocks = [];
136+
137+
let insideBlock = false;
138+
let closePattern = null;
139+
let blockType = '';
140+
let blockLines = [];
141+
let blockStartLine = 0;
142+
let blockCharOffset = 0;
143+
let currentCharOffset = 0;
144+
145+
for (let i = 0; i < lines.length; i++) {
146+
const line = lines[i];
147+
148+
if (insideBlock) {
149+
const completed = handleBlockLine(
150+
line,
151+
closePattern,
152+
blockLines,
153+
blockStartLine,
154+
blockCharOffset,
155+
blockType,
156+
);
157+
158+
if (completed) {
159+
blocks.push(completed);
160+
insideBlock = false;
161+
closePattern = null;
162+
blockLines = [];
163+
}
164+
} else {
165+
const opened = matchOpenTag(line);
166+
167+
if (opened) {
168+
const result = handleOpenTag(line, opened, i, currentCharOffset);
169+
170+
if (result.block) {
171+
blocks.push(result.block);
172+
} else {
173+
insideBlock = true;
174+
closePattern = result.state.closePattern;
175+
blockType = result.state.blockType;
176+
blockLines = result.state.blockLines;
177+
blockStartLine = result.state.blockStartLine;
178+
blockCharOffset = result.state.blockCharOffset;
179+
}
180+
}
181+
}
182+
183+
currentCharOffset += line.length + 1;
184+
}
185+
186+
return blocks;
187+
}
188+
189+
/**
190+
* @param {string} text
191+
* @param {string} filename
192+
* @returns {Array<{ text: string, filename: string }>}
193+
*/
194+
function preprocess(text, filename) {
195+
const blocks = extractBlocks(text);
196+
const metadata = [];
197+
const result = [];
198+
199+
for (let i = 0; i < blocks.length; i++) {
200+
const block = blocks[i];
201+
const virtualFilename = `${filename}/${i}.${block.type}.js`;
202+
203+
metadata.push({
204+
startLine: block.startLine,
205+
charOffset: block.charOffset,
206+
type: block.type,
207+
});
208+
209+
result.push({
210+
text: block.code,
211+
filename: virtualFilename,
212+
});
213+
}
214+
215+
blockMetadata.set(filename, metadata);
216+
217+
return result;
218+
}
219+
220+
/**
221+
* @param {import('eslint').Linter.LintMessage} message
222+
* @param {number} lineOffset
223+
* @param {number} charOffset
224+
* @returns {import('eslint').Linter.LintMessage}
225+
*/
226+
function adjustMessage(message, lineOffset, charOffset) {
227+
return {
228+
...message,
229+
line: message.line + lineOffset,
230+
endLine: message.endLine != null ? message.endLine + lineOffset : undefined,
231+
fix: message.fix
232+
? {
233+
range: [
234+
message.fix.range[0] + charOffset,
235+
message.fix.range[1] + charOffset,
236+
],
237+
text: message.fix.text,
238+
}
239+
: undefined,
240+
};
241+
}
242+
243+
/**
244+
* @param {Array<Array<import('eslint').Linter.LintMessage>>} messages
245+
* @param {string} filename
246+
* @returns {Array<import('eslint').Linter.LintMessage>}
247+
*/
248+
function postprocess(messages, filename) {
249+
const metadata = blockMetadata.get(filename);
250+
251+
blockMetadata.delete(filename);
252+
253+
if (!metadata) {
254+
return messages.flat();
255+
}
256+
257+
const result = [];
258+
259+
for (let i = 0; i < messages.length; i++) {
260+
const blockMessages = messages[i];
261+
const meta = metadata[i];
262+
263+
if (meta) {
264+
const lineOffset = meta.startLine - 1;
265+
266+
for (const message of blockMessages) {
267+
result.push(adjustMessage(message, lineOffset, meta.charOffset));
268+
}
269+
} else {
270+
result.push(...blockMessages);
271+
}
272+
}
273+
274+
return result;
275+
}
276+
277+
export default {
278+
meta: {
279+
name: 'eslint-plugin-webc',
280+
version: '1.0.0',
281+
},
282+
processors: {
283+
webc: {
284+
preprocess,
285+
postprocess,
286+
supportsAutofix: true,
287+
},
288+
},
289+
};

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;

0 commit comments

Comments
 (0)