feat: SEO crawlability + on-brand copy

Make every served byte crawlable without executing JS, remove off-brand
copy, and cut third-party requests.

- Copy: replace off-brand terms (survival/crisis/collapse) in title, meta,
  OG/Twitter, JSON-LD and webmanifest with on-brand preparedness copy.
  Update App.vue i18n page_title/meta_description (EN+DE) too, since the app
  overwrites the head at runtime. Reconcile area count to the real 6.
- Prerender: vite-plugin-seo-snapshot injects a static <noscript> snapshot
  (real <h1>, content, <a href>) after #app. JS users ignore <noscript>, so
  the live app/UX is unchanged; curl/no-JS crawlers get real content.
- 404: nginx now returns true 404s on unknown paths (no soft-200 SPA
  fallback) with a branded 404 page.
- Sitemap: drop no-op hreflang alternates, add <lastmod>.
- Fonts: self-host Space Mono / DM Serif Display / Barlow (woff2, latin +
  latin-ext, font-display: swap); preload above-the-fold faces. No more
  fonts.googleapis/gstatic requests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-06-18 08:20:19 +01:00
parent b845957314
commit 425e56c0ca
24 changed files with 260 additions and 26 deletions

View File

@@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0"/>
<!-- ── Primary meta ─────────────────────────────────────────────── -->
<title>Plan-B — AI-Assisted Crisis Preparedness Advisor</title>
<meta name="description" content="Free AI-assisted crisis-preparedness advisor for urban households. Assess your readiness across 9 collapse scenarios and get a personalised survival plan in minutes."/>
<title>Plan-B — AI-Assisted Preparedness for Urban Households</title>
<meta name="description" content="A calm, costed preparedness plan for your household in about 10 minutes. Check your readiness across 6 areas like water, energy and food, built on economics not fear."/>
<meta name="author" content="Plan-B"/>
<meta name="theme-color" content="#5A9A78"/>
<meta name="color-scheme" content="light"/>
@@ -16,8 +16,8 @@
<!-- ── Open Graph (Facebook, LinkedIn, WhatsApp, Signal, Telegram…) ─ -->
<meta property="og:type" content="website"/>
<meta property="og:site_name" content="Plan-B"/>
<meta property="og:title" content="Plan-B — AI-Assisted Crisis Preparedness Advisor"/>
<meta property="og:description" content="Assess your household's readiness across 9 collapse scenarios and get a personalised survival plan — water, food, energy, medical and budget. Built for city apartments."/>
<meta property="og:title" content="Plan-B — AI-Assisted Preparedness for Urban Households"/>
<meta property="og:description" content="Find out where your household is exposed and get a clear, costed 90-day plan. Water, energy, food and more, made for city apartments."/>
<meta property="og:url" content="https://plan-b.now/"/>
<meta property="og:image" content="https://plan-b.now/images/og-image.png"/>
<meta property="og:image:secure_url" content="https://plan-b.now/images/og-image.png"/>
@@ -30,8 +30,8 @@
<!-- ── Twitter / X ─────────────────────────────────────────────── -->
<meta name="twitter:card" content="summary_large_image"/>
<meta name="twitter:title" content="Plan-B — AI-Assisted Crisis Preparedness Advisor"/>
<meta name="twitter:description" content="Assess your household's readiness across 9 collapse scenarios and get a personalised survival plan. Built for city apartments."/>
<meta name="twitter:title" content="Plan-B — AI-Assisted Preparedness for Urban Households"/>
<meta name="twitter:description" content="A calm, costed 90-day preparedness plan for your household. Made for city apartments."/>
<meta name="twitter:image" content="https://plan-b.now/images/og-image.png"/>
<meta name="twitter:image:alt" content="Plan-B — AI-Assisted Preparedness for Urban Households"/>
@@ -42,10 +42,11 @@
<link rel="apple-touch-icon" href="/images/apple-touch-icon.png"/>
<link rel="manifest" href="/site.webmanifest"/>
<!-- ── Fonts ───────────────────────────────────────────────────── -->
<link rel="preconnect" href="https://fonts.googleapis.com"/>
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin/>
<link href="https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&family=DM+Serif+Display&family=Barlow:wght@300;400;500;600&display=swap" rel="stylesheet"/>
<!-- ── Fonts (self-hosted — no third-party requests) ───────────── -->
<link rel="preload" href="/fonts/barlow-400-latin.woff2" as="font" type="font/woff2" crossorigin/>
<link rel="preload" href="/fonts/dm-serif-display-400-latin.woff2" as="font" type="font/woff2" crossorigin/>
<link rel="preload" href="/fonts/space-mono-400-latin.woff2" as="font" type="font/woff2" crossorigin/>
<link rel="stylesheet" href="/fonts/fonts.css"/>
<!-- ── Structured data (Google rich results) ───────────────────── -->
<script type="application/ld+json">
@@ -57,7 +58,7 @@
"@id": "https://plan-b.now/#website",
"url": "https://plan-b.now/",
"name": "Plan-B",
"description": "AI-assisted crisis-preparedness advisor for urban households.",
"description": "AI-assisted preparedness for urban households.",
"inLanguage": ["en", "de"],
"publisher": { "@id": "https://plan-b.now/#org" }
},

