Add ESLint, Prettier, and pre-commit hook

This commit is contained in:
Fabricio Giglio
2026-05-06 20:49:09 +02:00
parent 434cefaa65
commit 71b080b420
8 changed files with 131 additions and 44 deletions
+3
View File
@@ -26,6 +26,9 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
- name: Lint
run: pnpm lint --max-warnings 0
- name: Type-check - name: Type-check
run: pnpm --filter frontend exec tsc --noEmit run: pnpm --filter frontend exec tsc --noEmit
+1
View File
@@ -0,0 +1 @@
pnpm lint-staged
+5
View File
@@ -0,0 +1,5 @@
dist/
node_modules/
backend/
pnpm-lock.yaml
*.min.js
+7
View File
@@ -0,0 +1,7 @@
{
"semi": true,
"singleQuote": false,
"tabWidth": 2,
"trailingComma": "es5",
"printWidth": 100
}
+30
View File
@@ -0,0 +1,30 @@
import js from "@eslint/js";
import ts from "typescript-eslint";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import prettierConfig from "eslint-config-prettier";
export default ts.config(
{ ignores: ["**/dist/**", "**/node_modules/**", "backend/**"] },
{
extends: [js.configs.recommended, ...ts.configs.recommended],
files: ["frontend/src/**/*.{ts,tsx}"],
plugins: {
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
// TypeScript handles unused-vars via tsconfig strict flags
"@typescript-eslint/no-unused-vars": "off",
// Allow explicit any in a few cases (PixiJS / AlphaTab interop)
"@typescript-eslint/no-explicit-any": "warn",
// Empty catch blocks are intentional in WebMidi / PixiJS teardown paths
"no-empty": ["error", { allowEmptyCatch: true }],
// Control-character regexes are intentional input sanitization
"no-control-regex": "off",
},
},
prettierConfig
);
@@ -13,20 +13,18 @@ import type { TempoEvent } from "@bach-to-basics/shared";
// malformed XML. AlphaTab processes MusicXML as data, not HTML, so CDATA // malformed XML. AlphaTab processes MusicXML as data, not HTML, so CDATA
// poses no XSS risk here. // poses no XSS risk here.
function sanitizeMusicXml(xml: string): string { function sanitizeMusicXml(xml: string): string {
return xml return xml.replace(/<script[\s\S]*?<\/script>/gi, "").replace(/\bon\w+\s*=/gi, "data-removed=");
.replace(/<script[\s\S]*?<\/script>/gi, "")
.replace(/\bon\w+\s*=/gi, "data-removed=");
} }
export function SheetMusicView() { export function SheetMusicView() {
const scrollRef = useRef<HTMLDivElement>(null); // outer overflow-auto container const scrollRef = useRef<HTMLDivElement>(null); // outer overflow-auto container
const containerRef = useRef<HTMLDivElement>(null); // inner AlphaTab host const containerRef = useRef<HTMLDivElement>(null); // inner AlphaTab host
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const apiRef = useRef<any>(null); const apiRef = useRef<any>(null);
const playerReady = useRef(false); // true once soundFont is loaded const playerReady = useRef(false); // true once soundFont is loaded
const scoreLoaded = useRef(false); // true once score SVG is rendered const scoreLoaded = useRef(false); // true once score SVG is rendered
const lastScrollTop = useRef(-1); const lastScrollTop = useRef(-1);
const lastScrollMs = useRef(0); const lastScrollMs = useRef(0);
// ── AlphaTab init (runs once on mount) ────────────────────────────────── // ── AlphaTab init (runs once on mount) ──────────────────────────────────
useEffect(() => { useEffect(() => {
@@ -36,11 +34,10 @@ export function SheetMusicView() {
import("@coderline/alphatab").then(({ AlphaTabApi }) => { import("@coderline/alphatab").then(({ AlphaTabApi }) => {
if (!containerRef.current) return; if (!containerRef.current) return;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const api = new AlphaTabApi(containerRef.current, { const api = new AlphaTabApi(containerRef.current, {
core: { engine: "svg", enableLazyLoading: false, logLevel: 0 }, core: { engine: "svg", enableLazyLoading: false, logLevel: 0 },
display: { display: {
layoutMode: 0, // page mode - vertical scroll layoutMode: 0, // page mode - vertical scroll
scale: 0.9, scale: 0.9,
// stretchForce 0 = notes keep their natural rhythmic proportions (like // stretchForce 0 = notes keep their natural rhythmic proportions (like
// a printed score). A very small value (0.1) avoids a ragged right // a printed score). A very small value (0.1) avoids a ragged right
@@ -57,7 +54,7 @@ export function SheetMusicView() {
enableCursor: true, enableCursor: true,
enableUserInteraction: true, enableUserInteraction: true,
soundFont: "/soundfont/sonivox.sf3", soundFont: "/soundfont/sonivox.sf3",
scrollMode: 0, // we drive scrolling ourselves scrollMode: 0, // we drive scrolling ourselves
}, },
} as never); } as never);
@@ -69,12 +66,18 @@ export function SheetMusicView() {
playerReady.current = true; playerReady.current = true;
api.masterVolume = 0; api.masterVolume = 0;
}); });
} catch { /* ignore */ } } catch {
/* ignore */
}
// ── Mark score as rendered so cursor/scroll can activate ───────────── // ── Mark score as rendered so cursor/scroll can activate ─────────────
try { try {
api.scoreLoaded.on(() => { scoreLoaded.current = true; }); api.scoreLoaded.on(() => {
} catch { /* ignore */ } scoreLoaded.current = true;
});
} catch {
/* ignore */
}
// ── Load score if MusicXML is already available ────────────────────── // ── Load score if MusicXML is already available ──────────────────────
const initialDoc = useAppStore.getState().document; const initialDoc = useAppStore.getState().document;
@@ -95,7 +98,9 @@ export function SheetMusicView() {
for (const note of beat.notes) syncEngine.stopMidi(note.realValue); for (const note of beat.notes) syncEngine.stopMidi(note.realValue);
}, 500); }, 500);
}); });
} catch { /* ignore */ } } catch {
/* ignore */
}
// ── Transport tick: cursor position + scroll ───────────────────────── // ── Transport tick: cursor position + scroll ─────────────────────────
// IMPORTANT: do NOT close over `doc` here - it would always be null // IMPORTANT: do NOT close over `doc` here - it would always be null
@@ -120,8 +125,8 @@ export function SheetMusicView() {
lastScrollMs.current = now; lastScrollMs.current = now;
requestAnimationFrame(() => { requestAnimationFrame(() => {
const scrollEl = scrollRef.current; // outer scroll container const scrollEl = scrollRef.current; // outer scroll container
const innerEl = containerRef.current; // AlphaTab host (cursor lives here) const innerEl = containerRef.current; // AlphaTab host (cursor lives here)
if (!scrollEl || !innerEl) return; if (!scrollEl || !innerEl) return;
// AlphaTab renders absolutely-positioned cursor overlays inside innerEl // AlphaTab renders absolutely-positioned cursor overlays inside innerEl
const cursor = const cursor =
@@ -150,7 +155,9 @@ export function SheetMusicView() {
playerReady.current = false; playerReady.current = false;
scoreLoaded.current = false; scoreLoaded.current = false;
if (apiRef.current) { if (apiRef.current) {
try { apiRef.current.destroy(); } catch {} try {
apiRef.current.destroy();
} catch {}
apiRef.current = null; apiRef.current = null;
} }
}; };
@@ -158,8 +165,8 @@ export function SheetMusicView() {
// ── Load when MusicXML/mxlBuffer arrives after initial mount ──────────── // ── Load when MusicXML/mxlBuffer arrives after initial mount ────────────
const { document: doc, settings } = useAppStore(); const { document: doc, settings } = useAppStore();
const isDark = settings.theme === "dark"; const isDark = settings.theme === "dark";
const keepWhite = isDark && settings.sheetMusicWhiteBackground; const keepWhite = isDark && settings.sheetMusicWhiteBackground;
const invertSheet = isDark && !settings.sheetMusicWhiteBackground; const invertSheet = isDark && !settings.sheetMusicWhiteBackground;
// ── Adapt stretchForce to layout mode ──────────────────────────────────── // ── Adapt stretchForce to layout mode ────────────────────────────────────
@@ -168,6 +175,7 @@ export function SheetMusicView() {
// near-natural note spacing (0.1) for a printed-score look. // near-natural note spacing (0.1) for a printed-score look.
// The value is stored in a ref so the load effect below can read it fresh. // The value is stored in a ref so the load effect below can read it fresh.
const stretchForceRef = useRef(0.1); const stretchForceRef = useRef(0.1);
// eslint-disable-next-line react-hooks/refs -- intentional: keeps ref in sync with derived state for effect to read fresh
stretchForceRef.current = settings.layoutMode === "all" ? 0.5 : 0.1; stretchForceRef.current = settings.layoutMode === "all" ? 0.5 : 0.1;
useEffect(() => { useEffect(() => {
@@ -179,7 +187,9 @@ export function SheetMusicView() {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
(apiRef.current.settings as any).display.stretchForce = stretchForceRef.current; (apiRef.current.settings as any).display.stretchForce = stretchForceRef.current;
apiRef.current.render(); apiRef.current.render();
} catch { /* ignore */ } } catch {
/* ignore */
}
}, [settings.layoutMode]); }, [settings.layoutMode]);
// ── Load score when MusicXML/mxlBuffer arrives ─────────────────────────── // ── Load score when MusicXML/mxlBuffer arrives ───────────────────────────
@@ -190,7 +200,9 @@ export function SheetMusicView() {
try { try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
(apiRef.current.settings as any).display.stretchForce = stretchForceRef.current; (apiRef.current.settings as any).display.stretchForce = stretchForceRef.current;
} catch { /* ignore */ } } catch {
/* ignore */
}
if (doc?.musicXml) { if (doc?.musicXml) {
scoreLoaded.current = false; scoreLoaded.current = false;
apiRef.current.load(new TextEncoder().encode(sanitizeMusicXml(doc.musicXml))); apiRef.current.load(new TextEncoder().encode(sanitizeMusicXml(doc.musicXml)));
@@ -211,7 +223,15 @@ export function SheetMusicView() {
: "radial-gradient(ellipse at 50% 100%, rgba(147,51,234,0.07) 0%, transparent 65%), var(--color-notes-bg)"; : "radial-gradient(ellipse at 50% 100%, rgba(147,51,234,0.07) 0%, transparent 65%), var(--color-notes-bg)";
return ( return (
<div ref={scrollRef} className="flex-1 overflow-auto" style={{ background: scrollBg, minHeight: 0, borderTop: "1px solid var(--color-notes-border)" }}> <div
ref={scrollRef}
className="flex-1 overflow-auto"
style={{
background: scrollBg,
minHeight: 0,
borderTop: "1px solid var(--color-notes-border)",
}}
>
{/* {/*
Page-width wrapper — centres the score and caps it at ~A4 screen width Page-width wrapper — centres the score and caps it at ~A4 screen width
(900 px). On wide monitors this creates margins on both sides so the (900 px). On wide monitors this creates margins on both sides so the
@@ -220,18 +240,15 @@ export function SheetMusicView() {
key-signature badge stays anchored inside the content column. key-signature badge stays anchored inside the content column.
*/} */}
<div style={{ maxWidth: 900, margin: "0 auto", padding: "8px 16px", position: "relative" }}> <div style={{ maxWidth: 900, margin: "0 auto", padding: "8px 16px", position: "relative" }}>
<div <div ref={containerRef} className={invertSheet ? "sheet-dark-invert" : ""} />
ref={containerRef}
className={invertSheet ? "sheet-dark-invert" : ""}
/>
{settings.showKeySignature && doc?.keySignature && settings.layoutMode !== "all" && ( {settings.showKeySignature && doc?.keySignature && settings.layoutMode !== "all" && (
<div <div
className="absolute top-2 right-4 z-10 pointer-events-none text-xs px-2 py-0.5 rounded" className="absolute top-2 right-4 z-10 pointer-events-none text-xs px-2 py-0.5 rounded"
style={{ style={{
background: keepWhite ? "#f5f7fb" : "var(--color-surface-2)", background: keepWhite ? "#f5f7fb" : "var(--color-surface-2)",
border: `1px solid ${keepWhite ? "#dde1ea" : "var(--color-border)"}`, border: `1px solid ${keepWhite ? "#dde1ea" : "var(--color-border)"}`,
color: keepWhite ? "#64748b" : "var(--color-text-muted)", color: keepWhite ? "#64748b" : "var(--color-text-muted)",
letterSpacing: "0.03em", letterSpacing: "0.03em",
}} }}
> >
@@ -253,30 +270,30 @@ const AT_PPQ = 960;
function secondsToTick(targetSec: number, tempoMap: TempoEvent[], ppq: number): number { function secondsToTick(targetSec: number, tempoMap: TempoEvent[], ppq: number): number {
if (!tempoMap.length) return Math.round(targetSec * 2 * AT_PPQ); if (!tempoMap.length) return Math.round(targetSec * 2 * AT_PPQ);
let atTick = 0; // accumulated output in AlphaTab 960-PPQ ticks let atTick = 0; // accumulated output in AlphaTab 960-PPQ ticks
let remainingSec = targetSec; let remainingSec = targetSec;
for (let i = 0; i < tempoMap.length; i++) { for (let i = 0; i < tempoMap.length; i++) {
const ev = tempoMap[i]; const ev = tempoMap[i];
const next = tempoMap[i + 1]; const next = tempoMap[i + 1];
// seconds per MIDI tick at this tempo // seconds per MIDI tick at this tempo
const secPerMidiTick = 60 / (ev.bpm * ppq); const secPerMidiTick = 60 / (ev.bpm * ppq);
// AlphaTab ticks per MIDI tick (scale factor between file PPQ and AT PPQ) // AlphaTab ticks per MIDI tick (scale factor between file PPQ and AT PPQ)
const atPerMidi = AT_PPQ / ppq; const atPerMidi = AT_PPQ / ppq;
if (next) { if (next) {
const midiSpan = next.tick - ev.tick; // segment length in MIDI ticks const midiSpan = next.tick - ev.tick; // segment length in MIDI ticks
const secSpan = midiSpan * secPerMidiTick; const secSpan = midiSpan * secPerMidiTick;
if (remainingSec <= secSpan) { if (remainingSec <= secSpan) {
atTick += Math.round(remainingSec / secPerMidiTick * atPerMidi); atTick += Math.round((remainingSec / secPerMidiTick) * atPerMidi);
return atTick; return atTick;
} }
remainingSec -= secSpan; remainingSec -= secSpan;
atTick += Math.round(midiSpan * atPerMidi); atTick += Math.round(midiSpan * atPerMidi);
} else { } else {
// Last segment - extends to end of piece // Last segment - extends to end of piece
atTick += Math.round(remainingSec / secPerMidiTick * atPerMidi); atTick += Math.round((remainingSec / secPerMidiTick) * atPerMidi);
return atTick; return atTick;
} }
} }
+4 -3
View File
@@ -4,9 +4,9 @@ import { accuracyTracker } from "../engine/AccuracyTracker";
export interface AccuracyState { export interface AccuracyState {
correct: number; correct: number;
wrong: number; wrong: number;
missed: number; missed: number;
score: number; // 0-100 score: number; // 0-100
} }
const INITIAL: AccuracyState = { correct: 0, wrong: 0, missed: 0, score: 0 }; const INITIAL: AccuracyState = { correct: 0, wrong: 0, missed: 0, score: 0 };
@@ -21,6 +21,7 @@ export function useAccuracy(enabled: boolean): AccuracyState {
useEffect(() => { useEffect(() => {
accuracyTracker.enable(enabled); accuracyTracker.enable(enabled);
if (!enabled) { if (!enabled) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: synchronously reset accuracy when disabled
setState(INITIAL); setState(INITIAL);
return; return;
} }
+25 -2
View File
@@ -13,14 +13,37 @@
"bugs": { "bugs": {
"url": "https://github.com/gigliof/bach-to-basics/issues" "url": "https://github.com/gigliof/bach-to-basics/issues"
}, },
"type": "module",
"scripts": { "scripts": {
"dev": "concurrently --names \"frontend,backend\" --prefix-colors \"cyan,yellow\" \"pnpm --filter frontend dev\" \"cd backend && .venv/bin/uvicorn main:app --reload --port 8000\"", "dev": "concurrently --names \"frontend,backend\" --prefix-colors \"cyan,yellow\" \"pnpm --filter frontend dev\" \"cd backend && .venv/bin/uvicorn main:app --reload --port 8000\"",
"build": "pnpm --filter frontend build", "build": "pnpm --filter frontend build",
"test": "pnpm --filter frontend test", "test": "pnpm --filter frontend test",
"backend:setup": "cd backend && python3 -m venv .venv && .venv/bin/pip install -r requirements.txt" "backend:setup": "cd backend && python3 -m venv .venv && .venv/bin/pip install -r requirements.txt",
"lint": "eslint frontend/src",
"lint:fix": "eslint frontend/src --fix",
"format": "prettier --write .",
"prepare": "husky"
},
"lint-staged": {
"frontend/src/**/*.{ts,tsx}": [
"eslint --fix --max-warnings 0",
"prettier --write"
],
"**/*.{json,md,yml,yaml,css}": [
"prettier --write"
]
}, },
"devDependencies": { "devDependencies": {
"concurrently": "^9.0.0" "@eslint/js": "^10.0.1",
"concurrently": "^9.0.0",
"eslint": "^10.3.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"husky": "^9.1.7",
"lint-staged": "^17.0.2",
"prettier": "^3.8.3",
"typescript-eslint": "^8.59.2"
}, },
"pnpm": { "pnpm": {
"onlyBuiltDependencies": [ "onlyBuiltDependencies": [