From 8569a31321e770ea93a7941a4bc265a97d27a53a Mon Sep 17 00:00:00 2001
From: Bissbert <43237892+Bissbert@users.noreply.github.com>
Date: Mon, 11 May 2026 12:31:12 +0700
Subject: [PATCH 1/3] =?UTF-8?q?feat(seo):=20wave=201=20=E2=80=94=20fix=20i?=
=?UTF-8?q?ndexing=20gap=20with=20knowledge=20subdomain?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Six-agent SEO audit (audits/seo-2026-05/) diagnosed why gemmology.dev is
indexed worse than knowledge.gemmology.dev despite being the better
product: 48% of sitemap URLs were /og/ image templates and high-value
surfaces (/tools, /quiz, /playground) were client:load React shells
with no server-rendered prose. This wave addresses the structural
gap; v3 will layer keyword-clustered content on top.
Sitemap & indexability (W1, W2):
- astro.config.mjs: filter excludes /og/, /og-image/, /study/review,
/study/settings; drop priority/changefreq (Google ignores since 2023)
- /og/learn, /og/minerals, /og-image: noindex,nofollow meta added
- Result: sitemap 910 → 466 URLs (zero /og/, zero /study/)
Tool pages SSG prose + schema (W3+W4):
- 7 tool pages now have 581–1326 server-rendered body words each
(was 0–28 words); 3 paragraphs covering definition, method, and
diagnostic application with named gem examples
- New ToolsSchema.astro component emits SoftwareApplication JSON-LD
on every tool page (applicationCategory EducationalApplication,
free Offer, gemmology.dev creator)
- /tools/index gains h1, intro, static category nav before React island
Learn schema (W5, W6):
- /learn/index: Course (FGA Diploma prep, 8 categories, PT100H workload)
+ CollectionPage with ItemList of all 138 articles + BreadcrumbList
- LearnSchema.astro: BreadcrumbList fragment URL bug fixed (was
emitting /learn#fundamentals as a crumb), datePublished now wired
through from new publishedAt content-collection field, hasPart Quiz
emitted when an article has curated pretest questions
- src/content/config.ts: publishedAt field added to learn collection
Quiz hub (W7):
- 250-word SSG intro with all 8 categories listed statically
- Course JSON-LD with 8 hasCourseInstance entries + Quiz JSON-LD
- /quiz/ now returns 541 body words (was 30)
About page E-E-A-T (W8):
- Visible Maintainers & Editors block with credentials list
- Person JSON-LD via extended StructuredData component
- TODO(author) markers for real name+credential before deploy
Verification:
- npm run build: 912 pages, clean
- Sitemap: 466 URLs, zero junk paths
- All key pages emit valid JSON-LD (homepage 2, /learn 3, /quiz 2,
/tools 1, /about 3 blocks)
- 441 /og/ pages all carry noindex,nofollow
Audit corpus: workspace/gemmology.dev/audits/seo-2026-05/
---
astro.config.mjs | 28 ++----
src/components/seo/LearnSchema.astro | 57 +++++++++--
src/components/seo/StructuredData.astro | 50 +++++++++-
src/components/seo/ToolsSchema.astro | 49 ++++++++++
src/content/config.ts | 1 +
src/pages/about.astro | 35 +++++++
src/pages/learn/[...slug].astro | 6 ++
src/pages/learn/index.astro | 77 +++++++++++++++
src/pages/og-image.astro | 1 +
src/pages/og/learn/[...slug].astro | 1 +
src/pages/og/minerals/[slug].astro | 1 +
src/pages/quiz/index.astro | 121 ++++++++++++++++++++++--
src/pages/tools/advanced.astro | 69 +++++++++++++-
src/pages/tools/conversions.astro | 63 +++++++++++-
src/pages/tools/identification.astro | 68 ++++++++++++-
src/pages/tools/index.astro | 108 ++++++++++++++++++++-
src/pages/tools/lab.astro | 73 +++++++++++++-
src/pages/tools/measurement.astro | 80 +++++++++++++++-
src/pages/tools/optical.astro | 82 +++++++++++++++-
19 files changed, 905 insertions(+), 65 deletions(-)
create mode 100644 src/components/seo/ToolsSchema.astro
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/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..2249ce0 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,15 @@ interface Props {
description?: string;
url: string;
};
+ /**
+ * Optional Person JSON-LD — used on /about to signal E-E-A-T authorship.
+ * TODO(author): replace with real maintainer name + credentials before deploy
+ * (placeholder values are defined in src/pages/about.astro)
+ */
+ person?: PersonData;
}
-const { breadcrumbs = [], webPage } = Astro.props;
+const { breadcrumbs = [], webPage, person } = Astro.props;
const breadcrumbSchema = breadcrumbs.length > 0
? {
@@ -48,7 +61,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
+ The following contributors are responsible for the accuracy and + currency of content on gemmology.dev. +
+
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}
/>