feat: add custom YAML tag code block extension

This commit is contained in:
hypercross 2026-05-11 12:05:19 +08:00
parent a19d848456
commit 1582191655
2 changed files with 144 additions and 127 deletions

View File

@ -0,0 +1,62 @@
import type { MarkedExtension } from "marked";
import yaml from "js-yaml";
export default function markedCodeBlockYamlTag(): MarkedExtension {
return {
extensions: [
{
name: "code-block-yaml-tag",
level: "block",
start(src: string) {
return src.match(/^```yaml\/tag\s*\n/m)?.index;
},
tokenizer(src: string) {
const rule = /^```yaml\/tag\s*\n([\s\S]*?)\n```/;
const match = rule.exec(src);
if (match) {
const yamlContent = match[1]?.trim() || "";
let props: Record<string, unknown> = {};
try {
props =
(yaml.load(yamlContent) as Record<string, unknown>) || {};
} catch (e) {
console.error("YAML Parse Error in code-block-yaml-tag:", e);
props = { error: "Invalid YAML content" };
}
const tagName = (props.tag as string) || "tag-unknown";
const { tag, ...rest } = props;
let content = "";
if ("body" in rest) {
content = String(rest.body || "");
delete (rest as Record<string, unknown>).body;
}
const propsStr = Object.entries(rest)
.map(([key, value]) => {
const strValue = String(value);
if (strValue.includes(" ") || strValue.includes('"')) {
return `${key}="${strValue.replace(/"/g, "&quot;")}"`;
}
return `${key}="${strValue}"`;
})
.join(" ");
return {
type: "code-block-yaml-tag",
raw: match[0],
tagName,
props: propsStr,
content,
};
}
},
renderer(token: any) {
const propsAttr = token.props ? ` ${token.props}` : "";
return `<${token.tagName}${propsAttr}>${token.content || ""}</${token.tagName}>\n`;
},
},
],
};
}

View File

