From 7bc6e942154099cb0edf342430622552936a416e Mon Sep 17 00:00:00 2001 From: Lawrence Lin Date: Wed, 22 Jul 2026 06:53:05 +0800 Subject: [PATCH 1/6] Upgrade TypeScript to 7.0 TypeScript 7.0 ships the native Go compiler as the standard tsc, cutting tsc --noEmit on the extension from ~4.7s to ~1.4s. Two adjustments the new compiler required: - TS7's lib.dom types Element.matches("") as a `this is` guard. For tags that map to HTMLElement itself (section, dt, figcaption) the false branch collapses HTMLElement to `never`, breaking every check after it. A matchesSelector() helper routes the selector through a plain string parameter to keep matches a boolean test. - Side-effect CSS imports now fail with TS2882; a vite-env.d.ts with vite/client types covers them. The website keeps its own TypeScript 5.x: astro check depends on the stable compiler API that only lands in TS 7.1. --- apps/extension/src/content/blockCandidates.ts | 3 +- apps/extension/src/content/dom.ts | 7 + .../src/content/extraction/shared.ts | 3 +- apps/extension/src/content/siteAdapters/x.ts | 3 +- apps/extension/src/vite-env.d.ts | 1 + package.json | 2 +- pnpm-lock.yaml | 284 +++++++++++++++--- 7 files changed, 261 insertions(+), 42 deletions(-) create mode 100644 apps/extension/src/content/dom.ts create mode 100644 apps/extension/src/vite-env.d.ts diff --git a/apps/extension/src/content/blockCandidates.ts b/apps/extension/src/content/blockCandidates.ts index 4bd678b..8e6f9bc 100644 --- a/apps/extension/src/content/blockCandidates.ts +++ b/apps/extension/src/content/blockCandidates.ts @@ -1,4 +1,5 @@ import { normalizeText } from "../shared/text"; +import { matchesSelector } from "./dom"; import { isAccessibilityOnlyElement, isPageChromeElement } from "./readingVisibility"; export type BlockCandidateSource = "semantic" | "archetype" | "adapter" | "legacy"; @@ -97,7 +98,7 @@ export function inferBlockRole(element: HTMLElement): BlockCandidateRole { return "definition"; } - if (element.matches("figcaption")) { + if (matchesSelector(element, "figcaption")) { return "caption"; } diff --git a/apps/extension/src/content/dom.ts b/apps/extension/src/content/dom.ts new file mode 100644 index 0000000..90ff3c3 --- /dev/null +++ b/apps/extension/src/content/dom.ts @@ -0,0 +1,7 @@ +// TypeScript 7's lib.dom types `Element.matches("")` as a `this is` guard. +// For tags that map to HTMLElement itself (e.g. "section", "dt", "figcaption"), +// the false branch collapses HTMLElement to `never`. Routing the selector through +// a `string` parameter keeps `matches` a plain boolean test. +export function matchesSelector(element: Element, selector: string): boolean { + return element.matches(selector); +} diff --git a/apps/extension/src/content/extraction/shared.ts b/apps/extension/src/content/extraction/shared.ts index df788c4..2b2911e 100644 --- a/apps/extension/src/content/extraction/shared.ts +++ b/apps/extension/src/content/extraction/shared.ts @@ -11,6 +11,7 @@ import { type BlockCandidate, type BlockCandidateSource } from "../blockCandidates"; +import { matchesSelector } from "../dom"; import { isNonReadingElement, isVisibleForReading } from "../readingVisibility"; import type { TextBlockOptions } from "./types"; @@ -364,7 +365,7 @@ function getStructuralMinimumTextLength(element: HTMLElement, text: string, opti if (element.matches("td")) { return 4; } - if (element.matches("dt")) { + if (matchesSelector(element, "dt")) { return 2; } if (element.matches("li, dd")) { diff --git a/apps/extension/src/content/siteAdapters/x.ts b/apps/extension/src/content/siteAdapters/x.ts index 8a82315..a46a7c4 100644 --- a/apps/extension/src/content/siteAdapters/x.ts +++ b/apps/extension/src/content/siteAdapters/x.ts @@ -1,4 +1,5 @@ import { countCodePoints } from "../../shared/text"; +import { matchesSelector } from "../dom"; import type { TextBlockOptions } from "../textBlocks"; import { getBaseMinimumTextLength, @@ -90,7 +91,7 @@ function getTranslatableTweetTextBlocks( } function isArticleTextBlock(element: HTMLElement, options: TextBlockOptions): boolean { - if (element.matches("section") || element.closest("section[contenteditable='false']")) { + if (matchesSelector(element, "section") || element.closest("section[contenteditable='false']")) { return false; } diff --git a/apps/extension/src/vite-env.d.ts b/apps/extension/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/apps/extension/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/package.json b/package.json index 793c08f..db2ea8b 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "eslint": "^10.3.0", "globals": "^17.6.0", "happy-dom": "^20.9.0", - "typescript": "^5.5.4", + "typescript": "^7.0.2", "typescript-eslint": "^8.59.2", "vitest": "^4.1.5" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f9465af..62b9011 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,11 +27,11 @@ importers: specifier: ^20.9.0 version: 20.9.0 typescript: - specifier: ^5.5.4 - version: 5.9.3 + specifier: ^7.0.2 + version: 7.0.2 typescript-eslint: specifier: ^8.59.2 - version: 8.59.2(eslint@10.3.0)(typescript@5.9.3) + version: 8.59.2(eslint@10.3.0)(typescript@7.0.2) vitest: specifier: ^4.1.5 version: 4.1.5(@types/node@25.6.2)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(vite@8.0.16(@types/node@25.6.2)(esbuild@0.27.7)(yaml@2.8.4)) @@ -1473,6 +1473,126 @@ packages: resolution: {integrity: sha512-NwjLUnGy8/Zfx23fl50tRC8rYaYnM52xNRYFAXvmiil9yh1+K6aRVQMnzW6gQB/1DLgWt977lYQn7C+wtgXZiA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript/typescript-aix-ppc64@7.0.2': + resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [aix] + + '@typescript/typescript-darwin-arm64@7.0.2': + resolution: {integrity: sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [darwin] + + '@typescript/typescript-darwin-x64@7.0.2': + resolution: {integrity: sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [darwin] + + '@typescript/typescript-freebsd-arm64@7.0.2': + resolution: {integrity: sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [freebsd] + + '@typescript/typescript-freebsd-x64@7.0.2': + resolution: {integrity: sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [freebsd] + + '@typescript/typescript-linux-arm64@7.0.2': + resolution: {integrity: sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [linux] + + '@typescript/typescript-linux-arm@7.0.2': + resolution: {integrity: sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==} + engines: {node: '>=16.20.0'} + cpu: [arm] + os: [linux] + + '@typescript/typescript-linux-loong64@7.0.2': + resolution: {integrity: sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==} + engines: {node: '>=16.20.0'} + cpu: [loong64] + os: [linux] + + '@typescript/typescript-linux-mips64el@7.0.2': + resolution: {integrity: sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==} + engines: {node: '>=16.20.0'} + cpu: [mips64el] + os: [linux] + + '@typescript/typescript-linux-ppc64@7.0.2': + resolution: {integrity: sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [linux] + + '@typescript/typescript-linux-riscv64@7.0.2': + resolution: {integrity: sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==} + engines: {node: '>=16.20.0'} + cpu: [riscv64] + os: [linux] + + '@typescript/typescript-linux-s390x@7.0.2': + resolution: {integrity: sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==} + engines: {node: '>=16.20.0'} + cpu: [s390x] + os: [linux] + + '@typescript/typescript-linux-x64@7.0.2': + resolution: {integrity: sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [linux] + + '@typescript/typescript-netbsd-arm64@7.0.2': + resolution: {integrity: sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [netbsd] + + '@typescript/typescript-netbsd-x64@7.0.2': + resolution: {integrity: sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [netbsd] + + '@typescript/typescript-openbsd-arm64@7.0.2': + resolution: {integrity: sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [openbsd] + + '@typescript/typescript-openbsd-x64@7.0.2': + resolution: {integrity: sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [openbsd] + + '@typescript/typescript-sunos-x64@7.0.2': + resolution: {integrity: sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [sunos] + + '@typescript/typescript-win32-arm64@7.0.2': + resolution: {integrity: sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [win32] + + '@typescript/typescript-win32-x64@7.0.2': + resolution: {integrity: sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [win32] + '@ungap/structured-clone@1.3.1': resolution: {integrity: sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==} @@ -3026,6 +3146,11 @@ packages: engines: {node: '>=14.17'} hasBin: true + typescript@7.0.2: + resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} + engines: {node: '>=16.20.0'} + hasBin: true + ufo@1.6.4: resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} @@ -4508,40 +4633,40 @@ snapshots: dependencies: '@types/node': 25.6.2 - '@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@10.3.0)(typescript@5.9.3))(eslint@10.3.0)(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@10.3.0)(typescript@7.0.2))(eslint@10.3.0)(typescript@7.0.2)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.59.2(eslint@10.3.0)(typescript@5.9.3) + '@typescript-eslint/parser': 8.59.2(eslint@10.3.0)(typescript@7.0.2) '@typescript-eslint/scope-manager': 8.59.2 - '@typescript-eslint/type-utils': 8.59.2(eslint@10.3.0)(typescript@5.9.3) - '@typescript-eslint/utils': 8.59.2(eslint@10.3.0)(typescript@5.9.3) + '@typescript-eslint/type-utils': 8.59.2(eslint@10.3.0)(typescript@7.0.2) + '@typescript-eslint/utils': 8.59.2(eslint@10.3.0)(typescript@7.0.2) '@typescript-eslint/visitor-keys': 8.59.2 eslint: 10.3.0 ignore: 7.0.5 natural-compare: 1.4.0 - ts-api-utils: 2.5.0(typescript@5.9.3) - typescript: 5.9.3 + ts-api-utils: 2.5.0(typescript@7.0.2) + typescript: 7.0.2 transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.59.2(eslint@10.3.0)(typescript@5.9.3)': + '@typescript-eslint/parser@8.59.2(eslint@10.3.0)(typescript@7.0.2)': dependencies: '@typescript-eslint/scope-manager': 8.59.2 '@typescript-eslint/types': 8.59.2 - '@typescript-eslint/typescript-estree': 8.59.2(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.59.2(typescript@7.0.2) '@typescript-eslint/visitor-keys': 8.59.2 debug: 4.4.3 eslint: 10.3.0 - typescript: 5.9.3 + typescript: 7.0.2 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.59.2(typescript@5.9.3)': + '@typescript-eslint/project-service@8.59.2(typescript@7.0.2)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.59.2(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.59.2(typescript@7.0.2) '@typescript-eslint/types': 8.59.2 debug: 4.4.3 - typescript: 5.9.3 + typescript: 7.0.2 transitivePeerDependencies: - supports-color @@ -4550,47 +4675,47 @@ snapshots: '@typescript-eslint/types': 8.59.2 '@typescript-eslint/visitor-keys': 8.59.2 - '@typescript-eslint/tsconfig-utils@8.59.2(typescript@5.9.3)': + '@typescript-eslint/tsconfig-utils@8.59.2(typescript@7.0.2)': dependencies: - typescript: 5.9.3 + typescript: 7.0.2 - '@typescript-eslint/type-utils@8.59.2(eslint@10.3.0)(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.59.2(eslint@10.3.0)(typescript@7.0.2)': dependencies: '@typescript-eslint/types': 8.59.2 - '@typescript-eslint/typescript-estree': 8.59.2(typescript@5.9.3) - '@typescript-eslint/utils': 8.59.2(eslint@10.3.0)(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.59.2(typescript@7.0.2) + '@typescript-eslint/utils': 8.59.2(eslint@10.3.0)(typescript@7.0.2) debug: 4.4.3 eslint: 10.3.0 - ts-api-utils: 2.5.0(typescript@5.9.3) - typescript: 5.9.3 + ts-api-utils: 2.5.0(typescript@7.0.2) + typescript: 7.0.2 transitivePeerDependencies: - supports-color '@typescript-eslint/types@8.59.2': {} - '@typescript-eslint/typescript-estree@8.59.2(typescript@5.9.3)': + '@typescript-eslint/typescript-estree@8.59.2(typescript@7.0.2)': dependencies: - '@typescript-eslint/project-service': 8.59.2(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.59.2(typescript@5.9.3) + '@typescript-eslint/project-service': 8.59.2(typescript@7.0.2) + '@typescript-eslint/tsconfig-utils': 8.59.2(typescript@7.0.2) '@typescript-eslint/types': 8.59.2 '@typescript-eslint/visitor-keys': 8.59.2 debug: 4.4.3 minimatch: 10.2.5 semver: 7.8.0 tinyglobby: 0.2.16 - ts-api-utils: 2.5.0(typescript@5.9.3) - typescript: 5.9.3 + ts-api-utils: 2.5.0(typescript@7.0.2) + typescript: 7.0.2 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.59.2(eslint@10.3.0)(typescript@5.9.3)': + '@typescript-eslint/utils@8.59.2(eslint@10.3.0)(typescript@7.0.2)': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@10.3.0) '@typescript-eslint/scope-manager': 8.59.2 '@typescript-eslint/types': 8.59.2 - '@typescript-eslint/typescript-estree': 8.59.2(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.59.2(typescript@7.0.2) eslint: 10.3.0 - typescript: 5.9.3 + typescript: 7.0.2 transitivePeerDependencies: - supports-color @@ -4599,6 +4724,66 @@ snapshots: '@typescript-eslint/types': 8.59.2 eslint-visitor-keys: 5.0.1 + '@typescript/typescript-aix-ppc64@7.0.2': + optional: true + + '@typescript/typescript-darwin-arm64@7.0.2': + optional: true + + '@typescript/typescript-darwin-x64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-x64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm@7.0.2': + optional: true + + '@typescript/typescript-linux-loong64@7.0.2': + optional: true + + '@typescript/typescript-linux-mips64el@7.0.2': + optional: true + + '@typescript/typescript-linux-ppc64@7.0.2': + optional: true + + '@typescript/typescript-linux-riscv64@7.0.2': + optional: true + + '@typescript/typescript-linux-s390x@7.0.2': + optional: true + + '@typescript/typescript-linux-x64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-sunos-x64@7.0.2': + optional: true + + '@typescript/typescript-win32-arm64@7.0.2': + optional: true + + '@typescript/typescript-win32-x64@7.0.2': + optional: true + '@ungap/structured-clone@1.3.1': {} '@vitest/coverage-v8@4.1.5(vitest@4.1.5)': @@ -6579,9 +6764,9 @@ snapshots: ts-algebra@2.0.0: {} - ts-api-utils@2.5.0(typescript@5.9.3): + ts-api-utils@2.5.0(typescript@7.0.2): dependencies: - typescript: 5.9.3 + typescript: 7.0.2 tsconfck@3.1.6(typescript@5.9.3): optionalDependencies: @@ -6601,19 +6786,42 @@ snapshots: dependencies: semver: 7.8.0 - typescript-eslint@8.59.2(eslint@10.3.0)(typescript@5.9.3): + typescript-eslint@8.59.2(eslint@10.3.0)(typescript@7.0.2): dependencies: - '@typescript-eslint/eslint-plugin': 8.59.2(@typescript-eslint/parser@8.59.2(eslint@10.3.0)(typescript@5.9.3))(eslint@10.3.0)(typescript@5.9.3) - '@typescript-eslint/parser': 8.59.2(eslint@10.3.0)(typescript@5.9.3) - '@typescript-eslint/typescript-estree': 8.59.2(typescript@5.9.3) - '@typescript-eslint/utils': 8.59.2(eslint@10.3.0)(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.59.2(@typescript-eslint/parser@8.59.2(eslint@10.3.0)(typescript@7.0.2))(eslint@10.3.0)(typescript@7.0.2) + '@typescript-eslint/parser': 8.59.2(eslint@10.3.0)(typescript@7.0.2) + '@typescript-eslint/typescript-estree': 8.59.2(typescript@7.0.2) + '@typescript-eslint/utils': 8.59.2(eslint@10.3.0)(typescript@7.0.2) eslint: 10.3.0 - typescript: 5.9.3 + typescript: 7.0.2 transitivePeerDependencies: - supports-color typescript@5.9.3: {} + typescript@7.0.2: + optionalDependencies: + '@typescript/typescript-aix-ppc64': 7.0.2 + '@typescript/typescript-darwin-arm64': 7.0.2 + '@typescript/typescript-darwin-x64': 7.0.2 + '@typescript/typescript-freebsd-arm64': 7.0.2 + '@typescript/typescript-freebsd-x64': 7.0.2 + '@typescript/typescript-linux-arm': 7.0.2 + '@typescript/typescript-linux-arm64': 7.0.2 + '@typescript/typescript-linux-loong64': 7.0.2 + '@typescript/typescript-linux-mips64el': 7.0.2 + '@typescript/typescript-linux-ppc64': 7.0.2 + '@typescript/typescript-linux-riscv64': 7.0.2 + '@typescript/typescript-linux-s390x': 7.0.2 + '@typescript/typescript-linux-x64': 7.0.2 + '@typescript/typescript-netbsd-arm64': 7.0.2 + '@typescript/typescript-netbsd-x64': 7.0.2 + '@typescript/typescript-openbsd-arm64': 7.0.2 + '@typescript/typescript-openbsd-x64': 7.0.2 + '@typescript/typescript-sunos-x64': 7.0.2 + '@typescript/typescript-win32-arm64': 7.0.2 + '@typescript/typescript-win32-x64': 7.0.2 + ufo@1.6.4: {} ultrahtml@1.6.0: {} From d4a5bd3acd2f742a93e3cc4f45005a30f6ed426a Mon Sep 17 00:00:00 2001 From: Lawrence Lin Date: Wed, 22 Jul 2026 06:53:33 +0800 Subject: [PATCH 2/6] Replace ESLint with oxlint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit typescript-eslint does not support TypeScript 7, so the previous lint setup cannot run against the new compiler. oxlint's type-aware mode (via oxlint-tsgolint, built on typescript-go) covers the same rules — consistent-type-imports, no-floating-promises, no-misused-promises, no-unnecessary-type-assertion and the no-unsafe-* family — and runs the whole lint in about a second instead of nine. The .oxlintrc.json mirrors the old eslint.config.mjs: correctness category as errors, the same type-aware rules opted in, node overrides for config/scripts .mjs files, and type-aware rules kept off for them. Small code fixes for findings the new linter surfaced: - drop meaningless `void` on the synchronous #drain() calls - drop an unnecessary `?? {}` fallback in an object spread - count code points via Array.from instead of string spread - remove unsafe optional chaining before property access in a test --- apps/extension/.oxlintrc.json | 51 + apps/extension/eslint.config.mjs | 60 -- apps/extension/package.json | 2 +- .../siteAdapters/youtube/controls.test.ts | 4 +- .../extension/src/content/translationQueue.ts | 4 +- apps/extension/src/shared/migrations/index.ts | 2 +- apps/extension/src/shared/text.ts | 2 +- package.json | 6 +- pnpm-lock.yaml | 971 +++++------------- 9 files changed, 332 insertions(+), 770 deletions(-) create mode 100644 apps/extension/.oxlintrc.json delete mode 100644 apps/extension/eslint.config.mjs diff --git a/apps/extension/.oxlintrc.json b/apps/extension/.oxlintrc.json new file mode 100644 index 0000000..15c7e17 --- /dev/null +++ b/apps/extension/.oxlintrc.json @@ -0,0 +1,51 @@ +{ + "$schema": "../../node_modules/oxlint/configuration_schema.json", + "options": { + "typeAware": true + }, + "env": { + "browser": true, + "webextensions": true + }, + "categories": { + "correctness": "error" + }, + "ignorePatterns": ["coverage/**", "dist/**", "node_modules/**", "test/fixtures/**"], + "rules": { + "typescript/consistent-type-imports": ["error", { "prefer": "type-imports" }], + "typescript/no-floating-promises": "error", + "typescript/no-misused-promises": "error", + "typescript/no-unnecessary-type-assertion": "error", + "typescript/no-unsafe-assignment": "error", + "typescript/no-unsafe-member-access": "error", + "typescript/no-unsafe-return": "error" + }, + "overrides": [ + { + "files": ["*.config.mjs", "scripts/**/*.mjs"], + "env": { + "node": true + }, + "rules": { + "typescript/await-thenable": "off", + "typescript/no-floating-promises": "off", + "typescript/no-misused-promises": "off", + "typescript/no-unnecessary-type-assertion": "off", + "typescript/require-await": "off", + "typescript/require-array-sort-compare": "off", + "typescript/no-unsafe-assignment": "off", + "typescript/no-unsafe-member-access": "off", + "typescript/no-unsafe-return": "off" + } + }, + { + "files": ["src/**/*.test.ts", "test/**/*.ts"], + "env": { + "node": true + }, + "rules": { + "typescript/no-misused-spread": "off" + } + } + ] +} diff --git a/apps/extension/eslint.config.mjs b/apps/extension/eslint.config.mjs deleted file mode 100644 index dc02a01..0000000 --- a/apps/extension/eslint.config.mjs +++ /dev/null @@ -1,60 +0,0 @@ -import js from "@eslint/js"; -import globals from "globals"; -import tseslint from "typescript-eslint"; - -export default tseslint.config( - { - ignores: ["coverage/**", "dist/**", "node_modules/**"] - }, - js.configs.recommended, - ...tseslint.configs.recommendedTypeChecked, - { - languageOptions: { - ecmaVersion: 2022, - sourceType: "module", - parserOptions: { - projectService: true, - tsconfigRootDir: import.meta.dirname - }, - globals: { - ...globals.browser, - ...globals.webextensions - } - }, - rules: { - "@typescript-eslint/consistent-type-imports": ["error", { prefer: "type-imports" }], - "@typescript-eslint/no-floating-promises": "error", - "@typescript-eslint/no-misused-promises": "error", - "@typescript-eslint/no-unnecessary-type-assertion": "error", - "@typescript-eslint/no-unsafe-assignment": "error", - "@typescript-eslint/no-unsafe-member-access": "error", - "@typescript-eslint/no-unsafe-return": "error" - } - }, - { - files: ["*.config.mjs", "scripts/**/*.mjs"], - extends: [js.configs.recommended], - languageOptions: { - parser: undefined, - parserOptions: { - projectService: false - }, - globals: globals.node - }, - rules: { - "@typescript-eslint/await-thenable": "off", - "@typescript-eslint/no-floating-promises": "off", - "@typescript-eslint/no-misused-promises": "off", - "@typescript-eslint/no-unnecessary-type-assertion": "off", - "@typescript-eslint/require-await": "off", - "@typescript-eslint/no-unsafe-assignment": "off", - "@typescript-eslint/no-unsafe-member-access": "off" - } - }, - { - files: ["src/**/*.test.ts"], - languageOptions: { - globals: globals.node - } - } -); diff --git a/apps/extension/package.json b/apps/extension/package.json index 55fe688..3617fd1 100644 --- a/apps/extension/package.json +++ b/apps/extension/package.json @@ -12,7 +12,7 @@ "check:release-readiness": "node scripts/check-release-readiness.mjs", "check:release-version": "node scripts/check-release-version.mjs", "clean": "node scripts/clean.mjs", - "lint": "eslint src vitest.config.ts vite.config.ts", + "lint": "oxlint", "fixture:capture": "node scripts/capture-fixture.mjs", "package": "pnpm build && node scripts/package-extension.mjs", "test": "vitest run --coverage", diff --git a/apps/extension/src/content/siteAdapters/youtube/controls.test.ts b/apps/extension/src/content/siteAdapters/youtube/controls.test.ts index 7423a7d..9ee591d 100644 --- a/apps/extension/src/content/siteAdapters/youtube/controls.test.ts +++ b/apps/extension/src/content/siteAdapters/youtube/controls.test.ts @@ -758,8 +758,8 @@ describe("initializeYouTubeControls", () => { ([message]) => (message as { type?: string } | undefined)?.type === "TRANSLATE_BATCH" ).slice(callsBeforeTrack); expect(captionCalls).toHaveLength(2); - expect((captionCalls[0]?.[0] as { segments: unknown[] }).segments).toHaveLength(32); - expect((captionCalls[1]?.[0] as { segments: unknown[] }).segments).toHaveLength(1); + expect((captionCalls[0][0] as { segments: unknown[] }).segments).toHaveLength(32); + expect((captionCalls[1][0] as { segments: unknown[] }).segments).toHaveLength(1); }); it("hides the caption overlay when captions disappear", async () => { diff --git a/apps/extension/src/content/translationQueue.ts b/apps/extension/src/content/translationQueue.ts index 5fda652..1308fb1 100644 --- a/apps/extension/src/content/translationQueue.ts +++ b/apps/extension/src/content/translationQueue.ts @@ -36,7 +36,7 @@ export class TranslationQueue { this.#pending.set(item.id, item); } } - void this.#drain(); + this.#drain(); } clear(): void { @@ -51,7 +51,7 @@ export class TranslationQueue { if (options.concurrency !== undefined) { this.#concurrency = options.concurrency; } - void this.#drain(); + this.#drain(); } get size(): number { diff --git a/apps/extension/src/shared/migrations/index.ts b/apps/extension/src/shared/migrations/index.ts index 67f7fe9..d8a5a2e 100644 --- a/apps/extension/src/shared/migrations/index.ts +++ b/apps/extension/src/shared/migrations/index.ts @@ -104,7 +104,7 @@ function parseV1(stored: unknown): ExtensionSettingsCurrent { siteAdapters: { x: { ...defaults.siteAdapters.x, - ...(source.siteAdapters?.x ?? {}) + ...source.siteAdapters?.x } }, version: SETTINGS_VERSION diff --git a/apps/extension/src/shared/text.ts b/apps/extension/src/shared/text.ts index fb0c984..884d4f6 100644 --- a/apps/extension/src/shared/text.ts +++ b/apps/extension/src/shared/text.ts @@ -26,7 +26,7 @@ const LETTER_PATTERN = /\p{L}/u; // Code-point count rather than UTF-16 code-unit count, so supplementary-plane characters // (rare ideographs, emoji) count as one rather than two. export function countCodePoints(value: string): number { - return [...value].length; + return Array.from(value).length; } // Fraction of letters that are CJK. The denominator counts only letters (not punctuation, diff --git a/package.json b/package.json index db2ea8b..2f20279 100644 --- a/package.json +++ b/package.json @@ -22,14 +22,12 @@ "version:set": "node apps/extension/scripts/set-version.mjs" }, "devDependencies": { - "@eslint/js": "^10.0.1", "@types/chrome": "^0.0.268", "@vitest/coverage-v8": "^4.1.5", - "eslint": "^10.3.0", - "globals": "^17.6.0", "happy-dom": "^20.9.0", + "oxlint": "^1.74.0", + "oxlint-tsgolint": "^7.0.2001", "typescript": "^7.0.2", - "typescript-eslint": "^8.59.2", "vitest": "^4.1.5" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 62b9011..290b53b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,30 +8,24 @@ importers: .: devDependencies: - '@eslint/js': - specifier: ^10.0.1 - version: 10.0.1(eslint@10.3.0) '@types/chrome': specifier: ^0.0.268 version: 0.0.268 '@vitest/coverage-v8': specifier: ^4.1.5 version: 4.1.5(vitest@4.1.5) - eslint: - specifier: ^10.3.0 - version: 10.3.0 - globals: - specifier: ^17.6.0 - version: 17.6.0 happy-dom: specifier: ^20.9.0 version: 20.9.0 + oxlint: + specifier: ^1.74.0 + version: 1.74.0(oxlint-tsgolint@7.0.2001) + oxlint-tsgolint: + specifier: ^7.0.2001 + version: 7.0.2001 typescript: specifier: ^7.0.2 version: 7.0.2 - typescript-eslint: - specifier: ^8.59.2 - version: 8.59.2(eslint@10.3.0)(typescript@7.0.2) vitest: specifier: ^4.1.5 version: 4.1.5(@types/node@25.6.2)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(vite@8.0.16(@types/node@25.6.2)(esbuild@0.27.7)(yaml@2.8.4)) @@ -716,45 +710,6 @@ packages: cpu: [x64] os: [win32] - '@eslint-community/eslint-utils@4.9.1': - resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - peerDependencies: - eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 - - '@eslint-community/regexpp@4.12.2': - resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} - engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} - - '@eslint/config-array@0.23.5': - resolution: {integrity: sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - - '@eslint/config-helpers@0.5.5': - resolution: {integrity: sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - - '@eslint/core@1.2.1': - resolution: {integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - - '@eslint/js@10.0.1': - resolution: {integrity: sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - peerDependencies: - eslint: ^10.0.0 - peerDependenciesMeta: - eslint: - optional: true - - '@eslint/object-schema@3.0.5': - resolution: {integrity: sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - - '@eslint/plugin-kit@0.7.1': - resolution: {integrity: sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@fontsource/jetbrains-mono@5.2.8': resolution: {integrity: sha512-6w8/SG4kqvIMu7xd7wt6x3idn1Qux3p9N62s6G3rfldOUYHpWcc2FKrqf+Vo44jRvqWj2oAtTHrZXEP23oSKwQ==} @@ -773,26 +728,6 @@ packages: '@modelcontextprotocol/sdk': optional: true - '@humanfs/core@0.19.2': - resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} - engines: {node: '>=18.18.0'} - - '@humanfs/node@0.16.8': - resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} - engines: {node: '>=18.18.0'} - - '@humanfs/types@0.15.0': - resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} - engines: {node: '>=18.18.0'} - - '@humanwhocodes/module-importer@1.0.1': - resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} - engines: {node: '>=12.22'} - - '@humanwhocodes/retry@0.4.3': - resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} - engines: {node: '>=18.18'} - '@img/colour@1.1.0': resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} @@ -967,6 +902,150 @@ packages: '@oxc-project/types@0.133.0': resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==} + '@oxlint-tsgolint/darwin-arm64@7.0.2001': + resolution: {integrity: sha512-CUJEdbSZ54+Xy9OXqOhWLTKZKV0BBiV7C2i/ygyVmXtkUNXx5YCzN8DpSSshTAKktoL7S+tnQ/ftFG/i7X896w==} + cpu: [arm64] + os: [darwin] + + '@oxlint-tsgolint/darwin-x64@7.0.2001': + resolution: {integrity: sha512-pXfBb5BqONCcgrXQNUZWXgiYmRSWJzd97S8i41VVOh6ut0tyo+cJ5FKFpczDHxiVNfj/3e7c9B4MtztNdpIVCw==} + cpu: [x64] + os: [darwin] + + '@oxlint-tsgolint/linux-arm64@7.0.2001': + resolution: {integrity: sha512-roP7zujb/QDPzDwEKsFFpzNHHy91/Y7oX9vQXk78ekyZtcQj1QXDIMH33gjDdHBfRl4K9pZ36xhRgrP4Zr+R8A==} + cpu: [arm64] + os: [linux] + + '@oxlint-tsgolint/linux-x64@7.0.2001': + resolution: {integrity: sha512-UDezNqdECVmngu2TPnjaS1YoAmcTaBoI5lV9vk3VahBxoi+I5r9k3iJTT7qZoYWOXTD/7T7bNcwRgrocR6BscQ==} + cpu: [x64] + os: [linux] + + '@oxlint-tsgolint/win32-arm64@7.0.2001': + resolution: {integrity: sha512-uJZhqB6pdXLuN+AD1F5082byyQti/NPmJA77GtcFlmT2HzRelqbNls3SaIqxpjdFgvSBF9g0yOKGBkGFg7kX8Q==} + cpu: [arm64] + os: [win32] + + '@oxlint-tsgolint/win32-x64@7.0.2001': + resolution: {integrity: sha512-FkDRm8hx9OwzGQqyWG1tO5QrTLRApff9DzSgpz9QZau37BR8d1VYKOxMLGf6shPZntJFoTwIIJYT68VndYDCog==} + cpu: [x64] + os: [win32] + + '@oxlint/binding-android-arm-eabi@1.74.0': + resolution: {integrity: sha512-+gHd12muVI9ZLBaWLPkHt3Fj7jihFjgQ1MGtBaRL8vWrWrI0P7dLUty/cHrHS0oqPYIRgQUJsPu2CExQuMcwNw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxlint/binding-android-arm64@1.74.0': + resolution: {integrity: sha512-xjKdoMB+H+RCOByv/7l7nfIGW9mlOisqYdcyC75UqYuQecLpReAeEYUf2CNeDEI3KtmUgxpRw/+c63y4AeF/Bw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxlint/binding-darwin-arm64@1.74.0': + resolution: {integrity: sha512-iUK7wvc6sejMKsC+Pt67mntoF5weFcyEunhZfLJceU6gL419mexz5wBkSx/EnkFBExMLNtOi9fnDSc5xfK0IzQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxlint/binding-darwin-x64@1.74.0': + resolution: {integrity: sha512-ggKc/tn5SJ1u2yG2izC6VKODfYKV8MQ2AicJlNzOjuyrC29udvOef6/JzK2r32xqCnBDLFouR1VCkjzEI0/N9Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxlint/binding-freebsd-x64@1.74.0': + resolution: {integrity: sha512-u++dH/43jy9hTLbneaWlS0gla/Bp1JdwJ2zgevCl8nDFUh6qRCGMxcL0f0lb7By3A9p/LfFr+7cG4HU1hG856g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxlint/binding-linux-arm-gnueabihf@1.74.0': + resolution: {integrity: sha512-Sj1zmtFDVTPeIbIz4ZfcXAbFHqCmKCXdCUlAJzvTF7I20NTH1RDpoF2PhkqNODutJzVhJYmm3oz0GwgY+tvE2g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm-musleabihf@1.74.0': + resolution: {integrity: sha512-//PKyQb/tQXcHArx2f7z+oVI/eMS2Jpv+edNuAtOrgIhWdGcpHxogveAxzmF2rpH1AIHp4Hq04RF/rgJdiICnQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm64-gnu@1.74.0': + resolution: {integrity: sha512-/k1Me+aX2tjuH10K62mLS0y8cLkJBHX6Ce0xPK+eWeel4bSdEGZ8dv4+hYMzg0GrSmjwy4yAYsDPeEeKBft/2w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@oxlint/binding-linux-arm64-musl@1.74.0': + resolution: {integrity: sha512-3tFSjBxc5D8/zvjEuLvOqcA8ZXKD0+6NuaVO/edeamNc49MoAsbfaC9s1UiwODwgF6slGaF8yJA2TPkukd77tg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@oxlint/binding-linux-ppc64-gnu@1.74.0': + resolution: {integrity: sha512-9QggtPkSPXOCTu8Szis7auOK/sC7KdQaN+/TujP7YVVhzCAOhgdRfgv8uEz0r2tk5xdgus5rLYUrCDoZNtiRUw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@oxlint/binding-linux-riscv64-gnu@1.74.0': + resolution: {integrity: sha512-VM5VPUJ4DJIWiK+AZn8FScUqMr6OFrCAYybMYjEEi7W13ParI64MByiXTkKMqZpBmvQ9zxl9Ebq2VUOiZRJYUg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + + '@oxlint/binding-linux-riscv64-musl@1.74.0': + resolution: {integrity: sha512-SaDY1gh9rOA592J54g+gu5hkOFFQBZsMmIYHs+NRHG+Uq0OxtuuCXMWQ3vu1830Eugv5uMXyjG+bv2Z9y4IXjw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + + '@oxlint/binding-linux-s390x-gnu@1.74.0': + resolution: {integrity: sha512-ZATQeHZCyr6MbDveg0obD5sxLHFOghtOdC5jwVwYlvFWqtFOxctgFEG6Ef/64hYvZrWyhyCckB10AelqLopeDA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@oxlint/binding-linux-x64-gnu@1.74.0': + resolution: {integrity: sha512-+aIvJyrdeD7LwCQ2WYLMUWNmnbeDRSPb40aBYtPjD9+PTqUwgJnk+HK5yLfSMeqXrMrDhE9uTmtt2y50tvjhHw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@oxlint/binding-linux-x64-musl@1.74.0': + resolution: {integrity: sha512-XyktaR8lhK2qWiCK0Tk8oYD+/cgn+oHA6ddRnxSSXUKkkojkV78CmShZUxQF+yrBFs0SuW+JBOPG6hecyc/iZg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@oxlint/binding-openharmony-arm64@1.74.0': + resolution: {integrity: sha512-mzbjrPl4neaVUiJ1fUiEUxTGaSZBoiKtaoB6jmIpz9S+VOA2vDYmJpihQ82w6178V5jxziclTg8Cgj5yF6tTDg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxlint/binding-win32-arm64-msvc@1.74.0': + resolution: {integrity: sha512-vUAe9okpS2Oa5+lX67lqHMuNUvfkleRKwrUDJ/WJBsgmddvZ1mrsh2HVmuFDRzqFELhaJhFaCNOuR6a7L3rtIA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxlint/binding-win32-ia32-msvc@1.74.0': + resolution: {integrity: sha512-yyXXJyYYSXL4I8K8jAWjJs+J3fa9gH2JmEbo4f5adm+1tNC9itseicBNuwK7BDHvqQ5J534s+yDULu89vYL2ZQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxlint/binding-win32-x64-msvc@1.74.0': + resolution: {integrity: sha512-VTC9IYTIMrVUk/i6Ms1ohzzDKZFkWn0KU2OBbPBzgmVZ2V30165T/zK4LztTr0Xgp9fZ1qQZ1rsZAu/rEmySlA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + '@poppinss/colors@4.1.6': resolution: {integrity: sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==} @@ -1366,9 +1445,6 @@ packages: '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} - '@types/esrecurse@4.3.1': - resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} - '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} @@ -1387,9 +1463,6 @@ packages: '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} - '@types/json-schema@7.0.15': - resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} - '@types/mdast@4.0.4': resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} @@ -1414,65 +1487,6 @@ packages: '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} - '@typescript-eslint/eslint-plugin@8.59.2': - resolution: {integrity: sha512-j/bwmkBvHUtPNxzuWe5z6BEk3q54YRyGlBXkSsmfoih7zNrBvl5A9A98anlp/7JbyZcWIJ8KXo/3Tq/DjFLtuQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - '@typescript-eslint/parser': ^8.59.2 - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.1.0' - - '@typescript-eslint/parser@8.59.2': - resolution: {integrity: sha512-plR3pp6D+SSUn1HM7xvSkx12/DhoHInI2YF35KAcVFNZvlC0gtrWqx7Qq1oH2Ssgi0vlFRCTbP+DZc7B9+TtsQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.1.0' - - '@typescript-eslint/project-service@8.59.2': - resolution: {integrity: sha512-+2hqvEkeyf/0FBor67duF0Ll7Ot8jyKzDQOSrxazF/danillRq2DwR9dLptsXpoZQqxE1UisSmoZewrlPas9Vw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.1.0' - - '@typescript-eslint/scope-manager@8.59.2': - resolution: {integrity: sha512-JzfyEpEtOU89CcFSwyNS3mu4MLvLSXqnmX05+aKBDM+TdR5jzcGOEBwxwGNxrEQ7p/z6kK2WyioCGBf2zZBnvg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@typescript-eslint/tsconfig-utils@8.59.2': - resolution: {integrity: sha512-BKK4alN7oi4C/zv4VqHQ+uRU+lTa6JGIZ7s1juw7b3RHo9OfKB+bKX3u0iVZetdsUCBBkSbdWbarJbmN0fTeSw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.1.0' - - '@typescript-eslint/type-utils@8.59.2': - resolution: {integrity: sha512-nhqaj1nmTdVVl/BP5omXNRGO38jn5iosis2vbdmupF2txCf8ylWT8lx+JlvMYYVqzGVKtjojUFoQ3JRWK+mfzQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.1.0' - - '@typescript-eslint/types@8.59.2': - resolution: {integrity: sha512-e82GVOE8Ps3E++Egvb6Y3Dw0S10u8NkQ9KXmtRhCWJJ8kDhOJTvtMAWnFL16kB1583goCWXsr0NieKCZMs2/0Q==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@typescript-eslint/typescript-estree@8.59.2': - resolution: {integrity: sha512-o0XPGNwcWw+FIwStOWn+BwBuEmL6QXP0rsvAFg7ET1dey1Nr6Wb1ac8p5HEsK0ygO/6mUxlk+YWQD9xcb/nnXg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.1.0' - - '@typescript-eslint/utils@8.59.2': - resolution: {integrity: sha512-Juw3EinkXqjaffxz6roowvV7GZT/kET5vSKKZT6upl5TXdWkLkYmNPXwDDL2Vkt2DPn0nODIS4egC/0AGxKo/Q==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.1.0' - - '@typescript-eslint/visitor-keys@8.59.2': - resolution: {integrity: sha512-NwjLUnGy8/Zfx23fl50tRC8rYaYnM52xNRYFAXvmiil9yh1+K6aRVQMnzW6gQB/1DLgWt977lYQn7C+wtgXZiA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript/typescript-aix-ppc64@7.0.2': resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} engines: {node: '>=16.20.0'} @@ -1663,11 +1677,6 @@ packages: '@webcomponents/custom-elements@1.6.0': resolution: {integrity: sha512-CqTpxOlUCPWRNUPZDxT5v2NnHXA4oox612iUGnmTUGQFhZ1Gkj8kirtl/2wcF6MqX7+PqqicZzOCBKKfIn0dww==} - acorn-jsx@5.3.2: - resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} - peerDependencies: - acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 - acorn-walk@8.3.5: resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==} engines: {node: '>=0.4.0'} @@ -1689,9 +1698,6 @@ packages: ajv: optional: true - ajv@6.15.0: - resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} - ajv@8.20.0: resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} @@ -1747,10 +1753,6 @@ packages: bail@2.0.2: resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} - balanced-match@4.0.4: - resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} - engines: {node: 18 || 20 || >=22} - base-64@1.0.0: resolution: {integrity: sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg==} @@ -1774,10 +1776,6 @@ packages: resolution: {integrity: sha512-F3PH5k5juxom4xktynS7MoFY+NUWH5LC4CnH11YB8NPew+HLpmBLCybSAEyb2F+4pRXhuhWqFesoQd6DAyc2hw==} engines: {node: '>=18'} - brace-expansion@5.0.6: - resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} - engines: {node: 18 || 20 || >=22} - braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} @@ -1866,10 +1864,6 @@ packages: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} - cross-spawn@7.0.6: - resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} - engines: {node: '>= 8'} - crossws@0.3.5: resolution: {integrity: sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==} @@ -1930,9 +1924,6 @@ packages: decode-named-character-reference@1.3.0: resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} - deep-is@0.1.4: - resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} - defu@6.1.7: resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} @@ -2043,62 +2034,16 @@ packages: escape-html@1.0.3: resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} - escape-string-regexp@4.0.0: - resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} - engines: {node: '>=10'} - escape-string-regexp@5.0.0: resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} engines: {node: '>=12'} - eslint-scope@9.1.2: - resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - - eslint-visitor-keys@3.4.3: - resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - - eslint-visitor-keys@5.0.1: - resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - - eslint@10.3.0: - resolution: {integrity: sha512-XbEXaRva5cF0ZQB8w6MluHA0kZZfV2DuCMJ3ozyEOHLwDpZX2Lmm/7Pp0xdJmI0GL1W05VH5VwIFHEm1Vcw2gw==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - hasBin: true - peerDependencies: - jiti: '*' - peerDependenciesMeta: - jiti: - optional: true - - espree@11.2.0: - resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - - esquery@1.7.0: - resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} - engines: {node: '>=0.10'} - - esrecurse@4.3.0: - resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} - engines: {node: '>=4.0'} - - estraverse@5.3.0: - resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} - engines: {node: '>=4.0'} - estree-walker@2.0.2: resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} - esutils@2.0.3: - resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} - engines: {node: '>=0.10.0'} - eventemitter3@5.0.4: resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} @@ -2116,12 +2061,6 @@ packages: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} - fast-json-stable-stringify@2.1.0: - resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} - - fast-levenshtein@2.0.6: - resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} - fast-sha256@1.3.0: resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} @@ -2147,25 +2086,10 @@ packages: fflate@0.7.4: resolution: {integrity: sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw==} - file-entry-cache@8.0.0: - resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} - engines: {node: '>=16.0.0'} - fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} - find-up@5.0.0: - resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} - engines: {node: '>=10'} - - flat-cache@4.0.1: - resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} - engines: {node: '>=16'} - - flatted@3.4.2: - resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} - flattie@1.1.1: resolution: {integrity: sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ==} engines: {node: '>=8'} @@ -2213,14 +2137,6 @@ packages: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} - glob-parent@6.0.2: - resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} - engines: {node: '>=10.13.0'} - - globals@17.6.0: - resolution: {integrity: sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==} - engines: {node: '>=18'} - google-auth-library@10.6.2: resolution: {integrity: sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==} engines: {node: '>=18'} @@ -2297,21 +2213,9 @@ packages: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} - ignore@5.3.2: - resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} - engines: {node: '>= 4'} - - ignore@7.0.5: - resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} - engines: {node: '>= 4'} - import-meta-resolve@4.2.0: resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} - imurmurhash@0.1.4: - resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} - engines: {node: '>=0.8.19'} - iron-webcrypto@1.2.1: resolution: {integrity: sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==} @@ -2349,9 +2253,6 @@ packages: resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} engines: {node: '>=16'} - isexe@2.0.0: - resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - istanbul-lib-coverage@3.2.2: resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} engines: {node: '>=8'} @@ -2379,22 +2280,13 @@ packages: json-bigint@1.0.0: resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==} - json-buffer@3.0.1: - resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} - json-schema-to-ts@3.1.1: resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==} engines: {node: '>=16'} - json-schema-traverse@0.4.1: - resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} - json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} - json-stable-stringify-without-jsonify@1.0.1: - resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} - jsonc-parser@2.3.1: resolution: {integrity: sha512-H8jvkz1O50L3dMZCsLqiuB2tA7muqbSg1AtGEkN0leAqGjsUzDJir3Zwr02BhqdcITPg3ei3mZ+HjMocAknhhg==} @@ -2410,9 +2302,6 @@ packages: jws@4.0.1: resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} - keyv@4.5.4: - resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} - kleur@3.0.3: resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} engines: {node: '>=6'} @@ -2421,10 +2310,6 @@ packages: resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} engines: {node: '>=6'} - levn@0.4.1: - resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} - engines: {node: '>= 0.8.0'} - lightningcss-android-arm64@1.32.0: resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} engines: {node: '>= 12.0.0'} @@ -2498,10 +2383,6 @@ packages: linebreak@1.1.0: resolution: {integrity: sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==} - locate-path@6.0.0: - resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} - engines: {node: '>=10'} - long@5.3.2: resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} @@ -2667,10 +2548,6 @@ packages: engines: {node: '>=22.0.0'} hasBin: true - minimatch@10.2.5: - resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} - engines: {node: 18 || 20 || >=22} - mrmime@2.0.1: resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} engines: {node: '>=10'} @@ -2686,9 +2563,6 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - natural-compare@1.4.0: - resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} - neotraverse@0.6.18: resolution: {integrity: sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA==} engines: {node: '>= 10'} @@ -2748,22 +2622,27 @@ packages: zod: optional: true - optionator@0.9.4: - resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} - engines: {node: '>= 0.8.0'} + oxlint-tsgolint@7.0.2001: + resolution: {integrity: sha512-KjK/XLcXr1DSyonKhsuFqJRiuKqcyG9j3LJ8nkOsrLzGvodBPqzHOKauy10asLMDI0sUpvb+1sxlzff3udZvfg==} + hasBin: true - p-limit@3.1.0: - resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} - engines: {node: '>=10'} + oxlint@1.74.0: + resolution: {integrity: sha512-odGl2s2x5IOJoj3A0v1k0PGBXVFBZeZ2+AK/+K2MJur7Ghi3bkyX5NuLUWHKqa4js1wjep3hJeuTQJOlr+4+dA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + oxlint-tsgolint: '>=0.24.0' + vite-plus: '*' + peerDependenciesMeta: + oxlint-tsgolint: + optional: true + vite-plus: + optional: true p-limit@6.2.0: resolution: {integrity: sha512-kuUqqHNUqoIWp/c467RI4X6mmyuojY5jGutNU0wVTmEOOfcuwLqyMVoAi9MKi2Ak+5i9+nhmrK4ufZE8069kHA==} engines: {node: '>=18'} - p-locate@5.0.0: - resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} - engines: {node: '>=10'} - p-queue@8.1.1: resolution: {integrity: sha512-aNZ+VfjobsWryoiPnEApGGmf5WmNsCo9xu8dfaYamG5qaLP7ClhLN6NgsFe6SwJ2UbLEBK5dv9x8Mn5+RVhMWQ==} engines: {node: '>=18'} @@ -2794,14 +2673,6 @@ packages: path-browserify@1.0.1: resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} - path-exists@4.0.0: - resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} - engines: {node: '>=8'} - - path-key@3.1.1: - resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} - engines: {node: '>=8'} - path-to-regexp@6.3.0: resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} @@ -2833,10 +2704,6 @@ packages: resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} engines: {node: ^10 || ^12 || >=14} - prelude-ls@1.2.1: - resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} - engines: {node: '>= 0.8.0'} - prettier@3.8.3: resolution: {integrity: sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==} engines: {node: '>=14'} @@ -2857,10 +2724,6 @@ packages: resolution: {integrity: sha512-dvpCIeLPbXZS/Ete7yLaO7RenOdken2NHKykBXbsaGxZT0UTltcarBciw+A78SRQs9iMAAVpsYA+l8b1hTePIA==} engines: {node: '>=12.0.0'} - punycode@2.3.1: - resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} - engines: {node: '>=6'} - queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -2994,14 +2857,6 @@ packages: resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - shebang-command@2.0.0: - resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} - engines: {node: '>=8'} - - shebang-regex@3.0.0: - resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} - engines: {node: '>=8'} - shiki@3.23.0: resolution: {integrity: sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA==} @@ -3101,15 +2956,10 @@ packages: ts-algebra@2.0.0: resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==} - ts-api-utils@2.5.0: - resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} - engines: {node: '>=18.12'} - peerDependencies: - typescript: '>=4.8.4' - tsconfck@3.1.6: resolution: {integrity: sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==} engines: {node: ^18 || >=20} + deprecated: unmaintained hasBin: true peerDependencies: typescript: ^5.0.0 @@ -3120,10 +2970,6 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - type-check@0.4.0: - resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} - engines: {node: '>= 0.8.0'} - type-fest@4.41.0: resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} engines: {node: '>=16'} @@ -3134,13 +2980,6 @@ packages: typescript-auto-import-cache@0.3.6: resolution: {integrity: sha512-RpuHXrknHdVdK7wv/8ug3Fr0WNsNi5l5aB8MYYuXhq2UH5lnEB1htJ1smhtD5VeCsGr2p8mUDtd83LCQDFVgjQ==} - typescript-eslint@8.59.2: - resolution: {integrity: sha512-pJw051uomb3ZeCzGTpRb8RbEqB5Y4WWet8gl/GcTlU35BSx0PVdZ86/bqkQCyKKuraVQEK7r6kBHQXF+fBhkoQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.1.0' - typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -3272,9 +3111,6 @@ packages: uploadthing: optional: true - uri-js@4.4.1: - resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} - vfile-location@5.0.3: resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==} @@ -3523,11 +3359,6 @@ packages: resolution: {integrity: sha512-n1brCuqClxfFfq/Rb0ICg9giSZqCS+pLtccdag6C2HyufBrh3fBOiy9nb6ggRMvWOVH5GrdJskj5iGTZNxd7SA==} engines: {node: '>=4'} - which@2.0.2: - resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} - engines: {node: '>= 8'} - hasBin: true - why-is-node-running@2.3.0: resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} engines: {node: '>=8'} @@ -3537,10 +3368,6 @@ packages: resolution: {integrity: sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==} engines: {node: '>=18'} - word-wrap@1.2.5: - resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} - engines: {node: '>=0.10.0'} - workerd@1.20260507.1: resolution: {integrity: sha512-z7JhsFSe6+X1b5fUHaVpo15VM1IRMJiLofEkq8iKdCo+Veqc+FUg5lIsuz8NwePxuSKrXtO4ZQpGkQLbPVXFhg==} engines: {node: '>=16'} @@ -3617,10 +3444,6 @@ packages: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} - yocto-queue@0.1.0: - resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} - engines: {node: '>=10'} - yocto-queue@1.2.2: resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} engines: {node: '>=12.20'} @@ -4097,40 +3920,6 @@ snapshots: '@esbuild/win32-x64@0.27.7': optional: true - '@eslint-community/eslint-utils@4.9.1(eslint@10.3.0)': - dependencies: - eslint: 10.3.0 - eslint-visitor-keys: 3.4.3 - - '@eslint-community/regexpp@4.12.2': {} - - '@eslint/config-array@0.23.5': - dependencies: - '@eslint/object-schema': 3.0.5 - debug: 4.4.3 - minimatch: 10.2.5 - transitivePeerDependencies: - - supports-color - - '@eslint/config-helpers@0.5.5': - dependencies: - '@eslint/core': 1.2.1 - - '@eslint/core@1.2.1': - dependencies: - '@types/json-schema': 7.0.15 - - '@eslint/js@10.0.1(eslint@10.3.0)': - optionalDependencies: - eslint: 10.3.0 - - '@eslint/object-schema@3.0.5': {} - - '@eslint/plugin-kit@0.7.1': - dependencies: - '@eslint/core': 1.2.1 - levn: 0.4.1 - '@fontsource/jetbrains-mono@5.2.8': {} '@fontsource/noto-serif-tc@5.2.10': {} @@ -4148,22 +3937,6 @@ snapshots: - supports-color - utf-8-validate - '@humanfs/core@0.19.2': - dependencies: - '@humanfs/types': 0.15.0 - - '@humanfs/node@0.16.8': - dependencies: - '@humanfs/core': 0.19.2 - '@humanfs/types': 0.15.0 - '@humanwhocodes/retry': 0.4.3 - - '@humanfs/types@0.15.0': {} - - '@humanwhocodes/module-importer@1.0.1': {} - - '@humanwhocodes/retry@0.4.3': {} - '@img/colour@1.1.0': {} '@img/sharp-darwin-arm64@0.34.5': @@ -4297,6 +4070,81 @@ snapshots: '@oxc-project/types@0.133.0': {} + '@oxlint-tsgolint/darwin-arm64@7.0.2001': + optional: true + + '@oxlint-tsgolint/darwin-x64@7.0.2001': + optional: true + + '@oxlint-tsgolint/linux-arm64@7.0.2001': + optional: true + + '@oxlint-tsgolint/linux-x64@7.0.2001': + optional: true + + '@oxlint-tsgolint/win32-arm64@7.0.2001': + optional: true + + '@oxlint-tsgolint/win32-x64@7.0.2001': + optional: true + + '@oxlint/binding-android-arm-eabi@1.74.0': + optional: true + + '@oxlint/binding-android-arm64@1.74.0': + optional: true + + '@oxlint/binding-darwin-arm64@1.74.0': + optional: true + + '@oxlint/binding-darwin-x64@1.74.0': + optional: true + + '@oxlint/binding-freebsd-x64@1.74.0': + optional: true + + '@oxlint/binding-linux-arm-gnueabihf@1.74.0': + optional: true + + '@oxlint/binding-linux-arm-musleabihf@1.74.0': + optional: true + + '@oxlint/binding-linux-arm64-gnu@1.74.0': + optional: true + + '@oxlint/binding-linux-arm64-musl@1.74.0': + optional: true + + '@oxlint/binding-linux-ppc64-gnu@1.74.0': + optional: true + + '@oxlint/binding-linux-riscv64-gnu@1.74.0': + optional: true + + '@oxlint/binding-linux-riscv64-musl@1.74.0': + optional: true + + '@oxlint/binding-linux-s390x-gnu@1.74.0': + optional: true + + '@oxlint/binding-linux-x64-gnu@1.74.0': + optional: true + + '@oxlint/binding-linux-x64-musl@1.74.0': + optional: true + + '@oxlint/binding-openharmony-arm64@1.74.0': + optional: true + + '@oxlint/binding-win32-arm64-msvc@1.74.0': + optional: true + + '@oxlint/binding-win32-ia32-msvc@1.74.0': + optional: true + + '@oxlint/binding-win32-x64-msvc@1.74.0': + optional: true + '@poppinss/colors@4.1.6': dependencies: kleur: 4.1.5 @@ -4589,8 +4437,6 @@ snapshots: '@types/deep-eql@4.0.2': {} - '@types/esrecurse@4.3.1': {} - '@types/estree@1.0.8': {} '@types/estree@1.0.9': {} @@ -4607,8 +4453,6 @@ snapshots: dependencies: '@types/unist': 3.0.3 - '@types/json-schema@7.0.15': {} - '@types/mdast@4.0.4': dependencies: '@types/unist': 3.0.3 @@ -4633,97 +4477,6 @@ snapshots: dependencies: '@types/node': 25.6.2 - '@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@10.3.0)(typescript@7.0.2))(eslint@10.3.0)(typescript@7.0.2)': - dependencies: - '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.59.2(eslint@10.3.0)(typescript@7.0.2) - '@typescript-eslint/scope-manager': 8.59.2 - '@typescript-eslint/type-utils': 8.59.2(eslint@10.3.0)(typescript@7.0.2) - '@typescript-eslint/utils': 8.59.2(eslint@10.3.0)(typescript@7.0.2) - '@typescript-eslint/visitor-keys': 8.59.2 - eslint: 10.3.0 - ignore: 7.0.5 - natural-compare: 1.4.0 - ts-api-utils: 2.5.0(typescript@7.0.2) - typescript: 7.0.2 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/parser@8.59.2(eslint@10.3.0)(typescript@7.0.2)': - dependencies: - '@typescript-eslint/scope-manager': 8.59.2 - '@typescript-eslint/types': 8.59.2 - '@typescript-eslint/typescript-estree': 8.59.2(typescript@7.0.2) - '@typescript-eslint/visitor-keys': 8.59.2 - debug: 4.4.3 - eslint: 10.3.0 - typescript: 7.0.2 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/project-service@8.59.2(typescript@7.0.2)': - dependencies: - '@typescript-eslint/tsconfig-utils': 8.59.2(typescript@7.0.2) - '@typescript-eslint/types': 8.59.2 - debug: 4.4.3 - typescript: 7.0.2 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/scope-manager@8.59.2': - dependencies: - '@typescript-eslint/types': 8.59.2 - '@typescript-eslint/visitor-keys': 8.59.2 - - '@typescript-eslint/tsconfig-utils@8.59.2(typescript@7.0.2)': - dependencies: - typescript: 7.0.2 - - '@typescript-eslint/type-utils@8.59.2(eslint@10.3.0)(typescript@7.0.2)': - dependencies: - '@typescript-eslint/types': 8.59.2 - '@typescript-eslint/typescript-estree': 8.59.2(typescript@7.0.2) - '@typescript-eslint/utils': 8.59.2(eslint@10.3.0)(typescript@7.0.2) - debug: 4.4.3 - eslint: 10.3.0 - ts-api-utils: 2.5.0(typescript@7.0.2) - typescript: 7.0.2 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/types@8.59.2': {} - - '@typescript-eslint/typescript-estree@8.59.2(typescript@7.0.2)': - dependencies: - '@typescript-eslint/project-service': 8.59.2(typescript@7.0.2) - '@typescript-eslint/tsconfig-utils': 8.59.2(typescript@7.0.2) - '@typescript-eslint/types': 8.59.2 - '@typescript-eslint/visitor-keys': 8.59.2 - debug: 4.4.3 - minimatch: 10.2.5 - semver: 7.8.0 - tinyglobby: 0.2.16 - ts-api-utils: 2.5.0(typescript@7.0.2) - typescript: 7.0.2 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/utils@8.59.2(eslint@10.3.0)(typescript@7.0.2)': - dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.3.0) - '@typescript-eslint/scope-manager': 8.59.2 - '@typescript-eslint/types': 8.59.2 - '@typescript-eslint/typescript-estree': 8.59.2(typescript@7.0.2) - eslint: 10.3.0 - typescript: 7.0.2 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/visitor-keys@8.59.2': - dependencies: - '@typescript-eslint/types': 8.59.2 - eslint-visitor-keys: 5.0.1 - '@typescript/typescript-aix-ppc64@7.0.2': optional: true @@ -4893,10 +4646,6 @@ snapshots: '@webcomponents/custom-elements@1.6.0': {} - acorn-jsx@5.3.2(acorn@8.16.0): - dependencies: - acorn: 8.16.0 - acorn-walk@8.3.5: dependencies: acorn: 8.16.0 @@ -4909,13 +4658,6 @@ snapshots: optionalDependencies: ajv: 8.20.0 - ajv@6.15.0: - dependencies: - fast-deep-equal: 3.1.3 - fast-json-stable-stringify: 2.1.0 - json-schema-traverse: 0.4.1 - uri-js: 4.4.1 - ajv@8.20.0: dependencies: fast-deep-equal: 3.1.3 @@ -5062,8 +4804,6 @@ snapshots: bail@2.0.2: {} - balanced-match@4.0.4: {} - base-64@1.0.0: {} base64-js@0.0.8: {} @@ -5087,10 +4827,6 @@ snapshots: widest-line: 5.0.0 wrap-ansi: 9.0.2 - brace-expansion@5.0.6: - dependencies: - balanced-match: 4.0.4 - braces@3.0.3: dependencies: fill-range: 7.1.1 @@ -5153,12 +4889,6 @@ snapshots: cookie@1.1.1: {} - cross-spawn@7.0.6: - dependencies: - path-key: 3.1.1 - shebang-command: 2.0.0 - which: 2.0.2 - crossws@0.3.5: dependencies: uncrypto: 0.1.3 @@ -5213,8 +4943,6 @@ snapshots: dependencies: character-entities: 2.0.2 - deep-is@0.1.4: {} - defu@6.1.7: {} dequal@2.0.3: {} @@ -5377,80 +5105,14 @@ snapshots: escape-html@1.0.3: {} - escape-string-regexp@4.0.0: {} - escape-string-regexp@5.0.0: {} - eslint-scope@9.1.2: - dependencies: - '@types/esrecurse': 4.3.1 - '@types/estree': 1.0.9 - esrecurse: 4.3.0 - estraverse: 5.3.0 - - eslint-visitor-keys@3.4.3: {} - - eslint-visitor-keys@5.0.1: {} - - eslint@10.3.0: - dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.3.0) - '@eslint-community/regexpp': 4.12.2 - '@eslint/config-array': 0.23.5 - '@eslint/config-helpers': 0.5.5 - '@eslint/core': 1.2.1 - '@eslint/plugin-kit': 0.7.1 - '@humanfs/node': 0.16.8 - '@humanwhocodes/module-importer': 1.0.1 - '@humanwhocodes/retry': 0.4.3 - '@types/estree': 1.0.9 - ajv: 6.15.0 - cross-spawn: 7.0.6 - debug: 4.4.3 - escape-string-regexp: 4.0.0 - eslint-scope: 9.1.2 - eslint-visitor-keys: 5.0.1 - espree: 11.2.0 - esquery: 1.7.0 - esutils: 2.0.3 - fast-deep-equal: 3.1.3 - file-entry-cache: 8.0.0 - find-up: 5.0.0 - glob-parent: 6.0.2 - ignore: 5.3.2 - imurmurhash: 0.1.4 - is-glob: 4.0.3 - json-stable-stringify-without-jsonify: 1.0.1 - minimatch: 10.2.5 - natural-compare: 1.4.0 - optionator: 0.9.4 - transitivePeerDependencies: - - supports-color - - espree@11.2.0: - dependencies: - acorn: 8.16.0 - acorn-jsx: 5.3.2(acorn@8.16.0) - eslint-visitor-keys: 5.0.1 - - esquery@1.7.0: - dependencies: - estraverse: 5.3.0 - - esrecurse@4.3.0: - dependencies: - estraverse: 5.3.0 - - estraverse@5.3.0: {} - estree-walker@2.0.2: {} estree-walker@3.0.3: dependencies: '@types/estree': 1.0.9 - esutils@2.0.3: {} - eventemitter3@5.0.4: {} expect-type@1.3.0: {} @@ -5467,10 +5129,6 @@ snapshots: merge2: 1.4.1 micromatch: 4.0.8 - fast-json-stable-stringify@2.1.0: {} - - fast-levenshtein@2.0.6: {} - fast-sha256@1.3.0: {} fast-uri@3.1.2: {} @@ -5490,26 +5148,10 @@ snapshots: fflate@0.7.4: {} - file-entry-cache@8.0.0: - dependencies: - flat-cache: 4.0.1 - fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 - find-up@5.0.0: - dependencies: - locate-path: 6.0.0 - path-exists: 4.0.0 - - flat-cache@4.0.1: - dependencies: - flatted: 3.4.2 - keyv: 4.5.4 - - flatted@3.4.2: {} - flattie@1.1.1: {} fontace@0.4.1: @@ -5559,12 +5201,6 @@ snapshots: dependencies: is-glob: 4.0.3 - glob-parent@6.0.2: - dependencies: - is-glob: 4.0.3 - - globals@17.6.0: {} - google-auth-library@10.6.2: dependencies: base64-js: 1.5.1 @@ -5712,14 +5348,8 @@ snapshots: transitivePeerDependencies: - supports-color - ignore@5.3.2: {} - - ignore@7.0.5: {} - import-meta-resolve@4.2.0: {} - imurmurhash@0.1.4: {} - iron-webcrypto@1.2.1: {} is-docker@3.0.0: {} @@ -5744,8 +5374,6 @@ snapshots: dependencies: is-inside-container: 1.0.0 - isexe@2.0.0: {} - istanbul-lib-coverage@3.2.2: {} istanbul-lib-report@3.0.1: @@ -5771,19 +5399,13 @@ snapshots: dependencies: bignumber.js: 9.3.1 - json-buffer@3.0.1: {} - json-schema-to-ts@3.1.1: dependencies: '@babel/runtime': 7.29.2 ts-algebra: 2.0.0 - json-schema-traverse@0.4.1: {} - json-schema-traverse@1.0.0: {} - json-stable-stringify-without-jsonify@1.0.1: {} - jsonc-parser@2.3.1: {} jsonc-parser@3.3.1: {} @@ -5805,19 +5427,10 @@ snapshots: jwa: 2.0.1 safe-buffer: 5.2.1 - keyv@4.5.4: - dependencies: - json-buffer: 3.0.1 - kleur@3.0.3: {} kleur@4.1.5: {} - levn@0.4.1: - dependencies: - prelude-ls: 1.2.1 - type-check: 0.4.0 - lightningcss-android-arm64@1.32.0: optional: true @@ -5872,10 +5485,6 @@ snapshots: base64-js: 0.0.8 unicode-trie: 2.0.0 - locate-path@6.0.0: - dependencies: - p-locate: 5.0.0 - long@5.3.2: {} longest-streak@3.1.0: {} @@ -6232,10 +5841,6 @@ snapshots: - bufferutil - utf-8-validate - minimatch@10.2.5: - dependencies: - brace-expansion: 5.0.6 - mrmime@2.0.1: {} ms@2.1.3: {} @@ -6244,8 +5849,6 @@ snapshots: nanoid@3.3.12: {} - natural-compare@1.4.0: {} - neotraverse@0.6.18: {} nlcst-to-string@4.0.0: @@ -6298,27 +5901,42 @@ snapshots: ws: 8.20.0 zod: 3.25.76 - optionator@0.9.4: - dependencies: - deep-is: 0.1.4 - fast-levenshtein: 2.0.6 - levn: 0.4.1 - prelude-ls: 1.2.1 - type-check: 0.4.0 - word-wrap: 1.2.5 - - p-limit@3.1.0: - dependencies: - yocto-queue: 0.1.0 + oxlint-tsgolint@7.0.2001: + optionalDependencies: + '@oxlint-tsgolint/darwin-arm64': 7.0.2001 + '@oxlint-tsgolint/darwin-x64': 7.0.2001 + '@oxlint-tsgolint/linux-arm64': 7.0.2001 + '@oxlint-tsgolint/linux-x64': 7.0.2001 + '@oxlint-tsgolint/win32-arm64': 7.0.2001 + '@oxlint-tsgolint/win32-x64': 7.0.2001 + + oxlint@1.74.0(oxlint-tsgolint@7.0.2001): + optionalDependencies: + '@oxlint/binding-android-arm-eabi': 1.74.0 + '@oxlint/binding-android-arm64': 1.74.0 + '@oxlint/binding-darwin-arm64': 1.74.0 + '@oxlint/binding-darwin-x64': 1.74.0 + '@oxlint/binding-freebsd-x64': 1.74.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.74.0 + '@oxlint/binding-linux-arm-musleabihf': 1.74.0 + '@oxlint/binding-linux-arm64-gnu': 1.74.0 + '@oxlint/binding-linux-arm64-musl': 1.74.0 + '@oxlint/binding-linux-ppc64-gnu': 1.74.0 + '@oxlint/binding-linux-riscv64-gnu': 1.74.0 + '@oxlint/binding-linux-riscv64-musl': 1.74.0 + '@oxlint/binding-linux-s390x-gnu': 1.74.0 + '@oxlint/binding-linux-x64-gnu': 1.74.0 + '@oxlint/binding-linux-x64-musl': 1.74.0 + '@oxlint/binding-openharmony-arm64': 1.74.0 + '@oxlint/binding-win32-arm64-msvc': 1.74.0 + '@oxlint/binding-win32-ia32-msvc': 1.74.0 + '@oxlint/binding-win32-x64-msvc': 1.74.0 + oxlint-tsgolint: 7.0.2001 p-limit@6.2.0: dependencies: yocto-queue: 1.2.2 - p-locate@5.0.0: - dependencies: - p-limit: 3.1.0 - p-queue@8.1.1: dependencies: eventemitter3: 5.0.4 @@ -6355,10 +5973,6 @@ snapshots: path-browserify@1.0.1: {} - path-exists@4.0.0: {} - - path-key@3.1.1: {} - path-to-regexp@6.3.0: {} pathe@2.0.3: {} @@ -6385,8 +5999,6 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 - prelude-ls@1.2.1: {} - prettier@3.8.3: {} prismjs@1.30.0: {} @@ -6413,8 +6025,6 @@ snapshots: '@types/node': 25.6.2 long: 5.3.2 - punycode@2.3.1: {} - queue-microtask@1.2.3: {} radix3@1.1.2: {} @@ -6657,12 +6267,6 @@ snapshots: '@img/sharp-win32-ia32': 0.34.5 '@img/sharp-win32-x64': 0.34.5 - shebang-command@2.0.0: - dependencies: - shebang-regex: 3.0.0 - - shebang-regex@3.0.0: {} - shiki@3.23.0: dependencies: '@shikijs/core': 3.23.0 @@ -6764,20 +6368,12 @@ snapshots: ts-algebra@2.0.0: {} - ts-api-utils@2.5.0(typescript@7.0.2): - dependencies: - typescript: 7.0.2 - tsconfck@3.1.6(typescript@5.9.3): optionalDependencies: typescript: 5.9.3 tslib@2.8.1: {} - type-check@0.4.0: - dependencies: - prelude-ls: 1.2.1 - type-fest@4.41.0: {} typesafe-path@0.2.2: {} @@ -6786,17 +6382,6 @@ snapshots: dependencies: semver: 7.8.0 - typescript-eslint@8.59.2(eslint@10.3.0)(typescript@7.0.2): - dependencies: - '@typescript-eslint/eslint-plugin': 8.59.2(@typescript-eslint/parser@8.59.2(eslint@10.3.0)(typescript@7.0.2))(eslint@10.3.0)(typescript@7.0.2) - '@typescript-eslint/parser': 8.59.2(eslint@10.3.0)(typescript@7.0.2) - '@typescript-eslint/typescript-estree': 8.59.2(typescript@7.0.2) - '@typescript-eslint/utils': 8.59.2(eslint@10.3.0)(typescript@7.0.2) - eslint: 10.3.0 - typescript: 7.0.2 - transitivePeerDependencies: - - supports-color - typescript@5.9.3: {} typescript@7.0.2: @@ -6912,10 +6497,6 @@ snapshots: ofetch: 1.5.1 ufo: 1.6.4 - uri-js@4.4.1: - dependencies: - punycode: 2.3.1 - vfile-location@5.0.3: dependencies: '@types/unist': 3.0.3 @@ -7096,10 +6677,6 @@ snapshots: which-pm-runs@1.1.0: {} - which@2.0.2: - dependencies: - isexe: 2.0.0 - why-is-node-running@2.3.0: dependencies: siginfo: 2.0.0 @@ -7109,8 +6686,6 @@ snapshots: dependencies: string-width: 7.2.0 - word-wrap@1.2.5: {} - workerd@1.20260507.1: optionalDependencies: '@cloudflare/workerd-darwin-64': 1.20260507.1 @@ -7185,8 +6760,6 @@ snapshots: y18n: 5.0.8 yargs-parser: 21.1.1 - yocto-queue@0.1.0: {} - yocto-queue@1.2.2: {} yocto-spinner@0.2.3: From bb9b63d9c1bcc0d52356c82a4d97a2ef06d45a95 Mon Sep 17 00:00:00 2001 From: Lawrence Lin Date: Wed, 22 Jul 2026 06:54:05 +0800 Subject: [PATCH 3/6] Adopt oxfmt as the repository formatter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The repo previously had no formatter. oxfmt is Prettier-compatible and formats the whole tree in about a second, so CI can enforce it cheaply (new Format check step runs oxfmt --check). Configuration follows the existing code style to keep this reformat minimal: printWidth 100 (also oxfmt's default) and no trailing commas. Two file groups are excluded on purpose: - **/*.md — prose, much of it translated READMEs; rewrapping would churn every locale for no benefit - apps/extension/test/fixtures/** — golden extraction fixtures whose whitespace is part of what the tests assert Everything else (source, scripts, JSON, CSS, HTML, YAML) is formatted in this commit; root scripts fmt / fmt:check are the entry points. --- .github/ISSUE_TEMPLATE/beta-feedback.yml | 1 - .github/ISSUE_TEMPLATE/provider-options.yml | 1 - .../ISSUE_TEMPLATE/website-translation.yml | 1 - .github/ISSUE_TEMPLATE/youtube-captions.yml | 1 - .github/workflows/ci.yml | 3 + .oxfmtrc.json | 6 + CHANGELOG.md | 12 + apps/extension/options.html | 114 +- apps/extension/package.json | 2 +- apps/extension/scripts/capture-fixture.mjs | 63 +- apps/extension/scripts/check-extension.mjs | 47 +- .../scripts/check-release-readiness.mjs | 124 +- .../scripts/check-release-version.mjs | 5 +- apps/extension/scripts/package-extension.mjs | 34 +- .../background/providers/anthropic.test.ts | 12 +- .../src/background/providers/anthropic.ts | 18 +- .../src/background/providers/google.test.ts | 47 +- .../src/background/providers/google.ts | 11 +- .../src/background/providers/index.test.ts | 25 +- .../src/background/providers/openai.test.ts | 23 +- .../src/background/providers/openai.ts | 18 +- .../src/background/providers/shared.test.ts | 17 +- .../src/background/providers/shared.ts | 10 +- .../src/background/serviceWorker.test.ts | 68 +- .../extension/src/background/serviceWorker.ts | 26 +- .../src/content/blockCandidates.test.ts | 12 +- apps/extension/src/content/blockCandidates.ts | 6 +- apps/extension/src/content/contentScript.ts | 16 +- .../src/content/displayStyle.test.ts | 12 +- apps/extension/src/content/displayStyle.ts | 59 +- .../extraction/archetypes/article.test.ts | 17 +- .../content/extraction/archetypes/article.ts | 27 +- .../extraction/archetypes/docs.test.ts | 20 +- .../src/content/extraction/archetypes/docs.ts | 46 +- .../src/content/extraction/cascade.ts | 15 +- .../src/content/extraction/inlineText.ts | 19 +- .../src/content/extraction/legacy.ts | 12 +- .../src/content/extraction/shared.ts | 82 +- .../src/content/extraction/universal.ts | 22 +- .../src/content/extractionFixtures.test.ts | 19 +- .../src/content/floatingButton.test.ts | 14 +- apps/extension/src/content/floatingButton.ts | 11 +- .../src/content/layoutStrategy.test.ts | 21 +- apps/extension/src/content/layoutStrategy.ts | 8 +- .../src/content/orchestrator.test.ts | 42 +- apps/extension/src/content/orchestrator.ts | 33 +- .../src/content/readingVisibility.test.ts | 24 +- .../src/content/readingVisibility.ts | 8 +- .../src/content/siteAdapters/index.ts | 5 +- .../src/content/siteAdapters/x.test.ts | 16 +- apps/extension/src/content/siteAdapters/x.ts | 55 +- .../content/siteAdapters/xFixtures.test.ts | 10 +- .../youtube/captionTracks.test.ts | 75 +- .../siteAdapters/youtube/captionTracks.ts | 38 +- .../siteAdapters/youtube/controls.test.ts | 505 ++++++--- .../content/siteAdapters/youtube/controls.ts | 106 +- apps/extension/src/content/textBlocks.test.ts | 24 +- .../src/content/translationQueue.test.ts | 21 +- .../extension/src/content/translationQueue.ts | 5 +- .../src/content/translationRenderer.test.ts | 17 +- .../src/content/translationRenderer.ts | 11 +- apps/extension/src/options/cachePrivacy.ts | 5 +- .../src/options/displayPreview.test.ts | 6 +- apps/extension/src/options/displayPreview.ts | 8 +- .../src/options/languageSelect.test.ts | 4 +- apps/extension/src/options/languageSelect.ts | 10 +- apps/extension/src/options/languages.test.ts | 11 +- apps/extension/src/options/languages.ts | 170 ++- apps/extension/src/options/options.css | 8 +- apps/extension/src/options/options.ts | 5 +- .../src/options/optionsLayout.test.ts | 26 +- .../src/options/providerSettings.test.ts | 57 +- .../extension/src/options/providerSettings.ts | 9 +- .../src/options/settingsForm.test.ts | 4 +- apps/extension/src/options/settingsForm.ts | 6 +- apps/extension/src/popup/popup.css | 16 +- apps/extension/src/shared/defaults.test.ts | 16 +- apps/extension/src/shared/json.test.ts | 4 +- .../src/shared/migrations/versions/v0.ts | 7 +- apps/extension/src/shared/storage.ts | 5 +- apps/extension/src/shared/text.ts | 3 +- apps/extension/src/shared/translationLabel.ts | 9 +- apps/extension/src/shared/types.ts | 7 +- apps/extension/test/chromeI18n.ts | 17 +- apps/extension/vite.config.ts | 11 +- apps/website/astro.config.mjs | 10 +- apps/website/package.json | 2 +- apps/website/scripts/build-og.mjs | 103 +- apps/website/src/i18n/ui.ts | 73 +- apps/website/src/scripts/site.ts | 8 +- apps/website/src/styles/global.css | 1005 ++++++++++++----- package.json | 9 +- pnpm-lock.yaml | 217 ++++ 93 files changed, 2970 insertions(+), 1003 deletions(-) create mode 100644 .oxfmtrc.json diff --git a/.github/ISSUE_TEMPLATE/beta-feedback.yml b/.github/ISSUE_TEMPLATE/beta-feedback.yml index f020fb5..8d01650 100644 --- a/.github/ISSUE_TEMPLATE/beta-feedback.yml +++ b/.github/ISSUE_TEMPLATE/beta-feedback.yml @@ -43,4 +43,3 @@ body: options: - label: I did not include API keys, private page contents, account tokens, or confidential data. required: true - diff --git a/.github/ISSUE_TEMPLATE/provider-options.yml b/.github/ISSUE_TEMPLATE/provider-options.yml index 60cbb4b..6c3978e 100644 --- a/.github/ISSUE_TEMPLATE/provider-options.yml +++ b/.github/ISSUE_TEMPLATE/provider-options.yml @@ -76,4 +76,3 @@ body: options: - label: I did not include API keys, private page contents, account tokens, or confidential data. required: true - diff --git a/.github/ISSUE_TEMPLATE/website-translation.yml b/.github/ISSUE_TEMPLATE/website-translation.yml index d0a65f0..e7ededf 100644 --- a/.github/ISSUE_TEMPLATE/website-translation.yml +++ b/.github/ISSUE_TEMPLATE/website-translation.yml @@ -84,4 +84,3 @@ body: options: - label: I did not include API keys, private page contents, account tokens, or confidential data. required: true - diff --git a/.github/ISSUE_TEMPLATE/youtube-captions.yml b/.github/ISSUE_TEMPLATE/youtube-captions.yml index 62060a2..b6f628c 100644 --- a/.github/ISSUE_TEMPLATE/youtube-captions.yml +++ b/.github/ISSUE_TEMPLATE/youtube-captions.yml @@ -76,4 +76,3 @@ body: options: - label: I did not include API keys, private videos, account tokens, or confidential data. required: true - diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1ad1a5b..5a64802 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,6 +39,9 @@ jobs: - name: Lint run: pnpm lint + - name: Format check + run: pnpm fmt:check + - name: Test run: pnpm test diff --git a/.oxfmtrc.json b/.oxfmtrc.json new file mode 100644 index 0000000..da7b5b3 --- /dev/null +++ b/.oxfmtrc.json @@ -0,0 +1,6 @@ +{ + "$schema": "./node_modules/oxfmt/configuration_schema.json", + "printWidth": 100, + "trailingComma": "none", + "ignorePatterns": ["**/*.md", "apps/extension/test/fixtures/**"] +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 60dd627..4a3b7e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Upgraded TypeScript to 7.0 (native Go compiler), cutting `tsc --noEmit` from + ~4.7s to ~1.4s. Adjusted three `Element.matches("")` call sites where the + new lib.dom `this is` narrowing collapsed `HTMLElement` to `never`. +- Replaced ESLint + typescript-eslint with oxlint (type-aware linting via + `oxlint-tsgolint`), preserving the same rule set including + `no-floating-promises` and the `no-unsafe-*` family. Lint now runs in ~1s. +- Adopted oxfmt as the repository formatter (Prettier-compatible, + `printWidth: 100`, no trailing commas). Markdown files and extraction test + fixtures are excluded. CI now enforces `oxfmt --check`. + ## [0.3.7] - 2026-06-15 ### Fixed diff --git a/apps/extension/options.html b/apps/extension/options.html index bb4bbd9..ab52e56 100644 --- a/apps/extension/options.html +++ b/apps/extension/options.html @@ -33,16 +33,22 @@

