/* eslint-disable @typescript-eslint/no-explicit-any -- prerender reads dynamic, loosely-typed PostgREST JSON */ export const config = { runtime: 'edge' }; import { blogPostPath, pagePath, postAlternates, postVersions, splitLanguagePrefix } from '../src/lib/language-path'; import { resolveOgImage } from '../src/lib/og-card'; import { blogCoverStyle, blogShareImage } from '../src/lib/blog-cover'; import { injectHead, shellCacheControl } from '../src/lib/seo-shell'; declare const process: { env: Record }; function esc(s: unknown): string { return String(s ?? '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } async function pg(base: string, key: string, query: string): Promise { try { const r = await fetch(`${base}/rest/v1/${query}`, { headers: { apikey: key, authorization: `Bearer ${key}` }, }); if (!r.ok) return []; return (await r.json()) as any[]; } catch { return []; } } /** A redirect for this address (resolve_redirect follows chains), as the response that sends it; null when there is none. */ async function resolveRedirect(base: string, key: string, path: string, origin: string): Promise { try { const r = await fetch(`${base}/rest/v1/rpc/resolve_redirect`, { method: 'POST', headers: { apikey: key, authorization: `Bearer ${key}`, 'content-type': 'application/json' }, body: JSON.stringify({ p_path: path }), }); if (!r.ok) return null; const d = (await r.json()) as { found?: boolean; to_path?: string; status_code?: number; external?: boolean } | null; if (!d?.found || !d.to_path) return null; const location = d.external ? d.to_path : `${origin}${d.to_path.startsWith('/') ? '' : '/'}${d.to_path}`; return new Response(null, { status: d.status_code === 302 ? 302 : 301, headers: { location, 'cache-control': 'public, max-age=0, s-maxage=300' }, }); } catch { return null; } } /** * The HTML document for every page navigation — ONE document for every reader. * * Vercel rewrites every non-asset, non-API path here (vercel.json). Until * 2026-10-04 only a hand-listed set of social and AI crawlers were routed here * and got a tiny prerendered head; browsers, Googlebot, Bingbot, curl and an * operator's own check got the static `index.html` and read "Website" (Hermes * on synclairvision, #625). A User-Agent list is a guard that enumerates: every * reader it does not name sees the wrong title. * * Now the function fetches the shell the build produced (`/index.html`, served * statically), fills its with the CUSTOMER's title, description, OG and * Twitter meta, canonical and hreflang for the requested page, and returns the * real SPA. The SPA hydrates on top; the injected tags carry `data-rh` so * react-helmet-async reconciles them instead of adding a second set. * * Caching follows the Performance → Edge caching dial exactly like get-page * does (see shellCacheControl). Identity comes from the same Supabase the Vite * build points at (site_settings seo/general/branding/site_languages/ * performance + per-page pages/blog_posts/kb_articles) — no new configuration. * If the shell cannot be fetched (preview protection, a cold miss during a * deploy) the function answers with a minimal head-only document, the old * behaviour, so a crawler still gets the right title. */ /** Browser hardening every HTML response carries (mirrors vercel.json → routes[0].headers). */ const SECURITY_HEADERS = { 'x-content-type-options': 'nosniff', 'x-frame-options': 'SAMEORIGIN', 'referrer-policy': 'strict-origin-when-cross-origin', 'permissions-policy': 'camera=(self), microphone=(self), geolocation=(), payment=(self)', } as const; /** The build's index.html, per isolate. A deployment is immutable, so one fetch per isolate suffices. */ let shellCache: { html: string; at: number } | null = null; const SHELL_TTL_MS = 10 * 60 * 1000; async function loadShell(origin: string, req: Request): Promise { if (shellCache && Date.now() - shellCache.at < SHELL_TTL_MS) return shellCache.html; try { // `/index.html` has an extension, so vercel.json serves it from the // filesystem — this never re-enters the function. On a protected preview // deployment the static file sits behind Vercel's login too, so the // visitor's own credentials travel with the fetch: their cookie and the // automation bypass header. Production has no protection and sends neither. const headers: Record = { accept: 'text/html' }; for (const h of ['cookie', 'x-vercel-protection-bypass', 'x-vercel-set-bypass-cookie']) { const v = req.headers.get(h); if (v) headers[h] = v; } const r = await fetch(`${origin}/index.html`, { headers, redirect: 'manual' }); if (!r.ok) return null; const html = await r.text(); if (!/[^<]*<\/title>/.test(html) || !/id="root"/.test(html)) return null; shellCache = { html, at: Date.now() }; return html; } catch { return null; } } export default async function handler(req: Request): Promise<Response> { const url = new URL(req.url); const path = (url.searchParams.get('path') || '/').replace(/\/+$/, '') || '/'; // Sidans eget språk, och dess syskon — båda tomma tills en sida slås upp. let pageLocale = ''; let siblings: any[] = []; // /en/product: prefixet är språket, resten är GRUPPENS basslugg. Vilka // prefix som finns avgörs av sajtens deklaration — fylls i när // inställningarna lästs. let requestedLang: string | null = null; let byKeyOuter: Record<string, any> = {}; let canonicalUrl = ''; // A blog post's language versions, as ready <link rel=alternate> pairs. let blogAlternates: Array<{ hreflang: string; href: string }> = []; const host = req.headers.get('host') || url.host; const proto = req.headers.get('x-forwarded-proto') || 'https'; const origin = `${proto}://${host}`; const pageUrl = path === '/' ? origin : `${origin}${path}`; const base = (process.env.VITE_SUPABASE_URL || process.env.SUPABASE_URL || '').replace(/\/+$/, ''); const key = process.env.VITE_SUPABASE_PUBLISHABLE_KEY || process.env.SUPABASE_ANON_KEY || ''; let title = 'Website'; let siteName = ''; let description = ''; let image = ''; let logoFallback = ''; let favicon = ''; let twitter = ''; let titleTemplate = '%s'; let isArticle = false; let noIndex = false; let noFollow = false; if (base && key) { const settings = await pg(base, key, 'site_settings?key=in.(seo,general,branding,site_languages,performance,blog)&select=key,value'); const byKey: Record<string, any> = {}; for (const row of settings) byKey[row.key] = row.value || {}; byKeyOuter = byKey; const seo = byKey.seo || {}; const branding = byKey.branding || {}; title = seo.siteTitle || branding.organizationName || 'Website'; siteName = seo.siteTitle || branding.organizationName || title; description = seo.defaultDescription || branding.brandTagline || ''; image = seo.ogImage || ''; logoFallback = branding.logo || ''; favicon = typeof branding.favicon === 'string' ? branding.favicon.trim() : ''; twitter = seo.twitterHandle || ''; titleTemplate = seo.titleTemplate || '%s'; const langs = (byKey.site_languages || {}) as { default?: string; enabled?: string[] }; const split = splitLanguagePrefix(path, langs.enabled ?? [], String(langs.default ?? 'en')); requestedLang = split.lang; // /blog/<slug> in the site language, /<lang>/blog/<slug> in the others (§9). const blogRest = requestedLang !== null ? split.rest : path; const blog = blogRest.match(/^\/blog\/(.+)$/); // /sv/blog and its lists are Swedish whatever they contain. // /sv/shop and /sv/shop/<id> too (§10): the shop's chrome in that language. if (requestedLang && /^\/(blog|shop)(\/|$)/.test(blogRest)) pageLocale = requestedLang; const kb = path.match(/^\/kb\/([^/]+)$/); if (kb) { // Same head as KbArticlePage: the title, and the question (or the start // of the answer) as the description. Anon eyes: RLS decides what a // visitor may see, so an internal article never lends it its words. isArticle = true; const slug = encodeURIComponent(decodeURIComponent(kb[1])); const [article] = await pg( base, key, `kb_articles?slug=eq.${slug}&is_published=eq.true&select=title,question,answer_text&limit=1`, ); if (article) { if (article.title) title = article.title; const d = article.question || String(article.answer_text || '').slice(0, 155); if (d) description = d; } } else if (blog) { isArticle = true; const defaultLang = String(langs.default ?? 'en').toLowerCase(); const lang = requestedLang ?? defaultLang; const slug = encodeURIComponent(decodeURIComponent(blog[1])); const cols = 'id,slug,title,excerpt,featured_image,meta_json,locale'; let [post] = await pg(base, key, `blog_posts?slug=eq.${slug}&status=eq.published&select=${cols}&limit=1`); // An instance without blog_posts.locale yet: the post as written, as before. if (!post) [post] = await pg(base, key, `blog_posts?slug=eq.${slug}&status=eq.published&select=title,excerpt,featured_image&limit=1`); // An overlay's own slug names the post — /sv/blog/<svensk-slug>, and // /blog/<english-slug> too when the post was written in Swedish: the // site language has no prefix, so `requestedLang` is null there. if (!post) { const [hit] = await pg(base, key, `content_translations?entity=eq.blog_post&locale=eq.${encodeURIComponent(lang)}&slug=eq.${slug}&status=eq.published&select=entity_id&limit=1`); if (hit?.entity_id) [post] = await pg(base, key, `blog_posts?id=eq.${encodeURIComponent(String(hit.entity_id))}&status=eq.published&select=${cols}&limit=1`); } // No post at this address: a redirect for it (manage_redirect) answers // as a real 301/302, so a search engine moves the link instead of // keeping a 200 "Page not found" (www, 29 retired posts, 2026-10-30). if (!post) { const moved = await resolveRedirect(base, key, path, origin); if (moved) return moved; } if (post) { const m = { ...((post.meta_json || {}) as Record<string, unknown>) }; let postTitle = post.title as string | undefined; let postExcerpt = post.excerpt as string | undefined; if (post.id) { const own = String(post.locale ?? defaultLang).toLowerCase(); const overlays = await pg(base, key, `content_translations?entity=eq.blog_post&entity_id=eq.${encodeURIComponent(String(post.id))}&status=eq.published&select=locale,slug,strings`); const versions = postVersions({ slug: String(post.slug), locale: own, title: postTitle }, overlays, defaultLang); blogAlternates = postAlternates(versions, defaultLang, origin); const here = versions.find((v) => v.locale === lang); if (here) { pageLocale = here.locale; canonicalUrl = `${origin}${blogPostPath(here.slug, here.locale, defaultLang)}`; if (here.source === 'overlay') { // The overlay's text wins where it has it; a missing unit shows the source. const strings = (overlays.find((o) => o.locale === lang)?.strings ?? {}) as Record<string, { text?: string }>; const t = (k: string) => strings[k]?.text?.trim() || ''; if (t('post.title')) postTitle = t('post.title'); if (t('post.excerpt')) postExcerpt = t('post.excerpt'); if (t('post.meta.seoTitle')) m.seoTitle = t('post.meta.seoTitle'); if (t('post.meta.description')) m.description = t('post.meta.description'); } } else { // Not in this language: the post's own address is the canonical one. pageLocale = own; canonicalUrl = `${origin}${blogPostPath(String(post.slug), own, defaultLang)}`; } } if (postTitle) title = postTitle; if (typeof m.seoTitle === 'string' && m.seoTitle.trim()) title = m.seoTitle.trim(); description = (m.description as string) || postExcerpt || description; // meta.ogImage first (the post page's own rule); then the cover dial: // brand → the derived brand card for THIS address (api/og draws the // post's title on the branding), never the stock photo; photo → the // featured image. Nothing → the site image / derived card below. const share = blogShareImage({ style: blogCoverStyle(byKey.blog), metaOgImage: typeof m.ogImage === 'string' ? m.ogImage : null, featuredImage: post.featured_image, origin, path, }); if (share) image = share; } } else { // Startsidan är den mest besökta sidan och den enda som INTE slogs upp — // path '/' hoppade över hela uppslaget, så crawlers fick lang="en" på en // svensk startsida medan varenda undersida var rätt. Roten pekar på en // riktig sidrad via general.homepageSlug, och den raden bär språket. const homepageSlug = String((byKey.general || {}).homepageSlug || 'home'); const langs = (byKey.site_languages || {}) as { default?: string; enabled?: string[] }; const defaultLang = String(langs.default ?? 'en'); // /en → engelska startsidan; /en/product → basen 'product', språket 'en'. const requestedRest = requestedLang !== null ? splitLanguagePrefix(path, langs.enabled ?? [], defaultLang).rest : path; const rawSlug = requestedRest === '/' ? homepageSlug : decodeURIComponent(requestedRest.replace(/^\//, '')); const slug = encodeURIComponent(rawSlug); let [page] = await pg( base, key, `pages?slug=eq.${slug}&status=eq.published&select=id,slug,title,meta_json,locale,translation_group_id&limit=1`, ); // Överläggsmodellen (page_translations): sidan i ett annat språk utan // egen rad. Prefix begärt och ingen syskonrad → titel, meta och språk ur // det publicerade överlägget; alternativen är sidan + dess överlägg. let overlayText: Record<string, { text?: string }> | null = null; // /en/terms: no page is called 'terms', but the English overlay of // /villkor carries that slug — the page is found through it. if (!page && requestedLang) { const [byOwnSlug] = await pg( base, key, `page_translations?locale=eq.${encodeURIComponent(requestedLang)}&slug=eq.${slug}&status=eq.published&select=page_id&limit=1`, ); if (byOwnSlug?.page_id) { [page] = await pg( base, key, `pages?id=eq.${encodeURIComponent(String(byOwnSlug.page_id))}&status=eq.published&select=id,slug,title,meta_json,locale,translation_group_id&limit=1`, ); } } if (page && requestedLang && String(page.locale ?? '').toLowerCase() !== requestedLang && !page.translation_group_id) { const [ov] = await pg( base, key, `page_translations?page_id=eq.${encodeURIComponent(String(page.id))}&locale=eq.${encodeURIComponent(requestedLang)}&status=eq.published&select=locale,slug,strings&limit=1`, ); if (ov?.strings) { overlayText = ov.strings as Record<string, { text?: string }>; const all = await pg( base, key, `page_translations?page_id=eq.${encodeURIComponent(String(page.id))}&status=eq.published&select=locale,slug`, ); siblings = [{ slug: page.slug, locale: page.locale }, ...all.map((x: any) => ({ slug: page.slug, locale: x.locale, path_slug: x.slug }))]; const p2 = pagePath({ slug: String(page.slug), locale: requestedLang, defaultLanguage: defaultLang, baseSlug: String(page.slug), homepageSlug, pathSlug: ov.slug }); canonicalUrl = p2 === '/' ? `${origin}/` : `${origin}${p2}`; pageLocale = requestedLang; } } // Prefix begärt: sidan vi vill visa är SYSKONET i det språket, inte // basraden. Basen är bara adressens ryggrad. if (page && requestedLang && page.translation_group_id && String(page.locale ?? '').toLowerCase() !== requestedLang) { const [sibling] = await pg( base, key, `pages?translation_group_id=eq.${encodeURIComponent(String(page.translation_group_id))}` + `&locale=eq.${encodeURIComponent(requestedLang)}&status=eq.published&select=slug,title,meta_json,locale,translation_group_id&limit=1`, ); if (sibling) page = sibling; } // The default-language address of an overlay page: its alternates are // the page itself plus every published overlay. Without this /about had // none while /en/about pointed back at it — a one-way pair, which // Google ignores. if (page && !requestedLang && !page.translation_group_id) { const all = await pg( base, key, `page_translations?page_id=eq.${encodeURIComponent(String(page.id))}&status=eq.published&select=locale,slug`, ); if (all.length) { siblings = [ { slug: page.slug, locale: page.locale ?? defaultLang }, ...all.map((x: any) => ({ slug: page.slug, locale: x.locale, path_slug: x.slug })), ]; } } if (page) { if (page.title) title = page.title; // Kanonisk adress på prefixformen — även när den GAMLA adressen // (/product-en) begärdes. Det är omdirigeringens crawler-halva: // klienten navigerar, boten läser rel=canonical. if (page.translation_group_id) { const canonSiblings = await pg( base, key, `pages?translation_group_id=eq.${encodeURIComponent(String(page.translation_group_id))}` + `&status=eq.published&select=slug,locale`, ); const baseSlug = canonSiblings.find( (x: any) => String(x.locale ?? '').toLowerCase().split('-')[0] === defaultLang.toLowerCase().split('-')[0], )?.slug ?? null; const p2 = pagePath({ slug: String(page.slug), locale: page.locale ? String(page.locale) : null, defaultLanguage: defaultLang, baseSlug, homepageSlug, }); canonicalUrl = p2 === '/' ? `${origin}/` : `${origin}${p2}`; siblings = canonSiblings; } // Språket följer sidan. Skalet hade `lang="en"` hårdkodat, så en // crawler fick veta att en svensk sida var engelsk — samma fel som // index.html bar innan sidorna fick sitt eget språk. if (page.locale && !overlayText) pageLocale = String(page.locale); const m = { ...((page.meta_json || {}) as Record<string, unknown>) }; if (overlayText) { // Överläggets text vinner där den finns; saknad enhet visar källan. const t = (k: string) => overlayText?.[k]?.text?.trim() || ''; if (t('page.title')) title = t('page.title'); if (t('page.meta.seoTitle')) m.seoTitle = t('page.meta.seoTitle'); if (t('page.meta.description')) m.description = t('page.meta.description'); if (t('page.meta.seoDescription')) m.seoDescription = t('page.meta.seoDescription'); if (t('page.meta.metaDescription')) m.metaDescription = t('page.meta.metaDescription'); } // The same keys PublicPage puts in its head (the page-SEO contract, // #579): a share preview that disagrees with the tab is a second truth. if (typeof m.seoTitle === 'string' && m.seoTitle.trim()) title = m.seoTitle.trim(); noIndex = m.noIndex === true; noFollow = m.noFollow === true; description = (m.description as string) || (m.seoDescription as string) || (m.metaDescription as string) || description; image = (m.ogImage as string) || (m.og_image as string) || (m.image as string) || image; } } } const fullTitle = title === siteName ? title : titleTemplate.replace('%s', title); // No SEO image configured: fall back to the brand logo so a share still // shows something recognisable rather than a bare link. // No explicit image anywhere → the DERIVED card (api/og), drawn from the // brand dials and the page's own title and picture. The logo-only // "summary" card is what a site got before 2026-10-21; it stays as the // fallback only when the derived card cannot be addressed (no origin). if (!image && origin) image = resolveOgImage(null, origin, path); const usingLogo = !image && !!logoFallback; if (usingLogo) image = logoFallback; // WhatsApp/Facebook/LinkedIn drop relative image paths — always absolutize. if (image && !/^https?:\/\//i.test(image)) { image = `${origin}${image.startsWith('/') ? '' : '/'}${image}`; } // summary_large_image without an image renders as a bare link on X/Twitter. const twitterCard = image ? (usingLogo ? 'summary' : 'summary_large_image') : 'summary'; const tags = [ `<title>${esc(fullTitle)}`, description && ``, // Dev mode promises "hidden from search engines", but the client-side tag // only reaches JS-running crawlers — the prerendered head must carry it too. (byKeyOuter.seo || {}).developmentMode === true ? '' : (noIndex || noFollow) && ``, ``, ``, description && ``, ``, siteName && ``, image && ``, image && ``, // No og:image:width/height: the renderer does not know the image's size, // and it used to claim 1200x630 for every image — a product render or a // portrait photo too. A wrong size is worse than none: crawlers read the // file when the size is absent. ``, ``, description && ``, image && ``, twitter && ``, ``, ...blogAlternates.map((a) => ``), // Samma adressform som sidhuvudet och sitemapen: standardspråket på // roten, andra språk som /lang/ — via samma pagePath. ...(siblings.length > 1 ? (() => { const langs = (byKeyOuter.site_languages || {}) as { default?: string }; const defaultLang = String(langs.default ?? 'en'); const homepageSlug = String((byKeyOuter.general || {}).homepageSlug || 'home'); const baseSlug = siblings.find( (s: any) => String(s.locale ?? '').toLowerCase().split('-')[0] === defaultLang.toLowerCase().split('-')[0], )?.slug ?? null; const hrefOf = (s: any) => { const p = pagePath({ slug: String(s.slug), locale: String(s.locale), defaultLanguage: defaultLang, baseSlug, homepageSlug, pathSlug: s.path_slug, }); return p === '/' ? `${origin}/` : `${origin}${p}`; }; const links = siblings .filter((s: any) => s?.slug && s?.locale) .map((s: any) => ``); // x-default names the version a stranger gets: the site's declared // language — only when the site HAS declared one (a guessed 'en' sent // a Swedish market to the English page on optictunnels.se). const declared = byKeyOuter.site_languages && langs.default; const fallback = declared && siblings.find( (s: any) => s?.slug && String(s.locale ?? '').toLowerCase().split('-')[0] === defaultLang.toLowerCase().split('-')[0], ); if (fallback) links.push(``); return links; })() : []), ].filter((t): t is string => typeof t === 'string' && t.length > 0); const cacheControl = shellCacheControl((byKeyOuter.performance || null) as { enableEdgeCaching?: boolean; edgeCacheTtlMinutes?: number } | null); const headers = { 'content-type': 'text/html; charset=utf-8', 'cache-control': cacheControl, // One document per URL, whoever asks — nothing varies by User-Agent any more. 'x-flowwink-shell': 'injected', // The same four headers vercel.json sets on every route; the Docker nginx has // had them since day one, Vercel sent only HSTS (sweep 2026-10-22). ...SECURITY_HEADERS, }; const shell = await loadShell(origin, req); if (shell) { try { return new Response(injectHead(shell, { tags, lang: pageLocale || null, icon: favicon || null }), { headers }); } catch { // fall through to the head-only document } } // Last resort (shell unreachable): the head alone, as the social prerender // always answered. A crawler still reads the right title; a browser gets a // link to reload — a deploy-time blip, not the steady state. const html = ` ${tags.join('\n ')}

${esc(fullTitle)}

${description ? `

${esc(description)}

` : ''}

${esc(pageUrl)}

`; return new Response(html, { headers: { ...headers, 'x-flowwink-shell': 'head-only', 'cache-control': 'public, max-age=0, s-maxage=30' } }); }