@ -1,148 +1,103 @@
import { Marked, type MarkedExtension } from 'marked'; import { Marked, type MarkedExtension } from "marked";
import {createDirectives, presetDirectiveConfigs} from 'marked-directive'; import { createDirectives, presetDirectiveConfigs } from "marked-directive";
import yaml from 'js-yaml';
import markedAlert from "marked-alert"; import markedAlert from "marked-alert";
import markedMermaid from "./mermaid"; import markedMermaid from "./mermaid";
import markedTable from "./table"; import markedTable from "./table";
import {gfmHeadingId} from "marked-gfm-heading-id"; import { gfmHeadingId } from "marked-gfm-heading-id";
import markedColumns from "./columns"; import markedColumns from "./columns";
import markedCodeBlockYamlTag from "./code-block-yaml-tag";
let globalIconPrefix: string | undefined = undefined; let globalIconPrefix: string | undefined = undefined;
function overrideIconPrefix(path?: string){ function overrideIconPrefix(path?: string) {
globalIconPrefix = path; globalIconPrefix = path;
return { return {
[Symbol.dispose](){ [Symbol.dispose]() {
globalIconPrefix = undefined; globalIconPrefix = undefined;
} },
} };
} }
// 使用 marked-directive 来支持指令语法 // 使用 marked-directive 来支持指令语法
const marked = new Marked() const marked = new Marked()
.use(gfmHeadingId()) .use(gfmHeadingId())
.use(markedAlert()) .use(markedAlert())
.use(markedMermaid()) .use(markedMermaid())
.use(markedTable()) .use(markedTable())
.use(createDirectives([ .use(markedCodeBlockYamlTag())
...presetDirectiveConfigs, .use(
{ createDirectives([
marker: '::::', ...presetDirectiveConfigs,
level: 'container' {
}, marker: "::::",
{ level: "container",
marker: ':::::', },
level: 'container' {
}, marker: ":::::",
{ level: "container",
level: 'inline', },
marker: ':', {
level: "inline",
marker: ":",
// :[icon] 或 :[icon.ext] 语法 // :[icon] 或 :[icon.ext] 语法
// 支持的扩展名: .svg, .png, .gif, .jpg, .jpeg, .webp // 支持的扩展名: .svg, .png, .gif, .jpg, .jpeg, .webp
// 如果不指定扩展名,默认为 .png // 如果不指定扩展名,默认为 .png
renderer(token) { renderer(token) {
if (!token.meta.name) { if (!token.meta.name) {
const iconText = token.text || ''; const iconText = token.text || "";
// 已知支持的图片扩展名 // 已知支持的图片扩展名
const supportedExtensions = ['svg', 'png', 'gif', 'jpg', 'jpeg', 'webp']; const supportedExtensions = [
"svg",
// 检查是否包含扩展名(查找最后一个点) "png",
let iconName = iconText; "gif",
let extension = 'png'; // 默认扩展名 "jpg",
"jpeg",
const lastDotIndex = iconName.lastIndexOf('.'); "webp",
if (lastDotIndex > 0) { ];
const potentialExt = iconName.slice(lastDotIndex + 1).toLowerCase();
if (supportedExtensions.includes(potentialExt)) { // 检查是否包含扩展名(查找最后一个点)
extension = potentialExt; let iconName = iconText;
iconName = iconName.slice(0, lastDotIndex); let extension = "png"; // 默认扩展名
}
} const lastDotIndex = iconName.lastIndexOf(".");
if (lastDotIndex > 0) {
const label = token.attrs?.label as string | undefined; const potentialExt = iconName
const inner = label ? `<span class="icon-label-stroke">${label}</span><span class="icon-label">${label}</span>` : ""; .slice(lastDotIndex + 1)
const style = globalIconPrefix ? `style="--icon-src: url('${globalIconPrefix}/${iconName}.${extension}')"` : ''; .toLowerCase();
const iconHtml = `<icon ${style} class="icon-${iconName} ${token.attrs?.class || ""}">${inner}</icon>`; if (supportedExtensions.includes(potentialExt)) {
extension = potentialExt;
const repeat = parseInt(`${token.attrs?.repeat || ''}`); iconName = iconName.slice(0, lastDotIndex);
const join = token.attrs?.join || ''; }
const separator = join ? `<${join}></${join}>` : '';
if(isNaN(repeat) || repeat < 1) return iconHtml;
return Array(repeat).fill(iconHtml).join(separator);
} }
return false;
} const label = token.attrs?.label as string | undefined;
}, const inner = label
]), { ? `<span class="icon-label-stroke">${label}</span><span class="icon-label">${label}</span>`
extensions: [ : "";
...markedColumns(), const style = globalIconPrefix
? `style="--icon-src: url('${globalIconPrefix}/${iconName}.${extension}')"`
: "";
const iconHtml = `<icon ${style} class="icon-${iconName} ${token.attrs?.class || ""}">${inner}</icon>`;
const repeat = parseInt(`${token.attrs?.repeat || ""}`);
const join = token.attrs?.join || "";
const separator = join ? `<${join}></${join}>` : "";
if (isNaN(repeat) || repeat < 1) return iconHtml;
return Array(repeat).fill(iconHtml).join(separator);
}
return false;
},
},
]),
{ {
// 自定义代码块渲染器,支持 yaml/tag 格式 extensions: [...markedColumns()],
name: 'code-block-yaml-tag', },
level: 'block', );
start(src: string) {
// 检测 ```yaml/tag 开头的代码块
return src.match(/^```yaml\/tag\s*\n/m)?.index;
},
tokenizer(src: string) {
const rule = /^```yaml\/tag\s*\n([\s\S]*?)\n```/;
const match = rule.exec(src);
if (match) {
const yamlContent = match[1]?.trim() || '';
let props: Record<string, unknown> = {};
try {
props = (yaml.load(yamlContent) as Record<string, unknown>) || {};
} catch (e) {
console.error("YAML Parse Error in code-block-yaml-tag:", e);
props = { error: "Invalid YAML content" };
}
// 提取 tag 名称,默认为 tag-unknown
const tagName = (props.tag as string) || 'tag-unknown';
// 移除 tag 属性,剩下的作为 HTML 属性
const { tag, ...rest } = props;
// 提取 innerText 内容(如果有 body 字段)
let content = '';
if ('body' in rest) {
content = String(rest.body || '');
delete (rest as Record<string, unknown>).body;
}
// 构建属性字符串
const propsStr = Object.entries(rest)
.map(([key, value]) => {
const strValue = String(value);
// 如果值包含空格或特殊字符,添加引号
if (strValue.includes(' ') || strValue.includes('"')) {
return `${key}="${strValue.replace(/"/g, '&quot;')}"`;
}
return `${key}="${strValue}"`;
})
.join(' ');
return {
type: 'code-block-yaml-tag',
raw: match[0],
tagName,
props: propsStr,
content
};
}
},
renderer(token: any) {
// 渲染为自定义 HTML 标签
const propsAttr = token.props ? ` ${token.props}` : '';
return `<${token.tagName}${propsAttr}>${token.content || ''}</${token.tagName}>\n`;
}
}]
});
export function parseMarkdown(content: string, iconPrefix?: string): string { export function parseMarkdown(content: string, iconPrefix?: string): string {
using prefix = overrideIconPrefix(iconPrefix); using prefix = overrideIconPrefix(iconPrefix);
return marked.parse(content.trimStart()) as string; return marked.parse(content.trimStart()) as string;
} }
export { marked }; export { marked };