ttrpg-tools/src/markdown/index.ts

160 lines
5.4 KiB
TypeScript
Raw Normal View History

2026-03-21 17:50:47 +08:00
import { Marked, type MarkedExtension, type Tokens } from 'marked';
2026-02-27 17:54:09 +08:00
import {createDirectives, presetDirectiveConfigs} from 'marked-directive';
2026-02-28 11:42:42 +08:00
import yaml from 'js-yaml';
2026-02-28 13:58:51 +08:00
import markedAlert from "marked-alert";
2026-03-02 11:03:51 +08:00
import markedMermaid from "./mermaid";
2026-03-02 13:39:36 +08:00
import {gfmHeadingId} from "marked-gfm-heading-id";
2026-02-26 00:17:23 +08:00
2026-03-13 11:14:01 +08:00
let globalIconPrefix: string | undefined = undefined;
function overrideIconPrefix(path?: string){
globalIconPrefix = path;
return {
[Symbol.dispose](){
globalIconPrefix = undefined;
}
}
}
2026-03-21 17:50:47 +08:00
/**
* CSV
* @param headers
* @param rows
* @returns CSV
*/
function tableToCSV(headers: string[], rows: string[][]): string {
const escapeCell = (cell: string) => {
// 如果单元格包含逗号、换行或引号,需要转义
if (cell.includes(',') || cell.includes('\n') || cell.includes('"')) {
return `"${cell.replace(/"/g, '""')}"`;
}
return cell;
};
const headerLine = headers.map(escapeCell).join(',');
const dataLines = rows.map(row => row.map(escapeCell).join(','));
return [headerLine, ...dataLines].join('\n');
}
2026-02-26 00:47:26 +08:00
// 使用 marked-directive 来支持指令语法
2026-02-28 13:58:51 +08:00
const marked = new Marked()
2026-03-02 13:39:36 +08:00
.use(gfmHeadingId())
2026-02-28 13:58:51 +08:00
.use(markedAlert())
2026-03-02 11:03:51 +08:00
.use(markedMermaid())
2026-02-28 13:58:51 +08:00
.use(createDirectives([
2026-02-27 17:54:09 +08:00
...presetDirectiveConfigs,
{
marker: '::::',
level: 'container'
},
{
marker: ':::::',
level: 'container'
},
{
level: 'inline',
marker: ':',
// :[blah] becomes <i class="icon icon-blah"></i>
renderer(token) {
if (!token.meta.name) {
2026-03-13 11:46:18 +08:00
const style = globalIconPrefix ? `style="--icon-src: url('${globalIconPrefix}/${token.text}.png')"` : '';
2026-03-13 11:14:01 +08:00
return `<icon ${style} class="icon-${token.text}"></icon>`;
2026-02-27 17:54:09 +08:00
}
return false;
}
},
2026-02-28 11:42:42 +08:00
]), {
// 自定义代码块渲染器,支持 yaml/tag 格式
extensions: [{
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() || '';
const props = yaml.load(yamlContent) as Record<string, unknown> || {};
2026-03-21 17:50:47 +08:00
2026-02-28 11:42:42 +08:00
// 提取 tag 名称,默认为 tag-unknown
const tagName = (props.tag as string) || 'tag-unknown';
2026-03-21 17:50:47 +08:00
2026-02-28 11:42:42 +08:00
// 移除 tag 属性,剩下的作为 HTML 属性
const { tag, ...rest } = props;
2026-03-21 17:50:47 +08:00
2026-02-28 11:42:42 +08:00
// 提取 innerText 内容(如果有 body 字段)
let content = '';
if ('body' in rest) {
content = String(rest.body || '');
delete (rest as Record<string, unknown>).body;
}
2026-03-21 17:50:47 +08:00
2026-02-28 11:42:42 +08:00
// 构建属性字符串
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(' ');
2026-03-21 17:50:47 +08:00
2026-02-28 11:42:42 +08:00
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`;
}
}]
});
2026-02-26 00:17:23 +08:00
2026-03-21 17:50:47 +08:00
// 覆盖默认的 table renderer 以支持自动转换
marked.use({
renderer: {
table(token: Tokens.Table) {
// 检查表头是否包含 md-table-label
const header = token.header;
const labelIndex = header.findIndex(cell => cell.text === 'md-table-label');
if (labelIndex !== -1) {
// 将 md-table-label 列转换为 label
const headers = header.map(cell => cell.text === 'md-table-label' ? 'label' : cell.text);
// 转换所有行
const rows = token.rows.map(row =>
row.map(cell => cell.text)
);
// 生成 CSV 数据
const csvData = tableToCSV(headers, rows);
// 渲染为 md-table 组件,内联 CSV 数据
return `<md-table>${csvData}</md-table>\n`;
}
// 默认表格渲染 - 使用 marked 默认行为
return false;
}
}
} as MarkedExtension);
2026-03-13 11:14:01 +08:00
export function parseMarkdown(content: string, iconPrefix?: string): string {
using prefix = overrideIconPrefix(iconPrefix);
return marked.parse(content.trimStart()) as string;
2026-02-26 00:17:23 +08:00
}
export { marked };