feat(seo): migrate to build-time prerendering with vite-react-ssg
Fixes SeoHead (title/canonical/hreflang/JSON-LD) to render during SSR via vite-react-ssg's Head component instead of a client-only useEffect, so crawlers see real tags without executing JS. Also fixes an SSR crash in useTheme (localStorage access with no window during server render) and removes now-duplicated static meta tags from index.html.
This commit is contained in:
@@ -4,10 +4,6 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Ombrora Convert</title>
|
||||
<meta name="description" content="Convertissez vos fichiers en ligne gratuitement : images, documents, polices et ebooks." />
|
||||
<meta property="og:site_name" content="Ombrora Convert" />
|
||||
<meta name="twitter:card" content="summary" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Generated
+1171
-49
File diff suppressed because it is too large
Load Diff
@@ -4,8 +4,8 @@
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"dev": "vite-react-ssg dev",
|
||||
"build": "vite-react-ssg build",
|
||||
"lint": "oxlint",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
@@ -16,7 +16,8 @@
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-i18next": "^17.0.11",
|
||||
"react-router-dom": "^6.30.4"
|
||||
"react-router-dom": "^6.30.4",
|
||||
"vite-react-ssg": "^0.9.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.2.17",
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Routes, Route, Navigate, useParams } from 'react-router-dom';
|
||||
import i18n from './i18n.js';
|
||||
import { Layout } from './components/Layout.jsx';
|
||||
import { HomePage } from './pages/HomePage.jsx';
|
||||
import { NotFound } from './pages/NotFound.jsx';
|
||||
|
||||
const SUPPORTED_LANGS = ['fr', 'en'];
|
||||
|
||||
function RootRedirect() {
|
||||
const preferred = navigator.language?.toLowerCase().startsWith('en') ? 'en' : 'fr';
|
||||
return <Navigate to={`/${preferred}/`} replace />;
|
||||
}
|
||||
|
||||
function LangShell() {
|
||||
const { lang } = useParams();
|
||||
|
||||
useEffect(() => {
|
||||
if (SUPPORTED_LANGS.includes(lang) && i18n.language !== lang) {
|
||||
i18n.changeLanguage(lang);
|
||||
}
|
||||
}, [lang]);
|
||||
|
||||
if (!SUPPORTED_LANGS.includes(lang)) return <NotFound />;
|
||||
return <Layout />;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/" element={<RootRedirect />} />
|
||||
<Route path="/:lang" element={<LangShell />}>
|
||||
<Route index element={<HomePage />} />
|
||||
</Route>
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { Navigate } from 'react-router-dom';
|
||||
|
||||
export function RootRedirect() {
|
||||
const preferred = navigator.language?.toLowerCase().startsWith('en') ? 'en' : 'fr';
|
||||
return <Navigate to={`/${preferred}/`} replace />;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Head } from 'vite-react-ssg';
|
||||
|
||||
const SITE_URL = 'https://convert.ombrora.com';
|
||||
|
||||
const META = {
|
||||
const DEFAULT_META = {
|
||||
fr: {
|
||||
title: 'Ombrora Convert — Conversion de fichiers en ligne',
|
||||
description:
|
||||
@@ -15,74 +15,30 @@ const META = {
|
||||
},
|
||||
};
|
||||
|
||||
function setNameMeta(name, content) {
|
||||
let el = document.head.querySelector(`meta[name="${name}"]`);
|
||||
if (!el) {
|
||||
el = document.createElement('meta');
|
||||
el.setAttribute('name', name);
|
||||
document.head.appendChild(el);
|
||||
}
|
||||
el.setAttribute('content', content);
|
||||
}
|
||||
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}`;
|
||||
|
||||
function setPropertyMeta(property, content) {
|
||||
let el = document.head.querySelector(`meta[property="${property}"]`);
|
||||
if (!el) {
|
||||
el = document.createElement('meta');
|
||||
el.setAttribute('property', property);
|
||||
document.head.appendChild(el);
|
||||
}
|
||||
el.setAttribute('content', content);
|
||||
}
|
||||
|
||||
function setAlternateLink(hreflang, href) {
|
||||
const selector = `link[rel="alternate"][hreflang="${hreflang}"]`;
|
||||
let el = document.head.querySelector(selector);
|
||||
if (!el) {
|
||||
el = document.createElement('link');
|
||||
el.setAttribute('rel', 'alternate');
|
||||
el.setAttribute('hreflang', hreflang);
|
||||
document.head.appendChild(el);
|
||||
}
|
||||
el.setAttribute('href', href);
|
||||
}
|
||||
|
||||
function setJsonLd(lang) {
|
||||
const id = 'seo-jsonld';
|
||||
let script = document.getElementById(id);
|
||||
if (!script) {
|
||||
script = document.createElement('script');
|
||||
script.id = id;
|
||||
script.type = 'application/ld+json';
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
script.textContent = JSON.stringify({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'SoftwareApplication',
|
||||
name: 'Ombrora Convert',
|
||||
applicationCategory: 'UtilitiesApplication',
|
||||
operatingSystem: 'Any',
|
||||
offers: { '@type': 'Offer', price: '0', priceCurrency: 'EUR' },
|
||||
inLanguage: lang,
|
||||
url: `${SITE_URL}/${lang}/`,
|
||||
});
|
||||
}
|
||||
|
||||
export function SeoHead({ lang }) {
|
||||
useEffect(() => {
|
||||
const meta = META[lang] ?? META.fr;
|
||||
document.title = meta.title;
|
||||
document.documentElement.lang = lang;
|
||||
setNameMeta('description', meta.description);
|
||||
setPropertyMeta('og:title', meta.title);
|
||||
setPropertyMeta('og:description', meta.description);
|
||||
setPropertyMeta('og:type', 'website');
|
||||
setPropertyMeta('og:locale', lang === 'fr' ? 'fr_FR' : 'en_US');
|
||||
setAlternateLink('fr', `${SITE_URL}/fr/`);
|
||||
setAlternateLink('en', `${SITE_URL}/en/`);
|
||||
setAlternateLink('x-default', `${SITE_URL}/fr/`);
|
||||
setJsonLd(lang);
|
||||
}, [lang]);
|
||||
|
||||
return null;
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const conversionPages = [];
|
||||
@@ -0,0 +1,14 @@
|
||||
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;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { useEffect, useState } from 'react';
|
||||
const STORAGE_KEY = 'theme';
|
||||
|
||||
function getInitialTheme() {
|
||||
if (typeof window === 'undefined') return 'light';
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored === 'light' || stored === 'dark') return stored;
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
|
||||
+3
-11
@@ -1,15 +1,7 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { ViteReactSSG } from 'vite-react-ssg';
|
||||
import 'flag-icons/css/flag-icons.min.css';
|
||||
import './i18n.js';
|
||||
import './index.css';
|
||||
import App from './App.jsx';
|
||||
import { routes } from './routes.jsx';
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</StrictMode>
|
||||
);
|
||||
export const createRoot = ViteReactSSG({ routes });
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export function ConversionPage({ lang, page }) {
|
||||
return null;
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { fetchFormats, uploadFiles } from '../api.js';
|
||||
import { FileCard } from '../FileCard.jsx';
|
||||
@@ -37,8 +36,7 @@ function defaultQualityFor(targetFormat) {
|
||||
return DEFAULT_QUALITY[targetFormat] ?? null;
|
||||
}
|
||||
|
||||
export function HomePage() {
|
||||
const { lang } = useParams();
|
||||
export function HomePage({ lang }) {
|
||||
const { t } = useTranslation();
|
||||
const [pendingFiles, setPendingFiles] = useState([]);
|
||||
const [submittedJobs, setSubmittedJobs] = useState([]);
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
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>
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { RootRedirect } from './RootRedirect.jsx';
|
||||
import { LangLayout } from './components/LangLayout.jsx';
|
||||
import { HomePage } from './pages/HomePage.jsx';
|
||||
import { NotFound } from './pages/NotFound.jsx';
|
||||
import { ConversionPage } from './pages/ConversionPage.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 },
|
||||
];
|
||||
@@ -1,5 +1,20 @@
|
||||
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()],
|
||||
@@ -8,4 +23,9 @@ export default defineConfig({
|
||||
'/api': 'http://localhost:3000',
|
||||
},
|
||||
},
|
||||
ssgOptions: {
|
||||
dirStyle: 'nested',
|
||||
script: 'async',
|
||||
onFinished: writeSitemap,
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user