Skip to content

Commit f613eaa

Browse files
chore: start lint with workers.
1 parent 4bcc0f3 commit f613eaa

16 files changed

Lines changed: 782 additions & 0 deletions

docs/lint-workers.md

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
# Lint Workers Plan
2+
3+
This document defines the worker-based lint architecture for component and styles
4+
panels in `@knighted/develop`.
5+
6+
## Goals
7+
8+
- Use `eslint@10` for component linting.
9+
- Use `stylelint` for style linting.
10+
- Run both in Web Workers so preview/typecheck remain responsive.
11+
- Reuse shared request/response and diagnostics formatting logic.
12+
- Keep ESLint and Stylelint implementations in separate modules.
13+
14+
## Module Layout
15+
16+
Main thread:
17+
18+
- `src/modules/lint/lint-controller.js`: orchestrates component/style lint flows.
19+
- `src/modules/lint/eslint/worker-adapter.js`: ESLint request adapter.
20+
- `src/modules/lint/stylelint/worker-adapter.js`: Stylelint request adapter.
21+
- `src/modules/lint/shared/worker-client.js`: worker RPC, timeout, cancellation.
22+
- `src/modules/lint/shared/protocol.js`: shared message contract.
23+
- `src/modules/lint/shared/format.js`: shared diagnostics normalization/formatting.
24+
25+
Worker thread:
26+
27+
- `src/modules/lint-worker.js`: worker entrypoint and engine dispatch.
28+
- `src/modules/lint/worker/runtime-loader.js`: runtime loading with CDN candidates.
29+
- `src/modules/lint/worker/eslint-runtime.js`: ESLint execution path.
30+
- `src/modules/lint/worker/stylelint-runtime.js`: Stylelint execution path.
31+
32+
Engine-specific config:
33+
34+
- `src/modules/lint/eslint/config.js`: ESLint lint options.
35+
- `src/modules/lint/stylelint/config.js`: Stylelint lint options and syntax map.
36+
37+
## Rollout Phases
38+
39+
1. Scaffold worker runtime plumbing.
40+
2. Wire panel actions in `src/index.html` and `src/app.js` for:
41+
- Component Lint
42+
- Styles Lint
43+
3. Keep lint on-demand first; avoid auto-lint until runtime stability is proven.
44+
4. Add line/column click navigation to editors once diagnostics UI supports
45+
structured entries.
46+
5. Add Playwright coverage for:
47+
- Component lint success and error scenarios.
48+
- Style lint success for `css`, `module`, `less`, and `sass` modes.
49+
- Timeout/unavailable runtime fallback states.
50+
51+
## Runtime Notes
52+
53+
- `eslint@10` is the target runtime.
54+
- Stylelint syntax handling is dialect-aware:
55+
- `css`, `module` -> default CSS syntax.
56+
- `less` -> `postcss-less` custom syntax.
57+
- `sass` -> `postcss-scss` custom syntax.
58+
- Runtime loading currently uses CDN candidate fallback and should stay lazy.
59+
60+
## Virtual Filesystem Policy
61+
62+
- Baseline linting is single-file and does not require a virtual filesystem.
63+
- If cross-file linting becomes required, do not roll a custom virtual
64+
filesystem implementation first.
65+
- Prefer a maintained virtual filesystem library with browser-worker support and
66+
predictable path normalization semantics.
67+
68+
## Follow-up Tasks
69+
70+
- Confirm browser-compatible runtime bundles for ESLint 10 + required plugins.
71+
- Confirm browser-compatible stylelint bundle path and syntax plugin availability.
72+
- Move diagnostics scope rendering from string-only lines to structured entries.

src/app.js

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { createCodeMirrorEditor } from './modules/editor-codemirror.js'
88
import { defaultCss, defaultJsx, defaultReactJsx } from './modules/defaults.js'
99
import { createDiagnosticsUiController } from './modules/diagnostics-ui.js'
1010
import { createLayoutThemeController } from './modules/layout-theme.js'
11+
import { createLintController } from './modules/lint/lint-controller.js'
1112
import { createPreviewBackgroundController } from './modules/preview-background.js'
1213
import { createRenderRuntimeController } from './modules/render-runtime.js'
1314
import { createTypeDiagnosticsController } from './modules/type-diagnostics.js'
@@ -24,6 +25,8 @@ const previewPanel = document.getElementById('preview-panel')
2425
const renderMode = document.getElementById('render-mode')
2526
const autoRenderToggle = document.getElementById('auto-render')
2627
const typecheckButton = document.getElementById('typecheck-button')
28+
const lintComponentButton = document.getElementById('lint-component-button')
29+
const lintStylesButton = document.getElementById('lint-styles-button')
2730
const renderButton = document.getElementById('render-button')
2831
const copyComponentButton = document.getElementById('copy-component')
2932
const clearComponentButton = document.getElementById('clear-component')
@@ -369,6 +372,16 @@ const setTypecheckButtonLoading = isLoading => {
369372
typecheckButton.disabled = isLoading
370373
}
371374

