Add SEO infrastructure: sitemap, OG meta, JSON-LD, custom 404

- Integrate @astrojs/sitemap for automatic sitemap generation
- Add robots.txt with sitemap reference
- Add Open Graph and Twitter Card meta tags to Layout
- Add canonical URL and structured data slot to Layout
- Add JSON-LD schema (WebSite, CollectionPage, Book, BreadcrumbList)
- Create custom 404 page with navigation links
- Create default OG image (SVG with graph-paper theme)
- Wire SITE_URL through Docker build args for production builds
- Update Caddyfile for proper 404 handling instead of SPA fallback
This commit is contained in:
Ryan Malloy 2026-02-13 07:32:57 -07:00
parent 015a664893
commit b892189f21
16 changed files with 317 additions and 6 deletions

View File

@ -10,3 +10,6 @@ CADDY_HOST=mims.localhost
# Dev mode: uncomment to mount src for hot reload
# DEV_MOUNT=./src
# Site URL for sitemap generation and OG meta tags
# SITE_URL=https://forrest.warehack.ing

View File

@ -3,7 +3,13 @@
file_server
# Handle clean URLs - try file, then directory, then .html extension
try_files {path} {path}/ {path}.html /index.html
try_files {path} {path}/ {path}.html
# Custom 404 page
handle_errors {
rewrite * /404.html
file_server
}
# Compression
encode gzip

View File

@ -20,6 +20,8 @@ CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
# Build stage for production
FROM base AS builder
WORKDIR /app
ARG SITE_URL=https://forrest.warehack.ing
ENV SITE_URL=${SITE_URL}
COPY package*.json ./
RUN npm ci
COPY . .

View File

@ -2,11 +2,12 @@
import { defineConfig } from 'astro/config';
import react from '@astrojs/react';
import sitemap from '@astrojs/sitemap';
import tailwindcss from '@tailwindcss/vite';
// https://astro.build/config
export default defineConfig({
integrations: [react()],
integrations: [react(), sitemap()],
// Production site URL for correct link generation
site: process.env.SITE_URL || 'https://mims.l.supported.systems',

View File

@ -4,6 +4,8 @@ services:
context: .
dockerfile: Dockerfile
target: ${MODE:-production}
args:
- SITE_URL=${SITE_URL:-https://forrest.warehack.ing}
container_name: mims-library
restart: unless-stopped
environment:

80
site/package-lock.json generated
View File

@ -9,6 +9,7 @@
"version": "0.0.1",
"dependencies": {
"@astrojs/react": "^4.4.2",
"@astrojs/sitemap": "^3.7.0",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-slot": "^1.2.4",
@ -22,6 +23,7 @@
"lucide-react": "^0.562.0",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"schema-dts": "^1.1.5",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18"
},
@ -102,6 +104,17 @@
"react-dom": "^17.0.2 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@astrojs/sitemap": {
"version": "3.7.0",
"resolved": "https://registry.npmjs.org/@astrojs/sitemap/-/sitemap-3.7.0.tgz",
"integrity": "sha512-+qxjUrz6Jcgh+D5VE1gKUJTA3pSthuPHe6Ao5JCxok794Lewx8hBFaWHtOnN0ntb2lfOf7gvOi9TefUswQ/ZVA==",
"license": "MIT",
"dependencies": {
"sitemap": "^8.0.2",
"stream-replace-string": "^2.0.0",
"zod": "^3.25.76"
}
},
"node_modules/@astrojs/telemetry": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/@astrojs/telemetry/-/telemetry-3.3.0.tgz",
@ -2846,6 +2859,15 @@
"@types/unist": "*"
}
},
"node_modules/@types/node": {
"version": "25.2.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.3.tgz",
"integrity": "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==",
"license": "MIT",
"dependencies": {
"undici-types": "~7.16.0"
}
},
"node_modules/@types/react": {
"version": "19.2.7",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
@ -2864,6 +2886,15 @@
"@types/react": "^19.2.0"
}
},
"node_modules/@types/sax": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/@types/sax/-/sax-1.2.7.tgz",
"integrity": "sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/unist": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
@ -3007,6 +3038,12 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/arg": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
"integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==",
"license": "MIT"
},
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
@ -6097,6 +6134,12 @@
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"license": "MIT"
},
"node_modules/schema-dts": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/schema-dts/-/schema-dts-1.1.5.tgz",
"integrity": "sha512-RJr9EaCmsLzBX2NDiO5Z3ux2BVosNZN5jo0gWgsyKvxKIUL5R3swNvoorulAeL9kLB0iTSX7V6aokhla2m7xbg==",
"license": "Apache-2.0"
},
"node_modules/semver": {
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
@ -6176,6 +6219,31 @@
"integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==",
"license": "MIT"
},
"node_modules/sitemap": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/sitemap/-/sitemap-8.0.2.tgz",
"integrity": "sha512-LwktpJcyZDoa0IL6KT++lQ53pbSrx2c9ge41/SeLTyqy2XUNA6uR4+P9u5IVo5lPeL2arAcOKn1aZAxoYbCKlQ==",
"license": "MIT",
"dependencies": {
"@types/node": "^17.0.5",
"@types/sax": "^1.2.1",
"arg": "^5.0.0",
"sax": "^1.4.1"
},
"bin": {
"sitemap": "dist/cli.js"
},
"engines": {
"node": ">=14.0.0",
"npm": ">=6.0.0"
}
},
"node_modules/sitemap/node_modules/@types/node": {
"version": "17.0.45",
"resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.45.tgz",
"integrity": "sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw==",
"license": "MIT"
},
"node_modules/smol-toml": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.0.tgz",
@ -6207,6 +6275,12 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/stream-replace-string": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/stream-replace-string/-/stream-replace-string-2.0.0.tgz",
"integrity": "sha512-TlnjJ1C0QrmxRNrON00JvaFFlNh5TTG00APw23j74ET7gkQpTASi6/L2fuiav8pzK715HXtUeClpBTw2NPSn6w==",
"license": "MIT"
},
"node_modules/string-width": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
@ -6438,6 +6512,12 @@
"integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==",
"license": "MIT"
},
"node_modules/undici-types": {
"version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
"license": "MIT"
},
"node_modules/unified": {
"version": "11.0.5",
"resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",

View File

@ -10,6 +10,7 @@
},
"dependencies": {
"@astrojs/react": "^4.4.2",
"@astrojs/sitemap": "^3.7.0",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-slot": "^1.2.4",
@ -23,6 +24,7 @@
"lucide-react": "^0.562.0",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"schema-dts": "^1.1.5",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18"
},

