- Device breakpoint toggles (mobile 375px / tablet 768px / desktop 100%) - Editor theme selector with 6 themes (VS Dark/Light, High Contrast, Monokai, Dracula, GitHub Dark) - Starter template gallery with 8 pre-built templates (Todo, API Fetch, CSS Animation, etc.) - Code autocomplete with DOM/React type definitions and snippet completions - Devtools panels: console, network, elements, performance - Code formatter (Prettier), diff view, and linter integration
173 lines
4.8 KiB
JavaScript
173 lines
4.8 KiB
JavaScript
import { loadScript } from './utils.js';
|
|
import { getActiveEditor, getActiveTab, getCurrentMode, setOnTabSwitch, setOnModeChange } from './editors.js';
|
|
|
|
const ESLINT_CDN = 'https://cdn.jsdelivr.net/npm/eslint-linter-browserify@9/linter.min.js';
|
|
|
|
let linterInstance = null;
|
|
let loaded = false;
|
|
let debounceTimer = null;
|
|
|
|
async function ensureESLint() {
|
|
if (loaded) return;
|
|
await loadScript(ESLINT_CDN);
|
|
if (window.eslint && window.eslint.Linter) {
|
|
linterInstance = new window.eslint.Linter();
|
|
} else if (window.Linter) {
|
|
linterInstance = new window.Linter();
|
|
}
|
|
loaded = true;
|
|
}
|
|
|
|
function isLintableMode(mode) {
|
|
return ['html-css-js', 'typescript', 'react', 'react-ts', 'wasm'].includes(mode);
|
|
}
|
|
|
|
function isLintableTab(tabId) {
|
|
return tabId === 'js';
|
|
}
|
|
|
|
const BROWSER_GLOBALS = {
|
|
document: 'readonly', window: 'readonly', console: 'readonly',
|
|
setTimeout: 'readonly', clearTimeout: 'readonly', setInterval: 'readonly', clearInterval: 'readonly',
|
|
requestAnimationFrame: 'readonly', cancelAnimationFrame: 'readonly',
|
|
fetch: 'readonly', URL: 'readonly', URLSearchParams: 'readonly',
|
|
HTMLElement: 'readonly', Event: 'readonly', CustomEvent: 'readonly',
|
|
MutationObserver: 'readonly', IntersectionObserver: 'readonly', ResizeObserver: 'readonly',
|
|
localStorage: 'readonly', sessionStorage: 'readonly', navigator: 'readonly', location: 'readonly',
|
|
history: 'readonly', performance: 'readonly', alert: 'readonly', confirm: 'readonly', prompt: 'readonly',
|
|
WebAssembly: 'readonly', Uint8Array: 'readonly', ArrayBuffer: 'readonly',
|
|
Map: 'readonly', Set: 'readonly', WeakMap: 'readonly', WeakSet: 'readonly',
|
|
Promise: 'readonly', Symbol: 'readonly', Proxy: 'readonly', Reflect: 'readonly',
|
|
globalThis: 'readonly', self: 'readonly', queueMicrotask: 'readonly',
|
|
};
|
|
|
|
const REACT_GLOBALS = {
|
|
React: 'readonly', ReactDOM: 'readonly',
|
|
};
|
|
|
|
function getEslintConfig(mode) {
|
|
const parserOptions = {
|
|
ecmaVersion: 'latest',
|
|
sourceType: 'module',
|
|
};
|
|
|
|
const globals = { ...BROWSER_GLOBALS };
|
|
|
|
if (mode === 'react' || mode === 'react-ts') {
|
|
parserOptions.ecmaFeatures = { jsx: true };
|
|
Object.assign(globals, REACT_GLOBALS);
|
|
}
|
|
|
|
return {
|
|
languageOptions: { globals, parserOptions },
|
|
rules: {
|
|
'no-constant-condition': 'warn',
|
|
'no-debugger': 'warn',
|
|
'no-empty': 'warn',
|
|
'no-extra-semi': 'warn',
|
|
'no-unreachable': 'warn',
|
|
'no-duplicate-case': 'warn',
|
|
},
|
|
};
|
|
}
|
|
|
|
function severityToMonaco(severity) {
|
|
// ESLint: 1=warn, 2=error
|
|
if (severity === 2) return monaco.MarkerSeverity.Error;
|
|
return monaco.MarkerSeverity.Warning;
|
|
}
|
|
|
|
function lintEditor(editor, mode) {
|
|
if (!linterInstance || !editor) return 0;
|
|
|
|
const model = editor.getModel();
|
|
if (!model) return 0;
|
|
|
|
const code = editor.getValue();
|
|
if (!code.trim()) {
|
|
monaco.editor.setModelMarkers(model, 'eslint', []);
|
|
return 0;
|
|
}
|
|
|
|
try {
|
|
const config = getEslintConfig(mode);
|
|
const messages = linterInstance.verify(code, config);
|
|
|
|
const markers = messages.map(m => ({
|
|
startLineNumber: m.line || 1,
|
|
startColumn: m.column || 1,
|
|
endLineNumber: m.endLine || m.line || 1,
|
|
endColumn: m.endColumn || (m.column || 1) + 1,
|
|
message: `${m.message} (${m.ruleId || 'syntax'})`,
|
|
severity: severityToMonaco(m.severity),
|
|
source: 'eslint',
|
|
}));
|
|
|
|
monaco.editor.setModelMarkers(model, 'eslint', markers);
|
|
return markers.length;
|
|
} catch (e) {
|
|
monaco.editor.setModelMarkers(model, 'eslint', []);
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
function updateBadge(count) {
|
|
// Lint feedback is shown as editor squiggles only — no badge needed
|
|
}
|
|
|
|
function scheduleLint() {
|
|
clearTimeout(debounceTimer);
|
|
debounceTimer = setTimeout(() => {
|
|
const mode = getCurrentMode();
|
|
const tabId = getActiveTab();
|
|
if (!isLintableMode(mode) || !isLintableTab(tabId)) {
|
|
clearMarkers();
|
|
updateBadge(0);
|
|
return;
|
|
}
|
|
const editor = getActiveEditor();
|
|
const count = lintEditor(editor, mode);
|
|
updateBadge(count);
|
|
}, 800);
|
|
}
|
|
|
|
function clearMarkers() {
|
|
const editor = getActiveEditor();
|
|
if (editor && editor.getModel()) {
|
|
monaco.editor.setModelMarkers(editor.getModel(), 'eslint', []);
|
|
}
|
|
}
|
|
|
|
export async function initLinter() {
|
|
try {
|
|
await ensureESLint();
|
|
} catch (e) {
|
|
console.warn('ESLint failed to load:', e.message);
|
|
return;
|
|
}
|
|
|
|
if (!linterInstance) return;
|
|
|
|
// Lint on tab switch
|
|
setOnTabSwitch((tabId, editor) => {
|
|
const mode = getCurrentMode();
|
|
if (isLintableMode(mode) && isLintableTab(tabId) && editor) {
|
|
const count = lintEditor(editor, mode);
|
|
updateBadge(count);
|
|
} else {
|
|
updateBadge(0);
|
|
}
|
|
});
|
|
|
|
// Clear markers on mode change
|
|
setOnModeChange((mode) => {
|
|
if (!isLintableMode(mode)) {
|
|
updateBadge(0);
|
|
}
|
|
});
|
|
}
|
|
|
|
export function lintOnChange() {
|
|
scheduleLint();
|
|
}
|