Compare commits

...

2 Commits

Author SHA1 Message Date
ea66086b44 Remove named volume from prod compose, use bind mount for notebooks
The base docker-compose.yml already mounts ./notebooks:/app/notebooks.
The named Docker volume was shadowing it in prod, preventing example
notebooks from being visible. Bind mount ensures examples ship with
the repo and user notebooks persist on the host filesystem.
2026-02-14 13:16:18 -07:00
c9116c5d86 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.
2026-02-14 13:15:52 -07:00
16 changed files with 2852 additions and 14 deletions

View File

@ -4,8 +4,6 @@ services:
context: ./backend
dockerfile: Dockerfile
target: prod
volumes:
- notebooks:/app/notebooks
expose:
- "8000"
networks:
@ -30,9 +28,6 @@ services:
caddy: "${SPICEBOOK_DOMAIN:-spicebook.localhost}"
caddy.reverse_proxy_1: "{{upstreams 4321}}"
volumes:
notebooks:
networks:
caddy:
external: true

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 });
}
};