PLAN-002: Sources catalog foundation — generator, per-source pages, front page, browse, brand pages
IMPLEMENTATION RULES: Before implementing this plan, read and follow:
- WORKFLOW.md - The implementation process
- PLANS.md - Plan structure and best practices
Status: Completed
Completed: 2026-05-12
Branch: feature/sources-catalog-foundation
Goal: Ship the structural shell of the sources catalog at atlas.sovereignsky.no/sources — a build-time generator that reads the v2 manifests + companion files (shipped by PLAN-001), a per-source product detail page for all 41 sources, a category-grid front page, a faceted browse-all page, and per-publisher brand pages. No merchandising layer yet (collections, use-case pages, featured datasets, "new & noteworthy", social-proof badges, visual sparklines) — those are PLAN-003.
Last Updated: 2026-05-12
Investigation: INVESTIGATE-sources-catalog-at-scale.md — see "Decisions resolved" section. PLAN-002 implements the foundation layer of the standard-v1 scope envelope.
Prerequisites:
- ✅ PLAN-001 — manifest schema v2, validator, companion files (publishers.yaml, source-categories.yaml), all 41 manifests carrying v2 fields.
Blocks:
PLAN-003-sources-catalog-merchandising— collections, use-case pages, featured rotation, social proof, sample-row previews, RSS feed.
Priority: High (the visible payoff of the catalogue work).
What this PLAN delivers
A visitor lands on atlas.sovereignsky.no/sources and sees:
- Front page — a category grid (7 cards: Health, Demographics, Income & poverty, Education & youth, Social & housing, NGO supply, Reference geographies) and a publisher strip (4 brand cards: SSB, FHI, Bufdir, Red Cross). One link to the faceted browse-all surface.
- Per-category index at
/sources/category/<id>— short editorial intro + grid of source cards in that category. - Per-publisher brand page at
/sources/by/<id>— publisher logo + bio + grid of all Atlas sources from that publisher. - Browse-all at
/sources/browse— searchable, faceted list of all 41 sources. Facets: provider, geo, eu_theme, cadence, lifecycle. Free-text search on title + description + keywords. - Per-source product detail page at
/sources/<id>— hero/buy-box with publisher logo, license badge, lifecycle badge, freshness, primary action ("Get this data" → copy-clickable PostgREST URL). Below the fold: provenance block, dimensions table, sample query, citation block, related/same-topic sources, prose summary. Schema.orgDatasetJSON-LD emitted for SEO.
What this PLAN does not deliver (deferred to PLAN-003):
- Curated collections /
/sources/collections/<slug>pages. - Use-case persona pages /
/sources/for/<persona>pages. - "Featured / dataset of the week" hero on the front page.
- "New & recently refreshed" strip + per-page RSS feed.
- Social-proof badges ("Used in N Atlas models", citation counts).
- Sparkline + Norway-thumbnail map + sample-row preview (visual richness — text + logos only in v2-foundation).
/sources/joinablecrosswalk page.
Architecture overview
Single source of truth: the per-source manifest.yml files + publishers.yaml + source-categories.yaml shipped by PLAN-001.
Build-time generator at website/scripts/generate-sources-registry.mjs:
- Walks
atlas-data/ingest/src/sources/*/manifest.yml. - Loads
publishers.yaml+source-categories.yaml. - Reads per-source
README.mdto extract the## Atlas summarysection if present. - Emits
website/src/data/sources-registry.json— single file consumed by all React components. - Emits
website/docs/sources/<id>.mdxper source — one MDX per source, URL =/sources/<id>(stable, per [Q6]). - Emits
website/docs/sources/by/<publisher-id>.mdxper publisher. - Emits
website/docs/sources/category/<category-id>.mdxper category. - Emits
website/docs/sources/browse.mdx— the faceted browse-all page. - The front page
website/docs/sources/index.mdxis hand-authored (uses React components) and stays as-is across regenerations.
CI drift gate: extends the existing check-manifests workflow to also re-run the generator and git diff --exit-code against committed output. If the registry or MDX files drift from the manifests, CI fails.
No per-source authoring after PLAN-002: all per-source MDX is generated. Editorial prose lives in the manifest's description: + the README's ## Atlas summary section. The catalog renders both; contributors don't touch generated MDX directly.
Phase 1: Generator script + registry JSON — DONE
Outcome (2026-05-12): Generator runs cleanly, emits sources-registry.json with 41 sources / 7 categories / 4 publishers. Full website build (npm run build) succeeds — prebuild hook regenerates registry automatically. Skipped ajv validation in the generator (the gate already enforces it upstream); trusts the data. Resolved publisher + category at generation time so consumers don't need to join.
Tasks
-
1.1 Created
website/scripts/generate-sources-registry.mjs:- ESM, runs under Node 20+ (matches existing
snapshot-openapi.mjsandsnapshot-lineage.mjs). - Uses
js-yaml(already inwebsite/node_modulestransitively; add explicit dep if needed). - Reads
atlas-data/ingest/src/sources/manifest.schema.jsononce and validates each manifest against it usingajv(already added in PLAN-001'satlas-data/ingest/package.json; either re-add here or invoke throughnode --experimental-vm-modulesif cross-package is fragile). - Resolves
tags.topic→ category (full object),publisher→ publisher (full object) at generation time so consumers don't need to do the join. - Reads per-source
README.mdand extracts the section under## Atlas summary(case-insensitive) if present; null otherwise. - Derives
latest_known_yearfromtime_coverage.end(ornullfor irregular). - Builds a default
sample_queryURL when the manifest'ssample_queryis empty:https://api-atlas.sovereignsky.no/<raw_table_name>?limit=5for the first raw table. - Derives
feedback_url= manifest.feedback_url ?? publisher.feedback_url.
- ESM, runs under Node 20+ (matches existing
-
1.2 Emit
website/src/data/sources-registry.jsonwith shape:{"generated_at": "2026-05-12T...","manifest_schema_id": "...","categories": [ {...resolved category objects...} ],"publishers": [ {...resolved publisher objects...} ],"sources": [{"source_id": "ssb-07459","upstream_title": "...","description": "...","atlas_summary": "...", // from README, may be null"publisher": { "id": "ssb", "display_name": "...", "logo": "..." },"category": { "id": "demographics", "name": "Demographics", "emoji": "👥" },"tags": { ... },"keywords": [...],"lifecycle": "stable","time_coverage": { "start": 1986, "end": 2026 },"methodology_notes": "...","suggested_joins": ["ssb-10826"],"related_by_topic": ["ssb-10826", "ssb-06913", "..."], // computed"related_by_join_keys": ["ssb-10826"], // computed from shared dimensions"dimensions": [...],"sample_query": "https://...","citation": {"text": "...","bibtex": "..."},"feedback_url": "...","eu_theme": "SOCI","license": "NLOD","license_url": "..."}]} -
1.3 Added
npm run sources:generate+prebuildhook to website/package.json. Also addedjs-yaml+@types/js-yamldevDeps. towebsite/package.json. Also hook it into aprebuildscript sonpm run buildregenerates automatically — but the committed JSON is still the contract for CI drift checks. -
1.4 First generated
sources-registry.jsoncommitted.
Validation
cd website
npm run generate:sources
test -f src/data/sources-registry.json && \
jq '.sources | length' src/data/sources-registry.json # 41
jq '.categories | length' src/data/sources-registry.json # 7
jq '.publishers | length' src/data/sources-registry.json # 4
User confirms phase is complete.
Phase 2: React components — DONE
Outcome (2026-05-12): 14 components shipped (slight rename of one — SourceCategoryList and SourcePublisherList covered Phase 3's category/publisher pages naturally), plus src/types/sources.ts and src/utils/sources.ts. Shared styles.module.css rather than per-component dirs. Typecheck passes; full build passes (components compile + import cleanly; Phase 3 will exercise their rendering). Dropped explicit JSX.Element return-type annotations after TS objected — React 18 + TS 5 prefers inference here.
Tasks
-
2.1
<SourceHero source={source}>— the buy-box. Renders:- Title + publisher logo + license badge.
<LifecycleBadge lifecycle={source.lifecycle}>— colour-coded ("Stable" green / "Beta" yellow / "Deprecated" grey / "Broken" red).<FreshnessBadge>— "Last refreshed YYYY-MM-DD" (placeholder ISO date pulled fromtime_coverage.endfor v2-foundation; PLAN-003 wires realmart_ingest_healthdata).- Primary action — "Get this data" button that expands an inline panel with the source's
sample_queryURL + acurlsnippet + a Pythonrequestssnippet. Copy-to-clipboard buttons. - Secondary actions — "Cite this source" (anchor
#citation), "View upstream" (links toupstream_landing_pageorupstream_url), "Report a data issue" (links tofeedback_urland to the Atlas GitHub issue tracker with a templated body).
-
2.2
<SourceProvenance source={source}>— provenance block:- Upstream URL + landing page.
- Attribution string verbatim.
- License + license URL.
methodology_notesMarkdown (or honest "no methodology notes yet — see upstream documentation" placeholder).
-
2.3
<SourceDimensions source={source}>— table rendering ofdimensions[]:- Columns: Code, Meaning, Value format, Notes.
-
2.4
<SourceCitation source={source}>— citation block:- One-line text citation (publisher, table number, accessed-via, today's date).
- BibTeX block in a copy-clickable
<pre>. - Permalink:
https://atlas.sovereignsky.no/sources/<id>.
-
2.5
<RelatedSources source={source} registry={registry}>— horizontal card row:- Combine
suggested_joins[](manifest-authored) +related_by_topic+related_by_join_keys, dedupe, cap at 6. - Each card: title, publisher logo, category emoji, lifecycle badge.
- Combine
-
2.6
<SourceCard source={source}>— used by browse / category / publisher pages:- Title, publisher logo, category badge, lifecycle badge, 2-line description, link to
/sources/<id>.
- Title, publisher logo, category badge, lifecycle badge, 2-line description, link to
-
2.7
<SourceCategoryGrid registry={registry}>— front-page category grid.- One large card per category (using
source-categories.yamldata + count of sources in it).
- One large card per category (using
-
2.8
<PublisherStrip registry={registry}>— horizontal strip of publisher brand cards (logo + count). -
2.9
<SourcesBrowse registry={registry}>— faceted list:- Free-text search input.
- Facet sidebar: provider, geo, eu_theme, cadence, lifecycle (multi-select).
- Grid of
<SourceCard>filtered by current selection. - URL query params reflect current facet state (deep-linkable).
-
2.10
<SchemaOrgDataset source={source}>— emits<script type="application/ld+json">with the Schema.orgDatasetJSON-LD payload. Maps manifest fields to Schema.org properties (name,description,creator,license,temporalCoverage,keywords,distribution,isAccessibleForFree: true).
Validation
- All components typecheck under
tsc --noEmit. - Components are pure (no data fetching) — they read from the registry JSON via props.
User confirms phase is complete.
Phase 3: Per-source MDX generation — DONE
Outcome (2026-05-12): Generator now emits 41 per-source + 7 per-category + 4 per-publisher MDX files. Full build passes — all components render through SSR cleanly. Pulled sidebar reconfig forward from Phase 4 to keep the intermediate state clean (without it, the autogenerated sidebar would have ballooned to 50+ entries).
Tasks
-
3.1 Extended
generate-sources-registry.mjsto also writewebsite/docs/sources/<source_id>.mdx. Template:---title: "<upstream_title>"description: "<description, first sentence ≤ 160 chars>"slug: /sources/<source_id>sidebar_label: "<source_id>"hide_table_of_contents: false---import {sourceById} from '@site/src/utils/sources';import SourceHero from '@site/src/components/sources/SourceHero';import SourceProvenance from '@site/src/components/sources/SourceProvenance';import SourceDimensions from '@site/src/components/sources/SourceDimensions';import SourceCitation from '@site/src/components/sources/SourceCitation';import RelatedSources from '@site/src/components/sources/RelatedSources';import SchemaOrgDataset from '@site/src/components/sources/SchemaOrgDataset';export const source = sourceById('<source_id>');<SourceHero source={source} />## Provenance {#provenance}<SourceProvenance source={source} />## Dimensions {#dimensions}<SourceDimensions source={source} />## Sample query {#sample-query}```bashcurl '<source.sample_query>'Citation
Related sources
About this source
<source.description as rendered Markdown>
<optional: source.atlas_summary from README>
``` Anchor IDs match the [Q34] stable-anchor contract:
#provenance,#methodology,#dimensions,#sample-query,#citation,#related. -
3.2 Add
website/src/utils/sources.ts— small helpers:getAllSources()→ reads fromsources-registry.json.sourceById(id)→ typed lookup.sourcesByCategory(catId)→ filter.sourcesByPublisher(pubId)→ filter.
-
3.3 Generate per-category index MDX files at
website/docs/sources/category/<id>.mdx:- Renders category description +
<SourceCategoryList categoryId={id}>(a component usingsourcesByCategory).
- Renders category description +
-
3.4 Generate per-publisher brand pages at
website/docs/sources/by/<id>.mdx:- Renders publisher logo + bio (from
publishers.yaml) +<SourcePublisherList publisherId={id}>.
- Renders publisher logo + bio (from
Validation
ls website/docs/sources/*.mdx | wc -l # 41 source pages + index/browse + categories/publishers
ls website/docs/sources/by/*.mdx | wc -l # 4 (publishers)
ls website/docs/sources/category/*.mdx | wc -l # 7 (categories)
cd website && npm run build # passes onBrokenLinks gate
User confirms phase is complete.
Phase 4: Top-level pages — index, browse — DONE
Outcome (2026-05-12): Replaced the 4-line stub at docs/sources/index.md with a rich index.mdx that renders the front-page composition (category grid + publisher strip + link to browse). Added docs/sources/browse.mdx (single component wrapper for the faceted browse view). Sidebar pulled forward in Phase 3 already; just added sources/browse here. Build clean.
Tasks
-
4.1 Replaced the existing stub at
website/docs/sources/index.mdwith an MDX that renders the front-page composition (category grid + publisher strip + link to browse). Hand-authored (not generated) so editorial control survives regenerations.---title: Sourcesslug: /sources---import SourceCategoryGrid from '@site/src/components/sources/SourceCategoryGrid';import PublisherStrip from '@site/src/components/sources/PublisherStrip';import {getAllRegistry} from '@site/src/utils/sources';export const registry = getAllRegistry();# Atlas sourcesAtlas aggregates public Norwegian data from SSB, FHI, Bufdir, and NGO supplysources, plus reference geographies. Browse by category, by publisher, orsearch every source.## Browse by category<SourceCategoryGrid registry={registry} />## Browse by publisher<PublisherStrip registry={registry} />[See all 41 sources →](./sources/browse)(No hero, no curated collections, no "for X" strip, no "new & noteworthy" — those land in PLAN-003.)
-
4.2 Generate
website/docs/sources/browse.mdx(could also be hand-authored — single-page; doesn't change per source). Renders<SourcesBrowse registry={registry} />. -
4.3 Update
website/sidebars.tsto expose/sourcesand its top-level landings (browse + per-category + per-publisher index pages) only — not the 41 per-source pages. Use explicit sidebar items, notautogenerated, so the 41 sources don't bloat the sidebar.{type: 'category',label: 'Sources',link: { type: 'doc', id: 'sources/index' },items: ['sources/browse',{type: 'category',label: 'By category',items: ['sources/category/health','sources/category/demographics',// ... 7 entries, generated or hand-listed],},{type: 'category',label: 'By publisher',items: ['sources/by/ssb','sources/by/fhi','sources/by/bufdir','sources/by/redcross',],},],},
Validation
-
npm run buildsucceeds. -
npm startshows the front page atlocalhost:3000/sourceswith the category grid and publisher strip. - Clicking a category card → category landing → source card → per-source page navigation works end-to-end.
User confirms phase is complete.
Phase 6: Homepage + navbar wiring — DONE
Outcome (2026-05-12): Added after the Phase 5 commit during local-preview review — the catalog existed but had no front door from /. Replaced docs/index.md with index.mdx that renders the slogan + <SourceCategoryGrid /> + <PublisherStrip /> at the top, with the docs intro pushed below. Added "Sources" to the navbar as the leftmost item so it surfaces from every page. Modelled on the dev-templates pattern (tmp.sovereignsky.no) per the web-shop framing in the INVESTIGATE.
Tasks
- 6.1 Replaced
docs/index.mdwithdocs/index.mdx. Hero (title + tagline) + "Browse by topic" (SourceCategoryGrid) + "Browse by publisher" (PublisherStrip) + link to /sources/browse + secondary documentation links + conventions. Sidebar still works because the route is unchanged. - 6.2 Added "Sources" to the navbar (leftmost left-position item) pointing to
/sourceswithactiveBasePath: '/sources'so it stays highlighted across the whole catalog subtree. - 6.3 Build verified locally.
Phase 5: CI drift gate + contributor docs — DONE
Outcome (2026-05-12): Extended .github/workflows/check-manifests.yml from one job to two — check-manifests keeps the manifest-shape gate; check-catalog regenerates the catalog and git diff --exit-codes against committed output. Updated adding-a-source.md Step 4b with the regenerate-and-commit step. Added new sources-catalog.md contributor doc. Local build clean across all changes.
Tasks
-
5.1 Extend
.github/workflows/check-manifests.yml(or add a sibling step):- After
check-manifests.shpasses, runcd website && npm ci && npm run generate:sources. git diff --exit-code src/data/sources-registry.json website/docs/sources/— fail if anything drifts.
- After
-
5.2 Update
website/docs/contributors/adding-a-source.mdStep 4b: note that after committing a manifest, the next push must also regenerate the catalog. Documentnpm run generate:sourcesworkflow. -
5.3 Add
website/docs/contributors/sources-catalog.md— a short contributor doc on the catalog architecture: where to find the generator, what's generated vs hand-authored, how to add a new category, how to add a new publisher. -
5.4 Move PLAN-002 from
plans/active/→plans/completed/once Phase 4 ships and the live site renders the catalog cleanly.
Validation
- CI gate triggers on a synthetic manifest change without regenerating registry — drift gate fails.
- User confirms the catalog renders cleanly at
atlas.sovereignsky.no/sourcesafter deploy.
Acceptance Criteria
-
website/scripts/generate-sources-registry.mjsexists, runs cleanly, emitssources-registry.json+ 41 per-source MDX + 7 per-category MDX + 4 per-publisher MDX + 1 browse MDX. - All 9 React components under
website/src/components/sources/exist, typecheck, and render against the registry data. -
/sourcesfront page shows category grid + publisher strip. -
/sources/browseshows faceted browse-all with search. -
/sources/<source_id>for all 41 sources renders the product detail page with the stable anchor IDs. -
/sources/category/<id>for all 7 categories. -
/sources/by/<publisher>for all 4 publishers. - Schema.org
DatasetJSON-LD emitted on every per-source page (verified bycurl … | grep '"@type":"Dataset"'). - CI drift gate fails the build when the registry is stale.
- No more than 6 entries in the rendered Docusaurus sidebar under "Sources" (landing + browse + by-category + by-publisher submenus — not 41 per-source links).
- Local
npm run buildpasses with no broken-link errors (verified per the Run Docusaurus locally before pushing discipline).
Implementation Notes
-
Generator language: JavaScript ESM (
.mjs) like the existing snapshot scripts. The website's package.json already runs Node 20+; no new TypeScript build step needed. -
Why MDX per source instead of dynamic
[id].tsx— per [Q7] resolution. MDX gives stable URLs, sitemap inclusion, OpenGraph cards, indexability for Google Dataset Search. Dynamic routes would lose all of that. Sidebar bloat is solved by explicitsidebars.tslisting rather than autogenerated. -
Single registry JSON vs split files — per [Q18] resolution. ~200 KB at 200 sources is fine for the build; splitting complicates the consumer code.
-
Front page is hand-authored, per-source pages are generated: this is the dev-templates pattern. Editorial control of the front page survives generator runs; per-source pages stay automatic.
-
Render
methodology_notesas Markdown, not plain text. The manifest field allows multi-line block scalars in YAML; the registry preserves the raw string; the React component renders it viareact-markdownor similar. Addreact-markdowntowebsite/package.jsonif not already there. -
Stable anchor IDs — [Q34] is a contract. Once shipped, renaming
#provenanceto#sourceis a breaking change for anyone deep-linking into Atlas (sister documents, third-party tools, LLM crawlers). -
Schema.org JSON-LD is invisible to users but crawled by Google Dataset Search and indexed by LLM training. Worth doing — cheap one-time win for SEO + LLM discoverability.
-
The CI drift gate may produce noisy diffs when a contributor edits a manifest but forgets to regenerate. The error message should tell them exactly what to run:
cd website && npm run generate:sources && git add src/data/sources-registry.json website/docs/sources/. -
PLAN-003 builds on top — it adds collections / use-case pages / featured / new & noteworthy / social proof / sample-row previews / sparklines / Norway map. Nothing in PLAN-002 is throwaway; PLAN-003 layers on it.
Files to Create or Modify
New files
website/scripts/generate-sources-registry.mjs— the generator (Phase 1).website/src/data/sources-registry.json— generator output, committed (Phase 1).website/src/utils/sources.ts— helpers (Phase 3).website/src/components/sources/SourceHero.tsxwebsite/src/components/sources/SourceProvenance.tsxwebsite/src/components/sources/SourceDimensions.tsxwebsite/src/components/sources/SourceCitation.tsxwebsite/src/components/sources/RelatedSources.tsxwebsite/src/components/sources/SourceCard.tsxwebsite/src/components/sources/SourceCategoryGrid.tsxwebsite/src/components/sources/PublisherStrip.tsxwebsite/src/components/sources/SourcesBrowse.tsxwebsite/src/components/sources/SchemaOrgDataset.tsxwebsite/src/components/sources/LifecycleBadge.tsxwebsite/src/components/sources/FreshnessBadge.tsxwebsite/src/components/sources/SourceCategoryList.tsxwebsite/src/components/sources/SourcePublisherList.tsxwebsite/docs/sources/browse.mdx— generated (Phase 3).website/docs/sources/<id>.mdx× 41 — generated (Phase 3).website/docs/sources/category/<id>.mdx× 7 — generated (Phase 3).website/docs/sources/by/<id>.mdx× 4 — generated (Phase 3).website/docs/contributors/sources-catalog.md— new contributor doc (Phase 5).
Modified files
website/docs/sources/index.md→ replaced withindex.mdxcontaining the front-page composition (Phase 4).website/sidebars.ts— explicit landings-only sidebar config under Sources (Phase 4).website/package.json— addgenerate:sourcesnpm script,prebuildhook, possiblyreact-markdowndep (Phase 1, Phase 2)..github/workflows/check-manifests.yml— extend with the drift gate (Phase 5).website/docs/contributors/adding-a-source.md— note the catalog regeneration step (Phase 5).