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 { defineConfig } from 'astro/config';
|
||||||
import react from '@astrojs/react';
|
import react from '@astrojs/react';
|
||||||
import node from '@astrojs/node';
|
import node from '@astrojs/node';
|
||||||
|
import icon from 'astro-icon';
|
||||||
import tailwindcss from '@tailwindcss/vite';
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
output: 'server',
|
output: 'server',
|
||||||
adapter: node({ mode: 'standalone' }),
|
adapter: node({ mode: 'standalone' }),
|
||||||
integrations: [react()],
|
integrations: [
|
||||||
|
react(),
|
||||||
|
icon({ include: { lucide: ['plus', 'book-open', 'zap', 'cpu'] } }),
|
||||||
|
],
|
||||||
telemetry: false,
|
telemetry: false,
|
||||||
devToolbar: { enabled: false },
|
devToolbar: { enabled: false },
|
||||||
vite: {
|
vite: {
|
||||||
plugins: [tailwindcss()],
|
plugins: [tailwindcss()],
|
||||||
|
ssr: { external: ['@resvg/resvg-js'] },
|
||||||
build: {
|
build: {
|
||||||
// CodeMirror + uPlot + React naturally exceeds 500kB minified
|
// CodeMirror + uPlot + React naturally exceeds 500kB minified
|
||||||
chunkSizeWarningLimit: 700,
|
chunkSizeWarningLimit: 700,
|
||||||
|
rollupOptions: { external: ['@resvg/resvg-js'] },
|
||||||
},
|
},
|
||||||
|
optimizeDeps: { exclude: ['@resvg/resvg-js'] },
|
||||||
server: {
|
server: {
|
||||||
host: '0.0.0.0',
|
host: '0.0.0.0',
|
||||||
...(process.env.VITE_HMR_HOST && {
|
...(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/language": "^6.10.0",
|
||||||
"@codemirror/state": "^6.5.0",
|
"@codemirror/state": "^6.5.0",
|
||||||
"@codemirror/view": "^6.35.0",
|
"@codemirror/view": "^6.35.0",
|
||||||
|
"@iconify-json/lucide": "^1.2.90",
|
||||||
"@lezer/highlight": "^1.2.0",
|
"@lezer/highlight": "^1.2.0",
|
||||||
"@lezer/lr": "^1.4.0",
|
"@lezer/lr": "^1.4.0",
|
||||||
|
"@resvg/resvg-js": "^2.6.2",
|
||||||
"astro": "^5.0.0",
|
"astro": "^5.0.0",
|
||||||
|
"astro-icon": "^1.1.5",
|
||||||
|
"astro-seo-meta": "^5.2.0",
|
||||||
"clsx": "^2.1.0",
|
"clsx": "^2.1.0",
|
||||||
"dompurify": "^3.3.1",
|
"dompurify": "^3.3.1",
|
||||||
"lucide-react": "^0.468.0",
|
"lucide-react": "^0.468.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
"satori": "^0.19.2",
|
||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^2.6.0",
|
||||||
"uplot": "^1.6.31",
|
"uplot": "^1.6.31",
|
||||||
"zustand": "^5.0.0"
|
"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 {
|
interface ImportMetaEnv {
|
||||||
readonly PUBLIC_API_URL: string;
|
readonly PUBLIC_API_URL: string;
|
||||||
|
readonly BACKEND_INTERNAL_URL: string;
|
||||||
|
readonly SITE: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ImportMeta {
|
interface ImportMeta {
|
||||||
|
|||||||
@ -13,6 +13,7 @@ const themeClass = theme === 'dark' ? 'dark' : 'light';
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="robots" content="noindex, nofollow" />
|
||||||
<title>{title}</title>
|
<title>{title}</title>
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-sb-bg text-sb-text min-h-screen antialiased overflow-x-hidden">
|
<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;
|
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>
|
<!doctype html>
|
||||||
@ -11,9 +29,25 @@ const { title = 'SpiceBook' } = Astro.props;
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<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" />
|
<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>
|
</head>
|
||||||
<body class="bg-slate-950 text-slate-200 min-h-screen antialiased">
|
<body class="bg-slate-950 text-slate-200 min-h-screen antialiased">
|
||||||
<slot />
|
<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 NotebookLayout from '../layouts/NotebookLayout.astro';
|
||||||
import NotebookList from '../components/NotebookList';
|
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">
|
<div class="max-w-5xl mx-auto px-6 py-10">
|
||||||
<!-- Header (static) -->
|
<!-- Header (static) -->
|
||||||
<header class="mb-10">
|
<header class="mb-10">
|
||||||
@ -16,7 +21,7 @@ import NotebookList from '../components/NotebookList';
|
|||||||
href="/notebook/new"
|
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"
|
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
|
New Notebook
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,10 +1,28 @@
|
|||||||
---
|
---
|
||||||
import NotebookLayout from '../../layouts/NotebookLayout.astro';
|
import NotebookLayout from '../../layouts/NotebookLayout.astro';
|
||||||
import NotebookEditor from '../../components/notebook/NotebookEditor';
|
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;
|
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">
|
<NotebookLayout
|
||||||
<NotebookEditor notebookId={id!} client:load />
|
title={title}
|
||||||
|
description={description}
|
||||||
|
ogImage={`/og/${id}.png`}
|
||||||
|
ogType="article"
|
||||||
|
canonicalPath={`/notebook/${id}`}
|
||||||
|
>
|
||||||
|
<NotebookEditor notebookId={id} client:load />
|
||||||
</NotebookLayout>
|
</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