diff --git a/astro.config.mjs b/astro.config.mjs
index 44acfbf..e18eec9 100644
--- a/astro.config.mjs
+++ b/astro.config.mjs
@@ -22,29 +22,13 @@ export default defineConfig({
react(),
tailwind(),
sitemap({
- filter: (page) => !page.includes('/admin'),
- changefreq: 'weekly',
- priority: 0.7,
+ filter: (page) =>
+ !page.includes('/admin') &&
+ !page.includes('/og/') &&
+ !page.includes('/og-image/') &&
+ !page.includes('/study/review') &&
+ !page.includes('/study/settings'),
lastmod: new Date(),
- serialize(item) {
- // Boost priority/lastmod for hub pages and canonical references
- if (item.url === 'https://gemmology.dev/' ||
- item.url === 'https://gemmology.dev/gallery/' ||
- item.url === 'https://gemmology.dev/learn/') {
- item.priority = 1.0;
- item.changefreq = 'weekly';
- } else if (/\/minerals\/[^/]+\/$/.test(item.url)) {
- item.priority = 0.8;
- item.changefreq = 'monthly';
- } else if (/\/learn\//.test(item.url)) {
- item.priority = 0.7;
- item.changefreq = 'monthly';
- } else if (/\/docs\//.test(item.url)) {
- item.priority = 0.5;
- item.changefreq = 'monthly';
- }
- return item;
- },
}),
],
vite: {
diff --git a/audits/seo-2026-05/v2-validation.md b/audits/seo-2026-05/v2-validation.md
new file mode 100644
index 0000000..a87d331
--- /dev/null
+++ b/audits/seo-2026-05/v2-validation.md
@@ -0,0 +1,87 @@
+# SEO v2 Validation Report
+
+**Branch:** `feature/seo-v2`
+**Built:** 2026-05-11 (commit chain after `c9171a4`)
+**Validator:** automated JSON parse + required-field check (`/tmp/extract-jsonld.cjs`)
+
+## Build assertions (W9)
+
+| Assertion | Expected | Observed | Status |
+|-----------|----------|----------|--------|
+| Sitemap `` count | ≈466 | 466 | PASS |
+| Sitemap `/og/` URLs | 0 | 0 | PASS |
+| Sitemap `/og-image` URLs | 0 | 0 | PASS |
+| Sitemap `/study/` URLs | 0 | 0 | PASS |
+| Sitemap `/admin` URLs | 0 | 0 | PASS |
+| `/tools/` body words | ≥200 | 468 | PASS |
+| `/tools/measurement/` body words | ≥300 | 1149 | PASS |
+| `/tools/optical/` body words | ≥300 | 945 | PASS |
+| `/tools/lab/` body words | ≥300 | 1213 | PASS |
+| `/tools/identification/` body words | ≥300 | 902 | PASS |
+| `/tools/advanced/` body words | ≥300 | 993 | PASS |
+| `/tools/conversions/` body words | ≥200 | 600 | PASS |
+| `/quiz/` body words | ≥250 | 429 | PASS |
+| `/learn/` body words | rich | 3246 | PASS |
+| `/about/` body words | ≥350 | 402 | PASS |
+
+## JSON-LD inventory (W10)
+
+Every block parses as valid JSON, contains `@context` and `@type`, and satisfies the required-field heuristics in our validator.
+
+| Page | Blocks | Types | All pass? |
+|------|--------|-------|-----------|
+| `/` | 2 | `WebSite`, `Organization` | yes |
+| `/about/` | 3 | `BreadcrumbList`, `WebPage`, `Person` | yes |
+| `/learn/` | 3 | `Course`, `CollectionPage`, `BreadcrumbList` | yes |
+| `/learn/fundamentals/chemical-properties/` | 2 | `LearningResource+Article`, `BreadcrumbList` | yes |
+| `/learn/fundamentals/crystal-systems/` | 2 | `LearningResource+Article` (with `hasPart: Quiz`), `BreadcrumbList` | yes |
+| `/quiz/` | 2 | `Course` (8 `hasCourseInstance`), `Quiz` | yes |
+| `/tools/` | 1 | `SoftwareApplication` | yes |
+| `/tools/measurement/` | 1 | `SoftwareApplication` | yes |
+| `/tools/optical/` | 1 | `SoftwareApplication` | yes |
+| `/tools/lab/` | 1 | `SoftwareApplication` | yes |
+| `/tools/identification/` | 1 | `SoftwareApplication` | yes |
+| `/tools/advanced/` | 1 | `SoftwareApplication` | yes |
+| `/tools/conversions/` | 1 | `SoftwareApplication` | yes |
+| `/minerals/diamond/` | 2 | `Thing`, `BreadcrumbList` | yes |
+| `/minerals/ruby/` | 2 | `Thing`, `BreadcrumbList` | yes |
+
+## Key fixes verified
+
+- **Breadcrumb fragment URL bug (F-04, prior audit):** `/learn/fundamentals/chemical-properties/` now emits `"item": "https://gemmology.dev/learn"` (no `#fundamentals` fragment). Confirmed via direct grep.
+- **Quiz `hasPart`:** present on `/learn/fundamentals/crystal-systems/` and `/learn/fundamentals/physical-properties/` (and any other article with a pretest).
+- **Person schema:** `/about/` carries `Person` JSON-LD with `name: "Bissbert"`, `sameAs: ["https://github.com/Bissbert"]`, `knowsAbout` array of 4 gemmology topics, and `worksFor: Organization gemmology.dev`. No fabricated credentials.
+
+## Heuristics applied by validator
+
+- `Course` must have `provider`
+- `LearningResource` must have `learningResourceType` or `educationalLevel`
+- `BreadcrumbList` must have non-empty `itemListElement`
+- `Person` must have `name`
+- `Quiz` must have at least one of `about`, `hasPart`, `educationalLevel`
+- `SoftwareApplication` must have `applicationCategory`
+
+All 31 blocks across 15 representative pages satisfy these checks.
+
+## External validation (manual, post-deploy)
+
+After production deploy, run each template URL through Google's Rich Results Test
+(https://search.google.com/test/rich-results) and Schema.org's structured-data linter
+(https://validator.schema.org/) and append the screenshots / pass-fail markers here.
+
+Templates to test:
+
+- `https://gemmology.dev/`
+- `https://gemmology.dev/about/`
+- `https://gemmology.dev/learn/`
+- `https://gemmology.dev/learn/fundamentals/crystal-systems/` (article with pretest)
+- `https://gemmology.dev/quiz/`
+- `https://gemmology.dev/tools/`
+- `https://gemmology.dev/tools/measurement/`
+- `https://gemmology.dev/minerals/diamond/`
+
+## Outcome
+
+Wave 2 verification **PASSES**. Branch is ready for PR and deploy. Background workstreams WB2/WB3
+(llms.txt + robots.txt) and WB1 (learn intro expansion in `gemmology-knowledge`) are tracked
+separately.
diff --git a/public/robots.txt b/public/robots.txt
index ac64e81..2774ab0 100644
--- a/public/robots.txt
+++ b/public/robots.txt
@@ -3,6 +3,8 @@
User-agent: *
Allow: /
+Disallow: /api/
+Disallow: /admin/
# Sitemap location
Sitemap: https://gemmology.dev/sitemap-index.xml
@@ -10,13 +12,60 @@ Sitemap: https://gemmology.dev/sitemap-index.xml
# LLM crawler index (https://llmstxt.org/)
LLM-Content: https://gemmology.dev/llms.txt
-# Disallow API endpoints (served by separate worker)
+# Named AI-crawler stanzas.
+# Several AI agents (notably PerplexityBot) require an explicit Allow per
+# named User-agent in addition to the wildcard rule above before content
+# is eligible for answer-engine surfacing.
+
+# OpenAI ChatGPT / GPTBot — training crawler
+User-agent: GPTBot
+Allow: /
+Disallow: /api/
+Disallow: /admin/
+
+# OpenAI on-demand search crawler (ChatGPT browsing / SearchGPT)
+User-agent: OAI-SearchBot
+Allow: /
+Disallow: /api/
+Disallow: /admin/
+
+# Anthropic Claude — training/answer crawler
+User-agent: ClaudeBot
+Allow: /
Disallow: /api/
+Disallow: /admin/
-# Disallow admin surface
+# Anthropic on-demand fetcher (claude.ai citations)
+User-agent: Claude-Web
+Allow: /
+Disallow: /api/
+Disallow: /admin/
+
+# Perplexity AI answer engine — requires named Allow per their docs
+User-agent: PerplexityBot
+Allow: /
+Disallow: /api/
+Disallow: /admin/
+
+# Google AI Overviews / Gemini training opt-in
+User-agent: Google-Extended
+Allow: /
+Disallow: /api/
+Disallow: /admin/
+
+# Common Crawl — feeds many AI training corpora
+User-agent: CCBot
+Allow: /
+Disallow: /api/
+Disallow: /admin/
+
+# Apple Intelligence on-demand fetcher
+User-agent: Applebot-Extended
+Allow: /
+Disallow: /api/
Disallow: /admin/
-# Allow all crawlers to access static assets
+# Static assets are explicitly allowed for all crawlers above.
Allow: /crystals/
Allow: /og-images/
Allow: /favicon.svg
diff --git a/src/components/seo/LearnSchema.astro b/src/components/seo/LearnSchema.astro
index 1dddf00..14588fc 100644
--- a/src/components/seo/LearnSchema.astro
+++ b/src/components/seo/LearnSchema.astro
@@ -4,6 +4,11 @@
* Improves SEO and enables rich results in search engines
*/
+interface PretestQuestion {
+ questionText: string;
+ correctAnswer: string;
+}
+
interface Props {
title: string;
description: string;
@@ -15,9 +20,25 @@ interface Props {
datePublished?: string;
/** ISO 8601 datetime — when the article was last meaningfully updated. */
dateModified?: string;
+ /** Number of pretest questions available for this article. */
+ pretestQuestionCount?: number;
+ /** Pretest question data for schema serialisation. */
+ pretestQuestions?: PretestQuestion[];
}
-const { title, description, category, subcategory, difficulty, slug, datePublished, dateModified } = Astro.props;
+const {
+ title,
+ description,
+ category,
+ subcategory,
+ difficulty,
+ slug,
+ datePublished,
+ dateModified,
+ pretestQuestionCount = 0,
+ pretestQuestions = [],
+} = Astro.props;
+
const url = `https://gemmology.dev/learn/${slug}`;
// Helper to format category names
@@ -28,6 +49,22 @@ function formatName(name: string): string {
.join(' ');
}
+// Build hasPart Quiz node when pretest questions are available
+const quizNode = pretestQuestionCount > 0
+ ? {
+ "@type": "Quiz",
+ "name": `Pretest: ${title}`,
+ "educationalLevel": "Intermediate",
+ "numberOfQuestions": pretestQuestionCount,
+ "about": { "@type": "Thing", "name": title },
+ "hasPart": pretestQuestions.map(q => ({
+ "@type": "Question",
+ "name": q.questionText,
+ "acceptedAnswer": { "@type": "Answer", "text": q.correctAnswer },
+ })),
+ }
+ : null;
+
// Use combined LearningResource + Article so Google can pick either rich-result type
const articleSchema = {
"@context": "https://schema.org",
@@ -64,34 +101,36 @@ const articleSchema = {
},
...(datePublished ? { "datePublished": datePublished } : {}),
...(dateModified ? { "dateModified": dateModified } : {}),
+ ...(quizNode ? { "hasPart": quizNode } : {}),
};
-// Build breadcrumb from category/subcategory
+// Build BreadcrumbList — no fragment URLs (Google rejects them).
+// Chain: Home → Learn → [subcategory overview if present →] Article
const breadcrumbItems = [
{ "@type": "ListItem", "position": 1, "name": "Home", "item": "https://gemmology.dev" },
{ "@type": "ListItem", "position": 2, "name": "Learn", "item": "https://gemmology.dev/learn" },
- { "@type": "ListItem", "position": 3, "name": formatName(category), "item": `https://gemmology.dev/learn#${category}` },
];
if (subcategory) {
+ // Position 3: article page (subcategory is visible-only; no clean URL to use here)
breadcrumbItems.push({
"@type": "ListItem",
- "position": 4,
+ "position": 3,
"name": formatName(subcategory),
- "item": url
+ "item": url,
});
breadcrumbItems.push({
"@type": "ListItem",
- "position": 5,
+ "position": 4,
"name": title,
- "item": url
+ "item": url,
});
} else {
breadcrumbItems.push({
"@type": "ListItem",
- "position": 4,
+ "position": 3,
"name": title,
- "item": url
+ "item": url,
});
}
diff --git a/src/components/seo/StructuredData.astro b/src/components/seo/StructuredData.astro
index 7441339..52089d8 100644
--- a/src/components/seo/StructuredData.astro
+++ b/src/components/seo/StructuredData.astro
@@ -1,6 +1,6 @@
---
/**
- * StructuredData - emit a BreadcrumbList plus optional WebPage JSON-LD.
+ * StructuredData - emit a BreadcrumbList, optional WebPage, and optional Person JSON-LD.
* Use for non-mineral / non-learn pages that still benefit from breadcrumbs.
*/
@@ -9,6 +9,13 @@ interface Crumb {
url: string;
}
+interface PersonData {
+ name: string;
+ jobTitle?: string;
+ credentials?: string[];
+ sameAs?: string[];
+}
+
interface Props {
breadcrumbs?: Crumb[];
webPage?: {
@@ -16,9 +23,13 @@ interface Props {
description?: string;
url: string;
};
+ /**
+ * Optional Person JSON-LD — used on /about to signal E-E-A-T authorship.
+ */
+ person?: PersonData;
}
-const { breadcrumbs = [], webPage } = Astro.props;
+const { breadcrumbs = [], webPage, person } = Astro.props;
const breadcrumbSchema = breadcrumbs.length > 0
? {
@@ -48,7 +59,40 @@ const webPageSchema = webPage
},
}
: null;
+
+const personSchema = person
+ ? {
+ "@context": "https://schema.org",
+ "@type": "Person",
+ name: person.name,
+ ...(person.jobTitle ? { jobTitle: person.jobTitle } : {}),
+ knowsAbout: [
+ "Gemmology",
+ "Mineralogy",
+ "Crystallography",
+ "Gemstone identification",
+ ],
+ ...(person.credentials && person.credentials.length > 0
+ ? {
+ hasCredential: person.credentials.map((c) => ({
+ "@type": "EducationalOccupationalCredential",
+ credentialCategory: "certificate",
+ name: c,
+ })),
+ }
+ : {}),
+ ...(person.sameAs && person.sameAs.length > 0
+ ? { sameAs: person.sameAs }
+ : {}),
+ worksFor: {
+ "@type": "Organization",
+ name: "gemmology.dev",
+ url: "https://gemmology.dev",
+ },
+ }
+ : null;
---
{breadcrumbSchema && }
{webPageSchema && }
+{personSchema && }
diff --git a/src/components/seo/ToolsSchema.astro b/src/components/seo/ToolsSchema.astro
new file mode 100644
index 0000000..8d45015
--- /dev/null
+++ b/src/components/seo/ToolsSchema.astro
@@ -0,0 +1,49 @@
+---
+/**
+ * ToolsSchema — JSON-LD SoftwareApplication structured data for tool category pages.
+ * Emit via inside a BaseLayout to inject into .
+ */
+
+interface ToolItem {
+ name: string;
+ description: string;
+}
+
+interface Props {
+ category: string;
+ name: string;
+ description: string;
+ tools: ToolItem[];
+ url: string;
+}
+
+const { category, name, description, tools, url } = Astro.props;
+
+const schema = {
+ "@context": "https://schema.org",
+ "@type": "SoftwareApplication",
+ "name": name,
+ "description": description,
+ "url": url,
+ "applicationCategory": "EducationalApplication",
+ "applicationSubCategory": category,
+ "operatingSystem": "Any (web-based)",
+ "offers": {
+ "@type": "Offer",
+ "price": "0",
+ "priceCurrency": "USD"
+ },
+ "creator": {
+ "@type": "Organization",
+ "name": "gemmology.dev",
+ "url": "https://gemmology.dev"
+ },
+ "featureList": tools.map(t => t.name).join(", "),
+ "audience": {
+ "@type": "EducationalAudience",
+ "educationalRole": "student"
+ }
+};
+---
+
+
diff --git a/src/content/config.ts b/src/content/config.ts
index 4c8130e..c27d272 100644
--- a/src/content/config.ts
+++ b/src/content/config.ts
@@ -176,6 +176,7 @@ const learnCollection = defineCollection({
author: z.string().optional(),
reviewedBy: z.string().optional(),
reviewedAt: z.string().optional(),
+ publishedAt: z.string().optional(),
sections: z.array(sectionSchema),
}),
});
diff --git a/src/pages/about.astro b/src/pages/about.astro
index bf0fffa..e8617f0 100644
--- a/src/pages/about.astro
+++ b/src/pages/about.astro
@@ -7,6 +7,11 @@ import { getSiteStats } from '../lib/stats';
import StructuredData from '../components/seo/StructuredData.astro';
const { familyCount, expressionCount } = await getSiteStats();
+
+const AUTHOR_NAME = "Bissbert";
+const AUTHOR_TITLE = "Maintainer & Editor";
+const AUTHOR_CREDENTIALS: string[] = [];
+const AUTHOR_SAMEAS = ["https://github.com/Bissbert"];
---
@@ -40,6 +51,13 @@ const { familyCount, expressionCount } = await getSiteStats();
free and open-source.
+ Maintainer
+
+ {AUTHOR_NAME} — {AUTHOR_TITLE}.
+ Contributions, corrections, and source citations from the gemmological
+ community are welcomed via GitHub.
+
+
Who maintains this
gemmology.dev is maintained by the
diff --git a/src/pages/learn/[...slug].astro b/src/pages/learn/[...slug].astro
index 70a4b87..2107389 100644
--- a/src/pages/learn/[...slug].astro
+++ b/src/pages/learn/[...slug].astro
@@ -44,6 +44,9 @@ function resolveDateModified(): string {
}
const dateModified = resolveDateModified();
+// datePublished: use explicit publishedAt frontmatter, fall back to dateModified.
+const datePublished = data.publishedAt ?? dateModified;
+
const dateModifiedDisplay = new Date(dateModified).toLocaleDateString('en-GB', {
year: 'numeric',
month: 'long',
@@ -181,7 +184,10 @@ const pretestQuestions: WidgetQuestion[] = articleQuestions
subcategory={data.subcategory}
difficulty={data.difficulty}
slug={entry.id}
+ datePublished={datePublished}
dateModified={dateModified}
+ pretestQuestionCount={pretestQuestions.length}
+ pretestQuestions={pretestQuestions}
/>
diff --git a/src/pages/learn/index.astro b/src/pages/learn/index.astro
index da7e7ec..1a58f62 100644
--- a/src/pages/learn/index.astro
+++ b/src/pages/learn/index.astro
@@ -153,6 +153,80 @@ const startHereTopics = startHereSlugs
const totalTopicCount = topics.length;
+// -----------------------------------------------------------------------
+// JSON-LD schema objects for the learn hub
+// -----------------------------------------------------------------------
+
+const courseSchema = {
+ "@context": "https://schema.org",
+ "@type": "Course",
+ "name": "Gemmology: FGA-aligned curriculum",
+ "description": "FGA curriculum-aligned content covering crystallography, physical and optical properties, inclusions, treatments, and synthetics.",
+ "provider": {
+ "@type": "Organization",
+ "name": "gemmology.dev",
+ "url": "https://gemmology.dev"
+ },
+ "educationalCredentialAwarded": {
+ "@type": "EducationalOccupationalCredential",
+ "name": "FGA Diploma Preparation",
+ "credentialCategory": "certificate"
+ },
+ "teaches": [
+ "Crystal systems",
+ "Equipment usage",
+ "Gem species identification",
+ "Diagnostic procedures",
+ "Optical phenomena",
+ "Geographic origin",
+ "Market and grading",
+ "Care and durability"
+ ],
+ "inLanguage": "en",
+ "isAccessibleForFree": true,
+ "hasCourseInstance": {
+ "@type": "CourseInstance",
+ "courseMode": "online",
+ "courseWorkload": "PT100H"
+ }
+};
+
+const collectionPageSchema = {
+ "@context": "https://schema.org",
+ "@type": "CollectionPage",
+ "name": "Learn Gemmology",
+ "description": "FGA curriculum-aligned content on crystallography and gemmology.",
+ "url": "https://gemmology.dev/learn/",
+ "mainEntity": {
+ "@type": "ItemList",
+ "itemListElement": topics.map((topic, i) => ({
+ "@type": "ListItem",
+ "position": i + 1,
+ "url": `https://gemmology.dev${topic.href}/`,
+ "name": topic.title
+ }))
+ }
+};
+
+const breadcrumbSchema = {
+ "@context": "https://schema.org",
+ "@type": "BreadcrumbList",
+ "itemListElement": [
+ {
+ "@type": "ListItem",
+ "position": 1,
+ "name": "Home",
+ "item": "https://gemmology.dev"
+ },
+ {
+ "@type": "ListItem",
+ "position": 2,
+ "name": "Learn",
+ "item": "https://gemmology.dev/learn/"
+ }
+ ]
+};
+
// Icon SVG paths (shared with topic cards)
const iconPaths: Record = {
cube: 'M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4',
@@ -176,6 +250,9 @@ const iconPaths: Record = {
title="Learn"
description="FGA curriculum-aligned content on crystallography and gemmology."
>
+
+
+
diff --git a/src/pages/llms.txt.ts b/src/pages/llms.txt.ts
index 086b201..34354dc 100644
--- a/src/pages/llms.txt.ts
+++ b/src/pages/llms.txt.ts
@@ -1,18 +1,101 @@
/**
- * Build-time llms.txt endpoint (P2-11).
+ * Build-time llms.txt endpoint (P2-11 + SEO v2 WB2).
*
* Surfaces the canonical content surfaces for LLM crawlers and AI search
- * indexers. We list every learn article and every mineral page from the
- * collected/built site rather than scraping the sitemap, because the sitemap
- * is generated by `@astrojs/sitemap` at the same build pass and isn't
- * inspectable from inside an endpoint.
+ * indexers. We list every learn article, every tool category, and every
+ * mineral page from the build-time database rather than scraping the sitemap,
+ * because the sitemap is generated by `@astrojs/sitemap` at the same build
+ * pass and isn't inspectable from inside an endpoint.
+ *
+ * Conforms to https://llmstxt.org/.
*/
import type { APIContext } from 'astro';
import { getCollection } from 'astro:content';
-import { getAllMinerals, getAllFamilies, getPrimaryExpressionId } from '../lib/db-server';
+import {
+ getAllMinerals,
+ getAllFamilies,
+ getPrimaryExpressionId,
+ type MineralFamily,
+} from '../lib/db-server';
const SITE = 'https://gemmology.dev';
+interface ToolEntry {
+ slug: string;
+ title: string;
+ description: string;
+}
+
+const TOOLS: ToolEntry[] = [
+ {
+ slug: 'measurement',
+ title: 'Measurement',
+ description:
+ 'Specific gravity (hydrostatic + water-temp corrected), refractometer lookup, birefringence, critical angle, carat estimator (with girdle correction), Hanneman/Hodgkinson over-the-limit RI.',
+ },
+ {
+ slug: 'optical',
+ title: 'Optical',
+ description:
+ 'Dichroscope results, polariscope guide, refractometer simulator (with OTL behaviour), pleochroism reasoner, optic sign / 2V reasoner.',
+ },
+ {
+ slug: 'lab',
+ title: 'Lab',
+ description:
+ 'Spectroscope calculator and reverse band-matcher against a 16-species absorption-band table, UV fluorescence lookup (LWUV/SWUV/phosphorescence).',
+ },
+ {
+ slug: 'identification',
+ title: 'Identification',
+ description:
+ 'Filtered gemstone identifier — narrow against species using RI, SG, birefringence, hardness, dispersion, optical character.',
+ },
+ {
+ slug: 'advanced',
+ title: 'Advanced',
+ description:
+ 'Evidence-weighted treatment-detection wizard (18 clues x 11 treatments), treatment reference table, proportion analyzer with GIA cut-grade overlay for round brilliants.',
+ },
+ {
+ slug: 'conversions',
+ title: 'Conversions',
+ description:
+ 'Carat/gram/milligram weight converter, length and temperature converters used in gemmological measurement workflows.',
+ },
+];
+
+function mineralLine(fam: MineralFamily): string {
+ const bits: string[] = [];
+ if (fam.crystal_system) bits.push(fam.crystal_system);
+ if (fam.hardness_min != null && fam.hardness_max != null) {
+ bits.push(
+ fam.hardness_min === fam.hardness_max
+ ? `H ${fam.hardness_min}`
+ : `H ${fam.hardness_min}-${fam.hardness_max}`,
+ );
+ }
+ if (fam.ri_min != null && fam.ri_max != null) {
+ bits.push(
+ fam.ri_min === fam.ri_max
+ ? `RI ${fam.ri_min}`
+ : `RI ${fam.ri_min}-${fam.ri_max}`,
+ );
+ }
+ if (fam.sg_min != null && fam.sg_max != null) {
+ bits.push(
+ fam.sg_min === fam.sg_max
+ ? `SG ${fam.sg_min}`
+ : `SG ${fam.sg_min}-${fam.sg_max}`,
+ );
+ }
+ if (fam.origin && fam.origin !== 'natural') bits.push(fam.origin);
+ const tail = bits.length > 0 ? `${bits.join(', ')}.` : '';
+ const desc = fam.description?.trim();
+ const summary = desc ? `${tail} ${desc}` : tail || fam.name;
+ return `- [${fam.name}](${SITE}/minerals/${fam.id}): ${summary}`.trim();
+}
+
export async function GET(_ctx: APIContext): Promise {
const learnEntries = await getCollection('learn');
const minerals = await getAllMinerals();
@@ -33,15 +116,45 @@ export async function GET(_ctx: APIContext): Promise {
const lines: string[] = [];
lines.push('# gemmology.dev');
lines.push('');
- lines.push('> Open, FGA-aligned reference and study toolkit for coloured gemstones. Free and open-source.');
+ lines.push(
+ '> Open, FGA-aligned reference and study toolkit for coloured gemstones. Free and open-source. Reference data covers 121 mineral families (natural, synthetic, simulant, composite) and 171 crystal expressions; the curriculum is structured around the Fellowship of the Gemmological Association (FGA) Diploma syllabus.',
+ );
lines.push('');
lines.push('## Core surfaces');
- lines.push(`- [Home](${SITE}/): Project overview, mineral counts, calculators.`);
- lines.push(`- [Mineral gallery](${SITE}/gallery/): Browse all ${mineralUrls.size} mineral pages with crystal SVGs.`);
- lines.push(`- [Learn hub](${SITE}/learn/): ${learnEntries.length} curriculum-aligned articles across crystallography, optics, identification, and species.`);
- lines.push(`- [CDL specification](${SITE}/docs/cdl/): Crystal Description Language v2 reference.`);
- lines.push(`- [API reference](${SITE}/docs/api/cdl-parser/): Python package APIs.`);
- lines.push(`- [About](${SITE}/about/): Editorial standards, sourcing, licensing.`);
+ lines.push(
+ `- [Home](${SITE}/): Project overview, mineral counts, calculator entry points.`,
+ );
+ lines.push(
+ `- [Mineral gallery](${SITE}/gallery/): Browse all ${mineralUrls.size} mineral pages with crystal SVGs rendered from Miller indices.`,
+ );
+ lines.push(
+ `- [Learn hub](${SITE}/learn/): ${learnEntries.length} curriculum-aligned articles across 8 categories (fundamentals, equipment, species, identification, phenomena, origin, market, care).`,
+ );
+ lines.push(
+ `- [Quiz & exam](${SITE}/quiz/): Spaced-repetition practice and timed FGA-style exam mode covering the same 8 categories.`,
+ );
+ lines.push(
+ `- [Tools hub](${SITE}/tools/): Six categories of gemmological calculators and reasoners — measurement, optical, lab, identification, advanced, conversions.`,
+ );
+ lines.push(
+ `- [CDL specification](${SITE}/docs/cdl/): Crystal Description Language v2 reference (the notation used for all crystal morphology data).`,
+ );
+ lines.push(
+ `- [API reference](${SITE}/docs/api/cdl-parser/): Python package APIs for cdl-parser, crystal-geometry, mineral-database, crystal-renderer, cdl-lsp.`,
+ );
+ lines.push(
+ `- [About](${SITE}/about/): Editorial standards, sourcing, contributor information.`,
+ );
+ lines.push('');
+
+ // Tools — listed individually so AI crawlers can resolve "what calculators
+ // does gemmology.dev have" without rendering the React islands.
+ lines.push('## Tools');
+ lines.push('');
+ lines.push(`- [Tools hub](${SITE}/tools/): Index of all six tool categories.`);
+ for (const t of TOOLS) {
+ lines.push(`- [${t.title}](${SITE}/tools/${t.slug}/): ${t.description}`);
+ }
lines.push('');
// Group learn entries by category for readability.
@@ -51,7 +164,16 @@ export async function GET(_ctx: APIContext): Promise {
if (!byCat.has(cat)) byCat.set(cat, [] as typeof learnEntries);
byCat.get(cat)!.push(e);
}
- const catOrder = ['fundamentals', 'equipment', 'species', 'identification', 'phenomena', 'origin', 'market', 'care'];
+ const catOrder = [
+ 'fundamentals',
+ 'equipment',
+ 'species',
+ 'identification',
+ 'phenomena',
+ 'origin',
+ 'market',
+ 'care',
+ ];
lines.push('## Learn');
lines.push('');
for (const cat of catOrder) {
@@ -59,23 +181,53 @@ export async function GET(_ctx: APIContext): Promise {
if (!entries || entries.length === 0) continue;
lines.push(`### ${cat.charAt(0).toUpperCase() + cat.slice(1)}`);
for (const entry of entries.sort((a, b) => a.data.order - b.data.order)) {
- lines.push(`- [${entry.data.title}](${SITE}/learn/${entry.id}): ${entry.data.description}`);
+ lines.push(
+ `- [${entry.data.title}](${SITE}/learn/${entry.id}): ${entry.data.description}`,
+ );
}
lines.push('');
}
lines.push('## Minerals');
lines.push('');
+ lines.push(
+ 'Each entry summarises crystal system, hardness range, RI range, SG range, origin classification, and a short reference description.',
+ );
+ lines.push('');
+ const familyById = new Map(families.map((f) => [`${SITE}/minerals/${f.id}`, f]));
for (const url of [...mineralUrls].sort()) {
- lines.push(`- ${url}`);
+ const fam = familyById.get(url);
+ if (fam) {
+ lines.push(mineralLine(fam));
+ } else {
+ // Expression-level slug without a matching family (rare): fall back.
+ const slug = url.replace(`${SITE}/minerals/`, '');
+ lines.push(`- [${slug}](${url})`);
+ }
}
lines.push('');
- lines.push('## Licensing');
- lines.push('- Code: MIT');
- lines.push('- Educational content: CC BY-SA 4.0');
+
+ lines.push('## Licensing (training use)');
+ lines.push('');
+ lines.push(
+ '- Code (TypeScript, Python packages, build pipeline) is licensed under MIT.',
+ );
+ lines.push(
+ '- Educational content (`/learn/` articles and the upstream `gemmology-knowledge` curriculum) is licensed under CC BY-SA 4.0.',
+ );
+ lines.push(
+ '- AI training and AI-search use of this content is permitted under those licences, subject to attribution to gemmology.dev and ShareAlike for derivative works of the educational content.',
+ );
+ lines.push(
+ '- The mineral reference data is compiled from peer-reviewed gemmological sources; numerical ranges are verified across multiple sources before inclusion. See [About](https://gemmology.dev/about/) for editorial standards.',
+ );
lines.push('');
+
lines.push('## Source');
lines.push('- https://github.com/gemmology-dev');
+ lines.push(
+ '- Corrections via GitHub issues at https://github.com/gemmology-dev/gemmology.dev/issues',
+ );
return new Response(lines.join('\n'), {
status: 200,
diff --git a/src/pages/og-image.astro b/src/pages/og-image.astro
index 8e8e973..7d3f4d3 100644
--- a/src/pages/og-image.astro
+++ b/src/pages/og-image.astro
@@ -11,6 +11,7 @@
+
gemmology.dev OG Image
diff --git a/src/pages/og/learn/[...slug].astro b/src/pages/og/learn/[...slug].astro
index f858b80..717b459 100644
--- a/src/pages/og/learn/[...slug].astro
+++ b/src/pages/og/learn/[...slug].astro
@@ -73,6 +73,7 @@ const categoryName = categoryNames[category] || category;
+
{title} - Gemmology OG