Add SEO meta tags, OG image generation, and astro-icon integration

Wire astro-seo-meta for OG, Twitter Card, and canonical tags on all
pages. Add Satori + resvg dynamic OG image endpoints at /og/[id].png
with branded dark-theme cards. Replace inline SVGs with zero-JS
astro-icon rendering. SSR fetches use 5s AbortController timeout and
shared ID validation across all dynamic routes.
This commit is contained in:
Ryan Malloy 2026-02-14 13:15:52 -07:00
parent 99e47685aa
commit c9116c5d86
15 changed files with 2852 additions and 9 deletions

View File

@ -1,20 +1,27 @@
import { defineConfig } from 'astro/config';
import react from '@astrojs/react';
import node from '@astrojs/node';
import icon from 'astro-icon';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
output: 'server',
adapter: node({ mode: 'standalone' }),
integrations: [react()],
integrations: [
react(),
icon({ include: { lucide: ['plus', 'book-open', 'zap', 'cpu'] } }),
],
telemetry: false,
devToolbar: { enabled: false },
vite: {
plugins: [tailwindcss()],
ssr: { external: ['@resvg/resvg-js'] },
build: {
// CodeMirror + uPlot + React naturally exceeds 500kB minified
chunkSizeWarningLimit: 700,
rollupOptions: { external: ['@resvg/resvg-js'] },
},
optimizeDeps: { exclude: ['@resvg/resvg-js'] },
server: {
host: '0.0.0.0',
...(process.env.VITE_HMR_HOST && {

File diff suppressed because it is too large Load Diff

View File

@ -19,14 +19,19 @@
"@codemirror/language": "^6.10.0",
"@codemirror/state": "^6.5.0",
"@codemirror/view": "^6.35.0",
"@iconify-json/lucide": "^1.2.90",
"@lezer/highlight": "^1.2.0",
"@lezer/lr": "^1.4.0",
"@resvg/resvg-js": "^2.6.2",
"astro": "^5.0.0",
"astro-icon": "^1.1.5",
"astro-seo-meta": "^5.2.0",
"clsx": "^2.1.0",
"dompurify": "^3.3.1",
"lucide-react": "^0.468.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"satori": "^0.19.2",
"tailwind-merge": "^2.6.0",
"uplot": "^1.6.31",
"zustand": "^5.0.0"

File diff suppressed because one or more lines are too long

View File

@ -2,6 +2,8 @@
interface ImportMetaEnv {
readonly PUBLIC_API_URL: string;
readonly BACKEND_INTERNAL_URL: string;
readonly SITE: string;
}
interface ImportMeta {

View File

@ -13,6 +13,7 @@ const themeClass = theme === 'dark' ? 'dark' : 'light';
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="robots" content="noindex, nofollow" />
<title>{title}</title>
</head>
<body class="bg-sb-bg text-sb-text min-h-screen antialiased overflow-x-hidden">

View File

@ -1,9 +1,27 @@
---
interface Props {
import { Seo } from 'astro-seo-meta';
import type { SEOProps } from '../lib/seo';
import { SEO_DEFAULTS } from '../lib/seo';
interface Props extends SEOProps {
title?: string;
}
const { title = 'SpiceBook' } = Astro.props;
const {
title = 'SpiceBook',
description,
ogImage,
ogType = 'website',
noindex = false,
canonicalPath,
} = Astro.props;
const pageTitle = title === 'SpiceBook' ? title : `${title} | SpiceBook`;
const pageDescription = description || SEO_DEFAULTS.defaultDescription;
const ogImageUrl = `${SEO_DEFAULTS.siteUrl}${ogImage || SEO_DEFAULTS.defaultOgImage}`;
const canonicalUrl = canonicalPath
? `${SEO_DEFAULTS.siteUrl}${canonicalPath}`
: undefined;
---
<!doctype html>
@ -11,9 +29,25 @@ const { title = 'SpiceBook' } = Astro.props;
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="SpiceBook — Notebook interface for SPICE circuit simulation" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>{title}</title>
{canonicalUrl && <link rel="canonical" href={canonicalUrl} />}
<Seo
title={pageTitle}
description={pageDescription}
icon="/favicon.svg"
colorScheme="dark"
robots={noindex ? 'noindex, nofollow' : 'index, follow'}
facebook={{
image: ogImageUrl,
url: canonicalUrl || SEO_DEFAULTS.siteUrl,
type: ogType,
}}
twitter={{
image: ogImageUrl,
card: 'summary_large_image',
}}
/>
<meta property="og:site_name" content={SEO_DEFAULTS.siteName} />
</head>
<body class="bg-slate-950 text-slate-200 min-h-screen antialiased">
<slot />

View File

@ -0,0 +1,12 @@
export const NOTEBOOK_ID_RE = /^[a-zA-Z0-9_\-]+$/;
export function buildNotebookDescription(
title: string,
engine: string,
tags: string[]
): string {
return (
`${title}${engine} simulation` +
(tags.length ? `. Tags: ${tags.join(', ')}` : '')
);
}

View File

@ -0,0 +1,198 @@
import satori from 'satori';
import { Resvg } from '@resvg/resvg-js';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
interface OgImageProps {
title: string;
description?: string;
engine?: string;
}
function loadFont(): ArrayBuffer {
// In prod: dist/client/fonts/ In dev: public/fonts/
const candidates = [
join(process.cwd(), 'dist', 'client', 'fonts', 'Inter-SemiBold.ttf'),
join(process.cwd(), 'public', 'fonts', 'Inter-SemiBold.ttf'),
];
for (const path of candidates) {
try {
return readFileSync(path).buffer as ArrayBuffer;
} catch {
// try next
}
}
throw new Error('Inter-SemiBold.ttf not found');
}
let fontData: ArrayBuffer | null = null;
function getFont(): ArrayBuffer {
if (!fontData) {
try {
fontData = loadFont();
} catch (err) {
console.error('[og-renderer] Font load failed:', err);
throw err;
}
}
return fontData;
}
// Small waveform logo — a sine-like path rendered as SVG text
function WaveformLogo() {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<svg
width="40"
height="40"
viewBox="0 0 40 40"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect width="40" height="40" rx="8" fill="#1e3a5f" />
<path
d="M6 20 Q10 8, 14 20 Q18 32, 22 20 Q26 8, 30 20 Q34 32, 38 20"
stroke="#60a5fa"
strokeWidth="2.5"
fill="none"
/>
</svg>
<span style={{ fontSize: '24px', color: '#94a3b8', fontWeight: 600 }}>
SpiceBook
</span>
</div>
);
}
export async function renderOgImage({
title,
description,
engine,
}: OgImageProps): Promise<Uint8Array> {
const svg = await satori(
<div
style={{
width: '1200px',
height: '630px',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
padding: '60px',
backgroundColor: '#020617', // slate-950
fontFamily: 'Inter',
}}
>
{/* Top: branding */}
<WaveformLogo />
{/* Center: title + description */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
<div
style={{
fontSize: title.length > 40 ? '42px' : '52px',
fontWeight: 600,
color: '#f1f5f9', // slate-100
lineHeight: 1.2,
maxWidth: '1000px',
}}
>
{title}
</div>
{description && (
<div
style={{
fontSize: '24px',
color: '#94a3b8', // slate-400
lineHeight: 1.4,
maxWidth: '900px',
}}
>
{description.length > 120
? description.slice(0, 117) + '...'
: description}
</div>
)}
</div>
{/* Bottom: engine badge + accent bar */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
{engine ? (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
fontSize: '18px',
color: '#64748b', // slate-500
}}
>
<div
style={{
display: 'flex',
padding: '6px 14px',
borderRadius: '6px',
backgroundColor: '#0f172a', // slate-900
border: '1px solid #1e293b', // slate-800
color: '#60a5fa', // blue-400
fontSize: '16px',
fontWeight: 600,
}}
>
{engine}
</div>
<span>circuit simulation</span>
</div>
) : (
<div style={{ display: 'flex' }} />
)}
<div
style={{
display: 'flex',
fontSize: '16px',
color: '#475569', // slate-600
}}
>
spicebook.warehack.ing
</div>
</div>
{/* Accent bar at very bottom */}
<div
style={{
display: 'flex',
position: 'absolute',
bottom: '0',
left: '0',
right: '0',
height: '4px',
background: 'linear-gradient(90deg, #2563eb, #60a5fa, #2563eb)',
}}
/>
</div>,
{
width: 1200,
height: 630,
fonts: [
{
name: 'Inter',
data: getFont(),
weight: 600,
style: 'normal',
},
],
}
);
const resvg = new Resvg(svg, {
fitTo: { mode: 'width', value: 1200 },
});
const pngBuffer = resvg.render().asPng();
return new Uint8Array(pngBuffer);
}

15
frontend/src/lib/seo.ts Normal file
View File

@ -0,0 +1,15 @@
export interface SEOProps {
title?: string;
description?: string;
ogImage?: string;
ogType?: 'website' | 'article';
noindex?: boolean;
canonicalPath?: string;
}
export const SEO_DEFAULTS = {
siteName: 'SpiceBook',
defaultDescription: 'Notebook interface for SPICE circuit simulation',
defaultOgImage: '/og/default.png',
siteUrl: import.meta.env.SITE || 'https://spicebook.warehack.ing',
} as const;

View File

@ -0,0 +1,28 @@
export const INTERNAL_API_BASE =
import.meta.env.BACKEND_INTERNAL_URL || 'http://localhost:8099';
export async function fetchNotebookMeta(id: string) {
const url = `${INTERNAL_API_BASE}/api/notebooks/${encodeURIComponent(id)}`;
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
const res = await fetch(url, { signal: controller.signal });
clearTimeout(timeout);
if (!res.ok) {
console.warn(`[server-api] fetchNotebookMeta(${id}): HTTP ${res.status}`);
return null;
}
return await res.json();
} catch (err) {
if (err instanceof Error && err.name === 'AbortError') {
console.error(`[server-api] fetchNotebookMeta(${id}): timed out after 5s`);
} else {
console.error(
`[server-api] fetchNotebookMeta(${id}):`,
err instanceof Error ? err.message : err
);
}
return null;
}
}

View File

@ -1,9 +1,14 @@
---
import { Icon } from 'astro-icon/components';
import NotebookLayout from '../layouts/NotebookLayout.astro';
import NotebookList from '../components/NotebookList';
---
<NotebookLayout title="SpiceBook">
<NotebookLayout
title="SpiceBook"
description="Notebook interface for SPICE circuit simulation"
canonicalPath="/"
>
<div class="max-w-5xl mx-auto px-6 py-10">
<!-- Header (static) -->
<header class="mb-10">
@ -16,7 +21,7 @@ import NotebookList from '../components/NotebookList';
href="/notebook/new"
class="inline-flex items-center gap-2 px-4 py-2.5 rounded-lg bg-blue-600 hover:bg-blue-500 text-white font-medium transition-colors text-sm"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg>
<Icon name="lucide:plus" class="w-4 h-4" />
New Notebook
</a>
</div>

View File

@ -1,10 +1,28 @@
---
import NotebookLayout from '../../layouts/NotebookLayout.astro';
import NotebookEditor from '../../components/notebook/NotebookEditor';
import { fetchNotebookMeta } from '../../lib/server-api';
import { NOTEBOOK_ID_RE, buildNotebookDescription } from '../../lib/notebook-meta';
const { id } = Astro.params;
if (!id || !NOTEBOOK_ID_RE.test(id)) {
return new Response('Invalid notebook ID', { status: 400 });
}
const notebook = await fetchNotebookMeta(id);
const title = notebook?.metadata?.title || 'Notebook';
const engine = notebook?.metadata?.engine || 'ngspice';
const tags: string[] = notebook?.metadata?.tags || [];
const description = buildNotebookDescription(title, engine, tags);
---
<NotebookLayout title="SpiceBook">
<NotebookEditor notebookId={id!} client:load />
<NotebookLayout
title={title}
description={description}
ogImage={`/og/${id}.png`}
ogType="article"
canonicalPath={`/notebook/${id}`}
>
<NotebookEditor notebookId={id} client:load />
</NotebookLayout>

View File

@ -0,0 +1,31 @@
import type { APIRoute } from 'astro';
import { renderOgImage } from '../../lib/og-renderer';
import { fetchNotebookMeta } from '../../lib/server-api';
import { NOTEBOOK_ID_RE, buildNotebookDescription } from '../../lib/notebook-meta';
export const GET: APIRoute = async ({ params }) => {
const { id } = params;
if (!id || !NOTEBOOK_ID_RE.test(id)) {
return new Response('Invalid notebook ID', { status: 400 });
}
const notebook = await fetchNotebookMeta(id);
const title = notebook?.metadata?.title || id;
const engine = notebook?.metadata?.engine || 'ngspice';
const tags: string[] = notebook?.metadata?.tags || [];
const description = buildNotebookDescription(title, engine, tags);
try {
const png = await renderOgImage({ title, description, engine });
return new Response(png.buffer as ArrayBuffer, {
headers: {
'Content-Type': 'image/png',
'Cache-Control': 'public, max-age=3600, s-maxage=86400',
},
});
} catch (err) {
console.error(`[og/${id}] render failed:`, err);
return new Response('OG image generation failed', { status: 500 });
}
};

View File

@ -0,0 +1,20 @@
import type { APIRoute } from 'astro';
import { renderOgImage } from '../../lib/og-renderer';
export const GET: APIRoute = async () => {
try {
const png = await renderOgImage({
title: 'SpiceBook',
description: 'Notebook interface for SPICE circuit simulation',
});
return new Response(png.buffer as ArrayBuffer, {
headers: {
'Content-Type': 'image/png',
'Cache-Control': 'public, max-age=86400, s-maxage=604800',
},
});
} catch (err) {
console.error('[og/default] render failed:', err);
return new Response('OG image generation failed', { status: 500 });
}
};