mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-08-01 16:43:37 +08:00
The site had drifted from the product. Every screenshot predated the v0.5.0 UI redesign, the reading experience shipped no search and no syntax highlighting, and a third of the pages were internal process artefacts — migration task lists addressed to agentic workers, a release runbook, a proposal marked "historical". Reorganise around the only two people who read this: someone getting the desktop app running for the first time, and someone reading the source. Five sections replace nine — start / desktop / im / cli / internals — and the pages that served neither reader are gone. Site rewrite: - Palette lifted from the desktop app's 「纸·墨·印」 themes, so the site and the product read as one thing. Light mirrors 纯白, dark mirrors 墨夜, and dark mode exists at all now. - Fonts are self-hosted. The old @import from Google Fonts is unreachable from mainland China, which left every heading in a fallback serif; it also only requested weight 600 while the CSS asked for 900, so Latin and CJK in the same heading disagreed. - Docs were shipped as one 968KB manifest downloaded on every page view. Split into a 32KB index plus one lazily imported chunk per page; the entry bundle is now 101KB gzipped. - Add search, syntax highlighting, per-route meta with canonical and hreflang, a sitemap, and an error boundary. Replace the 44vh mobile sidebar with a drawer. - Image dimensions are read at build time and written into the tag, so lazy images reserve their space instead of collapsing. Screenshots are recaptured from a real v0.5.0 build against a clean demo project, with tokens, QR codes and paired accounts redacted. The previous set is deleted rather than kept alongside. Routes follow file paths, so the restructure would have broken every inbound link; 37 old paths redirect, in both languages. The PR policy gate and CODEOWNERS also hardcoded docs/guide/contributing.md. Verified: check:docs 78 pages / 323 links / 0 problems, check:policy 127 pass. Walked every route at 1440 and 390 in both themes for overflow, contrast, keyboard reachability and focus management.
258 lines
9.5 KiB
JavaScript
258 lines
9.5 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 = '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
|
|
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, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
}
|
|
|
|
/**
|
|
* 每条路由的 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()
|