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:
@@ -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 },
|
||||
];
|
||||
Reference in New Issue
Block a user