View File

@@ -24,10 +24,25 @@ server {
try_files $uri =404;
}
# SPA fallback — any non-asset path serves index.html so future
# client-side routing works without 404s.
# App shell — only the root path serves index.html. The app is a
# single-route SPA (no history-based client routing), so there is no
# need to fall back unknown paths to index.html.
location = / {
try_files /index.html =404;
}
# Everything else serves a real file if it exists, otherwise a true 404.
# This avoids the soft-404 (HTTP 200 on unknown paths) that hurts SEO.
# When real redirects/removals appear, add `return 301`/`return 410`
# location blocks above this one.
location / {
try_files $uri $uri/ /index.html;
try_files $uri $uri/ =404;
}
# Branded 404 page (shipped from public/404.html).
error_page 404 /404.html;
location = /404.html {
internal;
}
# Don't serve dotfiles

35
public/404.html Normal file
View File

@@ -0,0 +1,35 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<meta name="robots" content="noindex, follow"/>
<title>Page not found — Plan-B</title>
<link rel="icon" href="/images/favicon.svg" type="image/svg+xml"/>
<style>
:root { color-scheme: light; }
html, body { height: 100%; margin: 0; }
body {
display: flex; align-items: center; justify-content: center;
background: #FAFAFA; color: #2a3010;
font-family: "Barlow", system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
text-align: center; padding: 2rem;
}
.wrap { max-width: 32rem; }
h1 { font-size: 3.5rem; margin: 0 0 .5rem; font-weight: 600; letter-spacing: -0.02em; }
p { font-size: 1.125rem; line-height: 1.6; margin: 0 0 1.75rem; color: #4a5340; }
a.home {
display: inline-block; padding: .75rem 1.5rem; border-radius: .5rem;
background: #5A9A78; color: #fff; text-decoration: none; font-weight: 500;
}
a.home:hover { background: #4d8567; }
</style>
</head>
<body>
<div class="wrap">
<h1>404</h1>
<p>This page isnt here. The plan still is — head back to the start and check your household readiness.</p>
<a class="home" href="/">Back to Plan-B</a>
</div>
</body>
</html>

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

115
public/fonts/fonts.css Normal file
View File

@@ -0,0 +1,115 @@
/* Self-hosted fonts — replaces fonts.googleapis.com.
Subsets: latin + latin-ext. font-display: swap.
Source: Google Fonts (SIL OFL 1.1). Regenerate via the fonts download step. */
@font-face {
font-family: 'Barlow';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url(/fonts/barlow-300-latin-ext.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
font-family: 'Barlow';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url(/fonts/barlow-300-latin.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Barlow';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/barlow-400-latin-ext.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
font-family: 'Barlow';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/barlow-400-latin.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Barlow';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(/fonts/barlow-500-latin-ext.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
font-family: 'Barlow';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(/fonts/barlow-500-latin.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Barlow';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(/fonts/barlow-600-latin-ext.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
font-family: 'Barlow';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(/fonts/barlow-600-latin.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'DM Serif Display';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/dm-serif-display-400-latin-ext.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
font-family: 'DM Serif Display';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/dm-serif-display-400-latin.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Space Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/space-mono-400-latin-ext.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
font-family: 'Space Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/space-mono-400-latin.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Space Mono';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(/fonts/space-mono-700-latin-ext.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
font-family: 'Space Mono';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(/fonts/space-mono-700-latin.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1,7 +1,7 @@
{
"name": "Plan-B — Crisis Preparedness Advisor",
"name": "Plan-B — Preparedness for Urban Households",
"short_name": "Plan-B",
"description": "AI-assisted crisis-preparedness advisor for urban households.",
"description": "AI-assisted preparedness for urban households.",
"start_url": "/",
"scope": "/",
"display": "standalone",

View File

@@ -1,12 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
xmlns:xhtml="http://www.w3.org/1999/xhtml">
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://plan-b.now/</loc>
<lastmod>2026-06-18</lastmod>
<changefreq>weekly</changefreq>
<priority>1.0</priority>
<xhtml:link rel="alternate" hreflang="en" href="https://plan-b.now/"/>
<xhtml:link rel="alternate" hreflang="de" href="https://plan-b.now/"/>
<xhtml:link rel="alternate" hreflang="x-default" href="https://plan-b.now/"/>
</url>
</urlset>

View File

@@ -589,8 +589,8 @@ import { onMounted } from 'vue'
const T = {
en: {
brand: "Plan-B",
page_title: "Plan-B — AI-Assisted Crisis Preparedness Advisor",
meta_description: "Free AI-assisted crisis-preparedness advisor for urban households. Assess your readiness across 9 collapse scenarios and get a personalised survival plan in minutes.",
page_title: "Plan-B — AI-Assisted Preparedness for Urban Households",
meta_description: "A calm, costed preparedness plan for your household in about 10 minutes. Check your readiness across 6 areas like water, energy and food, built on economics not fear.",
og_locale: "en_US",
hero_eyebrow: "Crisis Preparedness Advisor",
hero_sub: "AI-Assisted Preparedness for Urban Households",
@@ -713,8 +713,8 @@ const T = {
},
de: {
brand: "Plan-B",
page_title: "Plan-B — KI-gestützter Krisenvorsorge-Berater",
meta_description: "Kostenloser KI-gestützter Krisenvorsorge-Berater für urbane Haushalte. Bewerte deine Vorsorge über 9 Krisenszenarien und erhalte in Minuten einen personalisierten Survival-Plan.",
page_title: "Plan-B — KI-gestützte Vorsorge für urbane Haushalte",
meta_description: "Ein besonnener, kalkulierter Vorsorgeplan für Ihren Haushalt in rund 10 Minuten. Prüfen Sie Ihre Vorsorge in 6 Bereichen wie Wasser, Energie und Lebensmitteln, ökonomisch fundiert statt angstgetrieben.",
og_locale: "de_DE",
hero_eyebrow: "⚡ Krisenvorsorge-Berater",
hero_sub: "KI-gestützte Vorsorge für urbane Haushalte",

45
src/seo-snapshot.html Normal file
View File

@@ -0,0 +1,45 @@
<!--
Static SEO snapshot — injected into a <noscript> in the served HTML by
vite-plugin-seo-snapshot.js. Browsers with JavaScript enabled IGNORE this
block entirely, so it has ZERO effect on the live app/UX. It exists so that
`curl` and non-rendering crawlers receive real <h1> + content + <a href>
links instead of an empty shell. Keep copy on-brand (preparedness, not
crisis/survival) and factually aligned with the app.
-->
<main class="seo-snapshot" role="main">
<h1>Plan-B — AI-Assisted Preparedness for Urban Households</h1>
<p>Plan-B builds a calm, costed preparedness plan for your household in about
ten minutes. It checks where your home is exposed and gives you a clear,
prioritised 90-day plan — built on economics, not fear. Made for city
apartments.</p>
<h2>What Plan-B checks</h2>
<p>Answer a short set of questions about your household and get a readiness
picture across the core areas that keep a home running through a power outage,
a supply disruption, or a service interruption:</p>
<ul>
<li><strong>Water</strong> — storage, filtration and backup supply.</li>
<li><strong>Food</strong> — reserves, shelf-stable staples and rotation.</li>
<li><strong>Growing</strong> — fresh food you can produce at home.</li>
<li><strong>Energy</strong> — power and heat backup for an apartment.</li>
<li><strong>Medical</strong> — first aid and essential supplies.</li>
<li><strong>Sanitation</strong> — hygiene and waste when services pause.</li>
</ul>
<h2>How it works</h2>
<ol>
<li>Answer a few questions about your household and location.</li>
<li>See where you are exposed across each readiness area.</li>
<li>Get a costed, prioritised 90-day plan you can act on.</li>
</ol>
<nav aria-label="Site">
<ul>
<li><a href="/">Home</a></li>
<li><a href="/#about-section">About Plan-B</a></li>
</ul>
</nav>
<p>Plan-B is available in English and German (Kammergut).</p>
</main>

View File

@@ -0,0 +1,25 @@
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
// Injects a static, crawlable SEO snapshot into a <noscript> block in the
// served index.html. Browsers with JS enabled ignore <noscript>, so the live
// app and its UX are completely unaffected. Non-rendering crawlers and `curl`
// receive a real <h1>, descriptive content and <a href> links instead of an
// empty SPA shell.
export default function seoSnapshot() {
const snapshotPath = fileURLToPath(new URL('./src/seo-snapshot.html', import.meta.url))
return {
name: 'seo-snapshot',
transformIndexHtml(html) {
const snapshot = readFileSync(snapshotPath, 'utf8')
.replace(/<!--[\s\S]*?-->/g, '') // strip authoring comments from served HTML
.trim()
const block = `<noscript>\n${snapshot}\n</noscript>`
// Place it immediately after the app mount point.
return html.replace(
'<div id="app"></div>',
`<div id="app"></div>\n${block}`,
)
},
}
}

View File

@@ -1,7 +1,8 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import seoSnapshot from './vite-plugin-seo-snapshot.js'
export default defineConfig({
plugins: [vue()],
plugins: [vue(), seoSnapshot()],
server: { port: 5173 }
})