feat(docs): build maintainable documentation site
This commit is contained in:
98
scripts/project-doc-site.spec.ts
Normal file
98
scripts/project-doc-site.spec.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
/** Tests for the documentation website projection adapter. */
|
||||
|
||||
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 type { DocsPage } from '../website/docs.ts'
|
||||
import { addProjectionFrontmatter, rewriteMarkdown } from './project-doc-site.ts'
|
||||
|
||||
const roots: string[] = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function fixture(): { root: string; pages: DocsPage[] } {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-doc-site-'))
|
||||
roots.push(root)
|
||||
mkdirSync(join(root, 'docs'), { recursive: true })
|
||||
mkdirSync(join(root, 'packages'), { recursive: true })
|
||||
writeFileSync(join(root, 'docs/a.md'), '# A\n')
|
||||
writeFileSync(join(root, 'docs/b.md'), '# B\n')
|
||||
writeFileSync(join(root, 'packages/tool.ts'), 'one\ntwo\n')
|
||||
writeFileSync(join(root, 'packages/logo.svg'), '<svg/>\n')
|
||||
return {
|
||||
root,
|
||||
pages: [
|
||||
{ source: 'docs/a.md', route: 'en/a.md', label: 'A', sidebar: 'en-docs', section: 'Test', order: 1 },
|
||||
{ source: 'docs/b.md', route: 'en/reference/b.md', label: 'B', sidebar: 'en-docs', section: 'Test', order: 2 },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
describe('rewriteMarkdown', () => {
|
||||
it('maps published pages and pins unpublished source links', () => {
|
||||
const { root, pages } = fixture()
|
||||
const source = '[B](b.md#part) [source](../packages/tool.ts:2) [web](https://example.com)\n'
|
||||
expect(rewriteMarkdown(source, {
|
||||
sourcePath: 'docs/a.md',
|
||||
route: 'en/a.md',
|
||||
pages,
|
||||
repoRoot: root,
|
||||
repositoryRef: 'abc123',
|
||||
})).toBe(
|
||||
'[B](./reference/b.md#part) '
|
||||
+ '[source](https://github.com/deepseek-harness/deepseek-harness/blob/abc123/packages/tool.ts#L2) '
|
||||
+ '[web](https://example.com)\n',
|
||||
)
|
||||
})
|
||||
|
||||
it('uses raw GitHub content for unpublished images', () => {
|
||||
const { root, pages } = fixture()
|
||||
expect(rewriteMarkdown('\n', {
|
||||
sourcePath: 'docs/a.md',
|
||||
route: 'en/a.md',
|
||||
pages,
|
||||
repoRoot: root,
|
||||
repositoryRef: 'abc123',
|
||||
})).toBe('\n')
|
||||
})
|
||||
|
||||
it('does not rewrite Markdown-looking text inside code fences', () => {
|
||||
const { root, pages } = fixture()
|
||||
const source = '```md\n[B](b.md)\n```\n'
|
||||
expect(rewriteMarkdown(source, {
|
||||
sourcePath: 'docs/a.md',
|
||||
route: 'en/a.md',
|
||||
pages,
|
||||
repoRoot: root,
|
||||
repositoryRef: 'abc123',
|
||||
})).toBe(source)
|
||||
})
|
||||
|
||||
it('fails loud when a relative target is missing', () => {
|
||||
const { root, pages } = fixture()
|
||||
expect(() => rewriteMarkdown('[missing](missing.md)\n', {
|
||||
sourcePath: 'docs/a.md',
|
||||
route: 'en/a.md',
|
||||
pages,
|
||||
repoRoot: root,
|
||||
repositoryRef: 'abc123',
|
||||
})).toThrow('links to missing path "missing.md"')
|
||||
})
|
||||
})
|
||||
|
||||
describe('addProjectionFrontmatter', () => {
|
||||
it('adds frontmatter to an ordinary Markdown page', () => {
|
||||
expect(addProjectionFrontmatter('# Guide\n', 'docs/guide.md')).toBe(
|
||||
'---\neditSource: "docs/guide.md"\n---\n\n# Guide\n',
|
||||
)
|
||||
})
|
||||
|
||||
it('extends existing VitePress frontmatter', () => {
|
||||
expect(addProjectionFrontmatter('---\nlayout: home\n---\n', 'docs/index.md')).toBe(
|
||||
'---\neditSource: "docs/index.md"\nlayout: home\n---\n',
|
||||
)
|
||||
})
|
||||
})
|
||||
215
scripts/project-doc-site.ts
Normal file
215
scripts/project-doc-site.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* Build-time projection from canonical repository Markdown into VitePress.
|
||||
*
|
||||
* The generated tree is disposable: sources stay in their owning `docs/`
|
||||
* tier, while this adapter rewrites cross-source links for the public site.
|
||||
*/
|
||||
|
||||
import { existsSync, lstatSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, extname, posix, relative, resolve, sep } from 'node:path'
|
||||
import { fromMarkdown } from 'mdast-util-from-markdown'
|
||||
import { gfmFromMarkdown } from 'mdast-util-gfm'
|
||||
import { gfm } from 'micromark-extension-gfm'
|
||||
import type { Nodes } from 'mdast'
|
||||
import { docsPages, type DocsPage } from '../website/docs.ts'
|
||||
|
||||
const REPOSITORY_URL = 'https://github.com/deepseek-harness/deepseek-harness'
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
const generatedRoot = resolve(root, 'website/.generated')
|
||||
|
||||
interface Replacement {
|
||||
start: number
|
||||
end: number
|
||||
value: string
|
||||
}
|
||||
|
||||
/** Inputs for rewriting one canonical Markdown page. */
|
||||
export interface RewriteMarkdownOptions {
|
||||
sourcePath: string
|
||||
route: string
|
||||
pages: DocsPage[]
|
||||
repoRoot: string
|
||||
repositoryRef: string
|
||||
}
|
||||
|
||||
function repoPath(absPath: string, repoRoot: string): string {
|
||||
return relative(repoRoot, absPath).split(sep).join('/')
|
||||
}
|
||||
|
||||
function isExternalOrSiteAbsolute(url: string): boolean {
|
||||
return url.startsWith('#')
|
||||
|| url.startsWith('//')
|
||||
|| url.startsWith('/')
|
||||
|| /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(url)
|
||||
}
|
||||
|
||||
function splitTarget(url: string): { path: string; suffix: string } {
|
||||
const boundary = url.search(/[?#]/)
|
||||
if (boundary === -1) return { path: url, suffix: '' }
|
||||
return { path: url.slice(0, boundary), suffix: url.slice(boundary) }
|
||||
}
|
||||
|
||||
function decodePath(path: string): string {
|
||||
try {
|
||||
return decodeURIComponent(path)
|
||||
} catch {
|
||||
throw new Error(`project-doc-site: malformed percent escape in ${JSON.stringify(path)}.`)
|
||||
}
|
||||
}
|
||||
|
||||
function routeTarget(fromRoute: string, toRoute: string, suffix: string): string {
|
||||
const target = posix.relative(posix.dirname(fromRoute), toRoute)
|
||||
return `${target.startsWith('.') ? target : `./${target}`}${suffix}`
|
||||
}
|
||||
|
||||
function sourceMap(pages: DocsPage[]): Map<string, DocsPage> {
|
||||
const map = new Map<string, DocsPage>()
|
||||
for (const page of pages) {
|
||||
for (const source of [page.source, ...(page.sourceAliases ?? [])]) {
|
||||
if (map.has(source)) {
|
||||
throw new Error(`project-doc-site: duplicate source or alias ${JSON.stringify(source)}.`)
|
||||
}
|
||||
map.set(source, page)
|
||||
}
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
function resolveRepositoryTarget(sourceAbs: string, rawPath: string, repoRoot: string): { absPath: string; line?: number } {
|
||||
const decoded = decodePath(rawPath)
|
||||
let absPath = resolve(dirname(sourceAbs), decoded)
|
||||
if (existsSync(absPath)) return { absPath }
|
||||
|
||||
const lineMatch = decoded.match(/:(\d+)$/)
|
||||
if (lineMatch !== null) {
|
||||
const lineText = lineMatch[1]
|
||||
if (lineText === undefined) throw new Error('project-doc-site: line suffix matched without a line number.')
|
||||
absPath = resolve(dirname(sourceAbs), decoded.slice(0, -lineMatch[0].length))
|
||||
if (existsSync(absPath)) return { absPath, line: Number.parseInt(lineText, 10) }
|
||||
}
|
||||
|
||||
if (extname(decoded) === '') {
|
||||
const markdown = resolve(dirname(sourceAbs), `${decoded}.md`)
|
||||
if (existsSync(markdown)) return { absPath: markdown }
|
||||
const index = resolve(dirname(sourceAbs), decoded, 'index.md')
|
||||
if (existsSync(index)) return { absPath: index }
|
||||
}
|
||||
|
||||
throw new Error(`project-doc-site: ${repoPath(sourceAbs, repoRoot)} links to missing path ${JSON.stringify(rawPath)}.`)
|
||||
}
|
||||
|
||||
function githubTarget(
|
||||
absPath: string,
|
||||
line: number | undefined,
|
||||
suffix: string,
|
||||
repositoryRef: string,
|
||||
repoRoot: string,
|
||||
image: boolean,
|
||||
): string {
|
||||
const path = repoPath(absPath, repoRoot)
|
||||
if (image) return `https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/${repositoryRef}/${path}${suffix}`
|
||||
const kind = lstatSync(absPath).isDirectory() ? 'tree' : 'blob'
|
||||
const lineSuffix = line === undefined ? suffix : `#L${line}`
|
||||
return `${REPOSITORY_URL}/${kind}/${repositoryRef}/${path}${lineSuffix}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite repository-relative links without reserializing Markdown.
|
||||
*
|
||||
* @param source Markdown text from the canonical file.
|
||||
* @param options Source, route, manifest, and repository context.
|
||||
* @returns Markdown whose published links resolve inside the site or to GitHub.
|
||||
*/
|
||||
export function rewriteMarkdown(source: string, options: RewriteMarkdownOptions): string {
|
||||
const sourceAbs = resolve(options.repoRoot, options.sourcePath)
|
||||
const published = sourceMap(options.pages)
|
||||
const tree = fromMarkdown(source, { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()] })
|
||||
const replacements: Replacement[] = []
|
||||
|
||||
const rewrite = (node: Nodes & { url: string }): void => {
|
||||
if (isExternalOrSiteAbsolute(node.url)) return
|
||||
const { path, suffix } = splitTarget(node.url)
|
||||
if (path === '') return
|
||||
const { absPath, line } = resolveRepositoryTarget(sourceAbs, path, options.repoRoot)
|
||||
const targetPath = repoPath(absPath, options.repoRoot)
|
||||
const page = published.get(targetPath)
|
||||
const nextUrl = page === undefined
|
||||
? githubTarget(absPath, line, suffix, options.repositoryRef, options.repoRoot, node.type === 'image')
|
||||
: routeTarget(options.route, page.route, suffix)
|
||||
|
||||
const start = node.position?.start.offset
|
||||
const end = node.position?.end.offset
|
||||
if (start === undefined || end === undefined) {
|
||||
throw new Error(`project-doc-site: link ${JSON.stringify(node.url)} has no source offsets.`)
|
||||
}
|
||||
const rawNode = source.slice(start, end)
|
||||
const urlOffset = rawNode.lastIndexOf(node.url)
|
||||
if (urlOffset === -1) {
|
||||
throw new Error(`project-doc-site: cannot locate raw target ${JSON.stringify(node.url)} in ${JSON.stringify(rawNode)}.`)
|
||||
}
|
||||
replacements.push({
|
||||
start: start + urlOffset,
|
||||
end: start + urlOffset + node.url.length,
|
||||
value: nextUrl,
|
||||
})
|
||||
}
|
||||
|
||||
const visit = (node: Nodes): void => {
|
||||
if ((node.type === 'link' || node.type === 'image' || node.type === 'definition') && 'url' in node) rewrite(node)
|
||||
if ('children' in node) {
|
||||
for (const child of node.children) visit(child)
|
||||
}
|
||||
}
|
||||
visit(tree)
|
||||
|
||||
let projected = source
|
||||
for (const replacement of replacements.sort((a, b) => b.start - a.start)) {
|
||||
projected = projected.slice(0, replacement.start) + replacement.value + projected.slice(replacement.end)
|
||||
}
|
||||
return projected
|
||||
}
|
||||
|
||||
/**
|
||||
* Record the canonical edit target in VitePress frontmatter.
|
||||
*
|
||||
* @param markdown Projected Markdown content.
|
||||
* @param sourcePath Repository-relative canonical source path.
|
||||
* @returns Markdown with an `editSource` frontmatter field.
|
||||
*/
|
||||
export function addProjectionFrontmatter(markdown: string, sourcePath: string): string {
|
||||
const field = `editSource: ${JSON.stringify(sourcePath)}`
|
||||
if (markdown.startsWith('---\n')) return markdown.replace('---\n', `---\n${field}\n`)
|
||||
return `---\n${field}\n---\n\n${markdown}`
|
||||
}
|
||||
|
||||
/** Canonical Markdown files watched by the local VitePress dev server. */
|
||||
export function docsSourceFiles(): string[] {
|
||||
return [...new Set(docsPages.map(page => resolve(root, page.source)))]
|
||||
}
|
||||
|
||||
/** Rebuild the disposable VitePress source tree from the publication manifest. */
|
||||
export function projectDocs(): void {
|
||||
const routes = new Set<string>()
|
||||
const repositoryRef = process.env.GITHUB_SHA ?? 'master'
|
||||
rmSync(generatedRoot, { recursive: true, force: true })
|
||||
|
||||
for (const page of docsPages) {
|
||||
if (routes.has(page.route)) throw new Error(`project-doc-site: duplicate route ${JSON.stringify(page.route)}.`)
|
||||
routes.add(page.route)
|
||||
const sourceAbs = resolve(root, page.source)
|
||||
if (!existsSync(sourceAbs) || !lstatSync(sourceAbs).isFile()) {
|
||||
throw new Error(`project-doc-site: source ${JSON.stringify(page.source)} does not exist or is not a file.`)
|
||||
}
|
||||
const output = resolve(generatedRoot, page.route)
|
||||
mkdirSync(dirname(output), { recursive: true })
|
||||
const markdown = readFileSync(sourceAbs, 'utf8')
|
||||
const projected = rewriteMarkdown(markdown, {
|
||||
sourcePath: page.source,
|
||||
route: page.route,
|
||||
pages: docsPages,
|
||||
repoRoot: root,
|
||||
repositoryRef,
|
||||
})
|
||||
writeFileSync(output, addProjectionFrontmatter(projected, page.source))
|
||||
}
|
||||
}
|
||||
@@ -272,6 +272,7 @@ function docSyncLeafGates(): Gate[] {
|
||||
pnpmScript('type-equivalence', 'verify-type-equiv', { label: 'type equivalence' }),
|
||||
pnpmScript('translation-pairing', 'verify-translation-pairing', { label: 'translation pairing' }),
|
||||
pnpmScript('doc-budgets', 'verify-doc-budgets', { label: 'doc budgets' }),
|
||||
pnpmScript('docs-site', 'docs:check', { label: 'documentation site' }),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -10,10 +10,12 @@
|
||||
* `paragraph` node whose source span covers more than one line. The parser owns
|
||||
* all the structure that legitimately occupies multiple lines — fenced code
|
||||
* (any fence length), tables, list items, blockquotes, HTML blocks, headings,
|
||||
* thematic breaks, link-reference definitions — so a hard wrap is simply "a
|
||||
* paragraph node that starts and ends on different lines." This is checker, not
|
||||
* formatter: it reports and never rewrites, so it introduces zero cosmetic
|
||||
* churn (no emphasis-marker or table-delimiter normalization).
|
||||
* thematic breaks, link-reference definitions — while a small preprocessing
|
||||
* pass masks VitePress YAML frontmatter and custom-container delimiter lines.
|
||||
* A hard wrap is simply "a paragraph node that starts and ends on different
|
||||
* lines." This is checker, not formatter: it reports and never rewrites, so it
|
||||
* introduces zero cosmetic churn (no emphasis-marker or table-delimiter
|
||||
* normalization).
|
||||
*
|
||||
* A wrapped paragraph inside a list item or blockquote is still a `paragraph`
|
||||
* node, so those are caught too. Scope mirrors doc-typecheck plus the two
|
||||
@@ -57,11 +59,23 @@ interface Violation {
|
||||
text: string
|
||||
}
|
||||
|
||||
function maskVitePressStructure(source: string): string {
|
||||
const lines = source.split('\n')
|
||||
if (lines[0] === '---') {
|
||||
const closing = lines.indexOf('---', 1)
|
||||
if (closing !== -1) {
|
||||
for (let index = 0; index <= closing; index++) lines[index] = ''
|
||||
}
|
||||
}
|
||||
return lines.map(line => line.trimStart().startsWith(':::') ? '' : line).join('\n')
|
||||
}
|
||||
|
||||
/** Find every hard-wrapped prose paragraph in one Markdown file via its AST. */
|
||||
function findViolations(absPath: string): Violation[] {
|
||||
const file = relative(root, absPath)
|
||||
const source = readFileSync(absPath, 'utf8')
|
||||
const tree = fromMarkdown(source, { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()] })
|
||||
const parsedSource = maskVitePressStructure(source)
|
||||
const tree = fromMarkdown(parsedSource, { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()] })
|
||||
const out: Violation[] = []
|
||||
|
||||
const visit = (node: Nodes): void => {
|
||||
|
||||
Reference in New Issue
Block a user