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.
94 KiB
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 bothfrontend/package.jsonand rootpackage.jsondependencies (o2switch's nodevenv only installs root'snode_modules; verified inCLAUDE.md). Packages only ever invoked as a CLI/build tool (likevite,@vitejs/plugin-reacttoday) 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, theViteReactSSG/Head/ssgOptionsAPI) was confirmed against the published README/npm registry during planning, not assumed. - Local test runs must use
.env.localvalues as inline env vars, never.env(production creds) — perCLAUDE.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) — addcompression - Test:
test/staticServing.test.js(new)
Interfaces:
-
Produces:
config.frontendDist(string, absolute path) — consumed by Task 3. -
Step 1: Add
compressionto rootpackage.jsondependencies
In package.json, add to "dependencies" (alphabetical, matching existing style):
"compression": "^1.8.1",
Run: npm install
- Step 2: Add
frontendDisttosrc/config.js
src/config.js currently has no path import. Add it and a new config field:
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: <tmpdir> }).
- Step 3: Write the failing test for compression + cache headers
Create test/staticServing.test.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'), '<html><body>fr home</body></html>');
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):
import compression from 'compression';
Inside createApp, right after app.set('trust proxy', '1'); (currently line 67), add:
app.use(compression());
Replace src/app.js:251-255:
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:
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
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 toexpress.static. -
Step 1: Write the failing test
Add to test/staticServing.test.js, inside a new describe block:
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())):
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
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-255area (the catch-all afterexpress.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-
/apipath with no matching file infrontendDistgets404with 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):
await fs.mkdir(path.join(frontendDist, '404'), { recursive: true });
await fs.writeFile(path.join(frontendDist, '404', 'index.html'), '<html><body>not found</body></html>');
Add a new describe block:
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 sendFiles 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):
app.get(/^\/(?!api\/).*/, (req, res) => {
res.sendFile(path.join(frontendDist, 'index.html'));
});
with:
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
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.4available to both the root andfrontendinstall trees — required becausevite-react-ssg@0.9.2declares a peer dependency onreact-router-dom@^6.14.1and hard-conflicts (ERESOLVE, confirmed vianpm install vite-react-ssg --dry-runduring 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 ofreact-router-dom@6.30.4+vite-react-ssgresolved with zero conflicts (also confirmed during planning). -
This project's actual router usage (
BrowserRouter,Routes,Route,Navigate,useParams,Outlet,Link— seefrontend/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:
"react-router-dom": "^7.18.2",
to:
"react-router-dom": "^6.30.4",
In frontend/package.json, change:
"react-router-dom": "^7.18.2"
to:
"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
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 byroutes.jsx+RootRedirect.jsx+LangLayout.jsx - Modify:
frontend/src/components/SeoHead.jsx - Modify:
frontend/src/pages/HomePage.jsx(readlangfrom a prop instead ofuseParams) - Modify:
frontend/src/pages/NotFound.jsx(add noindex head) - Modify:
frontend/vite.config.js - Modify:
frontend/package.json,package.json(root) — addvite-react-ssg, change build/dev scripts
Interfaces:
-
Produces:
SUPPORTED_LANGS(array['fr', 'en']) andlistAllRoutePaths()(returnsstring[]of every content path, e.g.['/fr', '/en']today, growing as Tasks 7-10 add entries) fromfrontend/src/data/routePaths.js— consumed byroutes.jsxandvite.config.js. -
Produces:
SeoHead({ lang, path, title, description, jsonLd })(all strings exceptjsonLd, an object ornull) fromfrontend/src/components/SeoHead.jsx— consumed byHomePage.jsx(Task 5) andConversionPage.jsx(Task 7). -
Step 1: Add
vite-react-ssgto both package.json files
In frontend/package.json, add to "dependencies":
"vite-react-ssg": "^0.9.2",
Change the "scripts" block from:
"dev": "vite",
"build": "vite build",
to:
"dev": "vite-react-ssg dev",
"build": "vite-react-ssg build",
In package.json (root), add to "dependencies" (this package is imported 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):
"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:
export const conversionPages = [];
Create frontend/src/data/routePaths.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
RootRedirectandLangLayout
Create frontend/src/RootRedirect.jsx:
import { Navigate } from 'react-router-dom';
export function RootRedirect() {
const preferred = navigator.language?.toLowerCase().startsWith('en') ? 'en' : 'fr';
return <Navigate to={`/${preferred}/`} replace />;
}
(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:
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 (
<>
<Header theme={theme} onToggleTheme={toggleTheme} />
<main>
<Outlet />
</main>
<Footer />
</>
);
}
(Combines the old LangShell's language-sync effect with Layout's chrome — the "is this lang valid" branch from the old LangShell is no longer needed: only fr/en subtrees are ever generated in routes.jsx, below, so an invalid language can only ever be reached via the * wildcard route, which renders NotFound directly.)
- Step 4: Create
routes.jsx
Create frontend/src/routes.jsx:
import { RootRedirect } from './RootRedirect.jsx';
import { LangLayout } from './components/LangLayout.jsx';
import { HomePage } from './pages/HomePage.jsx';
import { NotFound } from './pages/NotFound.jsx';
import { conversionPages } from './data/conversionPages.js';
import { SUPPORTED_LANGS } from './data/routePaths.js';
function buildLangRoute(lang) {
return {
path: `/${lang}`,
Component: () => <LangLayout lang={lang} />,
children: [
{ index: true, Component: () => <HomePage lang={lang} /> },
...conversionPages.map((page) => ({
path: page.slugs[lang],
Component: () => <ConversionPage lang={lang} page={page} />,
})),
],
};
}
export const routes = [
{ path: '/', Component: RootRedirect },
...SUPPORTED_LANGS.map(buildLangRoute),
{ path: '/404', Component: NotFound },
{ path: '*', Component: NotFound },
];
Note: this imports ConversionPage from ./pages/ConversionPage.jsx, which doesn't exist until Task 7. Add a temporary import line import { ConversionPage } from './pages/ConversionPage.jsx'; now — it will resolve once Task 7 creates the file; since conversionPages is [] until Task 7 populates it, the .map() never actually calls ConversionPage before then, so this is safe to leave unresolved-but-unused in between Task 5 and Task 7 only if Task 7 is done before anything tries to build. To keep every task independently buildable, create a minimal placeholder now instead:
Create frontend/src/pages/ConversionPage.jsx (minimal stub, replaced fully in Task 7):
export function ConversionPage({ lang, page }) {
return null;
}
- Step 5: Rewrite
SeoHead.jsx
Replace frontend/src/components/SeoHead.jsx entirely:
import { Head } from 'vite-react-ssg';
const SITE_URL = 'https://convert.ombrora.com';
const DEFAULT_META = {
fr: {
title: 'Ombrora Convert — Conversion de fichiers en ligne',
description:
'Convertissez vos fichiers en ligne gratuitement : images, documents, polices et ebooks. Aucun compte requis, fichiers supprimés automatiquement.',
},
en: {
title: 'Ombrora Convert — Online File Conversion',
description:
'Convert your files online for free: images, documents, fonts, and ebooks. No account required, files deleted automatically.',
},
};
export function SeoHead({ lang, path = '', title, description, jsonLd, noindex = false }) {
const resolvedTitle = title ?? DEFAULT_META[lang].title;
const resolvedDescription = description ?? DEFAULT_META[lang].description;
const canonical = `${SITE_URL}/${lang}${path}`;
return (
<Head>
<title>{resolvedTitle}</title>
<meta name="description" content={resolvedDescription} />
{noindex && <meta name="robots" content="noindex" />}
<link rel="canonical" href={canonical} />
<meta property="og:site_name" content="Ombrora Convert" />
<meta property="og:title" content={resolvedTitle} />
<meta property="og:description" content={resolvedDescription} />
<meta property="og:type" content="website" />
<meta property="og:url" content={canonical} />
<meta property="og:locale" content={lang === 'fr' ? 'fr_FR' : 'en_US'} />
<meta name="twitter:card" content="summary" />
<meta name="twitter:title" content={resolvedTitle} />
<meta name="twitter:description" content={resolvedDescription} />
<link rel="alternate" hrefLang="fr" href={`${SITE_URL}/fr${path}`} />
<link rel="alternate" hrefLang="en" href={`${SITE_URL}/en${path}`} />
<link rel="alternate" hrefLang="x-default" href={`${SITE_URL}/fr${path}`} />
{jsonLd && <script type="application/ld+json">{JSON.stringify(jsonLd)}</script>}
</Head>
);
}
- Step 6: Update
HomePage.jsxto takelangas a prop
In frontend/src/pages/HomePage.jsx, remove the useParams import/usage and accept lang as a prop:
Change:
import { useParams } from 'react-router-dom';
Remove that line (no longer needed).
Change:
export function HomePage() {
const { lang } = useParams();
to:
export function HomePage({ lang }) {
The rest of HomePage.jsx (state, handlers, JSX) is unchanged for this task — the handler extraction into a shared hook happens in Task 7, when ConversionPage needs the same logic.
- Step 7: Update
NotFound.jsxwith a noindex head
Replace frontend/src/pages/NotFound.jsx:
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { SeoHead } from '../components/SeoHead.jsx';
export function NotFound() {
const { t } = useTranslation();
return (
<div className="not-found">
<SeoHead lang="fr" path="/404" noindex title={t('notFound.title')} description={t('notFound.title')} />
<h1>{t('notFound.title')}</h1>
<Link to="/fr/">{t('notFound.backHome')}</Link>
</div>
);
}
- Step 8: Rewrite
main.jsx
Replace frontend/src/main.jsx:
import { ViteReactSSG } from 'vite-react-ssg';
import 'flag-icons/css/flag-icons.min.css';
import './i18n.js';
import './index.css';
import { routes } from './routes.jsx';
export const createRoot = ViteReactSSG({ routes });
- Step 9: Delete
App.jsx
frontend/src/App.jsx is fully superseded by routes.jsx + RootRedirect.jsx + LangLayout.jsx. Delete it:
rm frontend/src/App.jsx
- Step 10: Update
vite.config.js
Replace frontend/vite.config.js:
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import fs from 'node:fs';
import path from 'node:path';
import { listAllRoutePaths } from './src/data/routePaths.js';
const SITE_URL = 'https://convert.ombrora.com';
function writeSitemap(outDir) {
const paths = listAllRoutePaths();
const lastmod = new Date().toISOString().slice(0, 10);
const urls = paths
.map((p) => ` <url>\n <loc>${SITE_URL}${p}/</loc>\n <lastmod>${lastmod}</lastmod>\n </url>`)
.join('\n');
const sitemap = `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls}\n</urlset>\n`;
fs.writeFileSync(path.join(outDir, 'sitemap.xml'), sitemap);
}
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': 'http://localhost:3000',
},
},
ssgOptions: {
dirStyle: 'nested',
script: 'async',
onFinished: writeSitemap,
},
});
- Step 11: Update
index.htmlstatic fallback tags
frontend/index.html's hardcoded <title>/description stay as-is — they're now purely the pre-hydration fallback for the (unreachable in prod) bare / path and are never what a real crawler sees for /fr/ or /en/, since those get their own fully-baked static HTML from the SSG build. No change needed here.
- Step 12: Build and verify the prerendered output
Run: npm run build --prefix frontend
Expected: build succeeds, producing frontend/dist/fr/index.html, frontend/dist/en/index.html, frontend/dist/404/index.html, and frontend/dist/sitemap.xml.
Check the output directly:
grep -o '<title>[^<]*</title>' frontend/dist/fr/index.html
grep -o '<title>[^<]*</title>' frontend/dist/en/index.html
grep 'rel="canonical"' frontend/dist/fr/index.html
grep 'hreflang="en"' frontend/dist/fr/index.html
cat frontend/dist/sitemap.xml
Expected: <title>Ombrora Convert — Conversion de fichiers en ligne</title> for fr, the English title for en, a canonical link present, an en hreflang alternate present in the fr page, and a sitemap listing /fr/ and /en/. This is the concrete proof that SeoHead's tags now render during the build (not only after client hydration) — the exact problem identified in the spec.
- Step 13: Manual smoke test
Run: npm run dev --prefix frontend and confirm in a browser: /fr/ and /en/ load, upload-and-convert still works for one file, language switcher still works. Stop the dev server afterward.
- Step 14: Run the full backend test 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) — confirms Tasks 1-3's Express changes still work against a real build.
- Step 15: Commit
git add frontend/src frontend/vite.config.js frontend/package.json frontend/package-lock.json package.json package-lock.json
git commit -m "feat(seo): migrate to build-time prerendering with vite-react-ssg"
Task 6: (folded into Task 5)
Sitemap generation was folded into Task 5's vite.config.js change (the onFinished hook) since it has no independent test surface beyond what Task 5 already verifies — splitting it out would just be re-reviewing the same writeSitemap function a second time.
Task 7: Conversion-page scaffolding — shared hook, ConversionPage, and 2 seed entries
Files:
- Create:
frontend/src/hooks/useConverterWorkflow.js - Modify:
frontend/src/pages/HomePage.jsx(use the extracted hook) - Modify:
frontend/src/pages/ConversionPage.jsx(replace the stub from Task 5) - Modify:
frontend/src/data/conversionPages.js(add 2 real entries) - Create:
frontend/src/data/conversionStepTemplates.js
Interfaces:
-
Produces:
useConverterWorkflow({ preferredTargetFormat } = {})returning{ pendingFiles, submittedJobs, handleFilesSelected, updateTargetFormat, updateQuality, updateIconSize, removePendingFile, removeSubmittedJob, handleConvert }— consumed by bothHomePage.jsxandConversionPage.jsx. -
Produces: each
conversionPages.jsentry shape:{ pairId, sourceFormat, targetFormat, family, slugs: { fr, en }, content: { fr: { title, description, intro, benefits }, en: { title, description, intro, benefits } } }. -
Step 1: Extract the upload/convert state into a hook
Create frontend/src/hooks/useConverterWorkflow.js, moving the logic currently in frontend/src/pages/HomePage.jsx:16-105 almost verbatim, parameterized by an optional preferred target format:
import { useState } from 'react';
import { fetchFormats, uploadFiles } from '../api.js';
import { extensionOf } from '../utils/archiveExtensions.js';
const DEFAULT_QUALITY = {
jpg: 80,
jpeg: 80,
webp: 80,
avif: 50,
tiff: 80,
png: 6,
zip: 6,
'tar.gz': 6,
'tar.bz2': 9,
'7z': 5,
'tar.7z': 5,
mp3: 192,
ogg: 192,
aac: 192,
m4a: 192,
};
const DEFAULT_ICON_SIZE = 256;
function defaultQualityFor(targetFormat) {
return DEFAULT_QUALITY[targetFormat] ?? null;
}
export function useConverterWorkflow({ preferredTargetFormat } = {}) {
const [pendingFiles, setPendingFiles] = useState([]);
const [submittedJobs, setSubmittedJobs] = useState([]);
async function handleFilesSelected(fileList) {
const files = Array.from(fileList);
const withTargets = await Promise.all(
files.map(async (file) => {
const targets = await fetchFormats(extensionOf(file.name));
const targetFormat =
preferredTargetFormat && targets.includes(preferredTargetFormat)
? preferredTargetFormat
: targets[0] ?? null;
return {
file,
targets,
targetFormat,
quality: defaultQualityFor(targetFormat),
iconSize: targetFormat === 'ico' ? DEFAULT_ICON_SIZE : null,
};
})
);
setPendingFiles((current) => [...current, ...withTargets]);
}
function updateTargetFormat(index, targetFormat) {
setPendingFiles((current) =>
current.map((item, i) =>
i === index
? {
...item,
targetFormat,
quality: defaultQualityFor(targetFormat),
iconSize: targetFormat === 'ico' ? DEFAULT_ICON_SIZE : null,
}
: item
)
);
}
function updateQuality(index, quality) {
setPendingFiles((current) => current.map((item, i) => (i === index ? { ...item, quality } : item)));
}
function updateIconSize(index, iconSize) {
setPendingFiles((current) => current.map((item, i) => (i === index ? { ...item, iconSize } : item)));
}
function removePendingFile(index) {
setPendingFiles((current) => current.filter((_, i) => i !== index));
}
function removeSubmittedJob(index) {
setSubmittedJobs((current) => current.filter((_, i) => i !== index));
}
async function handleConvert() {
const validItems = pendingFiles.filter((item) => item.targetFormat);
const jobs = await uploadFiles(validItems);
const jobsWithSize = jobs.map((job, i) => ({
...job,
size: validItems[i].file.size,
targetFormat: validItems[i].targetFormat,
}));
setSubmittedJobs((current) => [...current, ...jobsWithSize]);
setPendingFiles([]);
}
return {
pendingFiles,
submittedJobs,
handleFilesSelected,
updateTargetFormat,
updateQuality,
updateIconSize,
removePendingFile,
removeSubmittedJob,
handleConvert,
};
}
- Step 2: Update
HomePage.jsxto use the hook
Replace frontend/src/pages/HomePage.jsx entirely:
import { useTranslation } from 'react-i18next';
import { FileCard } from '../FileCard.jsx';
import { Dropzone } from '../components/Dropzone.jsx';
import { FileConfigCard } from '../components/FileConfigCard.jsx';
import { ReassuranceStrip } from '../components/ReassuranceStrip.jsx';
import { FormatsGrid } from '../components/FormatsGrid.jsx';
import { FormatMarquee } from '../components/FormatMarquee.jsx';
import { SeoHead } from '../components/SeoHead.jsx';
import { useConverterWorkflow } from '../hooks/useConverterWorkflow.js';
import '../styles/home.css';
import '../styles/sections.css';
export function HomePage({ lang }) {
const { t } = useTranslation();
const {
pendingFiles,
submittedJobs,
handleFilesSelected,
updateTargetFormat,
updateQuality,
updateIconSize,
removePendingFile,
removeSubmittedJob,
handleConvert,
} = useConverterWorkflow();
return (
<>
<SeoHead lang={lang} path="" />
<section className="hero">
<div className="hero-aurora" aria-hidden="true" />
<div className="hero-content">
<span className="hero-kicker">{t('hero.kicker')}</span>
<h1 className="hero-title">{t('hero.title')}</h1>
<p>{t('hero.subtitle')}</p>
<Dropzone label={t('hero.dropzoneLabel')} onFilesSelected={handleFilesSelected} />
<FormatMarquee />
{pendingFiles.length > 0 && (
<div className="pending-files">
<ul>
{pendingFiles.map((item, index) => (
<FileConfigCard
key={`${item.file.name}-${index}`}
item={item}
index={index}
t={t}
onTargetFormatChange={updateTargetFormat}
onQualityChange={updateQuality}
onIconSizeChange={updateIconSize}
onRemove={removePendingFile}
/>
))}
</ul>
<button className="convert-button" onClick={handleConvert}>
{t('hero.convert')}
</button>
</div>
)}
<ul className="job-list">
{submittedJobs.map((job, index) =>
job.id ? (
<FileCard
key={job.id}
fileName={job.file}
fileSize={job.size}
targetFormat={job.targetFormat}
jobId={job.id}
onRemove={() => removeSubmittedJob(index)}
/>
) : (
<FileCard
key={`${job.file}-${index}`}
fileName={job.file}
fileSize={job.size}
targetFormat={job.targetFormat}
initialError={job.error}
onRemove={() => removeSubmittedJob(index)}
/>
)
)}
</ul>
</div>
</section>
<ReassuranceStrip />
<FormatsGrid />
</>
);
}
- Step 3: Create the step templates
Create frontend/src/data/conversionStepTemplates.js:
export const STEP_TEMPLATES = {
image: {
fr: [
'Déposez votre fichier {source} dans la zone prévue, ou cliquez pour le sélectionner.',
'Le format {target} est déjà présélectionné comme format de sortie.',
"Ajustez la qualité si l'option est disponible pour ce format.",
'Cliquez sur Convertir, puis téléchargez votre fichier {target}.',
],
en: [
'Drop your {source} file into the upload area, or click to select it.',
'{target} is already pre-selected as the output format.',
'Adjust the quality if that option is available for this format.',
'Click Convert, then download your {target} file.',
],
},
document: {
fr: [
'Déposez votre fichier {source} dans la zone prévue, ou cliquez pour le sélectionner.',
'Le format {target} est déjà présélectionné comme format de sortie.',
'Cliquez sur Convertir pour lancer le traitement.',
'Téléchargez votre fichier {target} dès qu’il est prêt.',
],
en: [
'Drop your {source} file into the upload area, or click to select it.',
'{target} is already pre-selected as the output format.',
'Click Convert to start processing.',
'Download your {target} file as soon as it’s ready.',
],
},
video: {
fr: [
'Déposez votre fichier vidéo {source}, ou cliquez pour le sélectionner.',
'Le format {target} est déjà présélectionné comme format de sortie.',
'Choisissez la résolution de sortie si besoin (480p, 720p, 1080p).',
'Cliquez sur Convertir, puis téléchargez votre vidéo {target}.',
],
en: [
'Drop your {source} video file, or click to select it.',
'{target} is already pre-selected as the output format.',
'Choose the output resolution if needed (480p, 720p, 1080p).',
'Click Convert, then download your {target} video.',
],
},
audio: {
fr: [
'Déposez votre fichier audio {source}, ou cliquez pour le sélectionner.',
'Le format {target} est déjà présélectionné comme format de sortie.',
'Choisissez le débit binaire si besoin (128 à 320 kbps).',
'Cliquez sur Convertir, puis téléchargez votre fichier {target}.',
],
en: [
'Drop your {source} audio file, or click to select it.',
'{target} is already pre-selected as the output format.',
'Choose the bitrate if needed (128 to 320 kbps).',
'Click Convert, then download your {target} file.',
],
},
};
export function resolveSteps(family, lang, sourceFormat, targetFormat) {
return STEP_TEMPLATES[family][lang].map((step) =>
step.replace('{source}', sourceFormat.toUpperCase()).replace('{target}', targetFormat.toUpperCase())
);
}
These steps are shared per family rather than per pair because the mechanical upload/select/convert/download sequence is genuinely identical for any pair within a family — the page-specific, quality-gate-relevant content is the intro/benefits text in conversionPages.js, which is unique per pair.
- Step 4: Add the first 2 real entries to
conversionPages.js
Replace frontend/src/data/conversionPages.js:
export const conversionPages = [
{
pairId: 'jpg-to-png',
sourceFormat: 'jpg',
targetFormat: 'png',
family: 'image',
slugs: { fr: 'convertir-jpg-en-png', en: 'convert-jpg-to-png' },
content: {
fr: {
title: 'Convertir JPG en PNG en ligne',
description:
'Convertissez vos JPG en PNG gratuitement, sans perte de qualité et avec support de la transparence.',
intro:
"Le format PNG conserve une compression sans perte et prend en charge la transparence, contrairement au JPG qui compresse avec perte. Idéal pour les logos, captures d'écran et visuels nécessitant un fond transparent.",
benefits: [
'Compression sans perte, aucune dégradation visuelle',
'Support natif de la transparence (canal alpha)',
'Traitement local, fichiers supprimés automatiquement après conversion',
],
},
en: {
title: 'Convert JPG to PNG Online',
description: 'Convert JPG to PNG for free, with lossless quality and transparency support.',
intro:
"PNG uses lossless compression and supports transparency, unlike JPG's lossy compression. Ideal for logos, screenshots, and visuals that need a transparent background.",
benefits: [
'Lossless compression, no visual degradation',
'Native transparency support (alpha channel)',
'Files processed and deleted automatically, no account needed',
],
},
},
},
{
pairId: 'pdf-to-docx',
sourceFormat: 'pdf',
targetFormat: 'docx',
family: 'document',
slugs: { fr: 'convertir-pdf-en-word', en: 'convert-pdf-to-word' },
content: {
fr: {
title: 'Convertir PDF en Word (DOCX) en ligne',
description: 'Transformez un PDF en document Word modifiable, avec mise en page conservée.',
intro:
'Recevoir un contrat ou un rapport en PDF alors qu’il faut le modifier est une situation courante. Cette conversion reconstruit un document Word (.docx) éditable à partir du contenu du PDF.',
benefits: [
'Texte réellement modifiable, pas une image scannée',
'Mise en page approximativement conservée',
'Évite de ressaisir un document entier à la main',
],
},
en: {
title: 'Convert PDF to Word (DOCX) Online',
description: 'Turn a PDF into an editable Word document, with the layout preserved.',
intro:
'Receiving a contract or report as a PDF when you need to edit it is a common situation. This conversion rebuilds an editable Word (.docx) document from the PDF’s content.',
benefits: [
'Genuinely editable text, not a scanned image',
'Layout approximately preserved',
'Avoids retyping an entire document by hand',
],
},
},
},
];
- Step 5: Replace the
ConversionPagestub
Replace frontend/src/pages/ConversionPage.jsx:
import { useTranslation } from 'react-i18next';
import { FileCard } from '../FileCard.jsx';
import { Dropzone } from '../components/Dropzone.jsx';
import { FileConfigCard } from '../components/FileConfigCard.jsx';
import { ReassuranceStrip } from '../components/ReassuranceStrip.jsx';
import { SeoHead } from '../components/SeoHead.jsx';
import { useConverterWorkflow } from '../hooks/useConverterWorkflow.js';
import { resolveSteps } from '../data/conversionStepTemplates.js';
import '../styles/home.css';
import '../styles/sections.css';
export function ConversionPage({ lang, page }) {
const { t } = useTranslation();
const copy = page.content[lang];
const steps = resolveSteps(page.family, lang, page.sourceFormat, page.targetFormat);
const {
pendingFiles,
submittedJobs,
handleFilesSelected,
updateTargetFormat,
updateQuality,
updateIconSize,
removePendingFile,
removeSubmittedJob,
handleConvert,
} = useConverterWorkflow({ preferredTargetFormat: page.targetFormat });
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'HowTo',
name: copy.title,
step: steps.map((text, index) => ({ '@type': 'HowToStep', position: index + 1, text })),
};
return (
<>
<SeoHead
lang={lang}
path={`/${page.slugs[lang]}`}
title={copy.title}
description={copy.description}
jsonLd={jsonLd}
/>
<section className="hero">
<div className="hero-aurora" aria-hidden="true" />
<div className="hero-content">
<h1 className="hero-title">{copy.title}</h1>
<p>{copy.intro}</p>
<ul>
{copy.benefits.map((benefit) => (
<li key={benefit}>{benefit}</li>
))}
</ul>
<Dropzone label={t('hero.dropzoneLabel')} onFilesSelected={handleFilesSelected} />
{pendingFiles.length > 0 && (
<div className="pending-files">
<ul>
{pendingFiles.map((item, index) => (
<FileConfigCard
key={`${item.file.name}-${index}`}
item={item}
index={index}
t={t}
onTargetFormatChange={updateTargetFormat}
onQualityChange={updateQuality}
onIconSizeChange={updateIconSize}
onRemove={removePendingFile}
/>
))}
</ul>
<button className="convert-button" onClick={handleConvert}>
{t('hero.convert')}
</button>
</div>
)}
<ul className="job-list">
{submittedJobs.map((job, index) =>
job.id ? (
<FileCard
key={job.id}
fileName={job.file}
fileSize={job.size}
targetFormat={job.targetFormat}
jobId={job.id}
onRemove={() => removeSubmittedJob(index)}
/>
) : (
<FileCard
key={`${job.file}-${index}`}
fileName={job.file}
fileSize={job.size}
targetFormat={job.targetFormat}
initialError={job.error}
onRemove={() => removeSubmittedJob(index)}
/>
)
)}
</ul>
<ol>
{steps.map((step) => (
<li key={step}>{step}</li>
))}
</ol>
</div>
</section>
<ReassuranceStrip />
</>
);
}
Note on the HowTo JSON-LD: this is distinct from the FAQ schema restriction noted in the SEO skill's quality gates (FAQPage is deprecated for SERP features) — HowTo for the tool's own step-by-step usage is a legitimate, currently-supported schema type describing the actual on-page instructions, not a workaround for a retired rich-result type.
- Step 6: Update
routes.jsx's import
frontend/src/routes.jsx already imports ConversionPage from ./pages/ConversionPage.jsx (Task 5, Step 4) — no change needed, it now resolves to the real component.
- Step 7: Build and verify
Run: npm run build --prefix frontend
Expected: build succeeds, producing frontend/dist/fr/convertir-jpg-en-png/index.html, frontend/dist/en/convert-jpg-to-png/index.html, frontend/dist/fr/convertir-pdf-en-word/index.html, frontend/dist/en/convert-pdf-to-word/index.html.
grep -o '<title>[^<]*</title>' frontend/dist/fr/convertir-jpg-en-png/index.html
grep -o '<title>[^<]*</title>' frontend/dist/en/convert-pdf-to-word/index.html
cat frontend/dist/sitemap.xml
Expected: <title>Convertir JPG en PNG en ligne</title>, <title>Convert PDF to Word (DOCX) Online</title>, and sitemap.xml now lists 6 URLs (/fr/, /en/, plus the 2 new pages × 2 languages).
- Step 8: Manual smoke test
Run: npm run dev --prefix frontend, visit /fr/convertir-jpg-en-png and /en/convert-pdf-to-word in a browser, confirm the intro/benefits/steps render and a real file upload+convert works on each page. Also revisit /fr/ to confirm HomePage still works after the hook extraction. Stop the dev server afterward.
- Step 9: Commit
git add frontend/src
git commit -m "feat(seo): add conversion-page scaffolding with 2 seed pairs (jpg-to-png, pdf-to-docx)"
Task 8: Image family content (11 more pairs: 7 image + 2 HEIC + 2 ICO)
Files:
- Modify:
frontend/src/data/conversionPages.js(append 11 entries)
Interfaces:
-
Consumes: the entry shape defined in Task 7.
-
Step 1: Append the remaining image-family entries
Add these 11 objects to the conversionPages array in frontend/src/data/conversionPages.js (after the jpg-to-png entry — order within the array doesn't matter functionally, group by family for readability):
{
pairId: 'png-to-jpg',
sourceFormat: 'png',
targetFormat: 'jpg',
family: 'image',
slugs: { fr: 'convertir-png-en-jpg', en: 'convert-png-to-jpg' },
content: {
fr: {
title: 'Convertir PNG en JPG en ligne',
description: 'Réduisez la taille de vos images PNG en les convertissant en JPG, avec contrôle du niveau de qualité.',
intro:
'Le JPG produit des fichiers nettement plus légers que le PNG grâce à sa compression avec perte, au prix d’un fond blanc à la place de la transparence. Utile pour l’envoi par email ou l’hébergement web où le poids du fichier compte.',
benefits: [
'Fichiers jusqu’à 5 à 10 fois plus légers',
'Curseur de qualité pour équilibrer poids et netteté',
'Format universellement supporté par tous les navigateurs et appareils',
],
},
en: {
title: 'Convert PNG to JPG Online',
description: 'Shrink your PNG images by converting them to JPG, with adjustable quality.',
intro:
'JPG produces significantly smaller files than PNG thanks to lossy compression, at the cost of transparency (replaced with a white background). Useful for email attachments or web hosting where file size matters.',
benefits: [
'Files up to 5-10x smaller',
'Quality slider to balance size and sharpness',
'Universally supported format across browsers and devices',
],
},
},
},
{
pairId: 'png-to-webp',
sourceFormat: 'png',
targetFormat: 'webp',
family: 'image',
slugs: { fr: 'convertir-png-en-webp', en: 'convert-png-to-webp' },
content: {
fr: {
title: 'Convertir PNG en WebP en ligne',
description: 'Passez vos PNG au format WebP pour des pages web plus rapides, sans perdre la transparence.',
intro:
'WebP combine une compression supérieure au PNG et au JPG tout en conservant la transparence, ce qui en fait le choix recommandé par Google pour accélérer le chargement des sites.',
benefits: [
'Jusqu’à 30% plus léger qu’un PNG équivalent',
'Transparence conservée, contrairement au JPG',
'Recommandé pour améliorer les Core Web Vitals (LCP)',
],
},
en: {
title: 'Convert PNG to WebP Online',
description: 'Turn your PNGs into WebP for faster web pages, without losing transparency.',
intro:
'WebP combines better compression than both PNG and JPG while keeping transparency, making it Google’s recommended format for faster-loading sites.',
benefits: [
'Up to 30% smaller than an equivalent PNG',
'Transparency preserved, unlike JPG',
'Recommended to improve Core Web Vitals (LCP)',
],
},
},
},
{
pairId: 'webp-to-jpg',
sourceFormat: 'webp',
targetFormat: 'jpg',
family: 'image',
slugs: { fr: 'convertir-webp-en-jpg', en: 'convert-webp-to-jpg' },
content: {
fr: {
title: 'Convertir WebP en JPG en ligne',
description: 'Convertissez vos images WebP en JPG pour une compatibilité maximale avec anciens logiciels et appareils.',
intro:
'Bien que le WebP soit efficace, certains outils, imprimantes ou logiciels plus anciens ne le lisent pas encore. Le JPG reste le format le plus universellement compatible.',
benefits: [
'Compatible avec tous les logiciels, y compris les plus anciens',
'Idéal avant import dans un logiciel de retouche ou d’impression',
'Conversion rapide, sans installation',
],
},
en: {
title: 'Convert WebP to JPG Online',
description: 'Convert WebP images to JPG for maximum compatibility with older software and devices.',
intro:
'WebP is efficient, but some tools, printers, or older software still can’t read it. JPG remains the most universally compatible image format.',
benefits: [
'Compatible with all software, including older tools',
'Ideal before importing into editing or printing software',
'Fast conversion, no installation required',
],
},
},
},
{
pairId: 'jpg-to-webp',
sourceFormat: 'jpg',
targetFormat: 'webp',
family: 'image',
slugs: { fr: 'convertir-jpg-en-webp', en: 'convert-jpg-to-webp' },
content: {
fr: {
title: 'Convertir JPG en WebP en ligne',
description: 'Allégez vos photos JPG au format WebP pour accélérer votre site web sans sacrifier la qualité.',
intro:
'WebP réduit le poids des photos JPG de 25 à 35% en moyenne à qualité visuelle équivalente, un gain direct sur la vitesse de chargement de votre site.',
benefits: [
'25 à 35% de poids en moins à qualité équivalente',
'Meilleur temps de chargement pour votre site ou boutique en ligne',
'Curseur de qualité ajustable selon vos besoins',
],
},
en: {
title: 'Convert JPG to WebP Online',
description: 'Shrink your JPG photos to WebP to speed up your website without sacrificing quality.',
intro:
'WebP cuts JPG file size by 25-35% on average at equivalent visual quality, a direct win for your site’s load speed.',
benefits: [
'25-35% smaller at equivalent quality',
'Faster load times for your site or online store',
'Adjustable quality slider to fit your needs',
],
},
},
},
{
pairId: 'gif-to-png',
sourceFormat: 'gif',
targetFormat: 'png',
family: 'image',
slugs: { fr: 'convertir-gif-en-png', en: 'convert-gif-to-png' },
content: {
fr: {
title: 'Convertir GIF en PNG en ligne',
description: 'Extrayez une image PNG nette et sans compression à partir d’un GIF, avec transparence conservée.',
intro:
'Le GIF limite les images à 256 couleurs, ce qui crée des dégradés visibles. Convertir en PNG permet de récupérer une image fixe nette, avec un espace colorimétrique bien plus large.',
benefits: [
'Sort de la limite des 256 couleurs du GIF',
'Netteté préservée sur les dégradés et aplats de couleur',
'Transparence conservée si présente dans le GIF source',
],
},
en: {
title: 'Convert GIF to PNG Online',
description: 'Extract a sharp, uncompressed PNG image from a GIF, with transparency preserved.',
intro:
'GIF limits images to 256 colors, which creates visible banding on gradients. Converting to PNG recovers a sharp still image with a much wider color range.',
benefits: [
'Breaks free of GIF’s 256-color limit',
'Sharper gradients and flat color areas',
'Transparency preserved if present in the source GIF',
],
},
},
},
{
pairId: 'png-to-pdf',
sourceFormat: 'png',
targetFormat: 'pdf',
family: 'image',
slugs: { fr: 'convertir-png-en-pdf', en: 'convert-png-to-pdf' },
content: {
fr: {
title: 'Convertir PNG en PDF en ligne',
description: 'Regroupez ou transformez vos images PNG en document PDF prêt à partager ou imprimer.',
intro:
'Le PDF est le format de référence pour partager ou imprimer un document sans dépendre du logiciel du destinataire. Convertir un PNG en PDF permet de l’insérer facilement dans un dossier, un contrat ou une archive.',
benefits: [
'Format universellement lisible et imprimable',
'Idéal pour joindre une image à un dossier administratif',
'Aucune perte de qualité lors de la conversion',
],
},
en: {
title: 'Convert PNG to PDF Online',
description: 'Turn your PNG images into a ready-to-share, ready-to-print PDF document.',
intro:
'PDF is the standard format for sharing or printing a document without depending on the recipient’s software. Converting a PNG to PDF makes it easy to include in a folder, contract, or archive.',
benefits: [
'Universally readable and printable format',
'Ideal for attaching an image to an administrative file',
'No quality loss during conversion',
],
},
},
},
{
pairId: 'jpg-to-pdf',
sourceFormat: 'jpg',
targetFormat: 'pdf',
family: 'image',
slugs: { fr: 'convertir-jpg-en-pdf', en: 'convert-jpg-to-pdf' },
content: {
fr: {
title: 'Convertir JPG en PDF en ligne',
description: 'Transformez vos photos JPG en document PDF, pratique pour l’envoi de justificatifs ou de scans.',
intro:
'De nombreuses démarches administratives demandent un PDF plutôt qu’une photo. Cette conversion transforme instantanément votre JPG (photo de document, reçu, scan) en PDF prêt à être envoyé.',
benefits: [
'Format attendu par la plupart des démarches administratives en ligne',
'Pratique pour convertir une photo de reçu ou de justificatif',
'Traitement instantané, sans regroupement complexe de fichiers',
],
},
en: {
title: 'Convert JPG to PDF Online',
description: 'Turn your JPG photos into a PDF document, handy for sending receipts or scanned documents.',
intro:
'Many administrative processes require a PDF rather than a photo. This conversion instantly turns your JPG (a document photo, receipt, or scan) into a PDF ready to send.',
benefits: [
'The format most online administrative processes expect',
'Handy for converting a photo of a receipt or document',
'Instant processing, no complex file bundling',
],
},
},
},
{
pairId: 'heic-to-jpg',
sourceFormat: 'heic',
targetFormat: 'jpg',
family: 'image',
slugs: { fr: 'convertir-heic-en-jpg', en: 'convert-heic-to-jpg' },
content: {
fr: {
title: 'Convertir HEIC en JPG en ligne',
description: 'Convertissez vos photos iPhone au format HEIC en JPG, lisibles partout sans perte visible.',
intro:
'Les iPhone enregistrent les photos en HEIC par défaut, un format peu supporté en dehors de l’écosystème Apple. Le JPG reste lisible par tous les appareils, réseaux sociaux et logiciels.',
benefits: [
'Résout les photos HEIC illisibles sur Windows ou Android',
'Compatible avec tous les réseaux sociaux et messageries',
'Qualité visuelle quasiment identique à l’original',
],
},
en: {
title: 'Convert HEIC to JPG Online',
description: 'Convert your iPhone HEIC photos to JPG, viewable everywhere with no visible quality loss.',
intro:
'iPhones save photos as HEIC by default, a format poorly supported outside Apple’s ecosystem. JPG remains readable by every device, social network, and piece of software.',
benefits: [
'Fixes HEIC photos that won’t open on Windows or Android',
'Compatible with every social network and messaging app',
'Visual quality nearly identical to the original',
],
},
},
},
{
pairId: 'heic-to-png',
sourceFormat: 'heic',
targetFormat: 'png',
family: 'image',
slugs: { fr: 'convertir-heic-en-png', en: 'convert-heic-to-png' },
content: {
fr: {
title: 'Convertir HEIC en PNG en ligne',
description: 'Convertissez vos photos HEIC d’iPhone en PNG, sans compression avec perte.',
intro:
'Pour une retouche photo ou un usage graphique nécessitant une qualité maximale sans artefacts de compression, le PNG est préférable au JPG lors de la conversion d’un HEIC.',
benefits: [
'Pas de compression avec perte, contrairement au JPG',
'Adapté à la retouche photo ou au montage graphique',
'Compatible avec tous les logiciels d’édition d’image',
],
},
en: {
title: 'Convert HEIC to PNG Online',
description: 'Convert your iPhone HEIC photos to PNG, with no lossy compression.',
intro:
'For photo editing or graphic work that needs maximum quality without compression artifacts, PNG is the better choice over JPG when converting a HEIC file.',
benefits: [
'No lossy compression, unlike JPG',
'Suited for photo retouching or graphic design work',
'Compatible with every image editing tool',
],
},
},
},
{
pairId: 'png-to-ico',
sourceFormat: 'png',
targetFormat: 'ico',
family: 'image',
slugs: { fr: 'convertir-png-en-ico-favicon', en: 'convert-png-to-ico-favicon' },
content: {
fr: {
title: 'Convertir PNG en ICO en ligne (favicon)',
description: 'Créez un favicon .ico à partir d’une image PNG, pour votre site ou application.',
intro:
'Le format ICO est requis pour les favicons de site web et les icônes d’application Windows. Cette conversion génère un fichier .ico prêt à l’emploi à partir de votre logo ou icône PNG.',
benefits: [
'Génère un favicon compatible avec tous les navigateurs',
'Tailles d’icône standard disponibles (16, 32, 48, 256, 512px)',
'Aucune installation de logiciel de conception requise',
],
},
en: {
title: 'Convert PNG to ICO Online (favicon)',
description: 'Create an .ico favicon from a PNG image, for your website or application.',
intro:
'The ICO format is required for website favicons and Windows application icons. This conversion generates a ready-to-use .ico file from your PNG logo or icon.',
benefits: [
'Generates a favicon compatible with every browser',
'Standard icon sizes available (16, 32, 48, 256, 512px)',
'No design software installation required',
],
},
},
},
{
pairId: 'ico-to-png',
sourceFormat: 'ico',
targetFormat: 'png',
family: 'image',
slugs: { fr: 'convertir-ico-en-png', en: 'convert-ico-to-png' },
content: {
fr: {
title: 'Convertir ICO en PNG en ligne',
description: 'Extrayez l’image PNG d’un fichier ICO pour la modifier ou la réutiliser ailleurs.',
intro:
'Besoin de récupérer l’icône d’un ancien favicon ou d’un exécutable Windows pour la retoucher ? Cette conversion extrait l’image la plus grande contenue dans le fichier ICO au format PNG.',
benefits: [
'Extrait automatiquement l’image de plus haute résolution du fichier ICO',
'Format PNG modifiable dans n’importe quel logiciel de retouche',
'Pratique pour recycler une icône existante dans un nouveau design',
],
},
en: {
title: 'Convert ICO to PNG Online',
description: 'Extract the PNG image from an ICO file to edit or reuse it elsewhere.',
intro:
'Need to recover the icon from an old favicon or a Windows executable to edit it? This conversion extracts the highest-resolution image contained in the ICO file as a PNG.',
benefits: [
'Automatically extracts the highest-resolution image from the ICO file',
'PNG output editable in any image editing software',
'Handy for repurposing an existing icon into a new design',
],
},
},
},
- Step 2: Build and verify
Run: npm run build --prefix frontend
Expected: succeeds, frontend/dist/fr/ now contains a subdirectory for each of the 12 image-family slugs (1 from Task 7 + 11 new), same for frontend/dist/en/.
ls frontend/dist/fr | wc -l
cat frontend/dist/sitemap.xml | grep -c '<url>'
Expected: 12 image-family directories + the 1 document-family one from Task 7 = 13 directories under frontend/dist/fr/ (plus index.html for the home page itself, which lives at frontend/dist/fr/index.html, not a subdirectory). Sitemap now lists (2 shell + 13 pages) × 2 languages = 30 URLs.
- Step 3: Manual spot check
Run: npm run dev --prefix frontend, visit /fr/convertir-heic-en-jpg and /en/convert-png-to-ico-favicon, confirm each renders its own distinct intro/benefits and the tool works. Stop the dev server afterward.
- Step 4: Commit
git add frontend/src/data/conversionPages.js
git commit -m "feat(seo): add remaining image, HEIC, and ICO conversion pages"
Task 9: Document family content (7 more pairs)
Files:
-
Modify:
frontend/src/data/conversionPages.js(append 7 entries) -
Step 1: Append the remaining document-family entries
{
pairId: 'docx-to-pdf',
sourceFormat: 'docx',
targetFormat: 'pdf',
family: 'document',
slugs: { fr: 'convertir-word-en-pdf', en: 'convert-word-to-pdf' },
content: {
fr: {
title: 'Convertir Word (DOCX) en PDF en ligne',
description: 'Transformez votre document Word en PDF, prêt à envoyer sans risque de mise en page cassée.',
intro:
'Envoyer un .docx expose votre mise en page aux variations de logiciel du destinataire. Le PDF fige la mise en forme et se lit à l’identique sur tous les appareils.',
benefits: [
'Mise en page figée, identique quel que soit le lecteur',
'Format standard pour les candidatures, contrats et rapports',
'Empêche la modification accidentelle du contenu',
],
},
en: {
title: 'Convert Word (DOCX) to PDF Online',
description: 'Turn your Word document into a PDF, ready to send without risking broken formatting.',
intro:
'Sending a .docx exposes your layout to the recipient’s software quirks. PDF locks in the formatting and reads identically on every device.',
benefits: [
'Formatting locked in, identical on every viewer',
'The standard format for applications, contracts, and reports',
'Prevents accidental edits to the content',
],
},
},
},
{
pairId: 'pdf-to-txt',
sourceFormat: 'pdf',
targetFormat: 'txt',
family: 'document',
slugs: { fr: 'convertir-pdf-en-texte', en: 'convert-pdf-to-text' },
content: {
fr: {
title: 'Convertir PDF en texte (TXT) en ligne',
description: 'Extrayez le texte brut d’un PDF pour le réutiliser dans un autre document ou script.',
intro:
'Besoin de récupérer uniquement le contenu textuel d’un PDF, sans mise en forme, pour l’analyser, le copier dans un script ou l’indexer ? Cette conversion extrait le texte brut du document.',
benefits: [
'Texte brut, sans mise en forme parasite',
'Pratique pour l’analyse de données ou l’indexation',
'Fonctionne même sur des PDF multi-pages',
],
},
en: {
title: 'Convert PDF to Text (TXT) Online',
description: 'Extract the raw text from a PDF to reuse it in another document or script.',
intro:
'Need to pull just the text content out of a PDF, with no formatting, to analyze, script against, or index it? This conversion extracts the document’s raw text.',
benefits: [
'Plain text, free of formatting clutter',
'Handy for data analysis or indexing',
'Works even on multi-page PDFs',
],
},
},
},
{
pairId: 'pdf-to-html',
sourceFormat: 'pdf',
targetFormat: 'html',
family: 'document',
slugs: { fr: 'convertir-pdf-en-html', en: 'convert-pdf-to-html' },
content: {
fr: {
title: 'Convertir PDF en HTML en ligne',
description: 'Transformez un PDF en page HTML pour le publier ou l’intégrer sur un site web.',
intro:
'Publier le contenu d’un PDF directement sur une page web, plutôt que de forcer un téléchargement, améliore l’expérience utilisateur et le référencement du contenu. Cette conversion produit un fichier HTML à partir du PDF.',
benefits: [
'Contenu directement indexable par les moteurs de recherche',
'Évite de forcer un téléchargement pour consulter le contenu',
'Structure HTML réutilisable dans un site existant',
],
},
en: {
title: 'Convert PDF to HTML Online',
description: 'Turn a PDF into an HTML page to publish or embed on a website.',
intro:
'Publishing a PDF’s content directly on a web page, instead of forcing a download, improves user experience and the content’s search visibility. This conversion produces an HTML file from the PDF.',
benefits: [
'Content directly indexable by search engines',
'Avoids forcing a download just to view the content',
'HTML structure reusable within an existing site',
],
},
},
},
{
pairId: 'csv-to-xlsx',
sourceFormat: 'csv',
targetFormat: 'xlsx',
family: 'document',
slugs: { fr: 'convertir-csv-en-excel', en: 'convert-csv-to-excel' },
content: {
fr: {
title: 'Convertir CSV en Excel (XLSX) en ligne',
description: 'Transformez un fichier CSV en classeur Excel, avec colonnes et feuilles prêtes à l’emploi.',
intro:
'Un CSV n’a ni mise en forme ni formules. Le convertir en XLSX permet de l’ouvrir directement dans Excel avec des colonnes correctement dimensionnées, prêt pour vos calculs et graphiques.',
benefits: [
'Ouverture directe dans Excel, Google Sheets ou LibreOffice',
'Base prête pour ajouter formules, filtres et graphiques',
'Conserve l’intégralité des lignes et colonnes du CSV source',
],
},
en: {
title: 'Convert CSV to Excel (XLSX) Online',
description: 'Turn a CSV file into an Excel workbook, with columns and sheets ready to use.',
intro:
'A CSV has no formatting or formulas. Converting it to XLSX lets you open it directly in Excel with properly sized columns, ready for your calculations and charts.',
benefits: [
'Opens directly in Excel, Google Sheets, or LibreOffice',
'A ready base for adding formulas, filters, and charts',
'Preserves every row and column from the source CSV',
],
},
},
},
{
pairId: 'xlsx-to-csv',
sourceFormat: 'xlsx',
targetFormat: 'csv',
family: 'document',
slugs: { fr: 'convertir-excel-en-csv', en: 'convert-excel-to-csv' },
content: {
fr: {
title: 'Convertir Excel (XLSX) en CSV en ligne',
description: 'Exportez votre classeur Excel en CSV, un format universel pour l’import dans d’autres outils.',
intro:
'De nombreux outils (bases de données, scripts, imports en masse) n’acceptent que le CSV, pas le format XLSX propriétaire d’Excel. Cette conversion extrait les données brutes de la première feuille en CSV.',
benefits: [
'Format universellement accepté en import par la plupart des logiciels',
'Fichier plus léger et plus simple qu’un classeur Excel',
'Idéal avant import dans une base de données ou un script',
],
},
en: {
title: 'Convert Excel (XLSX) to CSV Online',
description: 'Export your Excel workbook to CSV, a universal format for importing into other tools.',
intro:
'Many tools (databases, scripts, bulk imports) only accept CSV, not Excel’s proprietary XLSX format. This conversion extracts the raw data from the first sheet as CSV.',
benefits: [
'A format universally accepted for import by most software',
'A lighter, simpler file than an Excel workbook',
'Ideal before importing into a database or script',
],
},
},
},
{
pairId: 'csv-to-pdf',
sourceFormat: 'csv',
targetFormat: 'pdf',
family: 'document',
slugs: { fr: 'convertir-csv-en-pdf', en: 'convert-csv-to-pdf' },
content: {
fr: {
title: 'Convertir CSV en PDF en ligne',
description: 'Transformez un fichier CSV en tableau PDF lisible et imprimable.',
intro:
'Un CSV brut n’est pas fait pour être lu tel quel ou imprimé. Cette conversion met en forme les données en un tableau PDF propre, prêt à être partagé ou archivé.',
benefits: [
'Tableau lisible et mis en forme, contrairement au CSV brut',
'Prêt à imprimer ou archiver tel quel',
'Pratique pour partager des données sans donner accès au fichier source',
],
},
en: {
title: 'Convert CSV to PDF Online',
description: 'Turn a CSV file into a readable, printable PDF table.',
intro:
'A raw CSV isn’t meant to be read as-is or printed. This conversion formats the data into a clean PDF table, ready to share or archive.',
benefits: [
'A readable, formatted table, unlike raw CSV',
'Ready to print or archive as-is',
'Handy for sharing data without giving access to the source file',
],
},
},
},
{
pairId: 'xlsx-to-pdf',
sourceFormat: 'xlsx',
targetFormat: 'pdf',
family: 'document',
slugs: { fr: 'convertir-excel-en-pdf', en: 'convert-excel-to-pdf' },
content: {
fr: {
title: 'Convertir Excel (XLSX) en PDF en ligne',
description: 'Transformez votre classeur Excel en PDF, figé et prêt à partager sans risque de modification.',
intro:
'Partager un fichier Excel expose vos formules et données brutes à la modification. Le PDF fige le contenu affiché, parfait pour un rapport ou un devis final.',
benefits: [
'Contenu figé, sans formules ni données modifiables',
'Mise en page identique quel que soit l’appareil du destinataire',
'Format standard pour les rapports et devis finaux',
],
},
en: {
title: 'Convert Excel (XLSX) to PDF Online',
description: 'Turn your Excel workbook into a PDF, locked and ready to share without risk of edits.',
intro:
'Sharing an Excel file exposes your formulas and raw data to changes. PDF locks in the displayed content, perfect for a final report or quote.',
benefits: [
'Locked content, no editable formulas or data',
'Identical layout regardless of the recipient’s device',
'The standard format for final reports and quotes',
],
},
},
},
- Step 2: Build and verify
Run: npm run build --prefix frontend
cat frontend/dist/sitemap.xml | grep -c '<url>'
Expected: (2 shell + 20 pages) × 2 = 44 URLs (13 from Task 8 + 7 new = 20 conversion pages total).
- Step 3: Manual spot check
Run: npm run dev --prefix frontend, visit /fr/convertir-excel-en-pdf and /en/convert-pdf-to-text, confirm distinct content and working upload. Stop the dev server afterward.
- Step 4: Commit
git add frontend/src/data/conversionPages.js
git commit -m "feat(seo): add remaining document conversion pages"
Task 10: Video and audio family content (10 pairs)
Files:
-
Modify:
frontend/src/data/conversionPages.js(append 10 entries) -
Step 1: Append the video and audio entries
{
pairId: 'mov-to-mp4',
sourceFormat: 'mov',
targetFormat: 'mp4',
family: 'video',
slugs: { fr: 'convertir-mov-en-mp4', en: 'convert-mov-to-mp4' },
content: {
fr: {
title: 'Convertir MOV en MP4 en ligne',
description: 'Convertissez vos vidéos MOV (iPhone, QuickTime) en MP4, lisibles sur tous les appareils.',
intro:
'Le format MOV, utilisé par défaut par les iPhone et QuickTime, est mal supporté sur Windows, Android et de nombreux lecteurs vidéo. Le MP4 est le format vidéo le plus universellement compatible.',
benefits: [
'Lecture garantie sur Windows, Android et tous les navigateurs',
'Compatible avec les principales plateformes de partage vidéo',
'Poids de fichier optimisé grâce au codec H.264',
],
},
en: {
title: 'Convert MOV to MP4 Online',
description: 'Convert your MOV videos (iPhone, QuickTime) to MP4, playable on every device.',
intro:
'MOV, the default format for iPhone and QuickTime, is poorly supported on Windows, Android, and many video players. MP4 is the most universally compatible video format.',
benefits: [
'Guaranteed playback on Windows, Android, and every browser',
'Compatible with the major video-sharing platforms',
'Optimized file size thanks to the H.264 codec',
],
},
},
},
{
pairId: 'mkv-to-mp4',
sourceFormat: 'mkv',
targetFormat: 'mp4',
family: 'video',
slugs: { fr: 'convertir-mkv-en-mp4', en: 'convert-mkv-to-mp4' },
content: {
fr: {
title: 'Convertir MKV en MP4 en ligne',
description: 'Convertissez vos fichiers MKV en MP4 pour une lecture sans souci sur mobile, TV et navigateurs.',
intro:
'Le MKV est un conteneur riche en fonctionnalités mais mal supporté nativement par les smartphones, smart TV et navigateurs web. Le MP4 se lit partout sans logiciel additionnel.',
benefits: [
'Lecture native sur smartphones, smart TV et navigateurs',
'Pas besoin d’installer un lecteur compatible MKV (type VLC)',
'Format accepté par toutes les plateformes de streaming personnel',
],
},
en: {
title: 'Convert MKV to MP4 Online',
description: 'Convert your MKV files to MP4 for hassle-free playback on mobile, TV, and browsers.',
intro:
'MKV is a feature-rich container but poorly supported natively by smartphones, smart TVs, and web browsers. MP4 plays everywhere with no extra software.',
benefits: [
'Native playback on smartphones, smart TVs, and browsers',
'No need to install an MKV-compatible player (like VLC)',
'Accepted by every personal streaming platform',
],
},
},
},
{
pairId: 'avi-to-mp4',
sourceFormat: 'avi',
targetFormat: 'mp4',
family: 'video',
slugs: { fr: 'convertir-avi-en-mp4', en: 'convert-avi-to-mp4' },
content: {
fr: {
title: 'Convertir AVI en MP4 en ligne',
description: 'Modernisez vos anciennes vidéos AVI en MP4, plus légères et compatibles partout.',
intro:
'L’AVI est un format ancien, souvent volumineux et peu efficace en compression par rapport aux standards actuels. Le convertir en MP4 réduit le poids du fichier tout en assurant une compatibilité moderne.',
benefits: [
'Fichier généralement plus léger qu’un AVI équivalent',
'Compatible avec les appareils et logiciels récents',
'Idéal pour numériser une ancienne bibliothèque de vidéos',
],
},
en: {
title: 'Convert AVI to MP4 Online',
description: 'Modernize your old AVI videos to MP4, smaller and compatible everywhere.',
intro:
'AVI is an old format, often bulky and inefficient by today’s compression standards. Converting it to MP4 reduces file size while ensuring modern compatibility.',
benefits: [
'Generally smaller file size than an equivalent AVI',
'Compatible with modern devices and software',
'Ideal for digitizing an old video library',
],
},
},
},
{
pairId: 'webm-to-mp4',
sourceFormat: 'webm',
targetFormat: 'mp4',
family: 'video',
slugs: { fr: 'convertir-webm-en-mp4', en: 'convert-webm-to-mp4' },
content: {
fr: {
title: 'Convertir WebM en MP4 en ligne',
description: 'Convertissez vos vidéos WebM en MP4 pour les monter, partager ou lire sur davantage d’appareils.',
intro:
'Le WebM, courant sur le web (YouTube, enregistrements d’écran), n’est pas toujours lisible dans les logiciels de montage ou certains lecteurs. Le MP4 offre une compatibilité plus large en dehors du navigateur.',
benefits: [
'Compatible avec la majorité des logiciels de montage vidéo',
'Lecture garantie hors navigateur, y compris sur ancien matériel',
'Pratique pour réutiliser un enregistrement d’écran ou une vidéo téléchargée',
],
},
en: {
title: 'Convert WebM to MP4 Online',
description: 'Convert your WebM videos to MP4 to edit, share, or play them on more devices.',
intro:
'WebM, common on the web (YouTube, screen recordings), isn’t always readable in editing software or certain players. MP4 offers broader compatibility outside the browser.',
benefits: [
'Compatible with most video editing software',
'Guaranteed playback outside the browser, even on older hardware',
'Handy for reusing a screen recording or a downloaded video',
],
},
},
},
{
pairId: 'mp4-to-webm',
sourceFormat: 'mp4',
targetFormat: 'webm',
family: 'video',
slugs: { fr: 'convertir-mp4-en-webm', en: 'convert-mp4-to-webm' },
content: {
fr: {
title: 'Convertir MP4 en WebM en ligne',
description: 'Convertissez vos vidéos MP4 en WebM, un format optimisé pour le web et les pages plus rapides.',
intro:
'Le WebM offre souvent une meilleure compression que le MP4 à qualité comparable et est nativement supporté par tous les navigateurs modernes, un atout pour héberger des vidéos sur un site web.',
benefits: [
'Compression souvent plus efficace que le MP4 à qualité égale',
'Lecture native dans tous les navigateurs modernes, sans plugin',
'Format libre de droits, sans licence de codec à payer',
],
},
en: {
title: 'Convert MP4 to WebM Online',
description: 'Convert your MP4 videos to WebM, a format optimized for the web and faster pages.',
intro:
'WebM often offers better compression than MP4 at comparable quality and is natively supported by every modern browser, a real asset for hosting videos on a website.',
benefits: [
'Often more efficient compression than MP4 at equal quality',
'Native playback in every modern browser, no plugin needed',
'A royalty-free format, no codec license fees',
],
},
},
},
{
pairId: 'wav-to-mp3',
sourceFormat: 'wav',
targetFormat: 'mp3',
family: 'audio',
slugs: { fr: 'convertir-wav-en-mp3', en: 'convert-wav-to-mp3' },
content: {
fr: {
title: 'Convertir WAV en MP3 en ligne',
description: 'Compressez vos fichiers audio WAV en MP3, jusqu’à 10 fois plus légers.',
intro:
'Le WAV est un format non compressé, donc volumineux — plusieurs dizaines de Mo par minute. Le MP3 compresse intelligemment le son pour des fichiers bien plus légers, à qualité d’écoute quasi identique.',
benefits: [
'Fichiers jusqu’à 10 fois plus légers qu’en WAV',
'Format le plus universellement lisible (lecteurs, téléphones, voitures)',
'Débit binaire ajustable (128 à 320 kbps) selon vos besoins',
],
},
en: {
title: 'Convert WAV to MP3 Online',
description: 'Compress your WAV audio files to MP3, up to 10 times smaller.',
intro:
'WAV is an uncompressed format, so it’s bulky — tens of megabytes per minute. MP3 intelligently compresses sound for much smaller files, at nearly identical listening quality.',
benefits: [
'Files up to 10x smaller than WAV',
'The most universally playable format (players, phones, cars)',
'Adjustable bitrate (128 to 320 kbps) to fit your needs',
],
},
},
},
{
pairId: 'mp3-to-wav',
sourceFormat: 'mp3',
targetFormat: 'wav',
family: 'audio',
slugs: { fr: 'convertir-mp3-en-wav', en: 'convert-mp3-to-wav' },
content: {
fr: {
title: 'Convertir MP3 en WAV en ligne',
description: 'Décompressez un MP3 en WAV pour le montage audio ou l’import dans un logiciel professionnel.',
intro:
'Certains logiciels de montage audio ou de production musicale préfèrent ou exigent un format non compressé comme le WAV pour éviter d’accumuler des pertes de qualité lors des traitements successifs.',
benefits: [
'Format non compressé, adapté au montage audio professionnel',
'Évite d’accumuler les pertes de qualité lors de traitements successifs',
'Compatible avec la quasi-totalité des logiciels audio (DAW)',
],
},
en: {
title: 'Convert MP3 to WAV Online',
description: 'Decompress an MP3 to WAV for audio editing or import into professional software.',
intro:
'Some audio editing or music production software prefers or requires an uncompressed format like WAV, to avoid stacking quality losses across successive processing steps.',
benefits: [
'Uncompressed format, suited to professional audio editing',
'Avoids stacking quality losses across successive processing',
'Compatible with virtually every audio software (DAW)',
],
},
},
},
{
pairId: 'm4a-to-mp3',
sourceFormat: 'm4a',
targetFormat: 'mp3',
family: 'audio',
slugs: { fr: 'convertir-m4a-en-mp3', en: 'convert-m4a-to-mp3' },
content: {
fr: {
title: 'Convertir M4A en MP3 en ligne',
description: 'Convertissez vos fichiers M4A (Apple Music, mémos vocaux iPhone) en MP3, lisibles partout.',
intro:
'Le M4A, utilisé par iTunes et les mémos vocaux iPhone, n’est pas toujours reconnu par les autoradios, lecteurs MP3 ou logiciels plus anciens. Le MP3 reste le format audio le plus universellement compatible.',
benefits: [
'Compatible avec les autoradios, lecteurs MP3 et logiciels anciens',
'Idéal pour exporter un mémo vocal ou un morceau iTunes',
'Débit binaire ajustable pour maîtriser le poids du fichier',
],
},
en: {
title: 'Convert M4A to MP3 Online',
description: 'Convert your M4A files (Apple Music, iPhone voice memos) to MP3, playable everywhere.',
intro:
'M4A, used by iTunes and iPhone voice memos, isn’t always recognized by car stereos, MP3 players, or older software. MP3 remains the most universally compatible audio format.',
benefits: [
'Compatible with car stereos, MP3 players, and older software',
'Ideal for exporting a voice memo or an iTunes track',
'Adjustable bitrate to control file size',
],
},
},
},
{
pairId: 'flac-to-mp3',
sourceFormat: 'flac',
targetFormat: 'mp3',
family: 'audio',
slugs: { fr: 'convertir-flac-en-mp3', en: 'convert-flac-to-mp3' },
content: {
fr: {
title: 'Convertir FLAC en MP3 en ligne',
description: 'Réduisez le poids de vos fichiers FLAC haute résolution en MP3, pour l’écoute mobile au quotidien.',
intro:
'Le FLAC préserve une qualité audio sans perte mais produit des fichiers volumineux, peu adaptés au stockage limité d’un téléphone. Le MP3 offre un compromis poids/qualité idéal pour l’écoute nomade.',
benefits: [
'Fichiers nettement plus légers qu’en FLAC',
'Adapté au stockage limité d’un smartphone ou baladeur',
'Compatible avec la quasi-totalité des lecteurs audio du marché',
],
},
en: {
title: 'Convert FLAC to MP3 Online',
description: 'Shrink your high-resolution FLAC files to MP3, for everyday mobile listening.',
intro:
'FLAC preserves lossless audio quality but produces large files, ill-suited to a phone’s limited storage. MP3 offers an ideal size/quality trade-off for listening on the go.',
benefits: [
'Noticeably smaller files than FLAC',
'Suited to the limited storage of a smartphone or music player',
'Compatible with virtually every audio player on the market',
],
},
},
},
{
pairId: 'ogg-to-mp3',
sourceFormat: 'ogg',
targetFormat: 'mp3',
family: 'audio',
slugs: { fr: 'convertir-ogg-en-mp3', en: 'convert-ogg-to-mp3' },
content: {
fr: {
title: 'Convertir OGG en MP3 en ligne',
description: 'Convertissez vos fichiers audio OGG en MP3 pour une compatibilité maximale sur tous vos appareils.',
intro:
'L’OGG, utilisé par certains jeux vidéo et logiciels open-source, n’est pas toujours reconnu par les téléphones, autoradios ou lecteurs multimédia grand public. Le MP3 assure une lecture sans souci partout.',
benefits: [
'Lecture garantie sur téléphones, autoradios et lecteurs grand public',
'Format le plus répandu pour le partage et le stockage audio',
'Débit binaire ajustable de 128 à 320 kbps',
],
},
en: {
title: 'Convert OGG to MP3 Online',
description: 'Convert your OGG audio files to MP3 for maximum compatibility across all your devices.',
intro:
'OGG, used by some video games and open-source software, isn’t always recognized by phones, car stereos, or mainstream media players. MP3 ensures hassle-free playback everywhere.',
benefits: [
'Guaranteed playback on phones, car stereos, and mainstream players',
'The most widespread format for audio sharing and storage',
'Adjustable bitrate from 128 to 320 kbps',
],
},
},
},
- Step 2: Build and verify
Run: npm run build --prefix frontend
cat frontend/dist/sitemap.xml | grep -c '<url>'
Expected: (2 shell + 30 pages) × 2 = 64 URLs — the full curated set from the spec.
- Step 3: Manual spot check
Run: npm run dev --prefix frontend, visit /fr/convertir-mkv-en-mp4 and /en/convert-flac-to-mp3, confirm content and upload flow. Stop the dev server afterward.
- Step 4: Commit
git add frontend/src/data/conversionPages.js
git commit -m "feat(seo): add video and audio conversion pages"
Task 11: Final integration verification
Files: none (verification only)
- Step 1: Full backend 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 documented pre-existing failures in test/cleanup.test.js and test/jobs/jobRepository.test.js).
- Step 2: Full production-style build from the root
Run: npm run build
Expected: succeeds end-to-end (this is the exact command o2switch's deploy would run) — confirms npm install --prefix frontend --include=dev && npm run build --prefix frontend still works now that frontend/package.json's build script is vite-react-ssg build.
- Step 3: Raw-HTML crawler simulation
With the app running against the real build (node src/server.js with .env.local values, and config.frontendDist pointing at the real frontend/dist), run:
curl -s http://localhost:3000/fr/ | grep -o '<title>[^<]*</title>'
curl -s http://localhost:3000/en/ | grep -o '<title>[^<]*</title>'
curl -s http://localhost:3000/fr/convertir-pdf-en-word/ | grep -o '<title>[^<]*</title>'
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:3000/
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:3000/this-does-not-exist/
Expected: correct, distinct titles for each page with zero JS execution (confirming a non-JS crawler sees real content), 301 for /, 404 for the bogus path. Per CLAUDE.md, check for an existing node src/server.js/worker.js before starting a new one, and don't restart the user's own process without asking.
- Step 4: Sitemap sanity check
curl -s http://localhost:3000/sitemap.xml | grep -c '<url>'
curl -s http://localhost:3000/robots.txt
Expected: 64 <url> entries, robots.txt unchanged and still pointing at /sitemap.xml.
- Step 5: Manual browser QA, one pair per family
In a browser, on the real dev or built app: upload and convert one real file per family — an image (/fr/convertir-jpg-en-png), a document (/en/convert-pdf-to-word), a video (/fr/convertir-mov-en-mp4), and an audio file (/en/convert-wav-to-mp3) — confirming the pre-selected target format from the URL is honored and the job completes.
- Step 6: Report results to the user
Summarize: full test suite status, build success, the 4 curl checks' output, and confirmation of the 4 manual conversions — do not claim the migration works without having actually run these checks.