# Tool titles, H1 and descriptions Implementation Plan > **Status, 18 September 2026:** implemented in v2.6.0 by #171; the checkboxes were never ticked. Task 2's `scripts/seo/merge-tool-copy.py` remains as a one-shot script. In v2.6.1 #179 replaced the hand-kept `_PDF_TOOLS`/`_NONPDF_TOOLS` copy behind Task 4's server fallback with tables read from the committed tool manifest. > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Every tool page gets one hand-written search title and meta description, stored in the registries and used by the server head, the server H1, the visible H1 and the browser tab. **Architecture:** Two new required fields on `Tool` and `NonPdfTool` flow through the existing `gen-llms.mjs` manifest into `backend/app/seo_meta.py`, which prefers them over the old formula. A small TypeScript helper feeds the workspace H1 and `document.title`; a backend parity test proves server and manifest agree. **Tech Stack:** TypeScript registries, Vitest, FastAPI/pytest, `frontend/scripts/gen-llms.mjs` (static TS parsing via `content-data.mjs`). **Spec:** `docs/superpowers/specs/2026-09-17-tool-page-seo-design.md` (sections 1 and 3) ## Global Constraints - `seoTitle`: 40–60 characters, no "PrivaTools", unique across all 221 tools, query-first (`Merge PDF Files Online Free – Combine PDFs Privately`), en dash `–` as separator, Title Case. - `metaDescription`: 120–160 characters, at most two sentences, ends with `.`, no `…`, unique. - No three-digit tool counts in either field. Forbidden claims: `no file size limits`, `never written to disk`, `everything stays in your browser` unless `clientOnly: true`, `password|encrypt|AES` on `create-zip`, `unlimited`, `100% safe`. - Type-check with `npx tsc --noEmit -p tsconfig.app.json` from `frontend/`. Run the whole backend suite with `.venv/bin/python -m pytest backend/tests -q --deselect backend/tests/test_api.py` from the repo root. - Branch: `feat/tool-page-seo` (rebased on `main`). Commit after every task. --- ### Task 1: Registry rules as tests **Files:** - Modify: `frontend/src/data/tools.ts:18-40` (interface `Tool`) - Modify: `frontend/src/data/non-pdf-tools.ts:21-42` (interface `NonPdfTool`) - Test: `frontend/src/test/tool-registry.test.ts` **Interfaces:** - Produces: `Tool.seoTitle: string`, `Tool.metaDescription: string`, same on `NonPdfTool`. Tasks 3–5 read them. - [ ] **Step 1: Write the failing tests** (append inside the existing `describe` in `tool-registry.test.ts`; `allTools` already exists there) ```ts const FORBIDDEN = [/no file size limits/i, /never written to disk/i, /unlimited/i, /100% safe/i]; it("gives every tool a query-first search title within budget", () => { const bad = allTools.filter(tool => { const title = tool.seoTitle ?? ""; return title.length < 40 || title.length > 60 || /PrivaTools/.test(title) || title.trim() !== title; }).map(tool => `${tool.slug}: ${tool.seoTitle}`); expect(bad).toEqual([]); const titles = allTools.map(tool => tool.seoTitle.toLowerCase()); expect(new Set(titles).size).toBe(titles.length); }); it("gives every tool a meta description written to budget", () => { const bad = allTools.filter(tool => { const text = tool.metaDescription ?? ""; const sentences = text.split(/(?<=\.)\s+/).filter(Boolean).length; return text.length < 120 || text.length > 160 || !text.endsWith(".") || text.includes("…") || sentences > 2; }).map(tool => `${tool.slug}: ${tool.metaDescription}`); expect(bad).toEqual([]); const descriptions = allTools.map(tool => tool.metaDescription.toLowerCase()); expect(new Set(descriptions).size).toBe(descriptions.length); }); it("keeps search copy free of counts and forbidden claims", () => { const bad = allTools.filter(tool => { const copy = `${tool.seoTitle}\n${tool.metaDescription}`; const browserClaim = /stays in your browser|in your browser/i.test(copy) && !tool.clientOnly; return /\b\d{3,}\b/.test(copy) || FORBIDDEN.some(re => re.test(copy)) || browserClaim; }).map(tool => tool.slug); expect(bad).toEqual([]); const createZip = nonPdfTools.find(tool => tool.slug === "create-zip"); expect(`${createZip?.seoTitle}\n${createZip?.metaDescription}`).not.toMatch(/password|encrypt|AES/i); }); ``` - [ ] **Step 2: Add the fields to both interfaces** (so the test compiles and fails on content, not types) In `tools.ts` after `longDescription: string;`: ```ts /** Whole , 40–60 chars, query-first, no brand. See docs/superpowers/specs/2026-09-17-tool-page-seo-design.md. */ seoTitle: string; /** Meta description, 120–160 chars, ends with a period. */ metaDescription: string; ``` Same two lines in `non-pdf-tools.ts` after its `longDescription: string;`. - [ ] **Step 3: Run the tests to verify they fail** Run: `cd frontend && npx vitest run src/test/tool-registry.test.ts` Expected: the three new tests FAIL listing every slug (fields missing). `npx tsc --noEmit -p tsconfig.app.json` also fails on the 221 objects missing the fields; that is expected until Task 2. - [ ] **Step 4: Commit the tests and types** ```bash git add frontend/src/data/tools.ts frontend/src/data/non-pdf-tools.ts frontend/src/test/tool-registry.test.ts git commit -m "Specify search title and meta description rules for every tool" ``` --- ### Task 2: Write the 221 titles and descriptions **Files:** - Create (scratch, not committed): `/tmp/claude-1001/.../scratchpad/seo-copy/<group>.json` per author group - Create: `scripts/seo/merge-tool-copy.py` - Modify: `frontend/src/data/tools.ts`, `frontend/src/data/non-pdf-tools.ts` (one `seoTitle` and one `metaDescription` line per tool, inserted after `longDescription`) **Interfaces:** - Consumes: registry `slug`, `name`, `description`, `longDescription`, `synonyms`, `clientOnly`. - Produces: JSON `{ "<slug>": { "seoTitle": "...", "metaDescription": "..." } }` merged into the registries. - [ ] **Step 1: Export the authoring input** ```bash cd frontend && node -e ' const { readContentArray } = await import("./scripts/content-data.mjs"); const pick = t => ({ slug: t.slug, name: t.name, description: t.description, longDescription: t.longDescription, synonyms: t.synonyms, clientOnly: !!t.clientOnly }); const pdf = readContentArray("src/data/tools.ts", "_toolsRaw", ["icon"]).map(pick); const rest = readContentArray("src/data/non-pdf-tools.ts", "_nonPdfToolsRaw", ["icon"]).map(pick); process.stdout.write(JSON.stringify({ pdf, rest }, null, 1)); ' > "$SCRATCH/seo-copy/input.json" ``` (`readContentArray` is the same static parser `gen-llms.mjs` uses; run with `--input-type=module` if `await import` is rejected.) - [ ] **Step 2: Author copy in four parallel groups** Dispatch four agents, each with ~55 tools from `input.json` and exactly these rules, returning a JSON object keyed by slug: - `seoTitle` 40–60 chars; opens with the natural search phrase for the task (`Merge PDF Files Online Free`, `Convert HEIC to JPG Online`, `Decode a JWT Token Online`), then ` – ` and one synonym or differentiator drawn from `synonyms`/`longDescription` (`Combine PDFs Privately`, `No Sign-Up`, `Batch Up to 25 Files`); Title Case; no brand; no numbers ≥ 100. - `metaDescription` 120–160 chars, ≤ 2 sentences, ends with `.`: what it does, then one differentiator. Server tools: "temporary server processing" wording is fine; only `clientOnly` tools may say "in your browser". Forbidden phrases as in Global Constraints. - Unique within the group; the merge step checks globally. - [ ] **Step 3: Write the merge script** ```python #!/usr/bin/env python3 """Insert seoTitle/metaDescription into the tool registries from JSON copy files.""" from __future__ import annotations import json, re, sys from pathlib import Path ROOT = Path(__file__).resolve().parents[2] REGISTRIES = [ROOT / "frontend/src/data/tools.ts", ROOT / "frontend/src/data/non-pdf-tools.ts"] def main(paths: list[str]) -> int: copy: dict[str, dict[str, str]] = {} for path in paths: copy.update(json.loads(Path(path).read_text(encoding="utf-8"))) missing = [] for registry in REGISTRIES: text = registry.read_text(encoding="utf-8") def insert(match: re.Match) -> str: slug = match.group(1) entry = copy.get(slug) if entry is None: missing.append(slug) return match.group(0) indent = match.group(2) title = json.dumps(entry["seoTitle"], ensure_ascii=False) desc = json.dumps(entry["metaDescription"], ensure_ascii=False) return f"{match.group(0)}\n{indent}seoTitle: {title},\n{indent}metaDescription: {desc}," # slug line ... longDescription line, capturing the indent of longDescription. pattern = re.compile(r'slug: "([a-z0-9-]+)",[\s\S]*?\n(\s*)longDescription: "(?:[^"\\]|\\.)*",') text = pattern.sub(insert, text) registry.write_text(text, encoding="utf-8") if missing: print("no copy for:", ", ".join(missing), file=sys.stderr) return 1 return 0 if __name__ == "__main__": raise SystemExit(main(sys.argv[1:])) ``` - [ ] **Step 4: Merge and validate** Run: `python3 scripts/seo/merge-tool-copy.py "$SCRATCH"/seo-copy/group-*.json && cd frontend && npx vitest run src/test/tool-registry.test.ts && npx tsc --noEmit -p tsconfig.app.json` Expected: PASS. If a slug fails a rule, fix that entry in its JSON and re-run the merge on a clean checkout of the two registry files (`git checkout -- frontend/src/data/tools.ts frontend/src/data/non-pdf-tools.ts`). - [ ] **Step 5: Read a sample by hand** Print 15 random entries and check they read naturally and describe the right tool: `node -e 'const {readContentArray}=await import("./scripts/content-data.mjs");const t=[...readContentArray("src/data/tools.ts","_toolsRaw",["icon"]),...readContentArray("src/data/non-pdf-tools.ts","_nonPdfToolsRaw",["icon"])];t.sort(()=>Math.random()-0.5).slice(0,15).forEach(x=>console.log(x.slug,"|",x.seoTitle,"|",x.metaDescription))'` - [ ] **Step 6: Commit** ```bash git add scripts/seo/merge-tool-copy.py frontend/src/data/tools.ts frontend/src/data/non-pdf-tools.ts git commit -m "Write query-first search titles and meta descriptions for every tool" ``` --- ### Task 3: One client source for title and H1 **Files:** - Create: `frontend/src/lib/tool-seo.ts` - Test: `frontend/src/lib/tool-seo.test.ts` - Modify: `frontend/src/skins/experience/ToolWorkspace.tsx:7,27` - Modify: `frontend/src/skins/daylight/SkinApp.tsx:849-853` - Modify: `frontend/src/pages/NonPdfToolPage.tsx:379` - Test: `frontend/src/skins/experience/ToolWorkspace.test.tsx` **Interfaces:** - Produces: `toolSeo(tool: { name: string; seoTitle?: string; metaDescription?: string; longDescription?: string }): { title: string; h1: string; description: string }`. - [ ] **Step 1: Write the failing helper test** ```ts import { describe, expect, it } from "vitest"; import { toolSeo } from "./tool-seo"; describe("toolSeo", () => { it("uses the registry search title for both the tab and the heading", () => { const seo = toolSeo({ name: "Merge PDF", seoTitle: "Merge PDF Files Online Free – Combine PDFs Privately", metaDescription: "Combine PDFs in order. Free, no sign-up." }); expect(seo.title).toBe("Merge PDF Files Online Free – Combine PDFs Privately"); expect(seo.h1).toBe("Merge PDF Files Online Free – Combine PDFs Privately"); expect(seo.description).toBe("Combine PDFs in order. Free, no sign-up."); }); it("falls back to the tool name for registries without search copy", () => { const seo = toolSeo({ name: "Merge PDF", longDescription: "Long text." }); expect(seo).toEqual({ title: "Merge PDF — Free Online", h1: "Merge PDF", description: "Long text." }); }); }); ``` - [ ] **Step 2: Run it to verify it fails** Run: `cd frontend && npx vitest run src/lib/tool-seo.test.ts` — Expected: FAIL, module not found. - [ ] **Step 3: Implement the helper** ```ts /** One source for a tool page's title, H1 and description. The server reads the same registry fields through the build manifest. */ export interface ToolSeoInput { name: string; seoTitle?: string; metaDescription?: string; longDescription?: string; description?: string } export interface ToolSeo { title: string; h1: string; description: string } export function toolSeo(tool: ToolSeoInput): ToolSeo { const title = tool.seoTitle?.trim(); return { title: title || `${tool.name} — Free Online`, h1: title || tool.name, description: tool.metaDescription?.trim() || tool.longDescription || tool.description || "", }; } ``` - [ ] **Step 4: Run the helper test** — Expected: PASS. - [ ] **Step 5: Write the failing workspace test** (add to `ToolWorkspace.test.tsx`; keep the existing mocks) ```ts it("uses the search title as the page heading", async () => { await act(async () => { render(<ToolWorkspace tool={{ slug: "compress-pdf", name: "Compress PDF", seoTitle: "Compress PDF Online Free – Shrink Files, Keep Quality", description: "A smaller file", category: "pdf" }} categoryLabel="PDF" related={[]} onFindTool={() => undefined}><div>File picker</div></ToolWorkspace>); }); expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("Compress PDF Online Free – Shrink Files, Keep Quality"); }); ``` - [ ] **Step 6: Run it** — Expected: FAIL (heading is "Compress PDF"). - [ ] **Step 7: Wire the helper** `ToolWorkspace.tsx`: extend `CatalogTool` with `seoTitle?: string; metaDescription?: string; longDescription?: string;`, import `toolSeo` from `@/lib/tool-seo`, and change the header to `<h1>{toolSeo(tool).h1}</h1>`. `SkinApp.tsx:851`: replace `` if (t) return `${t.name} Online Free — No Sign Up | PrivaTools`; `` with `if (t) return toolSeo(t).title;` and add `import { toolSeo } from "@/lib/tool-seo";` next to the other `@/lib` imports (the file is `@ts-nocheck`; keep the edit minimal). `NonPdfToolPage.tsx:379`: replace the `document.title = \`${tool.name} — PrivaTools\`` assignment with `document.title = toolSeo(tool).title;` and import the helper. - [ ] **Step 8: Run tests and type-check** Run: `cd frontend && npx vitest run src/skins/experience src/lib/tool-seo.test.ts && npx tsc --noEmit -p tsconfig.app.json` — Expected: PASS. - [ ] **Step 9: Commit** ```bash git add frontend/src/lib/tool-seo.ts frontend/src/lib/tool-seo.test.ts frontend/src/skins/experience/ToolWorkspace.tsx frontend/src/skins/experience/ToolWorkspace.test.tsx frontend/src/skins/daylight/SkinApp.tsx frontend/src/pages/NonPdfToolPage.tsx git commit -m "Drive the tool H1 and tab title from the search title" ``` --- ### Task 4: Server title, H1 and description from the manifest **Files:** - Modify: `backend/app/seo_meta.py:463-477` (`_tool_registries`), `:1713-1728` (`get_meta_for_path` tool branches), `:2426` and `:2486` (SSR `<h1>`) - Test: `backend/tests/test_seo_meta.py` **Interfaces:** - Produces: `_tool_seo_fields(slug: str) -> dict[str, str]` returning `seoTitle`, `metaDescription`, `lastReviewed`, `category` and `popularity` (as strings) from the manifest row, `{}` when no manifest. - Consumes: manifest rows written by `gen-llms.mjs` (fields flow automatically from the registries). - [ ] **Step 1: Write the failing parity test** ```python def test_tool_pages_use_registry_search_copy(tmp_path, monkeypatch): manifest = { "merge-pdf": {"slug": "merge-pdf", "name": "Merge PDF", "path": "/tool/merge-pdf", "category": "organize", "description": "Combine PDFs", "longDescription": "Long intro text for the page.", "seoTitle": "Merge PDF Files Online Free – Combine PDFs Privately", "metaDescription": "Combine PDF files in the order you choose. Free, no sign-up, temporary server processing."}, "image-compressor": {"slug": "image-compressor", "name": "Image Compressor", "path": "/tools/image-compressor", "category": "image", "description": "Shrink images", "longDescription": "Long intro for images.", "seoTitle": "Compress Images Online Free – Smaller JPG, PNG and WebP", "metaDescription": "Reduce JPG, PNG and WebP file size with a quality preset you control. Free, no sign-up, temporary server processing."}, } path = tmp_path / "tool-content.json" path.write_text(json.dumps(manifest), encoding="utf-8") monkeypatch.setattr(seo_meta, "_TOOL_JSON", path) seo_meta._load_manifest.cache_clear() title, description = seo_meta.get_meta_for_path("/tool/merge-pdf") assert title == "Merge PDF Files Online Free – Combine PDFs Privately" assert description == manifest["merge-pdf"]["metaDescription"] html = seo_meta.inject_seo("<html><head><title>x
", "/tools/image-compressor") assert "

