cc-haha/site/scripts/prepare-static-output.mjs
程序员阿江(Relakkes) 5e156ec03e chore(site): 文档站自定义域名切换到 cchaha.ai
把 9 个文件里的 11 处 claudecode-haha.relakkesyang.org 换成 cchaha.ai。
apex 走 Cloudflare CNAME flattening,www 由 GitHub 自动 301 到裸域名。

- docs/public/CNAME 与 prepare-static-output.mjs 的 expectedCustomDomain
  必须同步:后者是硬校验,不一致会让 postbuild 直接 throw、Pages 发不出去。
  它同时驱动 canonical / hreflang / og:url / sitemap.xml / robots.txt。
- meta.js 的 SITE_ORIGIN 管前端运行时那一份同名标签。
- AGENTS.md 里的契约描述一并更新,否则后续改动会照旧域名回退。
- 桌面端 IM 文档跳转链接及其测试断言。

验证:site build 通过(78 路由 + 74 重定向)、check:docs 通过(78 页 323 链接)、
sitemap 80 条 loc 全为新域名、AdapterSettings 6 tests passed。
2026-07-28 13:15:56 +08:00

258 lines
9.4 KiB
JavaScript

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 = 'cchaha.ai'
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
const siteImagePattern = /["'(]\s*(\/[A-Za-z0-9_./-]+\.(?:avif|gif|jpe?g|png|svg|webp))/gi
function imageTargets(markdown) {
const prose = markdown.replace(/```[\s\S]*?```/g, '')
return [
...prose.matchAll(markdownImagePattern),
...prose.matchAll(htmlImagePattern),
].map((match) => match[1])
}
function isOutsideDocsPublic(relative) {
return relative.startsWith('..')
|| path.isAbsolute(relative)
|| relative === 'public'
|| relative.startsWith(`public${path.sep}`)
}
async function copySources(sources) {
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)
}
}
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 (isOutsideDocsPublic(relative)) continue
sources.add(source)
}
}
await copySources(sources)
console.log(`Copied ${sources.size} referenced documentation images.`)
}
async function siteSourceFiles() {
const files = [path.join(paths.siteDir, 'index.html')]
const srcDir = path.join(paths.siteDir, 'src')
const entries = await fs.readdir(srcDir, { recursive: true, withFileTypes: true })
for (const entry of entries) {
if (!entry.isFile() || !/\.(?:jsx?|css)$/.test(entry.name)) continue
const directory = entry.parentPath || entry.path
// src/generated/ 里有一张「路径 → 图片尺寸」表,覆盖 docs/images 下的每张图。
// 那是给渲染器查尺寸用的,不代表页面引用了它们 —— 扫进来会把没人用的图
// (赞助、打赏之类)全拷进产物。
if (path.relative(srcDir, directory).split(path.sep)[0] === 'generated') continue
files.push(path.join(directory, entry.name))
}
return files
}
async function copySiteReferencedImages() {
const sources = new Set()
const missing = []
for (const file of await siteSourceFiles()) {
const content = await fs.readFile(file, 'utf8')
for (const match of content.matchAll(siteImagePattern)) {
const pathname = match[1]
const source = path.join(paths.docsDir, pathname.replace(/^\/+/, ''))
const relative = path.relative(paths.docsDir, source)
if (isOutsideDocsPublic(relative)) continue
if (await pathExists(source)) sources.add(source)
else missing.push(`${path.relative(paths.siteDir, file)}: ${pathname}`)
}
}
if (missing.length > 0) {
throw new Error(`Site-referenced images missing under docs/:\n- ${missing.join('\n- ')}`)
}
await copySources(sources)
console.log(`Copied ${sources.size} site-referenced images.`)
}
function escapeHtml(value) {
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
/**
* 每条路由的 HTML 骨架都写进自己的 title / description / canonical / hreflang。
* 不执行 JS 的爬虫看到的就不再是 78 个一模一样的页面。
*/
function shellForRoute(shell, meta) {
if (!meta) return shell
const origin = `https://${expectedCustomDomain}`
const isEnglish = meta.path === '/en' || meta.path.startsWith('/en/')
const canonical = `${origin}${meta.path}`
const alternate = meta.alternate ? `${origin}${meta.alternate}` : null
const head = [
`<link rel="canonical" href="${escapeHtml(canonical)}">`,
`<link rel="alternate" hreflang="${isEnglish ? 'en' : 'zh-Hans'}" href="${escapeHtml(canonical)}">`,
alternate
? `<link rel="alternate" hreflang="${isEnglish ? 'zh-Hans' : 'en'}" href="${escapeHtml(alternate)}">`
: '',
`<meta property="og:url" content="${escapeHtml(canonical)}">`
].filter(Boolean).join('\n ')
return shell
.replace(/<html lang="[^"]*"/, `<html lang="${isEnglish ? 'en' : 'zh-CN'}"`)
.replace(/<title>[\s\S]*?<\/title>/, `<title>${escapeHtml(meta.title)}</title>`)
.replace(
/<meta name="description" content="[^"]*">/,
`<meta name="description" content="${escapeHtml(meta.description)}">`
)
.replace(
/<meta property="og:title" content="[^"]*">/,
`<meta property="og:title" content="${escapeHtml(meta.title)}">`
)
.replace(
/<meta property="og:description" content="[^"]*">/,
`<meta property="og:description" content="${escapeHtml(meta.description)}">`
)
.replace('</head>', ` ${head}\n </head>`)
}
async function createRouteEntry(route, shell, meta) {
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'), shellForRoute(shell, meta))
}
function alternateFor(record, records) {
const source = record.sourcePath.replace(/^docs\//, '')
const target = record.locale === 'en' ? source.replace(/^en\//, '') : `en/${source}`
return records.find((candidate) => candidate.sourcePath === `docs/${target}`)?.path || null
}
async function writeSitemap(records) {
const origin = `https://${expectedCustomDomain}`
const urls = ['/', '/en', ...records.map((record) => record.path)]
const body = urls
.map((url) => ` <url><loc>${origin}${url}</loc><changefreq>weekly</changefreq></url>`)
.join('\n')
await fs.writeFile(
path.join(distDir, 'sitemap.xml'),
`<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${body}\n</urlset>\n`
)
await fs.writeFile(
path.join(distDir, 'robots.txt'),
`User-agent: *\nAllow: /\n\nSitemap: ${origin}/sitemap.xml\n`
)
}
/** 2026-07 信息架构重组前的老路径,发一份骨架出去让前端接管重定向,别在 CDN 层就 404。 */
const legacyRoutes = [
'/agent', '/agent/01-usage-guide', '/agent/02-implementation', '/agent/03-agent-framework',
'/channel', '/channel/01-channel-system', '/channel/02-im-gateway-proposal',
'/desktop/01-quick-start', '/desktop/02-architecture', '/desktop/03-features',
'/desktop/04-installation', '/desktop/05-FAQ', '/desktop/06-h5-access',
'/desktop/07-electron-migration-research', '/desktop/08-electron-migration-tasks',
'/desktop/09-electron-migration-validation-checklist', '/desktop/10-release-auto-update',
'/docs', '/features/computer-use', '/features/computer-use-architecture',
'/guide/cli-reference', '/guide/contributing', '/guide/env-vars', '/guide/faq',
'/guide/global-usage', '/guide/quick-start', '/guide/third-party-models',
'/memory', '/memory/01-usage-guide', '/memory/02-implementation', '/memory/03-autodream',
'/reference/fixes', '/reference/local-server', '/reference/project-structure',
'/skills', '/skills/01-usage-guide', '/skills/02-implementation'
]
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)
await copySiteReferencedImages()
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'}.`)
}
for (const record of records) {
await createRouteEntry(record.path, shell, {
alternate: alternateFor(record, records),
description: record.description,
path: record.path,
title: `${record.title} · Claude Code Haha`
})
}
await createRouteEntry('/en', shell, {
alternate: '/',
description: 'A local-first desktop client for Claude Code. Sessions, diffs, agents and scheduled runs all sit in the open.',
path: '/en',
title: 'Claude Code Haha — a local-first desktop client for Claude Code'
})
for (const legacy of legacyRoutes) {
await createRouteEntry(legacy, shell, null)
await createRouteEntry(`/en${legacy}`, shell, null)
}
await writeSitemap(records)
await fs.writeFile(path.join(distDir, '404.html'), shell)
console.log(`Prepared static output for ${records.length} documentation routes and ${legacyRoutes.length * 2} redirects.`)
}
await main()