From 4da00840c628738c33f9c5c74182f23a3a704ba2 Mon Sep 17 00:00:00 2001 From: David Montero Date: Tue, 18 Aug 2026 16:50:39 +0200 Subject: [PATCH] seo: own-locale canonical + hreflang for localized pages; /editor prerendered MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 // 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. --- frontend/scripts/prerender-seo.mjs | 31 +++++++++++++ frontend/src/entry-server.tsx | 20 ++++++++ frontend/src/seoRoutes.ts | 16 ++++++- frontend/src/utils/useSEO.ts | 74 +++++++++++++++++++++++++++++- 4 files changed, 139 insertions(+), 2 deletions(-) diff --git a/frontend/scripts/prerender-seo.mjs b/frontend/scripts/prerender-seo.mjs index eeef6a7f..89d3c6b7 100644 --- a/frontend/scripts/prerender-seo.mjs +++ b/frontend/scripts/prerender-seo.mjs @@ -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 //..., 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 // + // 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) => ``), + ``, + ].join('\n '); + }; + const withHreflang = (html, routePath) => + html.replace('', ` ${hreflangTags(routePath)}\n `); + + // Route metadata for the prod app shell (title/description per SEO route), + // so // 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('', ` ${canonicalTag}\n `); } + html = withHreflang(html, route.path); // Replace OG tags html = html.replace( diff --git a/frontend/src/entry-server.tsx b/frontend/src/entry-server.tsx index 48e4d770..83fee247 100644 --- a/frontend/src/entry-server.tsx +++ b/frontend/src/entry-server.tsx @@ -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 = () => ( +
+

Velxio Editor — multi-board circuit and code simulator

+

+ 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. +

+
+); + const OSS_ROUTE_COMPONENTS: Record = { + '/editor': EditorSeoSummary, '/examples': ExamplesPage, }; diff --git a/frontend/src/seoRoutes.ts b/frontend/src/seoRoutes.ts index 002165e0..5a2dca66 100644 --- a/frontend/src/seoRoutes.ts +++ b/frontend/src/seoRoutes.ts @@ -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, diff --git a/frontend/src/utils/useSEO.ts b/frontend/src/utils/useSEO.ts index 4b54dda9..f14178b3 100644 --- a/frontend/src/utils/useSEO.ts +++ b/frontend/src/utils/useSEO.ts @@ -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 {