Spike evaluation: Eleventy + Pagefind + Decap CMS
Third and final SSG iteration through SPECIFICATION.md's open questions, built to the same bar as the Astro and Next.js spikes (spikes/astro/content-pages/evaluation.md, spikes/nextjs/content-pages/evaluation.md), reading the same shared /data + /schema. Pagefind was reused as-is, same reasoning as the Next.js spike.
What this spike proves works
- The easiest of the three for "shared data available in every template."
src/_data/entries.jsdefault-exports a function returning the parsed YAML array — Eleventy's "global data file" convention means every template automatically gets anentriesvariable, no explicit per-page wiring, no schema/type layer at all if you don't want one (this spike still hand-writes the same field list as the others forentryFields, but nothing forces it the way Astro's Zod schema does). - Native markdown rendering, no extra library. Unlike the Next.js spike (which needed
marked— Next.js has no built-in markdown pipeline), Eleventy is markdown-first: any.mdfile in the input tree is a page by default.content-pages/content-pages.jsonis a directory data file supplying a defaultlayoutand a computedtitle/permalinkfrom the filename — that's the entire mechanism. Confirmed by dropping a throwaway file intocontent-pages/with zero code changes and rebuilding (see below). - Pagination is the most concise of the three "one page per data item" mechanisms.
src/entries.njk's front matter (pagination: { data: entries, size: 1, alias: entry }+ a computedpermalink) replaces Astro'sgetStaticPaths()and Next.js'sgenerateStaticParams()— same result, fewer lines, no separate function to write. - No client-JS bundler at all.
src/filters.jsandsrc/pagefind-search.jsare plain vanilla JS, passthrough-copied unchanged — no TypeScript, no Vite/webpack magic comments needed to stop a bundler from statically resolving/pagefind/pagefind.js(both the Astro and Next.js spikes needed@vite-ignore/webpackIgnorecomments for exactly this; Eleventy has no equivalent problem because it never touches JS files it isn't told to template). This cuts both ways — see friction below. - Filters, Pagefind search, and the "Sangya" test all carry over unchanged. Same 8 facets, same Go/Reset behaviour, same field/id naming as the other two spikes' filter forms (reimplemented per spike; no cross-spike code sharing). Confirmed by directly querying the built Pagefind index in Node: searching
"Sangya"returnscohort-discovery-service. - CMS wiring needed only path changes from the Astro/Next.js configs, again confirming
SPECIFICATION.md's expectation that this config shape carries over SSG to SSG. Verified viadecap-server's proxy API:entriesByFolderreturned all 13 entries and both markdown pages;getEntryreturned the banner settings file. - The shared banner singleton renders identically here too — third confirmation that the CMS's banner requirement is satisfiable the same way regardless of SSG.
Friction encountered (and fixed)
- Directory data files inherit their directory's path in the output URL by default.
content-pages/summary.mdinitially built to/content-pages/summary/— Eleventy preserves the input directory structure in permalinks unless told otherwise. Fixed with aneleventyComputed.permalinkin the directory data file (/evaluation/index.html), stripping thecontent-pages/prefix so URLs match the Astro/Next.js spikes'/summary/,/evaluation/convention. import.meta.url-relative paths are actually safe here, unlike the other two spikes — worth confirming why, not just assuming. Eleventy runs_data/*.jsfiles directly under Node with no bundling step, sopath.dirname(fileURLToPath(import.meta.url))correctly points at the real file location, at botheleventy --serveand build time. This is the one SSG of the three where the naive "resolve relative to this file" approach was never going to break — confirmed by checking how Eleventy actually executes data files (no bundler in the chain) rather than assuming it'd hit the same issue as Astro/Next.js and defensively avoidingimport.meta.urlout of habit.- No bundler also means no type-checking and no automatic minification for client JS. The flip side of "no build step for
filters.js": a typo or type error in that file is only caught by loading the page in a browser, not bynpm run build— Astro and Next.js both catch equivalent mistakes at build time via TypeScript. For a spike this size that's a fair trade; for a larger real deployment, worth deciding whether to add a lightweight bundler/type-check step back in deliberately, not by default. - Admin bootstrap HTML needed explicit exclusion from Eleventy's own template processing. Eleventy treats
.htmlas a template format by default; withouteleventyConfig.ignores.add('src/admin/index.html'), Eleventy would have tried to process Decap's bootstrap page through its own template engine and the passthrough copy would have written to the same output path — added the.ignoresexclusion so the file is copied byte-for-byte, same intent as the Astro spike's routing fix (though a different underlying cause: Astro's issue was directory-index resolution inpublic/, Eleventy's is template-engine collision). - Markdown-file computed data defaults to Liquid templating, not Nunjucks, even though every other template in the project uses Nunjucks (
.njk). SetmarkdownTemplateEngine: 'njk'in the Eleventy config socontent-pages.json's computedtitle/permalinkexpressions use the same templating language asbase.njkandentries.njk, rather than mixing two template languages in one project.
Second layout, images, and environment rendering (SPECIFICATION.md, "Demonstrate alternative layouts and images" / "Individual environments/rendering")
- A second layout (
article.njk) needed no routing restructure at all — Eleventy has no concept of a single enforced root layout; any template just names a differentlayout:in its front matter.src/article-page.njk(layout: article.njk) sits alongsidesrc/entries.njk/content-pages/*.md(layout: base.njk) with no other page affected. The simplest of the three SSGs for this specific requirement, on par with Astro's per-page opt-in. - Eleventy's vaunted "native markdown, no library needed" turned out to have a real limit worth correcting here: it only applies to files inside its own configured
inputdirectory. Eleventy's page-discovery is tied to oneinput:root (src/, pereleventy.config.js) — there's no equivalent of Astro's content-collectionloaderbaseoption for pointing page-discovery at an arbitrary external path./markdown(a repo-root sibling of/data, like the other two spikes read) is outsidesrc/, so it needed the same kind of explicit bridge Next.js needs (src/_data/articles.js, reading the folder with plainfsand rendering with an explicitly-addedmarkdown-itdependency), not Eleventy's automatic.md-is-a-page convention. This nuances the earlier "easiest of the three for native markdown" finding — true for content that lives inside the project, not true for a folder shared across all three spikes. - Confirmed empirically (not assumed) that Eleventy's markdown-it does not resolve relative image paths, even for files it does render natively. A throwaway test page one directory level deep rendered
../images/foo.pngcompletely unchanged as literal text — it only "worked" visually by coincidence of relative-URL depth, not real resolution, and would have broken immediately under aBASE_PATHsubpath or at a different nesting depth. So the same explicit rewrite Next.js needed was used here too:src/_data/articles.jsreplaces../images/with${BASE_PATH}/images/articles/before handing the markdown to markdown-it, and the two PNGs are passthrough-copied via the existingaddPassthroughCopy('src/images')(already covers the newsrc/images/articles/subfolder with no config change). Verified correct under both the default build and aBASE_PATH=/eleventybuild. SITE_ENV/SITE_BRANDneeded the least new mechanism of the three SSGs —src/_data/deploymentConfig.jsis a plain global data file (the same conventionentries.js/siteSettings.jsalready established), no config-file plumbing like Next.js'snext.config.tsenvfield. Verified:BASE_PATH=/eleventy SITE_ENV=preprod SITE_BRAND="Gateway Preview" npx eleventyshows the ribbon and rebadged wordmark/title on bothbase.njkandarticle.njkpages, with correctly-prefixed image paths, and no ribbon/default branding when unset.- Decap's
articlesfolder collection needed the same one shape again (folder: "markdown", repo-root-relative likeentries, notspikes/eleventy/...likepages) — confirmed viadecap-server's proxy API returningmarkdown/outline.md.
Search-index-driven listing and pagination (SPECIFICATION.md, "Search-index-driven listings with pagination")
- The homepage no longer server-renders any entry cards. The first pass rendered all 13 cards in
index.njk(with the dataset re-encoded into|-delimiteddata-*attributes) andfilters.jshid non-matches — wrong at the 100s-of-entries scale the spec targets. Nowfilters.jsfetches/search-data.json, applies free text (Pagefind, or a corpus-substring fallback pre-build) plus the 8 structured filters against corpus objects, and renders only the current page of results, escaping every CMS-authored field before it touchesinnerHTML. - The corpus endpoint is an Eleventy "JavaScript template" (
src/search-data.11ty.js, permalink/search-data.json) — the tidiest of Eleventy's mechanisms for emitting a computed non-HTML file, and the counterpart of Astro'ssearch-data.json.tsendpoint and Next.js's route handler. Same shape and field coverage in all three, including abodyfield carrying every textual attribute. - Pagination + page size: previous/next with a page indicator, per-page selector 5/10/20/50/100 defaulting to 20, page resets to 1 on any query/filter/size change, Reset restores the default size.
- The dev-mode fallback now substring-searches the corpus's full
bodyfield (which includescontactTeamPerson), so even pre-build dev search passes the "Sangya" test — it lacks Pagefind's ranking, and the mode label says so. The fragile|-delimiter encoding between template and script is gone entirely. - Trade-off accepted knowingly: the listing is client-rendered, so it's empty without JavaScript (a
<noscript>note says so). Entry pages remain fully static; Pagefind indexes those, so content search/SEO is unaffected. - Verified: built
_site/index.htmlcontains zero pre-rendered cards;_site/search-data.jsoncarries all 13 entries withbodyincluding never-displayed fields; aBASE_PATH=/eleventybuild produces correctly-prefixed corpus URLs; the "Sangya" fallback match and the pagination math (13 entries at size 5 → 3 pages, empty result → 1 page) were checked directly in Node against the built corpus.
CMS future-requirements read-through (SPECIFICATION.md, "CMS gui like options")
Same as both prior spikes' reads — Decap CMS's capabilities don't change per SSG, only the config.yml paths do:
- Non-developer content/wording edits: works — folder collections for entries and pages.
- Banner on/off + text/link: works — shared singleton, same as the other two.
- Separate instances per Gateway environment: partially addressed this round via the
SITE_ENV/SITE_BRANDbuild-time flags (see above) — CMS backend routing per environment still untested. - Images/embedded video: images now tested (see above) — video embedding still open.
- Unique page layouts per page: now tested —
article.njkproves a second, visually distinct layout is achievable with the least ceremony of the three SSGs. Still open: a single collection choosing its layout per entry via a frontmatter field, rather than two separate folder collections each hardwired to one layout.
What was actually run (for reproducibility)
npm init -y # then set "type": "module", scripts, dependencies by hand
npm install @11ty/eleventy yaml
npm install -D pagefind decap-server serve
npm run build # eleventy && pagefind --site _site
npm run preview # serve _site — verified via curl
npm run cms # decap-server from repo root; queried its API directly for entries/pages/banner
Also run directly against the built output, not inferred from code:
- Grepped
_site/entries/<id>/index.htmldirectly to confirm full entry content is present in the raw static HTML. - Queried the built Pagefind index directly in Node (
pagefind.search('Sangya')) — returnedcohort-discovery-service. - Dropped a throwaway markdown file into
content-pages/, rebuilt, confirmed a matching styled page, removed it.
Post-spike code review (senior developer/architect pass)
An independent review of this spike's code, conducted after completion, from the perspective of a senior developer/architect deciding what carries into the next stage of development. Since Eleventy is REPORT.md's recommended engine, this spike got the most scrutiny — its patterns are the likely production foundation. The review also covers the repo-root scripts/build-site.mjs and scripts/deploy-gcs.sh, which would ship with it.
AI declaration: original implementation by Claude Sonnet 5 (claude-sonnet-5); this review by Claude Fable 5 (claude-fable-5), based on a full read of every source file in this spike, the shared build/deploy scripts, the spec, the report, and the canonical schema. Every finding below was verified against the actual code, not inferred.
Code-level improvements
- Schema triplication with nothing to catch drift.
/schema/hdc-entry.schema.json's enums are hand-copied into all seven select-widget option lists insrc/admin/config.yml, and the field list is hand-copied again into the 18 label/accessor pairs of theentryFieldsfilter ineleventy.config.js. A new field or enum value takes three coordinated edits. Generate the CMS options and field list from the schema at build time, or at minimum add a CI diff check. - Duplicate YAML load:
src/_data/filterOptions.jsre-reads and re-parses every entry file thatsrc/_data/entries.jsalready loaded — extract a shared loader. ItsMath.min(...adoptingValues)also yieldsInfinityif no entry carriesnumberAdopting. - Rendering bug:
eleventy.config.js's cost-model line emits a dangling " — " whencostModelDetailsis absent. - Unescaped
|delimiter betweensrc/index.njk(wrote multi-value fields intodata-*attributes withjoin('|')) andsrc/filters.js(split on|) — any future value containing a pipe silently corrupted filtering. (Since removed — see "Search-index-driven listing and pagination" above; the listing now renders from the JSON corpus and nodata-*encoding exists.) - The entire 8-facet filter engine (
src/filters.js) is untyped and untested. This report itself concedes failures "only surface in-browser" — yet thematchesFilterspredicate is pure and trivially unit-testable today. That's the first test to write.
Architectural concerns
- The image bridge is a manual, silent-failure workflow — the biggest gap to close before an editor touches this.
src/_data/articles.jsrewrites only the literal../images/(breaking on./images/,images/, deeper nesting, and rewriting matches inside code blocks), and the two PNGs were hand-copied intosrc/images/articles/. Meanwhile Decap'sarticlescollection uploads to the repo-root/images— so the next CMS-uploaded image will 404 until a developer notices and copies it. Needs a build-step copy from/images(or the content moved insidesrc/, eliminating the bridge entirely). - Title-from-first-heading in
articles.jsbreaks on setext headings, frontmatter-led files, or heading-less files — Decap-created articles won't reliably start with#. - No validation at load:
entries.js/siteSettings.jscallparse()raw; malformed YAML fails the build with a bare stack trace, and an entry missingnamecrasheslocaleCompareopaquely. The ajv validationCLAUDE.mdflags as ad hoc belongs in the build. - Filter options derive from data, not schema — an enum value with zero current entries vanishes from the filter UI. Decide which is canonical.
scripts/deploy-gcs.shdeserves care before first use:--delete-unmatched-destination-objectswipes anything else in the bucket, CDN invalidation silently skips if no url-map is passed, and noCache-Controlmetadata is set — behind Cloud CDN, HTML and Pagefind index fragments need an explicit caching policy.- Accessibility: the result count in
src/index.njkneededaria-live="polite"(since added); the fieldset/label structure is otherwise sound.
Security considerations
- The two markdown pipelines disagree on raw HTML.
articles.jsbuilds its MarkdownIt withhtml: false(raw HTML escaped), but Eleventy's built-in markdown-it — which renderscontent-pages/, edited by the CMSpagescollection — allows raw HTML through by default: a CMS editor can inject<script>into a content page. Pick one policy; if editors are semi-trusted, sanitize. Note the flip side:html: falseon articles also blocks<iframe>— the likeliest implementation of the spec's still-untested video-embedding requirement. - Decap CMS loads from unpkg with a floating version range (
decap-cms@^3.0.0, no SRI hash,src/admin/index.html) — supply-chain exposure on the surface that runs with editors' GitHub tokens. Pin + hash or self-host; also decide whether/admin/ships on the production domain at all. bannerLink(base.njk) renders CMS input intohrefunvalidated —javascript:URLs possible.
Spec-fit gaps
- The dedicated
/search/page carried 3 facets, not 8.src/pagefind-search.js+ thedata-pagefind-filtertags exposed category/readiness/license only, whileSPECIFICATION.mdsays every search page MUST include all filters — including thenumberAdoptingrange, which PagefindUI can't express natively. (Since resolved by removal, in all three spikes — user testing also found the page non-functional, and once the homepage became the full, paginated search surface it was redundant;search.njk,pagefind-search.js, and the nav link are gone, making the homepage the single search surface.) - The dev-mode fallback in
filters.jsdegraded to name/description substring search when the Pagefind index was absent — the exact "two search implementations" failure the spec warns about. (Since improved — the fallback now substring-searches the corpus's fullbodyfield, so it reaches never-displayed attributes too; the remaining divergence is ranking/stemming, stated in the mode label.)
Questions for the next stage
- What is the single source of truth for the data model, and what generates the CMS config,
entryFields, and filter facets from it — schema-first codegen, or manual sync plus a CI drift check? - Testing strategy: unit tests for
filters.jsand the data loaders are extractable today; what end-to-end harness (Playwright against_site)? And do we reintroduce TypeScript/esbuild deliberately, as this report already hints, accepting the bundler Eleventy avoided? - Content governance for the shared
/markdown,/images,/datafolders: once the multi-spike scaffolding is gone, do these move inside the Eleventy input tree (eliminating thearticles.jsbridge entirely), and who owns the image-sync problem until then? - CI/CD: the GitHub Actions + Workload Identity Federation deploy in
REPORT.mdis documented, not built — including ajv schema validation as a merge gate for CMS-generated PRs, and the still-untested per-environment Decap backends. - Is
/admin/meant to exist on the production domain, and what serves Decap's GitHub OAuth flow (theHDRUK/hdc_v2repo inconfig.ymlis a placeholder) given a GCS bucket + CDN can't host the OAuth proxy?