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 deleted file mode 100644 index 7cf9cec..0000000 --- a/packages/markdown-parser/src/index.spec.ts +++ /dev/null @@ -1,179 +0,0 @@ -// @fuyeor/markdown-parser/src/index.spec.ts -import { describe, it, expect } from 'vitest'; -import { MarkdownParser } from './core/parser'; -import { createFuyeorMarkdownParser } from './default'; -import { render } from './core/render'; -import { headingRule, codeBlockRule, tableRule } from './rules/blocks'; -import { boldRule, linkRule } from './rules/inlines'; - -// build parser -const parse = new MarkdownParser() - .addBlockRule(tableRule) - .addBlockRule(codeBlockRule) - .addBlockRule(headingRule) - .addInlineRule(boldRule) - .addInlineRule(linkRule) - .build(); - -describe('test @fuyeor/markdown-parser', () => { - it('parse and filter malicious code', () => { - const ast = parse('text '); - - expect(ast[0].type).toBe('paragraph'); - expect(ast[0].children![0].content).toBe('text '); - }); - - it('parse ATX headings and internal bolding', () => { - const ast = parse('## Hello **World**'); - - expect(ast[0].type).toBe('heading'); - expect(ast[0].level).toBe(2); - expect(ast[0].children![0].type).toBe('text'); - expect(ast[0].children![0].content).toBe('Hello '); - expect(ast[0].children![1].type).toBe('bold'); - }); - - it('parse fenced code block', () => { - const content = '```ts\nconst a = 1;\n```'; - const ast = parse(content); - - expect(ast[0].type).toBe('code_block'); - expect(ast[0].lang).toBe('ts'); - expect(ast[0].content).toBe('const a = 1;'); - }); - - it('parse standard links', () => { - const ast = parse( - 'visit www.fuyeor.com or [click here](https://fuyeor.com)', - ); - const children = ast[0].children!; - - // automatic completion protocol - expect(children[1].type).toBe('link'); - expect(children[1].url).toBe('https://www.fuyeor.com'); - - // generate standard link - expect(children[3].type).toBe('link'); - expect(children[3].url).toBe('https://fuyeor.com'); - }); - - it('uses linkify for internationalized fuzzy domains', () => { - const ast = createFuyeorMarkdownParser()('Visit fuyeor.xn--p1ai'); - const link = ast[0].children?.find((node) => node.type === 'link'); - - expect(link).toMatchObject({ - url: 'https://fuyeor.рф', - children: [{ type: 'text', content: 'fuyeor.рф' }], - }); - }); - - it('parse table', () => { - const content = ` -| title 1 | title 2 | -|---|---| -| content 1 | content 2 | -`; - const ast = parse(content); - const tableNode = ast.find((n) => n.type === 'table')!; - - expect(tableNode).toBeDefined(); - expect(tableNode.headers).toHaveLength(2); - expect(tableNode.children![0].type).toBe('table_row'); - }); - - it('renders aligned headed tables', () => { - const ast = createFuyeorMarkdownParser()( - '| 属性 | 类型 | 说明 |\n| :--- | :---: | ---: |\n| name | string | 用户名 |', - ); - - expect(render(ast)).toBe(`
    - - - - - - - - - - - - - - -
    属性类型说明
    namestring用户名
    -`); - }); - - it('renders separator-first tables without a header', () => { - const ast = createFuyeorMarkdownParser()( - '| :--- | ---: |\n| Ray ID | a2c5ac427b7aa727 |\n| IP 地址 | 172.214.47.18 |', - ); - const tableNode = ast.find((node) => node.type === 'table'); - - expect(tableNode?.headers).toBeUndefined(); - expect(tableNode?.children).toHaveLength(2); - expect(render(ast)).toBe(` - - - - - - - - - - -
    Ray IDa2c5ac427b7aa727
    IP 地址172.214.47.18
    -`); - }); - - it('rejects unsafe link schemes', () => { - const ast = parse('[click](javascript:alert(1))'); - - expect(ast[0].children?.some((node) => node.type === 'link')).toBe(false); - }); - - it('bounds recursive block parsing', () => { - const boundedParse = createFuyeorMarkdownParser({ maxNestingDepth: 8 }); - - expect(() => boundedParse(`${'>'.repeat(100)} value`)).not.toThrow(); - }); - - it('fails fast for an invalid nesting depth', () => { - expect(() => new MarkdownParser({ maxNestingDepth: 0 })).toThrow( - RangeError, - ); - }); - - it.each([ - ['modern two-space indentation', ' - example'], - ['legacy three-space marker alignment', ' - example'], - ])('recognizes %s as a nested list', (_description, childLine) => { - const ast = createFuyeorMarkdownParser()(`1. example\n${childLine}`); - const rootList = ast[0]; - - expect(rootList.type).toBe('list'); - expect(rootList.children).toHaveLength(1); - expect( - rootList.children?.[0].children?.some((node) => node.type === 'list'), - ).toBe(true); - }); - - it('uses two-space steps for deeper list nesting', () => { - const ast = createFuyeorMarkdownParser()( - '1. root\n - level 1\n - level 2', - ); - const rootItem = ast[0].children?.[0]; - const levelOneList = rootItem?.children?.find( - (node) => node.type === 'list', - ); - const levelOneItem = levelOneList?.children?.[0]; - const levelTwoList = levelOneItem?.children?.find( - (node) => node.type === 'list', - ); - - expect(levelOneList).toBeDefined(); - expect(levelTwoList).toBeDefined(); - }); -}); 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;