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:
parent
99e47685aa
commit
c9116c5d86
@ -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 && {
|
||||
|
||||
1016
frontend/package-lock.json
generated
1016
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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"
|
||||
|
||||
1451
frontend/public/fonts/Inter-SemiBold.ttf
Normal file
1451
frontend/public/fonts/Inter-SemiBold.ttf
Normal file
File diff suppressed because one or more lines are too long
2
frontend/src/env.d.ts
vendored
2
frontend/src/env.d.ts
vendored
@ -2,6 +2,8 @@
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly PUBLIC_API_URL: string;
|
||||
readonly BACKEND_INTERNAL_URL: string;
|
||||
readonly SITE: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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 />
|
||||
|
||||
12
frontend/src/lib/notebook-meta.ts
Normal file
12
frontend/src/lib/notebook-meta.ts
Normal 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(', ')}` : '')
|
||||
);
|
||||
}
|
||||
198
frontend/src/lib/og-renderer.tsx
Normal file
198
frontend/src/lib/og-renderer.tsx
Normal 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
15
frontend/src/lib/seo.ts
Normal 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;
|
||||
28
frontend/src/lib/server-api.ts
Normal file
28
frontend/src/lib/server-api.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
31
frontend/src/pages/og/[id].png.ts
Normal file
31
frontend/src/pages/og/[id].png.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
20
frontend/src/pages/og/default.png.ts
Normal file
20
frontend/src/pages/og/default.png.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
Loading…
x
Reference in New Issue
Block a user