From aaf7d9c22a0870ee236beac9571275e6273c2a70 Mon Sep 17 00:00:00 2001 From: maruson08 Date: Sat, 26 Sep 2026 15:02:19 +0900 Subject: [PATCH] =?UTF-8?q?=E2=99=BB=EF=B8=8F[Refactor]=20Remove=20redunda?= =?UTF-8?q?nt=20tools=20URL=20namespace?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../workflows/deploy-cloudflare-bridge.yml | 17 +---- .gitignore | 1 + README.md | 29 +++---- docs/architecture.md | 12 +-- docs/image-metadata-privacy.md | 2 +- docs/seo.md | 6 +- docs/tool-status.md | 24 +++--- index.html | 10 +-- package.json | 2 +- privacy/index.html | 8 ++ scripts/build-site.mjs | 39 ++++++++++ scripts/site-routes.mjs | 32 ++++++++ scripts/validate-build.mjs | 34 +++++++++ sitemap.xml | 31 ++++---- tests/browser/ocr-smoke.js | 12 +-- tests/category-availability.test.mjs | 13 +++- tests/cloudflare-bridge.test.mjs | 15 ++-- tests/deployment-smoke.mjs | 75 ++++++------------- tests/home-structure.test.mjs | 10 +-- tests/i18n-quality.test.mjs | 5 +- tests/pdf-merge-and-categories.test.mjs | 9 ++- tests/run-all.mjs | 1 + tests/seo-foundation.test.mjs | 28 ++----- tests/serve-ocr-smoke.mjs | 21 ++++-- tests/url-namespace.test.mjs | 46 ++++++++++++ tools/image/compress/index.html | 4 +- tools/image/converter/index.html | 4 +- tools/image/index.html | 4 +- tools/image/metadata/index.html | 4 +- tools/image/resize/index.html | 4 +- tools/image/to-text/index.html | 2 +- tools/media/index.html | 4 +- tools/pdf/images-to-pdf/index.html | 4 +- tools/pdf/index.html | 4 +- tools/pdf/merge/index.html | 4 +- tools/pdf/metadata/index.html | 4 +- tools/pdf/organize/index.html | 4 +- tools/pdf/split/index.html | 4 +- tools/pdf/to-images/index.html | 4 +- tools/privacy/index.html | 4 +- tools/scan/index.html | 4 +- 41 files changed, 340 insertions(+), 204 deletions(-) create mode 100644 scripts/build-site.mjs create mode 100644 scripts/site-routes.mjs create mode 100644 scripts/validate-build.mjs create mode 100644 tests/url-namespace.test.mjs diff --git a/.github/workflows/deploy-cloudflare-bridge.yml b/.github/workflows/deploy-cloudflare-bridge.yml index c40a91b..96833b2 100644 --- a/.github/workflows/deploy-cloudflare-bridge.yml +++ b/.github/workflows/deploy-cloudflare-bridge.yml @@ -64,19 +64,9 @@ jobs: run: | set -euo pipefail + npm run build mkdir -p "$BRIDGE_DIRECTORY" - cp -R \ - 404.html \ - index.html \ - about \ - assets \ - css \ - js \ - privacy \ - robots.txt \ - sitemap.xml \ - tools \ - "$BRIDGE_DIRECTORY/" + cp -R dist/. "$BRIDGE_DIRECTORY/" printf '%s\n' \ 'https://secure-tools-web-bridge.pages.dev/*' \ @@ -87,10 +77,9 @@ jobs: > "$BRIDGE_DIRECTORY/_headers" [[ ! -e "$BRIDGE_DIRECTORY/CNAME" ]] - [[ ! -e "$BRIDGE_DIRECTORY/_redirects" ]] [[ ! -e "$BRIDGE_DIRECTORY/_worker.js" ]] [[ ! -d "$BRIDGE_DIRECTORY/functions" ]] - [[ "$(find "$BRIDGE_DIRECTORY" -name index.html -type f | wc -l)" -eq 20 ]] + node scripts/validate-build.mjs "$BRIDGE_DIRECTORY" - name: Validate Cloudflare Pages project isolation shell: bash diff --git a/.gitignore b/.gitignore index c2658d7..b947077 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ node_modules/ +dist/ diff --git a/README.md b/README.md index b4e3fca..3f5be16 100644 --- a/README.md +++ b/README.md @@ -19,23 +19,23 @@ See the [privacy model](./docs/privacy-model.md) for exact guarantees and bounda ### PDF -- [Images to PDF](./tools/pdf/images-to-pdf/) — arrange JPEG, PNG, and WebP images and save one PDF. -- [PDF Merge](./tools/pdf/merge/) — combine validated PDFs without rasterizing pages. -- [PDF Split](./tools/pdf/split/) — extract ranges or produce per-page and fixed-interval archives. -- [PDF Organizer](./tools/pdf/organize/) — preview, reorder, rotate, remove, and export pages. -- [PDF to Images](./tools/pdf/to-images/) — render pages to PNG, JPEG, or WebP. -- [PDF Metadata Inspector & Cleaner](./tools/pdf/metadata/) — inspect and remove supported document-info fields. +- [Images to PDF](https://tools.securetools.app/pdf/images-to-pdf/) — arrange JPEG, PNG, and WebP images and save one PDF. +- [PDF Merge](https://tools.securetools.app/pdf/merge/) — combine validated PDFs without rasterizing pages. +- [PDF Split](https://tools.securetools.app/pdf/split/) — extract ranges or produce per-page and fixed-interval archives. +- [PDF Organizer](https://tools.securetools.app/pdf/organize/) — preview, reorder, rotate, remove, and export pages. +- [PDF to Images](https://tools.securetools.app/pdf/to-images/) — render pages to PNG, JPEG, or WebP. +- [PDF Metadata Inspector & Cleaner](https://tools.securetools.app/pdf/metadata/) — inspect and remove supported document-info fields. ### Image -- [Image Converter](./tools/image/converter/) — convert JPEG, PNG, and WebP batches. -- [Image Resize](./tools/image/resize/) — resize batches by pixels or percentage. -- [Image Compressor](./tools/image/compress/) — quality-compress images and compare byte results. -- [Image Metadata Inspector & Cleaner](./tools/image/metadata/) — inspect supported metadata and save a verified cleaned copy without pixel re-encoding. +- [Image Converter](https://tools.securetools.app/image/converter/) — convert JPEG, PNG, and WebP batches. +- [Image Resize](https://tools.securetools.app/image/resize/) — resize batches by pixels or percentage. +- [Image Compressor](https://tools.securetools.app/image/compress/) — quality-compress images and compare byte results. +- [Image Metadata Inspector & Cleaner](https://tools.securetools.app/image/metadata/) — inspect supported metadata and save a verified cleaned copy without pixel re-encoding. ### Privacy -The [Privacy hub](./tools/privacy/) links to the specialized Image and PDF metadata tools. It is a cross-category navigation surface, not a generic sanitizer. Scan/OCR and Media remain planned. +The [Privacy hub](https://tools.securetools.app/privacy/) links to the specialized Image and PDF metadata tools. It is a cross-category navigation surface, not a generic sanitizer. Scan/OCR and Media remain planned. Detailed formats, limits, and behavior are listed in [tool status](./docs/tool-status.md). @@ -45,10 +45,11 @@ Secure Tools includes English, Korean, Japanese, Spanish, German, and French int ## Local development -Serve the repository over HTTP so ES Modules load correctly: +Build the deployable tree, then serve `dist/` over HTTP so ES Modules load correctly: ```bash -python -m http.server 8000 +npm run build +python -m http.server 8000 --directory dist ``` Open [http://localhost:8000](http://localhost:8000). Do not use a `file://` URL. @@ -62,7 +63,7 @@ npm test node tests/ocr-smoke.test.mjs ``` -The Image category includes a public, single-image [Image → Text OCR](./tools/image/to-text/) workflow for PNG, JPEG, and WebP input. English, Korean, and combined English + Korean recognition run through the same-origin OCR runtime documented in [Local OCR foundation](./docs/ocr-foundation.md). +The Image category includes a public, single-image [Image → Text OCR](https://tools.securetools.app/image/to-text/) workflow for PNG, JPEG, and WebP input. English, Korean, and combined English + Korean recognition run through the same-origin OCR runtime documented in [Local OCR foundation](./docs/ocr-foundation.md). ## Documentation diff --git a/docs/architecture.md b/docs/architecture.md index 9fac252..32c2ca9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -2,7 +2,7 @@ ## Application model -Secure Tools is a static GitHub Pages application built with semantic HTML, CSS, and Vanilla JavaScript ES Modules. It has no framework, backend, database, authentication service, or runtime API. Production deploys committed static files directly. A pinned npm preparation step reproduces and verifies the vendored OCR runtime; it does not create a server-side production dependency. +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. 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. @@ -15,7 +15,7 @@ The homepage points to stable category hubs instead of maintaining a flat list o - Privacy: a cross-category hub for the two metadata tools; - Scan/OCR and Media: planned, non-interactive surfaces. -Each production tool owns a route under `tools///`. The legacy `/tools/image-to-pdf/` route is a static migration page to `/tools/pdf/images-to-pdf/` with a visible fallback link. +Each production tool has a canonical route at `///`. The `tools/` directory remains the source-code organization, while `scripts/site-routes.mjs` maps its pages into the root-level public namespace. Every previously public `/tools/*` path has a one-hop 308 redirect to its canonical destination. The Privacy policy and metadata-tool hub share `/privacy/` because stripping the old prefix would otherwise collide with the existing policy route. ```text . @@ -27,9 +27,11 @@ Each production tool owns a route under `tools///`. The legacy ` │ ├── shared/ input, validation, output, save, PDF, and UI foundations │ ├── pdf/ PDF hub and production tools │ ├── image/ Image hub and production tools -│ ├── privacy/ metadata-tool navigation hub +│ ├── privacy/ source template retained for route-history checks │ ├── scan/, media/ planned category pages -│ └── image-to-pdf/ legacy static redirect +│ └── image-to-pdf/ retired client-side migration source +├── scripts/site-routes.mjs canonical pages and legacy redirect manifest +├── dist/ generated deployment artifact (ignored) ├── assets/vendor/ pinned same-origin runtime libraries ├── docs/ technical, privacy, and audit records └── tests/ static and functional validation @@ -57,7 +59,7 @@ Image conversion, resizing, and compression use browser decode, Canvas, and enco ## Development and delivery -Serving the committed production 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, `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 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. `.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. diff --git a/docs/image-metadata-privacy.md b/docs/image-metadata-privacy.md index af500e2..3fd3bb2 100644 --- a/docs/image-metadata-privacy.md +++ b/docs/image-metadata-privacy.md @@ -1,6 +1,6 @@ # Image Metadata privacy and verification -The Image Metadata Inspector & Cleaner at `/tools/image/metadata/` processes one signature-validated JPEG, PNG, or WebP file in browser memory. The application enforces its existing 50 MiB per-image limit before reading the full file. It does not upload the image, decode pixels, use Canvas, resize, convert, or re-encode it. +The Image Metadata Inspector & Cleaner at `/image/metadata/` processes one signature-validated JPEG, PNG, or WebP file in browser memory. The application enforces its existing 50 MiB per-image limit before reading the full file. It does not upload the image, decode pixels, use Canvas, resize, convert, or re-encode it. Inspection reports only structures supported by `secure-metadata v0.1.1`. Decoded values and opaque detected containers are presented differently. A `metadata-partial` result is a successful but non-exhaustive inspection; it is not evidence that every possible metadata structure was decoded. “No supported metadata detected” does not mean that the image contains no metadata or hidden information. diff --git a/docs/seo.md b/docs/seo.md index b863f7c..04ff8ba 100644 --- a/docs/seo.md +++ b/docs/seo.md @@ -12,7 +12,7 @@ The root `CNAME` remains `securetools.app` in H3.4A because this preparation tas - `/robots.txt` allows public crawling and points to `https://tools.securetools.app/sitemap.xml`. - `/sitemap.xml` lists exactly the 18 canonical Web Utilities pages intended for indexing. -- The 404 page and legacy `/tools/image-to-pdf/` alias are intentionally `noindex` and absent from the sitemap. +- Redirect-only `/tools/*` URLs and the 404 page are absent from the sitemap. - No Hub, old apex, GitHub Pages, or `pages.dev` URL belongs in the Web Utilities sitemap. - Static assets, tests, documentation files, and generated user downloads are not sitemap entries. @@ -43,7 +43,7 @@ https://:version.secure-tools-web-bridge.pages.dev/* X-Robots-Tag: noindex, nofollow ``` -This leaves `tools.securetools.app` without the bridge header while retaining duplicate-host protection on stable, branch, and immutable `pages.dev` URLs. It requires no Worker, Pages Function, redirect, or zone-level Transform Rule. The legacy `/tools/image-to-pdf/` page keeps its independent HTML `noindex` directive on every hostname. +This leaves `tools.securetools.app` without the bridge header while retaining duplicate-host protection on stable, branch, and immutable `pages.dev` URLs. It requires no Worker, Pages Function, or zone-level Transform Rule. Cloudflare Pages reads the generated `_redirects` file and permanently redirects each legacy `/tools/*` path to its root-level canonical route with status 308. ## Language and structured data @@ -70,6 +70,8 @@ Prolonged partial activation is unsafe because crawlers could see conflicting ca ## Maintenance +Canonical tool URLs use `https://tools.securetools.app///`. Legacy `https://tools.securetools.app/tools///` URLs remain compatibility entry points through permanent redirects and must never appear in canonical metadata or the sitemap. + When an indexable route is added, renamed, redirected, or retired: 1. update its title, description, canonical, and Open Graph metadata; diff --git a/docs/tool-status.md b/docs/tool-status.md index 1a78857..f92f717 100644 --- a/docs/tool-status.md +++ b/docs/tool-status.md @@ -4,18 +4,18 @@ | Category | Tool or surface | Status | Formats / scope | | --- | --- | --- | --- | -| PDF | [Images to PDF](../tools/pdf/images-to-pdf/) | Production | JPEG, PNG, WebP → PDF | -| PDF | [PDF Merge](../tools/pdf/merge/) | Production | Ordered PDF page copying | -| PDF | [PDF Split](../tools/pdf/split/) | Production | Ranges, every page, fixed intervals | -| PDF | [PDF Organizer](../tools/pdf/organize/) | Production | Preview, reorder, rotate, remove, export | -| PDF | [PDF to Images](../tools/pdf/to-images/) | Production | PDF pages → PNG, JPEG, WebP | -| PDF | [PDF Metadata Inspector & Cleaner](../tools/pdf/metadata/) | Production | Eight supported document-info fields | -| Image | [Image Converter](../tools/image/converter/) | Production | JPEG, PNG, WebP conversion | -| Image | [Image Resize](../tools/image/resize/) | Production | Pixel or percentage batch resize | -| Image | [Image Compressor](../tools/image/compress/) | Production | JPEG/WebP quality and PNG re-encoding | -| Image | [Image Metadata Inspector & Cleaner](../tools/image/metadata/) | Production | Supported JPEG, PNG, WebP metadata | -| Image | [Image → Text OCR](../tools/image/to-text/) | Production | One PNG, JPEG, or WebP → editable English/Korean text | -| Privacy | [Privacy hub](../tools/privacy/) | Production hub | Navigation to Image and PDF metadata tools | +| PDF | [Images to PDF](https://tools.securetools.app/pdf/images-to-pdf/) | Production | JPEG, PNG, WebP → PDF | +| PDF | [PDF Merge](https://tools.securetools.app/pdf/merge/) | Production | Ordered PDF page copying | +| PDF | [PDF Split](https://tools.securetools.app/pdf/split/) | Production | Ranges, every page, fixed intervals | +| PDF | [PDF Organizer](https://tools.securetools.app/pdf/organize/) | Production | Preview, reorder, rotate, remove, export | +| PDF | [PDF to Images](https://tools.securetools.app/pdf/to-images/) | Production | PDF pages → PNG, JPEG, WebP | +| PDF | [PDF Metadata Inspector & Cleaner](https://tools.securetools.app/pdf/metadata/) | Production | Eight supported document-info fields | +| Image | [Image Converter](https://tools.securetools.app/image/converter/) | Production | JPEG, PNG, WebP conversion | +| Image | [Image Resize](https://tools.securetools.app/image/resize/) | Production | Pixel or percentage batch resize | +| Image | [Image Compressor](https://tools.securetools.app/image/compress/) | Production | JPEG/WebP quality and PNG re-encoding | +| Image | [Image Metadata Inspector & Cleaner](https://tools.securetools.app/image/metadata/) | Production | Supported JPEG, PNG, WebP metadata | +| Image | [Image → Text OCR](https://tools.securetools.app/image/to-text/) | Production | One PNG, JPEG, or WebP → editable English/Korean text | +| Privacy | [Privacy hub](https://tools.securetools.app/privacy/) | Production hub | Navigation to Image and PDF metadata tools | | Scan/OCR | Category surface | Planned | No production processing tool | | Media | Category surface | Planned | No production processing tool | diff --git a/index.html b/index.html index 7e0a66d..1040b0e 100644 --- a/index.html +++ b/index.html @@ -96,11 +96,11 @@

