From 5034759a545585bb770cc07eb278754af44a5c4f Mon Sep 17 00:00:00 2001 From: Tim Dennis Date: Fri, 10 Jul 2026 15:34:17 -0700 Subject: [PATCH] feat(pathways): add canonical + Course JSON-LD to pathway pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pathway pages had zero JSON-LD and no . Adds: - via the shared absoluteUrl() helper - Course JSON-LD with a stable @id, matching the isPartOf: Course reference every lesson page already declares for its pathways (LessonJsonLd.astro) — that reference had no @id/url before this, so it was a dangling, unreferenceable node in the graph - about: DefinedTerm references derived from the union of topics covered by the pathway's own lessons, reusing the same controlled vocabulary lesson pages reference (no separate free-text list to drift out of sync) Course over ItemList/CollectionPage to stay consistent with the existing isPartOf convention rather than introduce a second type for the same concept. Left hasPart/itemListElement out — the page already renders the full lesson list and each lesson has its own JSON-LD, so enumerating them again on the Course node would be redundant weight for no real gain. Also fixes LessonJsonLd.astro's isPartOf Course stub to include the matching @id. --- src/components/LessonJsonLd.astro | 2 ++ src/pages/pathways/[id].astro | 39 +++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/src/components/LessonJsonLd.astro b/src/components/LessonJsonLd.astro index 070fc8c..7fd57c3 100644 --- a/src/components/LessonJsonLd.astro +++ b/src/components/LessonJsonLd.astro @@ -3,6 +3,7 @@ import type { Lesson } from "../lib/lessons"; import { isCreatedByUs, CREATOR_ORG } from "../lib/provenance"; import { getCollection } from "astro:content"; import { topicByName, termId, termSetId } from "../data/topics"; +import { absoluteUrl } from "../lib/urls"; interface Props { lesson: Lesson; @@ -86,6 +87,7 @@ if (lesson.roles.length > 0) { if (lesson.pathways.length > 0) { data.isPartOf = lesson.pathways.map((id) => ({ "@type": "Course", + "@id": absoluteUrl(Astro.site, `pathways/${id}`), name: pathwayMap[id] ?? id, })); } diff --git a/src/pages/pathways/[id].astro b/src/pages/pathways/[id].astro index d9cbb5f..13691ea 100644 --- a/src/pages/pathways/[id].astro +++ b/src/pages/pathways/[id].astro @@ -6,6 +6,9 @@ import LessonCard from "../../components/LessonCard.jsx"; import githubHealthRaw from "../../data/github-health.json"; import type { HealthSnapshot } from "../../lib/githubHealth"; import "../../components/lessons/lessons.css"; +import { absoluteUrl } from "../../lib/urls"; +import { topicByName, termId, termSetId } from "../../data/topics"; +import { CREATOR_ORG } from "../../lib/provenance"; const healthData = githubHealthRaw as unknown as HealthSnapshot; @@ -76,6 +79,37 @@ const sortedGroups = Object.entries(groupedLessons).sort(([keyA, aLessons], [key const minB = Math.min(...bLessons.map((l) => parseInt(l.sortingId) || 999)); return minA - minB; }); + +const canonicalUrl = absoluteUrl(Astro.site, `pathways/${id}`); + +// Subject terms this pathway covers, derived from its lessons' own controlled +// topic vocabulary (same DefinedTerm set the lesson pages reference), not a +// separate free-text list that could drift. +const aboutTerms = Array.from(new Set(pathwayLessons.flatMap((lesson) => lesson.topics))) + .map((topicName) => topicByName(topicName)) + .filter((t): t is NonNullable => t !== undefined) + .map((t) => ({ + "@type": "DefinedTerm", + "@id": termId(Astro.site, t.termCode), + name: t.name, + termCode: t.termCode, + inDefinedTermSet: termSetId(Astro.site), + })); + +// A pathway is a curated, self-paced sequence rather than formal instruction, +// but lesson pages already declare `isPartOf` a Course with this pathway's +// name (see LessonJsonLd.astro) — Course keeps that reference resolvable +// via a matching @id instead of leaving it a dangling, unreferenced node. +const pathwayJsonLd: Record = { + "@context": "https://schema.org", + "@type": "Course", + "@id": canonicalUrl, + name, + description, + url: canonicalUrl, + provider: { "@type": "Organization", name: CREATOR_ORG }, +}; +if (aboutTerms.length > 0) pathwayJsonLd.about = aboutTerms; --- + + +