Add ESLint, Prettier, and pre-commit hook
This commit is contained in:
@@ -26,6 +26,9 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Lint
|
||||
run: pnpm lint --max-warnings 0
|
||||
|
||||
- name: Type-check
|
||||
run: pnpm --filter frontend exec tsc --noEmit
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
pnpm lint-staged
|
||||
@@ -0,0 +1,5 @@
|
||||
dist/
|
||||
node_modules/
|
||||
backend/
|
||||
pnpm-lock.yaml
|
||||
*.min.js
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "es5",
|
||||
"printWidth": 100
|
||||
}
|
||||
@@ -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,9 +13,7 @@ import type { TempoEvent } from "@bach-to-basics/shared";
|
||||
// malformed XML. AlphaTab processes MusicXML as data, not HTML, so CDATA
|
||||
// poses no XSS risk here.
|
||||
function sanitizeMusicXml(xml: string): string {
|
||||
return xml
|
||||
.replace(/<script[\s\S]*?<\/script>/gi, "")
|
||||
.replace(/\bon\w+\s*=/gi, "data-removed=");
|
||||
return xml.replace(/<script[\s\S]*?<\/script>/gi, "").replace(/\bon\w+\s*=/gi, "data-removed=");
|
||||
}
|
||||
|
||||
export function SheetMusicView() {
|
||||
@@ -36,7 +34,6 @@ export function SheetMusicView() {
|
||||
import("@coderline/alphatab").then(({ AlphaTabApi }) => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const api = new AlphaTabApi(containerRef.current, {
|
||||
core: { engine: "svg", enableLazyLoading: false, logLevel: 0 },
|
||||
display: {
|
||||
@@ -69,12 +66,18 @@ export function SheetMusicView() {
|
||||
playerReady.current = true;
|
||||
api.masterVolume = 0;
|
||||
});
|
||||
} catch { /* ignore */ }
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
// ── Mark score as rendered so cursor/scroll can activate ─────────────
|
||||
try {
|
||||
api.scoreLoaded.on(() => { scoreLoaded.current = true; });
|
||||
} catch { /* ignore */ }
|
||||
api.scoreLoaded.on(() => {
|
||||
scoreLoaded.current = true;
|
||||
});
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
// ── Load score if MusicXML is already available ──────────────────────
|
||||
const initialDoc = useAppStore.getState().document;
|
||||
@@ -95,7 +98,9 @@ export function SheetMusicView() {
|
||||
for (const note of beat.notes) syncEngine.stopMidi(note.realValue);
|
||||
}, 500);
|
||||
});
|
||||
} catch { /* ignore */ }
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
// ── Transport tick: cursor position + scroll ─────────────────────────
|
||||
// IMPORTANT: do NOT close over `doc` here - it would always be null
|
||||
@@ -150,7 +155,9 @@ export function SheetMusicView() {
|
||||
playerReady.current = false;
|
||||
scoreLoaded.current = false;
|
||||
if (apiRef.current) {
|
||||
try { apiRef.current.destroy(); } catch {}
|
||||
try {
|
||||
apiRef.current.destroy();
|
||||
} catch {}
|
||||
apiRef.current = null;
|
||||
}
|
||||
};
|
||||
@@ -168,6 +175,7 @@ export function SheetMusicView() {
|
||||
// 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.
|
||||
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;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -179,7 +187,9 @@ export function SheetMusicView() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(apiRef.current.settings as any).display.stretchForce = stretchForceRef.current;
|
||||
apiRef.current.render();
|
||||
} catch { /* ignore */ }
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, [settings.layoutMode]);
|
||||
|
||||
// ── Load score when MusicXML/mxlBuffer arrives ───────────────────────────
|
||||
@@ -190,7 +200,9 @@ export function SheetMusicView() {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(apiRef.current.settings as any).display.stretchForce = stretchForceRef.current;
|
||||
} catch { /* ignore */ }
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
if (doc?.musicXml) {
|
||||
scoreLoaded.current = false;
|
||||
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)";
|
||||
|
||||
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
|
||||
(900 px). On wide monitors this creates margins on both sides so the
|
||||
@@ -220,10 +240,7 @@ export function SheetMusicView() {
|
||||
key-signature badge stays anchored inside the content column.
|
||||
*/}
|
||||
<div style={{ maxWidth: 900, margin: "0 auto", padding: "8px 16px", position: "relative" }}>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={invertSheet ? "sheet-dark-invert" : ""}
|
||||
/>
|
||||
<div ref={containerRef} className={invertSheet ? "sheet-dark-invert" : ""} />
|
||||
|
||||
{settings.showKeySignature && doc?.keySignature && settings.layoutMode !== "all" && (
|
||||
<div
|
||||
@@ -269,14 +286,14 @@ function secondsToTick(targetSec: number, tempoMap: TempoEvent[], ppq: number):
|
||||
const midiSpan = next.tick - ev.tick; // segment length in MIDI ticks
|
||||
const secSpan = midiSpan * secPerMidiTick;
|
||||
if (remainingSec <= secSpan) {
|
||||
atTick += Math.round(remainingSec / secPerMidiTick * atPerMidi);
|
||||
atTick += Math.round((remainingSec / secPerMidiTick) * atPerMidi);
|
||||
return atTick;
|
||||
}
|
||||
remainingSec -= secSpan;
|
||||
atTick += Math.round(midiSpan * atPerMidi);
|
||||
} else {
|
||||
// Last segment - extends to end of piece
|
||||
atTick += Math.round(remainingSec / secPerMidiTick * atPerMidi);
|
||||
atTick += Math.round((remainingSec / secPerMidiTick) * atPerMidi);
|
||||
return atTick;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ export function useAccuracy(enabled: boolean): AccuracyState {
|
||||
useEffect(() => {
|
||||
accuracyTracker.enable(enabled);
|
||||
if (!enabled) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: synchronously reset accuracy when disabled
|
||||
setState(INITIAL);
|
||||
return;
|
||||
}
|
||||
|
||||
+25
-2
@@ -13,14 +13,37 @@
|
||||
"bugs": {
|
||||
"url": "https://github.com/gigliof/bach-to-basics/issues"
|
||||
},
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"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",
|
||||
"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": {
|
||||
"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": {
|
||||
"onlyBuiltDependencies": [
|
||||
|
||||
Reference in New Issue
Block a user