Skip to content

Commit 3f00f2c

Browse files
authored
Biome as linter
1 parent b4cdbeb commit 3f00f2c

7 files changed

Lines changed: 186 additions & 33 deletions

File tree

Build/eslint.config.mts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import { defineConfig } from "eslint/config";
99
export default defineConfig([
1010
{ files: ["**/*.{js,mjs,cjs,ts,mts,cts}"], plugins: { js }, extends: ["js/recommended"], languageOptions: { globals: globals.browser } },
1111
tseslint.configs.recommended,
12-
{ files: ["**/*.json"], plugins: { json }, language: "json/json", extends: ["json/recommended"] },
1312
{ files: ["**/*.md"], plugins: { markdown }, language: "markdown/gfm", extends: ["markdown/recommended"] },
1413
{ files: ["**/*.css"], plugins: { css }, language: "css/css", extends: ["css/recommended"] },
1514
]);

Build/package-lock.json

Lines changed: 33 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Build/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@
2424
"@babel/core": "^7.29.0",
2525
"@babel/parser": "^7.29.3",
2626
"@babel/runtime": "^7.29.2",
27+
"@biomejs/js-api": "^4.0.0",
28+
"@biomejs/wasm-web": "^2.4.15",
2729
"@codemirror/autocomplete": "^6.20.2",
2830
"@codemirror/commands": "^6.10.3",
2931
"@codemirror/lang-css": "^6.3.1",

