Angular 文档流水线中的 docs-card 自定义 Markdown 扩展:从标记语法到 HTML 渲染全解析
【免费下载链接】angularDeliver web apps with confidence 🚀项目地址: https://gitcode.com/GitHub_Trending/an/angular
本文以 Angular 仓库中 adev(开发者文档站点)构建流水线内的测试夹具文档docs-card.md为主体,完整拆解 Angular 官方文档站点如何基于 marked 扩展机制实现<docs-card>自定义块级标签:包括标签属性语法、tokenizer 正则解析、渲染分支、防嵌套链接机制与测试验证方式。读完后,你可以理解 adev 文档流水线中"Markdown → 自定义 HTML 组件化结构"的完整链路,并掌握在同类文档系统中扩展自定义块级标记的方法。
原始文档内容:docs-card 的四种标准形态
本文的主体文档位于 docs-card.md。它本身是 marked 扩展的测试夹具(test fixture),共 8 行,却恰好覆盖了docs-card扩展的全部四种典型用法。原文完整内容如下:
<docs-card title="No Link Card">Card Content</docs-card> <docs-card title="Link Card" link="Try It Now" href="in/app/link"> Card Content with a symbol: `CommonModule` </docs-card> <docs-card title="Image Card" imgSrc="./angular.svg"></docs-card> <docs-card title="" link="Open on Playground" href="/playground"> The fastest way to play with an Angular app. No setup required. </docs-card>逐条拆解这四种形态,它们分别对应渲染器里的不同代码分支:
| 形态 | 属性组合 | 说明 |
|---|---|---|
| 无链接卡片 | 仅title | 纯展示卡片,渲染为普通<div class="docs-card">,不产生<a>标签 |
| 链接卡片 | title+link+href | 整个卡片变为可点击的<a>,卡片尾部显示link文案(此处为 "Try It Now"),卡片正文中还嵌入了行内代码`CommonModule` |
| 图片卡片 | title+imgSrc | 卡片头部内联一张 SVG 插图(此处为同目录下的angular.svg),无卡片正文 |
| 空标题卡片 | title=""+link+href | 标题为空字符串时不生成<h3>,仅渲染正文与链接文案,指向/playground |
这个夹具文档虽然只有 8 行,但它就是docs-card扩展的行为契约:每一种写法都严格对应一个自动化测试用例(后文详述)。
扩展注册:docs-card 如何接入 marked 解析链
adev 流水线中所有自定义文档标记都注册在 parse.mts 的扩展列表里。docsCardExtension与docsCardContainerExtension被加入 marked 的extensions数组,并随marked.use({extensions, walkTokens})统一启用:
const extensions = [ docsImageExtension, docsAlertExtension, // ... docsCardExtension, docsCardContainerExtension, // ... ]; export function parseMarkdown(markdownContent: string, context: Partial<RendererContext>): string { validatePairedTags(markdownContent, context.markdownFilePath); markedInstance ??= marked.use({extensions, walkTokens}); return markedInstance.parse(markdownContent, {renderer: new AdevDocsRenderer(context)}) as string; }值得注意的两个细节:
level: 'block':在 docs-card.mts 中,docsCardExtension声明level: 'block' as const,即它作为块级标记参与解析。这也解释了为什么夹具文档中的<docs-card>必须独占块级位置(前后留空行),且卡片内部正文会被单独再次按块级 token 解析。- 配对标签校验:解析入口先执行
validatePairedTags(markdownContent, context.markdownFilePath)(来自 validate-paired-tags.mts),在解析阶段就保证<docs-card>与</docs-card>成对出现,避免运行时才暴露书写错误。
Tokenizer:属性如何从开标签中被正则提取
扩展的核心解析逻辑定义在 docs-card.mts。tokenizer 首先用一个正则整体匹配卡片:
// Capture group 1: all attributes on the opening tag // Capture group 2: all content between the open and close tags const cardRule = /^\s*<docs-card(?:\s([^>]*))?>((?:.(?!\/docs-card))*)<\/docs-card>/s;这个正则有两个关键设计:
- 捕获组 1
([^>]*)抓取开标签上的全部属性串(title="..." link="..." href="..."等); - 捕获组 2 使用否定前瞻
(?!\/docs-card)逐字符消费内容,直到遇到</docs-card>,配合/s修饰符使.可以跨行匹配——这正是夹具文档中第二、四个卡片能跨行书写正文的原因。
随后,每个属性都由独立的子正则从属性串中逐项提取:
const titleRule = /title="([^"]*)"/; const linkRule = /link="([^"]*)"/; const hrefRule = /href="([^"]*)"/; const imgSrcRule = /imgSrc="([^"]*)"/; const iconImgSrcRule = /iconImgSrc="([^"]*)"/; const titleInlineRule = /(?:^|\s)titleInline(?=\s|$|=)/;对应地,DocsCardToken结构承载解析结果:
interface DocsCardToken extends Tokens.Generic { type: 'docs-card'; title: string; body: string; link?: string; href?: string; imgSrc?: string; iconImgSrc?: string; // Need image since icons are custom titleInline?: boolean; tokens: Token[]; }由此可以整理出docs-card的完整属性表(比原始文档仅展示的title/link/href/imgSrc更完整):
| 属性 | 类型 | 作用 | 是否影响标签结构 |
|---|---|---|---|
title | 字符串 | 卡片标题,渲染为<h3>;为空字符串时跳过<h3>生成 | 否 |
link | 字符串 | 卡片底部的操作文案(如 "Try It Now");有href时缺省文案为 "Learn more" | 否 |
href | 字符串 | 存在时整张卡片渲染为<a>;外链(以http开头)自动追加target="_blank" | 是 |
imgSrc | 字符串 | 卡片头部的 SVG 插图路径,走"SVG 插图卡片"渲染分支 | 是 |
iconImgSrc | 字符串 | 卡片标题旁的自定义 SVG 图标(须与href同用),内联 SVG 以支持 CSS 变量换肤 | 否 |
titleInline | 布尔(存在即真) | 图标与标题同行排版(包裹在docs-card-header-inline容器中) | 否 |
tokenizer 的最后一步是把卡片正文交给 lexer 递归解析成块级 token 子树:
const body = match[2].trim(); // ... this.lexer.blockTokens(token.body, token.tokens);这一步解释了为什么夹具第二张卡片里的`CommonModule`会被正常解析为行内代码——卡片正文并非简单文本,而是完整的 marked 文档片段。
渲染器:三个标准分支 + 一个 SVG 插图分支
docsCardExtension.renderer根据 token 决定走哪条渲染路径:
renderer(this: RendererThis, token: DocsCardToken) { return token.imgSrc ? getCardWithSvgIllustration(this, token) : getStandardCard(this.parser.renderer as AdevDocsRenderer, token); }标准卡片(getStandardCard)
按"是否带图标"和"是否带链接"划分为三个分支,与夹具文档的四张卡片一一对应:
iconImgSrc+href:先用 helpers.mts 的loadWorkspaceRelativeFile把 SVG 文件内容从磁盘读进来内联(源码注释说明了动机:不渲染成<img>而是内联 SVG,是为了用 CSS 变量支持深色/浅色主题切换),再输出带标题头部的<a class="docs-card">。- 仅
href(对应夹具的 "Link Card" 与空标题的 "Playground" 卡片):输出<a>,title为空时省略<h3>,正文通过parseWithoutCreatingLinks解析,底部<span>显示link文案或默认 "Learn more"。 - 无
href(对应 "No Link Card"):输出普通<div class="docs-card">,link属性仅在存在时才渲染为<span>。
SVG 插图卡片(getCardWithSvgIllustration)
对应夹具第三张 "Image Card"。插图通过loadWorkspaceRelativeFile(token.imgSrc!)内联进 HTML,卡片结构为:
<a href="..." class="docs-card docs-card-with-svg"> {内联的 SVG} <div class="docs-card-text-content"> <h3>标题</h3> {正文} <span>操作文案</span> </div> </a>夹具中imgSrc="./angular.svg"指向的就是同目录下的测试资产 angular.svg。
防嵌套链接机制
HTML 规范不允许<a>内再嵌套<a>。当卡片本身是链接时,正文里出现的行内链接或自动链接必须被"降级"。实现方式是借助渲染器上下文开关:
function parseWithoutCreatingLinks(renderer: AdevDocsRenderer, token: DocsCardToken) { renderer.context.disableAutoLinking = true; const parsed = renderer.parser.parse(token.tokens); renderer.context.disableAutoLinking = false; return parsed; }disableAutoLinking是 renderer.mts 中RendererContext的一个布尔字段,真正消费它的地方在 transformations/link.mts:当该开关为真时,链接转换逻辑会阻止生成新的<a>标签。这个模式同样被标题锚点转换复用(见 transformations/heading.mts),说明它是整条流水线中"临时抑制链接生成"的通用手段。
外链 target 处理
所有会生成<a>的分支都调用anchorTarget(token.href),该函数(定义在 helpers.mts)判断href是否以http开头,是则追加target="_blank",保证外链在新标签页打开而站内链接在当前页跳转。
测试契约:四种形态逐一断言
docs-card.spec.mts 用 JSDOM 加载夹具文档的解析结果,四个测试用例与文档四行(四张卡片)严格一一对应:
it('creates cards with no links', () => { const cardEl = markdownDocument.querySelectorAll('.docs-card')[0]; expect(cardEl.querySelector('h3')?.textContent?.trim()).toBe('No Link Card'); expect(cardEl.tagName).not.toBe('A'); }); it('creates cards with links', () => { const cardEl = markdownDocument.querySelectorAll('.docs-card')[1]; expect(cardEl.tagName).toBe('A'); expect(cardEl.getAttribute('href')).toBe('in/app/link'); }); it('should not create nested links', () => { const cardEl = markdownDocument.querySelectorAll('.docs-card')[1]; expect(cardEl.querySelectorAll('a').length).toBe(0); }); it('creates cards with svg images', () => { const cardEl = markdownDocument.querySelectorAll('.docs-card')[2]; expect(cardEl.querySelector('svg')).toBeTruthy(); }); it('does not create empty h3 tags when title is empty', () => { const cardEl = markdownDocument.querySelectorAll('.docs-card')[3]; expect(cardEl.querySelector('h3')).toBeNull(); });这套断言恰好验证了本文前面讲解的全部关键行为:
- 卡片 0:
<h3>文案正确、根节点不是<a>——对应无链接分支; - 卡片 1:根节点是
<a>且href原样透传(in/app/link)——对应链接分支; - 卡片 1 附加断言:卡片内部
<a>数量为 0——验证parseWithoutCreatingLinks的防嵌套链接机制; - 卡片 2:内部存在内联
<svg>元素——验证imgSrc走的是内联 SVG 而非<img>; - 卡片 3:
title=""时querySelector('h3')为null——验证空标题不产生空标签。
测试通过 Bazel 目标组织(见 BUILD.bazel),在仓库的 Bazel 测试体系中运行,与整条 adev 流水线共用同一套构建入口。
容器层:docs-card-container 的网格布局
单张卡片之外,docs-card-container.mts 提供了外层容器标签,其 tokenizer 结构(整体正则 + 属性子正则 +blockTokens递归解析)与docs-card完全同构,但只支持headerTitle与headerImgSrc两个属性:
- 无
headerTitle:卡片列表包在<div class="docs-card-grid">中; - 有
headerTitle:额外生成.docs-card-container-wrapper,内含.docs-card-container-header(标题 + 可选头部 SVG)与.docs-card-container-content.docs-card-grid两层结构。
其测试夹具 docs-card-container.md 演示了容器中嵌套多张docs-card的完整文档流写法(容器前后穿插普通标题、列表与段落),与 docs-card-container.spec.mts 配套验证。
样式层:卡片网格如何呈现
渲染产物中出现的docs-card-grid、docs-card、docs-card-container-wrapper等类名,由 styles/docs/_card.scss 中的docs-card()mixin 定义外观:
.docs-card-grid采用display: grid; grid-template-columns: repeat(2, 1fr)的双列网格布局,窄容器(@container docs-content (max-width: 450px))下自动降级为单列;.docs-card-container-wrapper带边框、圆角与交替渐变背景(奇数容器用白到浅蓝的斜向渐变,偶数用白到浅粉的渐变);- 头部 SVG 中的
theme-fill-*/theme-stroke-*类通过 CSS 变量着色——这正是渲染器坚持内联 SVG 而非<img>的原因,让插图可以随主题变量在明暗模式下换色。
小结:一条"标签 → token → HTML"的完整链路
回到本文主体文档 docs-card.md 的 8 行内容,它实际上定义了<docs-card>扩展的行为边界。完整链路可以概括为:
- 书写:作者在 adev 文档 Markdown 中使用
<docs-card title="..." link="..." href="..." imgSrc="...">块级标签; - 校验:
validatePairedTags保证开闭标签配对; - 分词:
docsCardExtension.tokenizer用整体正则 + 属性子正则提取出DocsCardToken,正文经blockTokens递归解析; - 渲染:按
imgSrc/iconImgSrc/href的组合选择渲染分支,外链自动加target="_blank",链接卡片正文经disableAutoLinking防嵌套; - 呈现:
_card.scss提供双列网格、容器头部与主题化 SVG 着色; - 验证:docs-card.spec.mts 对四种形态逐张断言,夹具文档即测试输入。
这条"正则分词 → token 树 → 分支渲染 → 样式类落地 → 测试契约"的扩展范式,同样适用于流水线中的docs-alert、docs-code、docs-workflow等其余自定义标记(它们的实现与测试均位于 marked 扩展目录 和 marked 测试目录),是理解 Angular 官方文档站点内容系统的一把钥匙。
【免费下载链接】angularDeliver web apps with confidence 🚀项目地址: https://gitcode.com/GitHub_Trending/an/angular
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考