Provider

- + - Presets switch the provider to OpenAI Compatible and fill the endpoint. + Presets switch the provider to OpenAI Compatible and fill the endpoint. @@ -60,7 +66,8 @@

Provider

API key - Paste the raw provider API key. Local OpenAI-compatible and Anthropic-compatible endpoints can leave this empty. + Paste the raw provider API key. Local OpenAI-compatible and Anthropic-compatible + endpoints can leave this empty. @@ -92,17 +104,28 @@

Target language

autocomplete="off" /> - + - Search by English name, native name, language code, or alias. + Search by English name, native name, language code, or alias. @@ -127,19 +158,22 @@

Target language

YouTube captions

- Margin currently translates existing YouTube caption tracks. If a video has no manual or auto-generated captions, speech-to-text is required before translation can work. + Margin currently translates existing YouTube caption tracks. If a video has no manual or + auto-generated captions, speech-to-text is required before translation can work.

Existing captions - Works with creator-provided captions and YouTube auto captions. These are translated by your selected translation provider. + Works with creator-provided captions and YouTube auto captions. These are translated + by your selected translation provider.
AI subtitles - Not available yet. Videos without YouTube captions will need an OpenAI or local speech-to-text provider before Margin can translate them. + Not available yet. Videos without YouTube captions will need an OpenAI or local + speech-to-text provider before Margin can translate them.
@@ -161,17 +195,28 @@

