A minimal, static catalogue of entries — a framework you can populate later. Each entry is an abstract "item" (it will eventually hold reports, but nothing in the code assumes that). Items can link out to an externally hosted page or document; this app is purely a discovery / index layer.
Built with Astro + TypeScript, fully static, client-side search and filtering, no backend, no database, no external APIs.
Status: scaffold. The structure, catalogue mechanics and a handful of dummy items + placeholder pages are in place. The copy (home, about, get-involved, contact) and the dummy data under
src/content/items/are meant to be replaced.
Requires Node 22+ (it also runs on Node 20.3+).
npm install
npm run dev # local dev server
npm run build # static build → dist/
npm run preview # serve the built site
npm run check # astro + TypeScript type check
npm run test:logic # verify the pure filtering/URL logic
npm run test:data # validate every item JSON against the schema
npm run test # both of the above- Home (
/) — short description + a live item count. - Catalogue (
/catalogue) — the primary feature:- Multi-select filters for category, topic, type, derived dynamically from the data (never hardcoded). These three facet names are generic placeholders — rename them in one place (see below).
- Case-insensitive search across title, description, keywords and authors.
- Filters + search combine as
(category AND topic AND type) AND search, with OR within a facet and AND across facets. - All filter/search state lives in the URL
(
?category=Research&type=Brief&search=health) so views are shareable and browser back/forward works.
- Markdown pages (
/about,/get-involved,/contact, …) — driven entirely by files insrc/content/pages/. Add a page by dropping in a Markdown file; no routing changes.
Two-layer content architecture:
src/
├─ content/
│ ├─ items/*.json # STRUCTURED data layer — one JSON document per item
│ └─ pages/*.md # CONTENT layer (static markdown pages)
├─ content.config.ts # defines the `pages` collection (+ stops auto-collection)
├─ lib/
│ ├─ types.ts # Item type, facet/state types (single source of truth)
│ ├─ items.ts # DATA ABSTRACTION LAYER — the only place that knows the source
│ └─ filtering.ts # pure filter + URL (de)serialisation logic (no DOM, testable)
├─ components/
│ ├─ Layout.astro # site shell (head, nav, footer)
│ ├─ SearchBar.astro
│ ├─ FilterPanel.astro # renders facets derived from the data
│ └─ ItemCard.astro # depends only on core fields; everything else optional
├─ scripts/
│ └─ catalogue.ts # the only client JS: filtering + URL state (~2KB)
├─ styles/global.css # design tokens + base + prose styles
└─ pages/
├─ index.astro
├─ catalogue.astro
└─ [slug].astro # generic route for every markdown page
Clear separation of concerns:
- Data layer (
lib/items.ts) is the only module that reads the data source. Today it bundles a folder of per-item JSON documents (src/content/items/*.json) viaimport.meta.glob; tomorrow it canfetch()a remote source or read generated data — the UI never changes because the returned shape staysItem[]. It also normalises records defensively, sorts them by title and derives facets. - Filtering logic (
lib/filtering.ts) is pure and framework-free, so the same rules are testable (npm run test:logic) and mirrored by the client. - UI components depend only on the three guaranteed fields and render optional metadata only when present.
Only id, title, description are guaranteed. Everything else (url,
authors, category, topic, type, keywords) is optional, and the meta
bag holds arbitrary future fields (date, status, source, region, …).
Unknown fields are ignored safely and never break rendering — so new metadata
can be added over time without schema migrations: add it under meta, which
is an open object. (The JSON Schema validates the known fields and steers
genuinely new data into meta rather than new top-level keys, keeping the
contract stable.)
The three facets (category, topic, type) are intentionally generic. To
rename one (e.g. topic → theme) or add one (e.g. region):
- Edit the
FACET_KEYStuple and add/rename the optionalstringfield onIteminsrc/lib/types.ts. - Add the matching label in
FACET_LABELSinsrc/lib/items.ts. - Update the empty-state shape in
emptyState()insrc/lib/filtering.ts. - Update the facet's entry in
schemas/item.schema.json.
The filter panel, card chips and client filtering all read from FACET_KEYS, so
nothing else needs to change.
Drop a new JSON document into src/content/items/, one file per entry. The id
is a stable, globally-unique kebab-case slug; the filename must be <id>.json
(e.g. id example-item-01 → src/content/items/example-item-01.json):
{
"id": "example-item-01",
"title": "Title",
"description": "One-paragraph summary.",
"url": "https://example.com/items/01",
"authors": ["Author One"],
"category": "Research",
"topic": "Health",
"type": "Summary",
"keywords": ["topic", "topic"],
"meta": { "status": "draft" }
}Only id, title and description are required. The data layer picks the file
up automatically (no central list to edit), and new facet values appear in the
filters automatically. Catalogue order is by title.
Each document is validated against a JSON Schema
(schemas/item.schema.json):
- In your editor:
.vscode/settings.jsonmaps the schema ontosrc/content/items/*.json, so VS Code flags missing/mistyped fields as you type. - In CI / locally:
npm run test:datavalidates every document against the same schema and checks catalogue-wide invariants (uniqueids; filename derived fromid). The runtime data layer stays lenient and skips malformed records, so this check is what fails loudly on an authoring mistake before an item silently drops out of the catalogue.
Drop a Markdown file into src/content/pages/:
---
title: FAQ
slug: faq
template: page
description: Frequently asked questions.
# optional nav controls:
nav: true # default true — set false to keep it routable but out of the menu
order: 4 # optional sort key for the header nav (lower first)
---
# FAQ
…It is served at /faq and is added to the header nav automatically (the nav
is built from the pages collection, after Home and Catalogue). No routing or
layout changes needed. Use nav: false to hide a page from the menu, and
order to position it (ties fall back to alphabetical by title).
Note on
layoutvstemplate: Astro 5 reserves thelayoutfrontmatter key for its legacy markdown layout feature (it tries to resolve the value as a component path, which breaks the build). We usetemplateinstead as a neutral layout hint; the generic[slug].astroroute applies the site layout.slugdefaults to the filename when omitted.
The catalogue is just the src/content/items/ folder. Updating it — now or
in the future — means adding, editing or removing one <id>.json file per entry.
Nothing else has to change: the data layer (src/lib/items.ts)
picks files up via import.meta.glob, facet values re-derive from the data, and
the catalogue rebuilds. Whatever produces those files, every one is validated
against schemas/item.schema.json by
npm run test:data in CI, so malformed data is caught before it ships.
Any change merged to main triggers a rebuild + redeploy via
.github/workflows/deploy.yml. So the question is
only ever "how do the JSON files get written?" — and the design keeps that
question open. A few patterns, none of which touch the UI:
-
Hand-authored PRs. A maintainer drops or edits a file in
src/content/items/and opens a pull request. CI validates it; merge deploys. This works today with no extra setup. -
Push from each item's source (PR-based automation). If each entry maps to its own upstream repo, that repo can carry a small metadata file and run a GitHub Action that opens/updates a PR against this repo, writing or refreshing its own
<id>.json. Maintainers review and merge; the deploy workflow does the rest. This keeps each source the owner of its own entry while this repo stays the catalogue/index. -
Pull from external sources (scheduled, within this repo). A scheduled workflow here (
on: schedule) fetches metadata from the external sources, regenerates the affected<id>.jsonfiles, and either commits tomaindirectly or opens a PR for review. Good when the upstream sources can't (or shouldn't) push into this repo themselves. -
Skip committing data entirely. Because
src/lib/items.tsis the single data seam,getItems()could insteadfetch()a remote source (an API, a published JSON index) at build time. The returned shape staysItem[], so the components never change — only that one function does.
None of this automation is implemented yet — the repo is deliberately set up
so it can be added later without a structural rewrite: the data layer is the one
swap point, the JSON Schema is the shared contract (additive via the open meta
bag, so new fields need no migration), and facets/filters derive from the data so
new values appear on their own.
Plain CSS with design tokens, not a utility framework. The design is
deliberately minimal and the surface area is small, so a handful of CSS custom
properties (spacing scale, type scale, colour) plus per-component scoped styles
are easier to read and audit than a framework — and ship near-zero CSS overhead.
Tokens live in src/styles/global.css; everything references them. Change the
accent by editing the --color-accent* tokens there.
Plain case-insensitive substring matching over a precomputed searchable
string (title + description + keywords + authors). It fully meets the
requirements with zero dependencies. If the dataset grows large enough to
need ranked/fuzzy search, lib/filtering.ts is the single seam to swap in
Fuse.js or FlexSearch (client-side only — no external search service).
Fully static (dist/), so it works as-is on GitHub Pages and Cloudflare
Pages.
- Cloudflare Pages / custom domain / root: remove
baseand setsiteto the real host inastro.config.mjs. - GitHub Pages project subpath:
siteandbaseare already set as placeholders (base: '/cimh-website'). All internal links useimport.meta.env.BASE_URL, so they respectbase. Updatesiteto the deploying account/org before going live.
This is a framework, not a finished site. Before launch you'll want to:
- replace the placeholder copy in
src/pages/index.astro, the footer inLayout.astro, and the markdown pages undersrc/content/pages/; - replace the dummy entries in
src/content/items/with real data; - rename the facets if
category/topic/typearen't the right axes; - set the real
site(and brand namesiteNameinLayout.astro); - decide how entries will be kept up to date and, if automating, wire up the workflow (see Keeping the catalogue up to date).