use serde::Deserialize;
use wasm_bindgen::prelude::*;
pub mod extensions;
pub mod frontmatter;
pub mod parser;
pub mod sanitizer;
#[derive(Debug, Clone)]
pub struct ParseResult {
pub html: String,
pub frontmatter: Option<frontmatter::Frontmatter>,
pub footnotes: Option<String>,
}
pub fn parse(input: &str) -> String {
let result = parse_with_frontmatter(input);
if let Some(footnotes) = result.footnotes {
format!("{}\n{}", result.html, footnotes)
} else {
result.html
}
}
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
struct WasmIconsOptions {
video: Option<String>,
audio: Option<String>,
download: Option<String>,
color_swatch: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
struct WasmParseOptions {
gfm_extensions: Option<bool>,
umd_extensions: Option<bool>,
max_heading_level: Option<u8>,
max_inline_nesting: Option<u8>,
base_url: Option<String>,
allow_fragment_extension_hint: Option<bool>,
icons: Option<WasmIconsOptions>,
}
fn parse_with_options_json(input: &str, options_json: Option<&str>) -> String {
let mut options = parser::ParserOptions::default();
if let Some(raw_json) = options_json {
let trimmed = raw_json.trim();
if !trimmed.is_empty() {
if let Ok(raw) = serde_json::from_str::<WasmParseOptions>(trimmed) {
if let Some(value) = raw.gfm_extensions {
options.gfm_extensions = value;
}
if let Some(value) = raw.umd_extensions {
options.umd_extensions = value;
}
if let Some(value) = raw.max_heading_level {
options.max_heading_level = value;
}
if let Some(value) = raw.max_inline_nesting {
options.max_inline_nesting = Some(value);
}
if let Some(value) = raw.base_url {
options.base_url = Some(value);
}
if let Some(value) = raw.allow_fragment_extension_hint {
options.allow_fragment_extension_hint = value;
}
if let Some(icons) = raw.icons {
if let Some(value) = icons.video {
options.icons.video = value;
}
if let Some(value) = icons.audio {
options.icons.audio = value;
}
if let Some(value) = icons.download {
options.icons.download = value;
}
if let Some(value) = icons.color_swatch {
options.icons.color_swatch = value;
}
}
}
}
}
let result = parse_with_frontmatter_opts(input, &options);
if let Some(footnotes) = result.footnotes {
format!("{}\n{}", result.html, footnotes)
} else {
result.html
}
}
pub fn parse_with_frontmatter(input: &str) -> ParseResult {
let options = parser::ParserOptions::default();
parse_with_frontmatter_opts(input, &options)
}
pub fn parse_with_frontmatter_opts(input: &str, options: &parser::ParserOptions) -> ParseResult {
let (frontmatter_data, content) = frontmatter::extract_frontmatter(input);
let content = extensions::nested_blocks::preprocess_nested_blocks(&content);
let content = extensions::preprocessor::preprocess_tasklist_indeterminate(&content);
let content = extensions::preprocessor::preprocess_discord_underline(&content);
let content = extensions::preprocessor::preprocess_code_block_filenames(&content);
let (preprocessed, header_map) = extensions::conflict_resolver::preprocess_conflicts(&content);
let preprocessed = sanitizer::remove_ascii_control_chars_from_markup(&preprocessed);
let sanitized = sanitizer::sanitize(&preprocessed);
let html = parser::parse_to_html(&sanitized, options);
let html = extensions::preprocessor::postprocess_discord_underline(&html);
let final_html = extensions::apply_extensions_with_headers(&html, &header_map, options);
let (body_html, footnotes_html) = extract_footnotes(&final_html);
ParseResult {
html: body_html,
frontmatter: frontmatter_data,
footnotes: footnotes_html,
}
}
fn extract_footnotes(html: &str) -> (String, Option<String>) {
use regex::Regex;
let footnote_pattern =
Regex::new(r#"(?s)<section class="footnotes"[^>]*>.*?</section>"#).unwrap();
if let Some(matched) = footnote_pattern.find(html) {
let footnotes = matched.as_str().to_string();
let body = footnote_pattern.replace(html, "").to_string();
(body, Some(footnotes))
} else {
(html.to_string(), None)
}
}
#[wasm_bindgen(js_name = parse)]
pub fn parse_wasm(input: &str, options_json: Option<String>) -> String {
parse_with_options_json(input, options_json.as_deref())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_basic_parse() {
let input = "Hello World";
let output = parse(input);
assert!(output.contains("Hello World"));
}
#[test]
fn test_html_escaping() {
let input = "<script>alert('xss')</script>";
let output = parse(input);
assert!(!output.contains("<script>"));
assert!(output.contains("<script>"));
}
#[test]
fn test_parse_with_options_json_base_url() {
let input = "[docs](/guide)";
let output = parse_with_options_json(input, Some(r#"{"baseUrl":"/app"}"#));
assert!(output.contains(r#"href="/app/guide""#));
}
#[test]
fn test_parse_with_options_json_icon_override() {
let input = "`#ff0000`";
let output = parse_with_options_json(
input,
Some(
r#"{"icons":{"colorSwatch":"<span class=\"my-icon\" aria-hidden=\"true\"></span>"}}"#,
),
);
assert!(output.contains(r#"<span class="my-icon" aria-hidden="true"></span>"#));
}
#[test]
fn test_parse_with_options_json_inline_nesting_limit() {
let input = "&color(blue){&abbr(t){x};};";
let output_from_json = parse_with_options_json(input, Some(r#"{"maxInlineNesting":1}"#));
let mut options = parser::ParserOptions::default();
options.max_inline_nesting = Some(1);
let expected = parse_with_frontmatter_opts(input, &options);
let expected_html = if let Some(footnotes) = expected.footnotes {
format!("{}\n{}", expected.html, footnotes)
} else {
expected.html
};
assert_eq!(output_from_json, expected_html);
}
}