Build/src/biome.ts

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { Biome, Distribution } from "@biomejs/js-api";
2+
import type { Configuration } from "@biomejs/js-api";
3+
import initBiomeWasm from "@biomejs/wasm-web";
4+
5+
let biomeWorkspace: Biome | null = null;
6+
let biomeProjectKey: number | null = null;
7+
let initialized = false;
8+
9+
export type BiomeDiag = {
10+
from: number;
11+
to: number;
12+
severity: "error" | "warning" | "info";
13+
message: string;
14+
};
15+
16+
function byteToCharOffset(text: string, byteOffset: number): number {
17+
let charIdx = 0;
18+
for (let i = 0; i < byteOffset; charIdx++) {
19+
const code = text.charCodeAt(charIdx);
20+
if (code < 0x80) {
21+
i += 1;
22+
} else if (code < 0x800) {
23+
i += 2;
24+
} else if (code >= 0xd800 && code <= 0xdfff) {
25+
i += 4;
26+
charIdx++;
27+
} else {
28+
i += 3;
29+
}
30+
}
31+
return charIdx;
32+
}
33+
34+
export async function initBiome(): Promise<void> {
35+
if (initialized) return;
36+
37+
await initBiomeWasm();
38+
39+
biomeWorkspace = await Biome.create({ distribution: Distribution.WEB });
40+
41+
const openRes = biomeWorkspace.openProject();
42+
biomeProjectKey = openRes.projectKey;
43+
44+
biomeWorkspace.applyConfiguration(biomeProjectKey, {
45+
linter: {
46+
enabled: true,
47+
rules: {
48+
recommended: true,
49+
},
50+
},
51+
} as unknown as Configuration);
52+
53+
initialized = true;
54+
}
55+
56+
export async function lintWithBiome(text: string, filepath = "file.ts"): Promise<BiomeDiag[]> {
57+
if (!initialized) {
58+
await initBiome();
59+
}
60+
61+
if (!biomeWorkspace) {
62+
throw new Error("Biome workspace not active after runtime initialization");
63+
}
64+
65+
if (biomeProjectKey == null) {
66+
throw new Error("Biome project key not initialized");
67+
}
68+
69+
const result = biomeWorkspace.lintContent(biomeProjectKey, text, {
70+
filePath: filepath,
71+
});
72+
73+
const diags = result?.diagnostics;
74+
if (!Array.isArray(diags)) {
75+
throw new Error("Unexpected Biome lint response shape: missing diagnostics block");
76+
}
77+
78+
return diags.map((d) => {
79+
const diag = d as unknown as {
80+
location?: { span?: [number, number] } | null;
81+
severity?: string | null;
82+
description?: string | null;
83+
category?: string | null;
84+
};
85+
86+
const span = diag.location?.span;
87+
const byteFrom = typeof span?.[0] === "number" ? span[0] : 0;
88+
const byteTo = typeof span?.[1] === "number" ? span[1] : byteFrom;
89+
90+
const from = byteToCharOffset(text, byteFrom);
91+
const to = byteToCharOffset(text, byteTo);
92+
93+
let severity: "error" | "warning" | "info" = "info";
94+
if (diag.severity === "error" || diag.severity === "fatal") severity = "error";
95+
if (diag.severity === "warning" || diag.severity === "warn") severity = "warning";
96+
97+
const rawMsg = diag.description ?? "";
98+
const category = diag.category
99+
? diag.category.replace(/^lint\//, "")
100+
: "";
101+
const message = category ? `${rawMsg} [${category}]` : rawMsg;
102+
103+
return { from, to, severity, message };
104+
});
105+
}

Build/src/editor.ts

Lines changed: 18 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import { Compartment, EditorState, Extension } from "@codemirror/state";
22
import { EditorView, keymap, lineNumbers } from "@codemirror/view";
3-
import { defaultKeymap, toggleComment } from "@codemirror/commands";
3+
import { defaultKeymap, toggleComment, history, undo, redo } from "@codemirror/commands";
44
import { html } from "@codemirror/lang-html";
55
import { css } from "@codemirror/lang-css";
66
import { javascript } from "@codemirror/lang-javascript";
77
import { autocompletion } from "@codemirror/autocomplete";
8-
import { foldGutter, foldKeymap, syntaxTree } from "@codemirror/language";
8+
import { foldGutter, foldKeymap } from "@codemirror/language";
99
import { linter, lintGutter } from "@codemirror/lint";
10+
import { lintWithBiome } from "./biome";
1011
import { monokai } from "@uiw/codemirror-theme-monokai";
1112
import { bbedit } from "@uiw/codemirror-theme-bbedit";
1213
import { CodeMirrorEditor, Editors } from "./types";
@@ -60,6 +61,7 @@ function createEditorConfig(
6061
container: HTMLElement,
6162
content: string,
6263
contentState: typeof htmlState | typeof cssState | typeof jsState,
64+
fileName = "file.txt",
6365
): CodeMirrorEditor {
6466
const themeCompartment = new Compartment();
6567
const autoRunCompartment = new Compartment();
@@ -69,32 +71,17 @@ function createEditorConfig(
6971
doc: content,
7072
extensions: [
7173
lineNumbers(),
74+
history(),
7275
foldGutter(),
73-
// Linting: use syntax tree to surface parse errors from the language parser
74-
linter((view) => {
75-
const diags: Array<{
76-
from: number;
77-
to: number;
78-
severity: "error" | "warning" | "info";
79-
message: string;
80-
}> = [];
76+
linter(async (view) => {
77+
const text = view.state.doc.toString();
8178
try {
82-
syntaxTree(view.state).iterate({
83-
enter: (node) => {
84-
if (node.type.isError) {
85-
diags.push({
86-
from: node.from,
87-
to: node.to,
88-
severity: "error",
89-
message: "Syntax error",
90-
});
91-
}
92-
},
93-
});
94-
} catch (e: unknown) {
95-
console.error("Error occurred while iterating syntax tree:", e);
79+
const biomeDiags = await lintWithBiome(text, fileName);
80+
return biomeDiags || [];
81+
} catch (err) {
82+
console.error("Biome linting failed:", err);
83+
return [];
9684
}
97-
return diags;
9885
}),
9986
lintGutter(),
10087
language,
@@ -112,6 +99,9 @@ function createEditorConfig(
11299
...defaultKeymap,
113100
...foldKeymap,
114101
{ key: "Mod-/", run: toggleComment },
102+
{ key: "Mod-z", run: undo },
103+
{ key: "Mod-y", run: redo },
104+
{ key: "Mod-Shift-z", run: redo },
115105
]),
116106
autoRunCompartment.of(autoRunState.get() ? autoRunListener : []),
117107
EditorView.updateListener.of((update) => {
@@ -161,17 +151,20 @@ export function initializeEditors(containers: EditorContainers): void {
161151
htmlContainer,
162152
htmlState.get(),
163153
htmlState,
154+
"index.html",
164155
);
165156
editors.css = createEditorConfig(
166157
css(),
167158
cssContainer,
168159
cssState.get(),
169160
cssState,
161+
"styles.css",
170162
);
171163
editors.js = createEditorConfig(
172164
javascript(),
173165
jsContainer,
174166
jsState.get(),
175167
jsState,
168+
"script.js",
176169
);
177170
}

Build/src/main.ts

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { copyToClipboard } from "./utils";
1010
import { editors } from "./editor";
1111
import { clearConsole, initializeConsole, consoleEntries } from "./console";
1212
import { runCode, formatCode } from "./runner";
13+
import { undo as cmUndo, redo as cmRedo } from "@codemirror/commands";
1314
import {
1415
resetCode,
1516
loadState,
@@ -369,7 +370,9 @@ export function toggleSearch(mode: "find" | "replace" = "find"): void {
369370
mode === "replace"
370371
? '.cm-search input[name="replace"]'
371372
: '.cm-search input[name="search"]';
372-
const field = document.querySelector(selector) as HTMLInputElement | null;
373+
// Scope lookup to the visible editor's DOM to avoid matching hidden panels
374+
const root = editor.dom as HTMLElement;
375+
const field = root.querySelector(selector) as HTMLInputElement | null;
373376
field?.focus();
374377
field?.select();
375378
});
@@ -392,6 +395,23 @@ globalActions.set({
392395
toggleSearch,
393396
});
394397

398+
// Provide undo/redo commands that operate on the active editor (or specific editor name)
399+
function undoAction(editorName?: string): void {
400+
const name = editorName || activeTabState.get();
401+
const ed = editors[name];
402+
if (ed) cmUndo(ed.view);
403+
}
404+
405+
function redoAction(editorName?: string): void {
406+
const name = editorName || activeTabState.get();
407+
const ed = editors[name];
408+
if (ed) cmRedo(ed.view);
409+
}
410+
411+
// Register undo/redo in global actions
412+
const prevActions = globalActions.get();
413+
globalActions.set({ ...prevActions, undo: undoAction, redo: redoAction });
414+
395415
// Initialize
396416
document.addEventListener("DOMContentLoaded", async () => {
397417
// Cache editor containers before initializing editors
@@ -415,6 +435,11 @@ document.addEventListener("DOMContentLoaded", async () => {
415435
{ html: htmlContainer, css: cssContainer, js: jsContainer },
416436
outputConsoleTabElEarly,
417437
);
438+
439+
// Apply persisted state early so layout (Split) can use saved sizes
440+
loadState();
441+
updateThemeIcon();
442+
418443
// Cache panel elements for Split.js
419444
editorPanelEl = document.getElementById("editor-panel");
420445
outputPanelEl = document.getElementById("output-panel");
@@ -602,9 +627,6 @@ document.addEventListener("DOMContentLoaded", async () => {
602627
if (themeLabelEl) bindText(themeLabelEl, themeLabel);
603628
if (themeIcon) bindClass(themeIcon, themeIconClass);
604629

605-
// Apply persisted state and ensure UI icons match
606-
loadState();
607-
updateThemeIcon();
608630
formatCode().catch((error) => {
609631
showError(`Error formatting code: ${error.message}`);
610632
});

Build/src/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,4 +83,6 @@ export interface Actions {
8383
copyAllConsole: () => void;
8484
copyEditorContent: (editor: string) => void;
8585
toggleSearch: (mode?: "find" | "replace") => void;
86+
undo?: (editor?: string) => void;
87+
redo?: (editor?: string) => void;
8688
}

0 commit comments

Comments
 (0)