Skip to content

Commit 509b4df

Browse files
committed
feat(vitepress): 支持生成并复制适配 LLM 的 Markdown
- 新增 llmMarkdown 插件,在开发与构建阶段提供 /llms 路由与 静态产物,降低将课程内容投喂给模型的成本。 - 转换时展开代码片段、扁平化 VitePress 容器并绝对化链接, 避免模型因站点语法和相对路径丢失上下文。 - 主题增加“复制给 LLM”入口,可一键复制链接或全文,提升 提问、分享与复用效率。 - 调整 Zig 示例与忽略规则,保证片
1 parent f5f39c7 commit 509b4df

8 files changed

Lines changed: 562 additions & 2 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ node_modules
22
.vscode
33
course/.vitepress/cache/
44
course/.vitepress/dist/
5+
dist/
56
course/.vitepress/.temp/
67
package-lock.json
78
*/zig-cache

course/.vitepress/config.mts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import { defineConfig } from "vitepress";
2+
import path from "node:path";
3+
import { fileURLToPath } from "node:url";
24

35
import themeConfig from "./themeConfig.js";
6+
import { llmMarkdownPlugin } from "./llmMarkdown.js";
47

58
export default defineConfig({
69
lang: "zh-CN",
@@ -13,6 +16,20 @@ export default defineConfig({
1316
lastUpdated: true,
1417
themeConfig: themeConfig,
1518
cleanUrls: true,
19+
vite: {
20+
plugins: [
21+
llmMarkdownPlugin({
22+
srcDir: path.resolve(
23+
path.dirname(fileURLToPath(import.meta.url)),
24+
"..",
25+
),
26+
outDir: path.resolve(
27+
path.dirname(fileURLToPath(import.meta.url)),
28+
"dist",
29+
),
30+
}),
31+
],
32+
},
1633
head: [
1734
["link", { rel: "icon", href: "./favicon.ico" }],
1835
[

course/.vitepress/llmMarkdown.ts

Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
1+
import fs from "node:fs/promises";
2+
import path from "node:path";
3+
import type { Plugin, ViteDevServer } from "vite";
4+
5+
const ORIGIN = "https://course.ziglang.cc";
6+
const SNIPPET_RE = /^<<<\s*(.+)$/gm;
7+
8+
interface Options {
9+
srcDir: string;
10+
outDir?: string;
11+
}
12+
13+
export function llmMarkdownPlugin(options: Options): Plugin {
14+
let outDir = options.outDir;
15+
16+
return {
17+
name: "zig-course-llm-markdown",
18+
configResolved(config) {
19+
outDir ??= config.build.outDir;
20+
},
21+
configureServer(server: ViteDevServer) {
22+
server.middlewares.use(async (req, res, next) => {
23+
if (!req.url?.startsWith("/llms/")) return next();
24+
25+
try {
26+
const pathname = decodeURIComponent(
27+
new URL(req.url, "http://local").pathname,
28+
);
29+
const relativePath = pathname.slice("/llms/".length);
30+
const source = resolveInside(options.srcDir, relativePath);
31+
const origin = `${req.headers["x-forwarded-proto"] ?? "http"}://${req.headers.host}`;
32+
const markdown = await renderLlmMarkdown(
33+
source,
34+
options.srcDir,
35+
origin,
36+
);
37+
res.statusCode = 200;
38+
res.setHeader("Content-Type", "text/markdown; charset=utf-8");
39+
res.end(markdown);
40+
} catch (error) {
41+
res.statusCode = 404;
42+
res.end(error instanceof Error ? error.message : "Not found");
43+
}
44+
});
45+
},
46+
async closeBundle() {
47+
if (!outDir) return;
48+
await generateLlmMarkdown(options.srcDir, path.join(outDir, "llms"));
49+
},
50+
};
51+
}
52+
53+
export async function generateLlmMarkdown(
54+
srcDir: string,
55+
outDir: string,
56+
): Promise<void> {
57+
const files = await collectMarkdownFiles(srcDir);
58+
await fs.rm(outDir, { recursive: true, force: true });
59+
60+
await Promise.all(
61+
files.map(async (file) => {
62+
const relativePath = normalizePath(path.relative(srcDir, file));
63+
const output = path.join(outDir, relativePath);
64+
await fs.mkdir(path.dirname(output), { recursive: true });
65+
await fs.writeFile(output, await renderLlmMarkdown(file, srcDir), "utf8");
66+
}),
67+
);
68+
}
69+
70+
export async function renderLlmMarkdown(
71+
file: string,
72+
srcDir: string,
73+
origin = ORIGIN,
74+
): Promise<string> {
75+
const relativePath = normalizePath(path.relative(srcDir, file));
76+
let markdown = await fs.readFile(file, "utf8");
77+
78+
markdown = stripFrontmatter(markdown);
79+
markdown = await expandSnippets(markdown, srcDir);
80+
markdown = flattenVitePressContainers(markdown);
81+
markdown = absolutizeLinks(markdown, relativePath, origin);
82+
83+
return markdown.trim() + "\n";
84+
}
85+
86+
async function collectMarkdownFiles(dir: string): Promise<string[]> {
87+
const entries = await fs.readdir(dir, { withFileTypes: true });
88+
const files: string[] = [];
89+
90+
for (const entry of entries) {
91+
if (entry.name === ".vitepress" || entry.name === "public") continue;
92+
93+
const fullPath = path.join(dir, entry.name);
94+
if (entry.isDirectory()) {
95+
files.push(...(await collectMarkdownFiles(fullPath)));
96+
} else if (entry.isFile() && entry.name.endsWith(".md")) {
97+
files.push(fullPath);
98+
}
99+
}
100+
101+
return files;
102+
}
103+
104+
async function expandSnippets(
105+
markdown: string,
106+
srcDir: string,
107+
): Promise<string> {
108+
const replacements = await Promise.all(
109+
Array.from(markdown.matchAll(SNIPPET_RE), async (match) => {
110+
const snippet = await readSnippet(match[1].trim(), srcDir);
111+
return { from: match[0], to: snippet };
112+
}),
113+
);
114+
115+
for (const { from, to } of replacements) {
116+
markdown = markdown.replace(from, to);
117+
}
118+
119+
return markdown;
120+
}
121+
122+
async function readSnippet(rawSpec: string, srcDir: string): Promise<string> {
123+
const [spec, ...labelParts] = rawSpec.split(/\s+/);
124+
const label = labelParts.join(" ").replace(/^\[(.*)\]$/, "$1");
125+
const languageOverride = spec.match(/\{([\w-]+)\}$/)?.[1];
126+
const specWithoutLanguage = spec.replace(/\{[\w-]+\}$/, "");
127+
const [rawFilePath, region] = specWithoutLanguage.split("#", 2);
128+
const filePath = rawFilePath.startsWith("@/")
129+
? rawFilePath.slice(2)
130+
: rawFilePath;
131+
const sourcePath = resolveInside(srcDir, filePath);
132+
const source = await fs.readFile(sourcePath, "utf8");
133+
const code = region ? extractRegion(source, region) : source.trimEnd();
134+
if (code === null) {
135+
return `<!-- Missing snippet: ${rawSpec} -->`;
136+
}
137+
const language = languageOverride ?? languageFromFile(sourcePath);
138+
const title = label ? `**${label}**\n\n` : "";
139+
140+
return `${title}\`\`\`${language}\n${code.trimEnd()}\n\`\`\``;
141+
}
142+
143+
function extractRegion(source: string, region: string): string | null {
144+
const lines = source.split(/\r?\n/);
145+
const start = lines.findIndex((line) => line.includes(`#region ${region}`));
146+
if (start < 0) return null;
147+
148+
const end = lines.findIndex(
149+
(line, index) => index > start && line.includes(`#endregion ${region}`),
150+
);
151+
if (end < 0) return null;
152+
153+
return dedent(lines.slice(start + 1, end)).join("\n");
154+
}
155+
156+
function dedent(lines: string[]): string[] {
157+
const indents = lines
158+
.filter((line) => line.trim().length > 0)
159+
.map((line) => line.match(/^\s*/)?.[0].length ?? 0);
160+
const minIndent = indents.length === 0 ? 0 : Math.min(...indents);
161+
162+
return minIndent > 0 ? lines.map((line) => line.slice(minIndent)) : lines;
163+
}
164+
165+
function flattenVitePressContainers(markdown: string): string {
166+
return markdown
167+
.split(/\r?\n/)
168+
.map((line) => {
169+
const open = line.match(/^:{3,}\s*([\w-]+)?\s*(.*)$/);
170+
if (open) {
171+
const type = open[1]?.toLowerCase();
172+
const title = open[2]?.trim();
173+
if (!type) return "";
174+
if (type === "code-group") return "";
175+
return `> [!${containerType(type)}]${title ? ` ${title}` : ""}`;
176+
}
177+
178+
return /^:{3,}\s*$/.test(line) ? "" : line;
179+
})
180+
.join("\n");
181+
}
182+
183+
function containerType(type: string): string {
184+
switch (type) {
185+
case "info":
186+
return "NOTE";
187+
case "tip":
188+
return "TIP";
189+
case "warning":
190+
return "WARNING";
191+
case "danger":
192+
return "CAUTION";
193+
case "details":
194+
return "DETAILS";
195+
default:
196+
return type.toUpperCase();
197+
}
198+
}
199+
200+
function absolutizeLinks(
201+
markdown: string,
202+
currentFile: string,
203+
origin: string,
204+
): string {
205+
return markdown.replace(
206+
/(!?\[[^\]]*\]\()([^\s)]+)((?:\s+(?:"[^"]*"|'[^']*'|\([^)]*\)))?)(\))/g,
207+
(_match, start, href, title, end) => {
208+
if (/^(https?:|mailto:|tel:|#)/.test(href))
209+
return `${start}${href}${title}${end}`;
210+
return `${start}${absoluteUrl(href, currentFile, origin)}${title}${end}`;
211+
},
212+
);
213+
}
214+
215+
function absoluteUrl(
216+
href: string,
217+
currentFile: string,
218+
origin: string,
219+
): string {
220+
const [rawPath, hash = ""] = href.split("#", 2);
221+
const suffix = hash ? `#${hash}` : "";
222+
const resolved = rawPath.startsWith("/")
223+
? rawPath
224+
: `/${normalizePath(path.posix.normalize(path.posix.join(path.posix.dirname(currentFile), rawPath)))}`;
225+
226+
return `${origin}${cleanRoute(resolved)}${suffix}`;
227+
}
228+
229+
function cleanRoute(route: string): string {
230+
if (!route.endsWith(".md") && !route.endsWith(".html")) return route;
231+
return route
232+
.replace(/\/index\.md$/, "/")
233+
.replace(/\/index\.html$/, "/")
234+
.replace(/\.md$/, "")
235+
.replace(/\.html$/, "");
236+
}
237+
238+
function stripFrontmatter(markdown: string): string {
239+
return markdown.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, "");
240+
}
241+
242+
function languageFromFile(filePath: string): string {
243+
const basename = path.basename(filePath);
244+
if (basename === "build.zig.zon") return "zig";
245+
const ext = path.extname(filePath).slice(1);
246+
return ext || "text";
247+
}
248+
249+
function resolveInside(root: string, relativePath: string): string {
250+
const resolved = path.resolve(root, relativePath);
251+
const normalizedRoot = path.resolve(root);
252+
if (
253+
resolved !== normalizedRoot &&
254+
!resolved.startsWith(normalizedRoot + path.sep)
255+
) {
256+
throw new Error(`Path escapes source root: ${relativePath}`);
257+
}
258+
return resolved;
259+
}
260+
261+
function normalizePath(filePath: string): string {
262+
return filePath.split(path.sep).join("/");
263+
}

0 commit comments

Comments
 (0)