diff --git a/packages/markdown-parser/src/core/parser.ts b/packages/markdown-parser/src/core/parser.ts index d244548..c105f8a 100644 --- a/packages/markdown-parser/src/core/parser.ts +++ b/packages/markdown-parser/src/core/parser.ts @@ -3,6 +3,7 @@ import { BlockState, InlineState } from './state'; import { linkify } from '@fuyeor/linkify'; import type { ASTNode, + ASTTransform, BlockRule, InlineRule, Linkifier, @@ -23,6 +24,7 @@ function linkifyWithCandidateCheck(text: string): ReturnType { export class MarkdownParser { #blockRuleMap = new Map(); #inlineRuleMap = new Map(); + #astTransforms: ASTTransform[] = []; #idSequence = 0; #isPreflight = false; readonly #maxNestingDepth: number; @@ -82,11 +84,21 @@ export class MarkdownParser { return this; } + // Register a post-parse AST transform for extensions such as Twemoji. + addAstTransform(transform: ASTTransform): this { + this.#astTransforms.push(transform); + return this; + } + build(): (content: string) => ASTNode[] { return (content: string) => { this.#idSequence = 0; const state = new BlockState(content); - return this.#parseBlocks(state); + const nodes = this.#parseBlocks(state); + return this.#astTransforms.reduce( + (currentNodes, transform) => transform(currentNodes), + nodes, + ); }; } @@ -165,7 +177,9 @@ export class MarkdownParser { // ` for code block and inline code firstChar === 96 || // ~ equals ` - firstChar === 126; + firstChar === 126 || + // $ for block math + firstChar === 36; if (mayInterrupt) { const rules = this.#blockRuleMap.get( @@ -182,6 +196,7 @@ export class MarkdownParser { 'blockquote', 'list', 'code_block', + 'math_block', 'ffm_blocks', ].includes(rule.name) ) { diff --git a/packages/markdown-parser/src/core/render.ts b/packages/markdown-parser/src/core/render.ts index dc00400..5ce79d7 100644 --- a/packages/markdown-parser/src/core/render.ts +++ b/packages/markdown-parser/src/core/render.ts @@ -31,6 +31,29 @@ const getTableAlignment = (align: unknown) => ? ` align="${align}"` : ''; +const displayLanguageNames: Record = { + javascript: 'JavaScript', + typescript: 'TypeScript', + cpp: 'C++', + csharp: 'C#', + xml: 'XML', + json: 'JSON', + yaml: 'YAML', + ffm: 'Fuyeor Flavored Markdown', + fon: 'FON', + fer: 'Fer', +}; + +// Render a stable fenced-code contract for asynchronous highlight.js processing. +const getCodeLanguage = (lang: unknown): string => { + if (typeof lang !== 'string') return ''; + return lang.trim().split(/\s+/u)[0] ?? ''; +}; + +const getDisplayLanguageName = (language: string): string => + displayLanguageNames[language] ?? + (language ? language.charAt(0).toUpperCase() + language.slice(1) : 'Text'); + export function render(nodes?: ASTNode[]): string { let html = ''; @@ -52,6 +75,19 @@ export function render(nodes?: ASTNode[]): string { case 'text': html += node.content ? escapeHtml(node.content) : ''; break; + case 'emoji': { + const emoji = String(node.content ?? ''); + const alt = String(node.alt ?? emoji); + const src = String(node.src ?? ''); + html += `${escapeHtml(alt)}`; + break; + } + case 'math_inline': + html += `${escapeHtml(String(node.content ?? ''))}`; + break; + case 'math_block': + html += `
${escapeHtml(String(node.content ?? ''))}
\n`; + break; case 'bold': html += `${render(node.children)}`; break; @@ -83,13 +119,19 @@ export function render(nodes?: ASTNode[]): string { : render(node.children); break; } - case 'code_block': + case 'code_block': { + const language = getCodeLanguage(node.lang); + const displayLanguage = getDisplayLanguageName(language); + const preAttributes = language + ? `class="hljs language-${escapeHtml(language)}" data-language="${escapeHtml(language)}"` + : 'class="hljs language-plaintext"'; html += `
`; - if (node.lang) { - html += `
${escapeHtml(node.lang)}
`; + if (language) { + html += `
${escapeHtml(displayLanguage)}
`; } - html += `
${node.content ? escapeHtml(node.content) : ''}\n
\n`; + html += `
${escapeHtml(String(node.content ?? ''))}\n
\n`; break; + } case 'list': { const tag = node.ordered ? 'ol' : 'ul'; const start = @@ -102,6 +144,26 @@ export function render(nodes?: ASTNode[]): string { case 'list_item': html += `
  • ${render(node.children)}
  • \n`; break; + case 'mermaid': + html += `
    ${escapeHtml(String(node.content ?? ''))}
    \n`; + break; + case 'abc': + html += `
    ${escapeHtml(String(node.content ?? ''))}
    \n`; + break; + case 'smiles_inline': + html += ``; + break; + case 'smiles_block': { + const blocks = String(node.content ?? '') + .split('\n') + .filter((line) => line.trim().length > 0) + .map( + (line) => + `
    `, + ); + html += blocks.length > 0 ? `${blocks.join('\n')}\n` : ''; + break; + } case 'table': { html += '\n'; if (node.headers) { diff --git a/packages/markdown-parser/src/default.ts b/packages/markdown-parser/src/default.ts index 8c5ac97..e44aa12 100644 --- a/packages/markdown-parser/src/default.ts +++ b/packages/markdown-parser/src/default.ts @@ -19,6 +19,8 @@ import { strikeRule, } from './rules/inlines'; import { ffmBlockRule } from './rules/ffm'; +import { latexPlugin } from './plugins/latex'; +import { extensionsPlugin } from './plugins/extensions'; export function createMarkdownParser(options: MarkdownParserOptions = {}) { return ( @@ -57,6 +59,8 @@ export function createFuyeorMarkdownParser( .addBlockRule(tableRule) .addBlockRule(hrRule) .addBlockRule(blockquoteRule) + .use(latexPlugin) + .use(extensionsPlugin) // inline order: Inline code -> Links -> Bold/Underline/Strikethrough .addInlineRule(hardBreakRule) diff --git a/packages/markdown-parser/src/index.spec.ts b/packages/markdown-parser/src/index.spec.ts index 7cf9cec..33c9a33 100644 --- a/packages/markdown-parser/src/index.spec.ts +++ b/packages/markdown-parser/src/index.spec.ts @@ -3,6 +3,7 @@ import { describe, it, expect } from 'vitest'; import { MarkdownParser } from './core/parser'; import { createFuyeorMarkdownParser } from './default'; import { render } from './core/render'; +import { latexPlugin } from './plugins/latex'; import { headingRule, codeBlockRule, tableRule } from './rules/blocks'; import { boldRule, linkRule } from './rules/inlines'; @@ -67,6 +68,103 @@ describe('test @fuyeor/markdown-parser', () => { }); }); + it('registers LaTeX as an explicit parser plugin', () => { + const latexParse = new MarkdownParser().use(latexPlugin).build(); + const ast = latexParse('The formula is $x^2$.'); + + expect(ast[0].children).toEqual([ + { type: 'text', content: 'The formula is ' }, + { type: 'math_inline', content: 'x^2' }, + { type: 'text', content: '.' }, + ]); + }); + + it('renders inline and multiline block LaTeX through the FFM parser', () => { + const ast = createFuyeorMarkdownParser()( + 'Inline $x^2$ formula.\n\n$$\nx^2 + y^2\n$$', + ); + + expect(ast.map((node) => node.type)).toEqual(['paragraph', 'math_block']); + expect(render(ast)) + .toBe(`

    Inline x^2 formula.

    +
    x^2 + y^2
    +`); + }); + + it('escapes LaTeX placeholders and leaves unmatched delimiters as text', () => { + const ast = createFuyeorMarkdownParser()( + '$$ and $unfinished', + ); + + expect(render(ast)).toBe( + '

    <script>alert(1)</script> and $unfinished

    \n', + ); + }); + + it('renders Twemoji while preserving inline code', () => { + const html = render(createFuyeorMarkdownParser()('Hello 😀 and `😀`.')); + + expect(html).toBe( + '

    Hello 😀 and 😀.

    \n', + ); + }); + + it('renders FFM Smiles inline placeholders safely', () => { + const html = render( + createFuyeorMarkdownParser()( + 'Molecule #[smiles = `C=C`] and #[smiles=``]', + ), + ); + + expect(html).toContain( + '', + ); + expect(html).toContain( + '', + ); + }); + + it('renders Mermaid, ABC, and fenced Smiles placeholders', () => { + const ast = createFuyeorMarkdownParser()( + '```mermaid\ngraph TD\nA-->B\n```\n```abc\nX:1\nK:C\n```\n```smiles\nC=C\nCCO\n```', + ); + const html = render(ast); + + expect(ast.map((node) => node.type)).toEqual([ + 'mermaid', + 'abc', + 'smiles_block', + ]); + expect(html).toContain( + '
    graph TD\nA-->B
    ', + ); + expect(html).toContain('
    X:1\nK:C
    '); + expect(html).toContain( + '
    \n
    ', + ); + }); + + it('renders highlight.js-compatible language placeholders', () => { + const html = render( + createFuyeorMarkdownParser()( + '```js\nconst value = 1;\n```\n```fon\nvalue = 1\n```\n```fer\nvalue = 1\n```\n```ffm\n# Title\n```', + ), + ); + + expect(html).toContain( + '
    ',
    +    );
    +    expect(html).toContain(
    +      '
    ',
    +    );
    +    expect(html).toContain(
    +      '
    ',
    +    );
    +    expect(html).toContain(
    +      '
    ',
    +    );
    +  });
    +
       it('parse table', () => {
         const content = `
     | title 1 | title 2 |
    diff --git a/packages/markdown-parser/src/index.ts b/packages/markdown-parser/src/index.ts
    index aaa247b..1414985 100644
    --- a/packages/markdown-parser/src/index.ts
    +++ b/packages/markdown-parser/src/index.ts
    @@ -19,11 +19,37 @@ export {
       underlineRule,
       strikeRule,
     } from './rules/inlines';
    +export { latexPlugin, latexInlineRule, latexBlockRule } from './plugins/latex';
    +export {
    +  twemojiPlugin,
    +  nativeEmojiPlugin,
    +  twemojiTransform,
    +  transformTwemojiNode,
    +  getTwemojiUrl,
    +} from './plugins/twemoji';
    +export { highlightPlugin, highlightTransform } from './plugins/highlight';
    +export {
    +  abcPlugin,
    +  abcTransform,
    +  mermaidPlugin,
    +  mermaidTransform,
    +  smilesBlockPlugin,
    +  smilesBlockTransform,
    +  smilesInlinePlugin,
    +  smilesPlugin,
    +  smilesInlineRule,
    +  SPECIAL_BLOCK_LANGUAGES,
    +  specialBlockPlugin,
    +  specialBlockTransform,
    +  transformSpecialBlockNode,
    +} from './plugins/async-blocks';
    +export { extensionsPlugin, extensionsTransform } from './plugins/extensions';
     export { createMarkdownParser, createFuyeorMarkdownParser } from './default';
     
     export type {
       NodeType,
       ASTNode,
    +  ASTTransform,
       ParserContext,
       BlockRule,
       InlineRule,
    diff --git a/packages/markdown-parser/src/plugins/ast.ts b/packages/markdown-parser/src/plugins/ast.ts
    new file mode 100644
    index 0000000..db9fd36
    --- /dev/null
    +++ b/packages/markdown-parser/src/plugins/ast.ts
    @@ -0,0 +1,37 @@
    +// @fuyeor/markdown-parser/src/plugins/ast.ts
    +import type { ASTNode } from '#/types';
    +
    +type ASTNodeReplacement = ASTNode | readonly ASTNode[];
    +
    +// Recursively transform only changed branches and preserve unchanged node arrays.
    +export const mapAstNodes = (
    +  nodes: ASTNode[],
    +  transformNode: (node: ASTNode) => ASTNodeReplacement,
    +): ASTNode[] => {
    +  let transformedNodes: ASTNode[] | undefined;
    +
    +  for (let index = 0; index < nodes.length; index++) {
    +    const node = nodes[index];
    +    const transformedChildren = node.children
    +      ? mapAstNodes(node.children, transformNode)
    +      : node.children;
    +    const transformedNode =
    +      transformedChildren && transformedChildren !== node.children
    +        ? { ...node, children: transformedChildren }
    +        : node;
    +    const replacement = transformNode(transformedNode);
    +    const replacementNodes = Array.isArray(replacement)
    +      ? replacement
    +      : [replacement];
    +
    +    if (
    +      !transformedNodes &&
    +      (replacementNodes.length !== 1 || replacementNodes[0] !== node)
    +    ) {
    +      transformedNodes = nodes.slice(0, index);
    +    }
    +    transformedNodes?.push(...replacementNodes);
    +  }
    +
    +  return transformedNodes ?? nodes;
    +};
    diff --git a/packages/markdown-parser/src/plugins/async-blocks.ts b/packages/markdown-parser/src/plugins/async-blocks.ts
    new file mode 100644
    index 0000000..1af6b5a
    --- /dev/null
    +++ b/packages/markdown-parser/src/plugins/async-blocks.ts
    @@ -0,0 +1,94 @@
    +// @fuyeor/markdown-parser/src/plugins/async-blocks.ts
    +import { InlineState } from '#/core/state';
    +import type {
    +  ASTNode,
    +  ASTTransform,
    +  InlineRule,
    +  MarkdownPlugin,
    +} from '#/types';
    +import { mapAstNodes } from './ast';
    +import { normalizeHighlightLanguage } from './highlight';
    +
    +const SMILES_INLINE_PATTERN = /^#\[smiles\s*=\s*`([^`]+)`\]/u;
    +export const SPECIAL_BLOCK_LANGUAGES = new Set(['mermaid', 'abc', 'smiles']);
    +
    +// Parse the FFM #[smiles = `...`] inline placeholder syntax.
    +export const smilesInlineRule: InlineRule = {
    +  name: 'smiles_inline',
    +  markers: ['#'],
    +  parse(state: InlineState) {
    +    if (state.currentChar !== '#') return null;
    +
    +    const match = state.content.slice(state.pos).match(SMILES_INLINE_PATTERN);
    +    if (!match) return null;
    +
    +    return {
    +      node: { type: 'smiles_inline', content: match[1].trim() },
    +      consumedChars: match[0].length,
    +    };
    +  },
    +};
    +
    +// Convert one fenced code block into its asynchronous renderer placeholder.
    +export const transformSpecialBlockNode = (
    +  node: ASTNode,
    +  languages: ReadonlySet,
    +): ASTNode => {
    +  if (node.type !== 'code_block') return node;
    +
    +  const language = normalizeHighlightLanguage(String(node.lang ?? ''));
    +  if (!languages.has(language)) return node;
    +
    +  return {
    +    type: language === 'smiles' ? 'smiles_block' : language,
    +    content: String(node.content ?? ''),
    +  } satisfies ASTNode;
    +};
    +
    +// Build a lazy transform for a selected set of special fenced languages.
    +const createSpecialBlockTransform =
    +  (languages: ReadonlySet): ASTTransform =>
    +  (nodes) =>
    +    mapAstNodes(nodes, (node) => transformSpecialBlockNode(node, languages));
    +
    +export const mermaidTransform = createSpecialBlockTransform(
    +  new Set(['mermaid']),
    +);
    +export const abcTransform = createSpecialBlockTransform(new Set(['abc']));
    +export const smilesBlockTransform = createSpecialBlockTransform(
    +  new Set(['smiles']),
    +);
    +export const specialBlockTransform = createSpecialBlockTransform(
    +  SPECIAL_BLOCK_LANGUAGES,
    +);
    +
    +// Register Mermaid, ABC, and fenced Smiles block placeholders together.
    +export const specialBlockPlugin: MarkdownPlugin = (parser) => {
    +  parser.addAstTransform(specialBlockTransform);
    +};
    +
    +// Register only Mermaid fenced blocks for consumers that opt in selectively.
    +export const mermaidPlugin: MarkdownPlugin = (parser) => {
    +  parser.addAstTransform(mermaidTransform);
    +};
    +
    +// Register only ABC fenced blocks for consumers that opt in selectively.
    +export const abcPlugin: MarkdownPlugin = (parser) => {
    +  parser.addAstTransform(abcTransform);
    +};
    +
    +// Register only fenced Smiles blocks for consumers that opt in selectively.
    +export const smilesBlockPlugin: MarkdownPlugin = (parser) => {
    +  parser.addAstTransform(smilesBlockTransform);
    +};
    +
    +// Register the FFM inline Smiles syntax.
    +export const smilesInlinePlugin: MarkdownPlugin = (parser) => {
    +  parser.addInlineRule(smilesInlineRule);
    +};
    +
    +// Register both inline and fenced Smiles syntax.
    +export const smilesPlugin: MarkdownPlugin = (parser) => {
    +  smilesInlinePlugin(parser);
    +  smilesBlockPlugin(parser);
    +};
    diff --git a/packages/markdown-parser/src/plugins/extensions.ts b/packages/markdown-parser/src/plugins/extensions.ts
    new file mode 100644
    index 0000000..0086a1e
    --- /dev/null
    +++ b/packages/markdown-parser/src/plugins/extensions.ts
    @@ -0,0 +1,32 @@
    +// @fuyeor/markdown-parser/src/plugins/extensions.ts
    +import type { ASTNode, ASTTransform, MarkdownPlugin } from '#/types';
    +import { mapAstNodes } from './ast';
    +import { transformTwemojiNode } from './twemoji';
    +import {
    +  SPECIAL_BLOCK_LANGUAGES,
    +  transformSpecialBlockNode,
    +  smilesInlinePlugin,
    +} from './async-blocks';
    +import { normalizeHighlightLanguage } from './highlight';
    +
    +// Normalize code languages and special blocks in the same pass as Twemoji.
    +const transformExtensionNode = (
    +  node: ASTNode,
    +): ASTNode | readonly ASTNode[] => {
    +  if (node.type === 'text') return transformTwemojiNode(node);
    +  if (node.type !== 'code_block') return node;
    +
    +  const language = normalizeHighlightLanguage(String(node.lang ?? ''));
    +  const normalizedNode =
    +    language && language !== node.lang ? { ...node, lang: language } : node;
    +  return transformSpecialBlockNode(normalizedNode, SPECIAL_BLOCK_LANGUAGES);
    +};
    +
    +export const extensionsTransform: ASTTransform = (nodes) =>
    +  mapAstNodes(nodes, transformExtensionNode);
    +
    +// Register all five asynchronous/frontend-compatible extensions together.
    +export const extensionsPlugin: MarkdownPlugin = (parser) => {
    +  parser.addAstTransform(extensionsTransform);
    +  smilesInlinePlugin(parser);
    +};
    diff --git a/packages/markdown-parser/src/plugins/highlight.ts b/packages/markdown-parser/src/plugins/highlight.ts
    new file mode 100644
    index 0000000..442c76d
    --- /dev/null
    +++ b/packages/markdown-parser/src/plugins/highlight.ts
    @@ -0,0 +1,41 @@
    +// @fuyeor/markdown-parser/src/plugins/highlight.ts
    +import type { ASTNode, ASTTransform, MarkdownPlugin } from '#/types';
    +import { mapAstNodes } from './ast';
    +
    +const languageAliases: Record = {
    +  'c++': 'cpp',
    +  'c#': 'csharp',
    +  cs: 'csharp',
    +  js: 'javascript',
    +  ts: 'typescript',
    +  sh: 'bash',
    +  yml: 'yaml',
    +  html: 'xml',
    +  md: 'markdown',
    +  rs: 'rust',
    +  rb: 'ruby',
    +  pl: 'perl',
    +  ps1: 'powershell',
    +  py: 'python',
    +};
    +
    +// Normalize fenced-code aliases to the language names used by highlight.js.
    +export const normalizeHighlightLanguage = (rawLanguage: string): string => {
    +  const language = rawLanguage.trim().split(/\s+/u)[0]?.toLowerCase() ?? '';
    +  return languageAliases[language] ?? language;
    +};
    +
    +export const highlightTransform: ASTTransform = (nodes) =>
    +  mapAstNodes(nodes, (node) => {
    +    if (node.type !== 'code_block') return node;
    +
    +    const language = normalizeHighlightLanguage(String(node.lang ?? ''));
    +    return language && language !== node.lang
    +      ? { ...node, lang: language }
    +      : node;
    +  });
    +
    +// Register language normalization for asynchronous highlight.js processing.
    +export const highlightPlugin: MarkdownPlugin = (parser) => {
    +  parser.addAstTransform(highlightTransform);
    +};
    diff --git a/packages/markdown-parser/src/plugins/latex.ts b/packages/markdown-parser/src/plugins/latex.ts
    new file mode 100644
    index 0000000..8e561c7
    --- /dev/null
    +++ b/packages/markdown-parser/src/plugins/latex.ts
    @@ -0,0 +1,99 @@
    +// @fuyeor/markdown-parser/src/plugins/latex.ts
    +import { BlockState, InlineState } from '#/core/state';
    +import type { BlockRule, InlineRule, MarkdownPlugin } from '#/types';
    +
    +const MATH_INLINE_MARKER = '$';
    +const MATH_BLOCK_MARKER = '$$';
    +
    +// Find an unescaped dollar delimiter without interpreting LaTeX escapes.
    +const findMathDelimiter = (
    +  content: string,
    +  from: number,
    +  delimiter: string,
    +): number => {
    +  let position = content.indexOf(delimiter, from);
    +  while (position !== -1) {
    +    let backslashCount = 0;
    +    for (
    +      let index = position - 1;
    +      index >= 0 && content[index] === '\\';
    +      index--
    +    )
    +      backslashCount++;
    +    if (backslashCount % 2 === 0) return position;
    +    position = content.indexOf(delimiter, position + delimiter.length);
    +  }
    +  return -1;
    +};
    +
    +// Parse inline $...$ formulas while leaving unmatched dollars as plain text.
    +export const latexInlineRule: InlineRule = {
    +  name: 'math_inline',
    +  markers: [MATH_INLINE_MARKER],
    +  parse(state: InlineState) {
    +    if (state.currentChar !== MATH_INLINE_MARKER) return null;
    +    if (state.content.startsWith(MATH_BLOCK_MARKER, state.pos)) return null;
    +
    +    const endIndex = findMathDelimiter(
    +      state.content,
    +      state.pos + MATH_INLINE_MARKER.length,
    +      MATH_INLINE_MARKER,
    +    );
    +    if (endIndex === -1) return null;
    +
    +    return {
    +      node: {
    +        type: 'math_inline',
    +        content: state.content
    +          .slice(state.pos + MATH_INLINE_MARKER.length, endIndex)
    +          .trim(),
    +      },
    +      consumedChars: endIndex - state.pos + MATH_INLINE_MARKER.length,
    +    };
    +  },
    +};
    +
    +// Parse a $$...$$ formula as one block, including formulas spanning lines.
    +export const latexBlockRule: BlockRule = {
    +  name: 'math_block',
    +  markers: [MATH_INLINE_MARKER],
    +  parse(state: BlockState) {
    +    const firstLine = state.currentLine;
    +    if (!firstLine) return null;
    +
    +    const trimmedLine = firstLine.trimStart();
    +    if (!trimmedLine.startsWith(MATH_BLOCK_MARKER)) return null;
    +
    +    const contentLines: string[] = [];
    +    let consumedLines = 1;
    +    let currentContent = trimmedLine.slice(MATH_BLOCK_MARKER.length);
    +    let endIndex = findMathDelimiter(currentContent, 0, MATH_BLOCK_MARKER);
    +
    +    while (endIndex === -1) {
    +      contentLines.push(currentContent);
    +      if (state.lineIndex + consumedLines >= state.lineCount) return null;
    +      currentContent = state.lines[state.lineIndex + consumedLines];
    +      consumedLines++;
    +      endIndex = findMathDelimiter(currentContent, 0, MATH_BLOCK_MARKER);
    +    }
    +
    +    contentLines.push(currentContent.slice(0, endIndex));
    +    const trailingContent = currentContent.slice(
    +      endIndex + MATH_BLOCK_MARKER.length,
    +    );
    +    if (trailingContent.trim() !== '') return null;
    +
    +    return {
    +      node: {
    +        type: 'math_block',
    +        content: contentLines.join('\n').trim(),
    +      },
    +      consumedLines,
    +    };
    +  },
    +};
    +
    +// Register LaTeX block and inline rules on a markdown-parser instance.
    +export const latexPlugin: MarkdownPlugin = (parser) => {
    +  parser.addBlockRule(latexBlockRule).addInlineRule(latexInlineRule);
    +};
    diff --git a/packages/markdown-parser/src/plugins/twemoji.ts b/packages/markdown-parser/src/plugins/twemoji.ts
    new file mode 100644
    index 0000000..cd311db
    --- /dev/null
    +++ b/packages/markdown-parser/src/plugins/twemoji.ts
    @@ -0,0 +1,67 @@
    +// @fuyeor/markdown-parser/src/plugins/twemoji.ts
    +import type { ASTNode, ASTTransform, MarkdownPlugin } from '#/types';
    +import { mapAstNodes } from './ast';
    +
    +const r = String.raw;
    +const baseEmojiPattern = r`\p{Emoji}(?:\p{EMod}|[\u{E0020}-\u{E007E}]+\u{E007F}|\uFE0F?\u20E3?)`;
    +const emojiPattern = r`\p{RI}{2}|(?![#*\d](?!\uFE0F?\u20E3))${baseEmojiPattern}(?:\u200D${baseEmojiPattern})*`;
    +const emojiRegex = new RegExp(emojiPattern, 'gu');
    +
    +// Convert a raw Emoji sequence to the Twemoji CDN codepoint path.
    +export const getTwemojiUrl = (emoji: string): string => {
    +  const codepoints = Array.from(emoji)
    +    .map((character) => character.codePointAt(0)!.toString(16))
    +    .filter((codepoint) => codepoint !== 'fe0f')
    +    .join('-');
    +  return `https://deliver.fuyeor.net/@libs/twemoji-new/svg/${codepoints}.svg`;
    +};
    +
    +// Split text nodes into plain text and emoji image nodes without touching code.
    +export const transformTwemojiNode = (node: ASTNode): ASTNode[] => {
    +  const content = node.content;
    +  if (typeof content !== 'string' || content.length === 0) return [node];
    +
    +  emojiRegex.lastIndex = 0;
    +  if (!emojiRegex.test(content)) return [node];
    +
    +  const nodes: ASTNode[] = [];
    +  let lastIndex = 0;
    +  emojiRegex.lastIndex = 0;
    +  let match: RegExpExecArray | null;
    +
    +  while ((match = emojiRegex.exec(content)) !== null) {
    +    const emoji = match[0];
    +    const matchIndex = match.index;
    +    if (matchIndex > lastIndex) {
    +      nodes.push({
    +        type: 'text',
    +        content: content.slice(lastIndex, matchIndex),
    +      });
    +    }
    +    nodes.push({
    +      type: 'emoji',
    +      content: emoji,
    +      alt: emoji,
    +      src: getTwemojiUrl(emoji),
    +    });
    +    lastIndex = matchIndex + emoji.length;
    +  }
    +
    +  if (lastIndex < content.length) {
    +    nodes.push({ type: 'text', content: content.slice(lastIndex) });
    +  }
    +  return nodes;
    +};
    +
    +export const twemojiTransform: ASTTransform = (nodes) =>
    +  mapAstNodes(nodes, (node) =>
    +    node.type === 'text' ? transformTwemojiNode(node) : node,
    +  );
    +
    +// Register Twemoji conversion as a post-parse AST transform.
    +export const twemojiPlugin: MarkdownPlugin = (parser) => {
    +  parser.addAstTransform(twemojiTransform);
    +};
    +
    +// Keep the name used by the existing Interactify markdown-it integration.
    +export const nativeEmojiPlugin = twemojiPlugin;
    diff --git a/packages/markdown-parser/src/types.ts b/packages/markdown-parser/src/types.ts
    index 460d45d..bec0595 100644
    --- a/packages/markdown-parser/src/types.ts
    +++ b/packages/markdown-parser/src/types.ts
    @@ -17,6 +17,13 @@ export type NodeType =
       | 'table'
       | 'table_row'
       | 'table_cell'
    +  | 'math_inline'
    +  | 'math_block'
    +  | 'emoji'
    +  | 'mermaid'
    +  | 'abc'
    +  | 'smiles_inline'
    +  | 'smiles_block'
       | 'bold'
       | 'italic'
       | 'link'
    @@ -81,4 +88,5 @@ export interface MarkdownParserOptions {
       linkifier?: Linkifier;
     }
     
    +export type ASTTransform = (nodes: ASTNode[]) => ASTNode[];
     export type MarkdownPlugin = (parser: MarkdownParser) => void;