Skip to content

Commit bde4548

Browse files
author
DevBot
committed
feat(www,tools): remediate hostile-audit content-truth findings (#1307)
Major: - /apilist renders the generated API + custom-element reference: 167 per-export entries and 10 per-element detail rows with the generated anchors; the built-output link gate now asserts every generated searchRecord anchor exists in both locales (pagefind skip documented). - /zh/contributing ships a real zh translation instead of hard-coded English; the www:check-truth locale gate now covers route-level content records (en without zh fails closed) and requires every blog post to declare its original language via frontmatter lang. Blog routes mark locale-mismatched renders with an explicit notice and a truthful lang attribute instead of masquerading. Minor: - Per-route, locale-appropriate title/description/og/canonical/hreflang derived from the content graph (content routes) + an authored bilingual route map (www/app/seo.ts), applied to the built shell by tools/apply-www-seo.ts (fail-closed) before Pagefind indexing; findSeoFailures strengthened beyond presence-only (boilerplate-title rejection, canonical/hreflang presence, per-locale title uniqueness, no English boilerplate description on zh pages). - Sitemap generator excludes locale-prefixed 404 pages symmetrically (/zh/404 leak fixed). - Changelog copy now names the withdrawn npm beta.1-beta.3 artifacts as the 0.41.0-era line; CHANGELOG.md gains reconciling 0.43.x and 0.44.0-beta.1 entries deferring to docs/release/. - content-examples gate: typescript/js fence aliases are type-checked, TS2304 suppression no longer covers documented framework exports (caught a real import-elided openElement() call in the configuration guide), blog exclusion documented with rationale. Also fixes the content-graph blog adapter to derive slugs/routes through the blog plugin's own collection options (date-prefix transform), so graph routes match the served routes.
1 parent 2b2756a commit bde4548

101 files changed

Lines changed: 2038 additions & 201 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.

CHANGELOG.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,25 @@ Current truth lives in:
1616
Historical changelog details remain available through git history and release
1717
evidence.
1818

19+
## 0.44.0-beta.1
20+
21+
**First public v0.44 prerelease (dist-tag `beta`; npm `latest` stays on the
22+
stable 0.43 line).** Framework qualification under a frozen governance
23+
envelope (ADR-0151): the TSX-to-Part Program compiler, page-route SSR bound
24+
to the compiled program, and the delivery gates. The authoritative note is
25+
[`docs/release/v0.44.0-beta.1.md`](./docs/release/v0.44.0-beta.1.md); the
26+
0.41.0-era npm `beta.1``beta.3` artifacts remain withdrawn partial
27+
publishes, unrelated to this line.
28+
29+
## 0.43.3 / 0.43.2 / 0.43.1 / 0.43.0
30+
31+
**Stable maintenance line (npm `latest`).** Compatible bug, security,
32+
runtime, documentation and release-truth patches under ADR-0140 — no 0.44
33+
feature train. Per-release notes:
34+
[`docs/release/v0.43.0.md`](./docs/release/v0.43.0.md),
35+
[`v0.43.1`](./docs/release/v0.43.1.md), [`v0.43.2`](./docs/release/v0.43.2.md),
36+
[`v0.43.3`](./docs/release/v0.43.3.md).
37+
1938
## 0.42.0
2039

2140
**WC light fullstack, stable.** The stable cut of the 0.42 alpha line — the

deno.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,8 @@
3838
"tasks": {
3939
"dev": "cd www && deno run --allow-read --allow-write --allow-net --allow-env --allow-ffi --allow-sys npm:vite --config vite.config.ts",
4040
"www:dev-smoke": "deno run --allow-net --allow-run tools/smoke-www-dev.ts",
41-
"build": "deno task generate:ui-manifest && (cd www && deno run --config ../deno.json --allow-read --allow-write --allow-net --allow-env --allow-ffi --allow-sys --allow-run ../packages/adapter-vite/src/cli/build.ts) && deno task www:pagefind && deno task www:check-artifact-truth && deno task www:check-links",
41+
"build": "deno task generate:ui-manifest && (cd www && deno run --config ../deno.json --allow-read --allow-write --allow-net --allow-env --allow-ffi --allow-sys --allow-run ../packages/adapter-vite/src/cli/build.ts) && deno task www:apply-seo && deno task www:pagefind && deno task www:check-artifact-truth && deno task www:check-links",
42+
"www:apply-seo": "deno run --allow-read --allow-write tools/apply-www-seo.ts",
4243
"www:pagefind": "cd www && deno run --config ../deno.json --allow-read --allow-write --allow-run --allow-env --allow-net --allow-ffi --allow-sys build-pagefind.ts",
4344
"preview": "cd www && deno run --allow-read --allow-write --allow-net --allow-env --allow-ffi npm:vite preview --config vite.config.ts",
4445
"workflow:check": "deno run --allow-read --allow-run=git tools/check-project-workflow.ts && deno task v044:orchestration:check",

packages/adapter-vite/__tests__/sitemap-generator.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,23 @@ Deno.test('generateSitemap creates deploy outputs with web-readable permissions'
3737
}
3838
});
3939