View File

@ -0,0 +1,26 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630">
<rect width="1200" height="630" fill="#f5f0e8"/>
<!-- Graph paper grid -->
<defs>
<pattern id="grid" width="20" height="20" patternUnits="userSpaceOnUse">
<path d="M 20 0 L 0 0 0 20" fill="none" stroke="#d4cfc5" stroke-width="0.5" opacity="0.5"/>
</pattern>
</defs>
<rect width="1200" height="630" fill="url(#grid)"/>
<!-- Circuit trace decoration -->
<line x1="100" y1="530" x2="400" y2="530" stroke="#4a8c6f" stroke-width="3"/>
<circle cx="100" cy="530" r="6" fill="#4a8c6f"/>
<circle cx="400" cy="530" r="6" fill="#4a8c6f"/>
<line x1="800" y1="100" x2="1100" y2="100" stroke="#4a8c6f" stroke-width="3"/>
<circle cx="800" cy="100" r="6" fill="#4a8c6f"/>
<circle cx="1100" cy="100" r="6" fill="#4a8c6f"/>
<!-- Title -->
<text x="600" y="260" text-anchor="middle" font-family="Georgia, serif" font-size="52" fill="#1e293b" letter-spacing="-1">Electronics Reference Library</text>
<!-- Subtitle -->
<text x="600" y="330" text-anchor="middle" font-family="Georgia, serif" font-size="28" fill="#64748b">Forrest M. Mims III &amp; Classic References</text>
<!-- Tagline -->
<text x="600" y="400" text-anchor="middle" font-family="system-ui, sans-serif" font-size="20" fill="#94a3b8">Hand-drawn circuit notebooks preserved digitally</text>
<!-- Book icon hint -->
<rect x="555" y="440" width="90" height="70" rx="4" fill="none" stroke="#3b5998" stroke-width="2.5"/>
<line x1="600" y1="445" x2="600" y2="505" stroke="#3b5998" stroke-width="1.5"/>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

4
site/public/robots.txt Normal file
View File

@ -0,0 +1,4 @@
User-agent: *
Allow: /
Sitemap: https://forrest.warehack.ing/sitemap-index.xml

