Repository Wiki
LyraVoid/Mizuki

交互式内容组件(提示框、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 / password frontmatter 的服务端行为):本页只描述 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)。

Loading diagram...

层级关系说明:

  • 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:

javascript
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

控制流拆解:

  1. 非法输入防御:children 非数组或为空时返回 class="hidden" 的 div,正文里携带一行说明,提示作者块指令的正确写法。之所以用 hidden 而不是抛错,是让一次笔误不会中断整站构建。
  2. 标题三级优先级:properties["has-directive-label"](指令后紧跟的文本被视为标签)> properties.title 指令属性 > type.toUpperCase()(如 NOTE)。当标签是节点对象时取其 children 展开,保证标题内容可包含行内 Markdown。
  3. 结构输出:blockquote.admonition.bdm-{type} + div.bdm-title + 原样保留的子节点。类型只体现在 class 修饰符上,样式与图标交由 CSS 按 bdm-note / bdm-tip 等前缀区分。

类型与产物对照

指令写法type输出 class缺省标题
:::notenoteadmonition bdm-noteNOTE
:::tiptipadmonition bdm-tipTIP
:::importantimportantadmonition bdm-importantIMPORTANT
:::cautioncautionadmonition bdm-cautionCAUTION
:::warningwarningadmonition bdm-warningWARNING

设计意图:把「语义类型」与「视觉样式」解耦——组件只输出结构化语义,配色、图标、圆角等全部由主题 CSS 承担,换主题不需要改插件。

GitHub 卡片实现

占位 + 客户端补全的混合渲染

GithubCardComponent(properties, children) 的核心策略是构建期只产出骨架,运行时再填充数据:

javascript
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 执行的内联脚本:

javascript
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" 的提示块作为产物,而不是抛异常:

javascript
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 体系联动。

语法与解析

核心正则与跳过集合定义了语法的边界:

javascript
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]]:

javascript
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 秒级内存缓存:

javascript
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 解析失败或读盘异常的单篇文章只被跳过,不会让整站构建失败——注释明确写了这条原则。

目标解析采用严格度递减的三级策略:

javascript
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

优先级匹配方式说明
1frontmatter slug 精确等于目标作者显式命名,最高权威
2contentPath 等于目标(含 /index 剥离)目录式文章 dir/index.md 可写成 [[dir]]
3文件名 basename 唯一匹配便捷写法 [[my-post]];多义时警告并放弃,强制作者写完整路径

第三级的多义保护很重要:宁可让链接保持原文本,也不静默链接到错误文章。

postUrl(meta, fallback, options, metas) 的优先级链与站内路由体系完全对齐,保证 Wiki 链接与文章页真实地址一致:

javascript
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,为每个节点分流:

javascript
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,内含封面(可选)与信息区:

javascript
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",防止防盗链站点拒绝加载。

插件入口与开关

javascript
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

以一篇文章同时含三种语法为例,端到端流程如下:

Loading diagram...

渲染期(GitHub 卡片)的分支逻辑:

Loading diagram...

注意该流程图中 fetch-error 路径的关键点:数据加载失败不破坏链接。卡片骨架(owner/repo 标题栏、GitHub logo、https://github.com/{repo} 链接)在构建期就已生成,API 故障只影响数字与描述的填充。

Usage Examples

提示框:块指令 + 自定义标题

来自插件自身的文档注释与校验逻辑,展示合法输入形状(五选一的 type、可选 title 属性):

javascript
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 含 /,这与两个校验守卫一一对应:

markdown
::github{repo="saicaca/fuwari"}

产物骨架(节选自实现):外层 a 的属性直接决定交互行为:

javascript
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 的判定逻辑直接给出两种形态的写作规则:

javascript
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 形状,可作为产物契约参考:

javascript
await remarkWikiLink()(tree, { path: fileURLToPath(

Source: markdown-enhancements.test.mjs

javascript
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)

javascript
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.enablewikiLinkbooleanundefined(视为启用)false 时 remarkWikiLink 直接短路返回
options.enable插件入口booleanundefined(视为启用)同上,宽松判断 enable === false
options.permalink注入自 permalinkConfigobject站点 permalink 配置决定 Wiki 链接 URL 的 %postname% 等模板展开
options.imageApi / options.apiImageswikiLink——透传给 resolvePostCoverSource 解析卡片封面来源
options.noReferrerDomainswikiLinkstring[][]命中域名的外链封面追加 referrerPolicy: "no-referrer"
properties.title提示框指令string类型大写:::note{title="..."} 自定义标题
properties.repoGitHub 卡片指令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。

签名来源:rehype-component-admonition.mjs

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 提示块。

签名来源:rehype-component-github-card.mjs

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

内部关键函数(供扩展时参考)

函数位置职责
normalizeContentPathL28归一化路径、剥 posts/ 前缀、拒绝 .. 穿越
collectPostMetasL54遍历文章目录收集 frontmatter,1 秒 TTL 缓存
resolveMetaL89slug → 路径 → basename 三级目标解析
globalPermalinkL128展开全局 permalink 模板占位符
postUrlL152四级回退生成目标 URL
parseValueL169拆解 page|alias#heading
createLink / createCardL245 / L267生成行内链接 / 卡片节点
replaceInline / transformL334 / 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.random UUID 理论可碰撞,源码注释声明接受该风险;页面卡片数量级远小于 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(...) 调用;加密降级逻辑集中在一处布尔判断,便于审计。