Skip to content

Commit 8e62d22

Browse files
author
DevBot
committed
feat(tools,www): typed Content Graph with deterministic generate + fail-closed CI gate (#1157, B2.4)
Define the normalized entry/source-location/locale/reference/fingerprint schemas (tools/lib/content-graph.ts) and the five Beta.2 adapters (tools/lib/content-graph-adapters.ts): Markdown collections via the adapter-vite collection loader, public API data from the PACKAGE_SURFACE.md machine blocks, compiler metadata from the @openelement/ui generated manifest, the roadmap timeline via the repo's TypeScript AST tooling, and release truth from docs/release. The generator (tools/generate-content-graph.ts) writes www/app/data/_generated-content-graph.json deterministically (sorted entries/alternates/references/keys; sha256 fingerprints) and --check is the CI drift gate registered in the AutoFlow policy. Validation fails closed on duplicate ids, broken entry/route references and false locale alternates (orphan locales, asymmetric pairs, byte-identical 'translations'). Deterministic queries for doc routes, locale availability, search records and per-route SEO metadata are exposed for the #1159 consumers.
1 parent 06454c2 commit 8e62d22

9 files changed

Lines changed: 5689 additions & 2 deletions

deno.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,8 @@
9797
"generate:ui-tokens:check": "deno run --allow-read tools/generate-ui-token-module.ts --check",
9898
"generate:ui-manifest": "deno task generate:ui-tokens && deno run --allow-read --allow-write --allow-env tools/generate-ui-manifest.ts",
9999
"generate:www-content-data": "deno run --allow-read --allow-write tools/generate-www-content-data.ts",
100+
"generate:content-graph": "deno run --allow-read --allow-write --allow-env tools/generate-content-graph.ts",
101+
"content-graph:check": "deno run --allow-read --allow-env tools/generate-content-graph.ts --check",
100102
"test:coverage": "deno test --coverage=.coverage --allow-read --allow-write --allow-env --allow-net --allow-run --allow-ffi --allow-sys && deno coverage .coverage --html && deno coverage .coverage --lcov > .coverage/lcov.info",
101103
"test:watch": "deno test --allow-read --allow-write --allow-env --allow-net --allow-run --watch",
102104
"test:e2e": "deno run -A npm:@playwright/test@1.59.1 test --config www/e2e/playwright.config.ts --project=chromium",

tools/autoflow/policy.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,28 @@ const GATES: readonly GateDefinition[] = [
208208
tiers: ['ci', 'release'],
209209
triggers: [/^www\//, /^docs\//, /^tools\/project-constants\.ts$/],
210210
},
211+
{
212+
// #1157 (B2.4): typed Content Graph drift gate — the committed
213+
// _generated-content-graph.json must be a byte-identical regeneration of
214+
// the owned Markdown/API/compiler-metadata/roadmap/release sources, and
215+
// the graph must validate (duplicate ids, broken references and false
216+
// locale alternates fail closed inside the generator).
217+
name: 'content-graph:check',
218+
command: ['deno', 'task', 'content-graph:check'],
219+
tiers: ['ci', 'release'],
220+
triggers: [
221+
/^www\/content\//,
222+
/^www\/content-collections\.ts$/,
223+
/^www\/app\/routes\//,
224+
/^www\/app\/data\/_generated-content-graph\.json$/,
225+
/^docs\/current\/PACKAGE_SURFACE\.md$/,
226+
/^docs\/roadmap\/ROADMAP\.md$/,
227+
/^docs\/release\//,
228+
/^packages\/ui\/src\/generated-manifest\.json$/,
229+
/^tools\/(?:lib\/content-graph|generate-content-graph|check-package-surface)/,
230+
/^deno\.json$/,
231+
],
232+
},
211233
{
212234
name: 'docs:check-version-anchors',
213235
command: ['deno', 'task', 'docs:check-version-anchors'],

tools/check-package-surface.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ function normalizeExports(exports: unknown): Record<string, string> {
5353
);
5454
}
5555

56-
interface SurfaceMapEntry {
56+
export interface SurfaceMapEntry {
5757
supported: string[];
5858
internal: string[];
5959
}
@@ -62,7 +62,7 @@ function isStringArray(value: unknown): value is string[] {
6262
return Array.isArray(value) && value.every((item) => typeof item === 'string');
6363
}
6464

65-
function extractSurfaceMap(doc: string): Record<string, SurfaceMapEntry> | null {
65+
export function extractSurfaceMap(doc: string): Record<string, SurfaceMapEntry> | null {
6666
const BEGIN = '<!-- package-surface-map';
6767
const begin = doc.indexOf(BEGIN);
6868
if (begin === -1) return null;

tools/generate-content-graph.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/**
2+
* Typed Content Graph generator (#1157, B2.4).
3+
*
4+
* Default mode builds the graph from the owned sources (Markdown collections,
5+
* PACKAGE_SURFACE.md API blocks, the @openelement/ui compiler manifest, the
6+
* roadmap route timeline and release truth) and writes
7+
* www/app/data/_generated-content-graph.json. `--check` regenerates and
8+
* requires byte-identical output — the CI drift gate.
9+
*
10+
* Validation fails closed on duplicate ids, broken entry/route references
11+
* and false locale alternates, in both modes.
12+
*/
13+
import { buildContentGraph, scanPublicRoutes, SITE_LOCALES } from './lib/content-graph-adapters.ts';
14+
import { serializeContentGraph, validateContentGraph } from './lib/content-graph.ts';
15+
16+
export const CONTENT_GRAPH_ARTIFACT = 'www/app/data/_generated-content-graph.json';
17+
18+
export async function generateContentGraphJson(): Promise<string> {
19+
const graph = await buildContentGraph();
20+
const routes = await scanPublicRoutes();
21+
const failures = validateContentGraph(graph, { routes, locales: SITE_LOCALES });
22+
if (failures.length > 0) {
23+
console.error('Content graph validation failed:');
24+
for (const failure of failures) {
25+
console.error(`- ${failure.file}: ${failure.message}`);
26+
}
27+
throw new Error(`content graph invalid: ${failures.length} failure(s)`);
28+
}
29+
return serializeContentGraph(graph);
30+
}
31+
32+
if (import.meta.main) {
33+
const check = Deno.args.includes('--check');
34+
const json = await generateContentGraphJson();
35+
if (check) {
36+
let existing: string;
37+
try {
38+
existing = await Deno.readTextFile(CONTENT_GRAPH_ARTIFACT);
39+
} catch {
40+
console.error(`${CONTENT_GRAPH_ARTIFACT} is missing; run deno task generate:content-graph`);
41+
Deno.exit(1);
42+
}
43+
if (existing !== json) {
44+
console.error(
45+
`${CONTENT_GRAPH_ARTIFACT} is stale; run deno task generate:content-graph and commit the result`,
46+
);
47+
Deno.exit(1);
48+
}
49+
console.log(`Content graph check passed (${CONTENT_GRAPH_ARTIFACT} is byte-identical).`);
50+
} else {
51+
await Deno.writeTextFile(CONTENT_GRAPH_ARTIFACT, json);
52+
const entries = (json.match(/"id":/g) ?? []).length;
53+
console.log(`Wrote ${entries} entries to ${CONTENT_GRAPH_ARTIFACT}`);
54+
}
55+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
/** Content Graph adapter + generator integration tests (#1157). */
2+
import { assert, assertEquals } from '@std/assert';
3+
import {
4+
extractMarkdownReferences,
5+
extractRoadmapTimeline,
6+
scanPublicRoutes,
7+
} from './content-graph-adapters.ts';
8+
import { generateContentGraphJson } from '../generate-content-graph.ts';
9+
import type { ContentGraph } from './content-graph.ts';
10+
11+
Deno.test('extractMarkdownReferences: classifies internal links, skips external and fragments', () => {
12+
const content = [
13+
'See [the guide](/guide/getting-started) and [localized](/zh/guide/api#x).',
14+
'Relative [peer](./other.md) and [translation](other.zh.md) plus [anchor](#top).',
15+
'External [site](https://example.com/docs) and [mail](mailto:a@b.c) are skipped.',
16+
'[ref]: /architecture/islands',
17+
].join('\n');
18+
const references = extractMarkdownReferences(
19+
content,
20+
(target) =>
21+
target.startsWith('/')
22+
? { kind: 'route', target }
23+
: { kind: 'entry', target: `article:guide/${target.replace(/\.mdx?$/, '')}` },
24+
);
25+
assertEquals(references, [
26+
{ kind: 'route', target: '/guide/getting-started', line: 1 },
27+
{ kind: 'route', target: '/zh/guide/api', line: 1 },
28+
{ kind: 'entry', target: 'article:guide/./other', line: 2 },
29+
{ kind: 'entry', target: 'article:guide/other.zh', line: 2 },
30+
{ kind: 'route', target: '/architecture/islands', line: 4 },
31+
]);
32+
});
33+
34+
Deno.test('extractRoadmapTimeline: reads the bilingual timeline through the TS AST', () => {
35+
const source = `
36+
const entries: Record<'en' | 'zh', TimelineEntry[]> = {
37+
en: [
38+
{ version: 'v0.44.0-beta.1', theme: 'qualification', copy: 'English copy.', state: 'next', stamp: 'CURRENT', status: 'prerelease' },
39+
],
40+
zh: [
41+
{ version: 'v0.44.0-beta.1', theme: '限定', copy: '中文文案。', state: 'next', stamp: 'CURRENT', status: 'prerelease' },
42+
],
43+
};
44+
`;
45+
const timeline = extractRoadmapTimeline(source);
46+
assertEquals(Object.keys(timeline).sort(), ['en', 'zh']);
47+
assertEquals(timeline.en.length, 1);
48+
assertEquals(timeline.en[0].version, 'v0.44.0-beta.1');
49+
assertEquals(timeline.en[0].stamp, 'CURRENT');
50+
assertEquals(timeline.zh[0].copy, '中文文案。');
51+
assertEquals(timeline.en[0].line > 0, true);
52+
});
53+
54+
Deno.test('scanPublicRoutes: discovers the real www route universe', async () => {
55+
const routes = await scanPublicRoutes();
56+
for (const expected of ['/guide/getting-started', '/roadmap', '/apilist', '/changelog']) {
57+
assert(routes.includes(expected), `missing route ${expected}`);
58+
}
59+
assert(routes.every((route) => !route.includes(':')), 'dynamic segments must be excluded');
60+
});
61+
62+
Deno.test('generateContentGraphJson: real repo sources produce a valid deterministic graph', async () => {
63+
const first = await generateContentGraphJson();
64+
const second = await generateContentGraphJson();
65+
assertEquals(first, second);
66+
67+
const graph = JSON.parse(first) as ContentGraph;
68+
const ids = new Set(graph.entries.map((entry) => entry.id));
69+
// Every adapter contributes: markdown, public API data, compiler metadata,
70+
// roadmap and release truth.
71+
assert(ids.has('article:guide/getting-started:en'), 'markdown adapter missing');
72+
assert(ids.has('api:@openelement/element'), 'api adapter missing');
73+
assert(ids.has('element:open-dialog'), 'compiler metadata adapter missing');
74+
assert(ids.has('roadmap:en:0'), 'roadmap adapter missing');
75+
assert(ids.has('release:state'), 'release adapter missing');
76+
77+
// Locale pairs are symmetric and translated.
78+
const en = graph.entries.find((entry) => entry.id === 'article:guide/getting-started:en');
79+
const zh = graph.entries.find((entry) => entry.id === 'article:guide/getting-started:zh');
80+
assert(en && zh, 'guide locale pair missing');
81+
assertEquals(en.alternates, [{ locale: 'zh', id: 'article:guide/getting-started:zh' }]);
82+
assertEquals(zh.alternates, [{ locale: 'en', id: 'article:guide/getting-started:en' }]);
83+
});

0 commit comments

Comments
 (0)