From 26433fa9c71fd4a0dab249186eee04d6c3c8898b Mon Sep 17 00:00:00 2001 From: Anthony GAEREMYNCK <1@anthony.sh> Date: Sun, 2 Aug 2026 10:13:28 +0200 Subject: [PATCH] docs: add implementation plan for SEO fixes and prerendering migration 11 tasks: Express fixes (compression/cache/redirect/404), react-router-dom v6 downgrade (required for vite-react-ssg compatibility, verified via dry-run), vite-react-ssg prerendering migration, and 30 curated conversion-pair landing pages with real fr/en content. --- .../superpowers/plans/2026-08-02-seo-fixes.md | 2281 +++++++++++++++++ 1 file changed, 2281 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-02-seo-fixes.md diff --git a/docs/superpowers/plans/2026-08-02-seo-fixes.md b/docs/superpowers/plans/2026-08-02-seo-fixes.md new file mode 100644 index 0000000..b1d4a60 --- /dev/null +++ b/docs/superpowers/plans/2026-08-02-seo-fixes.md @@ -0,0 +1,2281 @@ +# SEO Fixes and Prerendering Migration Implementation Plan + +> **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:** Make every route Convert exposes fully crawlable (correct title/meta/canonical/hreflang/JSON-LD in the raw HTML, real HTTP status codes) and add 30 curated conversion-pair landing pages to capture long-tail search traffic. + +**Architecture:** Migrate the frontend from pure client-side rendering to build-time prerendering with `vite-react-ssg` (each route rendered once at build to a static `index.html`, then hydrated normally), fix Express's redirect/404/caching behavior, and add a data-driven set of conversion-pair pages reusing the existing upload/convert UI. + +**Tech Stack:** React 19, react-router-dom (downgraded to v6.x — see Task 4), `vite-react-ssg` 0.9.2, `react-helmet-async` (transitive dependency of vite-react-ssg, consumed via its re-exported `Head` component), Express 5, `compression`. + +## Global Constraints + +- Every runtime package imported by `frontend/src/**` must be added to **both** `frontend/package.json` and root `package.json` dependencies (o2switch's nodevenv only installs root's `node_modules`; verified in `CLAUDE.md`). Packages only ever invoked as a CLI/build tool (like `vite`, `@vitejs/plugin-react` today) can stay in root **devDependencies**, matching the existing pattern. +- Don't guess package APIs or versions — every dependency version and API shape referenced in this plan (`vite-react-ssg@0.9.2`, `compression@1.8.1`, `react-router-dom@6.30.4`, the `ViteReactSSG`/`Head`/`ssgOptions` API) was confirmed against the published README/npm registry during planning, not assumed. +- Local test runs must use `.env.local` values as inline env vars, never `.env` (production creds) — per `CLAUDE.md`: `DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run`. +- No conversion-pair landing page is created for a pair that isn't actually registered in `src/converters/*.js` — every pair used in this plan was verified against the registry during planning. + +--- + +### Task 1: Compression, cache-control headers, and a configurable frontend dist path + +**Files:** +- Modify: `src/config.js` +- Modify: `src/app.js:1-10` (imports), `src/app.js:251-255` (static serving) +- Modify: `package.json` (root) — add `compression` +- Test: `test/staticServing.test.js` (new) + +**Interfaces:** +- Produces: `config.frontendDist` (string, absolute path) — consumed by Task 3. + +- [ ] **Step 1: Add `compression` to root `package.json` dependencies** + +In `package.json`, add to `"dependencies"` (alphabetical, matching existing style): +```json +"compression": "^1.8.1", +``` +Run: `npm install` + +- [ ] **Step 2: Add `frontendDist` to `src/config.js`** + +`src/config.js` currently has no `path` import. Add it and a new config field: + +```js +import 'dotenv/config'; +import path from 'node:path'; + +const REQUIRED_VARS = ['STORAGE_DIR', 'DB_HOST', 'DB_USER', 'DB_PASSWORD', 'DB_NAME']; + +export function loadConfig() { + const missing = REQUIRED_VARS.filter((key) => !process.env[key]); + if (missing.length > 0) { + throw new Error(`Missing required environment variables: ${missing.join(', ')}`); + } + + return { + port: Number(process.env.PORT ?? 3000), + storageDir: process.env.STORAGE_DIR, + frontendDist: path.join(import.meta.dirname, '..', 'frontend', 'dist'), + db: { + host: process.env.DB_HOST, + user: process.env.DB_USER, + password: process.env.DB_PASSWORD, + database: process.env.DB_NAME, + }, + maxFileSizeMb: Number(process.env.MAX_FILE_SIZE_MB ?? 100), + retentionHours: Number(process.env.RETENTION_HOURS ?? 1), + workerPollIntervalMs: Number(process.env.WORKER_POLL_INTERVAL_MS ?? 1500), + workerConcurrency: Number(process.env.WORKER_CONCURRENCY ?? 3), + ebookJobTimeoutMs: Number(process.env.EBOOK_JOB_TIMEOUT_MS ?? 180000), + videoJobTimeoutMs: Number(process.env.VIDEO_JOB_TIMEOUT_MS ?? 300000), + rateLimitMaxJobs: Number(process.env.RATE_LIMIT_MAX_JOBS ?? 20), + rateLimitWindowMinutes: Number(process.env.RATE_LIMIT_WINDOW_MINUTES ?? 10), + }; +} +``` + +This mirrors exactly what `src/app.js:251` already computes inline — moving it to config makes it overridable in tests the same way `storageDir` already is (see `test/api/jobs.test.js:18-24`, which does `{ ...loadConfig(), storageDir: }`). + +- [ ] **Step 3: Write the failing test for compression + cache headers** + +Create `test/staticServing.test.js`: + +```js +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import request from 'supertest'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; +import { createApp } from '../src/app.js'; +import { getPrismaClient, closePrismaClient } from '../src/db.js'; +import { loadConfig } from '../src/config.js'; +import { ensureStorageDirs } from '../src/storage.js'; + +let app; +let prisma; +let config; +let frontendDist; + +beforeAll(async () => { + frontendDist = await fs.mkdtemp(path.join(os.tmpdir(), 'converter-dist-')); + await fs.mkdir(path.join(frontendDist, 'fr'), { recursive: true }); + await fs.mkdir(path.join(frontendDist, 'assets'), { recursive: true }); + await fs.writeFile(path.join(frontendDist, 'fr', 'index.html'), 'fr home'); + await fs.writeFile(path.join(frontendDist, 'assets', 'app.abc123.js'), 'console.log(1)'); + + config = { + ...loadConfig(), + storageDir: await fs.mkdtemp(path.join(os.tmpdir(), 'converter-api-')), + frontendDist, + }; + await ensureStorageDirs(config); + prisma = getPrismaClient(config); + app = createApp(config, prisma); +}); + +afterAll(async () => { + await closePrismaClient(); + await fs.rm(config.storageDir, { recursive: true, force: true }); + await fs.rm(frontendDist, { recursive: true, force: true }); +}); + +describe('static asset serving', () => { + it('sends HTML pages with Cache-Control: no-cache', async () => { + const response = await request(app).get('/fr/'); + expect(response.status).toBe(200); + expect(response.headers['cache-control']).toBe('no-cache'); + }); + + it('sends hashed assets with a long immutable Cache-Control', async () => { + const response = await request(app).get('/assets/app.abc123.js'); + expect(response.status).toBe(200); + expect(response.headers['cache-control']).toBe('public, max-age=31536000, immutable'); + }); + + it('compresses responses when the client accepts it', async () => { + const response = await request(app).get('/fr/').set('Accept-Encoding', 'gzip'); + expect(response.headers['content-encoding']).toBe('gzip'); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run test/staticServing.test.js` +Expected: FAIL — no `Cache-Control` header set, no compression, and `frontendDist` fixture isn't wired into `createApp` yet (current code hardcodes the path at `src/app.js:251`). + +- [ ] **Step 4: Wire compression and cache headers into `src/app.js`** + +At the top of `src/app.js`, add the import (alphabetical with the existing block): +```js +import compression from 'compression'; +``` + +Inside `createApp`, right after `app.set('trust proxy', '1');` (currently line 67), add: +```js +app.use(compression()); +``` + +Replace `src/app.js:251-255`: +```js + const frontendDist = path.join(import.meta.dirname, '..', 'frontend', 'dist'); + app.use(express.static(frontendDist)); + app.get(/^\/(?!api\/).*/, (req, res) => { + res.sendFile(path.join(frontendDist, 'index.html')); + }); +``` +with: +```js + const frontendDist = config.frontendDist; + app.use( + express.static(frontendDist, { + setHeaders: (res, filePath) => { + if (filePath.endsWith('.html')) { + res.setHeader('Cache-Control', 'no-cache'); + } else if (filePath.includes(`${path.sep}assets${path.sep}`)) { + res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); + } + }, + }) + ); +``` +(The 404 catch-all that currently follows this block is replaced in Task 3 — leave the existing `app.get(/^\/(?!api\/).*/, ...)` block in place for now, Task 3 rewrites it.) + +- [ ] **Step 5: Run test to verify it passes** + +Run: `DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run test/staticServing.test.js` +Expected: PASS + +- [ ] **Step 6: Run the full existing suite to confirm no regression** + +Run: `DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run` +Expected: PASS (aside from the two pre-existing, unrelated failures documented in `CLAUDE.md`: `test/cleanup.test.js` and `test/jobs/jobRepository.test.js`) + +- [ ] **Step 7: Commit** + +```bash +git add src/config.js src/app.js package.json package-lock.json test/staticServing.test.js +git commit -m "feat(seo): add compression, cache-control headers, configurable frontend dist path" +``` + +--- + +### Task 2: Server-side language redirect for `/` + +**Files:** +- Modify: `src/app.js` (add redirect route) +- Test: `test/staticServing.test.js` (extend) + +**Interfaces:** +- Consumes: nothing new. +- Produces: `GET /` now returns a 301, never falls through to `express.static`. + +- [ ] **Step 1: Write the failing test** + +Add to `test/staticServing.test.js`, inside a new `describe` block: + +```js +describe('root redirect', () => { + it('redirects to /en/ when Accept-Language prefers English', async () => { + const response = await request(app).get('/').set('Accept-Language', 'en-US,en;q=0.9'); + expect(response.status).toBe(301); + expect(response.headers.location).toBe('/en/'); + }); + + it('redirects to /fr/ when Accept-Language prefers French', async () => { + const response = await request(app).get('/').set('Accept-Language', 'fr-FR,fr;q=0.9,en;q=0.5'); + expect(response.status).toBe(301); + expect(response.headers.location).toBe('/fr/'); + }); + + it('defaults to /fr/ when Accept-Language is absent', async () => { + const response = await request(app).get('/'); + expect(response.status).toBe(301); + expect(response.headers.location).toBe('/fr/'); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run test/staticServing.test.js` +Expected: FAIL — `/` currently falls through to the static catch-all and returns 200, not 301. + +- [ ] **Step 3: Add the redirect route** + +In `src/app.js`, add this route before `app.use(express.static(...))` (i.e., right after the `app.use(compression())` line added in Task 1, and before the API routes is fine too — place it right after `app.use(compression())`): + +```js + app.get('/', (req, res) => { + const acceptLanguage = req.headers['accept-language'] ?? ''; + const primaryTag = acceptLanguage.split(',')[0]?.split(';')[0]?.split('-')[0]?.trim().toLowerCase(); + const lang = primaryTag === 'en' ? 'en' : 'fr'; + res.redirect(301, `/${lang}/`); + }); +``` + +This mirrors the exact same "en → en, everything else → fr" rule the current client-side `RootRedirect` uses (`frontend/src/App.jsx:11`: `navigator.language?.toLowerCase().startsWith('en') ? 'en' : 'fr'`), so behavior is unchanged for users — only *when* the redirect happens changes (now before any HTML ships). + +- [ ] **Step 4: Run test to verify it passes** + +Run: `DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run test/staticServing.test.js` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/app.js test/staticServing.test.js +git commit -m "feat(seo): redirect / to /fr/ or /en/ server-side based on Accept-Language" +``` + +--- + +### Task 3: Real HTTP 404 for unmatched routes + +**Files:** +- Modify: `src/app.js:251-255` area (the catch-all after `express.static`) +- Test: `test/staticServing.test.js` (extend) + +**Interfaces:** +- Consumes: nothing new (relies on `express.static`'s own file-existence check, not a manifest). +- Produces: any request under a non-`/api` path with no matching file in `frontendDist` gets `404` with the prerendered not-found page's HTML. + +- [ ] **Step 1: Write the failing test** + +Add to the fixture setup in `test/staticServing.test.js`'s `beforeAll` (after the `fr` dir/file writes): + +```js + await fs.mkdir(path.join(frontendDist, '404'), { recursive: true }); + await fs.writeFile(path.join(frontendDist, '404', 'index.html'), 'not found'); +``` + +Add a new `describe` block: + +```js +describe('404 handling', () => { + it('returns a real 404 status for an unknown path', async () => { + const response = await request(app).get('/this-page-does-not-exist/'); + expect(response.status).toBe(404); + expect(response.text).toContain('not found'); + }); + + it('still serves a known route with 200', async () => { + const response = await request(app).get('/fr/'); + expect(response.status).toBe(200); + expect(response.text).toContain('fr home'); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run test/staticServing.test.js` +Expected: FAIL — the current catch-all (`src/app.js:253-255`) always `sendFile`s `index.html` (which doesn't exist in this fixture at the dist root at all, so it would currently 404 with an ENOENT-style Express error, not the intended not-found page — either way, today's code doesn't serve `404/index.html` with status 404). + +- [ ] **Step 3: Replace the catch-all** + +Replace the remaining catch-all in `src/app.js` (right after the `express.static` block from Task 1): +```js + app.get(/^\/(?!api\/).*/, (req, res) => { + res.sendFile(path.join(frontendDist, 'index.html')); + }); +``` +with: +```js + app.get(/^\/(?!api\/).*/, (req, res) => { + res.status(404).sendFile(path.join(frontendDist, '404', 'index.html')); + }); +``` + +This handler only runs when `express.static` (mounted just before it) found no matching file for the request — that's `express.static`'s standard behavior (it calls `next()` on no match instead of responding), so a request for a real prerendered route never reaches this line at all; it serves straight from the static middleware with a 200. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run test/staticServing.test.js` +Expected: PASS + +- [ ] **Step 5: Run the full suite** + +Run: `DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run` +Expected: PASS (aside from the two documented pre-existing failures) + +- [ ] **Step 6: Commit** + +```bash +git add src/app.js test/staticServing.test.js +git commit -m "feat(seo): return a real 404 status for unmatched routes" +``` + +--- + +### Task 4: Downgrade react-router-dom to v6 + +**Files:** +- Modify: `package.json` (root), `frontend/package.json` + +**Interfaces:** +- Produces: `react-router-dom@6.30.4` available to both the root and `frontend` install trees — required because `vite-react-ssg@0.9.2` declares a peer dependency on `react-router-dom@^6.14.1` and hard-conflicts (`ERESOLVE`, confirmed via `npm install vite-react-ssg --dry-run` during planning) with the currently installed `^7.18.2`. `react-router-dom@6.30.4`'s own peer dependencies (`react`/`react-dom` `>=16.8`) are satisfied by React 19, and a combined dry-run of `react-router-dom@6.30.4` + `vite-react-ssg` resolved with zero conflicts (also confirmed during planning). +- This project's actual router usage (`BrowserRouter`, `Routes`, `Route`, `Navigate`, `useParams`, `Outlet`, `Link` — see `frontend/src/App.jsx`, `frontend/src/components/Layout.jsx`, `frontend/src/pages/NotFound.jsx`) is basic declarative routing with no v7-only APIs, so no application code changes are required for this downgrade — only the dependency version. + +- [ ] **Step 1: Update both package.json files** + +In `package.json` (root), change: +```json +"react-router-dom": "^7.18.2", +``` +to: +```json +"react-router-dom": "^6.30.4", +``` + +In `frontend/package.json`, change: +```json +"react-router-dom": "^7.18.2" +``` +to: +```json +"react-router-dom": "^6.30.4" +``` + +- [ ] **Step 2: Install in both locations** + +Run: `npm install` +Run: `npm install --prefix frontend` + +- [ ] **Step 3: Confirm the frontend still builds** + +Run: `npm run build --prefix frontend` +Expected: build succeeds with no errors referencing `react-router-dom`. + +- [ ] **Step 4: Manual smoke test** + +Run: `npm run dev --prefix frontend` (in the background) and, in a browser, visit the dev URL, confirm: the app loads, redirects to `/fr/` or `/en/`, the file upload/convert flow still works for at least one file, and the language switcher (if present in `Header.jsx`) still navigates correctly. Stop the dev server afterward. + +- [ ] **Step 5: Commit** + +```bash +git add package.json package-lock.json frontend/package.json frontend/package-lock.json +git commit -m "chore(frontend): downgrade react-router-dom to v6 for vite-react-ssg compatibility" +``` + +--- + +### Task 5: Migrate to build-time prerendering with vite-react-ssg + +This is the core architectural task. It replaces the CSR entry point with a prerendering-capable one, rewrites `SeoHead` to render synchronously (fixing the `useEffect`-never-runs-during-SSR problem identified in the spec), and adds the `/404` route needed by Task 3's fallback. + +**Files:** +- Create: `frontend/src/data/routePaths.js` +- Create: `frontend/src/data/conversionPages.js` (empty array for now — populated in Tasks 7-10) +- Create: `frontend/src/routes.jsx` +- Create: `frontend/src/RootRedirect.jsx` +- Create: `frontend/src/components/LangLayout.jsx` +- Modify: `frontend/src/main.jsx` +- Modify: `frontend/src/App.jsx` — deleted, replaced by `routes.jsx` + `RootRedirect.jsx` + `LangLayout.jsx` +- Modify: `frontend/src/components/SeoHead.jsx` +- Modify: `frontend/src/pages/HomePage.jsx` (read `lang` from a prop instead of `useParams`) +- Modify: `frontend/src/pages/NotFound.jsx` (add noindex head) +- Modify: `frontend/vite.config.js` +- Modify: `frontend/package.json`, `package.json` (root) — add `vite-react-ssg`, change build/dev scripts + +**Interfaces:** +- Produces: `SUPPORTED_LANGS` (array `['fr', 'en']`) and `listAllRoutePaths()` (returns `string[]` of every content path, e.g. `['/fr', '/en']` today, growing as Tasks 7-10 add entries) from `frontend/src/data/routePaths.js` — consumed by `routes.jsx` and `vite.config.js`. +- Produces: `SeoHead({ lang, path, title, description, jsonLd })` (all strings except `jsonLd`, an object or `null`) from `frontend/src/components/SeoHead.jsx` — consumed by `HomePage.jsx` (Task 5) and `ConversionPage.jsx` (Task 7). + +- [ ] **Step 1: Add `vite-react-ssg` to both package.json files** + +In `frontend/package.json`, add to `"dependencies"`: +```json +"vite-react-ssg": "^0.9.2", +``` +Change the `"scripts"` block from: +```json +"dev": "vite", +"build": "vite build", +``` +to: +```json +"dev": "vite-react-ssg dev", +"build": "vite-react-ssg build", +``` + +In `package.json` (root), add to `"dependencies"` (this package is `import`ed at runtime by `frontend/src/main.jsx` and `SeoHead.jsx`, not just invoked as a CLI tool, so per the Global Constraints it belongs in dependencies, matching `react-router-dom`/`react-i18next`'s placement, not alongside `vite`/`@vitejs/plugin-react` in devDependencies): +```json +"vite-react-ssg": "^0.9.2", +``` + +Run: `npm install --prefix frontend` +Run: `npm install` + +- [ ] **Step 2: Create the route-paths data module** + +Create `frontend/src/data/conversionPages.js`: +```js +export const conversionPages = []; +``` + +Create `frontend/src/data/routePaths.js`: +```js +import { conversionPages } from './conversionPages.js'; + +export const SUPPORTED_LANGS = ['fr', 'en']; + +export function listAllRoutePaths() { + const paths = []; + for (const lang of SUPPORTED_LANGS) { + paths.push(`/${lang}`); + for (const page of conversionPages) { + paths.push(`/${lang}/${page.slugs[lang]}`); + } + } + return paths; +} +``` + +- [ ] **Step 3: Extract `RootRedirect` and `LangLayout`** + +Create `frontend/src/RootRedirect.jsx`: +```jsx +import { Navigate } from 'react-router-dom'; + +export function RootRedirect() { + const preferred = navigator.language?.toLowerCase().startsWith('en') ? 'en' : 'fr'; + return ; +} +``` +(Unreachable in production, where Express's Task 2 redirect always answers `/` first — kept as a client-side fallback for `vite-react-ssg dev`, which doesn't run through `src/app.js`.) + +Create `frontend/src/components/LangLayout.jsx`: +```jsx +import { useEffect } from 'react'; +import { Outlet } from 'react-router-dom'; +import i18n from '../i18n.js'; +import { Header } from './Header.jsx'; +import { Footer } from './Footer.jsx'; +import { useTheme } from '../hooks/useTheme.js'; +import '../styles/layout.css'; + +export function LangLayout({ lang }) { + const { theme, toggleTheme } = useTheme(); + + useEffect(() => { + if (i18n.language !== lang) { + i18n.changeLanguage(lang); + } + }, [lang]); + + return ( + <> +
+
+ +
+