375+
const setLintButtonLoading = ({ button, isLoading }) => {
376+
if (!(button instanceof HTMLButtonElement)) {
377+
return
378+
}
379+
380+
button.classList.toggle('render-button--loading', isLoading)
381+
button.setAttribute('aria-busy', isLoading ? 'true' : 'false')
382+
button.disabled = isLoading
383+
}
384+
372385
const setCdnLoading = isLoading => {
373386
if (!cdnLoading) return
374387
cdnLoading.hidden = !isLoading
@@ -409,6 +422,49 @@ const typeDiagnostics = createTypeDiagnosticsController({
409422
getActiveTypeDiagnosticsRuns,
410423
})
411424

425+
const lintController = createLintController({
426+
getComponentSource: () => getJsxSource(),
427+
getStylesSource: () => getCssSource(),
428+
getRenderMode: () => renderMode.value,
429+
getStyleMode: () => styleMode.value,
430+
setComponentDiagnostics: setTypeDiagnosticsDetails,
431+
setStyleDiagnostics: setStyleDiagnosticsDetails,
432+
setStatus,
433+
})
434+
435+
let activeComponentLintAbortController = null
436+
let activeStylesLintAbortController = null
437+
438+
const runComponentLint = async () => {
439+
activeComponentLintAbortController?.abort()
440+
activeComponentLintAbortController = new AbortController()
441+
442+
setLintButtonLoading({ button: lintComponentButton, isLoading: true })
443+
444+
try {
445+
await lintController.lintComponent({
446+
signal: activeComponentLintAbortController.signal,
447+
})
448+
} finally {
449+
setLintButtonLoading({ button: lintComponentButton, isLoading: false })
450+
}
451+
}
452+
453+
const runStylesLint = async () => {
454+
activeStylesLintAbortController?.abort()
455+
activeStylesLintAbortController = new AbortController()
456+
457+
setLintButtonLoading({ button: lintStylesButton, isLoading: true })
458+
459+
try {
460+
await lintController.lintStyles({
461+
signal: activeStylesLintAbortController.signal,
462+
})
463+
} finally {
464+
setLintButtonLoading({ button: lintStylesButton, isLoading: false })
465+
}
466+
}
467+
412468
const markTypeDiagnosticsStale = () => {
413469
typeDiagnostics.markTypeDiagnosticsStale()
414470
}
@@ -607,6 +663,16 @@ if (typecheckButton) {
607663
typeDiagnostics.triggerTypeDiagnostics()
608664
})
609665
}
666+
if (lintComponentButton) {
667+
lintComponentButton.addEventListener('click', () => {
668+
void runComponentLint()
669+
})
670+
}
671+
if (lintStylesButton) {
672+
lintStylesButton.addEventListener('click', () => {
673+
void runStylesLint()
674+
})
675+
}
610676
renderButton.addEventListener('click', renderPreview)
611677
if (clipboardSupported) {
612678
copyComponentButton.addEventListener('click', () => {
@@ -699,6 +765,10 @@ if (typeof compactViewportMediaQuery.addEventListener === 'function') {
699765
compactViewportMediaQuery.onchange = handleCompactViewportChange
700766
}
701767

768+
window.addEventListener('beforeunload', () => {
769+
lintController.dispose()
770+
})
771+
702772
applyAppGridLayout(getInitialAppGridLayout(), { persist: false })
703773
applyTheme(getInitialTheme(), { persist: false })
704774
applyEditorToolsVisibility()

src/index.html

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,9 @@ <h2>Component</h2>
208208
<button class="render-button" id="typecheck-button" type="button">
209209
Typecheck
210210
</button>
211+
<button class="render-button" id="lint-component-button" type="button">
212+
Lint
213+
</button>
211214
<label>
212215
<span class="sr-only">Render mode</span>
213216
<select id="render-mode">
@@ -291,6 +294,9 @@ <h2>Styles</h2>
291294
</div>
292295
<div class="panel-header-main-actions">
293296
<div class="controls controls--actions">
297+
<button class="render-button" id="lint-styles-button" type="button">
298+
Lint
299+
</button>
294300
<label>
295301
<span class="sr-only">Style mode</span>
296302
<select id="style-mode">

src/modules/cdn.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,13 @@ export const cdnImportSpecs = {
138138
esm: '@codemirror/lang-css',
139139
jspmGa: 'npm:@codemirror/lang-css@6.3.1',
140140
},
141+
eslintRuntime: {
142+
esm: ['eslint@10?bundle', 'eslint@10/use-at-your-own-risk?bundle'],
143+
},
144+
stylelintRuntime: {
145+
esm: 'stylelint@17.4.0?bundle',
146+
jspmGa: 'npm:stylelint@17.4.0/lib/index.mjs',
147+
},
141148
}
142149

143150
const getProviderPriority = () => {

src/modules/lint-worker.js

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import {
2+
isLintCancelMessage,
3+
isLintRequestMessage,
4+
lintEngines,
5+
lintWorkerMessageTypes,
6+
} from './lint/shared/protocol.js'
7+
import { runEslintLint } from './lint/worker/eslint-runtime.js'
8+
import { runStylelintLint } from './lint/worker/stylelint-runtime.js'
9+
10+
const canceledIds = new Set()
11+
12+
const toErrorMessage = error => {
13+
if (error instanceof Error && error.message) {
14+
return error.message
15+
}
16+
17+
return String(error)
18+
}
19+
20+
const runEngineLint = async payload => {
21+
if (payload.engine === lintEngines.eslint) {
22+
return runEslintLint({
23+
source: payload.source,
24+
filename: payload.filename,
25+
renderMode: payload.mode ?? 'dom',
26+
})
27+
}
28+
29+
if (payload.engine === lintEngines.stylelint) {
30+
return runStylelintLint({
31+
source: payload.source,
32+
filename: payload.filename,
33+
styleMode: payload.mode ?? 'css',
34+
})
35+
}
36+
37+
throw new Error(`Unknown lint engine: ${payload.engine}`)
38+
}
39+
40+
self.addEventListener('message', async event => {
41+
const message = event.data
42+
43+
if (isLintCancelMessage(message)) {
44+
canceledIds.add(message.id)
45+
return
46+
}
47+
48+
if (!isLintRequestMessage(message)) {
49+
return
50+
}
51+
52+
const { id, payload } = message
53+
54+
if (canceledIds.has(id)) {
55+
canceledIds.delete(id)
56+
return
57+
}
58+
59+
try {
60+
const diagnostics = await runEngineLint(payload)
61+
62+
if (canceledIds.has(id)) {
63+
canceledIds.delete(id)
64+
return
65+
}
66+
67+
self.postMessage({
68+
type: lintWorkerMessageTypes.result,
69+
id,
70+
diagnostics,
71+
})
72+
} catch (error) {
73+
if (canceledIds.has(id)) {
74+
canceledIds.delete(id)
75+
return
76+
}
77+
78+
self.postMessage({
79+
type: lintWorkerMessageTypes.error,
80+
id,
81+
message: toErrorMessage(error),
82+
})
83+
}
84+
})

src/modules/lint/eslint/config.js

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
export const getEslintLintOptions = ({ renderMode }) => {
2+
const reactRules =
3+
renderMode === 'react'
4+
? {
5+
'no-unused-vars': 'warn',
6+
}
7+
: {}
8+
9+
return {
10+
ignore: false,
11+
overrideConfigFile: true,
12+
overrideConfig: [
13+
{
14+
files: ['**/*.{js,mjs,cjs,jsx,ts,mts,cts,tsx}'],
15+
languageOptions: {
16+
ecmaVersion: 'latest',
17+
sourceType: 'module',
18+
parserOptions: {
19+
ecmaFeatures: {
20+
jsx: true,
21+
},
22+
},
23+
},
24+
rules: {
25+
'no-undef': 'error',
26+
'no-unreachable': 'error',
27+
'no-unused-vars': 'warn',
28+
...reactRules,
29+
},
30+
},
31+
],
32+
}
33+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { createLintRequest, lintEngines, lintScopes } from '../shared/protocol.js'
2+
3+
export const createEslintWorkerAdapter = ({ client }) => {
4+
const lintComponent = async ({ source, renderMode, signal }) => {
5+
const request = createLintRequest({
6+
engine: lintEngines.eslint,
7+
scope: lintScopes.component,
8+
source,
9+
filename: 'component.tsx',
10+
mode: renderMode,
11+
})
12+
13+
return client.run(request, { signal })
14+
}
15+
16+
return {
17+
lintComponent,
18+
}
19+
}

0 commit comments

Comments
 (0)