docs: rebuild documentation site with React

This commit is contained in:
程序员阿江(Relakkes)
2026-07-23 20:46:33 +08:00
parent f70fa017ef
commit c2fd674662
93 changed files with 10808 additions and 6902 deletions
+113
View File
@@ -0,0 +1,113 @@
import { promises as fs } from 'node:fs'
import path from 'node:path'
import { generateDocsManifest, paths } from './generate-docs-manifest.mjs'
const markdownTargetPattern = /!?\[[^\]]*]\(([^)\s]+)(?:\s+["'][^"']*["'])?\)/g
const htmlTargetPattern = /<(?:a|img)\b[^>]*?\b(?:href|src)=["']([^"']+)["'][^>]*>/gi
function withoutSuffix(target) {
return target.split(/[?#]/, 1)[0]
}
function isExternal(target) {
return /^(?:[a-z]+:|\/\/|#)/i.test(target)
}
function normalizeRoute(target) {
const decoded = decodeURIComponent(withoutSuffix(target))
const withoutExtension = decoded
.replace(/(?:\/index)?\.html$/i, '')
.replace(/\.md$/i, '')
const normalized = `/${withoutExtension}`.replace(/\/+/g, '/').replace(/\/$/, '')
return normalized || '/'
}
async function exists(targetPath) {
return fs.access(targetPath).then(() => true, () => false)
}
function collectTargets(markdown) {
const targets = []
const prose = markdown.replace(/```[\s\S]*?```/g, '')
for (const match of prose.matchAll(markdownTargetPattern)) {
targets.push(match[1])
}
for (const match of prose.matchAll(htmlTargetPattern)) {
targets.push(match[1])
}
return [...new Set(targets)]
}
function isImageTarget(target) {
return /\.(?:avif|gif|jpe?g|png|svg|webp)$/i.test(withoutSuffix(target))
}
async function main() {
const { records } = await generateDocsManifest()
const routes = new Set([
'/',
'/docs',
'/en',
'/en/docs',
...records.map((record) => record.path),
])
const problems = []
let checkedTargets = 0
for (const record of records) {
const sourceAbsolutePath = path.join(paths.repoDir, record.sourcePath)
const sourceDirectory = path.dirname(sourceAbsolutePath)
for (const target of collectTargets(record.content)) {
if (!target || isExternal(target)) {
continue
}
checkedTargets += 1
const pathname = withoutSuffix(target)
const image = isImageTarget(pathname)
if (pathname.startsWith('/')) {
const publicFile = path.join(paths.docsDir, 'public', pathname.replace(/^\//, ''))
const docsFile = path.join(paths.docsDir, pathname.replace(/^\//, ''))
const valid = image
? await exists(publicFile) || await exists(docsFile)
: routes.has(normalizeRoute(pathname)) || await exists(publicFile)
if (!valid) {
problems.push(`${record.sourcePath}: unresolved ${image ? 'image' : 'link'} ${target}`)
}
continue
}
const resolvedFile = path.resolve(sourceDirectory, pathname)
const markdownRoute = normalizeRoute(path.relative(paths.docsDir, resolvedFile))
const valid = image
? await exists(resolvedFile)
: await exists(resolvedFile)
|| routes.has(markdownRoute)
|| routes.has(normalizeRoute(`${path.relative(paths.docsDir, resolvedFile)}.md`))
if (!valid) {
problems.push(`${record.sourcePath}: unresolved ${image ? 'image' : 'link'} ${target}`)
}
}
}
if (problems.length > 0) {
console.error(`Documentation check found ${problems.length} problem(s):`)
for (const problem of problems) {
console.error(`- ${problem}`)
}
process.exitCode = 1
return
}
console.log(`Documentation check passed: ${records.length} pages, ${checkedTargets} local links and images.`)
}
await main()
+304
View File
@@ -0,0 +1,304 @@
import { promises as fs } from 'node:fs'
import path from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
const scriptDir = path.dirname(fileURLToPath(import.meta.url))
const siteDir = path.resolve(scriptDir, '..')
const repoDir = path.resolve(siteDir, '..')
const docsDir = path.join(repoDir, 'docs')
const outputPath = path.join(siteDir, 'src', 'generated', 'docs-manifest.js')
export const paths = {
docsDir,
outputPath,
repoDir,
siteDir
}
const excludedDirectoryNames = new Set(['superpowers', 'ui-clone'])
const excludedFiles = new Set(['index.md', 'en/index.md', 'AGENTS.md'])
const sectionLabels = {
zh: {
guide: '开始使用',
desktop: '桌面应用',
features: '核心能力',
im: '消息接入',
agent: '多 Agent',
skills: '技能系统',
memory: '记忆系统',
channel: 'Channel 研究',
reference: '参考资料'
},
en: {
guide: 'Get started',
desktop: 'Desktop app',
features: 'Core capabilities',
im: 'Messaging',
agent: 'Multi-agent',
skills: 'Skills',
memory: 'Memory',
channel: 'Channel research',
reference: 'Reference'
}
}
const sectionOrder = [
'guide',
'desktop',
'features',
'im',
'agent',
'skills',
'memory',
'channel',
'reference'
]
function toPosix(value) {
return value.split(path.sep).join('/')
}
function shouldInclude(relativePath) {
const normalized = toPosix(relativePath)
const parts = normalized.split('/')
return normalized.endsWith('.md')
&& !excludedFiles.has(normalized)
&& !parts.some((part) => excludedDirectoryNames.has(part))
}
async function listMarkdownFiles(directory, prefix = '') {
const entries = await fs.readdir(directory, { withFileTypes: true })
const files = []
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
const relativePath = prefix ? path.join(prefix, entry.name) : entry.name
const absolutePath = path.join(directory, entry.name)
if (entry.isDirectory()) {
if (!excludedDirectoryNames.has(entry.name)) {
files.push(...await listMarkdownFiles(absolutePath, relativePath))
}
continue
}
if (entry.isFile() && shouldInclude(relativePath)) {
files.push(toPosix(relativePath))
}
}
return files
}
function parseFrontmatter(markdown) {
if (!markdown.startsWith('---\n')) {
return { body: markdown, attributes: {} }
}
const closingIndex = markdown.indexOf('\n---\n', 4)
if (closingIndex === -1) {
return { body: markdown, attributes: {} }
}
const rawFrontmatter = markdown.slice(4, closingIndex)
const attributes = {}
for (const line of rawFrontmatter.split('\n')) {
const match = line.match(/^([A-Za-z][\w-]*):\s*(.+)$/)
if (match) {
attributes[match[1]] = match[2].replace(/^(['"])(.*)\1$/, '$2').trim()
}
}
return {
body: markdown.slice(closingIndex + 5),
attributes
}
}
function plainText(value) {
return value
.replace(/<!--[\s\S]*?-->/g, ' ')
.replace(/!\[[^\]]*]\([^)]*\)/g, ' ')
.replace(/\[([^\]]+)]\([^)]*\)/g, '$1')
.replace(/<[^>]+>/g, ' ')
.replace(/[`*_~>#|]/g, ' ')
.replace(/\s+/g, ' ')
.trim()
}
function extractTitle(body, frontmatter, fallback) {
if (frontmatter.title) {
return plainText(frontmatter.title)
}
const heading = body.match(/^#\s+(.+)$/m)
return heading ? plainText(heading[1]) : fallback
}
function extractDescription(body, frontmatter) {
if (frontmatter.description) {
return plainText(frontmatter.description).slice(0, 180)
}
const withoutFences = body.replace(/```[\s\S]*?```/g, '')
const paragraphs = withoutFences.split(/\n\s*\n/)
const paragraph = paragraphs.find((candidate) => {
const value = candidate.trim()
return value
&& !value.startsWith('#')
&& !value.startsWith('!')
&& !value.startsWith('<')
&& !value.startsWith('|')
&& !value.startsWith(':::')
})
return plainText(paragraph || '').slice(0, 180)
}
function routeFromRelativePath(relativePath) {
const withoutExtension = relativePath.replace(/\.md$/i, '')
const withoutIndex = withoutExtension.replace(/(^|\/)index$/i, '$1')
return `/${withoutIndex}`.replace(/\/+/g, '/').replace(/\/$/, '') || '/'
}
function sortKey(relativePath) {
const basename = path.posix.basename(relativePath, '.md')
if (basename === 'index') {
return '0000-index'
}
return basename.replace(/^(\d+)-/, (_, value) => value.padStart(4, '0'))
}
function buildRecord(relativePath, markdown) {
const { body, attributes } = parseFrontmatter(markdown)
const segments = relativePath.split('/')
const locale = segments[0] === 'en' ? 'en' : 'zh'
const localeRelativePath = locale === 'en' ? segments.slice(1).join('/') : relativePath
const section = localeRelativePath.split('/')[0]
const route = routeFromRelativePath(relativePath)
const basename = path.posix.basename(relativePath, '.md')
const fallbackTitle = basename === 'index'
? section
: basename.replace(/^\d+-/, '').replaceAll('-', ' ')
return {
slug: route.replace(/^\//, ''),
path: route,
locale,
section,
title: extractTitle(body, attributes, fallbackTitle),
description: extractDescription(body, attributes),
sourcePath: `docs/${relativePath}`,
sortKey: sortKey(relativePath),
content: markdown
}
}
function buildNavigation(records, locale) {
const localeRecords = records.filter((record) => record.locale === locale)
const sections = [...new Set(localeRecords.map((record) => record.section))]
.sort((left, right) => {
const leftIndex = sectionOrder.indexOf(left)
const rightIndex = sectionOrder.indexOf(right)
return (leftIndex === -1 ? Number.MAX_SAFE_INTEGER : leftIndex)
- (rightIndex === -1 ? Number.MAX_SAFE_INTEGER : rightIndex)
|| left.localeCompare(right)
})
return sections.map((section) => ({
key: section,
title: sectionLabels[locale][section] || section,
items: localeRecords
.filter((record) => record.section === section)
.sort((left, right) => left.sortKey.localeCompare(right.sortKey))
.map(({ content, sortKey: _sortKey, section: _section, ...item }) => item)
}))
}
function serialize(value) {
return JSON.stringify(value, null, 2)
.replaceAll('\u2028', '\\u2028')
.replaceAll('\u2029', '\\u2029')
}
export async function generateDocsManifest() {
const relativePaths = await listMarkdownFiles(docsDir)
const records = await Promise.all(relativePaths.map(async (relativePath) => {
const markdown = await fs.readFile(path.join(docsDir, relativePath), 'utf8')
return buildRecord(relativePath, markdown)
}))
records.sort((left, right) => left.path.localeCompare(right.path))
const navigation = {
zh: buildNavigation(records, 'zh'),
en: buildNavigation(records, 'en')
}
const moduleSource = `// Generated by site/scripts/generate-docs-manifest.mjs. Do not edit.
export const docsManifest = ${serialize(records)}
export const docsNavigation = ${serialize(navigation)}
export const docsBySlug = Object.fromEntries(
docsManifest.flatMap((document) => [
[document.slug, document],
[document.path, document],
[\`\${document.path}/\`, document]
])
)
export function normalizeDocsPath(value) {
const pathname = String(value || '/').split(/[?#]/, 1)[0]
const decoded = decodeURIComponent(pathname)
const withoutHtml = decoded.replace(/(?:\\/index)?\\.html$/i, '')
const withoutMarkdown = withoutHtml.replace(/\\.md$/i, '')
const normalized = \`/\${withoutMarkdown}\`.replace(/\\/+/g, '/').replace(/\\/$/, '')
return normalized || '/'
}
export function resolveDocsPath(value) {
const normalized = normalizeDocsPath(value)
return docsBySlug[normalized]
|| docsBySlug[normalized.replace(/^\\//, '')]
|| null
}
export function resolveDocsHref(href, sourcePath) {
if (!href || /^(?:[a-z]+:|\\/\\/|#)/i.test(href)) {
return href
}
const suffixIndex = href.search(/[?#]/)
const pathname = suffixIndex === -1 ? href : href.slice(0, suffixIndex)
const suffix = suffixIndex === -1 ? '' : href.slice(suffixIndex)
if (pathname.startsWith('/')) {
return \`\${normalizeDocsPath(pathname)}\${suffix}\`
}
const sourceRelative = sourcePath.replace(/^docs\\//, '')
const resolved = new URL(pathname, \`https://docs.local/\${sourceRelative}\`).pathname
const isMarkdown = /\\.md$/i.test(resolved)
const target = isMarkdown ? normalizeDocsPath(resolved) : resolved
return \`\${target}\${suffix}\`
}
`
await fs.mkdir(path.dirname(outputPath), { recursive: true })
const current = await fs.readFile(outputPath, 'utf8').catch(() => '')
if (current !== moduleSource) {
await fs.writeFile(outputPath, moduleSource)
}
return { records, navigation, outputPath }
}
const isDirectRun = process.argv[1]
&& pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url
if (isDirectRun) {
const { records } = await generateDocsManifest()
console.log(`Generated ${records.length} documentation routes.`)
}
+108
View File
@@ -0,0 +1,108 @@
import { promises as fs } from 'node:fs'
import path from 'node:path'
import { generateDocsManifest, paths } from './generate-docs-manifest.mjs'
const distDir = path.join(paths.siteDir, 'dist')
const expectedCustomDomain = 'claudecode-haha.relakkesyang.org'
async function pathExists(targetPath) {
return fs.access(targetPath).then(() => true, () => false)
}
async function copyDirectory(source, destination) {
if (!await pathExists(source)) {
return
}
await fs.mkdir(path.dirname(destination), { recursive: true })
await fs.cp(source, destination, { recursive: true, force: true })
}
const markdownImagePattern = /!\[[^\]]*]\(([^)\s]+)(?:\s+["'][^"']*["'])?\)/g
const htmlImagePattern = /<img\b[^>]*?\bsrc=["']([^"']+)["'][^>]*>/gi
function imageTargets(markdown) {
const prose = markdown.replace(/```[\s\S]*?```/g, '')
return [
...prose.matchAll(markdownImagePattern),
...prose.matchAll(htmlImagePattern),
].map((match) => match[1])
}
async function copyReferencedDocImages(records) {
const sources = new Set()
for (const record of records) {
const sourceDirectory = path.dirname(path.join(paths.repoDir, record.sourcePath))
for (const target of imageTargets(record.content)) {
const pathname = target.split(/[?#]/, 1)[0]
if (!pathname || /^(?:[a-z]+:|\/\/|data:)/i.test(pathname)) continue
const source = pathname.startsWith('/')
? path.join(paths.docsDir, pathname.replace(/^\/+/, ''))
: path.resolve(sourceDirectory, pathname)
const relative = path.relative(paths.docsDir, source)
if (
relative.startsWith('..')
|| path.isAbsolute(relative)
|| relative === 'public'
|| relative.startsWith(`public${path.sep}`)
) {
continue
}
sources.add(source)
}
}
for (const source of sources) {
if (!await pathExists(source)) continue
const destination = path.join(distDir, path.relative(paths.docsDir, source))
await fs.mkdir(path.dirname(destination), { recursive: true })
await fs.copyFile(source, destination)
}
console.log(`Copied ${sources.size} referenced documentation images.`)
}
async function createRouteEntry(route, shell) {
const relativeRoute = route.replace(/^\/+|\/+$/g, '')
if (!relativeRoute) {
return
}
const routeDirectory = path.join(distDir, ...relativeRoute.split('/'))
await fs.mkdir(routeDirectory, { recursive: true })
await fs.writeFile(path.join(routeDirectory, 'index.html'), shell)
}
async function main() {
const { records } = await generateDocsManifest()
const shellPath = path.join(distDir, 'index.html')
const shell = await fs.readFile(shellPath, 'utf8')
await copyDirectory(path.join(paths.docsDir, 'public'), distDir)
await copyReferencedDocImages(records)
const customDomain = (await fs.readFile(path.join(distDir, 'CNAME'), 'utf8')).trim()
if (customDomain !== expectedCustomDomain) {
throw new Error(`Expected CNAME to contain ${expectedCustomDomain}, received ${customDomain || 'an empty value'}.`)
}
const routes = new Set(records.map((record) => record.path))
routes.add('/en')
routes.add('/docs')
routes.add('/en/docs')
for (const route of routes) {
await createRouteEntry(route, shell)
}
await fs.writeFile(path.join(distDir, '404.html'), shell)
console.log(`Prepared static output for ${routes.size} client-side routes.`)
}
await main()