40+
Deno.test('generateSitemap never lists 404 pages, including locale-prefixed ones (#1307)', async () => {
41+
const root = await Deno.makeTempDir();
42+
try {
43+
await Deno.mkdir(join(root, 'zh', '404'), { recursive: true });
44+
await Deno.mkdir(join(root, 'zh'), { recursive: true });
45+
await Deno.writeTextFile(join(root, 'index.html'), 'home');
46+
await Deno.writeTextFile(join(root, '404.html'), 'not found'); // default-locale flat artifact
47+
await Deno.writeTextFile(join(root, 'zh', 'index.html'), 'zh home');
48+
await Deno.writeTextFile(join(root, 'zh', '404', 'index.html'), 'zh not found');
49+
generateSitemap(root, { hostname: 'https://example.com' });
50+
const xml = await Deno.readTextFile(join(root, 'sitemap.xml'));
51+
assert(!xml.includes('/404</loc>'), 'sitemap must not list any 404 page');
52+
assert(xml.includes('<loc>https://example.com/zh</loc>'));
53+
} finally {
54+
await Deno.remove(root, { recursive: true });
55+
}
56+
});
4057
Deno.test('generateSitemap exclude matches path boundaries, not bare prefixes (#1039)', async () => {
4158
const root = await Deno.makeTempDir();
4259
try {

packages/adapter-vite/src/internal/content/blog/blog-data.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export function writeBlogDataModule(posts: BlogPost[]): string {
3030
' tags?: string[];',
3131
' excerpt?: string;',
3232
' type?: string;',
33+
' lang?: string;',
3334
' };',
3435
' content: string;',
3536
' html: string;',

packages/adapter-vite/src/internal/content/blog/plugin.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,17 @@ function toBlogPost(entry: CollectionEntry): BlogPost {
2121
tags: Array.isArray(frontmatter.tags) ? frontmatter.tags.map(String) : [],
2222
...(typeof frontmatter.excerpt === 'string' ? { excerpt: frontmatter.excerpt } : {}),
2323
...(typeof frontmatter.type === 'string' ? { type: frontmatter.type } : {}),
24+
...(typeof frontmatter.lang === 'string' ? { lang: frontmatter.lang } : {}),
2425
},
2526
};
2627
}
2728

28-
function blogCollectionOptions(options: OpenElementBlogOptions): CollectionOptions {
29+
/**
30+
* The blog collection options (date-prefix slug transform + frontmatter
31+
* schema) — exported so the repo's content-graph adapter derives blog
32+
* slugs/routes through the same truth as the built site (#1307).
33+
*/
34+
export function blogCollectionOptions(options: OpenElementBlogOptions): CollectionOptions {
2935
return {
3036
contentDir: options.contentDir ?? 'posts',
3137
basePath: options.basePath ?? '/blog',
@@ -38,6 +44,7 @@ function blogCollectionOptions(options: OpenElementBlogOptions): CollectionOptio
3844
tags: 'string[]',
3945
excerpt: 'string',
4046
type: 'string',
47+
lang: 'string',
4148
},
4249
transform(frontmatter, context) {
4350
const datePrefix = context.fileName.match(/^(\d{4}-\d{2}-\d{2})-/)?.[1];
@@ -51,6 +58,7 @@ function blogCollectionOptions(options: OpenElementBlogOptions): CollectionOptio
5158
tags: frontmatter.tags ?? [],
5259
...(frontmatter.excerpt === undefined ? {} : { excerpt: frontmatter.excerpt }),
5360
...(frontmatter.type === undefined ? {} : { type: frontmatter.type }),
61+
...(frontmatter.lang === undefined ? {} : { lang: frontmatter.lang }),
5462
},
5563
};
5664
},

packages/adapter-vite/src/internal/content/blog/types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,12 @@ export interface BlogPostFrontmatter {
1212
excerpt?: string;
1313
/** Post type discriminator (e.g. 'adr', 'post') */
1414
type?: string;
15+
/**
16+
* Original language of the post body (e.g. 'en', 'zh'). Dispatches are
17+
* single-language originals: sites serving multiple locales use this to
18+
* mark locale-mismatched renders honestly instead of masquerading.
19+
*/
20+
lang?: string;
1521
}
1622

1723
/** A fully parsed blog post */

packages/adapter-vite/src/internal/content/sitemap/generator.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,9 +94,15 @@ export function generateSitemap(distDir: string, options: SitemapOptions): strin
9494
// Scan for index.html files
9595
const htmlPaths = scanHtmlFiles(resolvedDist);
9696

97+
// 404 pages are never sitemap entries. The default-locale 404 is renamed to
98+
// a flat 404.html by the SSG post-processor (so the scan never sees it);
99+
// locale-prefixed 404s stay <locale>/404/index.html and would leak without
100+
// this symmetric exclusion (#1307).
101+
const without404 = htmlPaths.filter((path) => path !== '/404' && !path.endsWith('/404'));
102+
97103
// Filter excluded paths. A pattern matches the path itself or a path
98104
// below it — excluding `/blog` must not also exclude `/blogroll` (#1039).
99-
const includedPaths = htmlPaths.filter((path) => {
105+
const includedPaths = without404.filter((path) => {
100106
return !exclude.some((pattern) =>
101107
path === pattern || path.startsWith(pattern.endsWith('/') ? pattern : `${pattern}/`)
102108
);

tools/apply-www-seo.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
/**
2+
* Apply the per-route SEO plan to the built www output (#1307).
3+
*
4+
* Runs in `deno task build` after the adapter build and before Pagefind
5+
* indexing: rewrites each built page's title/description/og metadata and
6+
* injects canonical + hreflang links, derived from the committed content
7+
* graph (content routes) and www/app/seo.ts (route-level pages). Fails
8+
* closed — a built page without an SEO entry, an entry without a built
9+
* page, or a page whose boilerplate anchors drifted all fail the build.
10+
*/
11+
import { walk } from '@std/fs/walk';
12+
import { join } from '@std/path';
13+
import type { ContentGraph } from './lib/content-graph.ts';
14+
import { applySeoToHtml, buildSeoPlan } from './lib/www-seo.ts';
15+
import { routeSeo } from '../www/app/seo.ts';
16+
17+
export const WWW_DIST = 'www/dist';
18+
const CONTENT_GRAPH = 'www/app/data/_generated-content-graph.json';
19+
const SITE_LOCALES = ['en', 'zh'] as const;
20+
21+
export async function applyWwwSeo(dist = WWW_DIST): Promise<string[]> {
22+
const graph = JSON.parse(await Deno.readTextFile(CONTENT_GRAPH)) as ContentGraph;
23+
const builtHtmlFiles: string[] = [];
24+
for await (
25+
const entry of walk(dist, {
26+
includeDirs: false,
27+
exts: ['.html'],
28+
skip: [/(^|\/)pagefind(\/|$)/],
29+
})
30+
) {
31+
builtHtmlFiles.push(entry.path.slice(dist.length + 1));
32+
}
33+
builtHtmlFiles.sort();
34+
35+
const { plan, failures } = buildSeoPlan({
36+
graph,
37+
routeSeo,
38+
locales: SITE_LOCALES,
39+
builtHtmlFiles,
40+
});
41+
const rewritten: string[] = [];
42+
if (failures.length === 0) {
43+
for (const entry of plan) {
44+
const path = join(dist, entry.file);
45+
const html = await Deno.readTextFile(path);
46+
const applied = applySeoToHtml(html, entry);
47+
if (applied === null) {
48+
failures.push({
49+
file: entry.file,
50+
message: 'expected boilerplate head anchors not found — template drift',
51+
});
52+
continue;
53+
}
54+
if (applied !== html) {
55+
await Deno.writeTextFile(path, applied);
56+
rewritten.push(entry.file);
57+
}
58+
}
59+
}
60+
if (failures.length > 0) {
61+
console.error('www SEO application failed:');
62+
for (const failure of failures) console.error(`- ${failure.file}: ${failure.message}`);
63+
Deno.exit(1);
64+
}
65+
return rewritten;
66+
}
67+
68+
if (import.meta.main) {
69+
const rewritten = await applyWwwSeo();
70+
console.log(`www SEO applied to ${rewritten.length} page(s).`);
71+
}

tools/autoflow/policy.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -253,7 +253,9 @@ const GATES: readonly GateDefinition[] = [
253253
// owned truth — generated nav freshness (byte-identical regeneration of
254254
// route meta + headerNav), headerNav hrefs resolve to real routes,
255255
// bilingual locale availability (no orphan/missing/duplicated-untranslated
256-
// zh), and the CURRENT roadmap entry names the package version tag.
256+
// zh — extended #1307 to route-level content records and the blog
257+
// single-language `lang` declaration), and the CURRENT roadmap entry
258+
// names the package version tag.
257259
name: 'www:check-truth',
258260
command: ['deno', 'task', 'www:check-truth'],
259261
tiers: ['ci', 'release'],
@@ -266,11 +268,14 @@ const GATES: readonly GateDefinition[] = [
266268
{
267269
// #1159 (B2.4): guide/architecture code examples that import
268270
// @openelement/* must type-check against the real framework sources.
271+
// #1307: the TS2304 suppression boundary reads the documented export
272+
// names from the generated API reference.
269273
name: 'content:examples-check',
270274
command: ['deno', 'task', 'content:examples-check'],
271275
tiers: ['ci', 'release'],
272276
triggers: [
273277
/^www\/content\//,
278+
/^www\/app\/data\/_generated-api-reference\.ts$/,
274279
/^packages\//,
275280
/^tools\/check-content-examples/,
276281
/^deno\.json$/,
@@ -434,10 +439,13 @@ const GATES: readonly GateDefinition[] = [
434439
tiers: ['ci', 'release'],
435440
// #1159: the build task ends with the built-output internal
436441
// link/fragment + SEO gate (www:check-links), so checker edits rebuild.
442+
// #1307: the build also applies the derived per-route SEO plan
443+
// (www:apply-seo) and the link gate asserts the generated reference
444+
// anchors against the built apilist pages.
437445
triggers: [
438446
/^(packages|www)\//,
439447
/^deno\.json$/,
440-
/^tools\/(?:check-www-links|lib\/www-links)/,
448+
/^tools\/(?:check-www-links|lib\/www-links|apply-www-seo|lib\/www-seo)/,
441449
],
442450
},
443451
{

tools/check-content-examples.test.ts

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
} from './check-content-examples.ts';
88
import ts from 'typescript';
99

10-
Deno.test('extractExamples: only fenced ts/tsx blocks importing @openelement', () => {
10+
Deno.test('extractExamples: ts/tsx plus the typescript/js aliases, importing @openelement', () => {
1111
const markdown = [
1212
'```ts',
1313
"import { signal } from '@openelement/element';",
@@ -23,11 +23,18 @@ Deno.test('extractExamples: only fenced ts/tsx blocks importing @openelement', (
2323
"import { OpenElement } from '@openelement/element';",
2424
'export class X extends OpenElement {}',
2525
'```',
26+
'```typescript',
27+
"import { signal } from '@openelement/element';",
28+
'const other = signal(1);',
29+
'```',
30+
'```js',
31+
"import { signal } from '@openelement/element';",
32+
'const plain = signal(2);',
33+
'```',
2634
].join('\n');
2735
const examples = extractExamples('guide/x.md', markdown);
28-
assertEquals(examples.length, 2);
29-
assertEquals(examples[0].lang, 'ts');
30-
assertEquals(examples[1].lang, 'tsx');
36+
assertEquals(examples.length, 4);
37+
assertEquals(examples.map((example) => example.lang), ['ts', 'tsx', 'ts', 'ts']);
3138
});
3239

3340
Deno.test('typeCheckExamples: framework-surface errors fail closed (RED proof)', async () => {
@@ -95,6 +102,17 @@ Deno.test('suppressElidedDiagnostic: suppression boundary is exact', () => {
95102
false,
96103
);
97104
assertEquals(suppressElidedDiagnostic(make(2304, "Cannot find name 'listEntries'.")), true);
105+
// #1307: an undefined name that IS a documented framework export must not be
106+
// suppressed — the snippet has to import it so its calls are type-checked.
107+
assertEquals(suppressElidedDiagnostic(make(2304, "Cannot find name 'definePage'.")), false);
108+
assertEquals(suppressElidedDiagnostic(make(2304, "Cannot find name 'signal'.")), false);
109+
assertEquals(
110+
suppressElidedDiagnostic(
111+
make(2304, "Cannot find name 'listEntries'."),
112+
new Set(['definePage']),
113+
),
114+
true,
115+
);
98116
assertEquals(
99117
suppressElidedDiagnostic(make(2339, "Property 'entries' does not exist on type '{}'.")),
100118
true,

0 commit comments

Comments
 (0)