# 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/.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 `{ "": { "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( undefined}>File picker
); });
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 `{toolSeo(tool).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 ``)
- 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("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)`.