diff --git a/.gitignore b/.gitignore index 151575c5..bce355a7 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,6 @@ fuzz/corpus fuzz/artifacts fuzz/hfuzz_target fuzz/hfuzz_workspace +node_modules/ +pnpm-lock.yaml +pnpm-debug.log* diff --git a/Cargo.toml b/Cargo.toml index 5462ac32..f4760ff7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,7 +41,7 @@ rust-version = "1.56" version = "1.0.0" [workspace] -members = ["generate", "mdast_util_to_markdown"] +members = ["generate", "mdast_util_to_markdown", "wasm"] [workspace.dependencies] pretty_assertions = "1" diff --git a/package.json b/package.json new file mode 100644 index 00000000..2c8265d2 --- /dev/null +++ b/package.json @@ -0,0 +1,10 @@ +{ + "name": "markdown-rs-monorepo", + "private": true, + "scripts": { + "build": "pnpm -r build", + "test": "pnpm -r test", + "lint": "cargo fmt --all && cargo clippy --all-features --all-targets --workspace" + }, + "packageManager": "pnpm@8.15.1" +} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 00000000..1e9d6a7f --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - 'wasm' \ No newline at end of file diff --git a/readme.md b/readme.md index f3710bab..a0b27ed1 100644 --- a/readme.md +++ b/readme.md @@ -373,6 +373,8 @@ Special thanks go out to: — same as `markdown-rs` but in JavaScript * [`mdxjs-rs`][mdxjs-rs] — wraps `markdown-rs` to *compile* MDX to JavaScript +* [`@wooorm/markdown-wasm`](https://www.npmjs.com/package/@wooorm/markdown-wasm) + — WASM bindings for `markdown-rs` ## License diff --git a/wasm/Cargo.toml b/wasm/Cargo.toml new file mode 100644 index 00000000..69e1aa37 --- /dev/null +++ b/wasm/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "markdown-rs-wasm" +version = "0.0.1" +authors = ["markdown-rs contributors"] +edition = "2018" +description = "WebAssembly bindings for markdown-rs" +license = "MIT" +repository = "https://github.com/wooorm/markdown-rs" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +markdown = { path = ".." } +wasm-bindgen = "0.2" +serde = { version = "1.0", features = ["derive"] } +serde-wasm-bindgen = "0.6" +console_error_panic_hook = "0.1" + +[dependencies.web-sys] +version = "0.3" +features = ["console"] + diff --git a/wasm/LICENSE b/wasm/LICENSE new file mode 100644 index 00000000..9ac1e969 --- /dev/null +++ b/wasm/LICENSE @@ -0,0 +1,22 @@ +(The MIT License) + +Copyright (c) 2022 Titus Wormer + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/wasm/README.md b/wasm/README.md new file mode 100644 index 00000000..0d840498 --- /dev/null +++ b/wasm/README.md @@ -0,0 +1,95 @@ +# @wooorm/markdown-wasm + +WebAssembly bindings for [markdown-rs](https://github.com/wooorm/markdown-rs). + +## Features + +- CommonMark compliant +- GFM support (tables, strikethrough, autolinks, task lists) +- MDX support (JSX in markdown) +- Pure ESM module +- Works in Node.js 16+ +- Single WASM binary (no platform-specific builds) + +## Installation + +```bash +npm install @wooorm/markdown-wasm +``` + +## Usage + +```javascript +import { toHtml, toHtmlWithOptions } from '@wooorm/markdown-wasm'; + +// Convert markdown to HTML +const html = await toHtml('# Hello World'); + +// With GitHub Flavored Markdown +const gfmHtml = await toHtmlWithOptions('~strikethrough~', { + gfm: true +}); + +// With MDX support +const mdxHtml = await toHtmlWithOptions('', { + mdx: true +}); +``` + +## API + +### `toHtml(markdown: string): Promise` + +Converts markdown to HTML using CommonMark. + +```javascript +const html = await toHtml('# Hello World'); +``` + +### `toHtmlWithOptions(markdown: string, options: Object): Promise` + +Converts markdown to HTML with options. + +**Options:** +- `gfm: boolean` - Enable GitHub Flavored Markdown +- `mdx: boolean` - Enable MDX (JSX in markdown) +- `frontmatter: boolean` - Enable YAML frontmatter +- `allowDangerousHtml: boolean` - Allow raw HTML (default: false) +- `allowDangerousProtocol: boolean` - Allow dangerous protocols (default: false) + +## Examples + +Run the examples to see the library in action: + +```bash +# Basic example +pnpm example:basic + +# Options example (GFM, MDX, security) +pnpm example:options +``` + +## Testing + +```bash +# Run tests +pnpm test +``` + +## Building + +```bash +# Build WASM module +pnpm build +``` + +## Why WASM? + +This implementation uses WebAssembly to provide: +- Universal compatibility (one binary for all platforms) +- No native dependencies +- Reliable performance across environments + +## License + +MIT \ No newline at end of file diff --git a/wasm/examples/basic.mjs b/wasm/examples/basic.mjs new file mode 100644 index 00000000..654d8ce4 --- /dev/null +++ b/wasm/examples/basic.mjs @@ -0,0 +1,46 @@ +import { toHtml } from '../lib/index.mjs'; + +const markdown = `# Hello World + +This is a **markdown** document with *emphasis*. + +## Features + +- Fast parsing +- CommonMark compliant +- WebAssembly powered + +## Code Example + +\`\`\`javascript +const result = await toHtml(markdown); +console.log(result); +\`\`\` + +## Links + +Check out [markdown-rs](https://github.com/wooorm/markdown-rs) for more information. +`; + +// Display header +console.log('\n╔════════════════════════════════════════════════╗'); +console.log('║ markdown-rs WASM - Basic Example ║'); +console.log('╚════════════════════════════════════════════════╝\n'); + +// Show input +console.log('Input Markdown:'); +console.log('```markdown'); +console.log(markdown.trim()); +console.log('```\n'); + +// Convert markdown to HTML +console.log('Converting to HTML...\n'); +const html = await toHtml(markdown); + +// Show output +console.log('Output HTML:'); +console.log('```html'); +console.log(html.trim()); +console.log('```\n'); + +console.log('Conversion complete!'); diff --git a/wasm/examples/with-options.mjs b/wasm/examples/with-options.mjs new file mode 100644 index 00000000..bd34ae94 --- /dev/null +++ b/wasm/examples/with-options.mjs @@ -0,0 +1,151 @@ +import { toHtmlWithOptions } from '../lib/index.mjs'; + +console.log('\n╔════════════════════════════════════════════════╗'); +console.log('║ markdown-rs WASM - Options Examples ║'); +console.log('╚════════════════════════════════════════════════╝\n'); + +// Example 1: GitHub Flavored Markdown +console.log('\n┌────────────────────────────────────────────────┐'); +console.log('│ Example 1: GitHub Flavored Markdown (GFM) │'); +console.log('└────────────────────────────────────────────────┘\n'); + +const gfmMarkdown = ` +## GFM Features + +### Tables +| Feature | Supported | +|---------|-----------| +| Tables | Yes | +| Tasks | Yes | + +### Task Lists +- [x] Completed task +- [ ] Pending task + +### Strikethrough +~strikethrough text~ + +### Autolinks +https://github.com/wooorm/markdown-rs +`; + +const gfmHtml = await toHtmlWithOptions(gfmMarkdown, { gfm: true }); +console.log('Input Markdown:'); +console.log('```markdown'); +console.log(gfmMarkdown.trim()); +console.log('```\n'); +console.log('Output HTML:'); +console.log('```html'); +console.log(gfmHtml.trim()); +console.log('```'); + +// Example 2: MDX Support +console.log('\n┌────────────────────────────────────────────────┐'); +console.log('│ Example 2: MDX (JSX in Markdown) │'); +console.log('└────────────────────────────────────────────────┘\n'); + +const mdxMarkdown = ` +# MDX Example + + + +Regular markdown with **JSX** components. +`; + +const mdxHtml = await toHtmlWithOptions(mdxMarkdown, { mdx: true }); +console.log('Input Markdown:'); +console.log('```mdx'); +console.log(mdxMarkdown.trim()); +console.log('```\n'); +console.log('Output HTML:'); +console.log('```html'); +console.log(mdxHtml.trim()); +console.log('```'); + +// Example 3: Frontmatter +console.log('\n┌────────────────────────────────────────────────┐'); +console.log('│ Example 3: Frontmatter Support │'); +console.log('└────────────────────────────────────────────────┘\n'); + +const frontmatterMarkdown = `--- +title: Example Post +date: 2024-01-01 +--- + +# Content + +This is the actual content after frontmatter. +`; + +const frontmatterHtml = await toHtmlWithOptions(frontmatterMarkdown, { + frontmatter: true +}); +console.log('Input Markdown:'); +console.log('```markdown'); +console.log(frontmatterMarkdown.trim()); +console.log('```\n'); +console.log('Output HTML:'); +console.log('```html'); +console.log(frontmatterHtml.trim()); +console.log('```'); + +// Example 4: Security Options +console.log('\n┌────────────────────────────────────────────────┐'); +console.log('│ Example 4: Security Options │'); +console.log('└────────────────────────────────────────────────┘\n'); + +const dangerousMarkdown = ` + + +[Dangerous Link](javascript:alert('XSS')) +`; + +console.log('Input Markdown:'); +console.log('```markdown'); +console.log(dangerousMarkdown.trim()); +console.log('```\n'); + +console.log('Safe mode output (default):'); +console.log('```html'); +const safeHtml = await toHtmlWithOptions(dangerousMarkdown, {}); +console.log(safeHtml.trim()); +console.log('```\n'); + +console.log('Dangerous mode output (be careful!):'); +console.log('```html'); +const dangerousHtml = await toHtmlWithOptions(dangerousMarkdown, { + allowDangerousHtml: true, + allowDangerousProtocol: true +}); +console.log(dangerousHtml.trim()); +console.log('```'); + +// Example 5: Combined Options +console.log('\n┌────────────────────────────────────────────────┐'); +console.log('│ Example 5: Combined Options (GFM + MDX) │'); +console.log('└────────────────────────────────────────────────┘\n'); + +const combinedMarkdown = ` +| GFM | MDX | +|-----|-----| +| Yes | Yes | + +~strikethrough~ and +`; + +const combinedHtml = await toHtmlWithOptions(combinedMarkdown, { + gfm: true, + mdx: true +}); +console.log('Input Markdown:'); +console.log('```markdown'); +console.log(combinedMarkdown.trim()); +console.log('```\n'); +console.log('Output HTML:'); +console.log('```html'); +console.log(combinedHtml.trim()); +console.log('```'); + +console.log('\n╔════════════════════════════════════════════════╗'); +console.log('║ Examples Completed! ║'); +console.log('╚════════════════════════════════════════════════╝\n'); diff --git a/wasm/lib/index.mjs b/wasm/lib/index.mjs new file mode 100644 index 00000000..bd5ef05a --- /dev/null +++ b/wasm/lib/index.mjs @@ -0,0 +1,80 @@ +import { readFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +// Dynamic import to handle the ES module +let wasmModule = null; + +async function loadWasmModule() { + if (!wasmModule) { + wasmModule = await import('../pkg/markdown_rs_wasm.mjs'); + } + return wasmModule; +} + +// Initialize WASM module once +let initialized = false; +let initPromise = null; + +async function ensureInitialized() { + if (initialized) return; + if (!initPromise) { + const wasm = await loadWasmModule(); + // Read WASM file for Node.js + const __dirname = dirname(fileURLToPath(import.meta.url)); + const wasmPath = join(__dirname, '..', 'pkg', 'markdown_rs_wasm_bg.wasm'); + const wasmBuffer = await readFile(wasmPath); + + initPromise = wasm.default({ module_or_path: wasmBuffer }).then(() => { + initialized = true; + }); + } + await initPromise; +} + +/** + * Convert markdown to HTML using CommonMark + * @param {string} input - Markdown text + * @returns {Promise} HTML output + */ +export async function toHtml(input) { + await ensureInitialized(); + const wasm = await loadWasmModule(); + return wasm.to_html(input); +} + +/** + * Convert markdown to HTML with options + * @param {string} input - Markdown text + * @param {Object} options - Parsing options + * @param {boolean} [options.gfm] - Enable GitHub Flavored Markdown + * @param {boolean} [options.mdx] - Enable MDX + * @param {boolean} [options.frontmatter] - Enable frontmatter + * @param {boolean} [options.allowDangerousHtml] - Allow raw HTML + * @param {boolean} [options.allowDangerousProtocol] - Allow dangerous protocols + * @returns {Promise} HTML output + */ +export async function toHtmlWithOptions(input, options) { + await ensureInitialized(); + const wasm = await loadWasmModule(); + return wasm.to_html_with_options(input, options); +} + +// Also export sync versions after initialization +export function toHtmlSync(input) { + if (!initialized || !wasmModule) { + throw new Error('WASM not initialized. Call toHtml() first or await init()'); + } + return wasmModule.to_html(input); +} + +export function toHtmlWithOptionsSync(input, options) { + if (!initialized || !wasmModule) { + throw new Error('WASM not initialized. Call toHtmlWithOptions() first or await init()'); + } + return wasmModule.to_html_with_options(input, options); +} + +export async function init() { + await ensureInitialized(); +} diff --git a/wasm/package.json b/wasm/package.json new file mode 100644 index 00000000..0121a73a --- /dev/null +++ b/wasm/package.json @@ -0,0 +1,36 @@ +{ + "name": "@wooorm/markdown-wasm", + "version": "0.0.1", + "description": "WebAssembly bindings for markdown-rs", + "type": "module", + "exports": { + ".": { + "types": "./lib/index.d.ts", + "default": "./lib/index.mjs" + } + }, + "files": [ + "lib/", + "pkg/", + "README.md", + "LICENSE" + ], + "scripts": { + "build": "wasm-pack build --target web --out-dir pkg && mv pkg/markdown_rs_wasm.js pkg/markdown_rs_wasm.mjs", + "test": "node --test test/index.test.mjs", + "examples": "node examples/basic.mjs && node examples/with-options.mjs" + }, + "keywords": [ + "markdown", + "commonmark", + "wasm", + "webassembly", + "mdx", + "gfm" + ], + "license": "MIT", + "repository": "https://github.com/wooorm/markdown-rs", + "engines": { + "node": ">=18" + } +} diff --git a/wasm/src/lib.rs b/wasm/src/lib.rs new file mode 100644 index 00000000..617c868e --- /dev/null +++ b/wasm/src/lib.rs @@ -0,0 +1,72 @@ +use serde::{Deserialize, Serialize}; +use wasm_bindgen::prelude::*; + +// Set panic hook for better error messages +#[wasm_bindgen(start)] +pub fn init() { + console_error_panic_hook::set_once(); +} + +#[derive(Serialize, Deserialize, Default)] +pub struct Options { + #[serde(default)] + pub gfm: bool, + + #[serde(default)] + pub mdx: bool, + + #[serde(default)] + pub frontmatter: bool, + + #[serde(rename = "allowDangerousHtml", default)] + pub allow_dangerous_html: bool, + + #[serde(rename = "allowDangerousProtocol", default)] + pub allow_dangerous_protocol: bool, +} + +/// Convert markdown to HTML with default options +#[wasm_bindgen] +pub fn to_html(input: &str) -> String { + markdown::to_html(input) +} + +/// Convert markdown to HTML with options +#[wasm_bindgen] +pub fn to_html_with_options(input: &str, options: JsValue) -> Result { + // Parse options from JavaScript + let opts: Options = if options.is_null() || options.is_undefined() { + Options::default() + } else { + serde_wasm_bindgen::from_value(options) + .map_err(|e| JsValue::from_str(&format!("Invalid options: {}", e)))? + }; + + // Build markdown options + let mut parse_options = markdown::ParseOptions::default(); + let mut compile_options = markdown::CompileOptions::default(); + + // Configure constructs based on options + if opts.gfm { + parse_options.constructs = markdown::Constructs::gfm(); + } else if opts.mdx { + parse_options.constructs = markdown::Constructs::mdx(); + } + + if opts.frontmatter { + parse_options.constructs.frontmatter = true; + } + + // Configure compile options + compile_options.allow_dangerous_html = opts.allow_dangerous_html; + compile_options.allow_dangerous_protocol = opts.allow_dangerous_protocol; + + let markdown_options = markdown::Options { + parse: parse_options, + compile: compile_options, + }; + + // Convert markdown to HTML + markdown::to_html_with_options(input, &markdown_options) + .map_err(|e| JsValue::from_str(&format!("Markdown error: {}", e))) +} diff --git a/wasm/test/index.test.mjs b/wasm/test/index.test.mjs new file mode 100644 index 00000000..4be8c5fb --- /dev/null +++ b/wasm/test/index.test.mjs @@ -0,0 +1,119 @@ +import assert from 'node:assert'; +import { describe, it } from 'node:test'; +import { toHtml, toHtmlWithOptions, toHtmlSync, toHtmlWithOptionsSync } from '../lib/index.mjs'; + +describe('markdown-rs WASM', () => { + describe('Basic functionality', () => { + it('converts heading to HTML', async () => { + const result = await toHtml('# Hello World'); + assert.strictEqual(result, '

Hello World

'); + }); + + it('converts paragraph to HTML', async () => { + const result = await toHtml('This is a paragraph.'); + assert.strictEqual(result, '

This is a paragraph.

'); + }); + + it('converts emphasis to HTML', async () => { + const result = await toHtml('*italic* and **bold**'); + assert.strictEqual(result, '

italic and bold

'); + }); + + it('converts links to HTML', async () => { + const result = await toHtml('[GitHub](https://github.com)'); + assert.strictEqual(result, '

GitHub

'); + }); + + it('converts code blocks to HTML', async () => { + const result = await toHtml('```js\nconst x = 1;\n```'); + assert(result.includes('
'));
+      assert(result.includes(' {
+    it('enables strikethrough with GFM', async () => {
+      const result = await toHtmlWithOptions('~strikethrough~', { gfm: true });
+      assert(result.includes('strikethrough'));
+    });
+
+    it('enables tables with GFM', async () => {
+      const markdown = '| a | b |\n|---|---|\n| c | d |';
+      const result = await toHtmlWithOptions(markdown, { gfm: true });
+      assert(result.includes(''));
+      assert(result.includes(''));
+    });
+
+    it('enables autolinks with GFM', async () => {
+      const result = await toHtmlWithOptions('https://example.com', { gfm: true });
+      assert(result.includes(''));
+    });
+  });
+
+  describe('MDX options', () => {
+    it('handles JSX with MDX enabled', async () => {
+      const result = await toHtmlWithOptions('# Hello ', { mdx: true });
+      assert(result.includes('

')); + }); + }); + + describe('Security options', () => { + it('blocks dangerous HTML by default', async () => { + const html = ''; + const result = await toHtmlWithOptions(html, {}); + assert(!result.includes(''; + const result = await toHtmlWithOptions(html, { allowDangerousHtml: true }); + assert(result.includes('

c