seo: own-locale canonical + hreflang for localized pages; /editor prerendered
- useSEO: for the static SEO routes (marketing pages, examples gallery, editor, home) the canonical is now the page's OWN locale variant and the head lists every locale variant as rel=alternate hreflang plus x-default on the locale-less English URL. /es/ and /zh-cn/ were declaring canonical=/ while Google indexed and ranked them on their own — an unstable contradiction. UGC pages (projects, profiles, example detail pages) are the same English content under every prefix and keep a single locale-less canonical with no hreflang. - prerender-seo.mjs emits the same hreflang set into each prerendered page and writes dist/seo-routes.json (path/title/description) so the prod app shell can serve the /<locale>/<route> variants (which have no static file) with the route's own head. - /editor gets seoMeta and a static SSR summary component, so it is prerendered: nginx now serves /editor/ as a real page and 301s /editor to it. Both forms answered 200 with the homepage head before, and Google indexed /editor and /editor/ as two pages.
This commit is contained in:
parent
1b735c9c30
commit
4da00840c6
|
|
@ -86,6 +86,36 @@ try {
|
|||
const routes = getPrerenderedRoutes();
|
||||
const exampleRoutes = getPrerenderedExampleRoutes();
|
||||
|
||||
// hreflang for the localized (marketing/static) pages: every locale
|
||||
// variant of the route plus x-default on the locale-less English URL.
|
||||
// The SPA serves the same page translated under /<locale>/..., so each
|
||||
// variant is a real page (see useSEO.ts, which emits the same set on the
|
||||
// client, and the prod app shell, which emits it for the /<locale>/
|
||||
// variants nginx has no static file for).
|
||||
const { LOCALES, LOCALE_META, DEFAULT_LOCALE } = await vite.ssrLoadModule('/src/i18n/config.ts');
|
||||
const localizedPath = (path, locale) =>
|
||||
locale === DEFAULT_LOCALE ? path : `/${locale}${path === '/' ? '' : path}`;
|
||||
const hreflangTags = (routePath) => {
|
||||
const variant = (l) => withSlash(`${DOMAIN}${localizedPath(routePath, l)}`);
|
||||
return [
|
||||
...LOCALES.map((l) => `<link rel="alternate" hreflang="${LOCALE_META[l].htmlLang}" href="${variant(l)}" />`),
|
||||
`<link rel="alternate" hreflang="x-default" href="${variant(DEFAULT_LOCALE)}" />`,
|
||||
].join('\n ');
|
||||
};
|
||||
const withHreflang = (html, routePath) =>
|
||||
html.replace('</head>', ` ${hreflangTags(routePath)}\n </head>`);
|
||||
|
||||
// Route metadata for the prod app shell (title/description per SEO route),
|
||||
// so /<locale>/<route> paths that fall through to it get the route's own
|
||||
// head instead of the homepage's.
|
||||
writeFileSync(
|
||||
join(distDir, 'seo-routes.json'),
|
||||
JSON.stringify(
|
||||
routes.map((r) => ({ path: r.path, title: r.seoMeta.title, description: r.seoMeta.description })),
|
||||
),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
console.log(`📄 Prerendering ${routes.length} SEO pages + ${exampleRoutes.length} example pages...\n`);
|
||||
|
||||
for (const route of routes) {
|
||||
|
|
@ -114,6 +144,7 @@ try {
|
|||
} else {
|
||||
html = html.replace('</head>', ` ${canonicalTag}\n </head>`);
|
||||
}
|
||||
html = withHreflang(html, route.path);
|
||||
|
||||
// Replace OG tags
|
||||
html = html.replace(
|
||||
|
|
|
|||
|
|
@ -19,7 +19,27 @@ import { ExampleDetailPage } from './pages/ExampleDetailPage';
|
|||
// Map route paths to their React component. The OSS build prerenders only
|
||||
// what it ships (the examples gallery); the marketing surface lives in the
|
||||
// pro overlay and is merged in by loadRouteComponents() below.
|
||||
/**
|
||||
* /editor is the live workspace (Monaco, WASM engines) — nothing there
|
||||
* survives renderToString, so its prerender is a static crawlable summary.
|
||||
* Being prerendered at all is what matters: nginx then serves /editor/ as
|
||||
* a real page and 301s /editor to it, instead of both forms answering 200
|
||||
* with the homepage head (Google had indexed the two as separate pages).
|
||||
*/
|
||||
const EditorSeoSummary: React.FC = () => (
|
||||
<main>
|
||||
<h1>Velxio Editor — multi-board circuit and code simulator</h1>
|
||||
<p>
|
||||
Write, compile and simulate Arduino, ESP32, ESP32-C3, ESP32-S3, Raspberry Pi
|
||||
Pico and Raspberry Pi code in your browser, wired to a live circuit canvas
|
||||
with SPICE analog simulation. Free, open source, no install and no account
|
||||
needed.
|
||||
</p>
|
||||
</main>
|
||||
);
|
||||
|
||||
const OSS_ROUTE_COMPONENTS: Record<string, React.FC> = {
|
||||
'/editor': EditorSeoSummary,
|
||||
'/examples': ExamplesPage,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -47,7 +47,21 @@ export const SEO_ROUTES: SeoRoute[] = [
|
|||
url: `${DOMAIN}/`,
|
||||
},
|
||||
},
|
||||
{ path: '/editor', priority: 0.9, changefreq: 'weekly' },
|
||||
{
|
||||
path: '/editor',
|
||||
priority: 0.9,
|
||||
changefreq: 'weekly',
|
||||
// With seoMeta the route is prerendered, so nginx serves /editor/ as a
|
||||
// real page and 301s /editor to it. Without it both forms answered 200
|
||||
// with the homepage head, and Google indexed /editor AND /editor/ as
|
||||
// two pages with different impressions.
|
||||
seoMeta: {
|
||||
title: 'Multi-Board Simulator Editor — Arduino, ESP32, RP2040, RISC-V | Velxio',
|
||||
description:
|
||||
'Write, compile and simulate Arduino, ESP32, Raspberry Pi Pico, ESP32-C3, and Raspberry Pi 3 code in your browser. 19 boards, 5 CPU architectures, 48+ components. Free and open-source.',
|
||||
url: `${DOMAIN}/editor`,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/examples',
|
||||
priority: 0.8,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
import { useEffect, useRef } from 'react';
|
||||
import { LOCALES, LOCALE_META, DEFAULT_LOCALE } from '../i18n/config';
|
||||
import { getLocaleFromPath, stripLocaleFromPath, localizedPath } from '../i18n/path';
|
||||
import { SEO_ROUTES } from '../seoRoutes';
|
||||
|
||||
export interface SEOMeta {
|
||||
title: string;
|
||||
|
|
@ -31,6 +34,53 @@ function withTrailingSlash(u: string): string {
|
|||
}
|
||||
}
|
||||
|
||||
const DOMAIN = 'https://velxio.dev';
|
||||
const HREFLANG_ATTR = 'data-seo-hreflang';
|
||||
|
||||
/**
|
||||
* Pages that exist once per locale (`/es/esp32-simulator/` renders the same
|
||||
* marketing page in Spanish): the static SEO routes plus the home. Everything
|
||||
* else — user projects, profiles, gallery example pages — is the same
|
||||
* English content under every prefix, so it keeps a single locale-less
|
||||
* canonical and no hreflang.
|
||||
*/
|
||||
const LOCALIZED_ROUTE_PATHS = new Set(
|
||||
SEO_ROUTES.filter((r) => !r.noindex).map((r) => (r.path === '/' ? '/' : r.path.replace(/\/$/, ''))),
|
||||
);
|
||||
|
||||
function isLocalizedRoute(strippedPath: string): boolean {
|
||||
const key = strippedPath === '/' ? '/' : strippedPath.replace(/\/$/, '');
|
||||
return LOCALIZED_ROUTE_PATHS.has(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical + hreflang for the current URL. For a localized route the
|
||||
* canonical is the page's OWN locale variant (`/es/esp32-simulator/`), and
|
||||
* every locale variant is listed as an alternate with `x-default` on the
|
||||
* locale-less English URL. Declaring canonical=/ from /es/ told Google the
|
||||
* Spanish page was a duplicate while it was ranking on its own; this
|
||||
* matches what Google already does with the pages.
|
||||
*/
|
||||
function localeLinks(url: string): { canonical: string; alternates: { hreflang: string; href: string }[] } {
|
||||
const canonicalDefault = withTrailingSlash(url);
|
||||
let strippedPath: string;
|
||||
try {
|
||||
strippedPath = stripLocaleFromPath(new URL(url, DOMAIN).pathname);
|
||||
} catch {
|
||||
return { canonical: canonicalDefault, alternates: [] };
|
||||
}
|
||||
if (!isLocalizedRoute(strippedPath)) return { canonical: canonicalDefault, alternates: [] };
|
||||
const current = typeof window !== 'undefined' ? getLocaleFromPath(window.location.pathname) : DEFAULT_LOCALE;
|
||||
const variant = (l: (typeof LOCALES)[number]) => withTrailingSlash(`${DOMAIN}${localizedPath(strippedPath, l)}`);
|
||||
return {
|
||||
canonical: variant(current),
|
||||
alternates: [
|
||||
...LOCALES.map((l) => ({ hreflang: LOCALE_META[l].htmlLang, href: variant(l) })),
|
||||
{ hreflang: 'x-default', href: variant(DEFAULT_LOCALE) },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates document.title, meta description, OG/Twitter tags, and canonical
|
||||
* to reflect the current page. Restores originals on unmount.
|
||||
|
|
@ -79,7 +129,27 @@ export function useSEO({ title, description, url, ogImage, jsonLd, noindex }: SE
|
|||
}
|
||||
|
||||
// Apply
|
||||
const canonicalUrl = withTrailingSlash(url);
|
||||
const { canonical: canonicalUrl, alternates } = localeLinks(url);
|
||||
// hreflang: replace whatever the server/prerender put there (it may be
|
||||
// another page's set — the shell is shared) with this page's own set.
|
||||
const staleHreflang = Array.from(document.head.querySelectorAll('link[rel="alternate"][hreflang]'));
|
||||
const removedHreflang = staleHreflang.map((el) => {
|
||||
const clone = el.cloneNode(true) as HTMLLinkElement;
|
||||
el.remove();
|
||||
return clone;
|
||||
});
|
||||
const addedHreflang: HTMLLinkElement[] = [];
|
||||
if (!noindex) {
|
||||
for (const alt of alternates) {
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'alternate';
|
||||
link.hreflang = alt.hreflang;
|
||||
link.href = alt.href;
|
||||
link.setAttribute(HREFLANG_ATTR, '1');
|
||||
document.head.appendChild(link);
|
||||
addedHreflang.push(link);
|
||||
}
|
||||
}
|
||||
document.title = title;
|
||||
set(descEl, description);
|
||||
if (noindex) {
|
||||
|
|
@ -113,6 +183,8 @@ export function useSEO({ title, description, url, ogImage, jsonLd, noindex }: SE
|
|||
if (ogImage) set(ogImgEl, origOgImg);
|
||||
set(twTitleEl, origTwTitle);
|
||||
set(twDescEl, origTwDesc);
|
||||
for (const el of addedHreflang) el.remove();
|
||||
for (const el of removedHreflang) document.head.appendChild(el);
|
||||
if (createdCanonical && activeCanonical && document.head.contains(activeCanonical)) {
|
||||
document.head.removeChild(activeCanonical);
|
||||
} else {
|
||||
|
|
|
|||
Loading…
Reference in New Issue