docs: fix release smoke test failures

This commit is contained in:
Turtle
2026-08-13 14:20:09 +08:00
parent be96840412
commit 62080d49c2
66 changed files with 1150 additions and 124 deletions
+3 -7
View File
@@ -12,6 +12,7 @@ import { dirname, resolve, sep } from 'node:path'
import ts from 'typescript'
import { LINK_MAP } from './gen-cordis-catalog.ts'
import { parseJsDoc, pointer, rawJsDoc } from './jsdoc.ts'
import { githubSlug } from './verify-md-links.ts'
const root = resolve(import.meta.dirname, '..')
const OUT = 'docs/config-catalog.md'
@@ -766,11 +767,6 @@ export function collectConfigCatalog(scanRoot: string = root): CatalogEntry[] {
return entries.sort((a, b) => a.pkg.localeCompare(b.pkg))
}
/** GitHub-style anchor slug for a `## \`pkg\`` heading. */
function slug(heading: string): string {
return heading.toLowerCase().replace(/[^a-z0-9 -]/g, '').replace(/ /g, '-')
}
/** Render the `Requires:` service-key line, or '' when the plugin injects nothing. */
function requiresLine(inject: string[]): string {
return inject.length ? `Requires: ${inject.map(k => `\`${k}\``).join(' · ')}` : ''
@@ -782,7 +778,7 @@ function requiresLine(inject: string[]): string {
function refLink(ref: TypeRef, byName: Map<string, CatalogEntry>): string {
const target = byName.get(ref.specifier)
if (target?.kind === 'config' && ref.imported === target.configTypeName) {
return `[\`${ref.alias}\`](#${slug(target.pkg)})`
return `[\`${ref.alias}\`](#${githubSlug(target.pkg)})`
}
const page = LINK_MAP[ref.imported]
if (page) return `[\`${ref.alias}\`](subsystems/${page})`
@@ -792,7 +788,7 @@ function refLink(ref: TypeRef, byName: Map<string, CatalogEntry>): string {
/** Render one configurable plugin's section. */
function renderConfigEntry(entry: CatalogEntry, byName: Map<string, CatalogEntry>): string[] {
const out = [`## \`${entry.pkg}\``, '']
const out = [`<a id="${githubSlug(entry.pkg)}"></a>`, '', `## \`${entry.pkg}\``, '']
const requires = requiresLine(entry.inject)
if (requires) out.push(requires, '')
out.push('```' + FENCE, ...(entry.pastes ?? []).map(p => p.text).join('\n\n').split('\n'), '```', '')
+3 -1
View File
@@ -10,6 +10,7 @@ import { globSync, readFileSync, writeFileSync } from 'node:fs'
import { resolve, sep } from 'node:path'
import ts from 'typescript'
import { parseJsDoc, pointer, rawJsDoc, reportViolations } from './jsdoc.ts'
import { githubSlug } from './verify-md-links.ts'
const root = resolve(import.meta.dirname, '..')
const OUT = 'docs/persistence-catalog.md'
@@ -341,7 +342,8 @@ function typeLinks(payload: string): string {
/** Render one log event entry. */
function renderEvent(e: AnnotatedLogEventEntry): string[] {
const out = [`#### \`${e.name}\`${e.surface ? 'surface' : 'log-only'}`, '']
const heading = `${e.name}${e.surface ? 'surface' : 'log-only'}`
const out = [`<a id="${githubSlug(heading)}"></a>`, '', `#### \`${e.name}\`${e.surface ? 'surface' : 'log-only'}`, '']
out.push('```' + FENCE, e.declaration, '```', '')
const links = typeLinks(e.payload)
if (links) out.push(links, '')
+2 -1
View File
@@ -63,6 +63,7 @@ import * as ToolWeb from '@deepseek-ai/dsh-tool-web'
import VmWorkflowEngine from '@deepseek-ai/dsh-workflow-worker-thread'
import * as ToolRalph from '@deepseek-ai/dsh-tool-ralph'
import * as ToolWorkflow from '@deepseek-ai/dsh-tool-workflow'
import { githubSlug } from './verify-md-links.ts'
/** Attachment seam marker that makes the attachments-conditional `read_image` schema harvestable. */
class CatalogAttachmentStore extends AttachmentStore {
@@ -704,7 +705,7 @@ export function render(catalog: ToolCatalog): string {
'',
]
for (const entry of catalog) {
lines.push(`## \`${entry.pkg}\``, '')
lines.push(`<a id="${githubSlug(entry.pkg)}"></a>`, '', `## \`${entry.pkg}\``, '')
for (const schema of entry.schemas) {
// Collection validated that every harvested schema has a source.
const source = entry.sources[schema.name] as string
+1 -1
View File
@@ -606,7 +606,7 @@ function docSyncLeafGates(options: {
pnpmScript('translation-prompt', 'verify-translation-prompt', { label: 'translation prompt' }),
pnpmScript('translation-pairing', 'verify-translation-pairing', { label: 'translation pairing' }),
pnpmScript('doc-budgets', 'verify-doc-budgets', { label: 'doc budgets' }),
pnpmExec('docs-site-projection', ['vitest', 'run', 'scripts/project-doc-site.spec.ts'], {
pnpmExec('docs-site-projection', ['vitest', 'run', 'scripts/project-doc-site.spec.ts', 'scripts/verify-doc-site-fragments.spec.ts'], {
label: 'documentation projection',
}),
// Keep the VitePress build itself in one gate because projection rewrites website/.generated.
+67
View File
@@ -0,0 +1,67 @@
/** Tests for built-site fragment validation. */
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { inspectSiteFragments } from './verify-doc-site-fragments.ts'
const roots: string[] = []
afterEach(() => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
})
function fixture(): string {
const root = mkdtempSync(join(tmpdir(), 'dsh-doc-fragments-'))
roots.push(root)
mkdirSync(join(root, 'guide'), { recursive: true })
writeFileSync(join(root, 'index.html'), '<a id="home"></a><a href="/guide/start#ready">start</a>')
writeFileSync(join(root, 'guide/start.html'), [
'<h1 id="ready">Ready</h1>',
'<a name="legacy"></a>',
'<a href="#ready">same page</a>',
'<a href="./start.html#legacy">html alias</a>',
'<a href="../#home">root</a>',
'<a href="https://example.com/page#missing">external</a>',
].join(''))
return root
}
describe('inspectSiteFragments', () => {
it('rejects a directory with no built pages', () => {
const root = mkdtempSync(join(tmpdir(), 'dsh-doc-fragments-empty-'))
roots.push(root)
expect(() => inspectSiteFragments(root)).toThrow('no HTML files found')
})
it('resolves clean, encoded, and same-page routes', () => {
const root = fixture()
writeFileSync(join(root, 'guide/encoded.html'), '<h1 id="a b">Encoded</h1><a href="./encoded#a%20b">self</a>')
expect(inspectSiteFragments(root)).toEqual({ checked: 5, broken: [] })
})
it('reports missing ids and missing built routes', () => {
const root = fixture()
writeFileSync(join(root, 'guide/broken.html'), [
'<a href="./start#missing">id</a>',
'<a href="./absent#missing">route</a>',
].join(''))
expect(inspectSiteFragments(root).broken).toEqual([
{
source: 'guide/broken.html',
href: './start#missing',
target: 'guide/start.html',
fragment: 'missing',
},
{
source: 'guide/broken.html',
href: './absent#missing',
fragment: 'missing',
},
])
})
})
+143
View File
@@ -0,0 +1,143 @@
/**
* Verify fragment links against the HTML emitted by VitePress. Markdown and
* VitePress use different heading-slug algorithms, so source-link validation
* alone cannot prove that a published fragment exists.
*
* Run with `tsx scripts/verify-doc-site-fragments.ts` after `docs:build`.
*/
import { globSync, readFileSync } from 'node:fs'
import { resolve, sep } from 'node:path'
import { JSDOM } from 'jsdom'
const root = resolve(import.meta.dirname, '..')
/** One fragment reference that does not resolve in the built site. */
export interface BrokenSiteFragment {
/** HTML file containing the link. */
source: string
/** Link value as emitted by VitePress. */
href: string
/** Built HTML target, or `undefined` when the route was not emitted. */
target?: string
/** Decoded fragment id requested by the link. */
fragment: string
}
/** Result of checking every fragment-bearing anchor in a built site. */
export interface SiteFragmentReport {
/** Number of internal fragment references inspected. */
checked: number
/** References whose route or fragment id is absent. */
broken: BrokenSiteFragment[]
}
interface BuiltPage {
file: string
route: string
ids: Set<string>
document: Document
}
function posixPath(path: string): string {
return path.split(sep).join('/')
}
function routeFor(file: string): string {
if (file === 'index.html') return '/'
if (file.endsWith('/index.html')) return `/${file.slice(0, -'index.html'.length)}`
return `/${file.slice(0, -'.html'.length)}`
}
function aliasesFor(page: BuiltPage): string[] {
if (page.route === '/') return ['/', '/index', '/index.html']
if (page.route.endsWith('/')) {
const stem = page.route.slice(0, -1)
return [page.route, stem, `${stem}/index`, `${stem}/index.html`]
}
return [page.route, `${page.route}.html`]
}
function decodedFragment(hash: string): string {
try {
return decodeURIComponent(hash.slice(1))
} catch {
return hash.slice(1)
}
}
/**
* Check fragment-bearing links in a VitePress output directory.
*
* @param distRoot - Directory containing generated HTML files.
* @returns Counted internal links and every unresolved target.
*/
export function inspectSiteFragments(distRoot: string): SiteFragmentReport {
const files = globSync('**/*.html', { cwd: distRoot }).map(posixPath).sort()
if (files.length === 0) {
throw new Error(`verify-doc-site-fragments: no HTML files found under ${distRoot}; run docs:build first.`)
}
const pages: BuiltPage[] = files.map((file) => {
const document = new JSDOM(readFileSync(resolve(distRoot, file), 'utf8')).window.document
const ids = new Set<string>()
for (const element of document.querySelectorAll<HTMLElement>('[id]')) ids.add(element.id)
for (const element of document.querySelectorAll<HTMLAnchorElement>('a[name]')) {
const name = element.getAttribute('name')
if (name !== null) ids.add(name)
}
return { file, route: routeFor(file), ids, document }
})
const byRoute = new Map<string, BuiltPage>()
for (const page of pages) {
for (const alias of aliasesFor(page)) byRoute.set(alias, page)
}
const origin = 'https://dsh-docs.invalid'
const broken: BrokenSiteFragment[] = []
let checked = 0
for (const page of pages) {
for (const anchor of page.document.querySelectorAll<HTMLAnchorElement>('a[href]')) {
const href = anchor.getAttribute('href')
if (href === null || !href.includes('#')) continue
let targetUrl: URL
try {
targetUrl = new URL(href, `${origin}${page.route}`)
} catch {
continue
}
if (targetUrl.origin !== origin || targetUrl.hash === '') continue
const fragment = decodedFragment(targetUrl.hash)
if (fragment === '') continue
checked++
const target = byRoute.get(targetUrl.pathname)
if (target === undefined || !target.ids.has(fragment)) {
broken.push({
source: page.file,
href,
...(target === undefined ? {} : { target: target.file }),
fragment,
})
}
}
}
return { checked, broken }
}
function main(): number {
const distRoot = resolve(root, 'website/.dist')
const report = inspectSiteFragments(distRoot)
if (report.broken.length === 0) {
console.log(`verify-doc-site-fragments: ${report.checked} internal fragment reference(s) resolve.`)
return 0
}
console.error(`verify-doc-site-fragments: ${report.broken.length} broken fragment reference(s):`)
for (const item of report.broken) {
const target = item.target === undefined ? 'target route was not built' : `${item.target} has no id ${JSON.stringify(item.fragment)}`
console.error(` ${item.source}: ${JSON.stringify(item.href)} (${target})`)
}
return 1
}
if (import.meta.main) process.exitCode = main()