交互式内容组件(提示框、GitHub 卡片、Wiki 链接)
Mizuki 的 Markdown 渲染管线在「纯文本 → 富交互 HTML」的转换过程中内置了三类交互式内容组件:提示框、GitHub 仓库卡片与 Wiki 链接(卡片式内链)。它们分别由 rehype-component-admonition.mjs、rehype-component-github-card.mjs 与 remark-wiki-link.mjs 三个插件实现,均在构建期将 Markdown 语法转换为结构化 HTML,使作者无需手写 HTML 即可表达「强调说明」「引用开源仓库」「站内互链」这三种高频写作语义。
Purpose and Scope
本页覆盖这三个组件的完整实现机制:
- 提示框:
:::note/:::tip/:::important/:::caution/:::warning块级指令如何被转换为带标题的blockquote.admonition结构; - GitHub 卡片:
::github{repo="owner/repo"}叶子指令如何生成占位卡片并注入客户端脚本,在浏览器中异步拉取api.github.com数据补全卡片; - Wiki 链接:
[[page|alias]]与[[page#heading]]语法如何在构建期扫描全部文章 frontmatter、解析目标文章、生成内联链接或带封面/描述/元数据的卡片。
以下相关主题有意留给兄弟页面,本页只做边界处的引用而不展开:
- Markdown 管线的整体组装(插件顺序、
markdownConfig全貌、KaTeX / Expressive Code 等):见 markdown-pipeline 上级页面; - Mermaid / PlantUML 图表组件(
DiagramManager、remark-plantuml):属于图表渲染能力; - 图片网格(
rehype-component-image-grid.mjs)、响应式图片与 Fancybox 灯箱:属于媒体处理能力; - 文章加密机制本身(
encrypted/passwordfrontmatter 的服务端行为):本页只描述 Wiki 卡片如何降级以避免泄露加密文章内容。
Overview
三类组件共享同一条核心思路:作者写轻量语法,构建期承担全部解析与组装成本,运行时只保留必要的增量行为。
| 组件 | 作者语法 | 处理层 | 产物 | 运行时行为 |
|---|---|---|---|---|
| 提示框 | :::note ... ::: 块指令 | rehype(HAST) | blockquote.admonition.bdm-* | 纯静态 |
| GitHub 卡片 | ::github{repo="owner/repo"} 叶子指令 | rehype(HAST) | a.card-github + 内联 script | 客户端 fetch GitHub API |
| Wiki 链接 | [[target]] / [[target|alias]] | remark(MDAST) | a 内联链接或 a.card-wiki-link 卡片 | 纯静态 |
提示框解决的问题是「让警告、注意事项这类语义在视觉上与正文区分」。AdmonitionComponent 接收五种类型(tip / note / important / caution / warning),生成统一的 blockquote 结构,标题可来自指令属性 title、指令标签(directive label),缺省时回退到类型名大写。
GitHub 卡片解决的问题是「引用开源项目时展示 stars / forks / 语言 / 许可证」。GithubCardComponent 刻意不在构建期请求 GitHub API——那会拖慢构建且受未认证速率限制——而是生成带唯一 UUID 的占位 DOM 与一段 defer 脚本,由访客浏览器各自拉取并填充数据。
Wiki 链接解决的问题是「站内互链免维护 URL」。remarkWikiLink 在 remark 阶段遍历 MDAST:独立成段的 [[page]] 会升级为内容卡片(含封面、标题、描述、发布日期、分类、标签);行内的 [[page]] 则替换为普通链接。目标文章通过「slug 精确匹配 → 内容路径精确匹配 → 文件名唯一匹配」三级策略解析,链接 URL 复用博客全局 permalink 规则,因此即使站点改了链接格式,Wiki 链接也无需改写。
Architecture
三个插件都挂在 Astro 的 Markdown 处理链上,注册点集中在 astro.config.mjs。remarkWikiLink 受 markdownConfig.wikiLink.enable 开关控制,并把 markdownConfig.wikiLink 的选项连同 permalink 配置一起注入插件;AdmonitionComponent 与 GithubCardComponent 则作为指令处理器导入,服务于 remark-directive 语法(:::type 与 ::github)。
层级关系说明:
- remark 与 rehype 的分工是关键设计决策。Wiki 链接需要在 MDAST(Markdown 抽象语法树)上做文本级正则替换,因为它处理的
[[...]]不是任何标准 Markdown 语法;而提示框与 GitHub 卡片对应 remark-directive 已解析出的指令节点,在 HAST 上由hastscript直接生成最终 HTML 更简单可靠。 remarkWikiLink是唯一读文件系统的插件:它用node:fs遍历src/content/posts/收集所有文章的 frontmatter,因此它能脱离 Astro Content Collections 独立工作,也不依赖 Vite 虚拟模块。GithubCardComponent是唯一有运行时网络行为的组件:数据补全发生在访客浏览器中,与构建环境完全解耦。
提示框(Admonition)实现
指令到 HTML 的映射
AdmonitionComponent(properties, children, type) 接收指令属性、指令子节点和五种 admonition 类型之一,返回一个由 hastscript 构造的 blockquote:
1export function AdmonitionComponent(properties, children, type) {
2 if (!Array.isArray(children) || children.length === 0) {
3 return h(
4 "div",
5 { class: "hidden" },
6 'Invalid admonition directive. (Admonition directives must be of block type ":::note{name="name"} <content> :::")',
7 );
8 }
9
10 let label = properties?.title || null;
11 if (properties?.["has-directive-label"]) {
12 label = children[0]; // The first child is the label
13 // biome-ignore lint/style/noParameterAssign: <check later>
14 children = children.slice(1);
15 }
16 const titleContent =
17 label && typeof label === "object" && Array.isArray(label.children)
18 ? label.children
19 : label || type.toUpperCase();
20
21 return h("blockquote", { class: `admonition bdm-${type}` }, [
22 h("div", { class: "bdm-title" }, titleContent),
23 ...children,
24 ]);
25}Source: rehype-component-admonition.mjs
控制流拆解:
- 非法输入防御:
children非数组或为空时返回class="hidden"的div,正文里携带一行说明,提示作者块指令的正确写法。之所以用hidden而不是抛错,是让一次笔误不会中断整站构建。 - 标题三级优先级:
properties["has-directive-label"](指令后紧跟的文本被视为标签)>properties.title指令属性 >type.toUpperCase()(如NOTE)。当标签是节点对象时取其children展开,保证标题内容可包含行内 Markdown。 - 结构输出:
blockquote.admonition.bdm-{type}+div.bdm-title+ 原样保留的子节点。类型只体现在 class 修饰符上,样式与图标交由 CSS 按bdm-note/bdm-tip等前缀区分。
类型与产物对照
| 指令写法 | type | 输出 class | 缺省标题 |
|---|---|---|---|
:::note | note | admonition bdm-note | NOTE |
:::tip | tip | admonition bdm-tip | TIP |
:::important | important | admonition bdm-important | IMPORTANT |
:::caution | caution | admonition bdm-caution | CAUTION |
:::warning | warning | admonition bdm-warning | WARNING |
设计意图:把「语义类型」与「视觉样式」解耦——组件只输出结构化语义,配色、图标、圆角等全部由主题 CSS 承担,换主题不需要改插件。
GitHub 卡片实现
占位 + 客户端补全的混合渲染
GithubCardComponent(properties, children) 的核心策略是构建期只产出骨架,运行时再填充数据:
1 const repo = properties.repo;
2 const cardUuid = `GC${Math.random().toString(36).slice(-6)}`; // Collisions are not important
3
4 const nAvatar = h(`div#${cardUuid}-avatar`, { class: "gc-avatar" });
5 const nLanguage = h(
6 `span#${cardUuid}-language`,
7 { class: "gc-language" },
8 "Waiting...",
9 );
10
11 const nStars = h(`div#${cardUuid}-stars`, { class: "gc-stars" }, "00K");
12 const nForks = h(`div#${cardUuid}-forks`, { class: "gc-forks" }, "0K");
13 const nLicense = h(`div#${cardUuid}-license`, { class: "gc-license" }, "0K");Source: rehype-component-github-card.mjs
每个可变槽位都拿到一个带 UUID 后缀的 DOM id({uuid}-description、{uuid}-stars、{uuid}-forks、{uuid}-license、{uuid}-language、{uuid}-avatar)。UUID 来自 Math.random().toString(36).slice(-6),源码注释明确说明「碰撞无关紧要」——即使同页两张卡片撞了 id,最坏情况只是数据显示到同一节点,不影响正确性主张。
运行时数据补全脚本
卡片的实际数据来自一段随卡片一起输出、defer 执行的内联脚本:
1 const nScript = h(
2 `script#${cardUuid}-script`,
3 { type: "text/javascript", defer: true },
4 `
5 fetch('https://api.github.com/repos/${repo}', { referrerPolicy: "no-referrer" }).then(response => response.json()).then(data => {
6 document.getElementById('${cardUuid}-description').innerText = data.description?.replace(/:[a-zA-Z0-9_]+:/g, '') || "Description not set";
7 document.getElementById('${cardUuid}-language').innerText = data.language;
8 document.getElementById('${cardUuid}-forks').innerText = Intl.NumberFormat('en-us', { notation: "compact", maximumFractionDigits: 1 }).format(data.forks).replaceAll("\\u202f", '');
9 document.getElementById('${cardUuid}-stars').innerText = Intl.NumberFormat('en-us', { notation: "compact", maximumFractionDigits: 1 }).format(data.stargazers_count).replaceAll("\\u202f", '');
10 const avatarEl = document.getElementById('${cardUuid}-avatar');
11 avatarEl.style.backgroundImage = 'url(' + data.owner.avatar_url + ')';
12 avatarEl.style.backgroundColor = 'transparent';
13 document.getElementById('${cardUuid}-license').innerText = data.license?.spdx_id || "no-license";
14 document.getElementById('${cardUuid}-card').classList.remove("fetch-waiting");
15 console.log("[GITHUB-CARD] Loaded card for ${repo} | ${cardUuid}.")
16 }).catch(err => {
17 const c = document.getElementById('${cardUuid}-card');
18 c?.classList.add("fetch-error");
19 console.warn("[GITHUB-CARD] (Error) Loading card for ${repo} | ${cardUuid}.")
20 })
21 `,
22 );
23
24 return h(
25 `a#${cardUuid}-card`,
26 {
27 class: "card-github fetch-waiting no-styling",
28 href: `https://github.com/${repo}`,
29 target: "_blank",
30 repo,
31 },
32 [
33 nTitle,
34 nDescription,
35 h("div", { class: "gc-infobar" }, [nStars, nForks, nLicense, nLanguage]),
36 nScript,
37 ],
38 );Source: rehype-component-github-card.mjs
值得注意的细节,每一条都对应一个真实约束:
referrerPolicy: "no-referrer":GitHub API 无需引用信息,同时避免把站点 URL 泄露给第三方接口。- 描述清洗:
.replace(/:[a-zA-Z0-9_]+:/g, '')去掉仓库描述里的 GitHub emoji 短码(如:rocket:),因为纯文本innerText无法渲染它们;描述为空时回退到"Description not set"。 - 紧凑数字格式:
Intl.NumberFormat('en-us', { notation: "compact", maximumFractionDigits: 1 })把12345显示为12.3K,再用replaceAll("\\u202f", '')去掉紧凑记法插入的窄不换行空格,保证数字连续。 - 许可证兜底:
data.license?.spdx_id || "no-license",仓库无许可证或字段缺失时显示占位。 - 状态机 class:卡片初始带
fetch-waiting(配合 CSS 显示加载态骨架),成功后移除;失败时追加fetch-error,且对document.getElementById使用?.防御 DOM 已被移除的情况。 - 外层是
<a target="_blank">:整张卡片可点击跳转到https://github.com/{repo},即使 API 请求失败,链接语义依然成立——降级路径优先保住「可跳转」这一最小价值。
输入校验
两个前置守卫同样以 class="hidden" 的提示块作为产物,而不是抛异常:
1 if (Array.isArray(children) && children.length !== 0) {
2 return h("div", { class: "hidden" }, [
3 'Invalid directive. ("github" directive must be leaf type "::github{repo="owner/repo"}")',
4 ]);
5 }
6
7 if (!properties.repo?.includes("/")) {
8 return h(
9 "div",
10 { class: "hidden" },
11 'Invalid repository. ("repo" attributte must be in the format "owner/repo")',
12 );
13 }Source: rehype-component-github-card.mjs
第一个守卫强制 ::github 必须是叶子指令(不能包裹内容);第二个守卫用 includes("/") 做最小代价的 owner/repo 形状校验。注意 repo.split("/")[0] / repo.split("/")[1] 随后被分别用作 owner 与 repo 展示文本,因此形如 a/b/c 的输入会把 c 丢进链接但仍能渲染——这是宽松校验换来的行为边界。
Wiki 链接实现
remark-wiki-link.mjs(部分实现参考 CuteLeaf/Firefly,遵循 MIT 许可,见 THIRD_PARTY_NOTICES.md)是三者中最复杂的一个:它维护跨文章索引、支持两种渲染形态,并与全局 permalink 体系联动。
语法与解析
核心正则与跳过集合定义了语法的边界:
1const POSTS_DIR = fileURLToPath(new URL("../content/posts/", import.meta.url));
2const MARKDOWN_EXTENSION = /\.(?:md|mdx|markdown)$/i;
3const WIKI_LINK = /!?\[\[([^[\]\n]+)\]\]/g;
4const STANDALONE_WIKI_LINK = /^\[\[([^[\]\n]+)\]\]$/;
5const SKIPPED_NODE_TYPES = new Set([
6 "code",
7 "inlineCode",
8 "link",
9 "linkReference",
10 "mdxJsxFlowElement",
11 "mdxJsxTextElement",
12]);Source: remark-wiki-link.mjs
WIKI_LINK的!?前缀允许作者用![[...]]转义写法表达字面文本(图片嵌入语法不在此插件职责内);SKIPPED_NODE_TYPES保证代码块、行内代码、既有链接与 MDX 组件内部的[[...]]不被改写——这是防止「改写用户明确想要保留的文本」的关键保护。
链接值的语法拆解由 parseValue 完成,支持 [[page|alias]] 与 [[page#heading]]:
1function parseValue(value) {
2 const separator = value.indexOf("|");
3 const destination = (
4 separator === -1 ? value : value.slice(0, separator)
5 ).trim();
6 const alias = separator === -1 ? "" : value.slice(separator + 1).trim();
7 const headingIndex = destination.indexOf("#");
8 const page =
9 headingIndex === -1 ? destination : destination.slice(0, headingIndex);
10 const heading =
11 headingIndex === -1 ? "" : destination.slice(headingIndex + 1).trim();
12 const contentPath = page ? normalizeContentPath(page) : "";
13 if ((!contentPath && !heading) || (page && !contentPath)) return null;
14 return { alias, contentPath, destination, heading };
15}Source: remark-wiki-link.mjs
解析失败(如纯 #heading 指向当前页但页面路径非法化后为空)返回 null,调用方据此原样跳过该文本,不做任何替换。normalizeContentPath(L28-L45)统一反斜杠为 /、剥离 ./ 前缀与末尾斜杠、去掉 .md/.mdx/.markdown 扩展名,并剥掉 posts/ 目录前缀;遇到 . 或 .. 段落则判为非法返回空串,天然阻止路径穿越式写法。
文章索引:collectPostMetas 与三级解析
插件用 node:fs 手工遍历 src/content/posts/(不依赖 Astro Content Collections),并对结果做 1 秒级内存缓存:
1function collectPostMetas() {
2 const now = Date.now();
3 if (now < metaCache.expiresAt) return metaCache.metas;
4 const metas = [];
5 const stack = [POSTS_DIR];
6 while (stack.length > 0) {
7 const directory = stack.pop();
8 let entries = [];
9 try {
10 entries = readdirSync(directory, { withFileTypes: true });
11 } catch {
12 continue;
13 }
14 for (const entry of entries) {
15 const filePath = path.join(directory, entry.name);
16 if (entry.isDirectory()) {
17 stack.push(filePath);
18 } else if (MARKDOWN_EXTENSION.test(entry.name)) {
19 try {
20 if (!statSync(filePath).isFile()) continue;
21 metas.push({
22 filePath,
23 contentPath: toContentPath(filePath),
24 data: matter(readFileSync(filePath, "utf8")).data ?? {},
25 });
26 } catch {
27 // 单篇文章损坏不应阻断其它 Wiki Link。
28 }
29 }
30 }
31 }
32 metaCache = { expiresAt: now + 1000, metas };
33 return metas;
34}Source: remark-wiki-link.mjs
设计意图:
- 1 秒 TTL 缓存(
expiresAt: now + 1000)平衡了「每篇文章都要重扫目录」的重复成本与「开发模式热更新后索引过期」的时效需求。构建大站时,同一秒内处理的多篇文章共享一次扫描。 - 逐文件 try/catch:frontmatter 解析失败或读盘异常的单篇文章只被跳过,不会让整站构建失败——注释明确写了这条原则。
目标解析采用严格度递减的三级策略:
1function resolveMeta(metas, target) {
2 const exactSlug = metas.find(
3 (meta) =>
4 typeof meta.data.slug === "string" && meta.data.slug.trim() === target,
5 );
6 if (exactSlug) return exactSlug;
7
8 const exactPath = metas.find(
9 (meta) =>
10 meta.contentPath === target ||
11 meta.contentPath.replace(/\/index$/i, "") === target,
12 );
13 if (exactPath) return exactPath;
14
15 if (!target.includes("/")) {
16 const byName = metas.filter(
17 (meta) => path.basename(meta.contentPath) === target,
18 );
19 if (byName.length === 1) return byName[0];
20 if (byName.length > 1) {
21 console.warn(
22 `[remark-wiki-link] "${target}" 匹配到多个文件,请使用更完整的路径。`,
23 );
24 }
25 }
26 return null;
27}Source: remark-wiki-link.mjs
| 优先级 | 匹配方式 | 说明 |
|---|---|---|
| 1 | frontmatter slug 精确等于目标 | 作者显式命名,最高权威 |
| 2 | contentPath 等于目标(含 /index 剥离) | 目录式文章 dir/index.md 可写成 [[dir]] |
| 3 | 文件名 basename 唯一匹配 | 便捷写法 [[my-post]];多义时警告并放弃,强制作者写完整路径 |
第三级的多义保护很重要:宁可让链接保持原文本,也不静默链接到错误文章。
链接 URL:复用全局 permalink 规则
postUrl(meta, fallback, options, metas) 的优先级链与站内路由体系完全对齐,保证 Wiki 链接与文章页真实地址一致:
1function postUrl(meta, fallback, options, metas) {
2 if (typeof meta?.data.permalink === "string" && meta.data.permalink.trim()) {
3 return `/${meta.data.permalink.replace(/^\/+|\/+$/g, "")}/`;
4 }
5 if (meta && options.permalink?.enable) {
6 return `/${globalPermalink(meta, metas, options.permalink)}/`;
7 }
8 if (typeof meta?.data.alias === "string" && meta.data.alias.trim()) {
9 return `/posts/${meta.data.alias.replace(/^\/+|\/+$/g, "")}/`;
10 }
11 const id = meta ? postId(meta) : fallback;
12 return `/posts/${id
13 .split("/")
14 .map((part) => encodeURIComponent(part))
15 .join("/")}/`;
16}Source: remark-wiki-link.mjs
四级回退依次是:文章 frontmatter permalink → 全局 permalink.format 模板(%year% / %monthnum% / %day% / %post_id% / %postname% / %raw_postname% / %category% 等,见 globalPermalink,L128-L150)→ frontmatter alias → 默认 /posts/{slug|contentPath}。末段逐段 encodeURIComponent,中文文件名也能生成合法 URL。目标文章不存在时(meta === null),fallback 使用原始目标字符串,链接仍会生成并指向 /posts/{target}/——由前端 404 页兜底,而不是构建失败。
globalPermalink 中的 %post_id% 通过「过滤草稿后按 published 升序排序再 findIndex」得到序号(L133-L137),与 WordPress 风格的数字型固定链接兼容。
树遍历:行内链接与卡片两条路径
transform 递归遍历 MDAST,为每个节点分流:
1async function transform(node, metas, options, currentFilePath) {
2 if (SKIPPED_NODE_TYPES.has(node.type) || !Array.isArray(node.children))
3 return;
4 for (let index = 0; index < node.children.length; index++) {
5 const child = node.children[index];
6 if (
7 child.type === "paragraph" &&
8 child.children?.length === 1 &&
9 child.children[0].type === "text"
10 ) {
11 const match = child.children[0].value.trim().match(STANDALONE_WIKI_LINK);
12 if (match) {
13 const parsed = parseValue(match[1]);
14 if (parsed?.contentPath && !parsed.heading) {
15 const card = await createCard(
16 parsed,
17 metas,
18 options,
19 currentFilePath,
20 );
21 if (card) {
22 node.children[index] = card;
23 continue;
24 }
25 }
26 }
27 }
28 if (child.type === "text") {
29 const replacements = replaceInline(child.value, metas, options);
30 if (replacements) {
31 node.children.splice(index, 1, ...replacements);
32 index += replacements.length - 1;
33 }
34 } else {
35 await transform(child, metas, options, currentFilePath);
36 }
37 }
38}Source: remark-wiki-link.mjs
两条路径的判定条件:
- 卡片路径:段落只含一个 text 子节点且整体恰为
[[page]](无|alias限制、但不允许#heading)。若createCard因目标未解析返回null,会自然落入下方的行内替换分支,卡片失败不会丢链接。 - 行内路径:
replaceInline用matchAll在文本值内扫描,把每个非!前缀的[[...]]换成createLink产物,并用splice+index补偿保持遍历正确。无匹配返回null表示节点未变。
createLink(L245-L257)生成普通 a 节点,锚点经 github-slugger 的 slug() 处理,与站内标题锚点算法一致;显示文本按「alias → 目标文章 title → 路径或 heading」回退。
卡片渲染与加密文章降级
createCard 组装 a.card-wiki-link,内含封面(可选)与信息区:
1async function createCard(parsed, metas, options, currentFilePath) {
2 const meta = resolveMeta(metas, parsed.contentPath);
3 if (!meta) return null;
4 const encrypted = meta.data.encrypted === true || Boolean(meta.data.password);
5 const title = displayTitle(parsed, meta);
6 const info = [element("div", { class: "wlc-title" }, [text(title)])];
7 if (!encrypted && typeof meta.data.description === "string") {
8 const description = meta.data.description.trim();
9 if (description) {
10 info.push(
11 element("div", { class: "wlc-description" }, [text(description)]),
12 );
13 }
14 }
15 // ……日期 / 分类 / 标签元数据组装 ……
16 const cover = encrypted
17 ? null
18 : await createWikiCover(meta, title, options, currentFilePath);
19
20 return element(
21 "a",
22 {
23 class: "card-wiki-link no-styling",
24 href: postUrl(meta, parsed.contentPath, options, metas),
25 },
26 [
27 ...(cover
28 ? [
29 element("span", { class: "wlc-cover", dataNoEnhance: true }, [
30 cover,
31 ]),
32 ]
33 : []),
34 element("div", { class: "wlc-info" }, info),
35 ],
36 );
37}Source: remark-wiki-link.mjs
加密文章降级是本组件最重要的隐私设计:当目标文章带 encrypted: true 或 password frontmatter 时,卡片不输出 description、不输出封面,只保留标题与链接。原因很直接——浏览器端加密只保护文章正文,若在他人文章的 Wiki 卡片里渲染出加密文章的摘要与封面图,等于在未解锁状态下泄露了加密内容的部分明文。
封面由 createWikiCover(L208-L235)借助 resolvePostCoverSource 解析:本地封面会被改写成相对当前文章的相对路径(wikiCoverUrl,L197-L206),交由 Astro 图片管线继续做响应式处理(widths: [160, 320, 480]、loading: "lazy"、decoding: "async");若 URL 命中 noReferrerDomains 配置则附加 referrerPolicy: "no-referrer",防止防盗链站点拒绝加载。
插件入口与开关
1export function remarkWikiLink(options = {}) {
2 return async (tree, file) => {
3 if (options.enable === false) return;
4 const currentFilePath = file?.path || file?.history?.at(-1);
5 await transform(tree, collectPostMetas(), options, currentFilePath);
6 };
7}Source: remark-wiki-link.mjs
options.enable === false 直接短路(宽松判断而非 === true,保证未传配置时默认启用);currentFilePath 从 file.path 或 history 末项取得,用于封面相对路径计算。
Core Flow
以一篇文章同时含三种语法为例,端到端流程如下:
渲染期(GitHub 卡片)的分支逻辑:
注意该流程图中 fetch-error 路径的关键点:数据加载失败不破坏链接。卡片骨架(owner/repo 标题栏、GitHub logo、https://github.com/{repo} 链接)在构建期就已生成,API 故障只影响数字与描述的填充。
Usage Examples
提示框:块指令 + 自定义标题
来自插件自身的文档注释与校验逻辑,展示合法输入形状(五选一的 type、可选 title 属性):
1/**
2 * Creates an admonition component.
3 *
4 * @param {Object} properties - The properties of the component.
5 * @param {string} [properties.title] - An optional title.
6 * @param {('tip'|'note'|'important'|'caution'|'warning')} type - The admonition type.
7 * @param {import('mdast').RootContent[]} children - The children elements of the component.
8 * @returns {import('mdast').Parent} The created admonition component.
9 */
10export function AdmonitionComponent(properties, children, type) {Source: rehype-component-admonition.mjs
对应作者侧写法::::note{title="部署提醒"} ... :::;若不写 title 且未使用 directive label,标题回退为 NOTE。
GitHub 卡片:叶子指令
合法写法必须是叶子指令且 repo 含 /,这与两个校验守卫一一对应:
::github{repo="saicaca/fuwari"}产物骨架(节选自实现):外层 a 的属性直接决定交互行为:
1 return h(
2 `a#${cardUuid}-card`,
3 {
4 class: "card-github fetch-waiting no-styling",
5 href: `https://github.com/${repo}`,
6 target: "_blank",
7 repo,
8 },
9 [
10 nTitle,
11 nDescription,
12 h("div", { class: "gc-infobar" }, [nStars, nForks, nLicense, nLanguage]),
13 nScript,
14 ],
15 );Source: rehype-component-github-card.mjs
Wiki 链接:卡片形态与行内形态
transform 的判定逻辑直接给出两种形态的写作规则:
1 const match = child.children[0].value.trim().match(STANDALONE_WIKI_LINK);
2 if (match) {
3 const parsed = parseValue(match[1]);
4 if (parsed?.contentPath && !parsed.heading) {
5 const card = await createCard(Source: remark-wiki-link.mjs
- 卡片形态:
[[my-post]]必须独占一个段落(段落内仅此文本),且不带#heading; - 行内形态:
参考 [[my-post|这篇教程]] 的第三节或[[guide#安装]]——行内、或带锚点的写法一律走createLink普通链接路径; - 转义:
![[not-a-link]]因WIKI_LINK的!?前缀与replaceInline中的if (match[0].startsWith("!")) continue;而原样保留。
测试对产物的断言(验证渲染形状)
仓库测试直接锁定了 Wiki 卡片的 DOM 形状,可作为产物契约参考:
await remarkWikiLink()(tree, {
path: fileURLToPath(Source: markdown-enhancements.test.mjs
assert.match(tree.children[0].data.hProperties.class, /card-wiki-link/);
assert.equal(tree.children[0].children[0].data.hName, "span");Source: markdown-enhancements.test.mjs
feed 测试同样以 card-wiki-link / wlc-title / wlc-description 等 class 作为识别锚点(feed-content.test.ts),说明这套 class 命名是跨模块共享的稳定契约。
Configuration Options
插件注册(astro.config.mjs)
1 ...(markdownConfig.wikiLink.enable
2 ? [
3 [
4 remarkWikiLink,
5 {
6 ...markdownConfig.wikiLink,
7 permalink: permalinkConfig,
8 },
9 ],
10 ]
11 : []),Source: astro.config.mjs
Wiki 链接插件按 markdownConfig.wikiLink.enable 条件注册,并把整段 wikiLink 配置与全局 permalink 配置合并注入。AdmonitionComponent 与 GithubCardComponent 则在文件头部直接导入(astro.config.mjs L33-L34)。
选项表
| 选项 | 层级 | 类型 | 默认 | 说明 |
|---|---|---|---|---|
markdownConfig.wikiLink.enable | wikiLink | boolean | undefined(视为启用) | false 时 remarkWikiLink 直接短路返回 |
options.enable | 插件入口 | boolean | undefined(视为启用) | 同上,宽松判断 enable === false |
options.permalink | 注入自 permalinkConfig | object | 站点 permalink 配置 | 决定 Wiki 链接 URL 的 %postname% 等模板展开 |
options.imageApi / options.apiImages | wikiLink | — | — | 透传给 resolvePostCoverSource 解析卡片封面来源 |
options.noReferrerDomains | wikiLink | string[] | [] | 命中域名的外链封面追加 referrerPolicy: "no-referrer" |
properties.title | 提示框指令 | string | 类型大写 | :::note{title="..."} 自定义标题 |
properties.repo | GitHub 卡片指令 | string | 必填 | owner/repo 形状,需包含 / |
提示框与 GitHub 卡片没有独立开关:它们作为指令处理器随管线常驻,是否出现在页面上完全取决于作者是否使用对应指令。
API Reference
AdmonitionComponent(properties, children, type): import('mdast').Parent
Parameters:
properties(Object):指令属性,可含title(自定义标题)与has-directive-label(布尔标记,指示第一个子节点是标签)。children(import('mdast').RootContent[]):指令内容子节点。type('tip'|'note'|'important'|'caution'|'warning'):提示框类型。
Returns: blockquote.admonition.bdm-{type} 节点;输入非法时返回携带提示文本的 div.hidden。
GithubCardComponent(properties, children): import('mdast').Parent
Parameters:
properties(Object):必含repo: string(owner/repo格式)。children(import('mdast').RootContent[]):必须为空(叶子指令)。
Returns: a.card-github.fetch-waiting.no-styling(target="_blank",href 指向 https://github.com/{repo}),内嵌 defer 脚本节点;输入非法时返回 div.hidden 提示块。
remarkWikiLink(options): (tree, file) => Promise<void>
Parameters:
options(Object, 默认{}):含enable、permalink、imageApi、apiImages、noReferrerDomains。tree(MDAST Root)、file(VFile):由 remark 管线传入。
Returns: Promise,就地修改 tree(原地替换/插入节点),无返回值。options.enable === false 时零成本短路。
签名来源:remark-wiki-link.mjs
内部关键函数(供扩展时参考)
| 函数 | 位置 | 职责 |
|---|---|---|
normalizeContentPath | L28 | 归一化路径、剥 posts/ 前缀、拒绝 .. 穿越 |
collectPostMetas | L54 | 遍历文章目录收集 frontmatter,1 秒 TTL 缓存 |
resolveMeta | L89 | slug → 路径 → basename 三级目标解析 |
globalPermalink | L128 | 展开全局 permalink 模板占位符 |
postUrl | L152 | 四级回退生成目标 URL |
parseValue | L169 | 拆解 page|alias#heading |
createLink / createCard | L245 / L267 | 生成行内链接 / 卡片节点 |
replaceInline / transform | L334 / L353 | 文本扫描替换 / 递归遍历分流 |
Failure Modes, Edge Cases & Concurrency
容错优先于失败是三个组件一致的设计哲学:
| 场景 | 行为 | 实现位置 |
|---|---|---|
提示框 children 为空/非数组 | 输出 div.hidden + 写法提示,不抛错 | admonition L14-L20 |
::github 带子内容 | 同上(叶子指令约束) | github-card L13-L17 |
repo 缺 / | 同上(格式约束) | github-card L19-L25 |
| GitHub API 请求失败 | 卡片加 fetch-error class,保留占位文本,链接仍可点 | github-card L74-L78 |
| GitHub API 无 description / license | 分别回退 "Description not set" / "no-license" | github-card L64 / L71 |
| Wiki 目标解析失败(多义或不存在) | createCard 返回 null → 行内链接兜底;postUrl 用原始目标生成 /posts/{target}/ | wiki-link L107-L114 |
| 单篇文章 frontmatter 损坏 | try/catch 跳过该篇,不阻断其余链接 | wiki-link L79-L81 |
| 目录读取异常 | readdirSync 外层 catch,continue 跳过该目录 | wiki-link L62-L66 |
| 加密文章被 Wiki 卡片引用 | 隐藏 description 与封面,防泄露明文 | wiki-link L270-L313 |
[[...]] 出现在代码块/MDX 内 | SKIPPED_NODE_TYPES 跳过,原文保留 | wiki-link L18-L25 |
并发与缓存:
metaCache是模块级单例(L26),TTL 1 秒。Astro 构建通常是单进程顺序处理文件,同一进程内该缓存天然线程安全;其风险面仅在于「开发服务器 1 秒内新增文章后立即被引用」的极小窗口——过期即重扫,自愈。- GitHub 卡片脚本在浏览器中并发执行(每张卡一个
fetch),UUID 隔离保证互不干扰;document.getElementById(...)在 catch 分支使用?.,防御用户在请求返回前已离开页面的竞态。 transform是async递归(createWikiCover内含 await),但树遍历本身串行进行,splice+index补偿(L383-L384)保证一次替换多个节点时索引不越界。
Performance & Operational Notes
- GitHub 卡片的速率限制转移:API 调用发生在访客浏览器,站点构建不消耗 GitHub 配额,也未配置
GITHUB_TOKEN类密钥——每张卡的成败完全取决于访客自己的 GitHub API 限额与网络。这是「构建速度与部署密钥零依赖」换「访客端数据可用性」的明确取舍。 - 重复 DOM id 的弱保证:
Math.randomUUID 理论可碰撞,源码注释声明接受该风险;页面卡片数量级远小于 36^6,实际碰撞概率可忽略。 - Wiki 索引成本:
collectPostMetas全量读盘并解析 frontmatter,随文章数线性增长;1 秒缓存把同批处理摊销为一次扫描。超大内容库如遇构建瓶颈,这里是首要优化点。 - 样式契约:三个组件都只输出语义结构,视觉完全依赖主题 CSS 的 class 契约(
admonition/bdm-*/gc-*/card-github/fetch-waiting/fetch-error/wlc-*)。改样式不动插件,反之亦然——tests/feed-content.test.ts与tests/markdown-enhancements.test.mjs均以这些 class 作为断言锚点,改名需同步测试。
Extension Points
- 新增 admonition 类型:
AdmonitionComponent的type参数来自指令名,只要 remark-directive 侧允许新指令名,配合 CSS 增加.bdm-{type}样式即可扩展;函数本身对未知type无白名单校验,会直接生成bdm-{type}class。 - 新增卡片槽位:
GithubCardComponent的槽位模式是「占位节点 + id + 脚本内innerText/style赋值」——按此模式追加节点与对应脚本行即可,无需框架或状态管理。 - Wiki 链接解析策略:
resolveMeta是纯函数,三级策略彼此独立;如需支持「按 tag/分类检索」等新解析维度,可在此追加匹配分支,createLink/createCard无需改动。 - 卡片内容裁剪:
createCard中元数据组装(日期 / 分类 / 标签)是独立metaItems数组,增删展示字段即增删对应element(...)调用;加密降级逻辑集中在一处布尔判断,便于审计。
Related Links
- 插件源码:rehype-component-admonition.mjs · rehype-component-github-card.mjs · remark-wiki-link.mjs
- 注册与配置:astro.config.mjs(导入)、astro.config.mjs(条件注册)
- 行为测试:markdown-enhancements.test.mjs · feed-content.test.ts
- 封面解析依赖:
src/utils/post-cover-source.ts(resolvePostCoverSource)与src/utils/image-referrer.ts(matchesNoReferrerDomain) - 许可说明:THIRD_PARTY_NOTICES.md(
remark-wiki-link.mjs的第三方来源声明) - 功能总览:README.md(提示框 / GitHub 卡片 / Wiki Link 的作者视角描述)