diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bfc3175..3e6c923 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,9 +9,13 @@ on: - '.github/workflows/ci.yml' - 'packages/linkify/**' - 'packages/markdown-parser/**' + - 'packages/markdown-parser-rust/**' - 'packages/markdown-parser-lit/**' - 'packages/markdown-parser-vue/**' - 'packages/playground/**' + - 'fixtures/**' + - 'Cargo.toml' + - 'Cargo.lock' - 'package.json' - 'pnpm-lock.yaml' - 'Dockerfile' @@ -21,9 +25,13 @@ on: - '.github/workflows/ci.yml' - 'packages/linkify/**' - 'packages/markdown-parser/**' + - 'packages/markdown-parser-rust/**' - 'packages/markdown-parser-lit/**' - 'packages/markdown-parser-vue/**' - 'packages/playground/**' + - 'fixtures/**' + - 'Cargo.toml' + - 'Cargo.lock' - 'package.json' - 'pnpm-lock.yaml' - 'Dockerfile' @@ -46,8 +54,11 @@ jobs: uses: actions/checkout@v4 - name: Set up pnpm uses: pnpm/action-setup@v4 + - name: Set up Rust + uses: dtolnay/rust-toolchain@stable with: - version: 11.20.0 + toolchain: stable + components: rustfmt, clippy - name: Set up Node.js uses: actions/setup-node@v4 with: @@ -63,6 +74,16 @@ jobs: run: pnpm --filter @fuyeor/markdown-parser test:unit - name: Run FFM regression tests run: pnpm --filter @fuyeor/markdown-parser test:ffm + - name: Run safety fixtures + run: pnpm --filter @fuyeor/markdown-parser test:safety + - name: Run shared linkify fixtures + run: pnpm --filter @fuyeor/linkify exec vitest run src/cross-language-fixtures.spec.ts + - name: Check Rust parser + run: | + cargo fmt --all -- --check + cargo check --workspace --all-targets --all-features + cargo clippy --workspace --all-targets --all-features -- -D warnings + cargo test --workspace --all-targets --all-features build-markdown-playground: needs: test-markdown-parser diff --git a/.gitignore b/.gitignore index ab234eb..88b1742 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ **/dist **/out **/tests.failed.json +**/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..50da161 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,107 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "fuyeor-markdown-parser" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..9926708 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,4 @@ +# /Cargo.toml +[workspace] +members = ["packages/markdown-parser-rust"] +resolver = "3" diff --git a/fixtures/ffm.json b/fixtures/ffm.json new file mode 100644 index 0000000..d7eb8b4 --- /dev/null +++ b/fixtures/ffm.json @@ -0,0 +1,192 @@ +{ + "schema_version": 2, + "description": "Language-neutral FFM parser fixtures.", + "cases": [ + { + "input": "| 属性 | 类型 | 说明 |\n| :--- | :---: | ---: |\n| name | string | 用户名 |", + "html": "\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
属性类型说明
namestring用户名
" + }, + { + "input": "| :--- | ---: |\n| Ray ID | a2c5ac427b7aa727 |\n| IP 地址 | 172.214.47.18 |", + "html": "\n\n\n\n\n\n\n\n\n\n\n
Ray IDa2c5ac427b7aa727
IP 地址172.214.47.18
" + }, + { + "input": "[x](javascript:alert(1))", + "html": "

[x](javascript:alert(1))

" + }, + { + "input": "--Fuyeor--", + "html": "

Fuyeor

" + }, + { + "input": "__Fuyeor__", + "html": "

Fuyeor

" + }, + { + "input": "`#ff0000`", + "html": "

#ff0000

" + }, + { + "input": "```quote\n## Title\n```", + "html": "
\n

Title

\n
" + }, + { + "input": "```accordion\n**One**\nContent\n```", + "html": "
One

Content

\n
" + }, + { + "input": "```chain\n**[x] Done**\nBody\n```", + "html": "
Done

Body

\n
" + }, + { + "input": "```slide\nFirst\n\n---\n\nSecond\n```", + "html": "

First

\n

Second

\n
" + }, + { + "input": "[x](data:text/html,)", + "html": "

[x](data:text/html,<script>alert(1)</script>)

