diff --git a/scripts/sync-knowledge.ts b/scripts/sync-knowledge.ts index 5c8e3fb..d09a624 100644 --- a/scripts/sync-knowledge.ts +++ b/scripts/sync-knowledge.ts @@ -5,7 +5,7 @@ */ import { execSync } from 'node:child_process'; -import { cpSync, existsSync, mkdirSync, readdirSync, rmSync, statSync } from 'node:fs'; +import { cpSync, existsSync, mkdirSync, readdirSync, rmSync, statSync, utimesSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -32,7 +32,27 @@ function error(message: string) { * Recursively copy YAML files from source to destination, * preserving subdirectory structure (e.g., equipment/, species/, origin/) */ -function copyYamlFilesRecursive(sourceDir: string, destDir: string, relativePath: string = ''): number { +/** + * Stamp dest with the source file's last git-commit timestamp so the build can + * surface a meaningful "Last updated" date instead of the sync wall-clock time. + * Falls back silently if the file is not tracked or git isn't available. + */ +function stampGitMtime(sourcePath: string, destPath: string, repoRoot: string): void { + try { + const isoStamp = execSync(`git log -1 --format=%cI -- "${sourcePath}"`, { + cwd: repoRoot, + encoding: 'utf-8', + }).trim(); + if (!isoStamp) return; + const ts = new Date(isoStamp); + if (Number.isNaN(ts.getTime())) return; + utimesSync(destPath, ts, ts); + } catch { + // ignore — leaves cpSync's default mtime in place + } +} + +function copyYamlFilesRecursive(sourceDir: string, destDir: string, relativePath: string = '', repoRoot?: string): number { let count = 0; const currentSource = join(sourceDir, relativePath); const currentDest = join(destDir, relativePath); @@ -50,13 +70,16 @@ function copyYamlFilesRecursive(sourceDir: string, destDir: string, relativePath if (stat.isDirectory()) { // Recurse into subdirectory - count += copyYamlFilesRecursive(sourceDir, destDir, join(relativePath, entry)); + count += copyYamlFilesRecursive(sourceDir, destDir, join(relativePath, entry), repoRoot); } else if (entry.endsWith('.yaml') || entry.endsWith('.yml')) { // Copy YAML file if (!existsSync(currentDest)) { mkdirSync(currentDest, { recursive: true }); } cpSync(sourcePath, destPath); + if (repoRoot) { + stampGitMtime(sourcePath, destPath, repoRoot); + } const displayPath = relativePath ? `${relativePath}/${entry}` : entry; log(` Copied: ${displayPath}`); count++; @@ -82,7 +105,7 @@ function syncFromLocal(): boolean { } // Copy YAML files recursively (handles flat files and subdirectories) - const fileCount = copyYamlFilesRecursive(localSource, CONTENT_DEST); + const fileCount = copyYamlFilesRecursive(localSource, CONTENT_DEST, '', LOCAL_KNOWLEDGE_DIR); if (fileCount === 0) { log('No YAML files found in local source'); @@ -129,7 +152,7 @@ function syncFromGit(): boolean { } // Copy YAML files recursively (handles flat files and subdirectories) - const fileCount = copyYamlFilesRecursive(CONTENT_SOURCE, CONTENT_DEST); + const fileCount = copyYamlFilesRecursive(CONTENT_SOURCE, CONTENT_DEST, '', KNOWLEDGE_DIR); if (fileCount === 0) { log('No YAML files found in source'); diff --git a/src/components/gallery/Gallery.tsx b/src/components/gallery/Gallery.tsx index f86cf58..758f307 100644 --- a/src/components/gallery/Gallery.tsx +++ b/src/components/gallery/Gallery.tsx @@ -5,16 +5,34 @@ import { Pagination } from '../ui/Pagination'; import { useFamilies } from '../../hooks/useFamilies'; import { useFilters } from '../../hooks/useCrystalDB'; import { usePagination } from '../../hooks/usePagination'; +import type { MineralFamily } from '../../lib/db'; interface GalleryProps { initialSystem?: string; initialSearch?: string; initialOrigin?: string; + /** SSG-fetched families — when present, the wasm DB only loads on filter/search. */ + initialFamilies?: MineralFamily[]; + initialSystems?: string[]; + initialOrigins?: string[]; + initialGroups?: string[]; } -export function Gallery({ initialSystem = '', initialSearch = '', initialOrigin = '' }: GalleryProps) { - const { families, loading, error, search, filterBySystem, filterByOrigin, filterByGroup } = useFamilies(); - const { systems, origins, mineralGroups, loading: filtersLoading } = useFilters(); +export function Gallery({ + initialSystem = '', + initialSearch = '', + initialOrigin = '', + initialFamilies, + initialSystems, + initialOrigins, + initialGroups, +}: GalleryProps) { + const { families, loading, error, search, filterBySystem, filterByOrigin, filterByGroup } = useFamilies(initialFamilies); + const { systems, origins, mineralGroups, loading: filtersLoading } = useFilters({ + systems: initialSystems, + origins: initialOrigins, + mineralGroups: initialGroups, + }); const [searchQuery, setSearchQuery] = useState(initialSearch); const [selectedSystem, setSelectedSystem] = useState(initialSystem || null); const [selectedOrigin, setSelectedOrigin] = useState(initialOrigin || null); diff --git a/src/components/playground/CDLPreview.tsx b/src/components/playground/CDLPreview.tsx index 0e07dc3..ff29868 100644 --- a/src/components/playground/CDLPreview.tsx +++ b/src/components/playground/CDLPreview.tsx @@ -17,9 +17,11 @@ interface CDLPreviewProps { loading?: boolean; error?: string | null; className?: string; + /** Re-run the render request — surfaced as a Retry button in the error state. */ + onRetry?: () => void; } -export function CDLPreview({ svgContent, loading, error, className }: CDLPreviewProps) { +export function CDLPreview({ svgContent, loading, error, className, onRetry }: CDLPreviewProps) { return (
-

{error}

+

{error}

+ {onRetry && ( + + )}
)} diff --git a/src/components/playground/Playground.tsx b/src/components/playground/Playground.tsx index dd0a008..fa972c5 100644 --- a/src/components/playground/Playground.tsx +++ b/src/components/playground/Playground.tsx @@ -269,6 +269,7 @@ export function Playground({ initialCDL }: PlaygroundProps) { loading={previewLoading} error={previewError} className="h-full" + onRetry={() => generatePreview(cdlCode)} /> ) : (
diff --git a/src/components/seo/LearnSchema.astro b/src/components/seo/LearnSchema.astro index 75c18cb..1dddf00 100644 --- a/src/components/seo/LearnSchema.astro +++ b/src/components/seo/LearnSchema.astro @@ -11,9 +11,13 @@ interface Props { subcategory?: string; difficulty?: string; slug: string; + /** ISO 8601 datetime — when the article was first published. */ + datePublished?: string; + /** ISO 8601 datetime — when the article was last meaningfully updated. */ + dateModified?: string; } -const { title, description, category, subcategory, difficulty, slug } = Astro.props; +const { title, description, category, subcategory, difficulty, slug, datePublished, dateModified } = Astro.props; const url = `https://gemmology.dev/learn/${slug}`; // Helper to format category names @@ -57,7 +61,9 @@ const articleSchema = { "about": { "@type": "Thing", "name": formatName(category) - } + }, + ...(datePublished ? { "datePublished": datePublished } : {}), + ...(dateModified ? { "dateModified": dateModified } : {}), }; // Build breadcrumb from category/subcategory diff --git a/src/hooks/useCrystalDB.ts b/src/hooks/useCrystalDB.ts index 87b5b52..21ab3fd 100644 --- a/src/hooks/useCrystalDB.ts +++ b/src/hooks/useCrystalDB.ts @@ -149,14 +149,29 @@ interface UseFiltersResult { loading: boolean; } -export function useFilters(): UseFiltersResult { - const [systems, setSystems] = useState([]); - const [categories, setCategories] = useState([]); - const [origins, setOrigins] = useState([]); - const [mineralGroups, setMineralGroups] = useState([]); - const [loading, setLoading] = useState(true); +interface UseFiltersInitial { + systems?: string[]; + categories?: string[]; + origins?: string[]; + mineralGroups?: string[]; +} + +/** + * When initial filter lists are provided (SSG case), skips the wasm-DB hit and + * serves the filter dropdowns synchronously. + */ +export function useFilters(initial: UseFiltersInitial = {}): UseFiltersResult { + const hasInitial = Boolean( + initial.systems || initial.categories || initial.origins || initial.mineralGroups, + ); + const [systems, setSystems] = useState(initial.systems ?? []); + const [categories, setCategories] = useState(initial.categories ?? []); + const [origins, setOrigins] = useState(initial.origins ?? []); + const [mineralGroups, setMineralGroups] = useState(initial.mineralGroups ?? []); + const [loading, setLoading] = useState(!hasInitial); useEffect(() => { + if (hasInitial) return; (async () => { try { const [systemsData, categoriesData, originsData, groupsData] = await Promise.all([ @@ -175,7 +190,7 @@ export function useFilters(): UseFiltersResult { setLoading(false); } })(); - }, []); + }, [hasInitial]); return { systems, categories, origins, mineralGroups, loading }; } diff --git a/src/hooks/useFamilies.ts b/src/hooks/useFamilies.ts index 1eac073..5861252 100644 --- a/src/hooks/useFamilies.ts +++ b/src/hooks/useFamilies.ts @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useRef } from 'react'; import { getAllFamilies, getFamilyById, @@ -27,13 +27,26 @@ interface UseFamiliesResult { /** * Hook for loading and filtering mineral families in the gallery. * Returns families with expression counts (no duplicates like the old minerals approach). + * + * When `initialFamilies` is provided (build-time SSG case), skips the initial + * sql.js fetch and serves them synchronously on first paint. The wasm DB only + * loads on the first dynamic action (search/filter). */ -export function useFamilies(): UseFamiliesResult { - const [families, setFamilies] = useState([]); - const [loading, setLoading] = useState(true); +export function useFamilies(initialFamilies?: MineralFamily[]): UseFamiliesResult { + const hasInitial = Array.isArray(initialFamilies); + const initialRef = useRef(initialFamilies); + const [families, setFamilies] = useState(initialFamilies ?? []); + const [loading, setLoading] = useState(!hasInitial); const [error, setError] = useState(null); const loadAll = useCallback(async () => { + // Reset to SSG-fetched data without touching wasm DB when available. + if (initialRef.current) { + setFamilies(initialRef.current); + setLoading(false); + setError(null); + return; + } try { setLoading(true); setError(null); @@ -115,8 +128,9 @@ export function useFamilies(): UseFamiliesResult { }, [loadAll]); useEffect(() => { + if (hasInitial) return; loadAll(); - }, [loadAll]); + }, [loadAll, hasInitial]); return { families, diff --git a/src/lib/db-server.ts b/src/lib/db-server.ts index d4b60f8..184a831 100644 --- a/src/lib/db-server.ts +++ b/src/lib/db-server.ts @@ -298,6 +298,21 @@ export async function getCategories(): Promise { return result[0].values.map((row) => row[0] as string); } +/** + * Get distinct origin classifications from the families table. + */ +export async function getOrigins(): Promise { + const database = await getDB(); + const result = database.exec( + `SELECT DISTINCT origin FROM mineral_families + WHERE origin IS NOT NULL AND origin != '' + ORDER BY origin` + ); + + if (result.length === 0) return []; + return result[0].values.map((row) => row[0] as string); +} + /** * Get distinct mineral groups from the database. */ diff --git a/src/lib/sanitize-svg.ts b/src/lib/sanitize-svg.ts index 2cf1ce9..f89b7fe 100644 --- a/src/lib/sanitize-svg.ts +++ b/src/lib/sanitize-svg.ts @@ -111,6 +111,14 @@ const SVG_CONFIG: DOMPurify.Config = { export function sanitizeSvg(svgContent: string): string { if (!svgContent) return ''; + // SSR / Node has no window — DOMPurify cannot run without jsdom. + // The SVGs we render in SSG come from our own minerals.db (built by + // crystal-renderer), so we can pass them through unchanged on the server + // and let the browser-side hydration re-sanitise once the DOM is available. + if (typeof window === 'undefined') { + return svgContent; + } + // DOMPurify.sanitize returns a string when not using RETURN_DOM options return DOMPurify.sanitize(svgContent, SVG_CONFIG) as string; } diff --git a/src/pages/404.astro b/src/pages/404.astro index ad7fb74..26010d2 100644 --- a/src/pages/404.astro +++ b/src/pages/404.astro @@ -19,23 +19,34 @@ const popularPages = [ -

404

-

Page Not Found

+ +

Lost in the lattice

- The page you're looking for doesn't exist or has been moved. + The page you're looking for doesn't exist or has been moved. Try one of the routes below or head back to the gallery.

-
+
+
-

Popular Pages

+

Popular Pages

{popularPages.map(page => ( -

{page.title}

+

{page.title}

{page.description}

))} diff --git a/src/pages/gallery/index.astro b/src/pages/gallery/index.astro index baa8096..46290b1 100644 --- a/src/pages/gallery/index.astro +++ b/src/pages/gallery/index.astro @@ -3,6 +3,12 @@ import BaseLayout from '../../layouts/BaseLayout.astro'; import { Gallery } from '../../components/gallery/Gallery'; import { Container, SectionHeader } from '../../components/ui'; import { getSiteStats } from '../../lib/stats'; +import { + getAllFamilies, + getCrystalSystems, + getOrigins, + getMineralGroups, +} from '../../lib/db-server'; const url = Astro.url; const initialSystem = url.searchParams.get('system') || ''; @@ -13,6 +19,16 @@ const initialOrigin = url.searchParams.get('origin') || ''; const canonicalURL = 'https://gemmology.dev/gallery'; const { familyCount } = await getSiteStats(); + +// SSG-fetch families + filter dropdowns at build time so the gallery first +// paint does not block on sql.js + the 8 MB minerals.db. The wasm DB only +// loads client-side once the user actually searches or filters. +const [initialFamilies, initialSystems, initialOrigins, initialGroups] = await Promise.all([ + getAllFamilies(), + getCrystalSystems(), + getOrigins(), + getMineralGroups(), +]); ---
diff --git a/src/pages/index.astro b/src/pages/index.astro index a738d16..5cb4ace 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -135,15 +135,15 @@ const iconPaths: Record = {
-
+
-
- cubic[m3m]:{'{'}{'{'}111{'}'} +
+ cubic[m3m]:{'{'}{'{'}111{'}'}
diff --git a/src/pages/learn/[...slug].astro b/src/pages/learn/[...slug].astro index 1de2671..70a4b87 100644 --- a/src/pages/learn/[...slug].astro +++ b/src/pages/learn/[...slug].astro @@ -1,4 +1,7 @@ --- +import { statSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; import { getCollection } from 'astro:content'; import BaseLayout from '../../layouts/BaseLayout.astro'; import { Breadcrumb, PageNav } from '../../components/docs'; @@ -24,6 +27,29 @@ interface Props { const { entry, allEntries } = Astro.props; const { data } = entry; +// Resolve dateModified for SEO + visible "Last updated" line. +// Order of preference: explicit reviewedAt frontmatter → file mtime fallback. +function resolveDateModified(): string { + if (data.reviewedAt) return data.reviewedAt; + try { + const filePath = (entry as unknown as { filePath?: string }).filePath; + if (filePath) { + const abs = filePath.startsWith('file:') ? fileURLToPath(filePath) : path.resolve(filePath); + return new Date(statSync(abs).mtimeMs).toISOString(); + } + } catch { + // fall through + } + return new Date().toISOString(); +} + +const dateModified = resolveDateModified(); +const dateModifiedDisplay = new Date(dateModified).toLocaleDateString('en-GB', { + year: 'numeric', + month: 'long', + day: 'numeric', +}); + // Helper to format category/subcategory names for display function formatCategoryName(name: string): string { return name @@ -155,6 +181,7 @@ const pretestQuestions: WidgetQuestion[] = articleQuestions subcategory={data.subcategory} difficulty={data.difficulty} slug={entry.id} + dateModified={dateModified} /> @@ -168,15 +195,11 @@ const pretestQuestions: WidgetQuestion[] = articleQuestions
By {data.author || 'gemmology.dev editors'} - {data.reviewedAt && ( - - Last reviewed - - {data.reviewedBy && by {data.reviewedBy}} - - )} + + Last updated + + {data.reviewedBy && by {data.reviewedBy}} +
{data.tags && data.tags.length > 0 && (