|
| 1 | +# ADR-0136: Content Collections — Generalize the Blog Content Pipeline |
| 2 | + |
| 3 | +- Status: PROPOSED (target: v0.44 line; tracked in #1087) |
| 4 | +- Date: 2026-08-22 |
| 5 | +- References: ADR-0018 (virtual data modules → write-to-disk generated |
| 6 | + modules), ADR-0126 (sanitize-html allow-list), ADR-0135 (0.43.0 stable |
| 7 | + scope freeze — this capability is deliberately NOT in the 0.43 line) |
| 8 | + |
| 9 | +## Context |
| 10 | + |
| 11 | +`createBlogPlugin` (`packages/adapter-vite/src/internal/content/blog/`) is a |
| 12 | +content pipeline hardcoded to the blog use case: scan a Markdown directory → |
| 13 | +gray-matter frontmatter → marked → sanitize-html allow-list → write |
| 14 | +`app/data/_generated-blog-data.ts`, with dev watch + regenerate. The |
| 15 | +frontmatter schema (`title/date/tags/excerpt/type/draft`) and the output |
| 16 | +filename are fixed. |
| 17 | + |
| 18 | +The www guide section needed a second instance of the same pattern: 15 guide |
| 19 | +pages moved from a card-grid TSX shell (bilingual content records as string |
| 20 | +literals) to linear Markdown-authored articles rendered like blog posts. |
| 21 | +Because the framework line is frozen (ADR-0135), the guide pipeline shipped |
| 22 | +as a www-local pilot — `www/build-content-data.ts` plus |
| 23 | +`www/app/site-ui/guide-article.tsx` — which deliberately duplicates the blog |
| 24 | +pipeline, including the ADR-0126 sanitize allow-list. Two copies of a |
| 25 | +security-relevant allow-list will drift; the pilot must be retired, not |
| 26 | +ossified. |
| 27 | + |
| 28 | +Every static-first framework that survived grew this capability (Astro |
| 29 | +Content Collections / Content Layer API is the closest analogue; Nuxt |
| 30 | +Content is a much larger query-CMS scope; Contentlayer is unmaintained — a |
| 31 | +scope caution). The blog plugin being 80% of a collection system already is |
| 32 | +the internal evidence that the need is real. |
| 33 | + |
| 34 | +## Decision |
| 35 | + |
| 36 | +1. **Generic collections config in `@openelement/adapter-vite`.** |
| 37 | + `openElement({ content: { collections: Record<string, CollectionOptions> } })` |
| 38 | + with `CollectionOptions = { contentDir, basePath?, schema? }`. One |
| 39 | + `createCollectionPlugin(name, options)` factory replaces the blog-specific |
| 40 | + plugin; each collection writes `app/data/_generated-{name}-data.ts` |
| 41 | + (ADR-0018 write-to-disk pattern, fail-closed per SOP-001). |
| 42 | +2. **Blog migrates onto the abstraction.** `content.blog` desugars to a |
| 43 | + `blog` collection with identical behavior; the acceptance test of the |
| 44 | + abstraction is that the existing blog pipeline is re-expressible through |
| 45 | + it with zero output change. An abstraction that cannot express its own |
| 46 | + origin is a false abstraction. |
| 47 | +3. **The www guide collection is the second consumer.** On the v0.44 train |
| 48 | + the www pilot (`www/build-content-data.ts`) is deleted and |
| 49 | + `content/guide/` moves to `collections.guide`. The pilot's filename |
| 50 | + convention (`slug.<locale>.md`) is retained as data for the follow-up |
| 51 | + i18n ADR, not enshrined by this one. |
| 52 | +4. **Out of scope — separate ADRs, do not bundle:** |
| 53 | + - Nav merging for content-backed dynamic routes (today a `[slug]` route |
| 54 | + cannot contribute per-item nav entries; guide keeps thin route shells |
| 55 | + with `export const meta` until that ADR lands). |
| 56 | + - Content-level i18n pairing conventions (locale-suffixed files, |
| 57 | + fallback order, translation-completeness reporting). |
| 58 | +5. **Non-goals:** query CMS features (Nuxt Content), content-driven routing |
| 59 | + (VitePress), pluggable content loaders (Astro Content Layer). A |
| 60 | + collection is a directory of Markdown with a frontmatter schema — |
| 61 | + nothing more. |
| 62 | + |
| 63 | +## Consequences |
| 64 | + |
| 65 | +- **Positive:** one sanitize allow-list (ADR-0126), one watcher, one module |
| 66 | + writer across all content sections; adding a section becomes `mkdir` + |
| 67 | + config, not a new pipeline; the www pilot duplication is deleted. |
| 68 | +- **Positive:** the frontmatter-schema axis is designed against two real |
| 69 | + consumers (blog: `date/tags`; guide: `order/lede/locale`) rather than |
| 70 | + extrapolated from one. |
| 71 | +- **Negative:** new public API on adapter-vite during a freeze culture — |
| 72 | + requires the surface inventory (`docs/current/PACKAGE_SURFACE.md`) |
| 73 | + update, api-surface gate evidence, and release-train coordination on the |
| 74 | + v0.44 line. |
| 75 | +- **Negative:** if `schema?` validation is under-designed, collections |
| 76 | + degenerate into untyped JSON blobs; the v0.44 implementation must decide |
| 77 | + whether schema is declarative (field list) or a user-supplied validate |
| 78 | + function — the pilot only demonstrates the declarative minimum. |
| 79 | +- **Neutral:** `createBlogPlugin` survives as a thin alias over the generic |
| 80 | + factory for one release line, then is removed. |
| 81 | +- **Migration impact (v0.44 train):** `packages/adapter-vite` |
| 82 | + (collection factory, blog desugar, writer naming), `www` (delete pilot, |
| 83 | + adopt `collections.guide`), `docs/current/PACKAGE_SURFACE.md`, and the |
| 84 | + www guide tests that pin the generated-module shape. |
0 commit comments