#!/usr/bin/env node /** * 生成 public/llms.txt(llmstxt.org 标准)——AI 爬虫的站点摘要索引。 * * 数据源:zh 目录下全部 Markdown 的 H1 + :::tip 概述首句(与 meta * description 同源,复用 .vitepress/config/extract.mjs)。按侧边栏章节分组。 * * 用法:node scripts/generate-llms-txt.mjs * 已挂入 package.json 的 docs:build 前置步骤。 */ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { SITE_URL, SITE_DESCRIPTION, extractDescription, } from "../.vitepress/config/extract.mjs"; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const ZH = path.join(ROOT, "zh"); /** 顶层目录 → 侧边栏章节名(与 .vitepress/config/zh.ts 保持一致) */ const SECTIONS = [ ["", "1. 了解项目"], ["quickstart", "2. 快速入门"], ["guide", "3. 模块开发实战"], ["architecture", "4. 架构设计"], ["core", "5. 核心功能"], ["modules", "6. 业务模块"], ["tools", "7. 文件处理"], ["api-spec", "8. 接口规范与对接"], ["frontend", "9. 前端开发"], ["generator", "10. 代码生成器"], ["deploy", "11. 运维部署"], ["standards", "12. 规范标准"], ["appendix", "附录"], ["other", "附录"], ]; function walk(dir, out = []) { for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { const p = path.join(dir, entry.name); if (entry.isDirectory()) walk(p, out); else if (entry.name.endsWith(".md")) out.push(p); } return out; } /** 提取首个 H1 文本 */ function h1Of(md) { const m = md.match(/^# (?!#)(.+)$/m); return m ? m[1].trim() : ""; } /** H1 编号排序键:"5.10 x" → [5,10];无编号排最后 */ function sortKey(h1) { const m = h1.match(/^(\d+(?:\.\d+)*)/); if (m) return m[1].split(".").map(Number); const letter = h1.match(/^([A-Z])\./); if (letter) return [1000 + letter[1].charCodeAt(0)]; return [9999]; } function cmpKey(a, b) { const len = Math.max(a.length, b.length); for (let i = 0; i < len; i++) { const x = a[i] ?? 0; const y = b[i] ?? 0; if (x !== y) return x - y; } return 0; } const files = walk(ZH); const groups = new Map(SECTIONS.map(([dir, label]) => [label, []])); for (const file of files) { const rel = path.relative(ZH, file).replace(/\\/g, "/"); const topDir = rel.includes("/") ? rel.split("/")[0] : ""; const label = SECTIONS.find(([dir]) => dir === topDir)?.[1] ?? "附录"; const md = fs.readFileSync(file, "utf8"); const h1 = h1Of(md); if (!h1) continue; const desc = extractDescription(md); const url = `${SITE_URL}/zh/${rel.replace(/\.md$/, ".html")}`; groups.get(label).push({ h1, desc, url, key: sortKey(h1) }); } const lines = [ "# RXThinkCMF ThinkPHP6+NaiveUI 开发文档", "", `> ${SITE_DESCRIPTION}。支持五种数据库一键切换、RBAC 按钮级权限、多租户数据隔离、声明式 CRUD 基座与代码生成器。本文档共 ${files.length} 篇,按章节组织。`, "", ]; // 按章节顺序输出,合并重复 label(appendix/other 同为「附录」,只输出一次) const printed = new Set(); for (const [, label] of SECTIONS) { if (printed.has(label)) continue; const items = groups.get(label); if (!items?.length) continue; printed.add(label); lines.push(`## ${label}`, ""); items.sort((a, b) => cmpKey(a.key, b.key)); for (const { h1, desc, url } of items) { lines.push(desc ? `- [${h1}](${url}): ${desc}` : `- [${h1}](${url})`); } lines.push(""); } const out = path.join(ROOT, "public", "llms.txt"); fs.writeFileSync(out, lines.join("\n"), "utf8"); console.log( `llms.txt: ${files.length} 页 → ${path.relative(ROOT, out)} (${printed.size} 个章节)`, );