diff --git a/course/.vitepress/config.mts b/course/.vitepress/config.mts index 1796fd14..dddf4795 100644 --- a/course/.vitepress/config.mts +++ b/course/.vitepress/config.mts @@ -5,10 +5,13 @@ import { fileURLToPath } from "node:url"; import themeConfig from "./themeConfig.js"; import { llmMarkdownPlugin } from "./llmMarkdown.js"; +const title = "Zig 语言圣经"; +const description = "简单、快速地学习 Zig,ziglang中文教程,zig中文教程"; + export default defineConfig({ lang: "zh-CN", - title: "Zig 语言圣经", - description: "简单、快速地学习 Zig,ziglang中文教程,zig中文教程", + title, + description, sitemap: { hostname: "https://course.ziglang.cc/", }, @@ -16,6 +19,14 @@ export default defineConfig({ lastUpdated: true, themeConfig: themeConfig, cleanUrls: true, + vue: { + template: { + compilerOptions: { + // 是自定义元素:网页端由 CSS 隐藏,正文只进入 /llms 输出 + isCustomElement: (tag) => tag === "llm-only", + }, + }, + }, vite: { plugins: [ llmMarkdownPlugin({ @@ -27,6 +38,8 @@ export default defineConfig({ path.dirname(fileURLToPath(import.meta.url)), "dist", ), + title, + description, }), ], }, diff --git a/course/.vitepress/llmMarkdown.ts b/course/.vitepress/llmMarkdown.ts index 6ef70a39..60017338 100644 --- a/course/.vitepress/llmMarkdown.ts +++ b/course/.vitepress/llmMarkdown.ts @@ -8,6 +8,8 @@ const SNIPPET_RE = /^<<<\s*(.+)$/gm; interface Options { srcDir: string; outDir?: string; + title?: string; + description?: string; } export function llmMarkdownPlugin(options: Options): Plugin { @@ -45,26 +47,75 @@ export function llmMarkdownPlugin(options: Options): Plugin { }, async closeBundle() { if (!outDir) return; - await generateLlmMarkdown(options.srcDir, path.join(outDir, "llms")); + await generateLlmMarkdown(options.srcDir, outDir, { + title: options.title ?? "", + description: options.description ?? "", + }); }, }; } export async function generateLlmMarkdown( srcDir: string, - outDir: string, + distDir: string, + meta: { title: string; description: string } = { title: "", description: "" }, ): Promise { - const files = await collectMarkdownFiles(srcDir); - await fs.rm(outDir, { recursive: true, force: true }); + // ponytail: 按路径排序,编号目录即正确顺序;要严格 sidebar 顺序再读 themeConfig + const files = (await collectMarkdownFiles(srcDir)).sort(); + const llmsDir = path.join(distDir, "llms"); + await fs.rm(llmsDir, { recursive: true, force: true }); - await Promise.all( + const pages = await Promise.all( files.map(async (file) => { const relativePath = normalizePath(path.relative(srcDir, file)); - const output = path.join(outDir, relativePath); + const markdown = await renderLlmMarkdown(file, srcDir); + const output = path.join(llmsDir, relativePath); await fs.mkdir(path.dirname(output), { recursive: true }); - await fs.writeFile(output, await renderLlmMarkdown(file, srcDir), "utf8"); + await fs.writeFile(output, markdown, "utf8"); + return { relativePath, markdown }; }), ); + + await writeLlmsIndex(distDir, pages, meta); +} + +async function writeLlmsIndex( + distDir: string, + pages: { relativePath: string; markdown: string }[], + meta: { title: string; description: string }, +): Promise { + const header = `# ${meta.title}\n${meta.description ? `\n> ${meta.description}\n` : ""}`; + + // llms.txt:llmstxt.org 标准索引,逐页链接指向 .md 原文 + const links = pages + .map((page) => { + const title = firstHeading(page.markdown) ?? page.relativePath; + return `- [${title}](${ORIGIN}/llms/${page.relativePath})`; + }) + .join("\n"); + await fs.writeFile( + path.join(distDir, "llms.txt"), + `${header}\n## 文档\n\n${links}\n`, + "utf8", + ); + + // llms-full.txt:全站正文拼接成单文件 + const body = pages.map((page) => page.markdown.trim()).join("\n\n---\n\n"); + await fs.writeFile( + path.join(distDir, "llms-full.txt"), + `${header}\n${body}\n`, + "utf8", + ); +} + +function firstHeading(markdown: string): string | null { + // 去掉 VitePress 自定义锚点 {#id},标题更干净 + return ( + markdown + .match(/^#\s+(.+)$/m)?.[1] + .replace(/\s*\{#[\w-]+\}\s*$/, "") + .trim() ?? null + ); } export async function renderLlmMarkdown( @@ -76,6 +127,7 @@ export async function renderLlmMarkdown( let markdown = await fs.readFile(file, "utf8"); markdown = stripFrontmatter(markdown); + markdown = unwrapLlmOnly(markdown); markdown = await expandSnippets(markdown, srcDir); markdown = flattenVitePressContainers(markdown); markdown = absolutizeLinks(markdown, relativePath, origin); @@ -239,6 +291,11 @@ function stripFrontmatter(markdown: string): string { return markdown.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, ""); } +// 去掉 标记,保留其中内容(网页端由 CSS 隐藏,这里让正文进入 LLM 输出) +function unwrapLlmOnly(markdown: string): string { + return markdown.replace(/<\/?llm-only(?:\s+[^>]*)?>/gi, ""); +} + function languageFromFile(filePath: string): string { const basename = path.basename(filePath); if (basename === "build.zig.zon") return "zig"; diff --git a/course/.vitepress/theme/copyToLLM.ts b/course/.vitepress/theme/copyToLLM.ts index a3822a8b..51cf528d 100644 --- a/course/.vitepress/theme/copyToLLM.ts +++ b/course/.vitepress/theme/copyToLLM.ts @@ -1,59 +1,12 @@ -import { computed, defineComponent, h, ref } from "vue"; +import { computed, defineComponent, h, onMounted, onUnmounted, ref } from "vue"; import { useData, withBase } from "vitepress"; -const actionRowStyle = { - display: "flex", - alignItems: "center", - gap: "18px", - margin: "12px 0 24px", -} as const; - -const triggerStyle = { - display: "inline-flex", - alignItems: "center", - gap: "8px", - padding: "0", - border: "0", - background: "transparent", - color: "var(--vp-c-text-2)", - fontSize: "14px", - fontWeight: "500", - lineHeight: "24px", - cursor: "pointer", -} as const; - -const menuStyle = { - position: "absolute", - left: "0", - top: "calc(100% + 8px)", - zIndex: "10", - minWidth: "210px", - padding: "6px", - border: "1px solid var(--vp-c-divider)", - borderRadius: "8px", - background: "var(--vp-c-bg-elv)", - boxShadow: "var(--vp-shadow-3)", -} as const; - -const itemStyle = { - display: "block", - width: "100%", - padding: "8px 10px", - border: "0", - borderRadius: "6px", - background: "transparent", - color: "var(--vp-c-text-1)", - textAlign: "left", - cursor: "pointer", - fontSize: "13px", - lineHeight: "18px", -} as const; - export default defineComponent({ setup() { const { frontmatter, page } = useData(); const open = ref(false); const copied = ref<"link" | "markdown" | "error" | null>(null); + const rootRef = ref(null); const llmPath = computed(() => withBase(`/llms/${page.value.filePath}`)); const llmUrl = computed(() => typeof window === "undefined" @@ -61,6 +14,26 @@ export default defineComponent({ : new URL(llmPath.value, window.location.origin).href, ); + function onDocumentClick(event: MouseEvent): void { + if (open.value && !rootRef.value?.contains(event.target as Node)) { + open.value = false; + } + } + + function onKeydown(event: KeyboardEvent): void { + if (open.value && event.key === "Escape") open.value = false; + } + + onMounted(() => { + document.addEventListener("click", onDocumentClick); + document.addEventListener("keydown", onKeydown); + }); + + onUnmounted(() => { + document.removeEventListener("click", onDocumentClick); + document.removeEventListener("keydown", onKeydown); + }); + async function copyText(text: string): Promise { if (navigator.clipboard) { await navigator.clipboard.writeText(text); @@ -121,13 +94,15 @@ export default defineComponent({ return null; } - return h("div", { class: "copy_llm", style: actionRowStyle }, [ - h("div", { style: { position: "relative", display: "inline-block" } }, [ + return h("div", { class: "copy_llm", ref: rootRef }, [ + h("div", { class: "copy_llm-wrapper" }, [ h( "button", { type: "button", - style: triggerStyle, + class: "copy_llm-trigger", + "aria-haspopup": "menu", + "aria-expanded": open.value ? "true" : "false", onClick: () => { open.value = !open.value; }, @@ -183,12 +158,13 @@ export default defineComponent({ ], ), open.value - ? h("div", { style: menuStyle }, [ + ? h("div", { class: "copy_llm-menu", role: "menu" }, [ h( "button", { type: "button", - style: itemStyle, + class: "copy_llm-item", + role: "menuitem", onClick: () => copy("link"), }, "复制 Markdown 链接", @@ -197,7 +173,8 @@ export default defineComponent({ "button", { type: "button", - style: itemStyle, + class: "copy_llm-item", + role: "menuitem", onClick: () => copy("markdown"), }, "复制 Markdown 正文", diff --git a/course/.vitepress/theme/index.ts b/course/.vitepress/theme/index.ts index f087f0ec..5ec8ad42 100644 --- a/course/.vitepress/theme/index.ts +++ b/course/.vitepress/theme/index.ts @@ -6,6 +6,7 @@ import giscus from "./giscus.js"; import version from "./version.js"; import copyToLLM from "./copyToLLM.js"; import "./style/print.css"; +import "./style/copyToLLM.css"; import { h } from "vue"; diff --git a/course/.vitepress/theme/style/copyToLLM.css b/course/.vitepress/theme/style/copyToLLM.css new file mode 100644 index 00000000..5934fce6 --- /dev/null +++ b/course/.vitepress/theme/style/copyToLLM.css @@ -0,0 +1,75 @@ +/* 只给 LLM 看: 的内容网页隐藏,仅进入 /llms、llms.txt、llms-full.txt */ +llm-only { + display: none; +} + +.copy_llm { + display: flex; + align-items: center; + gap: 18px; + margin: 12px 0 24px; +} + +.copy_llm-wrapper { + position: relative; + display: inline-block; +} + +.copy_llm-trigger { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 0; + border: 0; + background: transparent; + color: var(--vp-c-text-2); + font-size: 14px; + font-weight: 500; + line-height: 24px; + cursor: pointer; + transition: color 0.25s; +} + +.copy_llm-trigger:hover { + color: var(--vp-c-text-1); +} + +.copy_llm-trigger:focus-visible { + outline: 2px solid var(--vp-c-brand-1); + outline-offset: 2px; + border-radius: 4px; +} + +.copy_llm-menu { + position: absolute; + left: 0; + top: calc(100% + 8px); + z-index: 10; + min-width: 210px; + padding: 6px; + border: 1px solid var(--vp-c-divider); + border-radius: 8px; + background: var(--vp-c-bg-elv); + box-shadow: var(--vp-shadow-3); +} + +.copy_llm-item { + display: block; + width: 100%; + padding: 8px 10px; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--vp-c-text-1); + text-align: left; + cursor: pointer; + font-size: 13px; + line-height: 18px; + transition: background-color 0.25s; +} + +.copy_llm-item:hover, +.copy_llm-item:focus-visible { + background: var(--vp-c-default-soft); + outline: none; +}