" + }, + { + "input": "[x](file:///etc/passwd)", + "html": "

[x](file:///etc/passwd)

" + }, + { + "input": "Visit fuyeor.xn--p1ai", + "html": "

Visit fuyeor.рф

", + "assert": [ + { + "path": "/0/children/1/type", + "value": "link" + }, + { + "path": "/0/children/1/url", + "value": "https://fuyeor.рф" + }, + { + "path": "/0/children/1/children/0/content", + "value": "fuyeor.рф" + } + ] + }, + { + "input": "1. example\n - example", + "assert": [ + { + "path": "/0/type", + "value": "list" + }, + { + "path": "/0/children/0/children/1/type", + "value": "list" + } + ] + }, + { + "input": "1. example\n - example", + "assert": [ + { + "path": "/0/type", + "value": "list" + }, + { + "path": "/0/children/0/children/1/type", + "value": "list" + } + ] + }, + { + "input": "1. root\n - level 1\n - level 2", + "assert": [ + { + "path": "/0/type", + "value": "list" + }, + { + "path": "/0/children/0/children/1/children/0/children/1/type", + "value": "list" + } + ] + }, + { + "input": ">>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> value", + "options": { + "max_nesting_depth": 8 + }, + "no_throw": true + }, + { + "options": { + "max_nesting_depth": 0 + }, + "error": "invalid_nesting_depth" + }, + { + "input": "__under__ and --strike--\n\n```quote\n## nested\n```", + "assert": [ + { + "path": "/0/children/0/type", + "value": "underline" + }, + { + "path": "/0/children/2/type", + "value": "strike" + }, + { + "path": "/1/type", + "value": "blockquote" + }, + { + "path": "/1/children/0/type", + "value": "heading" + } + ] + }, + { + "input": "```slide\nFirst\n\n --- \n\nSecond\n```", + "assert": [ + { + "path": "/0/type", + "value": "slide" + }, + { + "path": "/0/children", + "length": 2 + } + ] + }, + { + "input": "```accordion\n**First**\nBody\n\n**Second**\nMore\n```", + "assert": [ + { + "path": "/0/type", + "value": "accordion" + }, + { + "path": "/0/children", + "length": 2 + }, + { + "path": "/0/children/0/name", + "value": "acc-0" + } + ] + }, + { + "input": "```chain\n**[x] Done**\nBody\n```", + "assert": [ + { + "path": "/0/type", + "value": "chain" + }, + { + "path": "/0/children/0/isCompleted", + "value": true + }, + { + "path": "/0/children/0/hasCheckbox", + "value": true + } + ] + } + ] +} diff --git a/fixtures/linkify.json b/fixtures/linkify.json new file mode 100644 index 0000000..33c83e7 --- /dev/null +++ b/fixtures/linkify.json @@ -0,0 +1,151 @@ +{ + "schema_version": 1, + "description": "Language-neutral linkify fixtures with visible text and normalized URLs.", + "cases": [ + { + "input": "Visit fuyeor.com.", + "links": [ + { + "text": "fuyeor.com", + "url": "https://fuyeor.com" + } + ] + }, + { + "input": "Visit fuyeor.com", + "links": [ + { + "text": "fuyeor.com", + "url": "https://fuyeor.com" + } + ] + }, + { + "input": "Visit fuyeor.com;", + "links": [ + { + "text": "fuyeor.com", + "url": "https://fuyeor.com" + } + ] + }, + { + "input": "(fuyeor.com)", + "links": [ + { + "text": "fuyeor.com", + "url": "https://fuyeor.com" + } + ] + }, + { + "input": "Visit fuyeor.cn.", + "links": [ + { + "text": "fuyeor.cn", + "url": "https://fuyeor.cn" + } + ] + }, + { + "input": "Visit fuyeor.co.uk.", + "links": [ + { + "text": "fuyeor.co.uk", + "url": "https://fuyeor.co.uk" + } + ] + }, + { + "input": "Visit web.dev.", + "links": [ + { + "text": "web.dev", + "url": "https://web.dev" + } + ] + }, + { + "input": "Visit vercel.app", + "links": [ + { + "text": "vercel.app", + "url": "https://vercel.app" + } + ] + }, + { + "input": "Visit fuyeor.рф.", + "links": [ + { + "text": "fuyeor.рф", + "url": "https://fuyeor.рф" + } + ] + }, + { + "input": "Visit fuyeor.xn--p1ai.", + "links": [ + { + "text": "fuyeor.рф", + "url": "https://fuyeor.рф" + } + ] + }, + { + "input": "Profile: https://fuyeor.com/@Fuyeor", + "links": [ + { + "text": "https://fuyeor.com/@Fuyeor", + "url": "https://fuyeor.com/@Fuyeor" + } + ] + }, + { + "input": "A link with parens: https://en.wikipedia.org/wiki/Link_(disambiguation)", + "links": [ + { + "text": "https://en.wikipedia.org/wiki/Link_(disambiguation)", + "url": "https://en.wikipedia.org/wiki/Link_(disambiguation)" + } + ] + }, + { + "input": "Visit fuyeor.123", + "links": [] + }, + { + "input": "Visit fuyeor.enterprise", + "links": [] + }, + { + "input": "Visit fuyeor.md", + "links": [] + }, + { + "input": "Visit fuyeor..com", + "links": [] + }, + { + "input": "Visit fuyeor,com", + "links": [] + }, + { + "input": "Visit fuyeor.com123", + "links": [] + }, + { + "input": "Visit fuyeor.comあ", + "links": [] + }, + { + "input": "fuyeor.com💜", + "links": [ + { + "text": "fuyeor.com", + "url": "https://fuyeor.com" + } + ] + } + ] +} diff --git a/fixtures/markdown.json b/fixtures/markdown.json new file mode 100644 index 0000000..08abd59 --- /dev/null +++ b/fixtures/markdown.json @@ -0,0 +1,57 @@ +{ + "schema_version": 2, + "description": "Language-neutral standard Markdown parser fixtures.", + "cases": [ + { + "input": "## Hello **World**", + "html": "

Hello World

" + }, + { + "input": "```ts\nconst a = 1;\n```", + "html": "
ts
const a = 1;\n
" + }, + { + "input": "visit www.fuyeor.com or [click here](https://fuyeor.com)", + "html": "

visit www.fuyeor.com or click here

" + }, + { + "input": "line\\\nbreak", + "html": "

line
\nbreak

" + }, + { + "input": "text ", + "html": "

text <script>alert(1)</script>

", + "assert": [ + { + "path": "/0/type", + "value": "paragraph" + }, + { + "path": "/0/children/0/content", + "value": "text " + } + ] + }, + { + "input": "\n| title 1 | title 2 |\n|---|---|\n| content 1 | content 2 |\n", + "assert": [ + { + "path": "/0/type", + "value": "table" + }, + { + "path": "/0/headers/0/type", + "value": "table_cell" + }, + { + "path": "/0/headers/1/type", + "value": "table_cell" + }, + { + "path": "/0/children/0/type", + "value": "table_row" + } + ] + } + ] +} diff --git a/fixtures/safety.json b/fixtures/safety.json new file mode 100644 index 0000000..2c5fb3a --- /dev/null +++ b/fixtures/safety.json @@ -0,0 +1,44 @@ +{ + "schema_version": 1, + "links": [ + { + "input": "https://fuyeor.com", + "valid": true + }, + { + "input": "#section", + "valid": true + }, + { + "input": "../doc", + "valid": true + }, + { + "input": "javascript:alert(1)", + "valid": false + }, + { + "input": "data:text/html,alert(1)", + "valid": false + } + ], + "colors": [ + { + "input": "#fff", + "valid": true + }, + { + "input": "rgba(0, 0, 0, 50%)", + "valid": true + }, + { + "input": "red", + "valid": false + }, + { + "input": "url(javascript:alert(1))", + "valid": false + } + ], + "description": "Language-neutral URL and color safety fixtures." +} diff --git a/packages/linkify/src/cross-language-fixtures.spec.ts b/packages/linkify/src/cross-language-fixtures.spec.ts new file mode 100644 index 0000000..035c605 --- /dev/null +++ b/packages/linkify/src/cross-language-fixtures.spec.ts @@ -0,0 +1,43 @@ +// packages/linkify/src/cross-language-fixtures.spec.ts +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { linkify } from './index'; + +type LinkFixtureMatch = { + text: string; + url: string; +}; + +type LinkFixture = { + input: string; + links: LinkFixtureMatch[]; +}; + +type LinkFixtureFile = { + schema_version: number; + description: string; + cases: LinkFixture[]; +}; + +const fixtures = JSON.parse( + readFileSync( + resolve(import.meta.dirname, '../../../fixtures/linkify.json'), + 'utf8', + ), +) as LinkFixtureFile; + +// Execute every language-neutral linkify fixture against the TypeScript implementation. +describe('language-neutral linkify fixtures', () => { + expect(fixtures.schema_version).toBe(1); + + for (const [index, fixture] of fixtures.cases.entries()) { + it(String(index), () => { + const actual = linkify(fixture.input).map(({ text, url }) => ({ + text, + url, + })); + expect(actual).toEqual(fixture.links); + }); + } +}); diff --git a/packages/linkify/src/index.spec.ts b/packages/linkify/src/index.spec.ts deleted file mode 100644 index c71dac0..0000000 --- a/packages/linkify/src/index.spec.ts +++ /dev/null @@ -1,26 +0,0 @@ -// src/index.spec.ts -import { describe, expect, it } from 'vitest'; -import cases from './cases.json'; -import { linkify } from './index'; - -type LinkifyCase = { - text: string; - expect: string; -}; - -type LinkifyCases = Record; - -// Execute every sectioned fixture against the public linkify API. -describe('linkify JSON cases', () => { - for (const [section, sectionCases] of Object.entries(cases as LinkifyCases)) { - describe(section, () => { - for (const testCase of sectionCases) { - it(testCase.text, () => { - const actual = linkify(testCase.text).map((match) => match.text); - const expected = testCase.expect ? [testCase.expect] : []; - expect(actual).toEqual(expected); - }); - } - }); - } -}); diff --git a/packages/markdown-parser-rust/Cargo.toml b/packages/markdown-parser-rust/Cargo.toml new file mode 100644 index 0000000..9c4544f --- /dev/null +++ b/packages/markdown-parser-rust/Cargo.toml @@ -0,0 +1,24 @@ +# packages/markdown-parser-rust/Cargo.toml +[package] +name = "fuyeor-markdown-parser" +version = "0.1.0" +edition = "2024" +rust-version = "1.85" +license = "MIT" +description = "A lightweight Fuyeor Flavored Markdown parser and renderer." +repository = "https://github.com/Fuyeor/markdown-parser" + +[lib] +name = "fuyeor_markdown_parser" +path = "src/lib.rs" + +[features] +default = [] +serde = ["dep:serde"] + +[dependencies] +serde = { version = "1.0", features = ["derive"], optional = true } + +[dev-dependencies] +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" diff --git a/packages/markdown-parser-rust/src/ast.rs b/packages/markdown-parser-rust/src/ast.rs new file mode 100644 index 0000000..02a88e9 --- /dev/null +++ b/packages/markdown-parser-rust/src/ast.rs @@ -0,0 +1,196 @@ +// packages/markdown-parser-rust/src/ast.rs +use std::collections::BTreeMap; + +#[cfg(feature = "serde")] +use serde::{Deserialize, Serialize}; + +/// Represents a built-in Markdown node or an extension-defined node. +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum NodeType { + /// Root document node. + Root, + /// Paragraph block. + Paragraph, + /// Plain text node. + Text, + /// ATX heading node. + Heading, + /// Fenced code block. + CodeBlock, + /// Blockquote node. + Blockquote, + /// Ordered or unordered list. + List, + /// List item node. + ListItem, + /// Pipe table node. + Table, + /// Table row node. + TableRow, + /// Table cell node. + TableCell, + /// Strong emphasis node. + Bold, + /// Emphasis node. + Italic, + /// Link node. + Link, + /// Explicit hard line break. + Hardbreak, + /// Thematic break node. + Hr, + /// FFM underline node. + Underline, + /// FFM strike node. + Strike, + /// Inline code span. + InlineCode, + /// Safe FFM color code span. + ColorCode, + /// FFM slide container. + Slide, + /// FFM slide item. + SlideItem, + /// FFM accordion container. + Accordion, + /// FFM accordion item. + AccordionItem, + /// FFM chain container. + Chain, + /// FFM chain item. + ChainItem, + /// Extension-defined node name. + Custom(String), +} + +impl NodeType { + /// Returns the wire-compatible node name used by the TypeScript parser. + pub fn as_str(&self) -> &str { + match self { + Self::Root => "root", + Self::Paragraph => "paragraph", + Self::Text => "text", + Self::Heading => "heading", + Self::CodeBlock => "code_block", + Self::Blockquote => "blockquote", + Self::List => "list", + Self::ListItem => "list_item", + Self::Table => "table", + Self::TableRow => "table_row", + Self::TableCell => "table_cell", + Self::Bold => "bold", + Self::Italic => "italic", + Self::Link => "link", + Self::Hardbreak => "hardbreak", + Self::Hr => "hr", + Self::Underline => "underline", + Self::Strike => "strike", + Self::InlineCode => "inline_code", + Self::ColorCode => "color_code", + Self::Slide => "slide", + Self::SlideItem => "slide_item", + Self::Accordion => "accordion", + Self::AccordionItem => "accordion_item", + Self::Chain => "chain", + Self::ChainItem => "chain_item", + Self::Custom(name) => name, + } + } +} + +/// Represents table alignment metadata. +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Alignment { + /// Aligns content to the left. + Left, + /// Centers content. + Center, + /// Aligns content to the right. + Right, +} + +impl Alignment { + /// Returns the HTML-compatible alignment value. + pub fn as_str(self) -> &'static str { + match self { + Self::Left => "left", + Self::Center => "center", + Self::Right => "right", + } + } +} + +/// Extensible AST node equivalent to the TypeScript `ASTNode` interface. +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[derive(Clone, Debug, PartialEq)] +pub struct AstNode { + /// The semantic node type. + pub node_type: NodeType, + /// Literal text or code content, when present. + pub content: Option, + /// Child nodes in source order. + pub children: Vec, + /// ATX heading level. + pub level: Option, + /// Fenced code info string. + pub lang: Option, + /// Link destination. + pub url: Option, + /// Whether a list is ordered. + pub ordered: Option, + /// Ordered-list starting number. + pub start: Option, + /// Table header cells. + pub headers: Option>, + /// Accordion or chain group name. + pub name: Option, + /// Accordion or chain item title nodes. + pub title: Option>, + /// Whether a chain item is completed. + pub is_completed: Option, + /// Whether a chain item explicitly contains a checkbox. + pub has_checkbox: Option, + /// Table-cell alignment. + pub align: Option, + /// Extension attributes not represented by built-in fields. + pub attributes: BTreeMap, +} + +impl AstNode { + /// Creates an empty AST node with no optional metadata. + pub fn new(node_type: NodeType) -> Self { + Self { + node_type, + content: None, + children: Vec::new(), + level: None, + lang: None, + url: None, + ordered: None, + start: None, + headers: None, + name: None, + title: None, + is_completed: None, + has_checkbox: None, + align: None, + attributes: BTreeMap::new(), + } + } + + /// Creates a text node with the supplied content. + pub fn text(content: impl Into) -> Self { + let mut node = Self::new(NodeType::Text); + node.content = Some(content.into()); + node + } + + /// Creates a node with child nodes. + pub fn with_children(node_type: NodeType, children: Vec) -> Self { + let mut node = Self::new(node_type); + node.children = children; + node + } +} diff --git a/packages/markdown-parser-rust/src/lib.rs b/packages/markdown-parser-rust/src/lib.rs new file mode 100644 index 0000000..18e625b --- /dev/null +++ b/packages/markdown-parser-rust/src/lib.rs @@ -0,0 +1,262 @@ +// packages/markdown-parser-rust/src/lib.rs +//! Fuyeor Flavored Markdown parser and safe HTML renderer for Rust. +//! +//! The implementation mirrors the rule order and AST semantics of the +//! TypeScript reference parser in `packages/markdown-parser`. +#![forbid(unsafe_code)] +#![warn(missing_docs)] + +mod ast; +mod linkify; +mod parser; +mod render; +mod rules; +mod safety; +mod state; + +pub use ast::{Alignment, AstNode, NodeType}; +pub use linkify::{LinkMatch, linkify}; +pub use parser::{Linkifier, MarkdownParser, ParserContext, ParserError, ParserOptions}; +pub use render::{render, render_optional}; +pub use safety::{is_safe_color_value, is_safe_link_url}; +pub use state::{BlockState, InlineState}; + +/// Constructs a parser with the base Markdown rule set. +pub fn create_markdown_parser(options: ParserOptions) -> MarkdownParser { + MarkdownParser::create_standard(options) +} + +/// Constructs a parser with the base Markdown rules and FFM extensions. +pub fn create_fuyeor_markdown_parser(options: ParserOptions) -> MarkdownParser { + MarkdownParser::create_ffm(options) +} + +#[cfg(test)] +mod cross_language_fixtures { + use serde::Deserialize; + use serde_json::{Map, Value, json}; + + use super::{ + AstNode, MarkdownParser, ParserOptions, create_fuyeor_markdown_parser, + create_markdown_parser, is_safe_color_value, is_safe_link_url, linkify, render, + }; + + #[derive(Deserialize)] + struct MarkdownFixtureFile { + schema_version: u32, + cases: Vec, + } + + #[derive(Deserialize)] + struct MarkdownCase { + input: Option, + html: Option, + #[serde(default)] + assert: Vec, + error: Option, + no_throw: Option, + options: Option, + } + + #[derive(Deserialize)] + struct MarkdownAssertion { + path: String, + value: Option, + length: Option, + } + + #[derive(Deserialize)] + struct FixtureOptions { + max_nesting_depth: Option, + } + + #[derive(Deserialize)] + struct SafetyFixtureFile { + schema_version: u32, + links: Vec, + colors: Vec, + } + + #[derive(Deserialize)] + struct SafetyCase { + input: String, + valid: bool, + } + + #[derive(Deserialize)] + struct LinkifyFixtureFile { + schema_version: u32, + cases: Vec, + } + + #[derive(Deserialize)] + struct LinkifyCase { + input: String, + links: Vec, + } + + #[derive(Deserialize)] + struct ExpectedLink { + text: String, + url: String, + } + + // Load a shared JSON fixture from the repository root. + fn read_fixture Deserialize<'de>>(name: &str) -> T { + let path = format!("{}/../../fixtures/{name}", env!("CARGO_MANIFEST_DIR")); + serde_json::from_str(&std::fs::read_to_string(path).expect("fixture must exist")) + .expect("fixture must be valid JSON") + } + + // Convert the Rust AST into the TypeScript-compatible wire projection. + fn normalize_node(node: &AstNode) -> Value { + let mut object = Map::new(); + object.insert("type".into(), json!(node.node_type.as_str())); + if let Some(content) = &node.content { + object.insert("content".into(), json!(content)); + } + if !node.children.is_empty() { + object.insert( + "children".into(), + Value::Array(node.children.iter().map(normalize_node).collect()), + ); + } + if let Some(level) = node.level { + object.insert("level".into(), json!(level)); + } + if let Some(lang) = &node.lang { + object.insert("lang".into(), json!(lang)); + } + if let Some(url) = &node.url { + object.insert("url".into(), json!(url)); + } + if let Some(ordered) = node.ordered { + object.insert("ordered".into(), json!(ordered)); + } + if let Some(start) = node.start { + object.insert("start".into(), json!(start)); + } + if let Some(headers) = &node.headers { + object.insert( + "headers".into(), + Value::Array(headers.iter().map(normalize_node).collect()), + ); + } + if let Some(name) = &node.name { + object.insert("name".into(), json!(name)); + } + if let Some(title) = &node.title { + object.insert( + "title".into(), + Value::Array(title.iter().map(normalize_node).collect()), + ); + } + if let Some(is_completed) = node.is_completed { + object.insert("isCompleted".into(), json!(is_completed)); + } + if let Some(has_checkbox) = node.has_checkbox { + object.insert("hasCheckbox".into(), json!(has_checkbox)); + } + if let Some(align) = node.align { + object.insert("align".into(), json!(align.as_str())); + } + Value::Object(object) + } + + // Read a JSON Pointer from the normalized AST projection. + fn read_json_pointer<'a>(root: &'a Value, pointer: &str) -> Option<&'a Value> { + pointer + .split('/') + .skip(1) + .try_fold(root, |value, segment| match value { + Value::Array(values) => values.get(segment.parse::().ok()?), + Value::Object(values) => values.get(segment), + _ => None, + }) + } + + // Translate fixture options into the Rust parser API. + fn parser_options(case: &MarkdownCase) -> ParserOptions { + ParserOptions { + max_nesting_depth: case + .options + .as_ref() + .and_then(|options| options.max_nesting_depth) + .unwrap_or(64), + ..ParserOptions::default() + } + } + + // Execute one standard or FFM Markdown case against the Rust parser. + fn execute_markdown_case(case: &MarkdownCase, ffm: bool) { + let options = parser_options(case); + if case.error.as_deref() == Some("invalid_nesting_depth") { + assert!(MarkdownParser::try_new(options).is_err()); + return; + } + let parser = if ffm { + create_fuyeor_markdown_parser(options) + } else { + create_markdown_parser(options) + }; + let ast = parser.parse(case.input.as_deref().unwrap_or("")); + if let Some(expected_html) = &case.html { + assert_eq!(render(&ast).trim(), expected_html, "Markdown fixture"); + } + let normalized = Value::Array(ast.iter().map(normalize_node).collect()); + for assertion in &case.assert { + let actual = read_json_pointer(&normalized, &assertion.path) + .unwrap_or_else(|| panic!("missing AST path {}", assertion.path)); + if let Some(length) = assertion.length { + assert_eq!(actual.as_array().map(Vec::len), Some(length)); + } else { + assert_eq!(Some(actual), assertion.value.as_ref()); + } + } + if case.no_throw == Some(true) { + assert!(normalized.is_array()); + } + } + + #[test] + fn executes_shared_markdown_fixtures() { + let standard = read_fixture::("markdown.json"); + assert_eq!(standard.schema_version, 2); + for case in &standard.cases { + execute_markdown_case(case, false); + } + let ffm = read_fixture::("ffm.json"); + assert_eq!(ffm.schema_version, 2); + for case in &ffm.cases { + execute_markdown_case(case, true); + } + } + + #[test] + fn executes_shared_safety_and_linkify_fixtures() { + let safety = read_fixture::("safety.json"); + assert_eq!(safety.schema_version, 1); + for case in safety.links { + assert_eq!(is_safe_link_url(&case.input), case.valid); + } + for case in safety.colors { + assert_eq!(is_safe_color_value(&case.input), case.valid); + } + + let linkify_cases = read_fixture::("linkify.json"); + assert_eq!(linkify_cases.schema_version, 1); + for case in linkify_cases.cases { + let actual = linkify(&case.input); + assert_eq!( + actual.len(), + case.links.len(), + "linkify input: {}", + case.input + ); + for (actual, expected) in actual.iter().zip(case.links.iter()) { + assert_eq!(actual.text, expected.text); + assert_eq!(actual.url, expected.url); + } + } + } +} diff --git a/packages/markdown-parser-rust/src/linkify.rs b/packages/markdown-parser-rust/src/linkify.rs new file mode 100644 index 0000000..9ac1581 --- /dev/null +++ b/packages/markdown-parser-rust/src/linkify.rs @@ -0,0 +1,234 @@ +// packages/markdown-parser-rust/src/linkify.rs + +/// A link match with byte offsets into the original UTF-8 string. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct LinkMatch { + /// Normalized, safe URL. + pub url: String, + /// Visible matched text. + pub text: String, + /// Start byte offset in the source text. + pub index: usize, + /// Exclusive end byte offset in the source text. + pub last_index: usize, +} + +const CC_TLD_BITMAP: [u32; 22] = [ + 0xeedf597c, 0xdeddb9cf, 0x15843f27, 0x1e005480, 0xb0095c00, 0x15fb9f, 0x7818068d, 0x340400f, + 0xf42b1d00, 0xd54f8141, 0x25d7fffc, 0x100084b, 0x538f3c40, 0x40000001, 0xfdf15100, 0x9fbb3be7, + 0x404419a, 0x408557, 0x4002, 0x100000, 0x400408, 0x1, +]; + +/// Detects links using the same conservative candidate policy as the TS package. +pub fn linkify(text: &str) -> Vec { + let mut matches = Vec::new(); + let mut index = 0; + + while index < text.len() { + if !text.is_char_boundary(index) { + index += 1; + continue; + } + + let explicit = + text[index..].starts_with("http://") || text[index..].starts_with("https://"); + let fuzzy_candidate = !explicit + && (index == 0 + || text[..index] + .chars() + .next_back() + .is_some_and(|character| character != '@' && !is_word_character(character))) + && text[index..] + .chars() + .next() + .is_some_and(|character| character.is_ascii_alphanumeric()); + + if !explicit && !fuzzy_candidate { + index += text[index..].chars().next().map_or(1, char::len_utf8); + continue; + } + + let end = candidate_end(text, index, explicit); + if end == index { + index += text[index..].chars().next().map_or(1, char::len_utf8); + continue; + } + + if !explicit + && text[end..] + .chars() + .next() + .is_some_and(is_domain_continuation) + { + index += text[index..].chars().next().map_or(1, char::len_utf8); + continue; + } + + let candidate = &text[index..end]; + let trimmed = trim_url(candidate); + if trimmed.is_empty() { + index += text[index..].chars().next().map_or(1, char::len_utf8); + continue; + } + if !explicit && !is_supported_fuzzy_url(trimmed) { + index += text[index..].chars().next().map_or(1, char::len_utf8); + continue; + } + + let normalized = normalize_match_text(trimmed); + let url = if explicit { + normalized.clone() + } else { + format!("https://{normalized}") + }; + matches.push(LinkMatch { + url, + text: normalized, + index, + last_index: index + trimmed.len(), + }); + index += trimmed.len(); + } + + matches +} + +/// Scans a URL-shaped candidate before applying domain validation. +fn candidate_end(text: &str, start: usize, explicit: bool) -> usize { + if explicit { + return text[start..] + .find(char::is_whitespace) + .map_or(text.len(), |offset| start + offset); + } + + let mut end = start; + let mut in_path = false; + while end < text.len() { + let character = text[end..].chars().next().expect("valid UTF-8 boundary"); + if character.is_whitespace() { + break; + } + if in_path { + end += character.len_utf8(); + } else if character == '/' { + in_path = true; + end += 1; + } else if character.is_alphanumeric() || matches!(character, '-' | '.') { + end += character.len_utf8(); + } else { + break; + } + } + end +} + +/// Keeps domain/path characters broad, then lets the fuzzy-domain validator reject invalid input. +fn is_supported_fuzzy_url(url: &str) -> bool { + let hostname_end = url.find('/').unwrap_or(url.len()); + let hostname = &url[..hostname_end]; + let labels = hostname.split('.').collect::>(); + if labels.len() < 2 || labels.iter().any(|label| label.is_empty()) { + return false; + } + let tld = *labels.last().expect("at least two labels"); + if labels[..labels.len() - 1].iter().any(|label| { + !label + .chars() + .all(|character| character.is_ascii_alphanumeric() || character == '-') + }) { + return false; + } + if tld != "рф" + && !tld + .chars() + .all(|character| character.is_ascii_alphanumeric() || character == '-') + { + return false; + } + + if matches!( + tld, + "рф" | "app" + | "biz" + | "com" + | "dev" + | "edu" + | "gov" + | "int" + | "mil" + | "net" + | "org" + | "pro" + | "web" + | "xyz" + | "xn--p1ai" + ) { + return true; + } + if tld.len() != 2 || !tld.bytes().all(|byte| byte.is_ascii_alphabetic()) { + return false; + } + is_cc_tld(tld) +} + +/// Checks a two-letter TLD against the generated 26x26 bitmap used by linkify. +fn is_cc_tld(tld: &str) -> bool { + let bytes = tld.as_bytes(); + let first = (bytes[0].to_ascii_lowercase() - b'a') as usize; + let second = (bytes[1].to_ascii_lowercase() - b'a') as usize; + let bit_index = first * 26 + second; + (CC_TLD_BITMAP[bit_index >> 5] & (1 << (bit_index & 31))) != 0 +} + +/// Removes terminal punctuation and one unmatched closing parenthesis. +fn trim_url(candidate: &str) -> &str { + let mut end = candidate.len(); + while end > 0 { + let character = candidate[..end] + .chars() + .next_back() + .expect("non-empty slice"); + if ".,:;?!".contains(character) { + end -= character.len_utf8(); + } else { + break; + } + } + + let mut trimmed = &candidate[..end]; + if trimmed.ends_with(')') { + let open_count = trimmed + .chars() + .filter(|character| *character == '(') + .count(); + let close_count = trimmed + .chars() + .filter(|character| *character == ')') + .count(); + if close_count > open_count { + trimmed = &trimmed[..trimmed.len() - 1]; + } + } + trimmed +} + +/// Converts the supported Russian punycode suffix while preserving the visible match span. +fn normalize_match_text(url: &str) -> String { + let hostname_end = url.find('/').unwrap_or(url.len()); + let suffix = ".xn--p1ai"; + if hostname_end <= suffix.len() || !url[..hostname_end].ends_with(suffix) { + return url.to_owned(); + } + + let suffix_start = hostname_end - suffix.len(); + format!("{}.рф{}", &url[..suffix_start], &url[hostname_end..]) +} + +fn is_word_character(character: char) -> bool { + character.is_ascii_alphanumeric() || character == '_' +} + +/// Prevents fuzzy matches from ending inside a Unicode word. +fn is_domain_continuation(character: char) -> bool { + character.is_alphanumeric() || matches!(character, '_' | '-') +} diff --git a/packages/markdown-parser-rust/src/parser.rs b/packages/markdown-parser-rust/src/parser.rs new file mode 100644 index 0000000..aba575c --- /dev/null +++ b/packages/markdown-parser-rust/src/parser.rs @@ -0,0 +1,434 @@ +// packages/markdown-parser-rust/src/parser.rs +use std::cell::RefCell; +use std::collections::HashMap; + +use crate::ast::{AstNode, NodeType}; +use crate::linkify::{LinkMatch, linkify}; +use crate::rules::{BlockRule, FfmBlockRule, InlineRule}; +use crate::safety::is_safe_link_url; +use crate::state::{BlockState, InlineState}; + +const LINKIFY_CANDIDATE_MARKERS: &[char] = &['.']; +const PRELIGHT_BLOCK_RULES: &[&str] = &[ + "heading", + "hr", + "blockquote", + "list", + "code_block", + "ffm_blocks", +]; + +/// A linkifier callback used by the parser's inline text projection. +pub type Linkifier = fn(&str) -> Vec; + +/// Parser options matching the TypeScript parser constructor. +#[derive(Clone, Copy)] +pub struct ParserOptions { + /// Maximum recursive block/inline parsing depth. + pub max_nesting_depth: usize, + /// Callback used to discover links in plain text. + pub linkifier: Linkifier, +} + +impl Default for ParserOptions { + fn default() -> Self { + Self { + max_nesting_depth: 64, + linkifier: linkify, + } + } +} + +/// Errors returned when parser configuration is invalid. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ParserError { + /// The configured maximum nesting depth is zero. + InvalidMaxNestingDepth, +} + +impl std::fmt::Display for ParserError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::InvalidMaxNestingDepth => { + formatter.write_str("max_nesting_depth must be a positive integer") + } + } + } +} + +impl std::error::Error for ParserError {} + +/// Recursive parser context passed to custom block and inline rules. +pub struct ParserContext<'a> { + pub(crate) parser: &'a MarkdownParser, + pub(crate) depth: usize, + runtime: &'a RefCell, +} + +impl ParserContext<'_> { + /// Recursively parses inline content using the current parser configuration. + pub fn parse_inline(&self, content: &str) -> Vec { + self.parser + .parse_inline_inner(content, self.depth + 1, self.runtime) + } + + /// Recursively parses block content using the current parser configuration. + pub fn parse_blocks(&self, content: &str) -> Vec { + self.parser + .parse_blocks_inner(content, self.depth + 1, self.runtime) + } + + /// Creates an ID with the probe suffix during paragraph preflight. + pub fn create_id(&self, prefix: &str) -> String { + if self.runtime.borrow().is_preflight { + format!("{prefix}-probe") + } else { + let mut runtime = self.runtime.borrow_mut(); + let sequence = runtime.id_sequence; + runtime.id_sequence += 1; + format!("{prefix}-{sequence}") + } + } +} + +/// State isolated to one parser invocation. +#[derive(Default)] +struct ParseRuntime { + id_sequence: usize, + is_preflight: bool, +} + +/// A lightweight, extensible Markdown and FFM parser. +pub struct MarkdownParser { + block_rules: Vec>, + inline_rules: Vec>, + block_rule_map: HashMap>, + inline_rule_map: HashMap>, + max_nesting_depth: usize, + linkifier: Linkifier, +} + +impl MarkdownParser { + /// Creates a parser and fails fast if its options are invalid. + pub fn new(options: ParserOptions) -> Self { + Self::try_new(options).expect("invalid MarkdownParser options") + } + + /// Creates a parser without panicking on invalid configuration. + pub fn try_new(options: ParserOptions) -> Result { + if options.max_nesting_depth == 0 { + return Err(ParserError::InvalidMaxNestingDepth); + } + Ok(Self { + block_rules: Vec::new(), + inline_rules: Vec::new(), + block_rule_map: HashMap::new(), + inline_rule_map: HashMap::new(), + max_nesting_depth: options.max_nesting_depth, + linkifier: options.linkifier, + }) + } + + /// Registers a block rule and appends it to the matching marker lists. + pub fn add_block_rule(&mut self, rule: R) -> &mut Self + where + R: BlockRule + 'static, + { + let rule_index = self.block_rules.len(); + let markers = rule.markers(); + self.block_rules.push(Box::new(rule)); + for marker in markers { + self.block_rule_map + .entry(*marker) + .or_default() + .push(rule_index); + } + self + } + + /// Registers an inline rule at the front of each matching marker list. + pub fn add_inline_rule(&mut self, rule: R) -> &mut Self + where + R: InlineRule + 'static, + { + let rule_index = self.inline_rules.len(); + let markers = rule.markers(); + self.inline_rules.push(Box::new(rule)); + for marker in markers { + self.inline_rule_map + .entry(*marker) + .or_default() + .insert(0, rule_index); + } + self + } + + /// Installs a plugin that can register rules or otherwise configure the parser. + pub fn use_plugin(&mut self, plugin: fn(&mut MarkdownParser)) -> &mut Self { + plugin(self); + self + } + + /// Parses a complete document and resets generated FFM IDs for this invocation. + pub fn parse(&self, content: &str) -> Vec { + let runtime = RefCell::new(ParseRuntime::default()); + self.parse_blocks_inner(content, 0, &runtime) + } + + /// Parses the input with the standard rule set. + pub fn create_standard(options: ParserOptions) -> Self { + let mut parser = Self::new(options); + parser.add_block_rule(crate::rules::CodeBlockRule); + parser.add_block_rule(crate::rules::ListRule); + parser.add_block_rule(crate::rules::HeadingRule); + parser.add_block_rule(crate::rules::TableRule); + parser.add_block_rule(crate::rules::HrRule); + parser.add_block_rule(crate::rules::BlockquoteRule); + parser.add_inline_rule(crate::rules::HardBreakRule); + parser.add_inline_rule(crate::rules::InlineCodeRule); + parser.add_inline_rule(crate::rules::LinkRule); + parser.add_inline_rule(crate::rules::BoldRule); + parser.add_inline_rule(crate::rules::UnderlineRule); + parser.add_inline_rule(crate::rules::ItalicRule); + parser.add_inline_rule(crate::rules::StrikeRule); + parser + } + + /// Parses the input with the standard rule set plus FFM fenced blocks. + pub fn create_ffm(options: ParserOptions) -> Self { + let mut parser = Self::new(options); + parser.add_block_rule(FfmBlockRule); + parser.add_block_rule(crate::rules::CodeBlockRule); + parser.add_block_rule(crate::rules::ListRule); + parser.add_block_rule(crate::rules::HeadingRule); + parser.add_block_rule(crate::rules::TableRule); + parser.add_block_rule(crate::rules::HrRule); + parser.add_block_rule(crate::rules::BlockquoteRule); + parser.add_inline_rule(crate::rules::HardBreakRule); + parser.add_inline_rule(crate::rules::InlineCodeRule); + parser.add_inline_rule(crate::rules::LinkRule); + parser.add_inline_rule(crate::rules::BoldRule); + parser.add_inline_rule(crate::rules::UnderlineRule); + parser.add_inline_rule(crate::rules::ItalicRule); + parser.add_inline_rule(crate::rules::StrikeRule); + parser + } + + /// Parses block content at a specific recursion depth. + fn parse_blocks_inner( + &self, + content: &str, + depth: usize, + runtime: &RefCell, + ) -> Vec { + let mut state = BlockState::new(content); + self.parse_blocks_state(&mut state, depth, runtime) + } + + /// Runs block rules and merges unmatched consecutive lines into paragraphs. + fn parse_blocks_state( + &self, + state: &mut BlockState, + depth: usize, + runtime: &RefCell, + ) -> Vec { + if depth > self.max_nesting_depth { + return vec![AstNode::with_children( + NodeType::Paragraph, + vec![AstNode::text(state.remaining_lines().join("\n"))], + )]; + } + + let context = ParserContext { + parser: self, + depth, + runtime, + }; + let mut nodes = Vec::new(); + while state.line_index < state.lines.len() { + let Some(line) = state.current_line() else { + break; + }; + if line.is_empty() || line.trim().is_empty() { + state.advance(1); + continue; + } + + let first_char = line.trim_start().chars().next(); + let mut matched = false; + if let Some(first_char) = first_char + && let Some(rule_indices) = self.block_rule_map.get(&first_char) + { + for rule_index in rule_indices { + if let Some(result) = self.block_rules[*rule_index].parse(state, &context) { + nodes.push(result.node); + state.advance(result.consumed_lines); + matched = true; + break; + } + } + } + + if matched { + continue; + } + + let mut paragraph_lines = Vec::new(); + while state.line_index < state.lines.len() { + let Some(current_line) = state.current_line() else { + break; + }; + if current_line.is_empty() || current_line.trim().is_empty() { + break; + } + let first = current_line.trim_start().chars().next(); + let may_interrupt = first.is_some_and(|character| { + matches!( + character, + '#' | '*' | '_' | '+' | '-' | '0'..='9' | '>' | '`' | '~' + ) + }); + if may_interrupt { + let mut is_interrupted = false; + if let Some(first) = first + && let Some(rule_indices) = self.block_rule_map.get(&first) + { + for rule_index in rule_indices { + let rule = &self.block_rules[*rule_index]; + if PRELIGHT_BLOCK_RULES.contains(&rule.name()) { + runtime.borrow_mut().is_preflight = true; + let result = rule.parse(state, &context).is_some(); + runtime.borrow_mut().is_preflight = false; + if result { + is_interrupted = true; + break; + } + } + } + } + if is_interrupted && !paragraph_lines.is_empty() { + break; + } + } + paragraph_lines.push(current_line.to_owned()); + state.advance(1); + } + + if !paragraph_lines.is_empty() { + let inline_content = paragraph_lines.join("\n"); + nodes.push(AstNode::with_children( + NodeType::Paragraph, + self.parse_inline_inner(&inline_content, depth, runtime), + )); + } + } + nodes + } + + /// Parses inline content at a specific recursion depth. + fn parse_inline_inner( + &self, + content: &str, + depth: usize, + runtime: &RefCell, + ) -> Vec { + let mut state = InlineState::new(content); + self.parse_inline_state(&mut state, depth, runtime) + } + + /// Runs marker-selected inline rules and linkifies the remaining text. + fn parse_inline_state( + &self, + state: &mut InlineState, + depth: usize, + runtime: &RefCell, + ) -> Vec { + if depth > self.max_nesting_depth { + return vec![AstNode::text(&state.content[state.pos..])]; + } + + let context = ParserContext { + parser: self, + depth, + runtime, + }; + let mut nodes = Vec::new(); + let mut text_start = state.pos; + while state.pos < state.content.len() { + let Some(character) = state.current_char() else { + break; + }; + if let Some(rule_indices) = self.inline_rule_map.get(&character).cloned() { + let mut matched = false; + for rule_index in rule_indices { + if let Some(result) = self.inline_rules[rule_index].parse(state, &context) { + self.flush_text( + &mut nodes, + &state.content[text_start..state.pos], + text_start, + ); + nodes.push(result.node); + state.advance(result.consumed_bytes); + text_start = state.pos; + matched = true; + break; + } + } + if matched { + continue; + } + } + + if character == '\n' { + self.flush_text( + &mut nodes, + &state.content[text_start..state.pos], + text_start, + ); + nodes.push(AstNode::new(NodeType::Hardbreak)); + state.advance(1); + text_start = state.pos; + continue; + } + state.advance(character.len_utf8()); + } + self.flush_text(&mut nodes, &state.content[text_start..], text_start); + nodes + } + + /// Converts a plain-text range into safe links and text nodes. + fn flush_text(&self, nodes: &mut Vec, text: &str, _offset: usize) { + if text.is_empty() || !has_linkify_candidate(text) { + if !text.is_empty() { + nodes.push(AstNode::text(text)); + } + return; + } + + let mut last_index = 0; + for matched in (self.linkifier)(text) { + if !is_safe_link_url(&matched.url) { + continue; + } + if matched.index > last_index { + nodes.push(AstNode::text(&text[last_index..matched.index])); + } + let mut link = AstNode::new(NodeType::Link); + link.url = Some(matched.url); + link.children = vec![AstNode::text(matched.text)]; + nodes.push(link); + last_index = matched.last_index; + } + if last_index < text.len() { + nodes.push(AstNode::text(&text[last_index..])); + } + } +} + +/// Avoids the linkifier call for text that cannot contain an URL candidate. +fn has_linkify_candidate(text: &str) -> bool { + text.contains("://") + || text + .chars() + .any(|character| LINKIFY_CANDIDATE_MARKERS.contains(&character)) +} diff --git a/packages/markdown-parser-rust/src/render.rs b/packages/markdown-parser-rust/src/render.rs new file mode 100644 index 0000000..58bf07f --- /dev/null +++ b/packages/markdown-parser-rust/src/render.rs @@ -0,0 +1,250 @@ +// packages/markdown-parser-rust/src/render.rs +use std::fmt::Write; + +use crate::ast::{AstNode, NodeType}; +use crate::safety::{is_safe_color_value, is_safe_link_url}; + +/// Renders AST nodes to the same safe HTML shape as the TypeScript renderer. +pub fn render(nodes: &[AstNode]) -> String { + let mut html = String::new(); + render_into(&mut html, nodes); + html +} + +/// Renders an optional node list, matching the TypeScript optional argument behavior. +pub fn render_optional(nodes: Option<&[AstNode]>) -> String { + nodes.map_or_else(String::new, render) +} + +/// Escapes the five characters that can change HTML parsing semantics. +fn escape_html(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +/// Renders a node list without creating temporary child strings. +fn render_into(output: &mut String, nodes: &[AstNode]) { + for node in nodes { + match &node.node_type { + NodeType::Heading => { + if let Some(level @ 1..=6) = node.level { + let _ = write!(output, ""); + render_into(output, &node.children); + let _ = writeln!(output, ""); + } else { + output.push_str(""); + render_into(output, &node.children); + output.push_str(""); + } + } + NodeType::Paragraph => { + output.push_str("

"); + render_into(output, &node.children); + output.push_str("

\n"); + } + NodeType::Text => { + if let Some(content) = &node.content { + output.push_str(&escape_html(content)); + } + } + NodeType::Bold => { + output.push_str(""); + render_into(output, &node.children); + output.push_str(""); + } + NodeType::Italic => { + output.push_str(""); + render_into(output, &node.children); + output.push_str(""); + } + NodeType::Underline => { + output.push_str(""); + render_into(output, &node.children); + output.push_str(""); + } + NodeType::Strike => { + output.push_str(""); + render_into(output, &node.children); + output.push_str(""); + } + NodeType::InlineCode => { + output.push_str(""); + if let Some(content) = &node.content { + output.push_str(&escape_html(content)); + } + output.push_str(""); + } + NodeType::ColorCode => { + let color = node.content.as_deref().unwrap_or_default(); + if !is_safe_color_value(color) { + output.push_str(&escape_html(color)); + } else { + let escaped = escape_html(color); + let _ = write!( + output, + "{escaped}" + ); + } + } + NodeType::Link => { + let url = node.url.as_deref().unwrap_or_default().trim(); + if is_safe_link_url(url) { + let escaped = escape_html(url); + let _ = write!(output, ""); + render_into(output, &node.children); + output.push_str(""); + } else { + render_into(output, &node.children); + } + } + NodeType::CodeBlock => { + output.push_str("
"); + if let Some(lang) = node.lang.as_deref().filter(|lang| !lang.is_empty()) { + let _ = write!( + output, + "
{}
", + escape_html(lang) + ); + } + output.push_str("
');
+                if let Some(content) = &node.content {
+                    output.push_str(&escape_html(content));
+                }
+                output.push_str("\n
\n"); + } + NodeType::List => { + let tag = if node.ordered.unwrap_or(false) { + "ol" + } else { + "ul" + }; + let start = if node.ordered.unwrap_or(false) + && node.start.is_some_and(|value| value != 1) + { + format!(" start=\"{}\"", node.start.unwrap_or_default()) + } else { + String::new() + }; + let _ = writeln!(output, "<{tag}{start}>"); + render_into(output, &node.children); + let _ = writeln!(output, ""); + } + NodeType::ListItem => { + output.push_str("
  • "); + render_into(output, &node.children); + output.push_str("
  • \n"); + } + NodeType::Table => { + output.push_str("\n"); + if let Some(headers) = &node.headers { + output.push_str("\n\n"); + for cell in headers { + let _ = write!(output, "", table_alignment(cell)); + render_into(output, &cell.children); + output.push_str("\n"); + } + output.push_str("\n\n"); + } + if !node.children.is_empty() { + output.push_str("\n"); + for row in &node.children { + output.push_str("\n"); + for cell in &row.children { + let _ = write!(output, "", table_alignment(cell)); + render_into(output, &cell.children); + output.push_str("\n"); + } + output.push_str("\n"); + } + output.push_str("\n"); + } + output.push_str("
    \n"); + } + NodeType::Hr => output.push_str("
    \n"), + NodeType::Blockquote => { + output.push_str("
    \n"); + render_into(output, &node.children); + output.push_str("
    \n"); + } + NodeType::Accordion => { + output.push_str("
    "); + render_into(output, &node.children); + output.push_str("
    "); + } + NodeType::AccordionItem => { + let name = escape_html(node.name.as_deref().unwrap_or_default()); + let _ = write!(output, "
    "); + render_optional_into(output, node.title.as_deref()); + output.push_str("
    "); + render_into(output, &node.children); + output.push_str("
    "); + } + NodeType::Chain => { + output.push_str("
    "); + render_into(output, &node.children); + output.push_str("
    "); + } + NodeType::ChainItem => { + let status_class = match (node.has_checkbox, node.is_completed) { + (Some(true), Some(true)) => "is-completed", + (Some(true), _) => "is-pending", + _ => "", + }; + let title = if node.title.as_ref().is_some_and(|title| !title.is_empty()) { + let mut title_html = String::from("
    "); + render_optional_into(&mut title_html, node.title.as_deref()); + title_html.push_str("
    "); + title_html + } else { + String::new() + }; + let _ = write!( + output, + "
    {title}
    " + ); + render_into(output, &node.children); + output.push_str("
    "); + } + NodeType::Slide => { + output.push_str( + "
    ", + ); + render_into(output, &node.children); + output.push_str("
    "); + } + NodeType::SlideItem => { + output.push_str("
    "); + render_into(output, &node.children); + output.push_str("
    "); + } + NodeType::Hardbreak => output.push_str("
    \n"), + NodeType::Root | NodeType::TableRow | NodeType::TableCell | NodeType::Custom(_) => { + output.push_str(""); + render_into(output, &node.children); + output.push_str(""); + } + } + } +} + +/// Renders optional title children without allocating when absent. +fn render_optional_into(output: &mut String, nodes: Option<&[AstNode]>) { + if let Some(nodes) = nodes { + render_into(output, nodes); + } +} + +/// Returns a safe table alignment attribute. +fn table_alignment(node: &AstNode) -> String { + node.align.map_or_else(String::new, |alignment| { + format!(" align=\"{}\"", alignment.as_str()) + }) +} diff --git a/packages/markdown-parser-rust/src/rules.rs b/packages/markdown-parser-rust/src/rules.rs new file mode 100644 index 0000000..0d5646e --- /dev/null +++ b/packages/markdown-parser-rust/src/rules.rs @@ -0,0 +1,981 @@ +// packages/markdown-parser-rust/src/rules.rs +use crate::ast::{Alignment, AstNode, NodeType}; +use crate::parser::ParserContext; +use crate::safety::{is_safe_color_value, is_safe_link_url}; +use crate::state::{BlockState, InlineState}; + +/// Result returned by a successful block rule. +pub struct BlockMatch { + pub node: AstNode, + pub consumed_lines: usize, +} + +/// Result returned by a successful inline rule. +pub struct InlineMatch { + pub node: AstNode, + pub consumed_bytes: usize, +} + +/// Extensible block-rule interface matching the TypeScript parser contract. +pub trait BlockRule { + fn name(&self) -> &'static str; + fn markers(&self) -> &'static [char]; + fn parse(&self, state: &BlockState, ctx: &ParserContext<'_>) -> Option; +} + +/// Extensible inline-rule interface matching the TypeScript parser contract. +pub trait InlineRule { + fn name(&self) -> &'static str; + fn markers(&self) -> &'static [char]; + fn parse(&self, state: &mut InlineState, ctx: &ParserContext<'_>) -> Option; +} + +/// Represents the content of a fenced code block. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct FencedBlock { + pub lang: String, + pub content: String, + pub consumed_lines: usize, +} + +/// Extracts a fenced block using the same opening and closing rules as TypeScript. +pub fn extract_fenced_block(state: &BlockState) -> Option { + let line = state.current_line()?; + let (indent, fence, info) = parse_fence_opening(line)?; + let mut consumed_lines = 1; + let mut content_lines = Vec::new(); + + while state.line_index + consumed_lines < state.lines.len() { + let next_line = &state.lines[state.line_index + consumed_lines]; + consumed_lines += 1; + if let Some((_, close_fence)) = parse_fence_closing(next_line) + && close_fence.as_bytes().first() == fence.as_bytes().first() + && close_fence.len() >= fence.len() + { + break; + } + + if next_line.len() >= indent && next_line[..indent].chars().all(|c| c == ' ') { + content_lines.push(next_line[indent..].to_owned()); + } else { + content_lines.push(next_line.clone()); + } + } + + Some(FencedBlock { + lang: info.trim().to_owned(), + content: content_lines.join("\n"), + consumed_lines, + }) +} + +/// Parses a fenced-block opening line with zero to three leading spaces. +fn parse_fence_opening(line: &str) -> Option<(usize, String, &str)> { + let indent = line.chars().take_while(|c| *c == ' ').count(); + if indent > 3 { + return None; + } + let rest = &line[indent..]; + let marker = rest.chars().next()?; + if marker != '`' && marker != '~' { + return None; + } + let fence_len = rest.chars().take_while(|c| *c == marker).count(); + if fence_len < 3 { + return None; + } + let info = &rest[marker.len_utf8() * fence_len..]; + if info.contains('`') { + return None; + } + Some(( + indent, + rest[..marker.len_utf8() * fence_len].to_owned(), + info, + )) +} + +/// Parses a fence closing line and accepts only spaces after the marker. +fn parse_fence_closing(line: &str) -> Option<(usize, String)> { + let indent = line.chars().take_while(|c| *c == ' ').count(); + if indent > 3 { + return None; + } + let rest = &line[indent..]; + let marker = rest.chars().next()?; + if marker != '`' && marker != '~' { + return None; + } + let length = rest.chars().take_while(|c| *c == marker).count(); + if length < 3 + || !rest[marker.len_utf8() * length..] + .chars() + .all(char::is_whitespace) + { + return None; + } + Some((indent, rest[..marker.len_utf8() * length].to_owned())) +} + +/// Parses ATX headings. +pub struct HeadingRule; + +impl BlockRule for HeadingRule { + fn name(&self) -> &'static str { + "heading" + } + + fn markers(&self) -> &'static [char] { + &['#'] + } + + fn parse(&self, state: &BlockState, ctx: &ParserContext<'_>) -> Option { + let line = state.current_line()?; + let indent = line.chars().take_while(|c| *c == ' ').count(); + if indent > 3 { + return None; + } + let rest = &line[indent..]; + let level = rest.chars().take_while(|c| *c == '#').count(); + if !(1..=6).contains(&level) { + return None; + } + let after = &rest[level..]; + if !after.is_empty() && !after.starts_with(char::is_whitespace) { + return None; + } + let mut text = after.trim().to_owned(); + if let Some(hash_start) = text.rfind(char::is_whitespace) + && text[hash_start..].trim().chars().all(|c| c == '#') + { + text.truncate(hash_start); + text = text.trim_end().to_owned(); + } else if text.chars().all(|c| c == '#') { + text.clear(); + } + + let mut node = AstNode::new(NodeType::Heading); + node.level = Some(level as u8); + node.children = if text.is_empty() { + Vec::new() + } else { + ctx.parse_inline(&text) + }; + Some(BlockMatch { + node, + consumed_lines: 1, + }) + } +} + +/// Parses fenced code blocks. +pub struct CodeBlockRule; + +impl BlockRule for CodeBlockRule { + fn name(&self) -> &'static str { + "code_block" + } + + fn markers(&self) -> &'static [char] { + &['`', '~'] + } + + fn parse(&self, state: &BlockState, _ctx: &ParserContext<'_>) -> Option { + let block = extract_fenced_block(state)?; + let mut node = AstNode::new(NodeType::CodeBlock); + node.lang = Some(block.lang); + node.content = Some(block.content); + Some(BlockMatch { + node, + consumed_lines: block.consumed_lines, + }) + } +} + +const TABLE_CELL_PATTERN: fn(&str) -> bool = |cell| { + let trimmed = cell.trim(); + let value = trimmed + .strip_prefix(':') + .unwrap_or(trimmed) + .strip_suffix(':') + .unwrap_or(trimmed.strip_prefix(':').unwrap_or(trimmed)); + !value.is_empty() && value.chars().all(|c| c == '-') +}; + +/// Splits table rows while treating escaped pipes as cell content. +fn extract_table_cells(row: &str) -> Vec { + if !row.contains('\\') { + let mut cells = row + .split('|') + .map(str::trim) + .map(str::to_owned) + .collect::>(); + if cells.first().is_some_and(|cell| cell.is_empty()) { + cells.remove(0); + } + if cells.last().is_some_and(|cell| cell.is_empty()) { + cells.pop(); + } + return cells; + } + + let mut cells = Vec::new(); + let mut cell = String::new(); + let mut escaped = false; + for character in row.chars() { + if escaped { + if character == '|' || character == '\\' { + cell.push(character); + } else { + cell.push('\\'); + cell.push(character); + } + escaped = false; + } else if character == '\\' { + escaped = true; + } else if character == '|' { + cells.push(std::mem::take(&mut cell)); + } else { + cell.push(character); + } + } + if escaped { + cell.push('\\'); + } + cells.push(cell); + if cells + .first() + .is_some_and(|value: &String| value.trim().is_empty()) + { + cells.remove(0); + } + if cells + .last() + .is_some_and(|value: &String| value.trim().is_empty()) + { + cells.pop(); + } + cells + .into_iter() + .map(|value| value.trim().to_owned()) + .collect() +} + +/// Converts separator cells into table alignment metadata. +fn parse_table_alignments(line: &str) -> Option>> { + let cells = extract_table_cells(line); + if cells.is_empty() || cells.iter().any(|cell| !TABLE_CELL_PATTERN(cell)) { + return None; + } + Some( + cells + .into_iter() + .map(|cell| { + let starts = cell.starts_with(':'); + let ends = cell.ends_with(':'); + match (starts, ends) { + (true, true) => Some(Alignment::Center), + (true, false) => Some(Alignment::Left), + (false, true) => Some(Alignment::Right), + (false, false) => None, + } + }) + .collect(), + ) +} + +/// Pads or truncates a row to the separator-defined column count. +fn normalize_table_cells(mut cells: Vec, column_count: usize) -> Vec { + cells.truncate(column_count); + cells.resize(column_count, String::new()); + cells +} + +/// Creates a table cell without allocating a default alignment. +fn create_table_cell( + content: &str, + alignment: Option, + ctx: &ParserContext<'_>, +) -> AstNode { + let mut node = AstNode::with_children(NodeType::TableCell, ctx.parse_inline(content)); + node.align = alignment; + node +} + +/// Parses pipe tables. +pub struct TableRule; + +impl BlockRule for TableRule { + fn name(&self) -> &'static str { + "table" + } + + fn markers(&self) -> &'static [char] { + &['|'] + } + + fn parse(&self, state: &BlockState, ctx: &ParserContext<'_>) -> Option { + let line = state.current_line()?; + if !line.contains('|') { + return None; + } + + let mut alignments = parse_table_alignments(line); + let mut header_cells = None; + let mut consumed_lines = if alignments.is_some() { 1 } else { 0 }; + if alignments.is_none() { + let next = state.lines.get(state.line_index + 1)?; + alignments = parse_table_alignments(next); + let parsed_headers = extract_table_cells(line); + if alignments.as_ref()?.len() != parsed_headers.len() { + return None; + } + header_cells = Some(parsed_headers); + consumed_lines = 2; + } + + let alignments = alignments?; + let column_count = alignments.len(); + let headers = header_cells.map(|cells| normalize_table_cells(cells, column_count)); + let mut rows = Vec::new(); + while let Some(row_line) = state.lines.get(state.line_index + consumed_lines) + && row_line.contains('|') + { + let cells = normalize_table_cells(extract_table_cells(row_line), column_count); + let row_children = cells + .iter() + .enumerate() + .map(|(index, cell)| create_table_cell(cell, alignments[index], ctx)) + .collect(); + rows.push(AstNode::with_children(NodeType::TableRow, row_children)); + consumed_lines += 1; + } + + let mut node = AstNode::with_children(NodeType::Table, rows); + node.headers = headers.map(|cells| { + cells + .iter() + .enumerate() + .map(|(index, cell)| create_table_cell(cell, alignments[index], ctx)) + .collect() + }); + Some(BlockMatch { + node, + consumed_lines, + }) + } +} + +/// Parses thematic breaks. +pub struct HrRule; + +impl BlockRule for HrRule { + fn name(&self) -> &'static str { + "hr" + } + + fn markers(&self) -> &'static [char] { + &['-', '*', '_'] + } + + fn parse(&self, state: &BlockState, _ctx: &ParserContext<'_>) -> Option { + let line = state.current_line()?; + if state.line_index > 0 && !state.lines[state.line_index - 1].trim().is_empty() { + return None; + } + let symbols = line + .chars() + .filter(|c| matches!(c, '-' | '*' | '_')) + .count(); + if symbols < 3 + || line + .chars() + .any(|c| !matches!(c, '-' | '*' | '_' | ' ' | '\t')) + { + return None; + } + Some(BlockMatch { + node: AstNode::new(NodeType::Hr), + consumed_lines: 1, + }) + } +} + +/// Parses blockquotes and lazy continuation lines. +pub struct BlockquoteRule; + +impl BlockRule for BlockquoteRule { + fn name(&self) -> &'static str { + "blockquote" + } + + fn markers(&self) -> &'static [char] { + &['>'] + } + + fn parse(&self, state: &BlockState, ctx: &ParserContext<'_>) -> Option { + let first = state.current_line()?; + if !first.trim_start().starts_with('>') { + return None; + } + let mut content_lines = Vec::new(); + let mut consumed_lines = 0; + while let Some(line) = state.lines.get(state.line_index + consumed_lines) { + if let Some(content) = line.trim_start().strip_prefix('>') { + content_lines.push(content.strip_prefix(' ').unwrap_or(content).to_owned()); + } else if !line.trim().is_empty() && !content_lines.is_empty() { + content_lines.push(line.trim_start().to_owned()); + } else { + break; + } + consumed_lines += 1; + } + let mut node = AstNode::new(NodeType::Blockquote); + node.children = ctx.parse_blocks(&content_lines.join("\n")); + Some(BlockMatch { + node, + consumed_lines, + }) + } +} + +const LIST_MARKERS: &[char] = &['-', '*', '+', '1', '2', '3', '4', '5', '6', '7', '8', '9']; + +/// Parses a list-item prefix and returns indentation, marker and content. +fn parse_list_item(line: &str) -> Option<(usize, String, String)> { + let indent = line.chars().take_while(|c| c.is_whitespace()).count(); + let rest = &line[indent..]; + let first = rest.chars().next()?; + let marker_len = if matches!(first, '-' | '*' | '+') { + 1 + } else if first.is_ascii_digit() { + let digits = rest.chars().take_while(|c| c.is_ascii_digit()).count(); + if !(1..=9).contains(&digits) + || !matches!(rest[digits..].chars().next(), Some('.') | Some(')')) + { + return None; + } + digits + 1 + } else { + return None; + }; + let after_marker = &rest[marker_len..]; + let whitespace = after_marker + .chars() + .take_while(|c| c.is_whitespace()) + .count(); + if whitespace == 0 { + return None; + } + let content = after_marker[whitespace..].to_owned(); + Some((indent, rest[..marker_len].to_owned(), content)) +} + +/// Parses unordered and ordered lists with two-space nesting steps. +pub struct ListRule; + +impl BlockRule for ListRule { + fn name(&self) -> &'static str { + "list" + } + + fn markers(&self) -> &'static [char] { + LIST_MARKERS + } + + fn parse(&self, state: &BlockState, ctx: &ParserContext<'_>) -> Option { + let first_line = state.current_line()?; + let (base_indent, marker, _) = parse_list_item(first_line)?; + let ordered = marker.chars().next()?.is_ascii_digit(); + let start_number = ordered.then(|| { + marker[..marker.len() - 1] + .parse::() + .expect("validated list number") + }); + let mut items = Vec::new(); + let mut consumed_lines = 0; + + while let Some(current_line) = state.lines.get(state.line_index + consumed_lines) { + let Some((item_indent, item_marker, item_content)) = parse_list_item(current_line) + else { + break; + }; + if item_indent != base_indent { + break; + } + let mut item_lines = vec![item_content]; + let mut item_consumed_lines = 1; + let marker_total_width = base_indent + marker.len() + 1; + let nested_list_indent = base_indent + 2; + + while let Some(next_line) = state + .lines + .get(state.line_index + consumed_lines + item_consumed_lines) + { + if next_line.trim().is_empty() { + item_lines.push(String::new()); + item_consumed_lines += 1; + continue; + } + let next_indent = next_line.chars().take_while(|c| c.is_whitespace()).count(); + let first_content_char = next_line[next_indent..].chars().next(); + let is_list_marker_candidate = + first_content_char.is_some_and(|c| matches!(c, '-' | '*' | '+' | '0'..='9')); + let mut normalized_content_start = marker_total_width; + if next_indent >= nested_list_indent && is_list_marker_candidate { + let relative_indent = next_indent - base_indent; + let nesting_level = relative_indent / 2; + let content_indent = nesting_level.saturating_sub(1) * 2; + normalized_content_start = next_indent - content_indent; + } + + if normalized_content_start != marker_total_width + && parse_list_item(next_line).is_some() + { + item_lines.push(next_line[normalized_content_start..].to_owned()); + item_consumed_lines += 1; + } else if next_indent >= marker_total_width { + item_lines.push(next_line[marker_total_width..].to_owned()); + item_consumed_lines += 1; + } else if next_indent > base_indent + && !next_line.trim_start().starts_with(['-', '*', '+']) + && !next_line + .trim_start() + .chars() + .next() + .is_some_and(|c| c.is_ascii_digit()) + { + item_lines.push(next_line.trim_start().to_owned()); + item_consumed_lines += 1; + } else { + break; + } + } + + let mut item = AstNode::new(NodeType::ListItem); + item.children = ctx.parse_blocks(&item_lines.join("\n")); + items.push(item); + consumed_lines += item_consumed_lines; + let _ = item_marker; + } + + let mut node = AstNode::with_children(NodeType::List, items); + node.ordered = Some(ordered); + node.start = start_number; + Some(BlockMatch { + node, + consumed_lines, + }) + } +} + +/// Parses FFM inline line breaks. +pub struct HardBreakRule; + +impl InlineRule for HardBreakRule { + fn name(&self) -> &'static str { + "hardbreak" + } + + fn markers(&self) -> &'static [char] { + &['\\', ' '] + } + + fn parse(&self, state: &mut InlineState, _ctx: &ParserContext<'_>) -> Option { + if state.current_char() == Some('\\') && state.content[state.pos + 1..].starts_with('\n') { + Some(InlineMatch { + node: AstNode::new(NodeType::Hardbreak), + consumed_bytes: 2, + }) + } else { + None + } + } +} + +/// Parses variable-length inline code fences. +pub struct InlineCodeRule; + +impl InlineRule for InlineCodeRule { + fn name(&self) -> &'static str { + "inline_code" + } + + fn markers(&self) -> &'static [char] { + &['`'] + } + + fn parse(&self, state: &mut InlineState, _ctx: &ParserContext<'_>) -> Option { + if state.current_char() != Some('`') { + return None; + } + let marker_len = state.content[state.pos..] + .chars() + .take_while(|c| *c == '`') + .count(); + let marker = "`".repeat(marker_len); + let mut current_pos = state.pos + marker_len; + let mut end = None; + while current_pos < state.content.len() { + let Some(found) = state.find_next_token(&marker, current_pos) else { + break; + }; + if state.content[found + marker_len..].starts_with('`') { + let mut skip = found + marker_len; + while state.content[skip..].starts_with('`') { + skip += 1; + } + current_pos = skip; + } else { + end = Some(found); + break; + } + } + let Some(end) = end else { + return Some(InlineMatch { + node: AstNode::text(marker), + consumed_bytes: marker_len, + }); + }; + + let mut raw = state.content[state.pos + marker_len..end].to_owned(); + if raw.starts_with(' ') && raw.ends_with(' ') && !raw.trim().is_empty() { + raw = raw[1..raw.len() - 1].to_owned(); + } + let node_type = if is_safe_color_value(&raw) { + NodeType::ColorCode + } else { + NodeType::InlineCode + }; + let mut node = AstNode::new(node_type); + node.content = Some(raw); + Some(InlineMatch { + node, + consumed_bytes: end + marker_len - state.pos, + }) + } +} + +/// Parses explicit links and applies the shared URL policy. +pub struct LinkRule; + +impl InlineRule for LinkRule { + fn name(&self) -> &'static str { + "link" + } + + fn markers(&self) -> &'static [char] { + &['['] + } + + fn parse(&self, state: &mut InlineState, ctx: &ParserContext<'_>) -> Option { + if state.current_char() != Some('[') { + return None; + } + let text_end = state.find_next_token("](", state.pos + 1)?; + let url_end = state.find_next_token(")", text_end + 2)?; + let inner = state.content[state.pos + 1..text_end].to_owned(); + let mut url = state.content[text_end + 2..url_end].trim().to_owned(); + if let Some(rest) = url.strip_prefix("www.") { + url = format!("http://www.{rest}"); + } + if !is_safe_link_url(&url) { + return None; + } + let mut node = AstNode::new(NodeType::Link); + node.url = Some(url); + node.children = ctx.parse_inline(&inner); + Some(InlineMatch { + node, + consumed_bytes: url_end + 1 - state.pos, + }) + } +} + +/// Parses FFM underline syntax. +pub struct UnderlineRule; + +impl InlineRule for UnderlineRule { + fn name(&self) -> &'static str { + "underline" + } + + fn markers(&self) -> &'static [char] { + &['_'] + } + + fn parse(&self, state: &mut InlineState, ctx: &ParserContext<'_>) -> Option { + if !state.content[state.pos..].starts_with("__") { + return None; + } + let end = state.find_next_token("__", state.pos + 2)?; + let mut node = AstNode::new(NodeType::Underline); + node.children = ctx.parse_inline(&state.content[state.pos + 2..end]); + Some(InlineMatch { + node, + consumed_bytes: end + 2 - state.pos, + }) + } +} + +/// Parses FFM dash strike syntax. +pub struct StrikeRule; + +impl InlineRule for StrikeRule { + fn name(&self) -> &'static str { + "strike" + } + + fn markers(&self) -> &'static [char] { + &['-'] + } + + fn parse(&self, state: &mut InlineState, ctx: &ParserContext<'_>) -> Option { + if !state.content[state.pos..].starts_with("--") { + return None; + } + let end = state.find_next_token("--", state.pos + 2)?; + let mut node = AstNode::new(NodeType::Strike); + node.children = ctx.parse_inline(&state.content[state.pos + 2..end]); + Some(InlineMatch { + node, + consumed_bytes: end + 2 - state.pos, + }) + } +} + +/// Parses FFM bold syntax. +pub struct BoldRule; + +impl InlineRule for BoldRule { + fn name(&self) -> &'static str { + "bold" + } + + fn markers(&self) -> &'static [char] { + &['*'] + } + + fn parse(&self, state: &mut InlineState, ctx: &ParserContext<'_>) -> Option { + if !state.content[state.pos..].starts_with("**") { + return None; + } + let end = state.find_next_token("**", state.pos + 2)?; + let mut node = AstNode::new(NodeType::Bold); + node.children = ctx.parse_inline(&state.content[state.pos + 2..end]); + Some(InlineMatch { + node, + consumed_bytes: end + 2 - state.pos, + }) + } +} + +/// Parses single-asterisk italic syntax. +pub struct ItalicRule; + +impl InlineRule for ItalicRule { + fn name(&self) -> &'static str { + "italic" + } + + fn markers(&self) -> &'static [char] { + &['*'] + } + + fn parse(&self, state: &mut InlineState, ctx: &ParserContext<'_>) -> Option { + if state.current_char() != Some('*') || state.content[state.pos + 1..].starts_with('*') { + return None; + } + let end = state.find_next_token("*", state.pos + 1)?; + if end == state.pos + 1 { + return None; + } + let mut node = AstNode::new(NodeType::Italic); + node.children = ctx.parse_inline(&state.content[state.pos + 1..end]); + Some(InlineMatch { + node, + consumed_bytes: end + 1 - state.pos, + }) + } +} + +const FFM_KEYWORDS: &[&str] = &["quote", "slide", "chain", "accordion"]; + +/// Parses FFM fenced blocks before generic code blocks. +pub struct FfmBlockRule; + +impl BlockRule for FfmBlockRule { + fn name(&self) -> &'static str { + "ffm_blocks" + } + + fn markers(&self) -> &'static [char] { + &['`', '~'] + } + + fn parse(&self, state: &BlockState, ctx: &ParserContext<'_>) -> Option { + let block = extract_fenced_block(state)?; + let block_type = block.lang.split_whitespace().next().unwrap_or_default(); + if !FFM_KEYWORDS.contains(&block_type) { + return None; + } + + let node = match block_type { + "quote" => { + AstNode::with_children(NodeType::Blockquote, ctx.parse_blocks(&block.content)) + } + "slide" => { + let slides = split_slide_contents(&block.content) + .into_iter() + .filter(|content| !content.trim().is_empty()) + .map(|content| { + AstNode::with_children( + NodeType::SlideItem, + ctx.parse_blocks(content.trim()), + ) + }) + .collect(); + AstNode::with_children(NodeType::Slide, slides) + } + "accordion" | "chain" => parse_foldable_block(block_type, &block.content, ctx), + _ => unreachable!("FFM keywords are exhaustive"), + }; + Some(BlockMatch { + node, + consumed_lines: block.consumed_lines, + }) + } +} + +/// Splits slide content on the exact blank-line-delimited `---` pattern. +fn split_slide_contents(content: &str) -> Vec { + let mut sections = Vec::new(); + let mut section_start = 0; + let mut search_from = 0; + + while let Some(relative_start) = content[search_from..].find("\n\n") { + let separator_start = search_from + relative_start; + let mut marker_start = separator_start + 2; + while marker_start < content.len() + && content[marker_start..] + .chars() + .next() + .is_some_and(char::is_whitespace) + { + marker_start += content[marker_start..] + .chars() + .next() + .expect("whitespace character exists") + .len_utf8(); + } + if !content[marker_start..].starts_with("---") { + search_from = separator_start + 2; + continue; + } + + let mut separator_end = marker_start + 3; + while separator_end < content.len() + && content[separator_end..] + .chars() + .next() + .is_some_and(|character| matches!(character, ' ' | '\t')) + { + separator_end += content[separator_end..] + .chars() + .next() + .expect("whitespace character exists") + .len_utf8(); + } + if !content[separator_end..].starts_with("\n\n") { + search_from = separator_start + 2; + continue; + } + + sections.push(content[section_start..separator_start].to_owned()); + section_start = separator_end + 2; + search_from = section_start; + } + sections.push(content[section_start..].to_owned()); + sections +} + +/// Parses accordion and chain item titles and checkbox state. +fn parse_foldable_block(block_type: &str, content: &str, ctx: &ParserContext<'_>) -> AstNode { + let accordion_name = (block_type == "accordion").then(|| ctx.create_id("acc")); + let mut items = Vec::new(); + let mut current_item: Option = None; + let mut current_lines = Vec::new(); + let mut preamble_lines = Vec::new(); + + for line in content.split('\n') { + if let Some((checkbox, title)) = parse_ffm_title(line) { + if let Some(mut previous) = current_item.take() { + previous.children = ctx.parse_blocks(current_lines.join("\n").trim()); + items.push(previous); + } else if !current_lines.is_empty() && !current_lines.join("").trim().is_empty() { + preamble_lines = std::mem::take(&mut current_lines); + } + + let node_type = if block_type == "accordion" { + NodeType::AccordionItem + } else { + NodeType::ChainItem + }; + let mut item = AstNode::new(node_type); + item.name = accordion_name.clone(); + item.title = Some(ctx.parse_inline(title)); + if block_type == "chain" { + item.is_completed = Some(checkbox == Some('x') || checkbox == Some('X')); + item.has_checkbox = Some(checkbox.is_some()); + } + current_item = Some(item); + current_lines.clear(); + } else { + current_lines.push(line.to_owned()); + } + } + + if let Some(mut last) = current_item { + last.children = ctx.parse_blocks(current_lines.join("\n").trim()); + items.push(last); + } else if !current_lines.is_empty() && !current_lines.join("").trim().is_empty() { + preamble_lines = current_lines; + } + + let mut children = if preamble_lines.is_empty() { + Vec::new() + } else { + ctx.parse_blocks(preamble_lines.join("\n").trim()) + }; + children.extend(items); + let node_type = if block_type == "accordion" { + NodeType::Accordion + } else { + NodeType::Chain + }; + let mut node = AstNode::with_children(node_type, children); + node.name = accordion_name; + node +} + +/// Parses the bold FFM title syntax and optional checkbox marker. +fn parse_ffm_title(line: &str) -> Option<(Option, &str)> { + let trimmed = line.trim(); + if !trimmed.starts_with("**") || !trimmed.ends_with("**") || trimmed.len() <= 4 { + return None; + } + let mut title = &trimmed[2..trimmed.len() - 2]; + let checkbox = if title.starts_with('[') + && title.as_bytes().get(2) == Some(&b']') + && matches!( + title.as_bytes().get(1), + Some(b' ') | Some(b'x') | Some(b'X') + ) { + let mark = title.as_bytes()[1] as char; + title = title[3..].trim_start(); + Some(mark) + } else { + None + }; + (!title.is_empty()).then_some((checkbox, title.trim_end())) +} diff --git a/packages/markdown-parser-rust/src/safety.rs b/packages/markdown-parser-rust/src/safety.rs new file mode 100644 index 0000000..7d78909 --- /dev/null +++ b/packages/markdown-parser-rust/src/safety.rs @@ -0,0 +1,64 @@ +// packages/markdown-parser-rust/src/safety.rs + +/// Validates links using the same relative-URL and protocol policy as FFM. +pub fn is_safe_link_url(value: &str) -> bool { + let value = value.trim(); + if value.is_empty() { + return false; + } + + if value.starts_with('/') + || value.starts_with('#') + || value.starts_with("./") + || value.starts_with("../") + { + return true; + } + + let Some(colon_index) = value.find(':') else { + return false; + }; + let scheme = &value[..colon_index]; + if scheme.is_empty() + || !scheme.chars().enumerate().all(|(index, character)| { + (index == 0 && character.is_ascii_alphabetic()) + || (index > 0 + && (character.is_ascii_alphanumeric() || matches!(character, '+' | '-' | '.'))) + }) + { + return false; + } + + matches!( + scheme.to_ascii_lowercase().as_str(), + "http" | "https" | "mailto" | "tel" + ) +} + +/// Validates the deliberately narrow FFM CSS color grammar. +pub fn is_safe_color_value(value: &str) -> bool { + if let Some(hex) = value.strip_prefix('#') { + return matches!(hex.len(), 3 | 4 | 6 | 8) && hex.chars().all(|c| c.is_ascii_hexdigit()); + } + + let Some(opening) = value.find('(') else { + return false; + }; + let Some(closing) = value.strip_suffix(')') else { + return false; + }; + if closing.len() <= opening + 1 || value[opening + 1..value.len() - 1].contains(')') { + return false; + } + + let function = &value[..opening]; + if !matches!(function, "rgb" | "rgba" | "hsl" | "hsla") { + return false; + } + + value[opening + 1..value.len() - 1] + .chars() + .all(|character| { + character.is_ascii_digit() || matches!(character, ' ' | '\t' | ',' | '%' | '.') + }) +} diff --git a/packages/markdown-parser-rust/src/state.rs b/packages/markdown-parser-rust/src/state.rs new file mode 100644 index 0000000..0a94e09 --- /dev/null +++ b/packages/markdown-parser-rust/src/state.rs @@ -0,0 +1,110 @@ +// packages/markdown-parser-rust/src/state.rs +use std::collections::HashMap; + +/// Holds normalized lines and the current block-parser position. +#[derive(Clone, Debug)] +pub struct BlockState { + /// Normalized source lines. + pub lines: Vec, + /// Current zero-based line index. + pub line_index: usize, +} + +impl BlockState { + /// Normalizes line endings and converts a leading tab to four spaces. + pub fn new(content: &str) -> Self { + let normalized = content.replace("\r\n", "\n").replace('\r', "\n"); + let lines = normalized.split('\n').map(normalize_leading_tab).collect(); + Self { + lines, + line_index: 0, + } + } + + /// Returns the current line, if the cursor has not reached the end. + pub fn current_line(&self) -> Option<&str> { + self.lines.get(self.line_index).map(String::as_str) + } + + /// Returns the unconsumed lines as owned strings. + pub fn remaining_lines(&self) -> Vec { + self.lines[self.line_index..].to_vec() + } + + /// Advances the block cursor by a validated number of lines. + pub fn advance(&mut self, count: usize) { + self.line_index = self.line_index.saturating_add(count); + } +} + +/// Replaces the leading whitespace through its last tab with four spaces. +fn normalize_leading_tab(line: &str) -> String { + let leading_end = line + .char_indices() + .take_while(|(_, character)| *character == ' ' || *character == '\t') + .map(|(index, character)| index + character.len_utf8()) + .last() + .unwrap_or(0); + let leading = &line[..leading_end]; + + if let Some(tab_index) = leading.rfind('\t') { + format!(" {}", &line[tab_index + 1..]) + } else { + line.to_owned() + } +} + +/// Holds inline source and a byte-position cursor. +#[derive(Clone, Debug)] +pub struct InlineState { + /// Inline source text. + pub content: String, + /// Current UTF-8 byte offset. + pub pos: usize, + token_positions: HashMap>, +} + +impl InlineState { + /// Creates an inline state from source text. + pub fn new(content: impl Into) -> Self { + let content = content.into(); + Self { + content, + pos: 0, + token_positions: HashMap::new(), + } + } + + /// Returns the character at the current cursor. + pub fn current_char(&self) -> Option { + self.content.get(self.pos..)?.chars().next() + } + + /// Returns the next byte position at or after `from` for a token. + pub fn find_next_token(&mut self, token: &str, from: usize) -> Option { + assert!(!token.is_empty(), "token must not be empty"); + let positions = self + .token_positions + .entry(token.to_owned()) + .or_insert_with(|| { + let mut positions = Vec::new(); + let mut search_from = 0; + while let Some(relative) = self.content[search_from..].find(token) { + let position = search_from + relative; + positions.push(position); + search_from = + position + token.chars().next().expect("token is not empty").len_utf8(); + } + positions + }); + positions.binary_search(&from).map_or_else( + |index| positions.get(index).copied(), + |index| positions.get(index).copied(), + ) + } + + /// Advances by a byte count that must end on a UTF-8 boundary. + pub fn advance(&mut self, count: usize) { + self.pos = self.pos.saturating_add(count); + } +} diff --git a/packages/markdown-parser/package.json b/packages/markdown-parser/package.json index 89d2d0d..c48c2b3 100644 --- a/packages/markdown-parser/package.json +++ b/packages/markdown-parser/package.json @@ -6,9 +6,10 @@ "type": "module", "sideEffects": false, "scripts": { - "test": "vitest run", - "test:unit": "vitest run src/index.spec.ts", - "test:ffm": "vitest run src/test/index.spec.ts -t \"Fuyeor Mark\"", + "test": "vitest run src/cross-language-fixtures.spec.ts", + "test:unit": "vitest run src/cross-language-fixtures.spec.ts -t \"standard Markdown fixtures\"", + "test:ffm": "vitest run src/cross-language-fixtures.spec.ts -t \"ffm fixtures\"", + "test:safety": "vitest run src/cross-language-fixtures.spec.ts -t \"safety fixtures\"", "test:compat": "vitest run src/test/index.spec.ts", "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.test.json --noEmit" }, diff --git a/packages/markdown-parser/src/cross-language-fixtures.spec.ts b/packages/markdown-parser/src/cross-language-fixtures.spec.ts new file mode 100644 index 0000000..71472bf --- /dev/null +++ b/packages/markdown-parser/src/cross-language-fixtures.spec.ts @@ -0,0 +1,127 @@ +// packages/markdown-parser/src/cross-language-fixtures.spec.ts +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { isSafeColorValue } from './core/color'; +import { MarkdownParser } from './core/parser'; +import { isSafeLinkUrl } from './core/url'; +import { render } from './core/render'; +import { createFuyeorMarkdownParser, createMarkdownParser } from './default'; + +type MarkdownAssertion = { + path: string; + value?: unknown; + length?: number; +}; + +type MarkdownCase = { + input?: string; + html?: string; + assert?: MarkdownAssertion[]; + error?: 'invalid_nesting_depth'; + no_throw?: boolean; + options?: { max_nesting_depth?: number }; +}; + +type MarkdownFixtureFile = { + schema_version: number; + description: string; + cases: MarkdownCase[]; +}; + +type SafetyCase = { input: string; valid: boolean }; +type SafetyFixtureFile = { + schema_version: number; + description: string; + links: SafetyCase[]; + colors: SafetyCase[]; +}; + +const loadJson = (name: string): T => + JSON.parse( + readFileSync(resolve(import.meta.dirname, `../../../fixtures/${name}`), 'utf8'), + ) as T; + +const standardFixtures = loadJson('markdown.json'); +const ffmFixtures = loadJson('ffm.json'); +const safetyFixtures = loadJson('safety.json'); + +// Read a portable JSON Pointer path from a TypeScript AST value. +function readJsonPointer(root: unknown, pointer: string): unknown { + let value = root; + for (const rawSegment of pointer.split('/').slice(1)) { + const segment = rawSegment.replaceAll('~1', '/').replaceAll('~0', '~'); + if (Array.isArray(value)) { + value = value[Number(segment)]; + continue; + } + if (value !== null && typeof value === 'object') { + value = (value as Record)[segment]; + continue; + } + return undefined; + } + return value; +} + +// Map the wire-level snake_case options to the TypeScript API. +function parserOptions(fixture: MarkdownCase) { + return fixture.options === undefined + ? undefined + : { maxNestingDepth: fixture.options.max_nesting_depth }; +} + +// Execute one canonical Markdown case against a selected parser dialect. +function executeMarkdownCase(fixture: MarkdownCase, ffm: boolean): void { + const options = parserOptions(fixture); + if (fixture.error === 'invalid_nesting_depth') { + expect(() => new MarkdownParser(options)).toThrow(RangeError); + return; + } + + const parser = ffm + ? createFuyeorMarkdownParser(options) + : createMarkdownParser(options); + const ast = parser(fixture.input ?? ''); + if (fixture.html !== undefined) expect(render(ast).trim()).toBe(fixture.html); + + for (const assertion of fixture.assert ?? []) { + const value = readJsonPointer(ast, assertion.path); + if (assertion.length !== undefined) { + expect(Array.isArray(value) ? value.length : undefined).toBe( + assertion.length, + ); + continue; + } + expect(value).toEqual(assertion.value); + } + + if (fixture.no_throw) expect(ast).toEqual(expect.any(Array)); +} + +// Execute every case in one language-neutral fixture file. +function executeMarkdownSuite( + name: string, + fixtureFile: MarkdownFixtureFile, + ffm: boolean, +): void { + describe(name, () => { + expect(fixtureFile.schema_version).toBe(2); + for (const [index, fixture] of fixtureFile.cases.entries()) { + it(String(index), () => executeMarkdownCase(fixture, ffm)); + } + }); +} + +executeMarkdownSuite('standard Markdown fixtures', standardFixtures, false); +executeMarkdownSuite('FFM fixtures', ffmFixtures, true); + +describe('safety fixtures', () => { + expect(safetyFixtures.schema_version).toBe(1); + for (const fixture of safetyFixtures.links) + it(`link: ${fixture.input}`, () => + expect(isSafeLinkUrl(fixture.input)).toBe(fixture.valid)); + for (const fixture of safetyFixtures.colors) + it(`color: ${fixture.input}`, () => + expect(isSafeColorValue(fixture.input)).toBe(fixture.valid)); +}); 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/test/index.spec.ts b/packages/markdown-parser/src/test/index.spec.ts index f468154..44250d0 100644 --- a/packages/markdown-parser/src/test/index.spec.ts +++ b/packages/markdown-parser/src/test/index.spec.ts @@ -18,10 +18,6 @@ const githubMark = JSON.parse( // githubMark: 0.29 edition readFileSync(resolve(__dirname, './specs/githubMark.json'), 'utf-8'), ); -const fuyeorMark = JSON.parse( - readFileSync(resolve(__dirname, './specs/fuyeorMark.json'), 'utf-8'), -); - /** * skip unimplemented test cases */ @@ -117,17 +113,6 @@ describe('markdown compatibility test', () => { }); }); - describe('Fuyeor Mark', () => { - fuyeorMark.forEach((caseItem: any) => { - it(caseItem.markdown, () => { - assertMarkdown( - `Fuyeor Mark ${caseItem.section}`, - caseItem.markdown, - caseItem.html, - ); - }); - }); - }); // after test complete, write error details into JSON afterAll(() => { diff --git a/packages/markdown-parser/src/test/specs/fuyeorMark.json b/packages/markdown-parser/src/test/specs/fuyeorMark.json deleted file mode 100644 index 832a234..0000000 --- a/packages/markdown-parser/src/test/specs/fuyeorMark.json +++ /dev/null @@ -1,62 +0,0 @@ -[ - { - "markdown": "--Fuyeor--", - "html": "

    Fuyeor

    ", - "example": 1, - "section": "Del" - }, - { - "markdown": "__Fuyeor__", - "html": "

    Fuyeor

    ", - "example": 1, - "section": "Underline" - }, - { - "markdown": "`#ff0000`", - "html": "

    #ff0000

    ", - "example": 1, - "section": "Color code" - }, - { - "markdown": "```quote\n## Title\n```", - "html": "
    \n

    Title

    \n
    ", - "example": 1, - "section": "Quote block" - }, - { - "markdown": "```accordion\n**One**\nContent\n```", - "html": "
    One

    Content

    \n
    ", - "example": 1, - "section": "Accordion" - }, - { - "markdown": "```chain\n**[x] Done**\nBody\n```", - "html": "
    Done

    Body

    \n
    ", - "example": 1, - "section": "Chain" - }, - { - "markdown": "```slide\nFirst\n\n---\n\nSecond\n```", - "html": "

    First

    \n

    Second

    \n
    ", - "example": 1, - "section": "Slide" - }, - { - "markdown": "[x](javascript:alert(1))", - "html": "

    [x](javascript:alert(1))

    ", - "example": 1, - "section": "Unsafe link schemes" - }, - { - "markdown": "[x](data:text/html,)", - "html": "

    [x](data:text/html,<script>alert(1)</script>)

    ", - "example": 1, - "section": "Unsafe link schemes" - }, - { - "markdown": "[x](file:///etc/passwd)", - "html": "

    [x](file:///etc/passwd)

    ", - "example": 1, - "section": "Unsafe link schemes" - } -]