mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-08-01 16:43:37 +08:00
305 lines
8.9 KiB
JavaScript
305 lines
8.9 KiB
JavaScript
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.`)
|
|
}
|