From fb22c59bfb268f8ff4d873ab66cf0666714c2c03 Mon Sep 17 00:00:00 2001 From: Manus AI Date: Tue, 25 Aug 2026 13:46:06 +0000 Subject: [PATCH 1/2] feat: Add LaTeX parser plugin --- packages/markdown-parser/src/core/parser.ts | 5 +- packages/markdown-parser/src/core/render.ts | 6 ++ packages/markdown-parser/src/default.ts | 2 + packages/markdown-parser/src/index.spec.ts | 34 +++++++ packages/markdown-parser/src/index.ts | 1 + packages/markdown-parser/src/plugins/latex.ts | 99 +++++++++++++++++++ packages/markdown-parser/src/types.ts | 2 + 7 files changed, 148 insertions(+), 1 deletion(-) create mode 100644 packages/markdown-parser/src/plugins/latex.ts diff --git a/packages/markdown-parser/src/core/parser.ts b/packages/markdown-parser/src/core/parser.ts index d244548..b4e46a6 100644 --- a/packages/markdown-parser/src/core/parser.ts +++ b/packages/markdown-parser/src/core/parser.ts @@ -165,7 +165,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 +184,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..97f2517 100644 --- a/packages/markdown-parser/src/core/render.ts +++ b/packages/markdown-parser/src/core/render.ts @@ -52,6 +52,12 @@ export function render(nodes?: ASTNode[]): string { case 'text': html += node.content ? escapeHtml(node.content) : ''; 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; diff --git a/packages/markdown-parser/src/default.ts b/packages/markdown-parser/src/default.ts index 8c5ac97..0845e55 100644 --- a/packages/markdown-parser/src/default.ts +++ b/packages/markdown-parser/src/default.ts @@ -19,6 +19,7 @@ import { strikeRule, } from './rules/inlines'; import { ffmBlockRule } from './rules/ffm'; +import { latexPlugin } from './plugins/latex'; export function createMarkdownParser(options: MarkdownParserOptions = {}) { return ( @@ -57,6 +58,7 @@ export function createFuyeorMarkdownParser( .addBlockRule(tableRule) .addBlockRule(hrRule) .addBlockRule(blockquoteRule) + .use(latexPlugin) // 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..fa2c733 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,39 @@ 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('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..516eea4 100644 --- a/packages/markdown-parser/src/index.ts +++ b/packages/markdown-parser/src/index.ts @@ -19,6 +19,7 @@ export { underlineRule, strikeRule, } from './rules/inlines'; +export { latexPlugin, latexInlineRule, latexBlockRule } from './plugins/latex'; export { createMarkdownParser, createFuyeorMarkdownParser } from './default'; export type { 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/types.ts b/packages/markdown-parser/src/types.ts index 460d45d..18b5ace 100644 --- a/packages/markdown-parser/src/types.ts +++ b/packages/markdown-parser/src/types.ts @@ -17,6 +17,8 @@ export type NodeType = | 'table' | 'table_row' | 'table_cell' + | 'math_inline' + | 'math_block' | 'bold' | 'italic' | 'link' From 3d61dd140c84c225b6ef8fea71760a8af34fb2e4 Mon Sep 17 00:00:00 2001 From: Manus AI Date: Tue, 25 Aug 2026 14:15:19 +0000 Subject: [PATCH 2/2] feat: Add five markdown extensions --- packages/markdown-parser/src/core/parser.ts | 14 ++- packages/markdown-parser/src/core/render.ts | 64 ++++++++++++- packages/markdown-parser/src/default.ts | 2 + packages/markdown-parser/src/index.spec.ts | 64 +++++++++++++ packages/markdown-parser/src/index.ts | 25 +++++ packages/markdown-parser/src/plugins/ast.ts | 37 ++++++++ .../src/plugins/async-blocks.ts | 94 +++++++++++++++++++ .../markdown-parser/src/plugins/extensions.ts | 32 +++++++ .../markdown-parser/src/plugins/highlight.ts | 41 ++++++++ .../markdown-parser/src/plugins/twemoji.ts | 67 +++++++++++++ packages/markdown-parser/src/types.ts | 6 ++ 11 files changed, 441 insertions(+), 5 deletions(-) create mode 100644 packages/markdown-parser/src/plugins/ast.ts create mode 100644 packages/markdown-parser/src/plugins/async-blocks.ts create mode 100644 packages/markdown-parser/src/plugins/extensions.ts create mode 100644 packages/markdown-parser/src/plugins/highlight.ts create mode 100644 packages/markdown-parser/src/plugins/twemoji.ts diff --git a/packages/markdown-parser/src/core/parser.ts b/packages/markdown-parser/src/core/parser.ts index b4e46a6..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, + ); }; } diff --git a/packages/markdown-parser/src/core/render.ts b/packages/markdown-parser/src/core/render.ts index 97f2517..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,13 @@ 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; @@ -89,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 = @@ -108,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 0845e55..e44aa12 100644 --- a/packages/markdown-parser/src/default.ts +++ b/packages/markdown-parser/src/default.ts @@ -20,6 +20,7 @@ import { } from './rules/inlines'; import { ffmBlockRule } from './rules/ffm'; import { latexPlugin } from './plugins/latex'; +import { extensionsPlugin } from './plugins/extensions'; export function createMarkdownParser(options: MarkdownParserOptions = {}) { return ( @@ -59,6 +60,7 @@ export function createFuyeorMarkdownParser( .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 fa2c733..33c9a33 100644 --- a/packages/markdown-parser/src/index.spec.ts +++ b/packages/markdown-parser/src/index.spec.ts @@ -101,6 +101,70 @@ describe('test @fuyeor/markdown-parser', () => { ); }); + 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 516eea4..1414985 100644
    --- a/packages/markdown-parser/src/index.ts
    +++ b/packages/markdown-parser/src/index.ts
    @@ -20,11 +20,36 @@ export {
       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/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 18b5ace..bec0595 100644
    --- a/packages/markdown-parser/src/types.ts
    +++ b/packages/markdown-parser/src/types.ts
    @@ -19,6 +19,11 @@ export type NodeType =
       | 'table_cell'
       | 'math_inline'
       | 'math_block'
    +  | 'emoji'
    +  | 'mermaid'
    +  | 'abc'
    +  | 'smiles_inline'
    +  | 'smiles_block'
       | 'bold'
       | 'italic'
       | 'link'
    @@ -83,4 +88,5 @@ export interface MarkdownParserOptions {
       linkifier?: Linkifier;
     }
     
    +export type ASTTransform = (nodes: ASTNode[]) => ASTNode[];
     export type MarkdownPlugin = (parser: MarkdownParser) => void;