From 2a1c455b70d20975cc92ab3bab59f09abda5837f Mon Sep 17 00:00:00 2001 From: maruson08 Date: Mon, 28 Sep 2026 19:11:53 +0900 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=A7[Config]=20Add=20TypeScript=20found?= =?UTF-8?q?ation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 3 + .gitignore | 1 + docs/README.md | 1 + docs/architecture.md | 6 +- docs/typescript-migration.md | 9 + package-lock.json | 378 ++++++++++++++++++ package.json | 9 +- scripts/build-site.mjs | 16 +- .../stage-typescript-test-dependencies.mjs | 11 + scripts/typescript-modules.mjs | 17 + scripts/validate-build.mjs | 28 +- tests/image-to-text.test.mjs | 6 +- tests/run-all.mjs | 4 +- tests/typescript-foundation.test.mjs | 40 ++ .../to-text/{controller.js => controller.ts} | 94 ++++- tools/image/to-text/{output.js => output.ts} | 14 +- tools/image/to-text/preview.js | 16 - tools/image/to-text/preview.ts | 27 ++ tools/shared/ocr.d.ts | 16 + tools/shared/save.d.ts | 7 + tsconfig.build.json | 14 + tsconfig.json | 21 + 22 files changed, 685 insertions(+), 53 deletions(-) create mode 100644 docs/typescript-migration.md create mode 100644 scripts/stage-typescript-test-dependencies.mjs create mode 100644 scripts/typescript-modules.mjs create mode 100644 tests/typescript-foundation.test.mjs rename tools/image/to-text/{controller.js => controller.ts} (61%) rename tools/image/to-text/{output.js => output.ts} (73%) delete mode 100644 tools/image/to-text/preview.js create mode 100644 tools/image/to-text/preview.ts create mode 100644 tools/shared/ocr.d.ts create mode 100644 tools/shared/save.d.ts create mode 100644 tsconfig.build.json create mode 100644 tsconfig.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4d13473..4b61e68 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -62,6 +62,9 @@ jobs: - name: Install pinned OCR build inputs run: npm ci --ignore-scripts + - name: Type-check migrated browser modules + run: npm run typecheck + - name: Verify prepared production OCR assets run: npm run build diff --git a/.gitignore b/.gitignore index b947077..f3d0cd4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ node_modules/ dist/ +.ts-build/ diff --git a/docs/README.md b/docs/README.md index 78464a2..fcbe562 100644 --- a/docs/README.md +++ b/docs/README.md @@ -8,6 +8,7 @@ The root [README](../README.md) introduces Secure Tools. This directory owns det | --- | --- | | [Architecture](./architecture.md) | Static application structure, shared browser foundations, delivery, and future ecosystem direction | | [Development workflow](./development-workflow.md) | Production, integration, Sprint, release, hotfix, merge-authority, and branch-cleanup rules | +| [TypeScript migration policy](./typescript-migration.md) | Strict incremental migration, JavaScript coexistence, and controlled browser emission | | [Privacy model](./privacy-model.md) | Local-processing and network boundaries, storage, security controls, and bounded privacy claims | | [Dependencies](./dependencies.md) | Production runtime inventory, versions, vendoring, licenses, and integrity ownership | | [Local OCR foundation](./ocr-foundation.md) | Self-hosted Tesseract assets, languages, lifecycle, cancellation, caching, and privacy guarantees | diff --git a/docs/architecture.md b/docs/architecture.md index 32c2ca9..daee86d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -2,7 +2,7 @@ ## Application model -Secure Tools is a static application built with semantic HTML, CSS, and Vanilla JavaScript ES Modules. It has no framework, Vite configuration, backend, database, authentication service, or runtime API. The build stages deployable files in `dist/`; a pinned npm preparation step reproduces and verifies the vendored OCR runtime without creating a server-side production dependency. +Secure Tools is a static application built with semantic HTML, CSS, and browser ES Modules. JavaScript and strict TypeScript coexist without a framework, Vite configuration, or bundler. It has no backend, database, authentication service, or runtime API. The build compiles an explicit list of TypeScript modules to ignored staging output, then stages deployable files in `dist/`; a pinned npm preparation step reproduces and verifies the vendored OCR runtime without creating a server-side production dependency. Production routes load application code and pinned libraries from the same origin. File-processing workflows run through browser APIs and in-memory data. The [privacy model](./privacy-model.md) defines the limits of that statement. @@ -59,9 +59,9 @@ Image conversion, resizing, and compression use browser decode, Canvas, and enco ## Development and delivery -Serving the generated `dist/` tree requires only an HTTP server. Reproducing OCR assets and running the full CI checks requires Node.js 24 and the exact lockfile. `npm run build` verifies prepared OCR assets and stages the site, `npm test` runs the static and unit suite, and `node tests/ocr-smoke.test.mjs` performs real English, Korean, and combined recognition. +Serving the generated `dist/` tree requires only an HTTP server. Reproducing OCR assets and running the full CI checks requires Node.js 24 and the exact lockfile. `npm run typecheck` enforces strict TypeScript contracts, `npm run build` verifies prepared OCR assets, compiles the declared TypeScript modules, and stages the site, `npm test` includes typechecking plus the static and unit suite, and `node tests/ocr-smoke.test.mjs` performs real English, Korean, and combined recognition. The incremental migration policy is documented in [TypeScript migration policy](./typescript-migration.md). -`.github/workflows/ci.yml` validates pull requests and pushes to `main` using Node.js 24. It installs the lockfile only to reproduce and verify OCR assets, then checks commit-range whitespace, JavaScript syntax, unit coverage, and real local OCR without adding deployment behavior. +`.github/workflows/ci.yml` validates pull requests and pushes to `main`, `v2`, and `v2.2` using Node.js 24. It installs the lockfile to reproduce and verify OCR assets and use the locked TypeScript compiler, then checks strict types, commit-range whitespace, JavaScript syntax, unit coverage, and real local OCR without adding deployment behavior. Development uses short-lived branches and normal merge commits. Shared `main` history is not force-pushed or rewritten. GitHub Pages can publish `main` from the repository root; relative links support both the `/Secure_Tools/` project path and root-hosted deployments. diff --git a/docs/typescript-migration.md b/docs/typescript-migration.md new file mode 100644 index 0000000..8df90a6 --- /dev/null +++ b/docs/typescript-migration.md @@ -0,0 +1,9 @@ +# TypeScript migration policy + +Secure Tools adopts TypeScript incrementally within its existing static ES-module architecture. New shared and core browser modules should prefer TypeScript when explicit contracts improve safety. Existing JavaScript remains valid and should move only when a focused change benefits from typing; repository-wide rename-only migrations are out of scope. + +TypeScript source uses strict mode. Run `npm run typecheck` before a pull request is merged. Browser TypeScript is compiled by `npm run compile:ts` into the ignored `.ts-build/` staging directory, then the static build copies only the declared modules to their established public JavaScript paths. Production builds do not include TypeScript sources, declaration files, or source maps. + +Node build and release scripts may remain `.mjs`. Tests, locale catalogs, DOM-heavy page modules, and specialized PDF or image workflows may remain JavaScript until a bounded migration is useful. Vendored and generated third-party code, including Tesseract assets, is excluded from migration. + +The module list in `scripts/typescript-modules.mjs` is the emission boundary. Add a browser module there only when its output path is stable and its consumers can continue importing JavaScript. This preserves JavaScript and TypeScript coexistence without a framework, bundler, runtime compiler, or Big Bang rewrite. diff --git a/package-lock.json b/package-lock.json index 527ed05..bbf15ac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,9 @@ "tesseract.js": "7.0.0", "tesseract.js-core": "7.0.0" }, + "devDependencies": { + "typescript": "7.0.2" + }, "engines": { "node": ">=24" } @@ -29,6 +32,346 @@ "integrity": "sha512-tepYhsYiRJ8E44R27MHxVteEDDLfYQxuvM6u4JlEHcCYqwpefvI5TGzoMgB67kunOvtyEJYtT5zfJ/6R6FVU9w==", "license": "MIT" }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/bmp-js": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/bmp-js/-/bmp-js-0.1.0.tgz", @@ -112,6 +455,41 @@ "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", "license": "MIT" }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "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" + } + }, "node_modules/wasm-feature-detect": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/wasm-feature-detect/-/wasm-feature-detect-1.9.0.tgz", diff --git a/package.json b/package.json index b31ee5e..d134016 100644 --- a/package.json +++ b/package.json @@ -4,11 +4,13 @@ "private": true, "type": "module", "scripts": { - "build": "node scripts/prepare-ocr-assets.mjs --check && node scripts/build-site.mjs", + "build": "node scripts/prepare-ocr-assets.mjs --check && npm run compile:ts && node scripts/build-site.mjs", + "compile:ts": "tsc -p tsconfig.build.json && node scripts/stage-typescript-test-dependencies.mjs", "prepare:ocr": "node scripts/prepare-ocr-assets.mjs", "smoke:ocr:browser": "node tests/serve-ocr-smoke.mjs", "test:commit-messages": "node tests/commit-message.test.mjs", - "test": "node tests/run-all.mjs" + "test": "npm run typecheck && npm run compile:ts && node tests/run-all.mjs", + "typecheck": "tsc -p tsconfig.json" }, "dependencies": { "@tesseract.js-data/eng": "1.0.0", @@ -18,5 +20,8 @@ }, "engines": { "node": ">=24" + }, + "devDependencies": { + "typescript": "7.0.2" } } diff --git a/scripts/build-site.mjs b/scripts/build-site.mjs index b555370..fb7f495 100644 --- a/scripts/build-site.mjs +++ b/scripts/build-site.mjs @@ -3,6 +3,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { canonicalPages, legacyRedirects, redirectStatus } from "./site-routes.mjs"; +import { compiledBrowserModules } from "./typescript-modules.mjs"; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const output = path.join(root, "dist"); @@ -13,7 +14,10 @@ fs.mkdirSync(output, { recursive: true }); for (const directory of ["assets", "css", "js"]) { fs.cpSync(path.join(root, directory), path.join(output, directory), { recursive: true }); } -fs.cpSync(path.join(root, "tools", "shared"), path.join(output, "shared"), { recursive: true }); +fs.cpSync(path.join(root, "tools", "shared"), path.join(output, "shared"), { + recursive: true, + filter: (source) => !/\.(?:d\.ts|ts|tsx|map)$/.test(source), +}); for (const { source, route } of canonicalPages) { const destination = route === "/" @@ -24,11 +28,19 @@ for (const { source, route } of canonicalPages) { const sourceDirectory = path.dirname(path.join(root, source)); for (const entry of fs.readdirSync(sourceDirectory, { withFileTypes: true })) { - if (entry.name === "index.html" || entry.isDirectory()) continue; + if (entry.name === "index.html" || entry.isDirectory() || /\.(?:d\.ts|ts|tsx|map)$/.test(entry.name)) continue; fs.copyFileSync(path.join(sourceDirectory, entry.name), path.join(path.dirname(destination), entry.name)); } } +for (const module of compiledBrowserModules) { + const source = path.join(root, ".ts-build", module.compiled); + const destination = path.join(output, module.public); + if (!fs.existsSync(source)) throw new Error(`Missing compiled TypeScript module: ${module.source}`); + fs.mkdirSync(path.dirname(destination), { recursive: true }); + fs.copyFileSync(source, destination); +} + for (const file of ["404.html", "robots.txt", "sitemap.xml"]) { fs.copyFileSync(path.join(root, file), path.join(output, file)); } diff --git a/scripts/stage-typescript-test-dependencies.mjs b/scripts/stage-typescript-test-dependencies.mjs new file mode 100644 index 0000000..e7ae3e3 --- /dev/null +++ b/scripts/stage-typescript-test-dependencies.mjs @@ -0,0 +1,11 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const stagingDirectory = path.join(root, ".ts-build", "tools", "shared"); + +fs.mkdirSync(stagingDirectory, { recursive: true }); +for (const file of ["image.js", "ocr.js", "save.js"]) { + fs.copyFileSync(path.join(root, "tools", "shared", file), path.join(stagingDirectory, file)); +} diff --git a/scripts/typescript-modules.mjs b/scripts/typescript-modules.mjs new file mode 100644 index 0000000..beb21ac --- /dev/null +++ b/scripts/typescript-modules.mjs @@ -0,0 +1,17 @@ +export const compiledBrowserModules = Object.freeze([ + Object.freeze({ + source: "tools/image/to-text/controller.ts", + compiled: "tools/image/to-text/controller.js", + public: "image/to-text/controller.js", + }), + Object.freeze({ + source: "tools/image/to-text/output.ts", + compiled: "tools/image/to-text/output.js", + public: "image/to-text/output.js", + }), + Object.freeze({ + source: "tools/image/to-text/preview.ts", + compiled: "tools/image/to-text/preview.js", + public: "image/to-text/preview.js", + }), +]); diff --git a/scripts/validate-build.mjs b/scripts/validate-build.mjs index ca6173b..5526432 100644 --- a/scripts/validate-build.mjs +++ b/scripts/validate-build.mjs @@ -4,16 +4,21 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { canonicalPages, legacyRedirects, redirectStatus } from "./site-routes.mjs"; +import { compiledBrowserModules } from "./typescript-modules.mjs"; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const output = path.resolve(root, process.argv[2] || "dist"); const htmlFiles = []; +const outputFiles = []; function visit(directory) { for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { const target = path.join(directory, entry.name); if (entry.isDirectory()) visit(target); - else if (entry.name === "index.html") htmlFiles.push(target); + else { + outputFiles.push(target); + if (entry.name === "index.html") htmlFiles.push(target); + } } } @@ -31,4 +36,23 @@ assert.ok(fs.existsSync(path.join(output, "assets", "vendor", "tesseract", "core assert.ok(fs.existsSync(path.join(output, "assets", "vendor", "tesseract", "lang", "eng.traineddata.gz"))); assert.ok(fs.existsSync(path.join(output, "assets", "vendor", "tesseract", "lang", "kor.traineddata.gz"))); -console.log(`Validated ${canonicalPages.length} canonical pages, ${legacyRedirects.length} redirects, and local OCR assets in ${output}.`); +for (const module of compiledBrowserModules) { + const compiledFile = path.join(output, module.public); + assert.ok(fs.existsSync(compiledFile), `missing compiled TypeScript module ${module.public}`); + const contents = fs.readFileSync(compiledFile, "utf8"); + assert.doesNotMatch(contents, /sourceMappingURL=/, `${module.public} exposes a source-map reference`); + assert.doesNotMatch(contents, /[A-Za-z]:[\\/]Users[\\/]|\/Users\//, `${module.public} exposes a local path`); +} + +assert.deepEqual( + outputFiles.filter((file) => /\.(?:d\.ts|ts|tsx|map)$/.test(file)), + [], + "production output excludes TypeScript sources and source maps", +); + +for (const file of outputFiles.filter((candidate) => /\.(?:html|css|js|json|txt|xml)$/.test(candidate))) { + const contents = fs.readFileSync(file, "utf8"); + assert.doesNotMatch(contents, /[A-Za-z]:[\\/]Users[\\/]|\/Users\//, `${path.relative(output, file)} exposes a local path`); +} + +console.log(`Validated ${canonicalPages.length} canonical pages, ${legacyRedirects.length} redirects, ${compiledBrowserModules.length} compiled TypeScript modules, and local OCR assets in ${output}.`); diff --git a/tests/image-to-text.test.mjs b/tests/image-to-text.test.mjs index 4659314..e23ab04 100644 --- a/tests/image-to-text.test.mjs +++ b/tests/image-to-text.test.mjs @@ -3,9 +3,9 @@ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { createImageToTextController, defaultOcrLanguage, OCR_UI_STATES } from "../tools/image/to-text/controller.js"; -import { copyText, createTextBlob, downloadText, textFilename } from "../tools/image/to-text/output.js"; -import { preparePreviewSource, releasePreviewSource } from "../tools/image/to-text/preview.js"; +import { createImageToTextController, defaultOcrLanguage, OCR_UI_STATES } from "../.ts-build/tools/image/to-text/controller.js"; +import { copyText, createTextBlob, downloadText, textFilename } from "../.ts-build/tools/image/to-text/output.js"; +import { preparePreviewSource, releasePreviewSource } from "../.ts-build/tools/image/to-text/preview.js"; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const read = (relative) => fs.readFileSync(path.join(root, relative), "utf8"); diff --git a/tests/run-all.mjs b/tests/run-all.mjs index 66ffef3..1dfecc8 100644 --- a/tests/run-all.mjs +++ b/tests/run-all.mjs @@ -17,7 +17,8 @@ function runNode(argumentsList, label) { if (result.status !== 0) throw new Error(`${label} failed with exit code ${result.status ?? "unknown"}.`); } -const sourceFiles = ["js", "tools", "tests"] +const sourceDirectories = ["js", "tools", "tests", ".ts-build"].filter((directory) => fs.existsSync(path.join(root, directory))); +const sourceFiles = sourceDirectories .flatMap((directory) => listFiles(path.join(root, directory))) .filter((file) => /\.(?:js|mjs)$/.test(file)); @@ -31,6 +32,7 @@ for (const test of [ "tests/image-metadata.test.mjs", "tests/ocr-foundation.test.mjs", "tests/image-to-text.test.mjs", + "tests/typescript-foundation.test.mjs", "tests/category-availability.test.mjs", "tests/pdf-merge-and-categories.test.mjs", "tests/file-input-queue-state.test.mjs", diff --git a/tests/typescript-foundation.test.mjs b/tests/typescript-foundation.test.mjs new file mode 100644 index 0000000..f8cb91e --- /dev/null +++ b/tests/typescript-foundation.test.mjs @@ -0,0 +1,40 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { compiledBrowserModules } from "../scripts/typescript-modules.mjs"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const read = (relative) => fs.readFileSync(path.join(root, relative), "utf8"); +const packageJson = JSON.parse(read("package.json")); +const typeConfig = JSON.parse(read("tsconfig.json")); +const buildConfig = JSON.parse(read("tsconfig.build.json")); + +assert.equal(packageJson.devDependencies.typescript, "7.0.2", "TypeScript is an exact development dependency"); +assert.equal(packageJson.scripts.typecheck, "tsc -p tsconfig.json"); +assert.match(packageJson.scripts.build, /npm run compile:ts/); +assert.match(packageJson.scripts["compile:ts"], /tsc -p tsconfig\.build\.json/); +assert.match(packageJson.scripts.test, /npm run typecheck/); +assert.equal(typeConfig.compilerOptions.strict, true); +assert.equal(typeConfig.compilerOptions.noEmit, true); +assert.equal(typeConfig.compilerOptions.sourceMap, false); +assert.equal(buildConfig.compilerOptions.outDir, ".ts-build"); + +assert.equal(compiledBrowserModules.length, 3, "the initial migration remains intentionally bounded"); +assert.equal(new Set(compiledBrowserModules.map((module) => module.public)).size, compiledBrowserModules.length); +for (const module of compiledBrowserModules) { + assert.ok(fs.existsSync(path.join(root, module.source)), `${module.source} exists`); + assert.ok(fs.existsSync(path.join(root, ".ts-build", module.compiled)), `${module.compiled} was compiled`); + assert.ok(!fs.existsSync(path.join(root, module.source.replace(/\.ts$/, ".js"))), `${module.source} has no duplicate source JavaScript`); +} + +const emittedFiles = fs.readdirSync(path.join(root, ".ts-build"), { recursive: true, withFileTypes: true }) + .filter((entry) => entry.isFile()) + .map((entry) => path.join(entry.parentPath, entry.name)); +assert.deepEqual(emittedFiles.filter((file) => /\.(?:ts|tsx|map)$/.test(file)), []); +for (const file of emittedFiles) { + assert.doesNotMatch(fs.readFileSync(file, "utf8"), /sourceMappingURL=|[A-Za-z]:[\\/]Users[\\/]|\/Users\//); +} + +console.log("TypeScript strictness, bounded emission, package scripts, and artifact hygiene checks passed."); diff --git a/tools/image/to-text/controller.js b/tools/image/to-text/controller.ts similarity index 61% rename from tools/image/to-text/controller.js rename to tools/image/to-text/controller.ts index f80b907..820736f 100644 --- a/tools/image/to-text/controller.js +++ b/tools/image/to-text/controller.ts @@ -1,4 +1,5 @@ import { OCR_LANGUAGES, resolveOcrLanguage } from "../../shared/ocr.js"; +import type { OcrLanguage, OcrProgress } from "../../shared/ocr.js"; export const OCR_UI_STATES = Object.freeze({ EMPTY: "empty", @@ -7,23 +8,75 @@ export const OCR_UI_STATES = Object.freeze({ SUCCESS: "success", ERROR: "error", CANCELLED: "cancelled", -}); +} as const); -export function defaultOcrLanguage(uiLanguage) { +export type OcrUiState = typeof OCR_UI_STATES[keyof typeof OCR_UI_STATES]; + +export interface ImageToTextFile { + readonly name: string; + readonly size: number; + readonly type: string; +} + +export interface ImageToTextSource { + readonly file: ImageToTextFile; + readonly previewUrl: string; + readonly previewType: string; +} + +export interface ImageToTextState { + phase: OcrUiState; + source: ImageToTextSource | null; + language: OcrLanguage; + text: string; + progress: OcrProgress | null; + error: unknown; +} + +export interface RecognitionOptions { + language: OcrLanguage; + signal: AbortSignal; + onProgress: (progress: OcrProgress) => void; +} + +export interface ImageToTextControllerConfiguration { + language?: OcrLanguage; + recognizeImage: ( + file: ImageToTextFile, + options: RecognitionOptions, + ) => Promise<{ text: string }>; + prepareSource: (file: ImageToTextFile) => Promise; + releaseSource?: (source: ImageToTextSource) => void; + onChange?: (state: ImageToTextState) => void; + dispose?: () => void | Promise; +} + +interface ActiveRecognition { + request: number; + abortController: AbortController; + promise: Promise<{ text: string }>; +} + +function errorCode(error: unknown): string | undefined { + if (typeof error !== "object" || error === null || !("code" in error)) return undefined; + return typeof error.code === "string" ? error.code : undefined; +} + +export function defaultOcrLanguage(uiLanguage: unknown): OcrLanguage { return String(uiLanguage || "").toLowerCase().startsWith("ko") ? OCR_LANGUAGES.KOREAN_ENGLISH : OCR_LANGUAGES.ENGLISH; } -export function createImageToTextController(configuration) { +export function createImageToTextController(configuration: ImageToTextControllerConfiguration) { const recognizeImage = configuration.recognizeImage; const prepareSource = configuration.prepareSource; const releaseSource = configuration.releaseSource || (() => {}); const onChange = configuration.onChange || (() => {}); let generation = 0; - let active = null; + let active: ActiveRecognition | null = null; let disposed = false; - let state = { + let state: ImageToTextState = { phase: OCR_UI_STATES.EMPTY, source: null, language: resolveOcrLanguage(configuration.language || OCR_LANGUAGES.ENGLISH), @@ -32,18 +85,18 @@ export function createImageToTextController(configuration) { error: null, }; - function publish(patch) { + function publish(patch: Partial): void { state = { ...state, ...patch }; onChange({ ...state }); } - function snapshot() { + function snapshot(): ImageToTextState { return { ...state }; } - async function cancel() { + async function cancel(): Promise { if (!active) return false; - const current = active; + const current: ActiveRecognition = active; generation += 1; active = null; current.abortController.abort(); @@ -52,7 +105,7 @@ export function createImageToTextController(configuration) { return true; } - async function select(file) { + async function select(file: ImageToTextFile): Promise { if (disposed) return; await cancel(); const request = ++generation; @@ -66,14 +119,14 @@ export function createImageToTextController(configuration) { return; } publish({ phase: OCR_UI_STATES.READY, source, text: "", progress: null, error: null }); - } catch (error) { + } catch (error: unknown) { if (request === generation && !disposed) { publish({ phase: OCR_UI_STATES.ERROR, source: null, text: "", progress: null, error }); } } } - async function remove() { + async function remove(): Promise { if (disposed) return; await cancel(); generation += 1; @@ -81,7 +134,7 @@ export function createImageToTextController(configuration) { publish({ phase: OCR_UI_STATES.EMPTY, source: null, text: "", progress: null, error: null }); } - async function setLanguage(language) { + async function setLanguage(language: unknown): Promise { const nextLanguage = resolveOcrLanguage(language); if (nextLanguage === state.language || disposed) return; await cancel(); @@ -95,7 +148,7 @@ export function createImageToTextController(configuration) { }); } - async function recognize() { + async function recognize(): Promise { if (disposed || !state.source || active) return; const request = ++generation; const abortController = new AbortController(); @@ -104,7 +157,7 @@ export function createImageToTextController(configuration) { const promise = recognizeImage(source.file, { language: state.language, signal: abortController.signal, - onProgress(progress) { + onProgress(progress: OcrProgress) { if (!disposed && request === generation && active?.request === request) publish({ progress }); }, }); @@ -114,13 +167,14 @@ export function createImageToTextController(configuration) { if (!disposed && request === generation && active?.request === request) { publish({ phase: OCR_UI_STATES.SUCCESS, text: result.text, progress: null, error: null }); } - } catch (error) { + } catch (error: unknown) { if (!disposed && request === generation && active?.request === request) { + const cancelled = errorCode(error) === "OCR_CANCELLED"; publish({ - phase: error?.code === "OCR_CANCELLED" ? OCR_UI_STATES.CANCELLED : OCR_UI_STATES.ERROR, + phase: cancelled ? OCR_UI_STATES.CANCELLED : OCR_UI_STATES.ERROR, text: "", progress: null, - error: error?.code === "OCR_CANCELLED" ? null : error, + error: cancelled ? null : error, }); } } finally { @@ -128,11 +182,11 @@ export function createImageToTextController(configuration) { } } - function updateText(text) { + function updateText(text: unknown): void { if (state.phase === OCR_UI_STATES.SUCCESS) publish({ text: String(text) }); } - async function dispose() { + async function dispose(): Promise { if (disposed) return; await cancel(); disposed = true; diff --git a/tools/image/to-text/output.js b/tools/image/to-text/output.ts similarity index 73% rename from tools/image/to-text/output.js rename to tools/image/to-text/output.ts index 2a54671..727fa82 100644 --- a/tools/image/to-text/output.js +++ b/tools/image/to-text/output.ts @@ -1,18 +1,24 @@ import { downloadBlob } from "../../shared/save.js"; +import type { DownloadEnvironment } from "../../shared/save.js"; const ILLEGAL_FILENAME_CHARACTERS = /[\\/:*?"<>|\u0000-\u001f]+/g; -export function textFilename(sourceName) { +export interface CopyEnvironment { + navigatorObject?: Pick; + documentObject?: Document; +} + +export function textFilename(sourceName: unknown): string { const withoutExtension = String(sourceName || "").replace(/\.[^.]+$/, ""); const clean = withoutExtension.trim().replace(ILLEGAL_FILENAME_CHARACTERS, "_").replace(/[. ]+$/g, ""); return `${clean || "recognized-text"}.txt`; } -export function createTextBlob(text) { +export function createTextBlob(text: unknown): Blob { return new Blob([String(text)], { type: "text/plain;charset=utf-8" }); } -export async function copyText(text, environment = {}) { +export async function copyText(text: unknown, environment: CopyEnvironment = {}): Promise { const navigatorObject = environment.navigatorObject || globalThis.navigator; const documentObject = environment.documentObject || globalThis.document; if (navigatorObject?.clipboard?.writeText) { @@ -33,6 +39,6 @@ export async function copyText(text, environment = {}) { if (!copied) throw Object.assign(new Error("OCR_COPY_FAILED"), { code: "OCR_COPY_FAILED" }); } -export function downloadText(text, sourceName, environment) { +export function downloadText(text: unknown, sourceName: unknown, environment?: DownloadEnvironment): void { downloadBlob(createTextBlob(text), textFilename(sourceName), environment); } diff --git a/tools/image/to-text/preview.js b/tools/image/to-text/preview.js deleted file mode 100644 index c6962fa..0000000 --- a/tools/image/to-text/preview.js +++ /dev/null @@ -1,16 +0,0 @@ -import { prepareImageForOcr } from "../../shared/ocr.js"; - -export async function preparePreviewSource(file, environment = {}) { - const prepareImage = environment.prepareImage || prepareImageForOcr; - const urlObject = environment.urlObject || URL; - const previewBlob = await prepareImage(file); - return { - file, - previewUrl: urlObject.createObjectURL(previewBlob), - previewType: previewBlob.type, - }; -} - -export function releasePreviewSource(source, environment = {}) { - if (source?.previewUrl) (environment.urlObject || URL).revokeObjectURL(source.previewUrl); -} diff --git a/tools/image/to-text/preview.ts b/tools/image/to-text/preview.ts new file mode 100644 index 0000000..4b8bfe5 --- /dev/null +++ b/tools/image/to-text/preview.ts @@ -0,0 +1,27 @@ +import { prepareImageForOcr } from "../../shared/ocr.js"; + +export interface PreviewSource { + readonly file: Blob; + readonly previewUrl: string; + readonly previewType: string; +} + +export interface PreviewEnvironment { + prepareImage?: (file: Blob) => Promise; + urlObject?: Pick; +} + +export async function preparePreviewSource(file: Blob, environment: PreviewEnvironment = {}): Promise { + const prepareImage = environment.prepareImage || prepareImageForOcr; + const urlObject = environment.urlObject || URL; + const previewBlob = await prepareImage(file); + return { + file, + previewUrl: urlObject.createObjectURL(previewBlob), + previewType: previewBlob.type, + }; +} + +export function releasePreviewSource(source: PreviewSource | null | undefined, environment: PreviewEnvironment = {}): void { + if (source?.previewUrl) (environment.urlObject || URL).revokeObjectURL(source.previewUrl); +} diff --git a/tools/shared/ocr.d.ts b/tools/shared/ocr.d.ts new file mode 100644 index 0000000..0cc5a4d --- /dev/null +++ b/tools/shared/ocr.d.ts @@ -0,0 +1,16 @@ +export const OCR_LANGUAGES: Readonly<{ + ENGLISH: "eng"; + KOREAN: "kor"; + KOREAN_ENGLISH: "eng+kor"; +}>; + +export type OcrLanguage = typeof OCR_LANGUAGES[keyof typeof OCR_LANGUAGES]; +export type OcrProgressStage = "loading-engine" | "loading-language" | "initializing" | "recognizing" | "complete"; + +export interface OcrProgress { + stage: OcrProgressStage; + progress: number | null; +} + +export function resolveOcrLanguage(language: unknown): OcrLanguage; +export function prepareImageForOcr(image: Blob): Promise; diff --git a/tools/shared/save.d.ts b/tools/shared/save.d.ts new file mode 100644 index 0000000..fd60c92 --- /dev/null +++ b/tools/shared/save.d.ts @@ -0,0 +1,7 @@ +export interface DownloadEnvironment { + documentObject?: Document; + urlObject?: Pick; + schedule?: (callback: () => void, delay: number) => unknown; +} + +export function downloadBlob(blob: Blob, filename: string, environment?: DownloadEnvironment): void; diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..ccfc657 --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "outDir": ".ts-build", + "rootDir": "." + }, + "include": [ + "tools/image/to-text/controller.ts", + "tools/image/to-text/output.ts", + "tools/image/to-text/preview.ts", + "tools/shared/*.d.ts" + ] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..96294db --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "allowJs": false, + "declaration": false, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "Bundler", + "noEmit": true, + "sourceMap": false, + "strict": true, + "target": "ES2022", + "types": [], + "verbatimModuleSyntax": true + }, + "include": [ + "tools/image/to-text/*.ts", + "tools/shared/*.d.ts" + ] +}