View File

@ -8,9 +8,23 @@ import SearchDialog from '@/components/SearchDialog';
interface Props {
title: string;
description?: string;
ogImage?: string;
ogType?: string;
}
const { title, description = "Classic electronics reference notebooks from Forrest M. Mims III" } = Astro.props;
const {
title,
description = "Classic electronics reference notebooks from Forrest M. Mims III",
ogImage,
ogType = 'website'
} = Astro.props;
const siteUrl = import.meta.env.SITE || 'https://forrest.warehack.ing';
const canonicalUrl = new URL(Astro.url.pathname, siteUrl).href;
const resolvedOgImage = ogImage
? new URL(ogImage, siteUrl).href
: new URL('/images/og-default.svg', siteUrl).href;
const fullTitle = `${title} | Electronics Reference Library`;
const allBooks = await getCollection('books');
const allBooksData = allBooks.map(serializeBook);
@ -22,12 +36,26 @@ const allBooksData = allBooks.map(serializeBook);
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content={description} />
<!-- Open Graph -->
<meta property="og:title" content={fullTitle} />
<meta property="og:description" content={description} />
<meta property="og:url" content={canonicalUrl} />
<meta property="og:image" content={resolvedOgImage} />
<meta property="og:site_name" content="Electronics Reference Library" />
<meta property="og:type" content={ogType} />
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={fullTitle} />
<meta name="twitter:description" content={description} />
<meta name="twitter:image" content={resolvedOgImage} />
<!-- Canonical -->
<link rel="canonical" href={canonicalUrl} />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#3b5998" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
<title>{title} | Electronics Reference Library</title>
<title>{fullTitle}</title>
<script is:inline>
(function() {
const theme = localStorage.getItem('theme');
@ -36,6 +64,7 @@ const allBooksData = allBooks.map(serializeBook);
}
})();
</script>
<slot name="structured-data" />
</head>
<body class="min-h-screen graph-paper-large">
<header class="border-b border-border bg-card/80 backdrop-blur-sm sticky top-0 z-50">

61
site/src/pages/404.astro Normal file
View File

@ -0,0 +1,61 @@
---
import Layout from '@/layouts/Layout.astro';
---
<Layout title="Page Not Found" description="The page you're looking for doesn't exist">
<div class="flex flex-col items-center justify-center py-20 text-center space-y-6">
<div class="w-20 h-20 rounded-full bg-muted flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="text-muted-foreground">
<circle cx="12" cy="12" r="10"/>
<path d="m15 9-6 6"/>
<path d="m9 9 6 6"/>
</svg>
</div>
<div class="space-y-2">
<h1 class="text-4xl font-bold title-accent">404</h1>
<p class="text-xl text-muted-foreground">The circuit you're tracing leads nowhere</p>
<p class="text-sm text-muted-foreground max-w-md">
This page doesn't exist. The component may have been desoldered, or the trace was never routed.
</p>
</div>
<div class="flex flex-wrap items-center justify-center gap-4 pt-4">
<a
href="/"
class="inline-flex items-center gap-2 px-6 py-3 bg-primary text-primary-foreground rounded-lg font-medium hover:bg-primary/90 transition-colors"
>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
<polyline points="9 22 9 12 15 12 15 22"/>
</svg>
Back to Home
</a>
<a
href="/mims"
class="inline-flex items-center gap-2 px-6 py-3 border border-border rounded-lg font-medium hover:bg-muted transition-colors"
>
Mims Collection
</a>
<a
href="/uglys"
class="inline-flex items-center gap-2 px-6 py-3 border border-border rounded-lg font-medium hover:bg-muted transition-colors"
>
Ugly's Collection
</a>
</div>
<!-- Circuit decoration -->
<div class="pt-8 opacity-30">
<svg width="200" height="40" viewBox="0 0 200 40" fill="none" xmlns="http://www.w3.org/2000/svg" class="text-muted-foreground">
<line x1="10" y1="20" x2="60" y2="20" stroke="currentColor" stroke-width="2"/>
<circle cx="10" cy="20" r="4" fill="currentColor"/>
<circle cx="80" cy="20" r="8" stroke="currentColor" stroke-width="2" fill="none"/>
<line x1="88" y1="20" x2="112" y2="20" stroke="currentColor" stroke-width="2" stroke-dasharray="4 4"/>
<circle cx="120" cy="20" r="8" stroke="currentColor" stroke-width="2" fill="none"/>
<line x1="128" y1="20" x2="190" y2="20" stroke="currentColor" stroke-width="2"/>
<circle cx="190" cy="20" r="4" fill="currentColor"/>
</svg>
</div>
</div>
</Layout>