Start with what you need to work on.

Browse focused categories, then choose a tool. Availability is always shown clearly.

diff --git a/package.json b/package.json index 2500808..b31ee5e 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "scripts": { - "build": "node scripts/prepare-ocr-assets.mjs --check", + "build": "node scripts/prepare-ocr-assets.mjs --check && node scripts/build-site.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", diff --git a/privacy/index.html b/privacy/index.html index 41438c0..0d3504f 100644 --- a/privacy/index.html +++ b/privacy/index.html @@ -31,6 +31,7 @@ + @@ -62,6 +63,13 @@

Preference storage

localStorage is used only to remember your language and theme selections on this device.

External links

Following an external link, such as GitHub, leaves Secure Tools and is governed by that service's own policies.

+

Privacy metadata tools

+

Choose the tool that matches your file type. Image and PDF metadata have different supported scopes.

+ +

These specialized tools process files locally and verify only their documented supported scope.

diff --git a/scripts/build-site.mjs b/scripts/build-site.mjs new file mode 100644 index 0000000..b555370 --- /dev/null +++ b/scripts/build-site.mjs @@ -0,0 +1,39 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { canonicalPages, legacyRedirects, redirectStatus } from "./site-routes.mjs"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const output = path.join(root, "dist"); + +fs.rmSync(output, { recursive: true, force: true }); +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 }); + +for (const { source, route } of canonicalPages) { + const destination = route === "/" + ? path.join(output, "index.html") + : path.join(output, route.slice(1), "index.html"); + fs.mkdirSync(path.dirname(destination), { recursive: true }); + fs.copyFileSync(path.join(root, source), destination); + + 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; + fs.copyFileSync(path.join(sourceDirectory, entry.name), path.join(path.dirname(destination), entry.name)); + } +} + +for (const file of ["404.html", "robots.txt", "sitemap.xml"]) { + fs.copyFileSync(path.join(root, file), path.join(output, file)); +} + +const redirectFile = `${legacyRedirects.map(({ from, to }) => `${from} ${to} ${redirectStatus}`).join("\n")}\n`; +fs.writeFileSync(path.join(output, "_redirects"), redirectFile); + +console.log(`Built ${canonicalPages.length} canonical pages and ${legacyRedirects.length} permanent redirects in dist/.`); diff --git a/scripts/site-routes.mjs b/scripts/site-routes.mjs new file mode 100644 index 0000000..4465863 --- /dev/null +++ b/scripts/site-routes.mjs @@ -0,0 +1,32 @@ +export const productionOrigin = "https://tools.securetools.app"; + +export const canonicalPages = [ + { source: "index.html", route: "/" }, + { source: "about/index.html", route: "/about/" }, + { source: "privacy/index.html", route: "/privacy/" }, + { source: "tools/pdf/index.html", route: "/pdf/" }, + { source: "tools/pdf/images-to-pdf/index.html", route: "/pdf/images-to-pdf/" }, + { source: "tools/pdf/merge/index.html", route: "/pdf/merge/" }, + { source: "tools/pdf/split/index.html", route: "/pdf/split/" }, + { source: "tools/pdf/organize/index.html", route: "/pdf/organize/" }, + { source: "tools/pdf/to-images/index.html", route: "/pdf/to-images/" }, + { source: "tools/pdf/metadata/index.html", route: "/pdf/metadata/" }, + { source: "tools/image/index.html", route: "/image/" }, + { source: "tools/image/converter/index.html", route: "/image/converter/" }, + { source: "tools/image/resize/index.html", route: "/image/resize/" }, + { source: "tools/image/compress/index.html", route: "/image/compress/" }, + { source: "tools/image/metadata/index.html", route: "/image/metadata/" }, + { source: "tools/image/to-text/index.html", route: "/image/to-text/" }, + { source: "tools/scan/index.html", route: "/scan/" }, + { source: "tools/media/index.html", route: "/media/" }, +]; + +export const legacyRedirects = [ + ...canonicalPages + .filter(({ source }) => source.startsWith("tools/")) + .map(({ route }) => ({ from: `/tools${route}`, to: route })), + { from: "/tools/privacy/", to: "/privacy/" }, + { from: "/tools/image-to-pdf/", to: "/pdf/images-to-pdf/" }, +]; + +export const redirectStatus = 308; diff --git a/scripts/validate-build.mjs b/scripts/validate-build.mjs new file mode 100644 index 0000000..ca6173b --- /dev/null +++ b/scripts/validate-build.mjs @@ -0,0 +1,34 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { canonicalPages, legacyRedirects, redirectStatus } from "./site-routes.mjs"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const output = path.resolve(root, process.argv[2] || "dist"); +const htmlFiles = []; + +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); + } +} + +visit(output); +assert.equal(htmlFiles.length, canonicalPages.length, "build output canonical page count matches the route manifest"); +for (const { route } of canonicalPages) { + const target = route === "/" ? path.join(output, "index.html") : path.join(output, route.slice(1), "index.html"); + assert.ok(fs.existsSync(target), `missing built route ${route}`); +} + +const expectedRedirects = `${legacyRedirects.map(({ from, to }) => `${from} ${to} ${redirectStatus}`).join("\n")}\n`; +assert.equal(fs.readFileSync(path.join(output, "_redirects"), "utf8"), expectedRedirects); +assert.ok(fs.existsSync(path.join(output, "assets", "vendor", "tesseract", "worker", "worker.min.js"))); +assert.ok(fs.existsSync(path.join(output, "assets", "vendor", "tesseract", "core", "tesseract-core-simd-lstm.wasm.js"))); +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}.`); diff --git a/sitemap.xml b/sitemap.xml index 3171181..574798e 100644 --- a/sitemap.xml +++ b/sitemap.xml @@ -3,20 +3,19 @@ https://tools.securetools.app/ https://tools.securetools.app/about/ https://tools.securetools.app/privacy/ - https://tools.securetools.app/tools/pdf/ - https://tools.securetools.app/tools/pdf/images-to-pdf/ - https://tools.securetools.app/tools/pdf/merge/ - https://tools.securetools.app/tools/pdf/split/ - https://tools.securetools.app/tools/pdf/organize/ - https://tools.securetools.app/tools/pdf/to-images/ - https://tools.securetools.app/tools/pdf/metadata/ - https://tools.securetools.app/tools/image/ - https://tools.securetools.app/tools/image/converter/ - https://tools.securetools.app/tools/image/resize/ - https://tools.securetools.app/tools/image/compress/ - https://tools.securetools.app/tools/image/metadata/ - https://tools.securetools.app/tools/image/to-text/ - https://tools.securetools.app/tools/privacy/ - https://tools.securetools.app/tools/scan/ - https://tools.securetools.app/tools/media/ + https://tools.securetools.app/pdf/ + https://tools.securetools.app/pdf/images-to-pdf/ + https://tools.securetools.app/pdf/merge/ + https://tools.securetools.app/pdf/split/ + https://tools.securetools.app/pdf/organize/ + https://tools.securetools.app/pdf/to-images/ + https://tools.securetools.app/pdf/metadata/ + https://tools.securetools.app/image/ + https://tools.securetools.app/image/converter/ + https://tools.securetools.app/image/resize/ + https://tools.securetools.app/image/compress/ + https://tools.securetools.app/image/metadata/ + https://tools.securetools.app/image/to-text/ + https://tools.securetools.app/scan/ + https://tools.securetools.app/media/ diff --git a/tests/browser/ocr-smoke.js b/tests/browser/ocr-smoke.js index 12c7e20..f895ca6 100644 --- a/tests/browser/ocr-smoke.js +++ b/tests/browser/ocr-smoke.js @@ -1,4 +1,4 @@ -import { createOcrService } from "../../tools/shared/ocr.js"; +import { createOcrService } from "/shared/ocr.js"; const output = document.querySelector("#result"); const requestsBefore = performance.getEntriesByType("resource").map((entry) => entry.name); @@ -15,8 +15,8 @@ async function verifyCategoryAvailability() { const cardPath = (card, categoryPath) => new URL(card.getAttribute("href"), new URL(categoryPath, location.origin)).pathname; const [imageDocument, scanDocument] = await Promise.all([ - loadCategory("/tools/image/"), - loadCategory("/tools/scan/"), + loadCategory("/image/"), + loadCategory("/scan/"), ]); const imageCard = findOcrCard(imageDocument); const scanCard = findOcrCard(scanDocument); @@ -24,9 +24,9 @@ async function verifyCategoryAvailability() { if (!imageCard.querySelector(".status--available") || !scanCard.querySelector(".status--available")) { throw new Error("Image to Text must be available in both categories"); } - const imagePath = cardPath(imageCard, "/tools/image/"); - const scanPath = cardPath(scanCard, "/tools/scan/"); - if (imagePath !== "/tools/image/to-text/" || scanPath !== imagePath) { + const imagePath = cardPath(imageCard, "/image/"); + const scanPath = cardPath(scanCard, "/scan/"); + if (imagePath !== "/image/to-text/" || scanPath !== imagePath) { throw new Error(`Category routes differ: ${imagePath}, ${scanPath}`); } const routeResponse = await fetch(scanPath); diff --git a/tests/category-availability.test.mjs b/tests/category-availability.test.mjs index e92be42..2cdc228 100644 --- a/tests/category-availability.test.mjs +++ b/tests/category-availability.test.mjs @@ -4,6 +4,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { translations } from "../js/i18n.js"; +import { canonicalPages } from "../scripts/site-routes.mjs"; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const read = (relative) => fs.readFileSync(path.join(root, relative), "utf8"); @@ -26,6 +27,14 @@ function assertRoutesExist(categoryPage, routes) { } } +function assertPublicRoutesExist(categoryRoute, routes) { + const publicRoutes = new Set(canonicalPages.map(({ route }) => route)); + for (const route of routes) { + const target = new URL(route, `https://tools.securetools.app${categoryRoute}`).pathname; + assert.ok(publicRoutes.has(target), `${categoryRoute} links to missing public route ${target}`); + } +} + function linkedCard(list, titleKey) { return [...list.matchAll(/([\s\S]*?)<\/a>/g)] .find((match) => match[2].includes(`data-i18n="${titleKey}"`)); @@ -63,7 +72,7 @@ assert.equal((scanList.match(/
/g) || []). assert.equal((scanList.match(/tools\.comingSoon/g) || []).length, 1); assert.match(scanList, /data-i18n="categories\.scan\.documentTitle"/); -const privacyHtml = read("tools/privacy/index.html"); +const privacyHtml = read("privacy/index.html"); const privacyList = categoryList(privacyHtml); const privacyRoutes = ["../image/metadata/", "../pdf/metadata/"]; assert.equal((privacyList.match(/
  • /g) || []).length, 2); @@ -72,7 +81,7 @@ assert.equal((privacyList.match(/status--available/g) || []).length, 2); assert.doesNotMatch(privacyList, /tools\.comingSoon|/i, `${route} must retain its source-level noindex`); - } else { - assert.equal(canonical, expectedCanonical, `${route} canonical changed`); - assert.equal(openGraphUrl, expectedCanonical, `${route} og:url changed`); - assert.equal(openGraphImage, socialImage, `${route} og:image changed`); - assert.equal(twitterImage, socialImage, `${route} twitter:image changed`); - } +for (const { from, to } of legacyRedirects) { + const response = await request(`${from}?namespace=legacy`, { status: redirectStatus }); + const location = new URL(response.headers.get("location"), base); + assert.equal(location.pathname, to, `${from} redirect target`); + assert.equal(location.search, "?namespace=legacy", `${from} preserves the query string`); } for (const asset of assets) { @@ -96,4 +69,4 @@ for (const asset of assets) { await response.arrayBuffer(); } -console.log(`Deployment smoke checks passed for ${base.origin}: indexing=${indexing}, 20 routes, 7 assets, no redirects, expected indexing header, 19 tools-host canonical and social metadata pages plus the intentional noindex legacy alias.`); +console.log(`Deployment smoke checks passed for ${base.origin}: indexing=${indexing}, ${canonicalPages.length} canonical routes, ${legacyRedirects.length} permanent redirects, and ${assets.length} assets.`); diff --git a/tests/home-structure.test.mjs b/tests/home-structure.test.mjs index ec1fb6a..d0bd108 100644 --- a/tests/home-structure.test.mjs +++ b/tests/home-structure.test.mjs @@ -17,11 +17,11 @@ function testCategoryFirstHomepage() { assert.equal((home.match(/class="category-card surface"/g) || []).length, 5, "Homepage must retain five category entry points"); const categoryHrefs = [...home.matchAll(/ match[1]); assert.deepEqual(categoryHrefs, [ - "./tools/pdf/", - "./tools/image/", - "./tools/privacy/", - "./tools/scan/", - "./tools/media/", + "./pdf/", + "./image/", + "./privacy/", + "./scan/", + "./media/", ]); const flow = [ diff --git a/tests/i18n-quality.test.mjs b/tests/i18n-quality.test.mjs index 8225b0c..39f5a2f 100644 --- a/tests/i18n-quality.test.mjs +++ b/tests/i18n-quality.test.mjs @@ -10,6 +10,7 @@ import { selectInitialLanguage, translations, } from "../js/i18n.js"; +import { canonicalPages } from "../scripts/site-routes.mjs"; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const languageNames = new Map([ @@ -76,8 +77,8 @@ function testResolutionDetectionAndPersistence() { } function testSelectorsAndDocumentTranslation() { - const pages = listFiles(root, (file) => file.endsWith(".html") && fs.readFileSync(file, "utf8").includes("data-language-select")); - assert.equal(pages.length, 20, "Every production page with the shared header must expose the language selector"); + const pages = canonicalPages.map(({ source }) => path.join(root, source)); + assert.equal(pages.length, 18, "Every canonical page comes from the route manifest"); for (const file of pages) { const html = fs.readFileSync(file, "utf8"); const select = html.match(/]*data-language-select[^>]*>([\s\S]*?)<\/select>/)?.[1]; diff --git a/tests/pdf-merge-and-categories.test.mjs b/tests/pdf-merge-and-categories.test.mjs index c0f37b0..916ca6f 100644 --- a/tests/pdf-merge-and-categories.test.mjs +++ b/tests/pdf-merge-and-categories.test.mjs @@ -8,10 +8,13 @@ import { fileURLToPath } from "node:url"; import { translations } from "../js/i18n.js"; import { formatBytes, moveArrayItem, sanitizePdfFilename } from "../tools/shared/file.js"; import { inspectPdf, isSupportedPdf, mergePdfFiles } from "../tools/pdf/merge/pdf.js"; +import { canonicalPages } from "../scripts/site-routes.mjs"; const require = createRequire(import.meta.url); const { PDFDocument, StandardFonts } = require("../assets/vendor/pdf-lib/pdf-lib.min.js"); const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const publicRouteBySource = new Map(canonicalPages.map(({ source, route }) => [source, route])); +const publicRoutes = new Set(canonicalPages.map(({ route }) => route)); async function makePdf(name, pageSizes, type = "application/pdf") { const document = await PDFDocument.create(); @@ -164,7 +167,11 @@ function testRoutesTranslationsAndPrivacy() { ? path.resolve(root, reference.slice(1)) : path.resolve(path.dirname(file), reference); if (reference.endsWith("/") || (fs.existsSync(target) && fs.statSync(target).isDirectory())) target = path.join(target, "index.html"); - assert.equal(fs.existsSync(target), true, `${relative} has missing ${attribute}: ${rawReference}`); + const publicBase = publicRouteBySource.get(relative); + const publicTarget = publicBase && attribute === "href" && reference.endsWith("/") + ? new URL(reference, `https://tools.securetools.app${publicBase}`).pathname + : null; + assert.equal(fs.existsSync(target) || publicRoutes.has(publicTarget), true, `${relative} has missing ${attribute}: ${rawReference}`); } for (const [, source] of html.matchAll(/]+src="([^"]+)"/gi)) assert.doesNotMatch(source, /^https?:/i, `${relative} loads an external script`); for (const tag of html.matchAll(/]*>/gi)) { diff --git a/tests/run-all.mjs b/tests/run-all.mjs index 2cb414e..66ffef3 100644 --- a/tests/run-all.mjs +++ b/tests/run-all.mjs @@ -38,6 +38,7 @@ for (const test of [ "tests/security-hardening.test.mjs", "tests/release-gate.test.mjs", "tests/seo-foundation.test.mjs", + "tests/url-namespace.test.mjs", "tests/home-structure.test.mjs", "tests/typography-i18n-layout.test.mjs", "tests/pdf-to-images.test.mjs", diff --git a/tests/seo-foundation.test.mjs b/tests/seo-foundation.test.mjs index d7859c2..6501b86 100644 --- a/tests/seo-foundation.test.mjs +++ b/tests/seo-foundation.test.mjs @@ -3,37 +3,19 @@ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { canonicalPages, productionOrigin } from "../scripts/site-routes.mjs"; + const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const read = (relative) => fs.readFileSync(path.join(root, relative), "utf8"); const readBytes = (relative) => fs.readFileSync(path.join(root, relative)); -const origin = "https://tools.securetools.app"; +const origin = productionOrigin; const legacyOrigin = "https://securetools.app"; -const indexableRoutes = new Map([ - ["index.html", "/"], - ["about/index.html", "/about/"], - ["privacy/index.html", "/privacy/"], - ["tools/pdf/index.html", "/tools/pdf/"], - ["tools/pdf/images-to-pdf/index.html", "/tools/pdf/images-to-pdf/"], - ["tools/pdf/merge/index.html", "/tools/pdf/merge/"], - ["tools/pdf/split/index.html", "/tools/pdf/split/"], - ["tools/pdf/organize/index.html", "/tools/pdf/organize/"], - ["tools/pdf/to-images/index.html", "/tools/pdf/to-images/"], - ["tools/pdf/metadata/index.html", "/tools/pdf/metadata/"], - ["tools/image/index.html", "/tools/image/"], - ["tools/image/converter/index.html", "/tools/image/converter/"], - ["tools/image/resize/index.html", "/tools/image/resize/"], - ["tools/image/compress/index.html", "/tools/image/compress/"], - ["tools/image/metadata/index.html", "/tools/image/metadata/"], - ["tools/image/to-text/index.html", "/tools/image/to-text/"], - ["tools/privacy/index.html", "/tools/privacy/"], - ["tools/scan/index.html", "/tools/scan/"], - ["tools/media/index.html", "/tools/media/"], -]); +const indexableRoutes = new Map(canonicalPages.map(({ source, route }) => [source, route])); const excludedRoutes = ["404.html", "tools/image-to-pdf/index.html"]; const allHtmlRoutes = [...indexableRoutes.keys(), ...excludedRoutes]; -assert.equal(indexableRoutes.size + 1, 20, "all 20 public and migration routes remain represented"); +assert.equal(indexableRoutes.size, 18, "all canonical pages come from the route manifest"); const shareImagePath = "assets/images/og-image.png"; const shareImageUrl = `${origin}/${shareImagePath}`; const iconLinks = new Map([ diff --git a/tests/serve-ocr-smoke.mjs b/tests/serve-ocr-smoke.mjs index 72a4e9a..826b8a1 100644 --- a/tests/serve-ocr-smoke.mjs +++ b/tests/serve-ocr-smoke.mjs @@ -3,7 +3,10 @@ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { legacyRedirects, redirectStatus } from "../scripts/site-routes.mjs"; + const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const siteRoot = path.join(root, "dist"); const port = Number.parseInt(process.argv[2] || "4173", 10); const contentTypes = new Map([ [".css", "text/css; charset=utf-8"], @@ -18,11 +21,17 @@ const contentTypes = new Map([ const server = http.createServer((request, response) => { const pathname = new URL(request.url, "http://127.0.0.1").pathname; - const requested = pathname === "/" - ? "/tests/browser/ocr-smoke.html" - : pathname.endsWith("/") ? `${pathname}index.html` : pathname; - const target = path.resolve(root, `.${decodeURIComponent(requested)}`); - if (!target.startsWith(`${root}${path.sep}`)) { + const redirect = legacyRedirects.find(({ from }) => from === pathname); + if (redirect) { + const target = new URL(redirect.to, "http://127.0.0.1"); + target.search = new URL(request.url, "http://127.0.0.1").search; + response.writeHead(redirectStatus, { Location: `${target.pathname}${target.search}` }).end(); + return; + } + const requested = pathname.endsWith("/") ? `${pathname}index.html` : pathname; + const fileRoot = requested.startsWith("/tests/browser/") ? root : siteRoot; + const target = path.resolve(fileRoot, `.${decodeURIComponent(requested)}`); + if (!target.startsWith(`${fileRoot}${path.sep}`)) { response.writeHead(403).end("Forbidden"); return; } @@ -41,5 +50,5 @@ const server = http.createServer((request, response) => { server.listen(port, "127.0.0.1", () => { console.log(`OCR browser smoke: http://127.0.0.1:${port}/tests/browser/ocr-smoke.html`); - console.log(`Image to Text UI QA: http://127.0.0.1:${port}/tools/image/to-text/`); + console.log(`Image to Text UI QA: http://127.0.0.1:${port}/image/to-text/`); }); diff --git a/tests/url-namespace.test.mjs b/tests/url-namespace.test.mjs new file mode 100644 index 0000000..c2865bd --- /dev/null +++ b/tests/url-namespace.test.mjs @@ -0,0 +1,46 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { canonicalPages, legacyRedirects, productionOrigin, redirectStatus } from "../scripts/site-routes.mjs"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const routes = new Set(canonicalPages.map(({ route }) => route)); + +assert.equal(canonicalPages.length, 18); +assert.equal(legacyRedirects.length, 17); +assert.equal(redirectStatus, 308); +assert.ok(routes.has("/image/to-text/")); +assert.ok(routes.has("/image/resize/")); +assert.ok(routes.has("/pdf/merge/")); +assert.equal(new Set(canonicalPages.map(({ source }) => source)).size, canonicalPages.length); +assert.equal(routes.size, canonicalPages.length); + +for (const { source, route } of canonicalPages) { + const html = fs.readFileSync(path.join(root, source), "utf8"); + assert.match(html, new RegExp(``)); + assert.match(html, new RegExp(``)); + assert.doesNotMatch(html, /(?:href|src)="[^"]*\/tools\//, `${source} contains active legacy navigation`); +} + +for (const { from, to } of legacyRedirects) { + assert.match(from, /^\/tools\//); + assert.ok(routes.has(to), `${from} targets unknown canonical route ${to}`); +} +assert.equal(new Set(legacyRedirects.map(({ from }) => from)).size, legacyRedirects.length); +assert.deepEqual( + legacyRedirects.find(({ from }) => from === "/tools/image/to-text/"), + { from: "/tools/image/to-text/", to: "/image/to-text/" }, +); +assert.deepEqual( + legacyRedirects.find(({ from }) => from === "/tools/privacy/"), + { from: "/tools/privacy/", to: "/privacy/" }, +); + +const sitemap = fs.readFileSync(path.join(root, "sitemap.xml"), "utf8"); +const sitemapUrls = [...sitemap.matchAll(/([^<]+)<\/loc>/g)].map((match) => match[1]); +assert.deepEqual(sitemapUrls, canonicalPages.map(({ route }) => `${productionOrigin}${route}`)); +assert.doesNotMatch(sitemap, /\/tools\//); + +console.log("Canonical route manifest, internal navigation, redirects, and sitemap namespace checks passed."); diff --git a/tools/image/compress/index.html b/tools/image/compress/index.html index a3ba882..18576d1 100644 --- a/tools/image/compress/index.html +++ b/tools/image/compress/index.html @@ -7,9 +7,9 @@ - + - + diff --git a/tools/image/converter/index.html b/tools/image/converter/index.html index 0900258..55654f8 100644 --- a/tools/image/converter/index.html +++ b/tools/image/converter/index.html @@ -10,9 +10,9 @@ - + - + diff --git a/tools/image/index.html b/tools/image/index.html index 9eb2af0..0fd9349 100644 --- a/tools/image/index.html +++ b/tools/image/index.html @@ -10,9 +10,9 @@ - + - + diff --git a/tools/image/metadata/index.html b/tools/image/metadata/index.html index 6eeff01..7b64885 100644 --- a/tools/image/metadata/index.html +++ b/tools/image/metadata/index.html @@ -7,9 +7,9 @@ - + - + diff --git a/tools/image/resize/index.html b/tools/image/resize/index.html index 01524dc..3b2c5e9 100644 --- a/tools/image/resize/index.html +++ b/tools/image/resize/index.html @@ -9,9 +9,9 @@ - + - + diff --git a/tools/image/to-text/index.html b/tools/image/to-text/index.html index 22fae48..d334f5a 100644 --- a/tools/image/to-text/index.html +++ b/tools/image/to-text/index.html @@ -2,7 +2,7 @@ -Image to Text OCR — Secure Tools +Image to Text OCR — Secure Tools diff --git a/tools/media/index.html b/tools/media/index.html index 185851f..59624eb 100644 --- a/tools/media/index.html +++ b/tools/media/index.html @@ -6,9 +6,9 @@ - + - + diff --git a/tools/pdf/images-to-pdf/index.html b/tools/pdf/images-to-pdf/index.html index feb1102..86e748a 100644 --- a/tools/pdf/images-to-pdf/index.html +++ b/tools/pdf/images-to-pdf/index.html @@ -10,9 +10,9 @@ - + - + diff --git a/tools/pdf/index.html b/tools/pdf/index.html index 2228291..2d527ae 100644 --- a/tools/pdf/index.html +++ b/tools/pdf/index.html @@ -6,9 +6,9 @@ - + - + diff --git a/tools/pdf/merge/index.html b/tools/pdf/merge/index.html index 8f9fbaa..0858460 100644 --- a/tools/pdf/merge/index.html +++ b/tools/pdf/merge/index.html @@ -6,9 +6,9 @@ - + - + diff --git a/tools/pdf/metadata/index.html b/tools/pdf/metadata/index.html index 9488f80..15ad7d4 100644 --- a/tools/pdf/metadata/index.html +++ b/tools/pdf/metadata/index.html @@ -7,9 +7,9 @@ - + - + diff --git a/tools/pdf/organize/index.html b/tools/pdf/organize/index.html index b41cfe2..d050761 100644 --- a/tools/pdf/organize/index.html +++ b/tools/pdf/organize/index.html @@ -7,9 +7,9 @@ - + - + diff --git a/tools/pdf/split/index.html b/tools/pdf/split/index.html index d43f315..f3c7d61 100644 --- a/tools/pdf/split/index.html +++ b/tools/pdf/split/index.html @@ -6,9 +6,9 @@ - + - + diff --git a/tools/pdf/to-images/index.html b/tools/pdf/to-images/index.html index 6a55bad..8059b8d 100644 --- a/tools/pdf/to-images/index.html +++ b/tools/pdf/to-images/index.html @@ -6,9 +6,9 @@ - + - + diff --git a/tools/privacy/index.html b/tools/privacy/index.html index 19ede0e..54e21e5 100644 --- a/tools/privacy/index.html +++ b/tools/privacy/index.html @@ -6,9 +6,9 @@ - + - + diff --git a/tools/scan/index.html b/tools/scan/index.html index 8e88532..2830f61 100644 --- a/tools/scan/index.html +++ b/tools/scan/index.html @@ -6,9 +6,9 @@ - + - +