Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions packages/markdown-parser/src/core/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { BlockState, InlineState } from './state';
import { linkify } from '@fuyeor/linkify';
import type {
ASTNode,
ASTTransform,
BlockRule,
InlineRule,
Linkifier,
Expand All @@ -23,6 +24,7 @@ function linkifyWithCandidateCheck(text: string): ReturnType<typeof linkify> {
export class MarkdownParser {
#blockRuleMap = new Map<string, BlockRule[]>();
#inlineRuleMap = new Map<string, InlineRule[]>();
#astTransforms: ASTTransform[] = [];
#idSequence = 0;
#isPreflight = false;
readonly #maxNestingDepth: number;
Expand Down Expand Up @@ -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,
);
};
}

Expand Down Expand Up @@ -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(
Expand All @@ -182,6 +196,7 @@ export class MarkdownParser {
'blockquote',
'list',
'code_block',
'math_block',
'ffm_blocks',
].includes(rule.name)
) {
Expand Down
70 changes: 66 additions & 4 deletions packages/markdown-parser/src/core/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,29 @@ const getTableAlignment = (align: unknown) =>
? ` align="${align}"`
: '';

const displayLanguageNames: Record<string, string> = {
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 = '';

Expand All @@ -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 += `<img class="emoji" draggable="false" alt="${escapeHtml(alt)}" src="${escapeHtml(src)}"/>`;
break;
}
case 'math_inline':
html += `<span class="math-inline">${escapeHtml(String(node.content ?? ''))}</span>`;
break;
case 'math_block':
html += `<div class="math-block">${escapeHtml(String(node.content ?? ''))}</div>\n`;
break;
case 'bold':
html += `<strong>${render(node.children)}</strong>`;
break;
Expand Down Expand Up @@ -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 += `<div class="code-block-wrapper">`;
if (node.lang) {
html += `<div class="code-lang">${escapeHtml(node.lang)}</div>`;
if (language) {
html += `<div class="code-lang">${escapeHtml(displayLanguage)}</div>`;
}
html += `<pre><code${node.lang ? ` class="language-${escapeHtml(node.lang)}"` : ''}>${node.content ? escapeHtml(node.content) : ''}\n</code></pre></div>\n`;
html += `<pre ${preAttributes}><code>${escapeHtml(String(node.content ?? ''))}\n</code></pre></div>\n`;
break;
}
case 'list': {
const tag = node.ordered ? 'ol' : 'ul';
const start =
Expand All @@ -102,6 +144,26 @@ export function render(nodes?: ASTNode[]): string {
case 'list_item':
html += `<li>${render(node.children)}</li>\n`;
break;
case 'mermaid':
html += `<div class="language-mermaid">${escapeHtml(String(node.content ?? ''))}</div>\n`;
break;
case 'abc':
html += `<div class="language-abc">${escapeHtml(String(node.content ?? ''))}</div>\n`;
break;
case 'smiles_inline':
html += `<span class="language-smiles smiles-inline" data-smiles="${escapeHtml(String(node.content ?? ''))}"></span>`;
break;
case 'smiles_block': {
const blocks = String(node.content ?? '')
.split('\n')
.filter((line) => line.trim().length > 0)
.map(
(line) =>
`<div class="language-smiles smiles-block" data-smiles="${escapeHtml(line.trim())}"></div>`,
);
html += blocks.length > 0 ? `${blocks.join('\n')}\n` : '';
break;
}
case 'table': {
html += '<table>\n';
if (node.headers) {
Expand Down
4 changes: 4 additions & 0 deletions packages/markdown-parser/src/default.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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)
Expand Down
98 changes: 98 additions & 0 deletions packages/markdown-parser/src/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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(`<p>Inline <span class="math-inline">x^2</span> formula.</p>
<div class="math-block">x^2 + y^2</div>
`);
});

it('escapes LaTeX placeholders and leaves unmatched delimiters as text', () => {
const ast = createFuyeorMarkdownParser()(
'$<script>alert(1)</script>$ and $unfinished',
);

expect(render(ast)).toBe(
'<p><span class="math-inline">&lt;script&gt;alert(1)&lt;/script&gt;</span> and $unfinished</p>\n',
);
});

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

expect(html).toBe(
'<p>Hello <img class="emoji" draggable="false" alt="😀" src="https://deliver.fuyeor.net/@libs/twemoji-new/svg/1f600.svg"/> and <code>😀</code>.</p>\n',
);
});

it('renders FFM Smiles inline placeholders safely', () => {
const html = render(
createFuyeorMarkdownParser()(
'Molecule #[smiles = `C=C`] and #[smiles=`<svg>`]',
),
);

expect(html).toContain(
'<span class="language-smiles smiles-inline" data-smiles="C=C"></span>',
);
expect(html).toContain(
'<span class="language-smiles smiles-inline" data-smiles="&lt;svg&gt;"></span>',
);
});

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(
'<div class="language-mermaid">graph TD\nA--&gt;B</div>',
);
expect(html).toContain('<div class="language-abc">X:1\nK:C</div>');
expect(html).toContain(
'<div class="language-smiles smiles-block" data-smiles="C=C"></div>\n<div class="language-smiles smiles-block" data-smiles="CCO"></div>',
);
});

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(
'<pre class="hljs language-javascript" data-language="javascript">',
);
expect(html).toContain(
'<pre class="hljs language-fon" data-language="fon">',
);
expect(html).toContain(
'<pre class="hljs language-fer" data-language="fer">',
);
expect(html).toContain(
'<pre class="hljs language-ffm" data-language="ffm">',
);
});

it('parse table', () => {
const content = `
| title 1 | title 2 |
Expand Down
26 changes: 26 additions & 0 deletions packages/markdown-parser/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
37 changes: 37 additions & 0 deletions packages/markdown-parser/src/plugins/ast.ts
Original file line number Diff line number Diff line change
@@ -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;
};
Loading
Loading