View File

@ -2,6 +2,7 @@
import Layout from '@/layouts/Layout.astro';
import BookGrid from '@/components/BookGrid.astro';
import { getCollection } from 'astro:content';
import type { WithContext, WebSite } from 'schema-dts';
const allBooks = await getCollection('books');
@ -15,9 +16,21 @@ const uglysBooks = allBooks
const featuredMimsBooks = mimsBooks.slice(0, 4);
const totalBooks = allBooks.length;
const siteUrl = import.meta.env.SITE || 'https://forrest.warehack.ing';
const jsonLd: WithContext<WebSite> = {
'@context': 'https://schema.org',
'@type': 'WebSite',
name: 'Electronics Reference Library',
description: 'Classic electronics and electrical reference notebooks from Forrest M. Mims III, preserved digitally.',
url: siteUrl,
};
---
<Layout title="Home">
<Fragment slot="structured-data">
<script type="application/ld+json" set:html={JSON.stringify(jsonLd)} />
</Fragment>
<!-- Hero Section -->
<section class="text-center py-12 space-y-6">
<div class="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-accent/20 text-accent-foreground text-sm font-medium">

View File

@ -5,6 +5,7 @@ import EBookReader from '@/components/EBookReader';
import { getCollection, type CollectionEntry } from 'astro:content';
import { getRelatedBooks } from '@/lib/related-books';
import RelatedBooks from '@/components/RelatedBooks.astro';
import type { WithContext, Book, BreadcrumbList } from 'schema-dts';
export async function getStaticPaths() {
const books = await getCollection('books');
@ -33,9 +34,35 @@ const currentIndex = mimsBooks.findIndex(b => b.slug === book.slug);
const prevBook = currentIndex > 0 ? mimsBooks[currentIndex - 1] : null;
const nextBook = currentIndex < mimsBooks.length - 1 ? mimsBooks[currentIndex + 1] : null;
const relatedBooks = getRelatedBooks(book, allBooks);
const siteUrl = import.meta.env.SITE || 'https://forrest.warehack.ing';
const bookSlug = book.slug.split('/').pop();
const bookJsonLd: WithContext<Book> = {
'@context': 'https://schema.org',
'@type': 'Book',
name: title,
description: description,
author: { '@type': 'Person', name: 'Forrest M. Mims III' },
url: `${siteUrl}/mims/${bookSlug}`,
...(coverImage && { image: new URL(coverImage, siteUrl).href }),
...(year && { datePublished: String(year) }),
};
const breadcrumbJsonLd: WithContext<BreadcrumbList> = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{ '@type': 'ListItem', position: 1, name: 'Home', item: siteUrl },
{ '@type': 'ListItem', position: 2, name: 'Mims Collection', item: `${siteUrl}/mims` },
{ '@type': 'ListItem', position: 3, name: shortTitle, item: `${siteUrl}/mims/${bookSlug}` },
],
};
---
<Layout title={shortTitle} description={description}>
<Layout title={shortTitle} description={description} ogImage={coverImage} ogType="book">
<Fragment slot="structured-data">
<script type="application/ld+json" set:html={JSON.stringify(bookJsonLd)} />
<script type="application/ld+json" set:html={JSON.stringify(breadcrumbJsonLd)} />
</Fragment>
<div class="space-y-8">
<!-- Breadcrumb -->
<div class="flex items-center gap-2 text-sm text-muted-foreground">

View File

