api/html.ts
Rama main · réplica de lectura del repositorio original.
/* 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<string, string | undefined> };
function esc(s: unknown): string {
return String(s ?? '')
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"');
}
async function pg(base: string, key: string, query: string): Promise<any[]> {
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<Response | null> {
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 <head> 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<string | null> {
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<string, string> = { 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>[^<]*<\/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)}</title>`,
description && `<meta name="description" content="${esc(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
? '<meta name="robots" content="noindex, nofollow">'
: (noIndex || noFollow) && `<meta name="robots" content="${[noIndex ? 'noindex' : 'index', noFollow ? 'nofollow' : 'follow'].join(', ')}">`,
`<meta property="og:type" content="${isArticle ? 'article' : 'website'}">`,
`<meta property="og:title" content="${esc(fullTitle)}">`,
description && `<meta property="og:description" content="${esc(description)}">`,
`<meta property="og:url" content="${esc(pageUrl)}">`,
siteName && `<meta property="og:site_name" content="${esc(siteName)}">`,
image && `<meta property="og:image" content="${esc(image)}">`,
image && `<meta property="og:image:secure_url" content="${esc(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.
`<meta name="twitter:card" content="${twitterCard}">`,
`<meta name="twitter:title" content="${esc(fullTitle)}">`,
description && `<meta name="twitter:description" content="${esc(description)}">`,
image && `<meta name="twitter:image" content="${esc(image)}">`,
twitter && `<meta name="twitter:site" content="${esc(twitter)}">`,
`<link rel="canonical" href="${esc(canonicalUrl || pageUrl)}">`,
...blogAlternates.map((a) => `<link rel="alternate" hreflang="${esc(a.hreflang)}" href="${esc(a.href)}">`),
// Samma adressform som sidhuvudet och sitemapen: standardspråket på
// roten, andra språk som /lang/<basslugg> — 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) => `<link rel="alternate" hreflang="${esc(String(s.locale).toLowerCase())}" href="${esc(hrefOf(s))}">`);
// 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(`<link rel="alternate" hreflang="x-default" href="${esc(hrefOf(fallback))}">`);
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 = `<!doctype html>
<html lang="${esc(pageLocale || 'en')}">
<head>
<meta charset="utf-8">
${tags.join('\n ')}
</head>
<body>
<h1>${esc(fullTitle)}</h1>
${description ? `<p>${esc(description)}</p>` : ''}
<p><a href="${esc(pageUrl)}">${esc(pageUrl)}</a></p>
</body>
</html>`;
return new Response(html, { headers: { ...headers, 'x-flowwink-shell': 'head-only', 'cache-control': 'public, max-age=0, s-maxage=30' } });
}