Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions course/.vitepress/config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,28 @@ 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/",
},
base: "/",
lastUpdated: true,
themeConfig: themeConfig,
cleanUrls: true,
vue: {
template: {
compilerOptions: {
// <llm-only> 是自定义元素:网页端由 CSS 隐藏,正文只进入 /llms 输出
isCustomElement: (tag) => tag === "llm-only",
},
},
},
vite: {
plugins: [
llmMarkdownPlugin({
Expand All @@ -27,6 +38,8 @@ export default defineConfig({
path.dirname(fileURLToPath(import.meta.url)),
"dist",
),
title,
description,
}),
],
},
Expand Down
71 changes: 64 additions & 7 deletions course/.vitepress/llmMarkdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<void> {
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<void> {
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})`;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using relative paths in llms.txt is more robust and portable than hardcoding ORIGIN. It allows the index to work correctly on local previews, staging environments, or if the site is hosted under a different domain or base path. LLM clients will resolve these relative links against the URL of the llms.txt file itself.

Suggested change
return `- [${title}](${ORIGIN}/llms/${page.relativePath})`;
return "- [" + title + "](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
);
}
Comment on lines +111 to 119

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

VitePress headings often contain custom anchors like {#custom-id} (e.g., # Zig 语言圣经 {#zig-bible}). The current regex will include these anchors in the title, which looks messy in the llms.txt index. Stripping them out ensures a clean title.

function firstHeading(markdown: string): string | null {
  const match = markdown.match(/^#\\s+(.+)$/m);
  if (!match) return null;
  return match[1].replace(/\\{#[\\w-]+\\\}\\s*$/, "").trim();
}


export async function renderLlmMarkdown(
Expand All @@ -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);
Expand Down Expand Up @@ -239,6 +291,11 @@ function stripFrontmatter(markdown: string): string {
return markdown.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, "");
}

// 去掉 <llm-only> 标记,保留其中内容(网页端由 CSS 隐藏,这里让正文进入 LLM 输出)
function unwrapLlmOnly(markdown: string): string {
return markdown.replace(/<\/?llm-only(?:\s+[^>]*)?>/gi, "");
}
Comment on lines +295 to +297

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If the <llm-only> tag has any attributes (such as classes, styles, or Vue directives) or multiple spaces, the current regex /<\\/?llm-only\\s*>/gi will fail to match and strip it. Updating the regex to support optional attributes ensures that the tags are always stripped correctly from the LLM markdown output.

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";
Expand Down
87 changes: 32 additions & 55 deletions course/.vitepress/theme/copyToLLM.ts
Original file line number Diff line number Diff line change
@@ -1,66 +1,39 @@
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<HTMLElement | null>(null);
const llmPath = computed(() => withBase(`/llms/${page.value.filePath}`));
const llmUrl = computed(() =>
typeof window === "undefined"
? ""
: 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<void> {
if (navigator.clipboard) {
await navigator.clipboard.writeText(text);
Expand Down Expand Up @@ -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;
},
Expand Down Expand Up @@ -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 链接",
Expand All @@ -197,7 +173,8 @@ export default defineComponent({
"button",
{
type: "button",
style: itemStyle,
class: "copy_llm-item",
role: "menuitem",
onClick: () => copy("markdown"),
},
"复制 Markdown 正文",
Expand Down
1 change: 1 addition & 0 deletions course/.vitepress/theme/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down
75 changes: 75 additions & 0 deletions course/.vitepress/theme/style/copyToLLM.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/* 只给 LLM 看:<llm-only> 的内容网页隐藏,仅进入 /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;
}
Loading