Skip to content

Commit 526c697

Browse files
SisyphusZhengDevBot
andauthored
feat(www): article-shell content pilot (ADR-0136), dragon hero, reading-rail fixes (#1106)
* feat(www): article-shell content pilot (ADR-0136), dragon hero islands, reading-rail fixes - content collections pilot: build-content-data.ts + article-page/article-body shell, content/guide + content/architecture markdown, thin route shells - rename cinematic islands to open-* prefix; add dragon-gaze hero islands and media assets (dragon-idle.mp4, frames, prism vendor, inter font) - tools/lib/static-server: .mp4/.webm types + single-range 206 support so video scrubbing works in e2e/smoke servers - open-reading-shell: widen article column (760 -> 880px), shrink rail gap - open-page-rail: fix #hash navigation into shadow roots (click interception, deep-link on load, root-chain lookup for the scroll-spy) - article-body: scroll-margin-top for headings under the fixed nav - regenerate per-locale visual baselines * chore(www): satisfy fmt/lint CI gates - deno fmt: architecture content md, open-hero-polish, dragon islands, open-layout-navigation test, cinematic-home spec - lint: drop async from await-free expect.poll arrows; bare boolean JSX attrs - deno.json: exclude www/public/assets/vendor (minified prism) from lint * chore(www,tools): satisfy hygiene/docs-truth/theme-token gates - theme tokens: hero palette aliases in vite.config.ts (--hero-ink/paper/ gold/gold-muted/gold-line); replace hex literals in index/hero-polish/ dragon-live-gaze with var() - repo hygiene: allowlist www/public/assets/dragon-* hero media (intentional design payload, same class as design/mockups) - strategic anchors: version anchor moved from thin route shells to content markdown; accept {{OPENELEMENT_VERSION}} in .md; architecture.md gains an explicit version note - dragon-live-gaze: initialize tick target (TS2454 through the idleBlink flag chain) * test(tools): www-truth fixtures create the content source dirs The ADR-0136 content-source precondition made version-form fixtures fail for the wrong reason (missing www/content/<collection>/ dirs), and let the retired-form assertions pass on the wrong stderr line. * test(www): contributing rejoins shared locale baselines; refresh baselines - /contributing renders byte-identical en/zh (English-only copy by design); per-locale baselines were exact duplicates the duplicate gate rejects. Rejoin via sharedLocaleBaselines, per the spec's own convergence contract. - regenerate all baselines against the current render (reading-shell width, article shell) and verify with a clean non-update run (8/8) --------- Co-authored-by: DevBot <devbot@openelement.dev>
1 parent 718fd07 commit 526c697

458 files changed

Lines changed: 5139 additions & 5369 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ packages/adapter-vite/__fixtures__/nitro-proof/node_modules/
2828
packages/adapter-vite/__fixtures__/request-time/.output-*/
2929
packages/adapter-vite/__fixtures__/request-time/.nitro/
3030
www/app/data/_generated-blog-data.ts
31+
www/app/data/_generated-guide-data.ts
32+
www/app/data/_generated-architecture-data.ts
3133
.openElement/
3234
www/.openElement/
3335

deno.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@
135135
"proseWrap": "preserve",
136136
"exclude": [
137137
"www/content/blog/",
138+
"www/content/guide/",
138139
"www/app/data/",
139140
"www/public/assets/",
140141
"custom-dist/",
@@ -153,7 +154,9 @@
153154
},
154155
"exclude": [
155156
"www/content/blog/",
157+
"www/content/guide/",
156158
"www/app/data/",
159+
"www/public/assets/vendor/",
157160
"custom-dist/",
158161
"dist-test-ssg-render/",
159162
"**/.nitro/"

deno.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
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.

docs/adr/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,7 @@ git history.
177177
| 0132 | Defer Real Scan-Engine Evidence to v0.44 — Attachment Scanning Is Optional Hardening | Accepted |
178178
| 0133 | 2026-08-20 Client-Router Guard Concurrency Fixes Preserve ADR-0122 Contracts | Accepted |
179179
| 0134 | Manual workflow_dispatch Greens Count as Release Evidence — Freshness Gate Evaluation Fix | Accepted |
180+
| 0136 | Content Collections — Generalize the Blog Content Pipeline | Proposed (v0.44, #1087) |
180181

181182
## Superseded / Historical
182183

tools/check-docs-truth-www.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@ const CHECK_SCRIPT = new URL('./check-docs-truth.ts', import.meta.url);
1313
async function writeFixture(root: string, routeSource: string): Promise<void> {
1414
await Deno.mkdir(`${root}/www/app/routes`, { recursive: true });
1515
await Deno.mkdir(`${root}/www/app/site-ui`, { recursive: true });
16+
// The www gate requires the article content sources (ADR-0136 pilot);
17+
// version-form fixtures must satisfy that precondition to isolate the
18+
// retired-version rule under test.
19+
await Deno.mkdir(`${root}/www/content/guide`, { recursive: true });
20+
await Deno.mkdir(`${root}/www/content/architecture`, { recursive: true });
1621
await Deno.writeTextFile(`${root}/www/vite.config.ts`, 'export default {};\n');
1722
await Deno.writeTextFile(`${root}/www/app/routes/fixture.tsx`, routeSource);
1823
}

tools/check-docs-truth.ts

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -303,7 +303,10 @@ const wwwForbidden: Array<{ name: string; re: RegExp }> = [
303303

304304
async function wwwCheckFile(file: string, issues: { file: string; text: string }[]): Promise<void> {
305305
const text = await Deno.readTextFile(file);
306-
const isHistorySurface = /(?:routes\/guide\/migration\.tsx|CHANGELOG\.md)$/.test(file);
306+
const isHistorySurface =
307+
/(?:routes\/guide\/migration\.tsx|content\/guide\/migration(?:\.zh)?\.md|CHANGELOG\.md)$/.test(
308+
file,
309+
);
307310
for (const { name, re } of wwwForbidden) {
308311
if (isHistorySurface && name === 'retired prerelease current claim') continue;
309312
if (re.test(text)) issues.push({ file, text: name });
@@ -321,10 +324,18 @@ async function wwwCheckFile(file: string, issues: { file: string; text: string }
321324
) {
322325
issues.push({ file, text: 'legacy per-page structural CSS' });
323326
}
324-
if (file.startsWith('www/app/routes/guide/') && !/extends GuidePage\b/.test(text)) {
325-
issues.push({ file, text: 'guide route does not build on the shared guide page shell' });
327+
const isContentRoute = file.startsWith('www/app/routes/guide/') ||
328+
file.startsWith('www/app/routes/architecture/');
329+
if (isContentRoute && !/extends ArticlePage\b/.test(text)) {
330+
issues.push({ file, text: 'content route does not build on the shared article shell' });
331+
}
332+
if (isContentRoute && /const content\s*=\s*\{|Record<'en' \| 'zh'/.test(text)) {
333+
issues.push({
334+
file,
335+
text: 'content route carries content records; content lives in www/content/<collection>/',
336+
});
326337
}
327-
if (file === 'www/app/site-ui/guide-page.tsx') {
338+
if (file === 'www/app/site-ui/article-page.tsx') {
328339
if (!/open-page-rail[^>]+items=/.test(text)) {
329340
issues.push({ file, text: 'guide shell lacks a declared SSR outline' });
330341
}
@@ -351,11 +362,13 @@ const wwwCheck: DocsTruthCheck = {
351362
run: async (opts) => {
352363
const issues: { file: string; text: string }[] = [];
353364

354-
if (await exists('www/content/guide')) {
355-
issues.push({
356-
file: 'www/content/guide',
357-
text: 'second guide source of truth; guide content lives only in www/app/routes/guide/',
358-
});
365+
for (const collection of ['guide', 'architecture'] as const) {
366+
if (!await exists(`www/content/${collection}`)) {
367+
issues.push({
368+
file: `www/content/${collection}`,
369+
text: `content source missing; articles live in www/content/${collection}/`,
370+
});
371+
}
359372
}
360373

361374
for (const root of sourceRoots) {
@@ -420,6 +433,8 @@ const textScanRoots = [
420433
'tools/',
421434
'www/app/routes/',
422435
'www/app/components/',
436+
'www/content/guide/',
437+
'www/content/architecture/',
423438
];
424439

425440
const textIgnoredPathParts = [
@@ -482,8 +497,8 @@ function isCurrentTruth(file: string): boolean {
482497
file === 'docs/roadmap/ROADMAP.md' ||
483498
file === 'docs/status/STATUS.md' ||
484499
file === 'docs/adr/ADR-0105-v040x-cleanup-train-exception.md' ||
485-
file === 'www/app/routes/architecture/architecture.tsx' ||
486-
file === 'www/app/routes/guide/architecture.tsx';
500+
file.startsWith('www/content/architecture/architecture.') ||
501+
file.startsWith('www/content/guide/architecture.');
487502
}
488503

489504
const textCheck: DocsTruthCheck = {

tools/check-repo-hygiene.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,9 @@ const allowedLargeBinaryDirs = [
106106
/^www\/design\/mockups\//,
107107
/^www\/e2e\/visual-baselines\.spec\.ts-snapshots\//,
108108
/^examples\/[^/]+\/fixtures\//,
109+
// Homepage cinematic hero media (dragon video/stills): shipped site assets,
110+
// served from www/public — intentional design payload, not stray binaries.
111+
/^www\/public\/assets\/dragon-/,
109112
];
110113

111114
const failures: Failure[] = [];

tools/check-strategic-docs.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,9 +49,12 @@ const currentDocs = [
4949
'docs/status/STATUS.md',
5050
'www/app/routes/index/index.tsx',
5151
'www/app/routes/roadmap.tsx',
52-
'www/app/routes/guide/getting-started.tsx',
52+
// Guide/architecture routes are thin ArticlePage shells since the ADR-0136
53+
// content pilot; the version anchor lives in the markdown body, which
54+
// substitutes {{OPENELEMENT_VERSION}} at render time.
55+
'www/content/guide/getting-started.md',
5356
'www/app/routes/apilist.tsx',
54-
'www/app/routes/architecture/architecture.tsx',
57+
'www/content/architecture/architecture.md',
5558
];
5659

5760
/**
@@ -109,7 +112,9 @@ export function strategicChecks(): Check[] {
109112
required: [PACKAGE_VERSION_TAG],
110113
accept: (text: string, file: string) =>
111114
text.includes(PACKAGE_VERSION_TAG) ||
112-
(file.endsWith('.tsx') &&
115+
// .tsx routes reference the version symbol; content markdown carries
116+
// the {{OPENELEMENT_VERSION}} placeholder (substituted at render).
117+
((file.endsWith('.tsx') || file.endsWith('.md')) &&
113118
(text.includes('OPENELEMENT_VERSION') ||
114119
text.includes('PUBLISHED_PACKAGE_VERSION'))),
115120
},

tools/lib/static-server.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,3 +57,38 @@ Deno.test('serveStatic rejects NUL with 403, returns 404 when nothing matches',
5757
await Deno.remove(root, { recursive: true });
5858
}
5959
});
60+
61+
Deno.test('serveStatic answers single-range requests with 206 and advertises accept-ranges', async () => {
62+
const root = await Deno.makeTempDir();
63+
await Deno.writeFile(`${root}/clip.mp4`, new Uint8Array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]));
64+
65+
const server = serveStatic(root);
66+
try {
67+
const plain = await fetch(`${server.origin}/clip.mp4`);
68+
assertEquals(plain.status, 200);
69+
assertEquals(plain.headers.get('content-type'), 'video/mp4');
70+
assertEquals(plain.headers.get('accept-ranges'), 'bytes');
71+
await plain.body?.cancel();
72+
73+
const partial = await fetch(`${server.origin}/clip.mp4`, {
74+
headers: { range: 'bytes=2-5' },
75+
});
76+
assertEquals(partial.status, 206);
77+
assertEquals(partial.headers.get('content-range'), 'bytes 2-5/10');
78+
assertEquals(new Uint8Array(await partial.arrayBuffer()), new Uint8Array([2, 3, 4, 5]));
79+
80+
const open = await fetch(`${server.origin}/clip.mp4`, { headers: { range: 'bytes=8-' } });
81+
assertEquals(open.status, 206);
82+
assertEquals(open.headers.get('content-range'), 'bytes 8-9/10');
83+
await open.body?.cancel();
84+
85+
const unsatisfiable = await fetch(`${server.origin}/clip.mp4`, {
86+
headers: { range: 'bytes=42-' },
87+
});
88+
assertEquals(unsatisfiable.status, 416);
89+
await unsatisfiable.body?.cancel();
90+
} finally {
91+
await server.close();
92+
await Deno.remove(root, { recursive: true });
93+
}
94+
});

0 commit comments

Comments
 (0)