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| name | \nstring | \n用户名 | \n
\n\n
"
+ },
+ {
+ "input": "| :--- | ---: |\n| Ray ID | a2c5ac427b7aa727 |\n| IP 地址 | 172.214.47.18 |",
+ "html": "\n\n\n| Ray ID | \na2c5ac427b7aa727 | \n
\n\n| IP 地址 | \n172.214.47.18 | \n
\n\n
"
+ },
+ {
+ "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": "\nTitle
\n
"
+ },
+ {
+ "input": "```accordion\n**One**\nContent\n```",
+ "html": ""
+ },
+ {
+ "input": "```chain\n**[x] Done**\nBody\n```",
+ "html": ""
+ },
+ {
+ "input": "```slide\nFirst\n\n---\n\nSecond\n```",
+ "html": ""
+ },
+ {
+ "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": ""
+ },
+ {
+ "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, "{tag}>");
+ }
+ 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(`
-
-
-| 属性 |
-类型 |
-说明 |
-
-
-
-
-| name |
-string |
-用户名 |
-
-
-
-`);
- });
-
- 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 ID |
-a2c5ac427b7aa727 |
-
-
-| 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": "\nTitle
\n
",
- "example": 1,
- "section": "Quote block"
- },
- {
- "markdown": "```accordion\n**One**\nContent\n```",
- "html": "",
- "example": 1,
- "section": "Accordion"
- },
- {
- "markdown": "```chain\n**[x] Done**\nBody\n```",
- "html": "",
- "example": 1,
- "section": "Chain"
- },
- {
- "markdown": "```slide\nFirst\n\n---\n\nSecond\n```",
- "html": "",
- "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"
- }
-]