码桶
发现社区成员的开源项目
build-wiki-site.js16.8 KB
'use strict';
// build-wiki-site.js
// 把 data/wiki/ 下的中文(默认)+英文两种语言内容,构建成一套纯静态 HTML 站点,
// 用于发布到 GitHub Pages,方便公开搜索/AI 爬虫抓取。
//
// 不改动 data/wiki/ 本身,不影响面板内置 Wiki(那边继续保留 5 语言不变)。
// 这个脚本只在 GitHub Actions 里跑,跑完把 out/ 目录发布出去,源仓库不受影响。
//
// 用法: node build-wiki-site.js <repo-root> <output-dir>
const fs = require('fs');
const path = require('path');
const { marked } = require('marked');
const sanitizeHtml = require('sanitize-html');
const [, , repoRootArg, outDirArg] = process.argv;
const REPO_ROOT = path.resolve(repoRootArg || '.');
const WIKI_DIR = path.join(REPO_ROOT, 'data', 'wiki');
const OUT_DIR = path.resolve(outDirArg || 'out');
const LANGS = ['zh', 'en']; // 只做中英文,其余语言(ja/de/ru)不生成静态页面
const SITE_TITLE = 'NodeNanny Wiki';
const REPO_URL = 'https://github.com/yibentq/nodenanny';
// GitHub Pages 的"项目站点"(仓库名不是 <user>.github.io 这种特殊仓库)实际发布在
// https://<user>.github.io/<repo>/ 这个子路径下,不是域名根目录。之前漏加了这个前缀,
// 导致站内所有 "/wiki/..." 这种绝对路径链接都跳错(少了 /nodenanny 这一层)。
// 如果将来换了自定义域名(那时页面就真的部署在根目录),把这里改成空字符串 '' 即可。
const BASE_PATH = '/nodenanny';
// sitemap.xml 里用的完整域名(带协议)。同样地,如果将来换自定义域名,这里要跟着改。
const SITE_ORIGIN = 'https://yibentq.github.io';
// ---------- 以下几个函数直接照搬 core/wiki-manager.js 里的规则,保证行为一致 ----------
function stripOrderPrefix(dirName) {
return dirName.replace(/^\d+-/, '');
}
function parseFrontmatter(raw) {
const fmMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
if (!fmMatch) return { meta: {}, content: raw };
const [, fmBlock, content] = fmMatch;
const meta = {};
fmBlock.split(/\r?\n/).forEach((line) => {
const kv = line.match(/^([a-zA-Z0-9_]+):\s*(.*)$/);
if (!kv) return;
const key = kv[1];
let value = kv[2].trim();
if (key === 'tags') {
const inner = value.replace(/^\[/, '').replace(/\]$/, '');
meta.tags = inner.split(',').map((s) => s.trim().replace(/^["']|["']$/g, '')).filter(Boolean);
return;
}
value = value.replace(/^["']|["']$/g, '');
if (key === 'order') {
meta.order = Number(value) || 0;
return;
}
meta[key] = value;
});
return { meta, content: content || '' };
}
function listCategoryDirs() {
if (!fs.existsSync(WIKI_DIR)) return [];
return fs.readdirSync(WIKI_DIR, { withFileTypes: true })
.filter((d) => d.isDirectory())
.map((d) => d.name)
.sort();
}
const BASE_PAGE_FILE_RE = /^[a-zA-Z0-9_-]+\.md$/;
function listPageFiles(categoryDirAbs) {
if (!fs.existsSync(categoryDirAbs)) return [];
return fs.readdirSync(categoryDirAbs, { withFileTypes: true })
.filter((d) => d.isFile() && BASE_PAGE_FILE_RE.test(d.name))
.map((d) => d.name);
}
// 按语言读取一篇文章。lang='en'时优先找 slug.en.md,找不到就回退中文原文,
// 并标记 available=false(静态站上会显示"暂无英文翻译,以下为中文原文"提示)。
function readPageRaw(categoryDirAbs, slug, lang) {
if (lang === 'en') {
const localizedPath = path.join(categoryDirAbs, `${slug}.en.md`);
if (fs.existsSync(localizedPath)) {
return { raw: fs.readFileSync(localizedPath, 'utf-8'), servedLang: 'en' };
}
}
const defaultPath = path.join(categoryDirAbs, `${slug}.md`);
if (!fs.existsSync(defaultPath)) return null;
return { raw: fs.readFileSync(defaultPath, 'utf-8'), servedLang: 'zh' };
}
function readCategoryMeta(categoryDirAbs, fallbackId, lang) {
if (lang === 'en') {
const localizedMetaPath = path.join(categoryDirAbs, '_category.en.json');
if (fs.existsSync(localizedMetaPath)) {
try {
const parsed = JSON.parse(fs.readFileSync(localizedMetaPath, 'utf-8'));
return { title: parsed.title || fallbackId, description: parsed.description || '' };
} catch (err) {
console.error(`[build-wiki-site] ${localizedMetaPath} 解析失败: ${err.message}`);
}
}
}
const metaPath = path.join(categoryDirAbs, '_category.json');
if (fs.existsSync(metaPath)) {
try {
const parsed = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
return { title: parsed.title || fallbackId, description: parsed.description || '' };
} catch (err) {
console.error(`[build-wiki-site] ${metaPath} 解析失败: ${err.message}`);
}
}
return { title: fallbackId, description: '' };
}
// ---------- 内部链接重写:复刻 public/wiki.html 里 resolveInternalWikiLink() 的规则 ----------
// ./slug -> 同分类下的另一篇文章
// ../01-xxx/slug -> 跨分类链接,带数字前缀,需要去掉前缀
function resolveInternalWikiLink(href, currentCategoryId) {
if (!href) return null;
const p = href.split('#')[0].replace(/\.md$/i, '');
if (!p) return null;
if (p.startsWith('./')) {
const slug = p.slice(2);
return slug ? { categoryId: currentCategoryId, slug } : null;
}
if (p.startsWith('../')) {
const rest = p.slice(3);
const parts = rest.split('/').filter(Boolean);
if (parts.length >= 2) {
const categoryId = parts[0].replace(/^\d+-/, '');
return { categoryId, slug: parts.slice(1).join('/') };
}
return null;
}
return null;
}
function pageUrl(lang, categoryId, slug) {
return `${BASE_PATH}/wiki/${lang}/${categoryId}/${slug}.html`;
}
// 渲染前重写 markdown 里的内部链接为静态站真实相对路径,外部链接原样保留。
function rewriteMarkdownLinks(md, lang, currentCategoryId) {
return md.replace(/\]\(([^)]+)\)/g, (whole, href) => {
if (/^https?:\/\//i.test(href) || href.startsWith('#')) return whole;
const target = resolveInternalWikiLink(href, currentCategoryId);
if (!target) return whole;
return `](${pageUrl(lang, target.categoryId, target.slug)})`;
});
}
// ---------- HTML 输出 ----------
function escapeHtml(s) {
return String(s || '').replace(/[&<>"']/g, (c) => ({
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
}[c]));
}
const BASE_CSS = `
:root{color-scheme:light dark;--fg:#1a1a1a;--bg:#ffffff;--muted:#6b6b6b;--border:#e3e3e3;--accent:#2563eb;}
@media (prefers-color-scheme:dark){:root{--fg:#e8e8e8;--bg:#14161a;--muted:#9a9a9a;--border:#2a2d33;}}
*{box-sizing:border-box;}
body{margin:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif;color:var(--fg);background:var(--bg);line-height:1.7;}
.topbar{display:flex;align-items:center;justify-content:space-between;padding:14px 20px;border-bottom:1px solid var(--border);flex-wrap:wrap;gap:10px;}
.topbar a{color:var(--fg);text-decoration:none;font-weight:600;}
.langs a{margin-left:10px;color:var(--muted);text-decoration:none;font-size:14px;}
.langs a.active{color:var(--accent);font-weight:600;}
.layout{display:flex;max-width:1100px;margin:0 auto;padding:20px;gap:32px;}
@media (max-width:760px){.layout{flex-direction:column;padding:14px;}}
nav.sidebar{flex:0 0 240px;}
nav.sidebar h3{font-size:13px;text-transform:uppercase;letter-spacing:.04em;color:var(--muted);margin:18px 0 8px;}
nav.sidebar ul{list-style:none;margin:0;padding:0;}
nav.sidebar li{margin:4px 0;}
nav.sidebar a{color:var(--fg);text-decoration:none;font-size:14px;}
nav.sidebar a:hover{color:var(--accent);}
main.content{flex:1;min-width:0;}
main.content h1{font-size:26px;margin-top:0;}
main.content code{background:rgba(127,127,127,.15);padding:2px 5px;border-radius:4px;font-size:0.92em;}
main.content pre{background:rgba(127,127,127,.12);padding:14px;border-radius:8px;overflow-x:auto;}
main.content pre code{background:none;padding:0;}
main.content a{color:var(--accent);}
.meta{color:var(--muted);font-size:13px;margin-bottom:20px;}
.notice{background:rgba(37,99,235,.1);border:1px solid rgba(37,99,235,.3);padding:10px 14px;border-radius:8px;font-size:14px;margin-bottom:20px;}
.footer{max-width:1100px;margin:0 auto;padding:20px;color:var(--muted);font-size:13px;border-top:1px solid var(--border);}
.cat-card{border:1px solid var(--border);border-radius:10px;padding:16px;margin-bottom:14px;}
.cat-card h2{margin:0 0 6px;font-size:18px;}
.cat-card p{color:var(--muted);margin:0 0 10px;font-size:14px;}
`;
function langSwitchHtml(lang, altUrl, altAvailable) {
return `<span class="langs">
<a class="${lang === 'zh' ? 'active' : ''}" href="${lang === 'zh' ? '#' : (altAvailable ? altUrl : '#')}">中文</a>
<a class="${lang === 'en' ? 'active' : ''}" href="${lang === 'en' ? '#' : (altAvailable ? altUrl : '#')}">EN</a>
</span>`;
}
function renderSidebar(tree, lang, activeCategoryId, activeSlug) {
return `<nav class="sidebar">` + tree.map((cat) => `
<h3>${escapeHtml(cat.title)}</h3>
<ul>${cat.pages.map((p) => `<li><a href="${pageUrl(lang, cat.categoryId, p.slug)}"${(cat.categoryId === activeCategoryId && p.slug === activeSlug) ? ' style="color:var(--accent);font-weight:600;"' : ''}>${escapeHtml(p.title)}</a></li>`).join('')}</ul>
`).join('') + `</nav>`;
}
function pageShell({ lang, title, bodyHtml, sidebarHtml, altUrl, altAvailable }) {
return `<!doctype html>
<html lang="${lang === 'zh' ? 'zh-CN' : 'en'}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${escapeHtml(title)} · ${SITE_TITLE}</title>
<style>${BASE_CSS}</style>
</head>
<body>
<div class="topbar">
<a href="${BASE_PATH}/wiki/${lang}/index.html">${SITE_TITLE}</a>
<div>
${langSwitchHtml(lang, altUrl, altAvailable)}
<a href="${REPO_URL}" style="margin-left:16px;font-weight:400;color:var(--muted);">GitHub ↗</a>
</div>
</div>
<div class="layout">
${sidebarHtml}
<main class="content">${bodyHtml}</main>
</div>
<div class="footer">NodeNanny is MIT-licensed and free to self-host. <a href="${REPO_URL}">${REPO_URL}</a></div>
</body>
</html>`;
}
// ---------- sitemap.xml 生成 ----------
// 直接复用 build() 里已经算好的 trees(每种语言的分类/文章清单),不用重新扫描文件系统。
// 每个 URL 都是完整绝对地址(含域名),这是 sitemap.xml 规范要求的,不能用相对路径。
function generateSitemapXml(trees, allPageUrls) {
const now = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
const urls = [
`${SITE_ORIGIN}${BASE_PATH}/`,
`${SITE_ORIGIN}${BASE_PATH}/wiki/zh/index.html`,
`${SITE_ORIGIN}${BASE_PATH}/wiki/en/index.html`,
...allPageUrls,
];
const body = urls.map((u) => ` <url>\n <loc>${escapeHtml(u)}</loc>\n <lastmod>${now}</lastmod>\n </url>`).join('\n');
return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${body}\n</urlset>\n`;
}
function build() {
if (fs.existsSync(OUT_DIR)) fs.rmSync(OUT_DIR, { recursive: true, force: true });
fs.mkdirSync(OUT_DIR, { recursive: true });
const dirNames = listCategoryDirs();
// 先按语言分别构建分类树(含每篇文章的元信息),供导航栏和索引页使用
const trees = {};
for (const lang of LANGS) {
trees[lang] = dirNames.map((dirName) => {
const categoryId = stripOrderPrefix(dirName);
const categoryDirAbs = path.join(WIKI_DIR, dirName);
const { title, description } = readCategoryMeta(categoryDirAbs, categoryId, lang);
const pages = listPageFiles(categoryDirAbs).map((fileName) => {
const slug = fileName.replace(/\.md$/, '');
const picked = readPageRaw(categoryDirAbs, slug, lang);
if (!picked) return null;
const { meta } = parseFrontmatter(picked.raw);
return {
slug,
title: meta.title || slug,
summary: meta.summary || '',
order: meta.order || 0,
available: picked.servedLang === lang,
};
}).filter(Boolean).sort((a, b) => (a.order - b.order) || a.title.localeCompare(b.title));
return { categoryId, dirName, title, description, pages };
});
}
let pageCount = 0;
const allPageUrls = []; // sitemap 用:收集每个生成页面的完整绝对 URL
for (const lang of LANGS) {
const otherLang = lang === 'zh' ? 'en' : 'zh';
const tree = trees[lang];
for (const cat of tree) {
const categoryDirAbs = path.join(WIKI_DIR, cat.dirName);
for (const pageMeta of cat.pages) {
const picked = readPageRaw(categoryDirAbs, pageMeta.slug, lang);
if (!picked) continue;
const { meta, content } = parseFrontmatter(picked.raw);
const rewrittenMd = rewriteMarkdownLinks(content, lang, cat.categoryId);
const rawHtml = marked.parse(rewrittenMd);
const safeHtml = sanitizeHtml(rawHtml, {
allowedTags: sanitizeHtml.defaults.allowedTags.concat(['h1', 'h2', 'img']),
allowedAttributes: {
...sanitizeHtml.defaults.allowedAttributes,
img: ['src', 'alt'],
a: ['href', 'name', 'target', 'rel'],
},
});
const notice = picked.servedLang !== lang
? `<div class="notice">${lang === 'en' ? 'No English translation yet — showing the original Chinese content.' : '暂无该语言译文,以下为中文原文。'}</div>`
: '';
const title = meta.title || pageMeta.slug;
const body = `<h1>${escapeHtml(title)}</h1>${meta.updated ? `<div class="meta">${lang === 'en' ? 'Updated' : '更新于'} ${escapeHtml(meta.updated)}</div>` : ''}${notice}${safeHtml}`;
const altUrl = pageUrl(otherLang, cat.categoryId, pageMeta.slug);
const altAvailable = !!(trees[otherLang].find((c) => c.categoryId === cat.categoryId)?.pages.find((p) => p.slug === pageMeta.slug));
const html = pageShell({
lang,
title,
bodyHtml: body,
sidebarHtml: renderSidebar(tree, lang, cat.categoryId, pageMeta.slug),
altUrl,
altAvailable,
});
const outPath = path.join(OUT_DIR, 'wiki', lang, cat.categoryId, `${pageMeta.slug}.html`);
fs.mkdirSync(path.dirname(outPath), { recursive: true });
fs.writeFileSync(outPath, html);
pageCount++;
allPageUrls.push(`${SITE_ORIGIN}${pageUrl(lang, cat.categoryId, pageMeta.slug)}`);
}
}
// 每种语言的索引页:列出全部分类卡片
const indexBody = tree.map((cat) => `
<div class="cat-card">
<h2>${escapeHtml(cat.title)}</h2>
<p>${escapeHtml(cat.description)}</p>
<ul>${cat.pages.map((p) => `<li><a href="${pageUrl(lang, cat.categoryId, p.slug)}">${escapeHtml(p.title)}</a></li>`).join('')}</ul>
</div>`).join('');
const indexHtml = pageShell({
lang,
title: lang === 'en' ? 'NodeNanny Wiki' : 'NodeNanny 百科',
bodyHtml: `<h1>${lang === 'en' ? 'NodeNanny Wiki' : 'NodeNanny 百科'}</h1>${indexBody}`,
sidebarHtml: renderSidebar(tree, lang, null, null),
altUrl: `${BASE_PATH}/wiki/${otherLang}/index.html`,
altAvailable: true,
});
const indexPath = path.join(OUT_DIR, 'wiki', lang, 'index.html');
fs.mkdirSync(path.dirname(indexPath), { recursive: true });
fs.writeFileSync(indexPath, indexHtml);
}
// 站点根目录 index.html:简单落地页,跳到中文索引(默认语言),同时给英文入口
const rootIndex = `<!doctype html>
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="google-site-verification" content="zD-4xN6LElLj4kc9UQffMdhSJJsJcMecWgh7bL6JQA4" />
<title>${SITE_TITLE}</title><style>${BASE_CSS}</style></head>
<body>
<div class="topbar"><a href="${BASE_PATH}/wiki/zh/index.html">${SITE_TITLE}</a></div>
<div class="layout" style="flex-direction:column;">
<main class="content">
<h1>NodeNanny Wiki</h1>
<p>NodeNanny is a post-deployment guardian for a single self-hosted proxy node — Xray / sing-box / v2ray / Shadowsocks / Trojan. This site mirrors the built-in wiki content (Chinese & English) for public search.</p>
<ul>
<li><a href="${BASE_PATH}/wiki/zh/index.html">中文百科 →</a></li>
<li><a href="${BASE_PATH}/wiki/en/index.html">English Wiki →</a></li>
<li><a href="${REPO_URL}">GitHub Repository →</a></li>
</ul>
</main>
</div>
</body></html>`;
fs.writeFileSync(path.join(OUT_DIR, 'index.html'), rootIndex);
// .nojekyll:防止 GitHub Pages 用 Jekyll 处理
fs.writeFileSync(path.join(OUT_DIR, '.nojekyll'), '');
// sitemap.xml:列出根页面 + 两个语言索引页 + 全部文章页,供 Google Search Console 提交
const sitemapXml = generateSitemapXml(trees, allPageUrls);
fs.writeFileSync(path.join(OUT_DIR, 'sitemap.xml'), sitemapXml);
console.log(`[build-wiki-site] 完成,共生成 ${pageCount} 篇文章页 + ${LANGS.length} 个语言索引页 + 1 份 sitemap.xml。输出目录:${OUT_DIR}`);
}
build();