Compress Images Online Free – Smaller JPG, PNG and WebP

" in html assert 'content="Compress Images Online Free – Smaller JPG, PNG and WebP"' in html # og:title assert "| PrivaTools" not in title ``` (`json` is already imported in that test module; `_load_manifest` is the `lru_cache`d loader at `seo_meta.py:405` — check the exact name and cache-clear call when writing the test. If `_TOOL_JSON` is a module constant `Path`, `monkeypatch.setattr` works as written.) - [ ] **Step 2: Run it to verify it fails** Run: `.venv/bin/python -m pytest backend/tests/test_seo_meta.py -k registry_search_copy -q` — Expected: FAIL, title is `Merge PDF — Free Online | PrivaTools`. - [ ] **Step 3: Implement** Add after `_tool_registries`: ```python _SEO_FIELDS = ("seoTitle", "metaDescription", "lastReviewed", "category", "popularity") def _tool_seo_fields(slug: str) -> dict[str, str]: """Search copy and review data the registries carry through the build manifest.""" data = _load_manifest(str(_TOOL_JSON), blog_content_mtime_ns()) row = (data or {}).get(slug) or {} return {key: str(row[key]).strip() for key in _SEO_FIELDS if row.get(key) not in (None, "")} ``` In `get_meta_for_path`, both tool branches become: ```python name, desc = _PDF_TOOLS[slug] fields = _tool_seo_fields(slug) return fields.get("seoTitle") or _tool_title(name), fields.get("metaDescription") or _tool_desc(desc) ``` In `_build_ssr_content` (both branches) replace the `

` line with: ```python parts.append(f"

{_tool_seo_fields(slug).get('seoTitle') or name}

") ``` - [ ] **Step 4: Run the parity test and the whole backend suite** Run: `.venv/bin/python -m pytest backend/tests -q --deselect backend/tests/test_api.py` — Expected: PASS apart from the known font-dependent subtitle test on this VM. `test_top50_seo.py` and `test_seo_meta.py:335` only require `[^<]*\|

[^<]*

\| ``` Body: what changed, the copy rules, verification results, and that the H1 and tab title now share one source. End with `🤖 Generated with [Claude Code](https://claude.com/claude-code)`.