Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
- Added the single-file Image Metadata Inspector & Cleaner for JPEG, PNG, and WebP with honest partial/opaque reporting, authoritative Privacy Clean, ICC preservation, and fail-closed verification before save.
- Pinned the immutable `secure-metadata v0.1.1` browser Release artifact as a same-origin dependency with exact provenance and SHA-256 release-gate coverage.
- Added per-file and aggregate compression metrics that distinguish byte savings from larger generated results.
- Added production crawler discovery files, canonical URLs, page-specific Open Graph metadata, SEO regression coverage, and search-engine submission guidance for securetools.app.
- Added per-image output dimension/pixel checks and a 200-megapixel aggregate resize-output workload limit.
- Added JPEG, PNG, and WebP input/output, lossy quality controls for JPEG/WebP, deterministic white JPEG transparency, metadata-stripping canvas re-encoding, collision-safe Unicode names, and ZIP batch output.
- Added per-file, queue, dimension, decoded-pixel, and 200-megapixel aggregate-work protections with recoverable errors.
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ The [documentation index](./docs/README.md) links to the maintained sources of t
- architecture and delivery;
- privacy, local processing, and network boundaries;
- production dependencies and vendoring;
- search discovery, canonical metadata, and search-engine submission;
- production and planned tool status;
- release QA evidence;
- UX, accessibility, localization, and format-specific privacy audits.
Expand Down
3 changes: 3 additions & 0 deletions about/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' blob: data:; connect-src 'none'; object-src 'none'; frame-src 'none'; base-uri 'self'; form-action 'self'">
<meta name="description" content="Why Secure Tools builds open, local-first file utilities that can be inspected instead of simply trusted.">
<link rel="canonical" href="https://securetools.app/about/">
<meta property="og:site_name" content="Secure Tools">
<meta property="og:url" content="https://securetools.app/about/">
<meta name="theme-color" content="#f7f7f5">
<meta property="og:type" content="website">
<meta property="og:title" content="About — Secure Tools">
Expand Down
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ The root [README](../README.md) introduces Secure Tools. This directory owns det
| [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 |
| [Tool status](./tool-status.md) | Production and planned surfaces, supported formats, behavior, and resource boundaries |
| [Search discovery and metadata](./seo.md) | Canonical routes, crawler files, metadata policy, maintenance, and submission steps |
| [Image Metadata privacy](./image-metadata-privacy.md) | Format-specific inspection, cleaning, preservation, and verification semantics |
| [UX consistency audit](./ux-consistency-audit.md) | Shared interaction, accessibility, responsive, theme, and historical browser-QA findings |
| [i18n copy review](./i18n-copy-review.md) | Localization coverage and editorial review record |
Expand Down
63 changes: 63 additions & 0 deletions docs/seo.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# Search discovery and metadata

## Production origin

The canonical public origin is `https://securetools.app`. GitHub Pages serves the custom domain recorded in the root `CNAME` file. Canonical and sitemap URLs must never use the legacy GitHub Pages hostname.

## Crawler discovery

- `/robots.txt` allows public crawling and points to `https://securetools.app/sitemap.xml`.
- `/sitemap.xml` lists the 18 real canonical pages intended for indexing.
- The 404 page and the legacy `/tools/image-to-pdf/` redirect are intentionally `noindex` and absent from the sitemap.
- Static assets, tests, documentation files, and generated user downloads are not sitemap entries.

The sitemap is reviewed static XML. It omits speculative `lastmod`, `changefreq`, and `priority` values. `tests/seo-foundation.test.mjs` keeps it synchronized with the explicit public-route inventory.

## Page metadata contract

Every indexable page has:

- one HTTPS canonical URL on `securetools.app` using the directory route's trailing slash;
- one non-empty, page-specific title and meta description;
- `og:type=website`, `og:site_name=Secure Tools`, page-specific Open Graph title and description, and an `og:url` equal to the canonical URL;
- no accidental `noindex` or `nofollow` directive.

No `og:image` is declared because the repository does not yet contain a reviewed production share image. Twitter/X card tags are omitted because they would duplicate the current Open Graph fields without an image-specific presentation.

## Language and structured-data decisions

All six interface languages share the same routable page URL and switch client-side. Secure Tools therefore does not publish fabricated locale URLs, sitemap entries, or `hreflang` tags. The existing i18n runtime continues to update the visible copy, document language, title, description, and matching Open Graph text without changing canonical identity.

JSON-LD is intentionally deferred. The current static metadata already describes each page accurately, and this task does not add speculative ratings, reviews, FAQs, paid offers, organization claims, or language routes. A future schema should be introduced only with a clearly represented product model and dedicated validation.

## Privacy boundary

Search discovery is implemented with static text, XML, and HTML metadata. It adds no analytics, telemetry, tracker, cookie, verification script, external font, remote SEO runtime, or processing request. Search Console ownership remains DNS-based.

## Maintenance

When an indexable route is added, renamed, redirected, or retired:

1. update its title, description, canonical, and Open Graph metadata;
2. update the route inventory and sitemap together;
3. keep redirects and error pages out of the sitemap and mark them `noindex` when appropriate;
4. run `node tests/seo-foundation.test.mjs` and `node tests/run-all.mjs`;
5. inspect the rendered canonical, console, and Network panel before release.

## Search engine submission

After the merged GitHub Pages deployment reaches production:

### Google Search Console

1. Open the `securetools.app` Domain property.
2. Open **Sitemaps** and submit `sitemap.xml`.
3. Use URL Inspection for `https://securetools.app/` and request indexing when appropriate.
4. Inspect major PDF, Image, and Metadata tool URLs after sitemap discovery.
5. Monitor Page indexing and sitemap processing over the following days.

DNS ownership is already verified; do not add a Search Console HTML tag or tracking script.

### Bing Webmaster Tools

Configure Bing after the production sitemap is available. Prefer importing the verified Google Search Console property when Bing offers that option; otherwise add `https://securetools.app/sitemap.xml` directly through Bing Webmaster Tools.
2 changes: 2 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' blob: data:; connect-src 'none'; object-src 'none'; frame-src 'none'; base-uri 'self'; form-action 'self'">
<meta name="description" content="Convert, inspect, and process files privately in your browser. No uploads, accounts, analytics, or tracking.">
<link rel="canonical" href="https://securetools.app/">
<meta property="og:url" content="https://securetools.app/">
<meta name="theme-color" content="#f7f7f5">
<meta property="og:type" content="website">
<meta property="og:title" content="Secure Tools — Private tools for everyday files">
Expand Down
3 changes: 3 additions & 0 deletions privacy/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' blob: data:; connect-src 'none'; object-src 'none'; frame-src 'none'; base-uri 'self'; form-action 'self'">
<meta name="description" content="How Secure Tools keeps file processing local, avoids tracking, and uses local storage only for preferences.">
<link rel="canonical" href="https://securetools.app/privacy/">
<meta property="og:site_name" content="Secure Tools">
<meta property="og:url" content="https://securetools.app/privacy/">
<meta name="theme-color" content="#f7f7f5">
<meta property="og:type" content="website">
<meta property="og:title" content="Privacy — Secure Tools">
Expand Down
4 changes: 4 additions & 0 deletions robots.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
User-agent: *
Allow: /

Sitemap: https://securetools.app/sitemap.xml
21 changes: 21 additions & 0 deletions sitemap.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url><loc>https://securetools.app/</loc></url>
<url><loc>https://securetools.app/about/</loc></url>
<url><loc>https://securetools.app/privacy/</loc></url>
<url><loc>https://securetools.app/tools/pdf/</loc></url>
<url><loc>https://securetools.app/tools/pdf/images-to-pdf/</loc></url>
<url><loc>https://securetools.app/tools/pdf/merge/</loc></url>
<url><loc>https://securetools.app/tools/pdf/split/</loc></url>
<url><loc>https://securetools.app/tools/pdf/organize/</loc></url>
<url><loc>https://securetools.app/tools/pdf/to-images/</loc></url>
<url><loc>https://securetools.app/tools/pdf/metadata/</loc></url>
<url><loc>https://securetools.app/tools/image/</loc></url>
<url><loc>https://securetools.app/tools/image/converter/</loc></url>
<url><loc>https://securetools.app/tools/image/resize/</loc></url>
<url><loc>https://securetools.app/tools/image/compress/</loc></url>
<url><loc>https://securetools.app/tools/image/metadata/</loc></url>
<url><loc>https://securetools.app/tools/privacy/</loc></url>
<url><loc>https://securetools.app/tools/scan/</loc></url>
<url><loc>https://securetools.app/tools/media/</loc></url>
</urlset>
2 changes: 1 addition & 1 deletion tests/image-resize.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ assert.match(html, /name="resize-mode"[^>]*value="pixels"/); assert.match(html,
assert.match(html, /id="aspect-ratio"[^>]*checked/); assert.match(html, /id="allow-enlargement"/);
assert.match(html, /id="output-format"[\s\S]*value="original"[\s\S]*value="jpeg"[\s\S]*value="png"[\s\S]*value="webp"/);
assert.match(html, /role="status" aria-live="polite"/); assert.match(html, /assets\/vendor\/jszip\/jszip\.min\.js/);
assert.match(html, /connect-src 'none'/); assert.doesNotMatch(html, /unsafe-inline|unsafe-eval|https?:\/\/(?!github\.com)/);
assert.match(html, /connect-src 'none'/); assert.doesNotMatch(html, /unsafe-inline|unsafe-eval/); assert.doesNotMatch(html, /<script[^>]+src="https?:\/\//i); assert.doesNotMatch(html, /<link[^>]+rel="stylesheet"[^>]+href="https?:/i);
assert.match(app, /finally\s*\{[\s\S]*state\.busy = false[\s\S]*elements\.progress\.hidden = true/);
assert.match(app, /URL\.revokeObjectURL/); assert.match(logic, /decoded\?\.close\(\)/);
assert.match(read("tools/shared/image.js"), /imageOrientation: "from-image"/);
Expand Down
1 change: 1 addition & 0 deletions tests/run-all.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ for (const test of [
"tests/pdf-split.test.mjs",
"tests/security-hardening.test.mjs",
"tests/release-gate.test.mjs",
"tests/seo-foundation.test.mjs",
"tests/home-structure.test.mjs",
"tests/typography-i18n-layout.test.mjs",
"tests/pdf-to-images.test.mjs",
Expand Down
94 changes: 94 additions & 0 deletions tests/seo-foundation.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import assert from "node:assert/strict";
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 read = (relative) => fs.readFileSync(path.join(root, relative), "utf8");
const origin = "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/privacy/index.html", "/tools/privacy/"],
["tools/scan/index.html", "/tools/scan/"],
["tools/media/index.html", "/tools/media/"],
]);

const excludedRoutes = ["404.html", "tools/image-to-pdf/index.html"];
const expectedUrls = [...indexableRoutes.values()].map((route) => `${origin}${route}`);
const titles = new Set();
const descriptions = new Set();

function values(html, pattern) {
return [...html.matchAll(pattern)].map((match) => match[1]);
}

for (const [relativeFile, route] of indexableRoutes) {
const html = read(relativeFile);
const expectedUrl = `${origin}${route}`;
const canonical = values(html, /<link rel="canonical" href="([^"]+)">/g);
const title = values(html, /<title>([^<]+)<\/title>/g);
const description = values(html, /<meta name="description" content="([^"]+)">/g);

assert.deepEqual(canonical, [expectedUrl], `${relativeFile}: one exact canonical URL`);
assert.equal(title.length, 1, `${relativeFile}: one title`);
assert.ok(title[0].trim(), `${relativeFile}: non-empty title`);
assert.equal(description.length, 1, `${relativeFile}: one description`);
assert.ok(description[0].trim(), `${relativeFile}: non-empty description`);
assert.deepEqual(values(html, /<meta property="og:type" content="([^"]+)">/g), ["website"], `${relativeFile}: Open Graph type`);
assert.deepEqual(values(html, /<meta property="og:site_name" content="([^"]+)">/g), ["Secure Tools"], `${relativeFile}: Open Graph site name`);
assert.deepEqual(values(html, /<meta property="og:url" content="([^"]+)">/g), [expectedUrl], `${relativeFile}: Open Graph URL matches canonical`);
assert.equal(values(html, /<meta property="og:title" content="([^"]+)">/g).length, 1, `${relativeFile}: one Open Graph title`);
assert.equal(values(html, /<meta property="og:description" content="([^"]+)">/g).length, 1, `${relativeFile}: one Open Graph description`);
assert.doesNotMatch(html, /<meta name="robots" content="[^"]*(?:noindex|nofollow)/i, `${relativeFile}: indexable`);
assert.doesNotMatch(html, /securetoolsproject\.github\.io/i, `${relativeFile}: no legacy canonical host`);
assert.doesNotMatch(html, /hreflang=/i, `${relativeFile}: no fabricated locale URL`);
titles.add(title[0]);
descriptions.add(description[0]);
}