Display style

Translation marker - Prefix translated passages with a small marker in the target language. + Prefix translated passages with a small marker in the target language. -
+
Preview

The original stays close, so reading never turns into guesswork.

- - 譯文安靜地放在旁邊,但仍然清楚可辨。 + + 譯文安靜地放在旁邊,但仍然清楚可辨。

@@ -202,28 +250,36 @@

X optimization

Optimize X timelines - Translate only tweet text from X timeline cards by default. + Translate only tweet text from X timeline cards by default. @@ -236,7 +292,9 @@

Debug mode

Debug mode - Show page detection, queue, and provider error details in the popup. + Show page detection, queue, and provider error details in the popup. @@ -244,7 +302,9 @@

Debug mode

- +

diff --git a/apps/extension/package.json b/apps/extension/package.json index 3617fd1..b237723 100644 --- a/apps/extension/package.json +++ b/apps/extension/package.json @@ -1,8 +1,8 @@ { "name": "@margin/extension", "version": "0.3.7", - "description": "Privacy-first bilingual web translation that keeps the original text in place so you never lose context.", "private": true, + "description": "Privacy-first bilingual web translation that keeps the original text in place so you never lose context.", "type": "module", "scripts": { "dev": "vite", diff --git a/apps/extension/scripts/capture-fixture.mjs b/apps/extension/scripts/capture-fixture.mjs index d9ba4ce..eab16f9 100644 --- a/apps/extension/scripts/capture-fixture.mjs +++ b/apps/extension/scripts/capture-fixture.mjs @@ -40,7 +40,9 @@ await writeFile(expectedOutputPath, expectedSkeleton); console.log(`Captured fixture HTML: ${htmlOutputPath}`); console.log(`Created expectation skeleton: ${expectedOutputPath}`); -console.log("Review both files before committing. Remove personal data and fill expectedTexts/excludedTexts."); +console.log( + "Review both files before committing. Remove personal data and fill expectedTexts/excludedTexts." +); function parseArgs(values) { const parsed = {}; @@ -139,7 +141,9 @@ async function fetchStaticHtml(url) { async function captureRenderedHtml(url, options) { if (typeof WebSocket === "undefined") { - throw new Error("Rendered capture requires a Node.js runtime with a global WebSocket implementation."); + throw new Error( + "Rendered capture requires a Node.js runtime with a global WebSocket implementation." + ); } const endpoint = normalizeCdpEndpoint(options.cdpEndpoint); const target = await openCdpTarget(endpoint); @@ -151,9 +155,17 @@ async function captureRenderedHtml(url, options) { const loadEvent = client.waitForEvent("Page.loadEventFired", 30_000).catch(() => undefined); await client.send("Page.navigate", { url }); await loadEvent; - await waitForExpression(client, "document.readyState === 'complete' || document.readyState === 'interactive'", 15_000); + await waitForExpression( + client, + "document.readyState === 'complete' || document.readyState === 'interactive'", + 15_000 + ); if (options.waitSelector) { - await waitForExpression(client, `Boolean(document.querySelector(${JSON.stringify(options.waitSelector)}))`, 15_000); + await waitForExpression( + client, + `Boolean(document.querySelector(${JSON.stringify(options.waitSelector)}))`, + 15_000 + ); } if (options.waitMs > 0) { await delay(options.waitMs); @@ -163,7 +175,9 @@ async function captureRenderedHtml(url, options) { returnByValue: true }); if (result.exceptionDetails) { - throw new Error(`Failed to evaluate page HTML: ${result.exceptionDetails.text ?? "unknown exception"}`); + throw new Error( + `Failed to evaluate page HTML: ${result.exceptionDetails.text ?? "unknown exception"}` + ); } const value = result.result?.value; if (typeof value !== "string" || !value.trim()) { @@ -183,7 +197,9 @@ function normalizeCdpEndpoint(value) { async function openCdpTarget(endpoint) { const response = await fetch(`${endpoint}/json/new?about%3Ablank`, { method: "PUT" }); if (!response.ok) { - throw new Error(`Failed to create Chrome DevTools target: ${response.status} ${response.statusText}`); + throw new Error( + `Failed to create Chrome DevTools target: ${response.status} ${response.statusText}` + ); } const target = await response.json(); if (!target.id || !target.webSocketDebuggerUrl) { @@ -326,9 +342,7 @@ class CdpClient { function sanitizeHtml(html) { return normalizeWhitespace( - stripVolatileAttributes( - stripComments(stripUnsafeBlocks(extractBodyContent(html))) - ) + stripVolatileAttributes(stripComments(stripUnsafeBlocks(extractBodyContent(html)))) ); } @@ -342,7 +356,10 @@ function stripUnsafeBlocks(html) { .replace(//gi, "") .replace(//gi, "") .replace(//gi, "") - .replace(/]*class=(["'])[^"']*\bmargin-translation\b[^"']*\1[^>]*>[\s\S]*?<\/div>/gi, ""); + .replace( + /]*class=(["'])[^"']*\bmargin-translation\b[^"']*\1[^>]*>[\s\S]*?<\/div>/gi, + "" + ); } function stripComments(html) { @@ -352,13 +369,25 @@ function stripComments(html) { function stripVolatileAttributes(html) { return html .replace(/\sdata-margin-[a-z0-9-]+=(?:"[^"]*"|'[^']*')/gi, "") - .replace(/\s(?:on[a-z]+|nonce|integrity|crossorigin|referrerpolicy|srcset|sizes|style)="[^"]*"/gi, "") - .replace(/\s(?:on[a-z]+|nonce|integrity|crossorigin|referrerpolicy|srcset|sizes|style)='[^']*'/gi, "") - .replace(/\s(?:data-reactroot|data-reactid|data-nextjs-router|data-hydration-on-demand)="[^"]*"/gi, "") - .replace(/\s(href|src)="https?:\/\/([^/"?#]+)([^"?#]*)[^"]*"/gi, (_match, attribute, host, pathname) => { - const safePath = pathname && pathname !== "/" ? pathname : ""; - return ` ${attribute}="https://${host}${safePath}"`; - }); + .replace( + /\s(?:on[a-z]+|nonce|integrity|crossorigin|referrerpolicy|srcset|sizes|style)="[^"]*"/gi, + "" + ) + .replace( + /\s(?:on[a-z]+|nonce|integrity|crossorigin|referrerpolicy|srcset|sizes|style)='[^']*'/gi, + "" + ) + .replace( + /\s(?:data-reactroot|data-reactid|data-nextjs-router|data-hydration-on-demand)="[^"]*"/gi, + "" + ) + .replace( + /\s(href|src)="https?:\/\/([^/"?#]+)([^"?#]*)[^"]*"/gi, + (_match, attribute, host, pathname) => { + const safePath = pathname && pathname !== "/" ? pathname : ""; + return ` ${attribute}="https://${host}${safePath}"`; + } + ); } function normalizeWhitespace(html) { diff --git a/apps/extension/scripts/check-extension.mjs b/apps/extension/scripts/check-extension.mjs index b44ba7f..f494f2b 100644 --- a/apps/extension/scripts/check-extension.mjs +++ b/apps/extension/scripts/check-extension.mjs @@ -17,11 +17,20 @@ const BUNDLE_SIZE_BUDGETS = [ ]; assert(manifest.manifest_version === 3, "manifest_version must be 3."); -assert(!manifest.background || manifest.background.type === "module", "background service worker must be an ES module."); -assert(!manifest.content_security_policy, "custom content_security_policy is not allowed without security review."); +assert( + !manifest.background || manifest.background.type === "module", + "background service worker must be an ES module." +); +assert( + !manifest.content_security_policy, + "custom content_security_policy is not allowed without security review." +); assert(!hasRemoteScript(manifest), "manifest must not reference remote scripts."); assert(!usesDangerousPermission(manifest), "manifest uses a high-risk permission."); -assert(hasMinimalKnownPermissions(manifest), "manifest permissions changed; update the allowlist in check-extension.mjs."); +assert( + hasMinimalKnownPermissions(manifest), + "manifest permissions changed; update the allowlist in check-extension.mjs." +); const files = await listProjectFiles(root); for (const file of files) { @@ -32,13 +41,18 @@ for (const file of files) { const content = await readFile(file, "utf8"); assert(!/\beval\s*\(/.test(content), `${file} must not use dynamic code evaluation.`); assert(!/\bnew\s+Function\s*\(/.test(content), `${file} must not construct executable strings.`); - assert(!/]+src=["']https?:\/\//i.test(content), `${file} must not load remote scripts.`); + assert( + !/]+src=["']https?:\/\//i.test(content), + `${file} must not load remote scripts.` + ); assert(!containsProviderApiKey(content), `${file} appears to contain a provider API key.`); } function containsProviderApiKey(content) { const googlePrefix = "AI" + "za"; - return content.includes("sk-ant-") || content.includes("sk-proj-") || content.includes(googlePrefix); + return ( + content.includes("sk-ant-") || content.includes("sk-proj-") || content.includes(googlePrefix) + ); } await checkBundleSizes(); @@ -99,16 +113,29 @@ function hasRemoteScript(value) { } function usesDangerousPermission(value) { - const permissions = new Set([...(value.permissions ?? []), ...(value.optional_permissions ?? [])]); - const dangerous = ["tabs", "cookies", "webRequest", "webRequestBlocking", "management", "scripting", "debugger"]; + const permissions = new Set([ + ...(value.permissions ?? []), + ...(value.optional_permissions ?? []) + ]); + const dangerous = [ + "tabs", + "cookies", + "webRequest", + "webRequestBlocking", + "management", + "scripting", + "debugger" + ]; return dangerous.some((permission) => permissions.has(permission)); } function hasMinimalKnownPermissions(value) { const permissions = [...(value.permissions ?? [])].sort(); const hostPermissions = [...(value.host_permissions ?? [])].sort(); - return JSON.stringify(permissions) === JSON.stringify(["activeTab", "storage"]) && - JSON.stringify(hostPermissions) === JSON.stringify([""]); + return ( + JSON.stringify(permissions) === JSON.stringify(["activeTab", "storage"]) && + JSON.stringify(hostPermissions) === JSON.stringify([""]) + ); } async function listProjectFiles(directory) { @@ -123,7 +150,7 @@ async function listProjectFiles(directory) { const path = join(directory, entry); const info = await stat(path); if (info.isDirectory()) { - files.push(...await listProjectFiles(path)); + files.push(...(await listProjectFiles(path))); continue; } diff --git a/apps/extension/scripts/check-release-readiness.mjs b/apps/extension/scripts/check-release-readiness.mjs index c5f7d83..d681b83 100644 --- a/apps/extension/scripts/check-release-readiness.mjs +++ b/apps/extension/scripts/check-release-readiness.mjs @@ -24,7 +24,10 @@ const sourceManifest = await readJson(sourceManifestPath); const version = rootPackage.version; const artifactPath = join(artifactsDir, `margin-read-v${version}.zip`); -const sourceMessages = await readLocaleMessages(join(root, "public/_locales"), sourceManifest.default_locale); +const sourceMessages = await readLocaleMessages( + join(root, "public/_locales"), + sourceManifest.default_locale +); checkVersionConsistency(); await checkChangelog(); @@ -63,18 +66,30 @@ if (warnings.length > 0) { function checkVersionConsistency() { assert(isSemver(version), `root package version must be a semver version. Got ${version}.`); - assert(!("version" in sourceManifest), "source manifest must not pin a version; it is injected at build from package.json."); - assert(extensionPackage.version === version, `extension package version must match root package version ${version}.`); + assert( + !("version" in sourceManifest), + "source manifest must not pin a version; it is injected at build from package.json." + ); + assert( + extensionPackage.version === version, + `extension package version must match root package version ${version}.` + ); const refName = process.env.GITHUB_REF_NAME; if (refName?.startsWith("v")) { - assert(refName === `v${version}`, `release tag ${refName} must match manifest version v${version}.`); + assert( + refName === `v${version}`, + `release tag ${refName} must match manifest version v${version}.` + ); } } async function checkChangelog() { const changelog = await readFile(changelogPath, "utf8"); - assert(changelog.includes(`## [${version}]`), `CHANGELOG.md must contain a section for ${version}.`); + assert( + changelog.includes(`## [${version}]`), + `CHANGELOG.md must contain a section for ${version}.` + ); assert( /\n## \[Unreleased\]\n/.test(changelog), "CHANGELOG.md must keep an [Unreleased] section." @@ -100,10 +115,19 @@ function checkSourceManifest(manifest, label, name, description) { description === rootPackage.description && description === extensionPackage.description, `${label} description must match package descriptions.` ); - assert(!manifest.content_security_policy, `${label} must not define a custom content_security_policy.`); - assert(!jsonContainsRemoteCodeReference(manifest), `${label} must not reference remote scripts or styles.`); + assert( + !manifest.content_security_policy, + `${label} must not define a custom content_security_policy.` + ); + assert( + !jsonContainsRemoteCodeReference(manifest), + `${label} must not reference remote scripts or styles.` + ); assert(!usesDangerousPermission(manifest), `${label} must not request high-risk permissions.`); - assert(hasExpectedPermissions(manifest), `${label} permissions changed; update release readiness allowlist deliberately.`); + assert( + hasExpectedPermissions(manifest), + `${label} permissions changed; update release readiness allowlist deliberately.` + ); assert( manifest.background?.type === "module", `${label} background service worker must be an ES module.` @@ -121,7 +145,10 @@ async function checkArtifact() { const artifact = await stat(artifactPath); assert(artifact.size > 0, `${relative(repoRoot, artifactPath)} must not be empty.`); - assert(artifact.size < 10 * 1024 * 1024, `${relative(repoRoot, artifactPath)} should stay under 10 MiB.`); + assert( + artifact.size < 10 * 1024 * 1024, + `${relative(repoRoot, artifactPath)} should stay under 10 MiB.` + ); const entries = listZipEntries(artifactPath); if (entries.length === 0) { @@ -142,8 +169,12 @@ async function checkArtifact() { // generated manifest is validated on its own merits below (MV3, name, permissions // allowlist, no custom CSP, no remote code, required files present). const { version: zipVersion } = zipManifest; - assert(zipVersion === version, `artifact manifest version must be ${version}. Got ${zipVersion}.`); - const zipMessages = readZipJson(artifactPath, `_locales/${zipManifest.default_locale}/messages.json`) ?? {}; + assert( + zipVersion === version, + `artifact manifest version must be ${version}. Got ${zipVersion}.` + ); + const zipMessages = + readZipJson(artifactPath, `_locales/${zipManifest.default_locale}/messages.json`) ?? {}; checkSourceManifest( zipManifest, "artifact manifest", @@ -192,19 +223,37 @@ function checkZipContents(entries) { const entry = normalizeZipEntry(rawEntry); assert(entry === rawEntry, `zip entry ${rawEntry} must use normalized relative paths.`); assert(!entry.startsWith("/"), `zip entry ${entry} must not be absolute.`); - assert(!entry.includes("../"), `zip entry ${entry} must not contain parent-directory traversal.`); + assert( + !entry.includes("../"), + `zip entry ${entry} must not contain parent-directory traversal.` + ); assert(!entry.startsWith("node_modules/"), `zip entry ${entry} must not include dependencies.`); assert(!entry.startsWith("src/"), `zip entry ${entry} must not include source files.`); - assert(!entry.startsWith("test/") && !entry.startsWith("tests/"), `zip entry ${entry} must not include tests.`); + assert( + !entry.startsWith("test/") && !entry.startsWith("tests/"), + `zip entry ${entry} must not include tests.` + ); assert(!entry.includes("__fixtures__/"), `zip entry ${entry} must not include test fixtures.`); - assert(!entry.startsWith(".github/"), `zip entry ${entry} must not include GitHub workflow files.`); + assert( + !entry.startsWith(".github/"), + `zip entry ${entry} must not include GitHub workflow files.` + ); assert(!entry.startsWith("docs/"), `zip entry ${entry} must not include repository docs.`); - assert(!entry.startsWith("artifacts/"), `zip entry ${entry} must not include nested artifacts.`); + assert( + !entry.startsWith("artifacts/"), + `zip entry ${entry} must not include nested artifacts.` + ); assert(!entry.endsWith(".map"), `zip entry ${entry} must not include source maps.`); assert(!/\.(?:ts|tsx)$/.test(entry), `zip entry ${entry} must not include TypeScript source.`); - assert(!/(^|\/)\.env(?:\.|$)/.test(entry), `zip entry ${entry} must not include environment files.`); + assert( + !/(^|\/)\.env(?:\.|$)/.test(entry), + `zip entry ${entry} must not include environment files.` + ); assert(!/(^|\/)\.DS_Store$/.test(entry), `zip entry ${entry} must not include macOS metadata.`); - assert(!/(^|\/)package-lock\.json$/.test(entry), `zip entry ${entry} must not include lockfiles.`); + assert( + !/(^|\/)package-lock\.json$/.test(entry), + `zip entry ${entry} must not include lockfiles.` + ); assert(!/(^|\/)pnpm-lock\.yaml$/.test(entry), `zip entry ${entry} must not include lockfiles.`); } } @@ -218,7 +267,10 @@ function checkZipTextContents(entries) { const content = readZipText(artifactPath, entry); assert(!containsProviderApiKey(content), `${entry} appears to contain a provider API key.`); if (entry !== "manifest.json") { - assert(!/]+src=["']https?:\/\//i.test(content), `${entry} must not load remote scripts.`); + assert( + !/]+src=["']https?:\/\//i.test(content), + `${entry} must not load remote scripts.` + ); } } } @@ -246,9 +298,14 @@ function readZipJson(path, entry) { } function readZipText(path, entry) { - const result = spawnSync("unzip", ["-p", path, entry], { encoding: "utf8", maxBuffer: 20 * 1024 * 1024 }); + const result = spawnSync("unzip", ["-p", path, entry], { + encoding: "utf8", + maxBuffer: 20 * 1024 * 1024 + }); if (result.status !== 0) { - fail(result.stderr || result.stdout || `Failed to read ${entry} from ${relative(repoRoot, path)}.`); + fail( + result.stderr || result.stdout || `Failed to read ${entry} from ${relative(repoRoot, path)}.` + ); return ""; } return result.stdout; @@ -305,13 +362,26 @@ function isSemver(value) { function hasExpectedPermissions(value) { const permissions = [...(value.permissions ?? [])].sort(); const hostPermissions = [...(value.host_permissions ?? [])].sort(); - return JSON.stringify(permissions) === JSON.stringify(["activeTab", "storage"]) && - JSON.stringify(hostPermissions) === JSON.stringify([""]); + return ( + JSON.stringify(permissions) === JSON.stringify(["activeTab", "storage"]) && + JSON.stringify(hostPermissions) === JSON.stringify([""]) + ); } function usesDangerousPermission(value) { - const permissions = new Set([...(value.permissions ?? []), ...(value.optional_permissions ?? [])]); - const dangerous = ["tabs", "cookies", "webRequest", "webRequestBlocking", "management", "scripting", "debugger"]; + const permissions = new Set([ + ...(value.permissions ?? []), + ...(value.optional_permissions ?? []) + ]); + const dangerous = [ + "tabs", + "cookies", + "webRequest", + "webRequestBlocking", + "management", + "scripting", + "debugger" + ]; return dangerous.some((permission) => permissions.has(permission)); } @@ -324,5 +394,9 @@ function containsProviderApiKey(content) { const googlePrefix = "AI" + "za"; const anthropicPrefix = "sk-" + "ant-"; const openAiProjectPrefix = "sk-" + "proj-"; - return content.includes(anthropicPrefix) || content.includes(openAiProjectPrefix) || content.includes(googlePrefix); + return ( + content.includes(anthropicPrefix) || + content.includes(openAiProjectPrefix) || + content.includes(googlePrefix) + ); } diff --git a/apps/extension/scripts/check-release-version.mjs b/apps/extension/scripts/check-release-version.mjs index 1a9c439..0ae22da 100644 --- a/apps/extension/scripts/check-release-version.mjs +++ b/apps/extension/scripts/check-release-version.mjs @@ -12,7 +12,10 @@ const failures = []; // The manifest version is injected at build from package.json, so only the // package.json files are checked against the release tag here. assert(rootPackage.version === expectedVersion, `root package version must be ${expectedVersion}.`); -assert(extensionPackage.version === expectedVersion, `extension package version must be ${expectedVersion}.`); +assert( + extensionPackage.version === expectedVersion, + `extension package version must be ${expectedVersion}.` +); if (failures.length > 0) { console.error("Release version check failed:"); diff --git a/apps/extension/scripts/package-extension.mjs b/apps/extension/scripts/package-extension.mjs index 5e1185a..cd501ce 100644 --- a/apps/extension/scripts/package-extension.mjs +++ b/apps/extension/scripts/package-extension.mjs @@ -14,17 +14,31 @@ const packageName = `margin-read-v${version}.zip`; const outputPath = join(artifacts, packageName); const failures = []; -const name = await resolveManifestMessage(manifest.name, join(dist, "_locales"), manifest.default_locale); +const name = await resolveManifestMessage( + manifest.name, + join(dist, "_locales"), + manifest.default_locale +); assert(name.startsWith("Margin Read"), "dist manifest name must start with Margin Read."); -const description = await resolveManifestMessage(manifest.description, join(dist, "_locales"), manifest.default_locale); -assert(description.includes("Privacy-first"), "dist manifest description must include Privacy-first."); +const description = await resolveManifestMessage( + manifest.description, + join(dist, "_locales"), + manifest.default_locale +); +assert( + description.includes("Privacy-first"), + "dist manifest description must include Privacy-first." +); const files = await listFiles(dist); for (const file of files) { const name = relative(dist, file); assert(!name.startsWith("node_modules/"), `${name} must not include dependencies.`); assert(!name.includes(".env"), `${name} must not include environment files.`); - assert(!/\.(?:ts|tsx|test\.[cm]?js|test\.ts)$/.test(name), `${name} must not include source or tests.`); + assert( + !/\.(?:ts|tsx|test\.[cm]?js|test\.ts)$/.test(name), + `${name} must not include source or tests.` + ); if (/\.(?:js|json|html|css)$/.test(name)) { const content = await readFile(file, "utf8"); @@ -86,7 +100,9 @@ async function resolveManifestMessage(value, localesDir, defaultLocale) { if (!match) { return value ?? ""; } - const messages = JSON.parse(await readFile(join(localesDir, defaultLocale, "messages.json"), "utf8")); + const messages = JSON.parse( + await readFile(join(localesDir, defaultLocale, "messages.json"), "utf8") + ); return messages[match[1]]?.message ?? ""; } @@ -94,7 +110,11 @@ function containsProviderApiKey(content) { const googlePrefix = "AI" + "za"; const anthropicPrefix = "sk-" + "ant-"; const openAiProjectPrefix = "sk-" + "proj-"; - return content.includes(anthropicPrefix) || content.includes(openAiProjectPrefix) || content.includes(googlePrefix); + return ( + content.includes(anthropicPrefix) || + content.includes(openAiProjectPrefix) || + content.includes(googlePrefix) + ); } function formatBytes(bytes) { @@ -115,7 +135,7 @@ async function listFiles(directory) { const path = join(directory, entry); const info = await stat(path); if (info.isDirectory()) { - files.push(...await listFiles(path)); + files.push(...(await listFiles(path))); continue; } files.push(path); diff --git a/apps/extension/src/background/providers/anthropic.test.ts b/apps/extension/src/background/providers/anthropic.test.ts index 511e680..1de5814 100644 --- a/apps/extension/src/background/providers/anthropic.test.ts +++ b/apps/extension/src/background/providers/anthropic.test.ts @@ -22,7 +22,8 @@ function stubFetch(response: Response): { fetch: typeof fetch; calls: FetchCall[ const calls: FetchCall[] = []; const fakeFetch = vi.fn((input: RequestInfo | URL, init: RequestInit = {}) => { const rawBody = init.body; - const body = typeof rawBody === "string" ? (JSON.parse(rawBody) as Record) : {}; + const body = + typeof rawBody === "string" ? (JSON.parse(rawBody) as Record) : {}; calls.push({ url: stringifyUrl(input), init, body }); return Promise.resolve(response); }); @@ -63,7 +64,10 @@ describe("anthropicProvider.translate", () => { const results = await anthropicProvider.translate( segments, - makeSettings({ providerEndpoint: "https://api.anthropic.com/v1/messages", model: "claude-3-5-haiku-latest" }) + makeSettings({ + providerEndpoint: "https://api.anthropic.com/v1/messages", + model: "claude-3-5-haiku-latest" + }) ); expect(results).toEqual([{ id: "a", text: "你好" }]); @@ -89,7 +93,9 @@ describe("anthropicProvider.translate", () => { }); it("throws when content is empty", async () => { - const { fetch: stub } = stubFetch(new Response(JSON.stringify({ content: [] }), { status: 200 })); + const { fetch: stub } = stubFetch( + new Response(JSON.stringify({ content: [] }), { status: 200 }) + ); vi.stubGlobal("fetch", stub); await expect(anthropicProvider.translate(segments, makeSettings())).rejects.toThrow( diff --git a/apps/extension/src/background/providers/anthropic.ts b/apps/extension/src/background/providers/anthropic.ts index 43ae1d0..389ea2d 100644 --- a/apps/extension/src/background/providers/anthropic.ts +++ b/apps/extension/src/background/providers/anthropic.ts @@ -1,6 +1,15 @@ -import type { Message, MessageCreateParamsNonStreaming, Tool } from "@anthropic-ai/sdk/resources/messages/messages"; +import type { + Message, + MessageCreateParamsNonStreaming, + Tool +} from "@anthropic-ai/sdk/resources/messages/messages"; import type { ModelInfo } from "@anthropic-ai/sdk/resources/models"; -import type { ExtensionSettings, ProviderModel, TextSegment, TranslationResult } from "../../shared/types"; +import type { + ExtensionSettings, + ProviderModel, + TextSegment, + TranslationResult +} from "../../shared/types"; import { assertProviderResponse, buildTranslationPayload, @@ -95,7 +104,10 @@ async function listAnthropicModels(settings: ExtensionSettings): Promise typeof model.id === "string" && model.id.length > 0 ) - .map((model) => ({ id: model.id, ...(model.display_name ? { displayName: model.display_name } : {}) })); + .map((model) => ({ + id: model.id, + ...(model.display_name ? { displayName: model.display_name } : {}) + })); } function buildAnthropicHeaders(settings: ExtensionSettings): Record { diff --git a/apps/extension/src/background/providers/google.test.ts b/apps/extension/src/background/providers/google.test.ts index 66a39cf..7126e34 100644 --- a/apps/extension/src/background/providers/google.test.ts +++ b/apps/extension/src/background/providers/google.test.ts @@ -29,7 +29,8 @@ function stubFetch(response: Response): { fetch: typeof fetch; calls: FetchCall[ const calls: FetchCall[] = []; const fakeFetch = vi.fn((input: RequestInfo | URL, init: RequestInit = {}) => { const rawBody = init.body; - const body = typeof rawBody === "string" ? (JSON.parse(rawBody) as Record) : {}; + const body = + typeof rawBody === "string" ? (JSON.parse(rawBody) as Record) : {}; calls.push({ url: stringifyUrl(input), init, body }); return Promise.resolve(response); }); @@ -96,14 +97,18 @@ describe("googleProvider.translate", () => { expect(generationConfig.responseJsonSchema.type).toBe("object"); expect(generationConfig.responseJsonSchema.properties).toHaveProperty("translations"); expect(generationConfig.responseJsonSchema.additionalProperties).toBe(false); - expect(generationConfig.responseJsonSchema.properties.translations.items.additionalProperties).toBe(false); - expect((calls[0].body.systemInstruction as { parts: Array<{ text: string }> }).parts[0].text).toContain( - "Return only valid JSON" - ); + expect( + generationConfig.responseJsonSchema.properties.translations.items.additionalProperties + ).toBe(false); + expect( + (calls[0].body.systemInstruction as { parts: Array<{ text: string }> }).parts[0].text + ).toContain("Return only valid JSON"); }); it("URL-encodes special characters in model name and api key", async () => { - const body = JSON.stringify({ candidates: [{ content: { parts: [{ text: '{"translations":[]}' }] } }] }); + const body = JSON.stringify({ + candidates: [{ content: { parts: [{ text: '{"translations":[]}' }] } }] + }); const { fetch: stub, calls } = stubFetch(new Response(body, { status: 200 })); vi.stubGlobal("fetch", stub); @@ -117,7 +122,9 @@ describe("googleProvider.translate", () => { }); it("trims a trailing slash from the configured endpoint", async () => { - const body = JSON.stringify({ candidates: [{ content: { parts: [{ text: '{"translations":[]}' }] } }] }); + const body = JSON.stringify({ + candidates: [{ content: { parts: [{ text: '{"translations":[]}' }] } }] + }); const { fetch: stub, calls } = stubFetch(new Response(body, { status: 200 })); vi.stubGlobal("fetch", stub); @@ -130,10 +137,14 @@ describe("googleProvider.translate", () => { }); it("throws when no candidate text is returned", async () => { - const { fetch: stub } = stubFetch(new Response(JSON.stringify({ candidates: [] }), { status: 200 })); + const { fetch: stub } = stubFetch( + new Response(JSON.stringify({ candidates: [] }), { status: 200 }) + ); vi.stubGlobal("fetch", stub); - await expect(googleProvider.translate(segments, makeSettings())).rejects.toThrow(/did not include translated content/); + await expect(googleProvider.translate(segments, makeSettings())).rejects.toThrow( + /did not include translated content/ + ); }); it("propagates non-2xx errors with provider status", async () => { @@ -148,9 +159,21 @@ describe("googleProvider.listModels", () => { it("strips models/ prefix and filters by generateContent support", async () => { const body = JSON.stringify({ models: [ - { name: "models/gemini-1.5-flash", displayName: "Gemini 1.5 Flash", supportedGenerationMethods: ["generateContent"] }, - { name: "models/gemini-1.5-pro", displayName: "Gemini 1.5 Pro", supportedGenerationMethods: ["generateContent", "countTokens"] }, - { name: "models/embedding-001", displayName: "Embedding 001", supportedGenerationMethods: ["embedContent"] }, + { + name: "models/gemini-1.5-flash", + displayName: "Gemini 1.5 Flash", + supportedGenerationMethods: ["generateContent"] + }, + { + name: "models/gemini-1.5-pro", + displayName: "Gemini 1.5 Pro", + supportedGenerationMethods: ["generateContent", "countTokens"] + }, + { + name: "models/embedding-001", + displayName: "Embedding 001", + supportedGenerationMethods: ["embedContent"] + }, { name: "" }, { displayName: "no name" } ] diff --git a/apps/extension/src/background/providers/google.ts b/apps/extension/src/background/providers/google.ts index cc72ca4..103d7da 100644 --- a/apps/extension/src/background/providers/google.ts +++ b/apps/extension/src/background/providers/google.ts @@ -1,5 +1,10 @@ import type { Content, GenerateContentResponse, GenerationConfig, Model } from "@google/genai"; -import type { ExtensionSettings, ProviderModel, TextSegment, TranslationResult } from "../../shared/types"; +import type { + ExtensionSettings, + ProviderModel, + TextSegment, + TranslationResult +} from "../../shared/types"; import { assertProviderResponse, buildTranslationPayload, @@ -76,7 +81,9 @@ async function listGoogleModels(settings: ExtensionSettings): Promise + ( + model + ): model is { name: string; displayName?: string; supportedGenerationMethods?: string[] } => typeof model.name === "string" && model.name.length > 0 && (model.supportedGenerationMethods?.includes("generateContent") ?? true) diff --git a/apps/extension/src/background/providers/index.test.ts b/apps/extension/src/background/providers/index.test.ts index 3358bc5..0fe243f 100644 --- a/apps/extension/src/background/providers/index.test.ts +++ b/apps/extension/src/background/providers/index.test.ts @@ -3,17 +3,22 @@ import type { TranslationProviderId } from "../../shared/types"; import { getProvider } from "./index"; describe("getProvider", () => { - it.each(["openai", "openai-compatible", "anthropic", "anthropic-compatible", "google"])( - "returns a provider whose id matches the requested id (%s)", - (id) => { - const provider = getProvider(id); - expect(provider.id).toBe(id); - expect(typeof provider.translate).toBe("function"); - expect(typeof provider.listModels).toBe("function"); - } - ); + it.each([ + "openai", + "openai-compatible", + "anthropic", + "anthropic-compatible", + "google" + ])("returns a provider whose id matches the requested id (%s)", (id) => { + const provider = getProvider(id); + expect(provider.id).toBe(id); + expect(typeof provider.translate).toBe("function"); + expect(typeof provider.listModels).toBe("function"); + }); it("throws on an unknown provider id", () => { - expect(() => getProvider("unknown" as TranslationProviderId)).toThrow(/Unsupported translation provider/); + expect(() => getProvider("unknown" as TranslationProviderId)).toThrow( + /Unsupported translation provider/ + ); }); }); diff --git a/apps/extension/src/background/providers/openai.test.ts b/apps/extension/src/background/providers/openai.test.ts index 4e9b656..da6e719 100644 --- a/apps/extension/src/background/providers/openai.test.ts +++ b/apps/extension/src/background/providers/openai.test.ts @@ -22,7 +22,8 @@ function stubFetch(response: Response): { fetch: typeof fetch; calls: FetchCall[ const calls: FetchCall[] = []; const fakeFetch = vi.fn((input: RequestInfo | URL, init: RequestInit = {}) => { const rawBody = init.body; - const body = typeof rawBody === "string" ? (JSON.parse(rawBody) as Record) : {}; + const body = + typeof rawBody === "string" ? (JSON.parse(rawBody) as Record) : {}; calls.push({ url: stringifyUrl(input), init, body }); return Promise.resolve(response); }); @@ -82,21 +83,23 @@ describe("openaiProvider.translate", () => { }); it("throws when provider returns no content", async () => { - const { fetch: stub } = stubFetch(new Response(JSON.stringify({ choices: [{}] }), { status: 200 })); + const { fetch: stub } = stubFetch( + new Response(JSON.stringify({ choices: [{}] }), { status: 200 }) + ); vi.stubGlobal("fetch", stub); - await expect(openaiProvider.translate(segments, makeSettings({ provider: "openai", apiKey: "sk-test" }))).rejects.toThrow( - /did not include translated content/ - ); + await expect( + openaiProvider.translate(segments, makeSettings({ provider: "openai", apiKey: "sk-test" })) + ).rejects.toThrow(/did not include translated content/); }); it("propagates non-2xx errors with provider status", async () => { const { fetch: stub } = stubFetch(new Response("unauthorized", { status: 401 })); vi.stubGlobal("fetch", stub); - await expect(openaiProvider.translate(segments, makeSettings({ provider: "openai", apiKey: "bad" }))).rejects.toThrow( - /401/ - ); + await expect( + openaiProvider.translate(segments, makeSettings({ provider: "openai", apiKey: "bad" })) + ).rejects.toThrow(/401/); }); }); @@ -174,7 +177,9 @@ describe("openaiProvider.listModels", () => { }); it("preserves the host but rewrites pathname for compatible endpoints", async () => { - const { fetch: stub, calls } = stubFetch(new Response(JSON.stringify({ data: [] }), { status: 200 })); + const { fetch: stub, calls } = stubFetch( + new Response(JSON.stringify({ data: [] }), { status: 200 }) + ); vi.stubGlobal("fetch", stub); await openaiCompatibleProvider.listModels( diff --git a/apps/extension/src/background/providers/openai.ts b/apps/extension/src/background/providers/openai.ts index 460bed7..e51458c 100644 --- a/apps/extension/src/background/providers/openai.ts +++ b/apps/extension/src/background/providers/openai.ts @@ -1,6 +1,14 @@ -import type { ChatCompletion, ChatCompletionCreateParamsNonStreaming } from "openai/resources/chat/completions"; +import type { + ChatCompletion, + ChatCompletionCreateParamsNonStreaming +} from "openai/resources/chat/completions"; import type { Model } from "openai/resources/models"; -import type { ExtensionSettings, ProviderModel, TextSegment, TranslationResult } from "../../shared/types"; +import type { + ExtensionSettings, + ProviderModel, + TextSegment, + TranslationResult +} from "../../shared/types"; import { assertProviderResponse, buildTranslationPayload, @@ -81,9 +89,9 @@ function buildOpenAIHeaders(settings: ExtensionSettings): Record }; } -function buildResponseFormat( - settings: ExtensionSettings -): { response_format?: OpenAIRequestBody["response_format"] } { +function buildResponseFormat(settings: ExtensionSettings): { + response_format?: OpenAIRequestBody["response_format"]; +} { if (settings.provider === "openai") { return { response_format: { diff --git a/apps/extension/src/background/providers/shared.test.ts b/apps/extension/src/background/providers/shared.test.ts index 69f27fe..980fa22 100644 --- a/apps/extension/src/background/providers/shared.test.ts +++ b/apps/extension/src/background/providers/shared.test.ts @@ -38,7 +38,10 @@ function decodePayload(json: string): TranslationPayload { describe("buildTranslationPayload", () => { it("encodes target and source language alongside segments", () => { const payload = decodePayload( - buildTranslationPayload(segments, makeSettings({ sourceLanguage: "English", targetLanguage: "繁體中文" })) + buildTranslationPayload( + segments, + makeSettings({ sourceLanguage: "English", targetLanguage: "繁體中文" }) + ) ); expect(payload).toMatchObject({ @@ -49,19 +52,25 @@ describe("buildTranslationPayload", () => { }); it("normalizes 'auto' to a descriptive label", () => { - const payload = decodePayload(buildTranslationPayload(segments, makeSettings({ sourceLanguage: "auto" }))); + const payload = decodePayload( + buildTranslationPayload(segments, makeSettings({ sourceLanguage: "auto" })) + ); expect(payload.sourceLanguage).toBe("auto-detected source language"); }); it("treats mixed-case 'AUTO' as auto", () => { - const payload = decodePayload(buildTranslationPayload(segments, makeSettings({ sourceLanguage: " AUTO " }))); + const payload = decodePayload( + buildTranslationPayload(segments, makeSettings({ sourceLanguage: " AUTO " })) + ); expect(payload.sourceLanguage).toBe("auto-detected source language"); }); it("trims explicit source language values", () => { - const payload = decodePayload(buildTranslationPayload(segments, makeSettings({ sourceLanguage: " Japanese " }))); + const payload = decodePayload( + buildTranslationPayload(segments, makeSettings({ sourceLanguage: " Japanese " })) + ); expect(payload.sourceLanguage).toBe("Japanese"); }); diff --git a/apps/extension/src/background/providers/shared.ts b/apps/extension/src/background/providers/shared.ts index 2957933..9797d4c 100644 --- a/apps/extension/src/background/providers/shared.ts +++ b/apps/extension/src/background/providers/shared.ts @@ -4,7 +4,10 @@ import type { ExtensionSettings, TextSegment, TranslationResult } from "../../sh export const TRANSLATION_SYSTEM_PROMPT = "You are a translation engine for a browser extension. Return only valid JSON. Preserve meaning, names, URLs, code-like tokens, and formatting where practical."; -export function buildTranslationPayload(segments: TextSegment[], settings: ExtensionSettings): string { +export function buildTranslationPayload( + segments: TextSegment[], + settings: ExtensionSettings +): string { const source = settings.sourceLanguage.trim().toLowerCase() === "auto" ? "auto-detected source language" @@ -63,7 +66,10 @@ export function parseTranslations(content: string, segments: TextSegment[]): Tra export function validateTranslations(value: unknown, segments: TextSegment[]): TranslationResult[] { const translations = - typeof value === "object" && value !== null && "translations" in value && Array.isArray(value.translations) + typeof value === "object" && + value !== null && + "translations" in value && + Array.isArray(value.translations) ? value.translations : []; const validIds = new Set(segments.map((segment) => segment.id)); diff --git a/apps/extension/src/background/serviceWorker.test.ts b/apps/extension/src/background/serviceWorker.test.ts index f9c0d18..7b0a553 100644 --- a/apps/extension/src/background/serviceWorker.test.ts +++ b/apps/extension/src/background/serviceWorker.test.ts @@ -1,7 +1,17 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { CACHE_KEY_PREFIX, DEFAULT_SETTINGS, PROVIDER_DEFAULTS, SETTINGS_KEY } from "../shared/defaults"; +import { + CACHE_KEY_PREFIX, + DEFAULT_SETTINGS, + PROVIDER_DEFAULTS, + SETTINGS_KEY +} from "../shared/defaults"; import type { LocalTranslationProviderId } from "../shared/localProviders"; -import type { ExtensionSettings, RuntimeMessage, TextSegment, TranslationResult } from "../shared/types"; +import type { + ExtensionSettings, + RuntimeMessage, + TextSegment, + TranslationResult +} from "../shared/types"; import type * as ServiceWorkerModuleType from "./serviceWorker"; type ServiceWorkerModule = typeof ServiceWorkerModuleType; @@ -45,7 +55,8 @@ type MessageListener = ( const storageStore = new Map(); const fetchCalls: FetchCall[] = []; -let fetchResponder: (url: string, init: RequestInit) => Response | Promise = () => new Response("noop"); +let fetchResponder: (url: string, init: RequestInit) => Response | Promise = () => + new Response("noop"); let registeredListener: MessageListener | undefined; async function loadModule(): Promise { @@ -101,7 +112,8 @@ beforeEach(() => { }); const fakeFetch = vi.fn((input: RequestInfo | URL, init: RequestInit = {}) => { - const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + const url = + typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; fetchCalls.push({ url, init }); return Promise.resolve(fetchResponder(url, init)); }); @@ -117,7 +129,10 @@ function saveSettings(overrides: Partial): void { } function jsonResponse(body: unknown, status = 200): Response { - return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } }); + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" } + }); } describe("handleMessage GET_SETTINGS", () => { @@ -176,7 +191,10 @@ describe("handleMessage TRANSLATE_BATCH", () => { saveSettings({ provider: "openai", apiKey: "" }); const { handleMessage } = await loadModule(); - const result = (await handleMessage({ type: "TRANSLATE_BATCH", segments })) as TranslateBatchResult; + const result = (await handleMessage({ + type: "TRANSLATE_BATCH", + segments + })) as TranslateBatchResult; expect(result.ok).toBe(false); expect(result.error).toMatch(/Configure an API key/); @@ -199,7 +217,10 @@ describe("handleMessage TRANSLATE_BATCH", () => { } const { handleMessage } = await loadModule(); - const result = (await handleMessage({ type: "TRANSLATE_BATCH", segments })) as TranslateBatchResult; + const result = (await handleMessage({ + type: "TRANSLATE_BATCH", + segments + })) as TranslateBatchResult; expect(result.ok).toBe(true); expect(result.results).toEqual([{ id: "s1", text: "你好" }]); @@ -207,11 +228,19 @@ describe("handleMessage TRANSLATE_BATCH", () => { ); it("dispatches through the registry and caches results in session and persistent stores", async () => { - saveSettings({ provider: "openai", apiKey: "sk-test", cacheMode: "persistent", targetLanguage: "繁體中文" }); + saveSettings({ + provider: "openai", + apiKey: "sk-test", + cacheMode: "persistent", + targetLanguage: "繁體中文" + }); stubOpenAIResponse([{ id: "s1", text: "你好" }]); const { handleMessage } = await loadModule(); - const first = (await handleMessage({ type: "TRANSLATE_BATCH", segments })) as TranslateBatchResult; + const first = (await handleMessage({ + type: "TRANSLATE_BATCH", + segments + })) as TranslateBatchResult; expect(first.results).toEqual([{ id: "s1", text: "你好" }]); expect(fetchCalls).toHaveLength(1); @@ -219,11 +248,16 @@ describe("handleMessage TRANSLATE_BATCH", () => { throw new Error("should not refetch on cache hit"); }; - const second = (await handleMessage({ type: "TRANSLATE_BATCH", segments })) as TranslateBatchResult; + const second = (await handleMessage({ + type: "TRANSLATE_BATCH", + segments + })) as TranslateBatchResult; expect(second.results).toEqual([{ id: "s1", text: "你好" }]); expect(fetchCalls).toHaveLength(1); - const persistentKeys = Array.from(storageStore.keys()).filter((key) => key.startsWith(CACHE_KEY_PREFIX)); + const persistentKeys = Array.from(storageStore.keys()).filter((key) => + key.startsWith(CACHE_KEY_PREFIX) + ); expect(persistentKeys).toHaveLength(1); }); @@ -234,7 +268,9 @@ describe("handleMessage TRANSLATE_BATCH", () => { await handleMessage({ type: "TRANSLATE_BATCH", segments }); - const persistentKeys = Array.from(storageStore.keys()).filter((key) => key.startsWith(CACHE_KEY_PREFIX)); + const persistentKeys = Array.from(storageStore.keys()).filter((key) => + key.startsWith(CACHE_KEY_PREFIX) + ); expect(persistentKeys).toHaveLength(0); }); @@ -247,7 +283,9 @@ describe("handleMessage TRANSLATE_BATCH", () => { await handleMessage({ type: "TRANSLATE_BATCH", segments }); expect(fetchCalls).toHaveLength(2); - const persistentKeys = Array.from(storageStore.keys()).filter((key) => key.startsWith(CACHE_KEY_PREFIX)); + const persistentKeys = Array.from(storageStore.keys()).filter((key) => + key.startsWith(CACHE_KEY_PREFIX) + ); expect(persistentKeys).toHaveLength(0); }); @@ -379,7 +417,9 @@ describe("handleMessage unknown messages", () => { it("returns an error including the message type", async () => { const { handleMessage } = await loadModule(); - const result = (await handleMessage({ type: "BOGUS" } as unknown as RuntimeMessage)) as UnsupportedResult; + const result = (await handleMessage({ + type: "BOGUS" + } as unknown as RuntimeMessage)) as UnsupportedResult; expect(result.ok).toBe(false); expect(result.error).toMatch(/BOGUS/); diff --git a/apps/extension/src/background/serviceWorker.ts b/apps/extension/src/background/serviceWorker.ts index 360e388..aea61b6 100644 --- a/apps/extension/src/background/serviceWorker.ts +++ b/apps/extension/src/background/serviceWorker.ts @@ -1,4 +1,9 @@ -import { clearPersistentCache, getCachedTranslation, getSettings, setCachedTranslation } from "../shared/storage"; +import { + clearPersistentCache, + getCachedTranslation, + getSettings, + setCachedTranslation +} from "../shared/storage"; import type { ExtensionSettings, RuntimeMessage, @@ -44,7 +49,10 @@ export async function handleMessage(message: RuntimeMessage): Promise { } if (message.type === "LIST_MODELS") { - return listProviderModels({ ...message.settings, apiKey: normalizeApiKey(message.settings.apiKey) }); + return listProviderModels({ + ...message.settings, + apiKey: normalizeApiKey(message.settings.apiKey) + }); } return { ok: false, error: `Unsupported runtime message: ${getRuntimeMessageType(message)}.` }; @@ -77,8 +85,8 @@ async function translateBatch(segments: TextSegment[]): Promise { }); it("classifies definition list entries as documentation content", () => { - const document = createDocument("
Hooks
Commands that run at selected lifecycle events.
"); + const document = createDocument( + "
Hooks
Commands that run at selected lifecycle events.
" + ); const term = document.querySelector("dt"); const description = document.querySelector("dd"); @@ -56,14 +58,18 @@ describe("createBlockCandidate", () => { }); it("keeps definition terms inline while descriptions remain integrated", () => { - const document = createDocument("
Hooks
Commands that run at selected lifecycle events.
"); + const document = createDocument( + "
Hooks
Commands that run at selected lifecycle events.
" + ); const term = document.querySelector("dt"); const description = document.querySelector("dd"); expect(term).not.toBeNull(); expect(description).not.toBeNull(); expect(createBlockCandidate(term as HTMLElement, "semantic").renderStrategy).toBe("inline"); - expect(createBlockCandidate(description as HTMLElement, "semantic").renderStrategy).toBe("integrated"); + expect(createBlockCandidate(description as HTMLElement, "semantic").renderStrategy).toBe( + "integrated" + ); }); it("treats figcaption as an explicit low-value caption role", () => { diff --git a/apps/extension/src/content/blockCandidates.ts b/apps/extension/src/content/blockCandidates.ts index 8e6f9bc..8d01da6 100644 --- a/apps/extension/src/content/blockCandidates.ts +++ b/apps/extension/src/content/blockCandidates.ts @@ -64,7 +64,10 @@ export interface BlockCandidate { skipReason?: BlockCandidateSkipReason; } -export function createBlockCandidate(element: HTMLElement, source: BlockCandidateSource): BlockCandidate { +export function createBlockCandidate( + element: HTMLElement, + source: BlockCandidateSource +): BlockCandidate { const text = normalizeText(element.innerText ?? element.textContent ?? ""); const role = inferBlockRole(element); const score = scoreBlockCandidate(element, text, role, source); @@ -236,4 +239,3 @@ function hasHighLinkDensity(element: HTMLElement, text: string): boolean { ); return linkTextLength / text.length > 0.55; } - diff --git a/apps/extension/src/content/contentScript.ts b/apps/extension/src/content/contentScript.ts index c45f0b2..b342ef5 100644 --- a/apps/extension/src/content/contentScript.ts +++ b/apps/extension/src/content/contentScript.ts @@ -25,13 +25,21 @@ chrome.runtime.onMessage.addListener((message: RuntimeMessage, _sender, sendResp if (message.type === "TOGGLE_TRANSLATION") { const next = typeof message.enabled === "boolean" ? message.enabled : !orchestrator.isEnabled(); void orchestrator.setEnabled(next).finally(() => { - sendResponse({ ok: true, enabled: orchestrator.isEnabled(), debug: orchestrator.getDebugState() }); + sendResponse({ + ok: true, + enabled: orchestrator.isEnabled(), + debug: orchestrator.getDebugState() + }); }); return true; } if (message.type === "GET_PAGE_STATE") { - sendResponse({ ok: true, enabled: orchestrator.isEnabled(), debug: orchestrator.getDebugState() }); + sendResponse({ + ok: true, + enabled: orchestrator.isEnabled(), + debug: orchestrator.getDebugState() + }); return; } }); @@ -52,7 +60,9 @@ chrome.storage.onChanged.addListener((changes, areaName) => { async function initializeFloatingButton(): Promise { if (!document.body) { - window.addEventListener("DOMContentLoaded", () => void initializeFloatingButton(), { once: true }); + window.addEventListener("DOMContentLoaded", () => void initializeFloatingButton(), { + once: true + }); return; } diff --git a/apps/extension/src/content/displayStyle.test.ts b/apps/extension/src/content/displayStyle.test.ts index a05caa1..ecd430d 100644 --- a/apps/extension/src/content/displayStyle.test.ts +++ b/apps/extension/src/content/displayStyle.test.ts @@ -21,12 +21,18 @@ afterEach(() => { describe("getTranslationClassName", () => { it("adds the base class and style modifier", () => { - expect(getTranslationClassName("balanced")).toBe("margin-translation margin-translation--balanced"); + expect(getTranslationClassName("balanced")).toBe( + "margin-translation margin-translation--balanced" + ); }); it("maps legacy display styles to their modern equivalents", () => { - expect(getTranslationClassName("integrated")).toBe("margin-translation margin-translation--quiet"); - expect(getTranslationClassName("highlighted")).toBe("margin-translation margin-translation--card"); + expect(getTranslationClassName("integrated")).toBe( + "margin-translation margin-translation--quiet" + ); + expect(getTranslationClassName("highlighted")).toBe( + "margin-translation margin-translation--card" + ); }); }); diff --git a/apps/extension/src/content/displayStyle.ts b/apps/extension/src/content/displayStyle.ts index 46b184e..87b4175 100644 --- a/apps/extension/src/content/displayStyle.ts +++ b/apps/extension/src/content/displayStyle.ts @@ -160,8 +160,18 @@ export function deriveIntegratedStyleTokens( textAlign: style.textAlign, color: style.color, maxWidth: style.maxWidth === "none" ? "" : style.maxWidth, - marginTop: getIntegratedMarginTop(style.marginBottom, sourceLineHeight, sourceFontSize, heading), - marginBottom: getIntegratedMarginBottom(style.marginBottom, sourceLineHeight, sourceFontSize, heading) + marginTop: getIntegratedMarginTop( + style.marginBottom, + sourceLineHeight, + sourceFontSize, + heading + ), + marginBottom: getIntegratedMarginBottom( + style.marginBottom, + sourceLineHeight, + sourceFontSize, + heading + ) }; } @@ -204,7 +214,11 @@ export function getFocusFontWeight(fontWeight: string): string { return String(clamp(numeric, 450, 600)); } -export function getIntegratedLineHeight(lineHeight: string, sourceFontSize: number, heading: boolean): string { +export function getIntegratedLineHeight( + lineHeight: string, + sourceFontSize: number, + heading: boolean +): string { if (heading) { return "1.25"; } @@ -225,7 +239,10 @@ export function getIntegratedMarginTop( ): string { const marginPixels = parseCssPixels(sourceMarginBottom) ?? 0; const desiredGapPixels = sourceLineHeight * (heading ? 0.14 : 0.22); - const marginTopPixels = Math.max(desiredGapPixels - marginPixels, marginPixels > 0 ? -marginPixels + 4 : 0); + const marginTopPixels = Math.max( + desiredGapPixels - marginPixels, + marginPixels > 0 ? -marginPixels + 4 : 0 + ); return `${roundCssNumber(marginTopPixels / sourceFontSize)}em`; } @@ -334,9 +351,9 @@ function getCjkFontFamily(languageCode: string): string { "system-ui", "-apple-system", "BlinkMacSystemFont", - "\"Hiragino Sans\"", - "\"Yu Gothic\"", - "\"Noto Sans JP\"", + '"Hiragino Sans"', + '"Yu Gothic"', + '"Noto Sans JP"', "sans-serif" ].join(", "); } @@ -345,9 +362,9 @@ function getCjkFontFamily(languageCode: string): string { "system-ui", "-apple-system", "BlinkMacSystemFont", - "\"Apple SD Gothic Neo\"", - "\"Noto Sans KR\"", - "\"Malgun Gothic\"", + '"Apple SD Gothic Neo"', + '"Noto Sans KR"', + '"Malgun Gothic"', "sans-serif" ].join(", "); } @@ -356,9 +373,9 @@ function getCjkFontFamily(languageCode: string): string { "system-ui", "-apple-system", "BlinkMacSystemFont", - "\"PingFang SC\"", - "\"Noto Sans SC\"", - "\"Microsoft YaHei\"", + '"PingFang SC"', + '"Noto Sans SC"', + '"Microsoft YaHei"', "sans-serif" ].join(", "); } @@ -366,9 +383,9 @@ function getCjkFontFamily(languageCode: string): string { "system-ui", "-apple-system", "BlinkMacSystemFont", - "\"PingFang TC\"", - "\"Noto Sans TC\"", - "\"Microsoft JhengHei\"", + '"PingFang TC"', + '"Noto Sans TC"', + '"Microsoft JhengHei"', "sans-serif" ].join(", "); } @@ -402,7 +419,9 @@ function normalizeLanguageCode(value: string): string { } function supportsCss(declaration: string): boolean { - return typeof CSS !== "undefined" && typeof CSS.supports === "function" && CSS.supports(declaration); + return ( + typeof CSS !== "undefined" && typeof CSS.supports === "function" && CSS.supports(declaration) + ); } function parseCssPixels(value: string): number | undefined { @@ -414,7 +433,11 @@ function parseCssPixels(value: string): number | undefined { return Number.isFinite(parsed) ? parsed : undefined; } -function getSourceLineHeightPixels(lineHeight: string, sourceFontSize: number, heading: boolean): number { +function getSourceLineHeightPixels( + lineHeight: string, + sourceFontSize: number, + heading: boolean +): number { const lineHeightPixels = parseCssPixels(lineHeight); if (lineHeightPixels !== undefined) { return lineHeightPixels; diff --git a/apps/extension/src/content/extraction/archetypes/article.test.ts b/apps/extension/src/content/extraction/archetypes/article.test.ts index 23a33c2..1a91b5b 100644 --- a/apps/extension/src/content/extraction/archetypes/article.test.ts +++ b/apps/extension/src/content/extraction/archetypes/article.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; -import { ARTICLE_ARCHETYPE_CONFIDENCE_THRESHOLD, detectArticleArchetypeConfidence } from "./article"; +import { + ARTICLE_ARCHETYPE_CONFIDENCE_THRESHOLD, + detectArticleArchetypeConfidence +} from "./article"; describe("detectArticleArchetypeConfidence", () => { it("recognizes publication article bodies", () => { @@ -13,7 +16,9 @@ describe("detectArticleArchetypeConfidence", () => { `); - expect(detectArticleArchetypeConfidence(document)).toBeGreaterThanOrEqual(ARTICLE_ARCHETYPE_CONFIDENCE_THRESHOLD); + expect(detectArticleArchetypeConfidence(document)).toBeGreaterThanOrEqual( + ARTICLE_ARCHETYPE_CONFIDENCE_THRESHOLD + ); }); it("does not classify ordinary semantic articles as an archetype", () => { @@ -25,7 +30,9 @@ describe("detectArticleArchetypeConfidence", () => { `); - expect(detectArticleArchetypeConfidence(document)).toBeLessThan(ARTICLE_ARCHETYPE_CONFIDENCE_THRESHOLD); + expect(detectArticleArchetypeConfidence(document)).toBeLessThan( + ARTICLE_ARCHETYPE_CONFIDENCE_THRESHOLD + ); }); it("does not classify a sparse article-content widget", () => { @@ -35,7 +42,9 @@ describe("detectArticleArchetypeConfidence", () => {
`); - expect(detectArticleArchetypeConfidence(document)).toBeLessThan(ARTICLE_ARCHETYPE_CONFIDENCE_THRESHOLD); + expect(detectArticleArchetypeConfidence(document)).toBeLessThan( + ARTICLE_ARCHETYPE_CONFIDENCE_THRESHOLD + ); }); }); diff --git a/apps/extension/src/content/extraction/archetypes/article.ts b/apps/extension/src/content/extraction/archetypes/article.ts index c37081d..489e2ee 100644 --- a/apps/extension/src/content/extraction/archetypes/article.ts +++ b/apps/extension/src/content/extraction/archetypes/article.ts @@ -9,7 +9,19 @@ const ARTICLE_CONTAINER_SELECTORS = [ ".post-content", ".story-content" ]; -const ARTICLE_BLOCK_SELECTORS = ["p", "li", "dt", "dd", "figcaption", "blockquote", "h1", "h2", "h3", "td", "th"]; +const ARTICLE_BLOCK_SELECTORS = [ + "p", + "li", + "dt", + "dd", + "figcaption", + "blockquote", + "h1", + "h2", + "h3", + "td", + "th" +]; const ARTICLE_ARCHETYPE_SELECTOR = ARTICLE_CONTAINER_SELECTORS.flatMap((containerSelector) => ARTICLE_BLOCK_SELECTORS.map((blockSelector) => `${containerSelector} ${blockSelector}`) ).join(", "); @@ -20,12 +32,16 @@ export const ARTICLE_ARCHETYPE_CONFIDENCE_THRESHOLD = 0.65; export function collectArticleBlocks(document: Document, options: TextBlockOptions): HTMLElement[] { const candidates = document.querySelectorAll(ARTICLE_ARCHETYPE_SELECTOR); - if (computeArticleArchetypeConfidence(document, candidates.length) < ARTICLE_ARCHETYPE_CONFIDENCE_THRESHOLD) { + if ( + computeArticleArchetypeConfidence(document, candidates.length) < + ARTICLE_ARCHETYPE_CONFIDENCE_THRESHOLD + ) { return []; } return Array.from(candidates).filter( - (element) => isTranslatableElement(element, options) && shouldIncludeCandidate(element, "archetype") + (element) => + isTranslatableElement(element, options) && shouldIncludeCandidate(element, "archetype") ); } @@ -36,7 +52,10 @@ export function detectArticleArchetypeConfidence(document: Document): number { ); } -function computeArticleArchetypeConfidence(document: Document, archetypeBlockCount: number): number { +function computeArticleArchetypeConfidence( + document: Document, + archetypeBlockCount: number +): number { if (archetypeBlockCount === 0) { return 0; } diff --git a/apps/extension/src/content/extraction/archetypes/docs.test.ts b/apps/extension/src/content/extraction/archetypes/docs.test.ts index 74322a7..255b1bf 100644 --- a/apps/extension/src/content/extraction/archetypes/docs.test.ts +++ b/apps/extension/src/content/extraction/archetypes/docs.test.ts @@ -11,7 +11,9 @@ describe("detectDocsArchetypeConfidence", () => { `); - expect(detectDocsArchetypeConfidence(document)).toBeGreaterThanOrEqual(DOCS_ARCHETYPE_CONFIDENCE_THRESHOLD); + expect(detectDocsArchetypeConfidence(document)).toBeGreaterThanOrEqual( + DOCS_ARCHETYPE_CONFIDENCE_THRESHOLD + ); }); it("recognizes Docusaurus-style docs with markdown containers", () => { @@ -26,7 +28,9 @@ describe("detectDocsArchetypeConfidence", () => { `); - expect(detectDocsArchetypeConfidence(document)).toBeGreaterThanOrEqual(DOCS_ARCHETYPE_CONFIDENCE_THRESHOLD); + expect(detectDocsArchetypeConfidence(document)).toBeGreaterThanOrEqual( + DOCS_ARCHETYPE_CONFIDENCE_THRESHOLD + ); }); it("does not classify normal article pages as docs", () => { @@ -38,7 +42,9 @@ describe("detectDocsArchetypeConfidence", () => { `); - expect(detectDocsArchetypeConfidence(document)).toBeLessThan(DOCS_ARCHETYPE_CONFIDENCE_THRESHOLD); + expect(detectDocsArchetypeConfidence(document)).toBeLessThan( + DOCS_ARCHETYPE_CONFIDENCE_THRESHOLD + ); }); it("does not classify isolated data-as components as docs", () => { @@ -48,7 +54,9 @@ describe("detectDocsArchetypeConfidence", () => { `); - expect(detectDocsArchetypeConfidence(document)).toBeLessThan(DOCS_ARCHETYPE_CONFIDENCE_THRESHOLD); + expect(detectDocsArchetypeConfidence(document)).toBeLessThan( + DOCS_ARCHETYPE_CONFIDENCE_THRESHOLD + ); }); it("requires structure signals beyond repeated data-as blocks", () => { @@ -60,7 +68,9 @@ describe("detectDocsArchetypeConfidence", () => { `); - expect(detectDocsArchetypeConfidence(document)).toBeLessThan(DOCS_ARCHETYPE_CONFIDENCE_THRESHOLD); + expect(detectDocsArchetypeConfidence(document)).toBeLessThan( + DOCS_ARCHETYPE_CONFIDENCE_THRESHOLD + ); }); }); diff --git a/apps/extension/src/content/extraction/archetypes/docs.ts b/apps/extension/src/content/extraction/archetypes/docs.ts index 99286bd..6b7fa90 100644 --- a/apps/extension/src/content/extraction/archetypes/docs.ts +++ b/apps/extension/src/content/extraction/archetypes/docs.ts @@ -1,11 +1,36 @@ import type { TextBlockOptions } from "../types"; import { isTranslatableElement, shouldIncludeCandidate } from "../shared"; -const DATA_AS_DOCS_BLOCK_SELECTORS = ["[data-as='p']", "[data-as='h1']", "[data-as='h2']", "[data-as='h3']"]; -const MARKDOWN_DOCS_CONTAINER_SELECTORS = ["main .prose", "main .markdown-body", "main .theme-doc-markdown"]; -const MARKDOWN_DOCS_BLOCK_SELECTORS = ["p", "li", "dt", "dd", "figcaption", "blockquote", "h1", "h2", "h3", "td", "th"]; +const DATA_AS_DOCS_BLOCK_SELECTORS = [ + "[data-as='p']", + "[data-as='h1']", + "[data-as='h2']", + "[data-as='h3']" +]; +const MARKDOWN_DOCS_CONTAINER_SELECTORS = [ + "main .prose", + "main .markdown-body", + "main .theme-doc-markdown" +]; +const MARKDOWN_DOCS_BLOCK_SELECTORS = [ + "p", + "li", + "dt", + "dd", + "figcaption", + "blockquote", + "h1", + "h2", + "h3", + "td", + "th" +]; const DOCS_ARCHETYPE_SELECTOR = [ - ...DATA_AS_DOCS_BLOCK_SELECTORS.flatMap((selector) => [`article ${selector}`, `main ${selector}`, selector]), + ...DATA_AS_DOCS_BLOCK_SELECTORS.flatMap((selector) => [ + `article ${selector}`, + `main ${selector}`, + selector + ]), ...MARKDOWN_DOCS_CONTAINER_SELECTORS.flatMap((containerSelector) => MARKDOWN_DOCS_BLOCK_SELECTORS.map((blockSelector) => `${containerSelector} ${blockSelector}`) ) @@ -14,17 +39,24 @@ export const DOCS_ARCHETYPE_CONFIDENCE_THRESHOLD = 0.7; export function collectDocsBlocks(document: Document, options: TextBlockOptions): HTMLElement[] { const candidates = document.querySelectorAll(DOCS_ARCHETYPE_SELECTOR); - if (computeDocsArchetypeConfidence(document, candidates.length) < DOCS_ARCHETYPE_CONFIDENCE_THRESHOLD) { + if ( + computeDocsArchetypeConfidence(document, candidates.length) < + DOCS_ARCHETYPE_CONFIDENCE_THRESHOLD + ) { return []; } return Array.from(candidates).filter( - (element) => isTranslatableElement(element, options) && shouldIncludeCandidate(element, "archetype") + (element) => + isTranslatableElement(element, options) && shouldIncludeCandidate(element, "archetype") ); } export function detectDocsArchetypeConfidence(document: Document): number { - return computeDocsArchetypeConfidence(document, document.querySelectorAll(DOCS_ARCHETYPE_SELECTOR).length); + return computeDocsArchetypeConfidence( + document, + document.querySelectorAll(DOCS_ARCHETYPE_SELECTOR).length + ); } function computeDocsArchetypeConfidence(document: Document, archetypeBlockCount: number): number { diff --git a/apps/extension/src/content/extraction/cascade.ts b/apps/extension/src/content/extraction/cascade.ts index 124fbb4..fc5a35e 100644 --- a/apps/extension/src/content/extraction/cascade.ts +++ b/apps/extension/src/content/extraction/cascade.ts @@ -3,14 +3,21 @@ import { collectArticleBlocks } from "./archetypes/article"; import { collectDocsBlocks } from "./archetypes/docs"; import { collectInlineRunBlocks } from "./inlineText"; import { collectLegacyBlocks } from "./legacy"; -import { createIncludedBlockCandidates, getNormalizedText, removeCoveredAncestorCandidates } from "./shared"; +import { + createIncludedBlockCandidates, + getNormalizedText, + removeCoveredAncestorCandidates +} from "./shared"; import type { TextBlockOptions } from "./types"; import { collectUniversalBlocks } from "./universal"; const MIN_SEMANTIC_BLOCKS = 3; const MIN_SEMANTIC_TEXT_LENGTH = 500; -export function collectBlockCandidates(document: Document, options: TextBlockOptions): BlockCandidate[] { +export function collectBlockCandidates( + document: Document, + options: TextBlockOptions +): BlockCandidate[] { const docsBlocks = collectDocsBlocks(document, options); const articleBlocks = collectArticleBlocks(document, options); const universalBlocks = uniqueElements([ @@ -52,7 +59,9 @@ function createSemanticBlockCandidates( options: TextBlockOptions ): BlockCandidate[] { const archetypeBlockSet = new Set(archetypeBlocks); - const remainingUniversalBlocks = universalBlocks.filter((element) => !archetypeBlockSet.has(element)); + const remainingUniversalBlocks = universalBlocks.filter( + (element) => !archetypeBlockSet.has(element) + ); return removeCoveredAncestorCandidates( [ diff --git a/apps/extension/src/content/extraction/inlineText.ts b/apps/extension/src/content/extraction/inlineText.ts index ecaeef9..79dabec 100644 --- a/apps/extension/src/content/extraction/inlineText.ts +++ b/apps/extension/src/content/extraction/inlineText.ts @@ -48,14 +48,18 @@ const INLINE_ELEMENT_TAGS = new Set([ ]); // Blocks already produced by the semantic collectors — never re-wrap their own text. -const ALREADY_HANDLED_BLOCK_SELECTOR = "p, li, dt, dd, figcaption, blockquote, h1, h2, h3, h4, h5, h6"; +const ALREADY_HANDLED_BLOCK_SELECTOR = + "p, li, dt, dd, figcaption, blockquote, h1, h2, h3, h4, h5, h6"; const MAX_LINK_TEXT_RATIO = 0.5; // Recovers translatable prose that is not wrapped in its own semantic block element by // finding containers that directly own loose inline text and splitting that text into // run-based synthetic blocks. -export function collectInlineRunBlocks(document: Document, options: TextBlockOptions): HTMLElement[] { +export function collectInlineRunBlocks( + document: Document, + options: TextBlockOptions +): HTMLElement[] { const blocks: HTMLElement[] = []; for (const container of findInlineTextContainers(document, options)) { blocks.push(...splitContainerInlineRuns(container, document, options)); @@ -93,7 +97,10 @@ function findInlineTextContainers(document: Document, options: TextBlockOptions) return containers; } -function nearestBlockContainer(element: HTMLElement | null, options: TextBlockOptions): HTMLElement | undefined { +function nearestBlockContainer( + element: HTMLElement | null, + options: TextBlockOptions +): HTMLElement | undefined { let current: HTMLElement | null = element; while (current && INLINE_ELEMENT_TAGS.has(current.tagName)) { current = current.parentElement; @@ -106,7 +113,11 @@ function nearestBlockContainer(element: HTMLElement | null, options: TextBlockOp // Semantic leaves are already extracted whole; table cells are structural and their real // content lives in inner blocks (so loose cell text is metadata/UI we should leave alone). - if (current.matches(ALREADY_HANDLED_BLOCK_SELECTOR) || current.tagName === "TD" || current.tagName === "TH") { + if ( + current.matches(ALREADY_HANDLED_BLOCK_SELECTOR) || + current.tagName === "TD" || + current.tagName === "TH" + ) { return undefined; } diff --git a/apps/extension/src/content/extraction/legacy.ts b/apps/extension/src/content/extraction/legacy.ts index c64ca82..118730b 100644 --- a/apps/extension/src/content/extraction/legacy.ts +++ b/apps/extension/src/content/extraction/legacy.ts @@ -28,7 +28,11 @@ export function collectLegacyBlocks(document: Document, options: TextBlockOption ); } -function splitLegacyContainer(container: HTMLElement, document: Document, options: TextBlockOptions): HTMLElement[] { +function splitLegacyContainer( + container: HTMLElement, + document: Document, + options: TextBlockOptions +): HTMLElement[] { return splitTextByBrGroups(container, document, options, "marginLegacyBlock"); } @@ -49,7 +53,11 @@ function isLegacyContainer(element: HTMLElement, options: TextBlockOptions): boo return element.querySelectorAll("p, li, blockquote, h1, h2, h3").length === 0; } -function hasNestedLegacyCandidate(element: HTMLElement, textLength: number, options: TextBlockOptions): boolean { +function hasNestedLegacyCandidate( + element: HTMLElement, + textLength: number, + options: TextBlockOptions +): boolean { return Array.from(element.querySelectorAll("td, font")).some((child) => { if (child === element || shouldSkipElement(child, options)) { return false; diff --git a/apps/extension/src/content/extraction/shared.ts b/apps/extension/src/content/extraction/shared.ts index 2b2911e..6afe9cf 100644 --- a/apps/extension/src/content/extraction/shared.ts +++ b/apps/extension/src/content/extraction/shared.ts @@ -187,7 +187,13 @@ export function splitContainerInlineRuns( let currentNodes: ChildNode[] = []; const flush = (): void => { - const block = createSplitBlock(container, currentNodes, document, options, "marginInlineRunBlock"); + const block = createSplitBlock( + container, + currentNodes, + document, + options, + "marginInlineRunBlock" + ); currentNodes = []; if (block) { blocks.push(block); @@ -221,7 +227,10 @@ function createSplitBlock( } const text = normalizeText(nodes.map((node) => getSplitNodeText(node, options)).join(" ")); - if (countCodePoints(text) < getMinimumTextLengthForText(text, options) || text.length > MAX_TEXT_LENGTH) { + if ( + countCodePoints(text) < getMinimumTextLengthForText(text, options) || + text.length > MAX_TEXT_LENGTH + ) { return undefined; } @@ -243,7 +252,9 @@ function createSplitBlock( } function isInlineRunSeparator(node: ChildNode): boolean { - return node.nodeType === Node.ELEMENT_NODE && INLINE_RUN_SEPARATOR_TAGS.has((node as Element).tagName); + return ( + node.nodeType === Node.ELEMENT_NODE && INLINE_RUN_SEPARATOR_TAGS.has((node as Element).tagName) + ); } export function isBrSeparatedContainer(element: HTMLElement, options: TextBlockOptions): boolean { @@ -261,11 +272,17 @@ export function isBrSeparatedContainer(element: HTMLElement, options: TextBlockO return breakCount >= 3 && countCodePoints(text) > getBaseMinimumTextLength(text, options) * 3; } -export function filterCandidateElements(elements: HTMLElement[], source: BlockCandidateSource): HTMLElement[] { +export function filterCandidateElements( + elements: HTMLElement[], + source: BlockCandidateSource +): HTMLElement[] { return elements.filter((element) => shouldIncludeCandidate(element, source)); } -export function createBlockCandidates(elements: HTMLElement[], source: BlockCandidateSource): BlockCandidate[] { +export function createBlockCandidates( + elements: HTMLElement[], + source: BlockCandidateSource +): BlockCandidate[] { return elements.map((element) => createBlockCandidate(element, source)); } @@ -274,7 +291,9 @@ export function createIncludedBlockCandidates( source: BlockCandidateSource, options: TextBlockOptions ): BlockCandidate[] { - const includedCandidates = createBlockCandidates(elements, source).filter((candidate) => !candidate.skipReason); + const includedCandidates = createBlockCandidates(elements, source).filter( + (candidate) => !candidate.skipReason + ); return removeCoveredAncestorCandidates(includedCandidates, options); } @@ -282,10 +301,15 @@ export function removeCoveredAncestorCandidates( candidates: BlockCandidate[], options: TextBlockOptions ): BlockCandidate[] { - return candidates.filter((candidate) => !isTextFullyCoveredByDescendantCandidates(candidate, candidates, options)); + return candidates.filter( + (candidate) => !isTextFullyCoveredByDescendantCandidates(candidate, candidates, options) + ); } -export function shouldIncludeCandidate(element: HTMLElement, source: BlockCandidateSource): boolean { +export function shouldIncludeCandidate( + element: HTMLElement, + source: BlockCandidateSource +): boolean { const candidate = createBlockCandidate(element, source); return !candidate.skipReason; } @@ -318,14 +342,17 @@ function isTextFullyCoveredByDescendantCandidates( options: TextBlockOptions ): boolean { const descendantCandidates = candidates.filter( - (otherCandidate) => otherCandidate !== candidate && candidate.element.contains(otherCandidate.element) + (otherCandidate) => + otherCandidate !== candidate && candidate.element.contains(otherCandidate.element) ); if (descendantCandidates.length === 0) { return false; } // Fast path: the descendants reproduce the parent's text exactly — it owns nothing extra. - const descendantText = normalizeText(descendantCandidates.map((descendant) => descendant.text).join(" ")); + const descendantText = normalizeText( + descendantCandidates.map((descendant) => descendant.text).join(" ") + ); if (descendantText === candidate.text) { return true; } @@ -354,11 +381,19 @@ function getOwnResidualText(parentText: string, descendants: BlockCandidate[]): return normalizeText(residual); } -function getMinimumTextLength(element: HTMLElement, text: string, options: TextBlockOptions): number { +function getMinimumTextLength( + element: HTMLElement, + text: string, + options: TextBlockOptions +): number { return applyCjkMinimum(text, getStructuralMinimumTextLength(element, text, options)); } -function getStructuralMinimumTextLength(element: HTMLElement, text: string, options: TextBlockOptions): number { +function getStructuralMinimumTextLength( + element: HTMLElement, + text: string, + options: TextBlockOptions +): number { if (isHeadingElement(element) || element.matches("th")) { return 2; } @@ -385,7 +420,9 @@ function applyCjkMinimum(text: string, base: number): number { // A sentence terminator or full quotation enclosure both mark a complete utterance, // so even very short lines (e.g. 「ちぇー」) are allowed. const isCompleteUtterance = hasCjkSentenceTerminator(text) || isCjkQuotedText(text); - const cjkMinimum = isCompleteUtterance ? CJK_MIN_TEXT_LENGTH_WITH_TERMINATOR : CJK_MIN_TEXT_LENGTH; + const cjkMinimum = isCompleteUtterance + ? CJK_MIN_TEXT_LENGTH_WITH_TERMINATOR + : CJK_MIN_TEXT_LENGTH; return Math.min(base, cjkMinimum); } @@ -418,7 +455,9 @@ function isHeadingElement(element: HTMLElement): boolean { } function isShortLeadInParagraph(element: HTMLElement, text: string): boolean { - return element.matches("p") && text.length <= 80 && /[::]$/.test(text) && !element.querySelector("a"); + return ( + element.matches("p") && text.length <= 80 && /[::]$/.test(text) && !element.querySelector("a") + ); } function trimParagraphBoundaryBreaks(text: string): string { @@ -430,15 +469,24 @@ function getSplitNodeText(node: ChildNode, options: TextBlockOptions): string { return node.textContent ?? ""; } - if (node instanceof HTMLElement && !shouldSkipElement(node, options) && !node.matches("script, style, noscript")) { + if ( + node instanceof HTMLElement && + !shouldSkipElement(node, options) && + !node.matches("script, style, noscript") + ) { return node.innerText; } return ""; } -function getExistingSplitBlocks(container: HTMLElement, datasetKey: SplitDatasetKey): HTMLElement[] { - return Array.from(container.querySelectorAll(`[data-${toKebabCase(datasetKey)}="true"]`)); +function getExistingSplitBlocks( + container: HTMLElement, + datasetKey: SplitDatasetKey +): HTMLElement[] { + return Array.from( + container.querySelectorAll(`[data-${toKebabCase(datasetKey)}="true"]`) + ); } function isBreakElement(node: ChildNode): boolean { diff --git a/apps/extension/src/content/extraction/universal.ts b/apps/extension/src/content/extraction/universal.ts index 5b55f88..b1b08cf 100644 --- a/apps/extension/src/content/extraction/universal.ts +++ b/apps/extension/src/content/extraction/universal.ts @@ -10,12 +10,22 @@ import { const UNIVERSAL_SEMANTIC_SELECTOR = "article p, article li, article dt, article dd, article figcaption, article blockquote, article h1, article h2, article h3, article td, article th, main p, main li, main dt, main dd, main figcaption, main blockquote, main h1, main h2, main h3, main td, main th, p, li, dt, dd, figcaption, blockquote, h1, h2, h3"; -export function collectUniversalBlocks(document: Document, options: TextBlockOptions): HTMLElement[] { +export function collectUniversalBlocks( + document: Document, + options: TextBlockOptions +): HTMLElement[] { const blocks: HTMLElement[] = []; - for (const element of Array.from(document.querySelectorAll(UNIVERSAL_SEMANTIC_SELECTOR))) { + for (const element of Array.from( + document.querySelectorAll(UNIVERSAL_SEMANTIC_SELECTOR) + )) { if (isBrSeparatedContainer(element, options)) { - blocks.push(...filterCandidateElements(splitBrSeparatedContainer(element, document, options), "semantic")); + blocks.push( + ...filterCandidateElements( + splitBrSeparatedContainer(element, document, options), + "semantic" + ) + ); continue; } @@ -27,6 +37,10 @@ export function collectUniversalBlocks(document: Document, options: TextBlockOpt return blocks; } -function splitBrSeparatedContainer(container: HTMLElement, document: Document, options: TextBlockOptions): HTMLElement[] { +function splitBrSeparatedContainer( + container: HTMLElement, + document: Document, + options: TextBlockOptions +): HTMLElement[] { return splitTextByBrGroups(container, document, options, "marginBrSeparatedBlock"); } diff --git a/apps/extension/src/content/extractionFixtures.test.ts b/apps/extension/src/content/extractionFixtures.test.ts index 3009432..557e62a 100644 --- a/apps/extension/src/content/extractionFixtures.test.ts +++ b/apps/extension/src/content/extractionFixtures.test.ts @@ -29,7 +29,11 @@ import headingAnchorButtonExpected from "../../test/fixtures/extraction/universa import headingAnchorButtonHtml from "../../test/fixtures/extraction/universal/heading-anchor-button.html?raw"; import { collectBlockCandidates } from "./textBlocks"; import type { TextBlockOptions } from "./textBlocks"; -import type { BlockCandidateRole, BlockCandidateSource, BlockRenderStrategy } from "./blockCandidates"; +import type { + BlockCandidateRole, + BlockCandidateSource, + BlockRenderStrategy +} from "./blockCandidates"; interface ExtractionFixture { name: string; @@ -154,7 +158,10 @@ describe("extraction fixtures", () => { for (const fixture of fixtures) { it(`matches ${fixture.name}`, () => { const document = createFixtureDocument(fixture.html); - const candidates = collectBlockCandidates(document, { ...defaultOptions, ...fixture.options }); + const candidates = collectBlockCandidates(document, { + ...defaultOptions, + ...fixture.options + }); const actualBlocks = candidates.map((candidate) => ({ text: candidate.text, role: candidate.role, @@ -181,7 +188,9 @@ describe("extraction fixtures", () => { } for (const occurrence of fixture.expected.expectedOccurrences ?? []) { - const count = actualBlocks.filter((block) => normalize(block.text).includes(occurrence.textIncludes)).length; + const count = actualBlocks.filter((block) => + normalize(block.text).includes(occurrence.textIncludes) + ).length; expect( count, `expected "${occurrence.textIncludes}" to appear ${occurrence.count} time(s) in ${fixture.name}, got ${count}` @@ -225,7 +234,9 @@ function normalize(value: string): string { return value.replace(/\s+/g, " ").trim(); } -function omitUndefinedProperties(shape: ExpectedBlockShape): Partial> { +function omitUndefinedProperties( + shape: ExpectedBlockShape +): Partial> { return Object.fromEntries( Object.entries(shape).filter(([key, value]) => key !== "textIncludes" && value !== undefined) ); diff --git a/apps/extension/src/content/floatingButton.test.ts b/apps/extension/src/content/floatingButton.test.ts index 0a86cb2..51919e4 100644 --- a/apps/extension/src/content/floatingButton.test.ts +++ b/apps/extension/src/content/floatingButton.test.ts @@ -9,13 +9,16 @@ function getHost(): HTMLElement | null { function getPrimaryButton(): HTMLButtonElement | null { return ( - getHost()?.shadowRoot?.querySelector(".margin-floating__button--primary") ?? null + getHost()?.shadowRoot?.querySelector(".margin-floating__button--primary") ?? + null ); } function getCloseButton(): HTMLButtonElement | null { return ( - getHost()?.shadowRoot?.querySelector(".margin-floating__button--secondary") ?? null + getHost()?.shadowRoot?.querySelector( + ".margin-floating__button--secondary" + ) ?? null ); } @@ -99,7 +102,12 @@ describe("installFloatingButton", () => { it("turns the feature off via onClose when the close button is clicked", () => { const onClose = vi.fn<() => void>(); - handle = installFloatingButton({ document, initialEnabled: false, onToggle: onToggle, onClose }); + handle = installFloatingButton({ + document, + initialEnabled: false, + onToggle: onToggle, + onClose + }); handle.syncFromSettings({ showFloatingButton: true }); getCloseButton()?.click(); diff --git a/apps/extension/src/content/floatingButton.ts b/apps/extension/src/content/floatingButton.ts index 96b9b8e..9af0755 100644 --- a/apps/extension/src/content/floatingButton.ts +++ b/apps/extension/src/content/floatingButton.ts @@ -61,10 +61,15 @@ export function installFloatingButton(options: FloatingButtonOptions): FloatingB host.setAttribute("translate", "no"); host.className = "margin-notranslate"; const shadow = host.attachShadow({ mode: "open" }); - shadow.append(createFloatingStyles(doc), createFloatingControls(doc, handlePrimaryClick, handleClose)); + shadow.append( + createFloatingStyles(doc), + createFloatingControls(doc, handlePrimaryClick, handleClose) + ); doc.documentElement.append(host); - primaryButton = shadow.querySelector(".margin-floating__button--primary") ?? undefined; - const dragHandle = shadow.querySelector(".margin-floating__shell") ?? primaryButton; + primaryButton = + shadow.querySelector(".margin-floating__button--primary") ?? undefined; + const dragHandle = + shadow.querySelector(".margin-floating__shell") ?? primaryButton; dragHandle?.addEventListener("pointerdown", handlePointerDown); applyPosition(); doc.defaultView?.addEventListener("resize", applyPosition); diff --git a/apps/extension/src/content/layoutStrategy.test.ts b/apps/extension/src/content/layoutStrategy.test.ts index cac134e..46bc302 100644 --- a/apps/extension/src/content/layoutStrategy.test.ts +++ b/apps/extension/src/content/layoutStrategy.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it, vi } from "vitest"; -import { applyTranslationLayout, classifyTranslationLayout, shouldMirrorBlockLayout } from "./layoutStrategy"; +import { + applyTranslationLayout, + classifyTranslationLayout, + shouldMirrorBlockLayout +} from "./layoutStrategy"; describe("shouldMirrorBlockLayout", () => { it("mirrors centered article blocks inside wider parents", () => { @@ -67,7 +71,8 @@ describe("classifyTranslationLayout", () => { describe("applyTranslationLayout", () => { it("keeps Quartr-style centered article translations aligned with the source column", () => { const source = createElement({ - className: "quik_text_quik_text__w6RJ0 quik-size-bodyLarge mx-auto w-full max-w-160 text-quik_textReading", + className: + "quik_text_quik_text__w6RJ0 quik-size-bodyLarge mx-auto w-full max-w-160 text-quik_textReading", sourceRect: rect({ width: 640 }), parentRect: rect({ width: 1168 }), sourceStyle: style({ @@ -158,7 +163,17 @@ function createElement({ } function rect({ width }: { width: number }): DOMRectReadOnly { - return { bottom: 0, height: 0, left: 0, right: width, top: 0, width, x: 0, y: 0, toJSON: () => ({}) }; + return { + bottom: 0, + height: 0, + left: 0, + right: width, + top: 0, + width, + x: 0, + y: 0, + toJSON: () => ({}) + }; } function style(overrides: Partial): CSSStyleDeclaration { diff --git a/apps/extension/src/content/layoutStrategy.ts b/apps/extension/src/content/layoutStrategy.ts index 290f28b..47581e4 100644 --- a/apps/extension/src/content/layoutStrategy.ts +++ b/apps/extension/src/content/layoutStrategy.ts @@ -7,13 +7,7 @@ export interface LayoutSnapshot { parentRect?: DOMRectReadOnly; sourceStyle: Pick< CSSStyleDeclaration, - | "alignSelf" - | "boxSizing" - | "display" - | "marginLeft" - | "marginRight" - | "maxWidth" - | "width" + "alignSelf" | "boxSizing" | "display" | "marginLeft" | "marginRight" | "maxWidth" | "width" >; parentStyle?: Pick; } diff --git a/apps/extension/src/content/orchestrator.test.ts b/apps/extension/src/content/orchestrator.test.ts index 1046955..fde1add 100644 --- a/apps/extension/src/content/orchestrator.test.ts +++ b/apps/extension/src/content/orchestrator.test.ts @@ -1,7 +1,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { DEFAULT_SETTINGS } from "../shared/defaults"; import type { LocalTranslationProviderId } from "../shared/localProviders"; -import type { ExtensionSettings, RuntimeMessage, TextSegment, TranslationResult } from "../shared/types"; +import type { + ExtensionSettings, + RuntimeMessage, + TextSegment, + TranslationResult +} from "../shared/types"; import { createOrchestrator, type ContentOrchestrator } from "./orchestrator"; import { TRANSLATION_CLASS, TRANSLATED_ATTR } from "./translationRenderer"; @@ -118,7 +123,9 @@ beforeEach(() => { onEnabledChange = vi.fn<(enabled: boolean) => void>(); sendMessageMock = vi.fn(makeRouter()); - vi.stubGlobal("chrome", { runtime: { sendMessage: sendMessageMock, getManifest: () => ({ version: "0.3.2" }) } }); + vi.stubGlobal("chrome", { + runtime: { sendMessage: sendMessageMock, getManifest: () => ({ version: "0.3.2" }) } + }); vi.stubGlobal("window", { getComputedStyle: stubComputedStyle, innerHeight: VIEWPORT_HEIGHT, @@ -277,7 +284,10 @@ describe("createOrchestrator — error paths", () => { }); it("flags blocks the provider did not return translations for", async () => { - const paragraphs = Array.from({ length: 3 }, (_, i) => `${SAMPLE_PARAGRAPH} (paragraph ${i + 1})`); + const paragraphs = Array.from( + { length: 3 }, + (_, i) => `${SAMPLE_PARAGRAPH} (paragraph ${i + 1})` + ); seedDocument(`
${paragraphs.map((text) => `

${text}

`).join("")}
`); useRouter({ @@ -317,7 +327,10 @@ describe("createOrchestrator — provider-specific queue", () => { const batches = sendMessageMock.mock.calls .map(([msg]) => msg) - .filter((msg): msg is Extract => msg.type === "TRANSLATE_BATCH") + .filter( + (msg): msg is Extract => + msg.type === "TRANSLATE_BATCH" + ) .map((msg) => msg.segments.length); expect(batches.length).toBeGreaterThanOrEqual(2); expect(Math.max(...batches)).toBeLessThanOrEqual(3); @@ -410,7 +423,9 @@ describe("createOrchestrator — X longform articles", () => { await new Promise((resolve) => setTimeout(resolve, DEBOUNCE_QUIESCE_MS)); - expect(document.querySelectorAll(`.${TRANSLATION_CLASS}`).length).toBe(translationsAfterFirstPass); + expect(document.querySelectorAll(`.${TRANSLATION_CLASS}`).length).toBe( + translationsAfterFirstPass + ); }); }); @@ -471,8 +486,14 @@ describe("createOrchestrator — viewport prioritisation", () => { ); setRect(document.getElementById("inview") as HTMLElement, { top: 100, bottom: 200 }); - setRect(document.getElementById("near") as HTMLElement, { top: NEAR_VIEWPORT_TOP, bottom: NEAR_VIEWPORT_TOP + 100 }); - setRect(document.getElementById("far") as HTMLElement, { top: FAR_VIEWPORT_TOP, bottom: FAR_VIEWPORT_TOP + 100 }); + setRect(document.getElementById("near") as HTMLElement, { + top: NEAR_VIEWPORT_TOP, + bottom: NEAR_VIEWPORT_TOP + 100 + }); + setRect(document.getElementById("far") as HTMLElement, { + top: FAR_VIEWPORT_TOP, + bottom: FAR_VIEWPORT_TOP + 100 + }); const orchestrator = createTestOrchestrator(); await orchestrator.setEnabled(true); @@ -515,7 +536,9 @@ describe("createOrchestrator — debug state plumbing", () => { const debug = orchestrator.getDebugState(); expect(debug.lastProviderRequestStartedAt).toBeGreaterThan(0); - expect(debug.lastProviderRequestFinishedAt).toBeGreaterThanOrEqual(debug.lastProviderRequestStartedAt ?? 0); + expect(debug.lastProviderRequestFinishedAt).toBeGreaterThanOrEqual( + debug.lastProviderRequestStartedAt ?? 0 + ); expect(debug.lastProviderDurationMs).toBe( (debug.lastProviderRequestFinishedAt ?? 0) - (debug.lastProviderRequestStartedAt ?? 0) ); @@ -528,7 +551,8 @@ describe("createOrchestrator — debug state plumbing", () => { debugMode: true, provider: "google", model: "gemini-1.5-flash", - providerEndpoint: "https://user:pass@generativelanguage.googleapis.com/v1beta/models?key=secret#fragment" + providerEndpoint: + "https://user:pass@generativelanguage.googleapis.com/v1beta/models?key=secret#fragment" } }); diff --git a/apps/extension/src/content/orchestrator.ts b/apps/extension/src/content/orchestrator.ts index cd9e767..808035d 100644 --- a/apps/extension/src/content/orchestrator.ts +++ b/apps/extension/src/content/orchestrator.ts @@ -1,6 +1,11 @@ import { isLocalTranslationProvider } from "../shared/localProviders"; import { normalizeText } from "../shared/text"; -import type { ExtensionSettings, PageDebugState, TranslationProviderId, TranslationResult } from "../shared/types"; +import type { + ExtensionSettings, + PageDebugState, + TranslationProviderId, + TranslationResult +} from "../shared/types"; import type { BlockCandidate } from "./blockCandidates"; import { type TranslationDisplayStyle } from "./displayStyle"; import { createIncludedBlockCandidates } from "./extraction/shared"; @@ -15,7 +20,12 @@ import { createTranslationRenderer, type TranslationRenderer } from "./translationRenderer"; -import { compareQueueItems, TranslationQueue, type QueuePriority, type TranslationQueueItem } from "./translationQueue"; +import { + compareQueueItems, + TranslationQueue, + type QueuePriority, + type TranslationQueueItem +} from "./translationQueue"; const MIN_TEXT_LENGTH = 24; const BATCH_SIZE = 6; @@ -194,7 +204,10 @@ export function createOrchestrator(options: ContentOrchestratorOptions): Content ? createIncludedBlockCandidates(adapterResult.blocks, "adapter", textBlockOptions) : collectBlockCandidates(document, textBlockOptions); rememberCandidates(candidates); - const initialQueueItems = candidates.map(createQueueItem).sort(compareQueueItems).slice(0, INITIAL_QUEUE_LIMIT); + const initialQueueItems = candidates + .map(createQueueItem) + .sort(compareQueueItems) + .slice(0, INITIAL_QUEUE_LIMIT); debugState.lastScanAt = Date.now(); debugState.detectedBlocks = candidates.length; debugState.enqueuedBlocks += initialQueueItems.length; @@ -280,7 +293,8 @@ export function createOrchestrator(options: ContentOrchestratorOptions): Content .filter((entry) => entry.isIntersecting) .map((entry) => entry.target) .filter( - (target): target is HTMLElement => target instanceof HTMLElement && !target.hasAttribute(TRANSLATED_ATTR) + (target): target is HTMLElement => + target instanceof HTMLElement && !target.hasAttribute(TRANSLATED_ATTR) ) .map((element) => candidateByElement.get(element)) .filter((candidate): candidate is BlockCandidate => Boolean(candidate)); @@ -444,7 +458,10 @@ function getQueuePriority(element: HTMLElement): QueuePriority { if (rect.bottom >= 0 && rect.top <= viewportHeight) { return 0; } - if (rect.top <= viewportHeight * (1 + NEAR_VIEWPORT_MULTIPLIER) && rect.bottom >= -viewportHeight * NEAR_VIEWPORT_MULTIPLIER) { + if ( + rect.top <= viewportHeight * (1 + NEAR_VIEWPORT_MULTIPLIER) && + rect.bottom >= -viewportHeight * NEAR_VIEWPORT_MULTIPLIER + ) { return 1; } return 2; @@ -486,7 +503,10 @@ function isMarginManagedNode(node: Node): boolean { ); } -function getSiblingText(element: HTMLElement, key: "previousElementSibling" | "nextElementSibling"): string | undefined { +function getSiblingText( + element: HTMLElement, + key: "previousElementSibling" | "nextElementSibling" +): string | undefined { const sibling = element[key]; if (!(sibling instanceof HTMLElement)) { return undefined; @@ -495,7 +515,6 @@ function getSiblingText(element: HTMLElement, key: "previousElementSibling" | "n return text.length > 0 ? text.slice(0, 280) : undefined; } - function getSampleText(candidates: BlockCandidate[]): string | undefined { const sample = candidates.map((candidate) => candidate.text).find((text) => text.length > 0); return sample?.slice(0, 160); diff --git a/apps/extension/src/content/readingVisibility.test.ts b/apps/extension/src/content/readingVisibility.test.ts index 0c7c440..3f6e301 100644 --- a/apps/extension/src/content/readingVisibility.test.ts +++ b/apps/extension/src/content/readingVisibility.test.ts @@ -34,7 +34,9 @@ afterEach(() => { describe("reading visibility", () => { it("identifies page chrome", () => { - const document = createDocument(""); + const document = createDocument( + "" + ); const paragraph = getElement(document, "p"); expect(isPageChromeElement(paragraph)).toBe(true); @@ -55,7 +57,9 @@ describe("reading visibility", () => { }); it("lets not-sr-only content remain readable", () => { - const document = createDocument("

Visible text that should remain readable.

"); + const document = createDocument( + '

Visible text that should remain readable.

' + ); const paragraph = getElement(document, "p"); expect(isAccessibilityOnlyElement(paragraph)).toBe(false); @@ -63,7 +67,9 @@ describe("reading visibility", () => { }); it("excludes display-hidden elements", () => { - const document = createDocument("

Hidden paragraph with enough text for testing.

"); + const document = createDocument( + "

Hidden paragraph with enough text for testing.

" + ); const paragraph = getElement(document, "p"); styles.set(paragraph, { display: "none" }); @@ -71,7 +77,9 @@ describe("reading visibility", () => { }); it("excludes offscreen absolute elements", () => { - const document = createDocument("

Offscreen paragraph with enough text for testing.

"); + const document = createDocument( + "

Offscreen paragraph with enough text for testing.

" + ); const paragraph = getElement(document, "p"); styles.set(paragraph, { position: "absolute", left: "-9999px" }); @@ -79,7 +87,9 @@ describe("reading visibility", () => { }); it("excludes clipped tiny elements", () => { - const document = createDocument("

Clipped paragraph with enough text for testing.

"); + const document = createDocument( + "

Clipped paragraph with enough text for testing.

" + ); const paragraph = getElement(document, "p"); styles.set(paragraph, { position: "absolute", @@ -92,7 +102,9 @@ describe("reading visibility", () => { }); it("excludes tiny overflow-hidden elements", () => { - const document = createDocument("

Tiny paragraph with enough text for testing.

"); + const document = createDocument( + "

Tiny paragraph with enough text for testing.

" + ); const paragraph = getElement(document, "p"); styles.set(paragraph, { position: "absolute", diff --git a/apps/extension/src/content/readingVisibility.ts b/apps/extension/src/content/readingVisibility.ts index b568e4a..01c9a25 100644 --- a/apps/extension/src/content/readingVisibility.ts +++ b/apps/extension/src/content/readingVisibility.ts @@ -45,7 +45,9 @@ export function isVisibleForReading(element: HTMLElement): boolean { } export function isAccessibilityOnlyElement(element: HTMLElement): boolean { - return Boolean(element.closest(ACCESSIBILITY_ONLY_SELECTOR) || hasAccessibilityOnlyAncestor(element)); + return Boolean( + element.closest(ACCESSIBILITY_ONLY_SELECTOR) || hasAccessibilityOnlyAncestor(element) + ); } function hasAccessibilityOnlyAncestor(element: HTMLElement): boolean { @@ -108,7 +110,9 @@ function isClipped(clip: string | undefined, clipPath: string | undefined): bool function isTinyBox(width: string | undefined, height: string | undefined): boolean { const parsedWidth = parseCssPixels(width); const parsedHeight = parseCssPixels(height); - return parsedWidth !== undefined && parsedHeight !== undefined && parsedWidth <= 2 && parsedHeight <= 2; + return ( + parsedWidth !== undefined && parsedHeight !== undefined && parsedWidth <= 2 && parsedHeight <= 2 + ); } function parseCssPixels(value: string | undefined): number | undefined { diff --git a/apps/extension/src/content/siteAdapters/index.ts b/apps/extension/src/content/siteAdapters/index.ts index c91ff8d..b036a33 100644 --- a/apps/extension/src/content/siteAdapters/index.ts +++ b/apps/extension/src/content/siteAdapters/index.ts @@ -14,7 +14,10 @@ export interface SiteAdapterResult { blocks: HTMLElement[]; } -export function collectSiteAdapterBlocks(document: Document, options: TextBlockOptions): SiteAdapterResult { +export function collectSiteAdapterBlocks( + document: Document, + options: TextBlockOptions +): SiteAdapterResult { let matched = false; for (const adapter of siteAdapters) { if (!adapter.matches(document, options)) { diff --git a/apps/extension/src/content/siteAdapters/x.test.ts b/apps/extension/src/content/siteAdapters/x.test.ts index e6b463e..37d6dca 100644 --- a/apps/extension/src/content/siteAdapters/x.test.ts +++ b/apps/extension/src/content/siteAdapters/x.test.ts @@ -79,7 +79,9 @@ describe("xAdapter.collectBlocks (tweets)", () => { expect(blocks).toHaveLength(1); expect(blocks[0]?.getAttribute("data-testid")).toBe("tweetText"); expect(blocks[0]?.dataset.marginXBlock).toBe("tweet-text"); - expect(normalize(blocks[0]?.textContent ?? "")).toBe("Anthropic just leaked their agent roadmap in 22 minutes."); + expect(normalize(blocks[0]?.textContent ?? "")).toBe( + "Anthropic just leaked their agent roadmap in 22 minutes." + ); }); it("splits long X posts on paragraph boundaries", () => { @@ -117,7 +119,9 @@ Third paragraph keeps the translated structure readable. const blocks = collect(document, withX()); expect(blocks).toHaveLength(1); - expect(normalize(blocks[0]?.textContent ?? "")).toBe("The main post has enough text to translate."); + expect(normalize(blocks[0]?.textContent ?? "")).toBe( + "The main post has enough text to translate." + ); }); it("can include quoted X post text when enabled", () => { @@ -165,7 +169,9 @@ Third paragraph keeps the translated structure readable. const blocks = collect(document, withX({ xSkipNativeTranslatedPosts: true })); expect(blocks).toHaveLength(1); - expect(normalize(blocks[0]?.textContent ?? "")).toBe("The main post has enough text to translate."); + expect(normalize(blocks[0]?.textContent ?? "")).toBe( + "The main post has enough text to translate." + ); }); }); @@ -229,7 +235,9 @@ describe("xAdapter.collectBlocks (articles)", () => { const blocks = collect(document, withX({ xTranslateArticles: true })); expect(blocks).toHaveLength(1); - expect(normalize(blocks[0]?.textContent ?? "")).toBe("A readable article paragraph should still be translated."); + expect(normalize(blocks[0]?.textContent ?? "")).toBe( + "A readable article paragraph should still be translated." + ); }); it("skips X article blocks that already received integrated translations", () => { diff --git a/apps/extension/src/content/siteAdapters/x.ts b/apps/extension/src/content/siteAdapters/x.ts index a46a7c4..b547953 100644 --- a/apps/extension/src/content/siteAdapters/x.ts +++ b/apps/extension/src/content/siteAdapters/x.ts @@ -22,15 +22,17 @@ const X_TWEET_PARAGRAPH_BLOCK_DATASET = "marginXParagraphBlock"; export const xAdapter: SiteAdapter = { id: "x", matches(document, options) { - return Boolean(options.xOptimizedTranslation && document.querySelector(X_TWEET_ARTICLE_SELECTOR)); + return Boolean( + options.xOptimizedTranslation && document.querySelector(X_TWEET_ARTICLE_SELECTOR) + ); }, collectBlocks(document, options) { const articleBlocks = collectArticleBlocks(document, options); if (articleBlocks.length > 0) { return articleBlocks; } - return Array.from(document.querySelectorAll(X_TWEET_TEXT_SELECTOR)).flatMap((element) => - getTranslatableTweetTextBlocks(element, document, options) + return Array.from(document.querySelectorAll(X_TWEET_TEXT_SELECTOR)).flatMap( + (element) => getTranslatableTweetTextBlocks(element, document, options) ); } }; @@ -40,12 +42,12 @@ function collectArticleBlocks(document: Document, options: TextBlockOptions): HT return []; } - const titleBlocks = Array.from(document.querySelectorAll(X_ARTICLE_TITLE_SELECTOR)).filter((element) => - isArticleTextBlock(element, options) - ); - const richTextBlocks = Array.from(document.querySelectorAll(X_ARTICLE_BLOCK_SELECTOR)).filter((element) => - isArticleTextBlock(element, options) - ); + const titleBlocks = Array.from( + document.querySelectorAll(X_ARTICLE_TITLE_SELECTOR) + ).filter((element) => isArticleTextBlock(element, options)); + const richTextBlocks = Array.from( + document.querySelectorAll(X_ARTICLE_BLOCK_SELECTOR) + ).filter((element) => isArticleTextBlock(element, options)); return [...titleBlocks, ...richTextBlocks]; } @@ -118,7 +120,11 @@ function isInsideQuotedPost(element: HTMLElement, article: HTMLElement): boolean function hasNativeXTranslationMarker(article: HTMLElement, textElement: HTMLElement): boolean { return Array.from(article.querySelectorAll("div, span")).some((element) => { - if (element === textElement || textElement.contains(element) || isInsideQuotedPost(element, article)) { + if ( + element === textElement || + textElement.contains(element) || + isInsideQuotedPost(element, article) + ) { return false; } return /Translated from\s+\w+/i.test(element.innerText); @@ -126,11 +132,17 @@ function hasNativeXTranslationMarker(article: HTMLElement, textElement: HTMLElem } function isEligibleTweetTextContainer(element: HTMLElement, options: TextBlockOptions): boolean { - if (element.hasAttribute(options.translatedAttr) || element.closest(`.${options.translationClass}`)) { + if ( + element.hasAttribute(options.translatedAttr) || + element.closest(`.${options.translationClass}`) + ) { return false; } - if (shouldSkipElement(element, options) || element.querySelector("input, textarea, select, button")) { + if ( + shouldSkipElement(element, options) || + element.querySelector("input, textarea, select, button") + ) { return false; } @@ -191,7 +203,10 @@ function createTweetParagraphBlock(document: Document, index: number): HTMLEleme return block; } -function splitNodeByParagraphBreaks(node: ChildNode, document: Document): Array { +function splitNodeByParagraphBreaks( + node: ChildNode, + document: Document +): Array { if (node.nodeType === Node.TEXT_NODE) { return splitTextByParagraphBreaks(node.textContent ?? "", document); } @@ -209,11 +224,19 @@ function splitNodeByParagraphBreaks(node: ChildNode, document: Document): Array< } function isPlainTextInlineElement(element: HTMLElement): boolean { - return element.children.length === 0 && !element.matches("a, button, input, textarea, select, svg, img, video"); + return ( + element.children.length === 0 && + !element.matches("a, button, input, textarea, select, svg, img, video") + ); } -function getExistingTweetParagraphBlocks(container: HTMLElement, options: TextBlockOptions): HTMLElement[] { +function getExistingTweetParagraphBlocks( + container: HTMLElement, + options: TextBlockOptions +): HTMLElement[] { return Array.from( - container.querySelectorAll(`[data-${toKebabCase(X_TWEET_PARAGRAPH_BLOCK_DATASET)}="true"]`) + container.querySelectorAll( + `[data-${toKebabCase(X_TWEET_PARAGRAPH_BLOCK_DATASET)}="true"]` + ) ).filter((element) => isTranslatableElement(element, options)); } diff --git a/apps/extension/src/content/siteAdapters/xFixtures.test.ts b/apps/extension/src/content/siteAdapters/xFixtures.test.ts index a88223d..e85e39f 100644 --- a/apps/extension/src/content/siteAdapters/xFixtures.test.ts +++ b/apps/extension/src/content/siteAdapters/xFixtures.test.ts @@ -3,7 +3,11 @@ import xArticleExpected from "../../../test/fixtures/extraction/site/x/article.e import xArticleHtml from "../../../test/fixtures/extraction/site/x/article.html?raw"; import xLongPostExpected from "../../../test/fixtures/extraction/site/x/long-post.expected.json?raw"; import xLongPostHtml from "../../../test/fixtures/extraction/site/x/long-post.html?raw"; -import type { BlockCandidateRole, BlockCandidateSource, BlockRenderStrategy } from "../blockCandidates"; +import type { + BlockCandidateRole, + BlockCandidateSource, + BlockRenderStrategy +} from "../blockCandidates"; import { createIncludedBlockCandidates } from "../extraction/shared"; import type { TextBlockOptions } from "../textBlocks"; import { xAdapter } from "./x"; @@ -136,7 +140,9 @@ function normalize(value: string): string { return value.replace(/\s+/g, " ").trim(); } -function omitUndefinedProperties(shape: ExpectedBlockShape): Partial> { +function omitUndefinedProperties( + shape: ExpectedBlockShape +): Partial> { return Object.fromEntries( Object.entries(shape).filter(([key, value]) => key !== "textIncludes" && value !== undefined) ); diff --git a/apps/extension/src/content/siteAdapters/youtube/captionTracks.test.ts b/apps/extension/src/content/siteAdapters/youtube/captionTracks.test.ts index ee76d6e..bc7c13e 100644 --- a/apps/extension/src/content/siteAdapters/youtube/captionTracks.test.ts +++ b/apps/extension/src/content/siteAdapters/youtube/captionTracks.test.ts @@ -111,7 +111,9 @@ describe("extractYouTubeCaptionTracks", () => { }); it("falls back to the first caption track when only auto-generated captions exist", () => { - expect(choosePreferredCaptionTrack([{ baseUrl: "https://example.com/asr", isAutoGenerated: true }])).toMatchObject({ + expect( + choosePreferredCaptionTrack([{ baseUrl: "https://example.com/asr", isAutoGenerated: true }]) + ).toMatchObject({ baseUrl: "https://example.com/asr" }); }); @@ -140,7 +142,9 @@ describe("extractYouTubeCaptionTracks", () => { }); vi.stubGlobal("fetch", fetchMock); - await expect(discoverYouTubeCaptionTracks(document, "https://www.youtube.com/watch?v=abc")).resolves.toEqual([ + await expect( + discoverYouTubeCaptionTracks(document, "https://www.youtube.com/watch?v=abc") + ).resolves.toEqual([ { baseUrl: "https://www.youtube.com/api/timedtext?v=abc&lang=en", languageCode: "en", @@ -168,14 +172,21 @@ describe("extractYouTubeCaptionTracks", () => { const fetchMock = vi.fn(); vi.stubGlobal("fetch", fetchMock); - await expect(discoverYouTubeCaptionTracks(document, "https://www.youtube.com/watch?v=abc")).resolves.toHaveLength(1); + await expect( + discoverYouTubeCaptionTracks(document, "https://www.youtube.com/watch?v=abc") + ).resolves.toHaveLength(1); expect(fetchMock).not.toHaveBeenCalled(); }); it("returns no discovered caption tracks when the watch page request fails", async () => { - vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false, text: () => Promise.resolve("") })); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ ok: false, text: () => Promise.resolve("") }) + ); - await expect(discoverYouTubeCaptionTracks(document, "https://www.youtube.com/watch?v=abc")).resolves.toEqual([]); + await expect( + discoverYouTubeCaptionTracks(document, "https://www.youtube.com/watch?v=abc") + ).resolves.toEqual([]); }); }); @@ -192,27 +203,31 @@ describe("caption cue parsing", () => { }); it("handles sparse json3 caption events", () => { - expect(parseJson3CaptionCues({ events: [{ segs: [{ utf8: "Untimed caption" }] }, {}] })).toEqual([ - { id: "cue-0", startMs: 0, durationMs: 0, text: "Untimed caption" } - ]); + expect( + parseJson3CaptionCues({ events: [{ segs: [{ utf8: "Untimed caption" }] }, {}] }) + ).toEqual([{ id: "cue-0", startMs: 0, durationMs: 0, text: "Untimed caption" }]); expect(parseJson3CaptionCues({})).toEqual([]); }); it("filters caption events without readable text", () => { expect(parseJson3CaptionCues({ events: [{ segs: [{}] }] })).toEqual([]); - expect(parseXmlCaptionCues(``)).toEqual([]); + expect(parseXmlCaptionCues(``)).toEqual( + [] + ); }); it("parses XML timed text and decodes entities", () => { - expect(parseXmlCaptionCues(`Tom &amp; Jerry`)).toEqual([ - { id: "cue-0", startMs: 1500, durationMs: 2000, text: "Tom & Jerry" } - ]); + expect( + parseXmlCaptionCues( + `Tom &amp; Jerry` + ) + ).toEqual([{ id: "cue-0", startMs: 1500, durationMs: 2000, text: "Tom & Jerry" }]); }); it("uses zero timing for malformed XML timestamps", () => { - expect(parseXmlCaptionCues(`No timing`)).toEqual([ - { id: "cue-0", startMs: 0, durationMs: 0, text: "No timing" } - ]); + expect( + parseXmlCaptionCues(`No timing`) + ).toEqual([{ id: "cue-0", startMs: 0, durationMs: 0, text: "No timing" }]); }); it("fetches captions as json3 by appending the format parameter", async () => { @@ -229,9 +244,14 @@ describe("caption cue parsing", () => { vi.stubGlobal("fetch", fetchMock); await expect( - fetchYouTubeCaptionCues({ baseUrl: "https://www.youtube.com/api/timedtext?v=abc&lang=en", isAutoGenerated: false }) + fetchYouTubeCaptionCues({ + baseUrl: "https://www.youtube.com/api/timedtext?v=abc&lang=en", + isAutoGenerated: false + }) ).resolves.toEqual([{ id: "cue-0", startMs: 1000, durationMs: 2000, text: "Hello" }]); - expect(fetchMock).toHaveBeenCalledWith("https://www.youtube.com/api/timedtext?v=abc&lang=en&fmt=json3"); + expect(fetchMock).toHaveBeenCalledWith( + "https://www.youtube.com/api/timedtext?v=abc&lang=en&fmt=json3" + ); }); it("parses fetched JSON captions even without a JSON content type", async () => { @@ -240,12 +260,16 @@ describe("caption cue parsing", () => { vi.fn().mockResolvedValue({ ok: true, headers: new Headers(), - text: () => Promise.resolve(JSON.stringify({ events: [{ segs: [{ utf8: "No content type" }] }] })) + text: () => + Promise.resolve(JSON.stringify({ events: [{ segs: [{ utf8: "No content type" }] }] })) }) ); await expect( - fetchYouTubeCaptionCues({ baseUrl: "https://www.youtube.com/api/timedtext?v=abc&lang=en", isAutoGenerated: false }) + fetchYouTubeCaptionCues({ + baseUrl: "https://www.youtube.com/api/timedtext?v=abc&lang=en", + isAutoGenerated: false + }) ).resolves.toEqual([{ id: "cue-0", startMs: 0, durationMs: 0, text: "No content type" }]); }); @@ -255,12 +279,16 @@ describe("caption cue parsing", () => { vi.fn().mockResolvedValue({ ok: true, headers: new Headers({ "content-type": "text/xml" }), - text: () => Promise.resolve(`Hello XML`) + text: () => + Promise.resolve(`Hello XML`) }) ); await expect( - fetchYouTubeCaptionCues({ baseUrl: "https://www.youtube.com/api/timedtext?v=abc&lang=en", isAutoGenerated: false }) + fetchYouTubeCaptionCues({ + baseUrl: "https://www.youtube.com/api/timedtext?v=abc&lang=en", + isAutoGenerated: false + }) ).resolves.toEqual([{ id: "cue-0", startMs: 2000, durationMs: 1000, text: "Hello XML" }]); }); @@ -276,7 +304,10 @@ describe("caption cue parsing", () => { ); await expect( - fetchYouTubeCaptionCues({ baseUrl: "https://www.youtube.com/api/timedtext?v=abc&lang=en", isAutoGenerated: false }) + fetchYouTubeCaptionCues({ + baseUrl: "https://www.youtube.com/api/timedtext?v=abc&lang=en", + isAutoGenerated: false + }) ).rejects.toThrow("Caption track request failed with 429."); }); }); diff --git a/apps/extension/src/content/siteAdapters/youtube/captionTracks.ts b/apps/extension/src/content/siteAdapters/youtube/captionTracks.ts index 0fa0f5c..e2a5c7d 100644 --- a/apps/extension/src/content/siteAdapters/youtube/captionTracks.ts +++ b/apps/extension/src/content/siteAdapters/youtube/captionTracks.ts @@ -33,7 +33,9 @@ interface Json3TimedText { }>; } -export function extractYouTubeCaptionTracks(documentRoot: Document = document): YouTubeCaptionTrack[] { +export function extractYouTubeCaptionTracks( + documentRoot: Document = document +): YouTubeCaptionTrack[] { const response = extractPlayerResponse(documentRoot); return getCaptionTracksFromPlayerResponse(response); } @@ -57,11 +59,15 @@ export async function discoverYouTubeCaptionTracks( return extractYouTubeCaptionTracks(fetchedDocument); } -function getCaptionTracksFromPlayerResponse(response: PlayerResponse | undefined): YouTubeCaptionTrack[] { +function getCaptionTracksFromPlayerResponse( + response: PlayerResponse | undefined +): YouTubeCaptionTrack[] { const tracks = response?.captions?.playerCaptionsTracklistRenderer?.captionTracks ?? []; return tracks - .filter((track): track is Required> & typeof track => Boolean(track.baseUrl)) + .filter((track): track is Required> & typeof track => + Boolean(track.baseUrl) + ) .map((track) => ({ baseUrl: track.baseUrl, languageCode: track.languageCode, @@ -70,11 +76,15 @@ function getCaptionTracksFromPlayerResponse(response: PlayerResponse | undefined })); } -export function choosePreferredCaptionTrack(tracks: YouTubeCaptionTrack[]): YouTubeCaptionTrack | undefined { +export function choosePreferredCaptionTrack( + tracks: YouTubeCaptionTrack[] +): YouTubeCaptionTrack | undefined { return tracks.find((track) => !track.isAutoGenerated) ?? tracks[0]; } -export async function fetchYouTubeCaptionCues(track: YouTubeCaptionTrack): Promise { +export async function fetchYouTubeCaptionCues( + track: YouTubeCaptionTrack +): Promise { return fetchYouTubeCaptionCuesFromUrl(getJson3TimedTextUrl(track.baseUrl)); } @@ -95,7 +105,9 @@ export async function fetchYouTubeCaptionCuesFromUrl(url: string): Promise { - const text = normalizeCaptionText((event.segs ?? []).map((segment) => segment.utf8 ?? "").join("")); + const text = normalizeCaptionText( + (event.segs ?? []).map((segment) => segment.utf8 ?? "").join("") + ); return { id: `cue-${index}`, startMs: event.tStartMs ?? 0, @@ -189,8 +201,18 @@ function getJson3TimedTextUrl(baseUrl: string): string { return url.toString(); } -function getTrackName(name?: { simpleText?: string; runs?: Array<{ text?: string }> }): string | undefined { - return name?.simpleText ?? name?.runs?.map((run) => run.text ?? "").join("").trim() ?? undefined; +function getTrackName(name?: { + simpleText?: string; + runs?: Array<{ text?: string }>; +}): string | undefined { + return ( + name?.simpleText ?? + name?.runs + ?.map((run) => run.text ?? "") + .join("") + .trim() ?? + undefined + ); } function secondsToMs(value: string | null): number { diff --git a/apps/extension/src/content/siteAdapters/youtube/controls.test.ts b/apps/extension/src/content/siteAdapters/youtube/controls.test.ts index 9ee591d..8283dd2 100644 --- a/apps/extension/src/content/siteAdapters/youtube/controls.test.ts +++ b/apps/extension/src/content/siteAdapters/youtube/controls.test.ts @@ -115,7 +115,10 @@ describe("initializeYouTubeControls", () => { expect(Array.from(captionItems ?? [], (item) => item.disabled)).toEqual([false, false]); expect( - Array.from(captionItems ?? [], (item) => item.querySelector(".margin-youtube__menu-detail")?.textContent) + Array.from( + captionItems ?? [], + (item) => item.querySelector(".margin-youtube__menu-detail")?.textContent + ) ).toEqual([ "No YouTube captions detected. AI subtitles will need speech-to-text support.", "No YouTube captions detected. AI subtitles will need speech-to-text support." @@ -137,13 +140,17 @@ describe("initializeYouTubeControls", () => { vi.advanceTimersByTime(250); const host = document.querySelector("#margin-youtube-subtitle-control"); - expect(host?.shadowRoot?.querySelector(".margin-youtube")?.getAttribute("data-has-captions")).toBe("false"); + expect( + host?.shadowRoot?.querySelector(".margin-youtube")?.getAttribute("data-has-captions") + ).toBe("false"); }); it("shows why captions cannot be translated when no YouTube caption track exists", async () => { const host = await mountYouTubeControl(); - host.shadowRoot?.querySelector('.margin-youtube__menu-item[data-action="bilingual"]')?.click(); + host.shadowRoot + ?.querySelector('.margin-youtube__menu-item[data-action="bilingual"]') + ?.click(); await flushPromises(); expect( @@ -200,7 +207,9 @@ describe("initializeYouTubeControls", () => { ` ); - host.shadowRoot?.querySelector('.margin-youtube__menu-item[data-action="bilingual"]')?.click(); + host.shadowRoot + ?.querySelector('.margin-youtube__menu-item[data-action="bilingual"]') + ?.click(); await Promise.resolve(); await Promise.resolve(); @@ -227,12 +236,16 @@ describe("initializeYouTubeControls", () => { return Promise.resolve({ ok: true, settings: { targetLanguage: "Japanese" } }); }); const host = await mountYouTubeControl(); - document.querySelector(".html5-video-player")?.insertAdjacentHTML( - "beforeend", - `
Hello world
` - ); + document + .querySelector(".html5-video-player") + ?.insertAdjacentHTML( + "beforeend", + `
Hello world
` + ); - host.shadowRoot?.querySelector('.margin-youtube__menu-item[data-action="bilingual"]')?.click(); + host.shadowRoot + ?.querySelector('.margin-youtube__menu-item[data-action="bilingual"]') + ?.click(); await Promise.resolve(); await Promise.resolve(); @@ -249,15 +262,20 @@ describe("initializeYouTubeControls", () => { }); it("translates caption tracks in batches and renders by video time", async () => { - sendMessage.mockImplementation((message: { type?: string; segments?: Array<{ id: string; text: string }> }) => { - if (message.type === "TRANSLATE_BATCH") { - return Promise.resolve({ - ok: true, - results: (message.segments ?? []).map((segment) => ({ id: segment.id, text: `zh: ${segment.text}` })) - }); + sendMessage.mockImplementation( + (message: { type?: string; segments?: Array<{ id: string; text: string }> }) => { + if (message.type === "TRANSLATE_BATCH") { + return Promise.resolve({ + ok: true, + results: (message.segments ?? []).map((segment) => ({ + id: segment.id, + text: `zh: ${segment.text}` + })) + }); + } + return Promise.resolve({ ok: true, settings: { targetLanguage: "Japanese" } }); } - return Promise.resolve({ ok: true, settings: { targetLanguage: "Japanese" } }); - }); + ); vi.stubGlobal( "fetch", vi.fn().mockResolvedValue({ @@ -277,7 +295,9 @@ describe("initializeYouTubeControls", () => { const host = await mountYouTubeControlWithTrack(); setVideoTime(1.2); - host.shadowRoot?.querySelector('.margin-youtube__menu-item[data-action="bilingual"]')?.click(); + host.shadowRoot + ?.querySelector('.margin-youtube__menu-item[data-action="bilingual"]') + ?.click(); await flushPromises(); const overlay = document @@ -298,15 +318,20 @@ describe("initializeYouTubeControls", () => { }); it("prioritizes caption track translation from the current playback time", async () => { - sendMessage.mockImplementation((message: { type?: string; segments?: Array<{ id: string; text: string }> }) => { - if (message.type === "TRANSLATE_BATCH") { - return Promise.resolve({ - ok: true, - results: (message.segments ?? []).map((segment) => ({ id: segment.id, text: segment.text })) - }); + sendMessage.mockImplementation( + (message: { type?: string; segments?: Array<{ id: string; text: string }> }) => { + if (message.type === "TRANSLATE_BATCH") { + return Promise.resolve({ + ok: true, + results: (message.segments ?? []).map((segment) => ({ + id: segment.id, + text: segment.text + })) + }); + } + return Promise.resolve({ ok: true, settings: { targetLanguage: "Japanese" } }); } - return Promise.resolve({ ok: true, settings: { targetLanguage: "Japanese" } }); - }); + ); vi.stubGlobal( "fetch", vi.fn().mockResolvedValue({ @@ -327,7 +352,9 @@ describe("initializeYouTubeControls", () => { const host = await mountYouTubeControlWithTrack(); setVideoTime(4.2); - host.shadowRoot?.querySelector('.margin-youtube__menu-item[data-action="bilingual"]')?.click(); + host.shadowRoot + ?.querySelector('.margin-youtube__menu-item[data-action="bilingual"]') + ?.click(); await flushPromises(); expect(sendMessage).toHaveBeenLastCalledWith({ @@ -341,15 +368,20 @@ describe("initializeYouTubeControls", () => { }); it("hides the track overlay when playback is outside active cues", async () => { - sendMessage.mockImplementation((message: { type?: string; segments?: Array<{ id: string; text: string }> }) => { - if (message.type === "TRANSLATE_BATCH") { - return Promise.resolve({ - ok: true, - results: (message.segments ?? []).map((segment) => ({ id: segment.id, text: segment.text })) - }); + sendMessage.mockImplementation( + (message: { type?: string; segments?: Array<{ id: string; text: string }> }) => { + if (message.type === "TRANSLATE_BATCH") { + return Promise.resolve({ + ok: true, + results: (message.segments ?? []).map((segment) => ({ + id: segment.id, + text: segment.text + })) + }); + } + return Promise.resolve({ ok: true, settings: { targetLanguage: "Japanese" } }); } - return Promise.resolve({ ok: true, settings: { targetLanguage: "Japanese" } }); - }); + ); vi.stubGlobal( "fetch", vi.fn().mockResolvedValue({ @@ -366,7 +398,9 @@ describe("initializeYouTubeControls", () => { const host = await mountYouTubeControlWithTrack(); setVideoTime(1.2); - host.shadowRoot?.querySelector('.margin-youtube__menu-item[data-action="bilingual"]')?.click(); + host.shadowRoot + ?.querySelector('.margin-youtube__menu-item[data-action="bilingual"]') + ?.click(); await flushPromises(); setVideoTime(3); vi.advanceTimersByTime(100); @@ -378,15 +412,20 @@ describe("initializeYouTubeControls", () => { }); it("does not fall back to DOM caption requests after a caption track is active", async () => { - sendMessage.mockImplementation((message: { type?: string; segments?: Array<{ id: string; text: string }> }) => { - if (message.type === "TRANSLATE_BATCH") { - return Promise.resolve({ - ok: true, - results: (message.segments ?? []).map((segment) => ({ id: segment.id, text: segment.text })) - }); + sendMessage.mockImplementation( + (message: { type?: string; segments?: Array<{ id: string; text: string }> }) => { + if (message.type === "TRANSLATE_BATCH") { + return Promise.resolve({ + ok: true, + results: (message.segments ?? []).map((segment) => ({ + id: segment.id, + text: segment.text + })) + }); + } + return Promise.resolve({ ok: true, settings: { targetLanguage: "Japanese" } }); } - return Promise.resolve({ ok: true, settings: { targetLanguage: "Japanese" } }); - }); + ); vi.stubGlobal( "fetch", vi.fn().mockResolvedValue({ @@ -403,13 +442,17 @@ describe("initializeYouTubeControls", () => { const host = await mountYouTubeControlWithTrack(); setVideoTime(1.2); - host.shadowRoot?.querySelector('.margin-youtube__menu-item[data-action="bilingual"]')?.click(); + host.shadowRoot + ?.querySelector('.margin-youtube__menu-item[data-action="bilingual"]') + ?.click(); await flushPromises(); const callsAfterTrack = getTranslationCallCount(); - document.querySelector(".html5-video-player")?.insertAdjacentHTML( - "beforeend", - `
DOM caption
` - ); + document + .querySelector(".html5-video-player") + ?.insertAdjacentHTML( + "beforeend", + `
DOM caption
` + ); await Promise.resolve(); vi.advanceTimersByTime(20); @@ -417,15 +460,20 @@ describe("initializeYouTubeControls", () => { }); it("renders the first caption track cue when no video element is mounted", async () => { - sendMessage.mockImplementation((message: { type?: string; segments?: Array<{ id: string; text: string }> }) => { - if (message.type === "TRANSLATE_BATCH") { - return Promise.resolve({ - ok: true, - results: (message.segments ?? []).map((segment) => ({ id: segment.id, text: `zh: ${segment.text}` })) - }); + sendMessage.mockImplementation( + (message: { type?: string; segments?: Array<{ id: string; text: string }> }) => { + if (message.type === "TRANSLATE_BATCH") { + return Promise.resolve({ + ok: true, + results: (message.segments ?? []).map((segment) => ({ + id: segment.id, + text: `zh: ${segment.text}` + })) + }); + } + return Promise.resolve({ ok: true, settings: { targetLanguage: "Japanese" } }); } - return Promise.resolve({ ok: true, settings: { targetLanguage: "Japanese" } }); - }); + ); vi.stubGlobal( "fetch", vi.fn().mockResolvedValue({ @@ -441,7 +489,9 @@ describe("initializeYouTubeControls", () => { ); const host = await mountYouTubeControlWithTrack({ withVideo: false }); - host.shadowRoot?.querySelector('.margin-youtube__menu-item[data-action="bilingual"]')?.click(); + host.shadowRoot + ?.querySelector('.margin-youtube__menu-item[data-action="bilingual"]') + ?.click(); await flushPromises(); expect( @@ -495,12 +545,16 @@ describe("initializeYouTubeControls", () => { }) ); const host = await mountYouTubeControlWithTrack(); - document.querySelector(".html5-video-player")?.insertAdjacentHTML( - "beforeend", - `
DOM caption
` - ); + document + .querySelector(".html5-video-player") + ?.insertAdjacentHTML( + "beforeend", + `
DOM caption
` + ); - host.shadowRoot?.querySelector('.margin-youtube__menu-item[data-action="bilingual"]')?.click(); + host.shadowRoot + ?.querySelector('.margin-youtube__menu-item[data-action="bilingual"]') + ?.click(); await flushPromises(); expect(sendMessage).toHaveBeenLastCalledWith({ @@ -510,15 +564,20 @@ describe("initializeYouTubeControls", () => { }); it("uses a loaded YouTube timedtext resource when the direct caption track is empty", async () => { - sendMessage.mockImplementation((message: { type?: string; segments?: Array<{ id: string; text: string }> }) => { - if (message.type === "TRANSLATE_BATCH") { - return Promise.resolve({ - ok: true, - results: (message.segments ?? []).map((segment) => ({ id: segment.id, text: `zh: ${segment.text}` })) - }); + sendMessage.mockImplementation( + (message: { type?: string; segments?: Array<{ id: string; text: string }> }) => { + if (message.type === "TRANSLATE_BATCH") { + return Promise.resolve({ + ok: true, + results: (message.segments ?? []).map((segment) => ({ + id: segment.id, + text: `zh: ${segment.text}` + })) + }); + } + return Promise.resolve({ ok: true, settings: { targetLanguage: "Japanese" } }); } - return Promise.resolve({ ok: true, settings: { targetLanguage: "Japanese" } }); - }); + ); vi.stubGlobal( "fetch", vi @@ -548,10 +607,14 @@ describe("initializeYouTubeControls", () => { const host = await mountYouTubeControlWithTrack(); setVideoTime(1.2); - host.shadowRoot?.querySelector('.margin-youtube__menu-item[data-action="bilingual"]')?.click(); + host.shadowRoot + ?.querySelector('.margin-youtube__menu-item[data-action="bilingual"]') + ?.click(); await flushPromises(); - expect(fetch).toHaveBeenLastCalledWith("https://www.youtube.com/api/timedtext?v=abc&fmt=json3&pot=token"); + expect(fetch).toHaveBeenLastCalledWith( + "https://www.youtube.com/api/timedtext?v=abc&fmt=json3&pot=token" + ); expect( document .querySelector("#margin-youtube-caption-overlay") @@ -561,15 +624,20 @@ describe("initializeYouTubeControls", () => { }); it("primes YouTube timedtext while keeping native captions hidden", async () => { - sendMessage.mockImplementation((message: { type?: string; segments?: Array<{ id: string; text: string }> }) => { - if (message.type === "TRANSLATE_BATCH") { - return Promise.resolve({ - ok: true, - results: (message.segments ?? []).map((segment) => ({ id: segment.id, text: `zh: ${segment.text}` })) - }); + sendMessage.mockImplementation( + (message: { type?: string; segments?: Array<{ id: string; text: string }> }) => { + if (message.type === "TRANSLATE_BATCH") { + return Promise.resolve({ + ok: true, + results: (message.segments ?? []).map((segment) => ({ + id: segment.id, + text: `zh: ${segment.text}` + })) + }); + } + return Promise.resolve({ ok: true, settings: { targetLanguage: "Japanese" } }); } - return Promise.resolve({ ok: true, settings: { targetLanguage: "Japanese" } }); - }); + ); vi.stubGlobal( "fetch", vi @@ -585,20 +653,27 @@ describe("initializeYouTubeControls", () => { text: () => Promise.resolve( JSON.stringify({ - events: [{ tStartMs: 1000, dDurationMs: 1000, segs: [{ utf8: "Hidden native cue" }] }] + events: [ + { tStartMs: 1000, dDurationMs: 1000, segs: [{ utf8: "Hidden native cue" }] } + ] }) ) }) ); let timedTextEntries: PerformanceEntry[] = []; - const performanceSpy = vi.spyOn(performance, "getEntriesByType").mockImplementation(() => timedTextEntries); + const performanceSpy = vi + .spyOn(performance, "getEntriesByType") + .mockImplementation(() => timedTextEntries); const host = await mountYouTubeControlWithTrack(); const subtitlesButton = document.querySelector(".ytp-subtitles-button")!; let subtitlesButtonClicks = 0; subtitlesButton.setAttribute("aria-pressed", "false"); subtitlesButton.addEventListener("click", () => { subtitlesButtonClicks += 1; - subtitlesButton.setAttribute("aria-pressed", subtitlesButtonClicks % 2 === 1 ? "true" : "false"); + subtitlesButton.setAttribute( + "aria-pressed", + subtitlesButtonClicks % 2 === 1 ? "true" : "false" + ); if (subtitlesButtonClicks === 1) { timedTextEntries = [ { @@ -617,7 +692,9 @@ describe("initializeYouTubeControls", () => { await flushPromises(); expect(subtitlesButtonClicks).toBe(2); - expect(document.getElementById("margin-youtube-native-caption-style")).toBeInstanceOf(HTMLStyleElement); + expect(document.getElementById("margin-youtube-native-caption-style")).toBeInstanceOf( + HTMLStyleElement + ); expect( document .querySelector("#margin-youtube-caption-overlay") @@ -630,15 +707,20 @@ describe("initializeYouTubeControls", () => { }); it("does not toggle YouTube captions when they are already enabled for priming", async () => { - sendMessage.mockImplementation((message: { type?: string; segments?: Array<{ id: string; text: string }> }) => { - if (message.type === "TRANSLATE_BATCH") { - return Promise.resolve({ - ok: true, - results: (message.segments ?? []).map((segment) => ({ id: segment.id, text: segment.text })) - }); + sendMessage.mockImplementation( + (message: { type?: string; segments?: Array<{ id: string; text: string }> }) => { + if (message.type === "TRANSLATE_BATCH") { + return Promise.resolve({ + ok: true, + results: (message.segments ?? []).map((segment) => ({ + id: segment.id, + text: segment.text + })) + }); + } + return Promise.resolve({ ok: true, settings: { targetLanguage: "Japanese" } }); } - return Promise.resolve({ ok: true, settings: { targetLanguage: "Japanese" } }); - }); + ); vi.stubGlobal( "fetch", vi @@ -654,7 +736,9 @@ describe("initializeYouTubeControls", () => { text: () => Promise.resolve( JSON.stringify({ - events: [{ tStartMs: 1000, dDurationMs: 1000, segs: [{ utf8: "Already enabled cue" }] }] + events: [ + { tStartMs: 1000, dDurationMs: 1000, segs: [{ utf8: "Already enabled cue" }] } + ] }) ) }) @@ -677,7 +761,9 @@ describe("initializeYouTubeControls", () => { subtitlesButton.setAttribute("aria-pressed", "true"); setVideoTime(1.2); - host.shadowRoot?.querySelector('.margin-youtube__menu-item[data-action="bilingual"]')?.click(); + host.shadowRoot + ?.querySelector('.margin-youtube__menu-item[data-action="bilingual"]') + ?.click(); await flushPromises(); expect(clickSpy).not.toHaveBeenCalled(); @@ -711,7 +797,9 @@ describe("initializeYouTubeControls", () => { ); const host = await mountYouTubeControlWithTrack(); - host.shadowRoot?.querySelector('.margin-youtube__menu-item[data-action="bilingual"]')?.click(); + host.shadowRoot + ?.querySelector('.margin-youtube__menu-item[data-action="bilingual"]') + ?.click(); await flushPromises(); expect( @@ -723,15 +811,20 @@ describe("initializeYouTubeControls", () => { it("translates long caption tracks in multiple batches", async () => { const callsBeforeTrack = getTranslationCallCount(); - sendMessage.mockImplementation((message: { type?: string; segments?: Array<{ id: string; text: string }> }) => { - if (message.type === "TRANSLATE_BATCH") { - return Promise.resolve({ - ok: true, - results: (message.segments ?? []).map((segment) => ({ id: segment.id, text: segment.text })) - }); + sendMessage.mockImplementation( + (message: { type?: string; segments?: Array<{ id: string; text: string }> }) => { + if (message.type === "TRANSLATE_BATCH") { + return Promise.resolve({ + ok: true, + results: (message.segments ?? []).map((segment) => ({ + id: segment.id, + text: segment.text + })) + }); + } + return Promise.resolve({ ok: true, settings: { targetLanguage: "Japanese" } }); } - return Promise.resolve({ ok: true, settings: { targetLanguage: "Japanese" } }); - }); + ); vi.stubGlobal( "fetch", vi.fn().mockResolvedValue({ @@ -751,12 +844,14 @@ describe("initializeYouTubeControls", () => { ); const host = await mountYouTubeControlWithTrack(); - host.shadowRoot?.querySelector('.margin-youtube__menu-item[data-action="bilingual"]')?.click(); + host.shadowRoot + ?.querySelector('.margin-youtube__menu-item[data-action="bilingual"]') + ?.click(); await flushPromises(); - const captionCalls = sendMessage.mock.calls.filter( - ([message]) => (message as { type?: string } | undefined)?.type === "TRANSLATE_BATCH" - ).slice(callsBeforeTrack); + const captionCalls = sendMessage.mock.calls + .filter(([message]) => (message as { type?: string } | undefined)?.type === "TRANSLATE_BATCH") + .slice(callsBeforeTrack); expect(captionCalls).toHaveLength(2); expect((captionCalls[0][0] as { segments: unknown[] }).segments).toHaveLength(32); expect((captionCalls[1][0] as { segments: unknown[] }).segments).toHaveLength(1); @@ -773,7 +868,9 @@ describe("initializeYouTubeControls", () => { ` ); - host.shadowRoot?.querySelector('.margin-youtube__menu-item[data-action="bilingual"]')?.click(); + host.shadowRoot + ?.querySelector('.margin-youtube__menu-item[data-action="bilingual"]') + ?.click(); await Promise.resolve(); await Promise.resolve(); @@ -789,10 +886,12 @@ describe("initializeYouTubeControls", () => { it("turns caption translation off when the active mode is selected again", async () => { const host = await mountYouTubeControl(); - document.querySelector(".html5-video-player")?.insertAdjacentHTML( - "beforeend", - `
Hello world
` - ); + document + .querySelector(".html5-video-player") + ?.insertAdjacentHTML( + "beforeend", + `
Hello world
` + ); const bilingualItem = host.shadowRoot?.querySelector( '.margin-youtube__menu-item[data-action="bilingual"]' ); @@ -802,18 +901,26 @@ describe("initializeYouTubeControls", () => { bilingualItem?.click(); expect(document.querySelector("#margin-youtube-caption-overlay")).toBeNull(); - expect(host.shadowRoot?.querySelector(".margin-youtube")?.getAttribute("data-mode")).toBe("idle"); - expect(host.shadowRoot?.querySelector(".margin-youtube__button")?.getAttribute("aria-pressed")).toBe("false"); + expect(host.shadowRoot?.querySelector(".margin-youtube")?.getAttribute("data-mode")).toBe( + "idle" + ); + expect( + host.shadowRoot?.querySelector(".margin-youtube__button")?.getAttribute("aria-pressed") + ).toBe("false"); }); it("marks translated caption overlay mode separately", async () => { const host = await mountYouTubeControl(); - document.querySelector(".html5-video-player")?.insertAdjacentHTML( - "beforeend", - `
Hello world
` - ); + document + .querySelector(".html5-video-player") + ?.insertAdjacentHTML( + "beforeend", + `
Hello world
` + ); - host.shadowRoot?.querySelector('.margin-youtube__menu-item[data-action="translated"]')?.click(); + host.shadowRoot + ?.querySelector('.margin-youtube__menu-item[data-action="translated"]') + ?.click(); await Promise.resolve(); await Promise.resolve(); @@ -827,12 +934,16 @@ describe("initializeYouTubeControls", () => { it("does not translate the same caption text twice", async () => { const host = await mountYouTubeControl(); - document.querySelector(".html5-video-player")?.insertAdjacentHTML( - "beforeend", - `
Hello world
` - ); + document + .querySelector(".html5-video-player") + ?.insertAdjacentHTML( + "beforeend", + `
Hello world
` + ); - host.shadowRoot?.querySelector('.margin-youtube__menu-item[data-action="bilingual"]')?.click(); + host.shadowRoot + ?.querySelector('.margin-youtube__menu-item[data-action="bilingual"]') + ?.click(); await Promise.resolve(); await Promise.resolve(); const callsAfterFirstCaption = sendMessage.mock.calls.length; @@ -847,12 +958,16 @@ describe("initializeYouTubeControls", () => { it("debounces rapid caption mutations before refreshing", async () => { const host = await mountYouTubeControl(); - document.querySelector(".html5-video-player")?.insertAdjacentHTML( - "beforeend", - `
Hello
` - ); + document + .querySelector(".html5-video-player") + ?.insertAdjacentHTML( + "beforeend", + `
Hello
` + ); - host.shadowRoot?.querySelector('.margin-youtube__menu-item[data-action="bilingual"]')?.click(); + host.shadowRoot + ?.querySelector('.margin-youtube__menu-item[data-action="bilingual"]') + ?.click(); await Promise.resolve(); await Promise.resolve(); const callsAfterFirstCaption = sendMessage.mock.calls.length; @@ -876,12 +991,16 @@ describe("initializeYouTubeControls", () => { return Promise.resolve({ ok: true, settings: { targetLanguage: "Japanese" } }); }); const host = await mountYouTubeControl(); - document.querySelector(".html5-video-player")?.insertAdjacentHTML( - "beforeend", - `
Hello world
` - ); + document + .querySelector(".html5-video-player") + ?.insertAdjacentHTML( + "beforeend", + `
Hello world
` + ); - host.shadowRoot?.querySelector('.margin-youtube__menu-item[data-action="bilingual"]')?.click(); + host.shadowRoot + ?.querySelector('.margin-youtube__menu-item[data-action="bilingual"]') + ?.click(); await Promise.resolve(); await Promise.resolve(); @@ -900,12 +1019,16 @@ describe("initializeYouTubeControls", () => { return Promise.resolve({ ok: true, settings: { targetLanguage: "Japanese" } }); }); const host = await mountYouTubeControl(); - document.querySelector(".html5-video-player")?.insertAdjacentHTML( - "beforeend", - `
Hello world
` - ); + document + .querySelector(".html5-video-player") + ?.insertAdjacentHTML( + "beforeend", + `
Hello world
` + ); - host.shadowRoot?.querySelector('.margin-youtube__menu-item[data-action="bilingual"]')?.click(); + host.shadowRoot + ?.querySelector('.margin-youtube__menu-item[data-action="bilingual"]') + ?.click(); await Promise.resolve(); await Promise.resolve(); @@ -924,12 +1047,16 @@ describe("initializeYouTubeControls", () => { return Promise.resolve({ ok: true, settings: { targetLanguage: "Japanese" } }); }); const host = await mountYouTubeControl(); - document.querySelector(".html5-video-player")?.insertAdjacentHTML( - "beforeend", - `
Hello world
` - ); + document + .querySelector(".html5-video-player") + ?.insertAdjacentHTML( + "beforeend", + `
Hello world
` + ); - host.shadowRoot?.querySelector('.margin-youtube__menu-item[data-action="bilingual"]')?.click(); + host.shadowRoot + ?.querySelector('.margin-youtube__menu-item[data-action="bilingual"]') + ?.click(); await Promise.resolve(); await Promise.resolve(); @@ -948,7 +1075,9 @@ describe("initializeYouTubeControls", () => { `
Hello world
` ); - host.shadowRoot?.querySelector('.margin-youtube__menu-item[data-action="bilingual"]')?.click(); + host.shadowRoot + ?.querySelector('.margin-youtube__menu-item[data-action="bilingual"]') + ?.click(); await Promise.resolve(); await Promise.resolve(); document @@ -965,7 +1094,10 @@ describe("initializeYouTubeControls", () => { vi.advanceTimersByTime(20); await Promise.resolve(); - expect(document.querySelector("#margin-youtube-caption-overlay")?.shadowRoot?.children.length).toBe(1); + expect( + document.querySelector("#margin-youtube-caption-overlay")?.shadowRoot?.children + .length + ).toBe(1); }); it("ignores caption responses after the mode is turned off", async () => { @@ -982,10 +1114,12 @@ describe("initializeYouTubeControls", () => { const bilingualItem = host.shadowRoot?.querySelector( '.margin-youtube__menu-item[data-action="bilingual"]' ); - document.querySelector(".html5-video-player")?.insertAdjacentHTML( - "beforeend", - `
Hello world
` - ); + document + .querySelector(".html5-video-player") + ?.insertAdjacentHTML( + "beforeend", + `
Hello world
` + ); bilingualItem?.click(); bilingualItem?.click(); @@ -1006,12 +1140,16 @@ describe("initializeYouTubeControls", () => { return Promise.resolve({ ok: true, settings: { targetLanguage: "Japanese" } }); }); const host = await mountYouTubeControl(); - document.querySelector(".html5-video-player")?.insertAdjacentHTML( - "beforeend", - `
Hello world
` - ); + document + .querySelector(".html5-video-player") + ?.insertAdjacentHTML( + "beforeend", + `
Hello world
` + ); - host.shadowRoot?.querySelector('.margin-youtube__menu-item[data-action="bilingual"]')?.click(); + host.shadowRoot + ?.querySelector('.margin-youtube__menu-item[data-action="bilingual"]') + ?.click(); await Promise.resolve(); document.querySelector(".ytp-caption-window-container")?.remove(); await Promise.resolve(); @@ -1033,7 +1171,9 @@ describe("initializeYouTubeControls", () => { `
Hello world
` ); - host.shadowRoot?.querySelector('.margin-youtube__menu-item[data-action="bilingual"]')?.click(); + host.shadowRoot + ?.querySelector('.margin-youtube__menu-item[data-action="bilingual"]') + ?.click(); await Promise.resolve(); await Promise.resolve(); const callsAfterFirstCaption = sendMessage.mock.calls.length; @@ -1063,7 +1203,9 @@ describe("initializeYouTubeControls", () => { `
caption 0
` ); - host.shadowRoot?.querySelector('.margin-youtube__menu-item[data-action="bilingual"]')?.click(); + host.shadowRoot + ?.querySelector('.margin-youtube__menu-item[data-action="bilingual"]') + ?.click(); await Promise.resolve(); await Promise.resolve(); @@ -1105,7 +1247,9 @@ describe("initializeYouTubeControls", () => { vi.advanceTimersByTime(250); document .querySelector("#margin-youtube-subtitle-control") - ?.shadowRoot?.querySelector('.margin-youtube__menu-item[data-action="bilingual"]') + ?.shadowRoot?.querySelector( + '.margin-youtube__menu-item[data-action="bilingual"]' + ) ?.click(); expect(document.querySelector("#margin-youtube-caption-overlay")).toBeNull(); @@ -1113,11 +1257,15 @@ describe("initializeYouTubeControls", () => { it("removes caption overlay when leaving the watch page", async () => { const host = await mountYouTubeControl(); - document.querySelector(".html5-video-player")?.insertAdjacentHTML( - "beforeend", - `
Hello world
` - ); - host.shadowRoot?.querySelector('.margin-youtube__menu-item[data-action="bilingual"]')?.click(); + document + .querySelector(".html5-video-player") + ?.insertAdjacentHTML( + "beforeend", + `
Hello world
` + ); + host.shadowRoot + ?.querySelector('.margin-youtube__menu-item[data-action="bilingual"]') + ?.click(); await Promise.resolve(); setPageUrl("https://www.youtube.com/feed/subscriptions"); @@ -1130,7 +1278,9 @@ describe("initializeYouTubeControls", () => { it("opens extension settings from the menu", async () => { const host = await mountYouTubeControl(); - const settingsItem = host.shadowRoot?.querySelectorAll(".margin-youtube__menu-item")[2]; + const settingsItem = host.shadowRoot?.querySelectorAll( + ".margin-youtube__menu-item" + )[2]; settingsItem?.click(); @@ -1155,9 +1305,9 @@ describe("initializeYouTubeControls", () => { const host = await mountYouTubeControl(); await Promise.resolve(); - expect(host.shadowRoot?.querySelector(".margin-youtube__button")?.getAttribute("aria-label")).toBe( - "Margin captions into English" - ); + expect( + host.shadowRoot?.querySelector(".margin-youtube__button")?.getAttribute("aria-label") + ).toBe("Margin captions into English"); }); it("rescans when YouTube changes player DOM", async () => { @@ -1180,9 +1330,9 @@ describe("initializeYouTubeControls", () => { storageListener?.({ [SETTINGS_KEY]: { newValue: { targetLanguage: "French" } } }, "sync"); storageListener?.({ [SETTINGS_KEY]: { newValue: { debugMode: true } } }, "local"); - expect(host.shadowRoot?.querySelector(".margin-youtube__button")?.getAttribute("aria-label")).toBe( - "Margin captions into Japanese" - ); + expect( + host.shadowRoot?.querySelector(".margin-youtube__button")?.getAttribute("aria-label") + ).toBe("Margin captions into Japanese"); }); it("debounces repeated YouTube navigation signals", () => { @@ -1232,7 +1382,9 @@ async function mountYouTubeControl(): Promise { return document.querySelector("#margin-youtube-subtitle-control")!; } -async function mountYouTubeControlWithTrack({ withVideo = true }: { withVideo?: boolean } = {}): Promise { +async function mountYouTubeControlWithTrack({ + withVideo = true +}: { withVideo?: boolean } = {}): Promise { setPageUrl("https://www.youtube.com/watch?v=abc"); document.body.innerHTML = `