vite-plugin-lint-overlay 0.1.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/README.md ADDED
@@ -0,0 +1,55 @@
1
+ # vite-plugin-lint-overlay
2
+
3
+ A Vite dev-server overlay that shows **TypeScript** + **ESLint** problems in a single UI. 😊
4
+
5
+ - ✅ TypeScript diagnostics (optional, watch mode)
6
+ - ✅ ESLint diagnostics (incremental, cached)
7
+ - ✅ Runs on **dev start**, **file events**, and **browser reload**
8
+
9
+ ---
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ npm install -D vite-plugin-lint-overlay
15
+ ```
16
+
17
+ **Peer dependencies:** `vite`, `eslint`, `typescript`.
18
+
19
+ ---
20
+
21
+ ## Usage
22
+
23
+ `vite.config.js` / `vite.config.ts`
24
+
25
+ ```js
26
+ import { defineConfig } from 'vite';
27
+ import react from '@vitejs/plugin-react';
28
+ import lintOverlay from 'vite-plugin-lint-overlay';
29
+
30
+ export default defineConfig({
31
+ plugins: [
32
+ react(),
33
+ lintOverlay({
34
+ rootDir: 'src',
35
+ ts: true // Enable TypeScript checks
36
+ })
37
+ ]
38
+ });
39
+ ```
40
+
41
+ ---
42
+
43
+ ## Options
44
+
45
+ | Option | Type | Default | Description |
46
+ | :--- | :--- | :--- | :--- |
47
+ | **rootDir** | `string` | `'src'` | Root directory to watch and lint for ESLint issues. |
48
+ | **ts** | `boolean` | `false` | Set to `true` to enable the TypeScript compiler check. |
49
+ | **tsconfigPath** | `string` | `''` | Path to `tsconfig`. Defaults to `tsconfig.app.json` or `tsconfig.json`. |
50
+
51
+ ---
52
+
53
+ ## License
54
+
55
+ MIT
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "vite-plugin-lint-overlay",
3
+ "version": "0.1.0",
4
+ "description": "Smart dev-server overlay for ESLint and TypeScript errors.",
5
+ "license": "MIT",
6
+ "author": "Max Matinpalo",
7
+ "homepage": "https://github.com/max-matinpalo/vite-lint-overlay#readme",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/max-matinpalo/vite-lint-overlay.git"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/max-matinpalo/vite-lint-overlay/issues"
14
+ },
15
+ "type": "module",
16
+ "main": "./src/lint-overlay.js",
17
+ "module": "./src/lint-overlay.js",
18
+ "exports": {
19
+ ".": "./src/lint-overlay.js"
20
+ },
21
+ "files": [
22
+ "src",
23
+ "README.md"
24
+ ],
25
+ "peerDependencies": {
26
+ "eslint": "^8.0.0 || ^9.0.0",
27
+ "typescript": "^5.0.0",
28
+ "vite": "^4.0.0 || ^5.0.0 || ^6.0.0"
29
+ }
30
+ }
@@ -0,0 +1,137 @@
1
+ const STYLES = `
2
+ :host {
3
+ position: fixed; top: 0; left: 0; z-index: 99999;
4
+ width: 100%; height: 100%;
5
+ pointer-events: none; display: none;
6
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, monospace;
7
+ }
8
+ :host([visible]) {
9
+ display: flex; pointer-events: auto;
10
+ background: #00000099; backdrop-filter: blur(4px);
11
+ justify-content: center; align-items: center; padding: 20px;
12
+ }
13
+ .container {
14
+ background: #1e1e1e; color: #d4d4d4;
15
+ width: 100%; max-width: 900px; max-height: 800px;
16
+ border-radius: 8px; border: 1px solid #333333;
17
+ display: flex; flex-direction: column;
18
+ box-shadow: 0 20px 50px #00000080; overflow: hidden;
19
+ }
20
+ .header {
21
+ background: #252526; padding: 12px 16px;
22
+ border-bottom: 1px solid #333333;
23
+ display: flex; justify-content: space-between; align-items: center;
24
+ flex-shrink: 0;
25
+ }
26
+ .title { font-weight: bold; font-size: 14px; color: #ffffff; }
27
+ .close {
28
+ cursor: pointer; background: transparent; border: none; color: #999999;
29
+ font-size: 20px; line-height: 1; padding: 0;
30
+ }
31
+ .close:hover { color: #ffffff; }
32
+ .body { flex: 1; overflow-y: auto; display: flex; flex-direction: column; }
33
+ h2 {
34
+ margin: 0; padding: 10px 16px; font-size: 13px; font-weight: bold;
35
+ background: #252526; border-bottom: 1px solid #333333;
36
+ position: sticky; top: 0; z-index: 10;
37
+ text-transform: uppercase; letter-spacing: 0.5px;
38
+ }
39
+ h2.red { color: #f44336; }
40
+ h2.orange { color: #ff9800; border-top: 1px solid #333333; }
41
+ .list { list-style: none; margin: 0; padding: 0; }
42
+ .item {
43
+ padding: 12px 16px; border-bottom: 1px solid #2d2d2d;
44
+ border-left: 3px solid transparent;
45
+ display: flex; flex-direction: column; gap: 6px;
46
+ }
47
+ .item.error { border-left-color: #f44336; background: #f443360d; }
48
+ .item.warning { border-left-color: #ff9800; background: #ff98000d; }
49
+ .meta {
50
+ font-size: 12px; font-weight: bold; color: #888888; display: flex; align-items: center;
51
+ gap: 8px; font-family: Menlo, monospace;
52
+ }
53
+ .file { color: #aaaaaa; word-break: break-all; }
54
+ .badge {
55
+ padding: 3px 6px; border-radius: 4px; font-size: 10px; font-weight: 700;
56
+ text-transform: uppercase; letter-spacing: 0.5px; color: #ffffff; background: #444444;
57
+ }
58
+ .badge.ts { background: #3178c6; color: #ffffff; }
59
+ .badge.eslint { background: #f7b93e; color: #000000; }
60
+ .msg {
61
+ white-space: pre-wrap; word-break: break-word; font-size: 13px;
62
+ line-height: 1.5; color: #cccccc; font-family: Menlo, monospace;
63
+ }
64
+ `;
65
+
66
+
67
+ class SmartErrorOverlay extends HTMLElement {
68
+ constructor() {
69
+ super();
70
+ this.attachShadow({ mode: 'open' });
71
+ this.root = this.shadowRoot;
72
+ }
73
+
74
+ connectedCallback() {
75
+ this.root.innerHTML = `<style>${STYLES}</style><div id="mount"></div>`;
76
+ }
77
+
78
+ setErrors(errors) {
79
+ if (!this.root.getElementById('mount')) this.connectedCallback();
80
+ if (!errors || !errors.length) return this.removeAttribute('visible');
81
+ this.setAttribute('visible', '');
82
+ const errs = errors.filter(e => e.severity === 'error');
83
+ const warns = errors.filter(e => e.severity === 'warning');
84
+ const renderList = (list) => list.map(e => {
85
+ const type = (e.source || 'unk').toLowerCase();
86
+ const cleanFile = (e.file || 'Global').split(/\/|\\/).slice(-3).join('/');
87
+ const hasPath = /[\\/]/.test(e.file || '');
88
+ const fileDisplay = hasPath && cleanFile !== e.file
89
+ ? `.../${cleanFile}` : (e.file || 'Global');
90
+ return `
91
+ <li class="item ${e.severity || 'error'}">
92
+ <div class="meta">
93
+ <span class="badge ${type}">${e.source || 'UNK'}</span>
94
+ <span class="file" title="${this.escape(e.file)}">
95
+ ${this.escape(fileDisplay)}${e.line ? ':' + e.line : ''}
96
+ </span>
97
+ </div>
98
+ <div class="msg">${this.escape(e.message)}</div>
99
+ </li>`;
100
+ }).join('');
101
+ this.root.getElementById('mount').innerHTML = `
102
+ <div class="container">
103
+ <div class="header"><span class="title">Dev Server Issues</span>
104
+ <button id="close" class="close">×</button></div>
105
+ <div class="body">
106
+ ${errs.length ? `<h2 class="red">Errors (${errs.length})</h2>
107
+ <ul class="list">${renderList(errs)}</ul>` : ''}
108
+ ${warns.length ? `<h2 class="orange">Warnings (${warns.length})</h2>
109
+ <ul class="list">${renderList(warns)}</ul>` : ''}
110
+ </div>
111
+ </div>`;
112
+ this.root.getElementById('close').onclick = () => this.removeAttribute('visible');
113
+ }
114
+
115
+ escape(str) {
116
+ return String(str || '').replace(/[&<>"']/g, (m) => ({
117
+ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#039;'
118
+ })[m]);
119
+ }
120
+ }
121
+
122
+
123
+ if (!customElements.get('smart-error-overlay')) {
124
+ customElements.define('smart-error-overlay', SmartErrorOverlay);
125
+ }
126
+
127
+
128
+ if (import.meta.hot) {
129
+ let overlay = document.querySelector('smart-error-overlay');
130
+ if (!overlay) {
131
+ overlay = document.createElement('smart-error-overlay');
132
+ document.body.appendChild(overlay);
133
+ }
134
+ import.meta.hot.on('smart-overlay:update', (data) => {
135
+ if (overlay) overlay.setErrors(data?.errors || []);
136
+ });
137
+ }
@@ -0,0 +1,116 @@
1
+ import { Worker } from 'node:worker_threads';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+
7
+ export default function lintOverlay(props = {}) {
8
+ const { rootDir: rawRoot = 'src' } = props;
9
+ const { tsconfigPath = '' } = props;
10
+ const { ts = false } = props;
11
+ const rootDir = String(rawRoot).trim().replace(/\/+$/, '') || 'src';
12
+ const virtualId = 'virtual:lint-overlay-client.js';
13
+ const resolvedVirtualId = '\0' + virtualId;
14
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
15
+
16
+ let tsErrors = [];
17
+ let lintErrors = [];
18
+ let serverWs = null;
19
+
20
+ const updateOverlay = () => {
21
+ if (!serverWs) return;
22
+ serverWs.send({
23
+ type: 'custom',
24
+ event: 'smart-overlay:update',
25
+ data: { errors: [...tsErrors, ...lintErrors] }
26
+ });
27
+ };
28
+
29
+ return {
30
+ name: 'vite-plugin-lint-overlay',
31
+ apply: 'serve',
32
+
33
+ resolveId(id) {
34
+ if (id === virtualId || id === '/' + virtualId) return resolvedVirtualId;
35
+ },
36
+
37
+ load(id) {
38
+ if (id === resolvedVirtualId) {
39
+ return fs.readFileSync(path.join(__dirname, 'client-overlay.js'), 'utf-8');
40
+ }
41
+ },
42
+
43
+ configureServer(server) {
44
+ serverWs = server.ws;
45
+ const lintWorker = new Worker(
46
+ new URL('./lint-worker.js', import.meta.url),
47
+ { type: 'module', workerData: { rootDir } }
48
+ );
49
+ const handleCrash = (source, msg) => {
50
+ const err = { file: 'Global', message: msg, source, severity: 'error' };
51
+ if (source === 'TS') tsErrors = [err];
52
+ if (source === 'ESLint') lintErrors = [err];
53
+ updateOverlay();
54
+ };
55
+
56
+ if (ts) {
57
+ const tsWorker = new Worker(
58
+ new URL('./ts-worker.js', import.meta.url),
59
+ { type: 'module', workerData: { tsconfigPath } }
60
+ );
61
+ tsWorker.on('error', (e) => handleCrash('TS', `TS Worker error: ${e.message}`));
62
+ tsWorker.on('exit', (c) => c !== 0 && handleCrash('TS', `TS Worker died (code ${c})`));
63
+ tsWorker.on('message', (msg) => {
64
+ if (msg.type !== 'SNAPSHOT') return;
65
+ tsErrors = msg.errors;
66
+ updateOverlay();
67
+ });
68
+ server.httpServer?.on('close', () => tsWorker.terminate());
69
+ }
70
+
71
+ lintWorker.on('error', (e) => handleCrash('ESLint', `Lint Worker error: ${e.message}`));
72
+ lintWorker.on('exit', (c) => c !== 0 && handleCrash('ESLint', `Lint Worker died (code ${c})`));
73
+ lintWorker.on('message', (msg) => {
74
+ if (msg.type !== 'SNAPSHOT') return;
75
+ lintErrors = msg.errors;
76
+ updateOverlay();
77
+ });
78
+
79
+ server.httpServer?.on('close', () => lintWorker.terminate());
80
+ const isTarget = (f) => /\.(js|jsx|ts|tsx)$/.test(f);
81
+ const isInRoot = (f) => {
82
+ const rel = path
83
+ .relative(server.config.root || process.cwd(), f)
84
+ .replace(/\\/g, '/');
85
+ return rel === rootDir || rel.startsWith(rootDir + '/');
86
+ };
87
+
88
+ const lintFile = (f) => {
89
+ if (!isTarget(f)) return;
90
+ if (!isInRoot(f)) return;
91
+ lintWorker.postMessage({ type: 'LINT', files: [f] });
92
+ };
93
+
94
+ lintWorker.postMessage({ type: 'LINT_ALL' });
95
+ server.ws.on('connection', () => {
96
+ updateOverlay();
97
+ lintWorker.postMessage({ type: 'LINT_ALL' });
98
+ });
99
+ server.watcher.on('add', lintFile);
100
+ server.watcher.on('change', lintFile);
101
+ server.watcher.on('unlink', (f) => {
102
+ if (!isTarget(f)) return;
103
+ if (!isInRoot(f)) return;
104
+ lintWorker.postMessage({ type: 'UNLINK', f });
105
+ });
106
+ },
107
+
108
+ transformIndexHtml() {
109
+ return [{
110
+ tag: 'script',
111
+ attrs: { type: 'module', src: `/${virtualId}` },
112
+ injectTo: 'body-prepend'
113
+ }];
114
+ }
115
+ };
116
+ }
@@ -0,0 +1,108 @@
1
+ import { parentPort, workerData } from 'node:worker_threads';
2
+ import { ESLint } from 'eslint';
3
+ import path from 'node:path';
4
+
5
+ if (!parentPort) throw new Error('No parentPort');
6
+
7
+ let rootDir = workerData?.rootDir || 'src';
8
+ rootDir = String(rootDir).trim().replace(/\/+$/, '');
9
+ if (!rootDir) rootDir = 'src';
10
+
11
+ const ALL = [`${rootDir}/**/*.{js,jsx,ts,tsx}`];
12
+ const cache = new Map();
13
+ const CWD = process.cwd();
14
+ const rel = (p) => path.relative(CWD, p).replace(/\\/g, '/');
15
+
16
+ let eslint = null;
17
+ let running = false;
18
+ let pending = null;
19
+
20
+ const snap = (errors) => parentPort.postMessage({ type: 'SNAPSHOT', errors });
21
+
22
+ function errText(e) {
23
+ if (!e) return 'Unknown error';
24
+ if (typeof e === 'string') return e;
25
+ const msg = e.message ? String(e.message) : String(e);
26
+ const stack = e.stack
27
+ ? String(e.stack).split('\n').slice(0, 2).join('\n')
28
+ : '';
29
+ return stack ? `${msg}\n${stack}` : msg;
30
+ }
31
+
32
+ function globalError(message) {
33
+ return [{
34
+ file: 'Global',
35
+ line: 0,
36
+ source: 'ESLint',
37
+ severity: 'error',
38
+ message
39
+ }];
40
+ }
41
+
42
+ function unlinkFile(f) {
43
+ if (!f) return;
44
+ cache.delete(rel(f));
45
+ snap([...cache.values()].flat());
46
+ }
47
+
48
+ async function run() {
49
+ while (pending) {
50
+ const job = pending;
51
+ pending = null;
52
+
53
+ if (!eslint) {
54
+ try {
55
+ eslint = new ESLint();
56
+ } catch (e) {
57
+ snap(globalError(`Init failed: ${errText(e)}`));
58
+ continue;
59
+ }
60
+ }
61
+
62
+ try {
63
+ if (job.reset) cache.clear();
64
+
65
+ const res = await eslint.lintFiles(job.files);
66
+
67
+ res.forEach((r) => {
68
+ const msgs = r.messages.filter((m) => m.severity > 0);
69
+ const key = rel(r.filePath);
70
+ if (!msgs.length) return cache.delete(key);
71
+
72
+ cache.set(key, msgs.map((m) => ({
73
+ file: key,
74
+ line: m.line || 0,
75
+ message: m.message,
76
+ source: 'ESLint',
77
+ severity: m.severity === 2 ? 'error' : 'warning'
78
+ })));
79
+ });
80
+
81
+ snap([...cache.values()].flat());
82
+ } catch (e) {
83
+ snap(globalError(`Lint crashed: ${errText(e)}`));
84
+ }
85
+ }
86
+
87
+ running = false;
88
+ }
89
+
90
+ parentPort.on('message', (msg) => {
91
+ if (msg?.type === 'UNLINK') {
92
+ unlinkFile(msg.f);
93
+ return;
94
+ }
95
+
96
+ const global = msg?.type === 'LINT_ALL';
97
+ if (pending?.reset && !global) return;
98
+
99
+ pending = {
100
+ files: global ? ALL : (Array.isArray(msg.files) ? msg.files : []),
101
+ reset: global
102
+ };
103
+
104
+ if (!running) {
105
+ running = true;
106
+ run();
107
+ }
108
+ });
@@ -0,0 +1,100 @@
1
+ import { parentPort, workerData } from 'node:worker_threads';
2
+ import ts from 'typescript';
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+
6
+ if (!parentPort) throw new Error('No parentPort');
7
+
8
+ const ROOT = process.cwd();
9
+
10
+ let tsconfigPath = workerData?.tsconfigPath || '';
11
+ tsconfigPath = String(tsconfigPath).trim();
12
+
13
+ function postGlobalError(message) {
14
+ parentPort.postMessage({
15
+ type: 'SNAPSHOT',
16
+ errors: [{
17
+ file: 'Global',
18
+ line: 0,
19
+ source: 'TS',
20
+ severity: 'error',
21
+ message
22
+ }]
23
+ });
24
+ }
25
+
26
+ function resolveConfigPath() {
27
+ if (tsconfigPath) {
28
+ const abs = path.isAbsolute(tsconfigPath)
29
+ ? tsconfigPath
30
+ : path.join(ROOT, tsconfigPath);
31
+
32
+ if (fs.existsSync(abs)) return abs;
33
+
34
+ const found = ts.findConfigFile(ROOT, ts.sys.fileExists, tsconfigPath);
35
+ if (found) return found;
36
+
37
+ return '';
38
+ }
39
+
40
+ const fallback = fs.existsSync(path.join(ROOT, 'tsconfig.app.json'))
41
+ ? 'tsconfig.app.json'
42
+ : 'tsconfig.json';
43
+
44
+ return ts.findConfigFile(ROOT, ts.sys.fileExists, fallback) || '';
45
+ }
46
+
47
+ const configPath = resolveConfigPath();
48
+
49
+ if (!configPath) {
50
+ postGlobalError(
51
+ tsconfigPath
52
+ ? `tsconfig not found: ${tsconfigPath}`
53
+ : 'tsconfig not found: tsconfig.app.json or tsconfig.json'
54
+ );
55
+ parentPort.close();
56
+ process.exit(0);
57
+ }
58
+
59
+ const host = ts.createWatchCompilerHost(
60
+ configPath,
61
+ { noEmit: true },
62
+ ts.sys,
63
+ ts.createSemanticDiagnosticsBuilderProgram,
64
+ () => { },
65
+ () => { }
66
+ );
67
+
68
+ host.afterProgramCreate = (builder) => {
69
+ const prog = builder.getProgram();
70
+
71
+ const errors = ts.getPreEmitDiagnostics(prog)
72
+ .filter((d) =>
73
+ d.category === ts.DiagnosticCategory.Error ||
74
+ d.category === ts.DiagnosticCategory.Warning
75
+ )
76
+ .map((d) => {
77
+ const message = ts.flattenDiagnosticMessageText(d.messageText, '\n');
78
+ const severity = d.category === ts.DiagnosticCategory.Error
79
+ ? 'error'
80
+ : 'warning';
81
+
82
+ if (!d.file) {
83
+ return { file: 'Global', line: 0, message, source: 'TS', severity };
84
+ }
85
+
86
+ const { line } = d.file.getLineAndCharacterOfPosition(d.start ?? 0);
87
+
88
+ return {
89
+ file: path.relative(ROOT, d.file.fileName).replace(/\\/g, '/'),
90
+ line: line + 1,
91
+ message,
92
+ source: 'TS',
93
+ severity
94
+ };
95
+ });
96
+
97
+ parentPort.postMessage({ type: 'SNAPSHOT', errors });
98
+ };
99
+
100
+ ts.createWatchProgram(host);