@ -3,6 +3,7 @@ import Layout from '@/layouts/Layout.astro';
import FilterableBookGrid from '@/components/FilterableBookGrid';
import { getCollection } from 'astro:content';
import { serializeBook } from '@/lib/types';
import type { WithContext, CollectionPage } from 'schema-dts';
const allBooks = await getCollection('books');
const mimsBooks = allBooks
@ -12,12 +13,25 @@ const mimsBooks = allBooks
// Get unique topics for filtering
const allTopics = [...new Set(mimsBooks.flatMap(book => book.data.topics))].sort();
const serializedBooks = mimsBooks.map(serializeBook);
const siteUrl = import.meta.env.SITE || 'https://forrest.warehack.ing';
const jsonLd: WithContext<CollectionPage> = {
'@context': 'https://schema.org',
'@type': 'CollectionPage',
name: 'Forrest Mims Mini-Notebooks',
description: "The complete collection of Forrest M. Mims III's Radio Shack Engineer's Mini-Notebooks",
url: `${siteUrl}/mims`,
numberOfItems: mimsBooks.length,
};
---
<Layout
title="Mims Mini-Notebooks"
description="The complete collection of Forrest M. Mims III's Radio Shack Engineer's Mini-Notebooks"
>
<Fragment slot="structured-data">
<script type="application/ld+json" set:html={JSON.stringify(jsonLd)} />
</Fragment>
<div class="space-y-8">
<!-- Header -->
<div class="space-y-4">

View File

@ -5,6 +5,7 @@ import EBookReader from '@/components/EBookReader';
import { getCollection, type CollectionEntry } from 'astro:content';
import { getRelatedBooks } from '@/lib/related-books';
import RelatedBooks from '@/components/RelatedBooks.astro';
import type { WithContext, Book, BreadcrumbList } from 'schema-dts';
export async function getStaticPaths() {
const books = await getCollection('books');
@ -33,9 +34,35 @@ const currentIndex = uglysBooks.findIndex(b => b.slug === book.slug);
const prevBook = currentIndex > 0 ? uglysBooks[currentIndex - 1] : null;
const nextBook = currentIndex < uglysBooks.length - 1 ? uglysBooks[currentIndex + 1] : null;
const relatedBooks = getRelatedBooks(book, allBooks);
const siteUrl = import.meta.env.SITE || 'https://forrest.warehack.ing';
const bookSlug = book.slug.split('/').pop();
const bookJsonLd: WithContext<Book> = {
'@context': 'https://schema.org',
'@type': 'Book',
name: title,
description: description,
author: { '@type': 'Person', name: "George V. Hart" },
url: `${siteUrl}/uglys/${bookSlug}`,
...(coverImage && { image: new URL(coverImage, siteUrl).href }),
...(year && { datePublished: String(year) }),
};
const breadcrumbJsonLd: WithContext<BreadcrumbList> = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{ '@type': 'ListItem', position: 1, name: 'Home', item: siteUrl },
{ '@type': 'ListItem', position: 2, name: "Ugly's Collection", item: `${siteUrl}/uglys` },
{ '@type': 'ListItem', position: 3, name: shortTitle, item: `${siteUrl}/uglys/${bookSlug}` },
],
};
---
<Layout title={shortTitle} description={description}>
<Layout title={shortTitle} description={description} ogImage={coverImage} ogType="book">
<Fragment slot="structured-data">
<script type="application/ld+json" set:html={JSON.stringify(bookJsonLd)} />
<script type="application/ld+json" set:html={JSON.stringify(breadcrumbJsonLd)} />
</Fragment>
<div class="space-y-8">
<!-- Breadcrumb -->
<div class="flex items-center gap-2 text-sm text-muted-foreground">

View File

@ -3,6 +3,7 @@ import Layout from '@/layouts/Layout.astro';
import FilterableBookGrid from '@/components/FilterableBookGrid';
import { getCollection } from 'astro:content';
import { serializeBook } from '@/lib/types';
import type { WithContext, CollectionPage } from 'schema-dts';
const allBooks = await getCollection('books');
const uglysBooks = allBooks
@ -12,12 +13,25 @@ const uglysBooks = allBooks
// Get unique topics for filtering
const allTopics = [...new Set(uglysBooks.flatMap(book => book.data.topics))].sort();
const serializedBooks = uglysBooks.map(serializeBook);
const siteUrl = import.meta.env.SITE || 'https://forrest.warehack.ing';
const jsonLd: WithContext<CollectionPage> = {
'@context': 'https://schema.org',
'@type': 'CollectionPage',
name: "Ugly's Electrical References",
description: 'The essential pocket reference for electricians - tables, formulas, and NEC code references',
url: `${siteUrl}/uglys`,
numberOfItems: uglysBooks.length,
};
---
<Layout
title="Ugly's Electrical References"
description="The essential pocket reference for electricians - packed with tables, formulas, and NEC code references"
>
<Fragment slot="structured-data">
<script type="application/ld+json" set:html={JSON.stringify(jsonLd)} />
</Fragment>
<div class="space-y-8">
<!-- Header -->
<div class="space-y-4">