assert.equal(titles.size, indexableRoutes.size, "page titles are unique");
assert.equal(descriptions.size, indexableRoutes.size, "page descriptions are unique");

for (const relativeFile of excludedRoutes) {
const html = read(relativeFile);
assert.match(html, /<meta name="robots" content="noindex">/i, `${relativeFile}: intentional noindex`);
assert.doesNotMatch(html, /rel="canonical"/i, `${relativeFile}: excluded from canonical inventory`);
}

const robots = read("robots.txt");
assert.equal(robots, `User-agent: *\nAllow: /\n\nSitemap: ${origin}/sitemap.xml\n`);
assert.doesNotMatch(robots, /^Disallow:\s*\/$/im, "robots.txt does not block the site");

const sitemap = read("sitemap.xml");
assert.match(sitemap, /^<\?xml version="1\.0" encoding="UTF-8"\?>/);
assert.match(sitemap, /<urlset xmlns="http:\/\/www\.sitemaps\.org\/schemas\/sitemap\/0\.9">/);
assert.match(sitemap, /<\/urlset>\s*$/);
assert.doesNotMatch(sitemap, /<(?:lastmod|changefreq|priority)>/);
const sitemapUrls = values(sitemap, /<loc>([^<]+)<\/loc>/g);
assert.deepEqual(sitemapUrls, expectedUrls, "sitemap exactly matches the canonical public route inventory");
assert.equal(new Set(sitemapUrls).size, sitemapUrls.length, "sitemap URLs are unique");
for (const url of sitemapUrls) assert.ok(url.startsWith(`${origin}/`), `${url}: production origin`);

assert.equal(read("CNAME").trim(), "securetools.app", "GitHub Pages custom domain");

const productionHtml = [...indexableRoutes.keys()].map(read).join("\n");
assert.doesNotMatch(productionHtml, /google-analytics|googletagmanager|gtag\(|meta pixel|facebook\.net\/.*fbevents|session replay/i);
assert.doesNotMatch(productionHtml, /<script[^>]+src="https?:\/\//i, "no remote runtime scripts");
assert.doesNotMatch(productionHtml, /<link[^>]+href="https?:\/\/[^\"]+"[^>]+rel="stylesheet"/i, "no remote stylesheets or fonts");

console.log("SEO route, crawler, canonical, metadata, privacy, and custom-domain checks passed.");
Loading
Loading