-
- {
- handleInput(val);
- }}
- />
-
+ {/* Toolbar */}
-
-
}
- onLoaded={() => console.log("WASM Loaded")}
+ {/* Left side - Logo and project name */}
+
+

+
+ Haxiom
+
+
+ /
+
+
+ solid-markdown-wasm
+
+
+
+ {/* Right side - Theme selectors and Try Haxiom */}
+
+
+ {/* biome-ignore lint/a11y/noLabelWithoutControl: */}
+
+
+
+
+
+ {/* biome-ignore lint/a11y/noLabelWithoutControl: */}
+
+
+
+ {/* Try Haxiom link */}
+
+ Try Haxiom
+
+
+
+
+ {/* Main content */}
+
+
+ {
+ handleInput(val);
+ }}
/>
+
+
+ }
+ onLoaded={() => console.log("WASM Loaded")}
+ />
+
+
);
diff --git a/example/src/assets/haxiom.svg b/example/src/assets/haxiom.svg
new file mode 100644
index 0000000..fd67307
--- /dev/null
+++ b/example/src/assets/haxiom.svg
@@ -0,0 +1 @@
+
diff --git a/example/src/assets/markdown_preview.md b/example/src/assets/markdown_preview.md
index 3289c61..b192499 100644
--- a/example/src/assets/markdown_preview.md
+++ b/example/src/assets/markdown_preview.md
@@ -105,6 +105,10 @@ You are using [solid-markdown-wasm](https://github.com/zeon256/solid-markdown-wa
| left bar | right bar |
| left baz | right baz |
+## Iframes
+
+
+
## Inline code
This web site is using `solid-markdown-wasm`.
@@ -402,4 +406,4 @@ underbrace(1 + 2 + 3 + dots + n, "sum") = (n(n+1))/2
```
```math
overbrace(x + x + dots + x, n "times") = n x
-```
\ No newline at end of file
+```
diff --git a/example/src/index.css b/example/src/index.css
index 9919297..6617a0e 100644
--- a/example/src/index.css
+++ b/example/src/index.css
@@ -57,7 +57,7 @@
--fgColor-default: #f0f6fc;
--fgColor-muted: #9198a1;
--fgColor-accent: #4493f8;
- --fgColor-success: #3fb950;
+ --fgColor-success: #14b8a6;
--fgColor-attention: #d29922;
--fgColor-danger: #f85149;
--fgColor-done: #ab7df8;
@@ -69,7 +69,7 @@
--borderColor-muted: #3d444db3;
--borderColor-neutral-muted: #3d444db3;
--borderColor-accent-emphasis: #1f6feb;
- --borderColor-success-emphasis: #238636;
+ --borderColor-success-emphasis: #0d9488;
--borderColor-attention-emphasis: #9e6a03;
--borderColor-danger-emphasis: #da3633;
--borderColor-done-emphasis: #8957e5;
@@ -114,7 +114,7 @@
--fgColor-default: #1f2328;
--fgColor-muted: #59636e;
--fgColor-accent: #0969da;
- --fgColor-success: #1a7f37;
+ --fgColor-success: #0d9488;
--fgColor-attention: #9a6700;
--fgColor-danger: #d1242f;
--fgColor-done: #8250df;
@@ -126,7 +126,7 @@
--borderColor-muted: #d1d9e0b3;
--borderColor-neutral-muted: #d1d9e0b3;
--borderColor-accent-emphasis: #0969da;
- --borderColor-success-emphasis: #1a7f37;
+ --borderColor-success-emphasis: #0d9488;
--borderColor-attention-emphasis: #9a6700;
--borderColor-danger-emphasis: #cf222e;
--borderColor-done-emphasis: #8250df;
@@ -1297,3 +1297,173 @@
.markdown-body .highlight pre:has(+ .zeroclipboard-container) {
min-height: 52px;
}
+
+/* Code block wrapper styling */
+.markdown-body .code-block-wrapper {
+ position: relative;
+ margin-bottom: 1rem;
+ border-radius: 0.5rem;
+ overflow: hidden;
+}
+
+.markdown-body .code-block-wrapper pre {
+ margin: 0;
+ border-top-left-radius: 0;
+ border-top-right-radius: 0;
+}
+
+.markdown-body .code-block-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 0.5rem 1rem;
+ background-color: var(--bgColor-muted, #151b23);
+ border-bottom: 1px solid var(--borderColor-muted, #3d444db3);
+}
+
+.markdown-body .code-block-language {
+ font-size: 0.75rem;
+ font-weight: 500;
+ color: var(--fgColor-muted, #9198a1);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+
+.markdown-body .code-block-buttons {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.markdown-body .code-block-copy,
+.markdown-body .code-block-collapse {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0.25rem 0.5rem;
+ font-size: 0.75rem;
+ color: var(--fgColor-muted, #9198a1);
+ background: transparent;
+ border: 1px solid var(--borderColor-muted, #3d444db3);
+ border-radius: 0.25rem;
+ cursor: pointer;
+ transition: all 0.15s ease;
+}
+
+.markdown-body .code-block-copy:hover,
+.markdown-body .code-block-collapse:hover {
+ color: var(--fgColor-default, #f0f6fc);
+ background-color: var(--bgColor-neutral-muted, #656c7633);
+ border-color: var(--borderColor-default, #3d444d);
+}
+
+.markdown-body .code-block-copy:active,
+.markdown-body .code-block-collapse:active {
+ transform: scale(0.95);
+}
+
+.markdown-body .code-block-copy svg,
+.markdown-body .code-block-collapse svg {
+ width: 1rem;
+ height: 1rem;
+}
+
+/* Hide the language data span (it's only for JS to read) */
+.markdown-body .code-lang-data {
+ display: none;
+}
+
+/* Collapsed state for code blocks */
+.markdown-body .code-block-wrapper.collapsed pre {
+ display: none;
+}
+
+/* Copy button success state */
+.markdown-body .code-block-copy.copied {
+ color: var(--fgColor-success, #14b8a6);
+ border-color: var(--fgColor-success, #14b8a6);
+}
+
+.markdown-body .code-block-copy.copied:hover {
+ color: var(--fgColor-success, #14b8a6);
+ border-color: var(--fgColor-success, #14b8a6);
+}
+
+/* Light mode adjustments */
+@media (prefers-color-scheme: light) {
+ .markdown-body .code-block-header {
+ background-color: var(--bgColor-muted, #f6f8fa);
+ border-bottom-color: var(--borderColor-muted, #d1d9e0b3);
+ }
+
+ .markdown-body .code-block-language {
+ color: var(--fgColor-muted, #59636e);
+ }
+
+ .markdown-body .code-block-copy,
+ .markdown-body .code-block-collapse {
+ color: var(--fgColor-muted, #59636e);
+ border-color: var(--borderColor-muted, #d1d9e0b3);
+ }
+
+ .markdown-body .code-block-copy:hover,
+ .markdown-body .code-block-collapse:hover {
+ color: var(--fgColor-default, #1f2328);
+ background-color: var(--bgColor-neutral-muted, #818b981f);
+ border-color: var(--borderColor-default, #d1d9e0);
+ }
+
+ .markdown-body .code-block-copy.copied {
+ color: var(--fgColor-success, #14b8a6);
+ border-color: var(--fgColor-success, #14b8a6);
+ }
+
+ .markdown-body .code-block-copy.copied:hover {
+ color: var(--fgColor-success, #14b8a6);
+ border-color: var(--fgColor-success, #14b8a6);
+ }
+}
+
+/* Iframe placeholder styles - these reserve space for the overlay iframes */
+.markdown-body .iframe-placeholder {
+ /* Default dimensions if not specified inline */
+ width: 100%;
+ min-height: 300px;
+ /* Allow inline styles to override */
+ box-sizing: border-box;
+ background: transparent;
+ border-radius: 6px;
+ position: relative;
+ /* Margins for proper spacing */
+ margin-top: 16px;
+ margin-bottom: 16px;
+}
+
+/* When iframe has explicit dimensions via inline style, don't force min-height */
+.markdown-body .iframe-placeholder[style*="height"] {
+ min-height: unset;
+}
+
+.markdown-body .iframe-placeholder[style*="width"] {
+ width: unset;
+}
+
+/* Iframe overlay wrapper styles */
+.iframe-overlay-wrapper {
+ overflow: hidden;
+ border-radius: 6px;
+}
+
+.iframe-overlay-wrapper iframe {
+ display: block;
+ width: 100%;
+ height: 100%;
+ border: none;
+}
+
+@media (prefers-color-scheme: dark) {
+ .markdown-body .iframe-placeholder {
+ background: var(--bgColor-muted, #161b22);
+ border-color: var(--borderColor-default, #30363d);
+ }
+}
diff --git a/markdown-renderer/Cargo.lock b/markdown-renderer/Cargo.lock
index ec4867f..41fab90 100644
--- a/markdown-renderer/Cargo.lock
+++ b/markdown-renderer/Cargo.lock
@@ -17,6 +17,19 @@ dependencies = [
"memchr",
]
+[[package]]
+name = "ammonia"
+version = "4.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "17e913097e1a2124b46746c980134e8c954bc17a6a59bb3fde96f088d126dde6"
+dependencies = [
+ "cssparser",
+ "html5ever",
+ "maplit",
+ "tendril",
+ "url",
+]
+
[[package]]
name = "anes"
version = "0.1.6"
@@ -470,6 +483,29 @@ version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
+[[package]]
+name = "cssparser"
+version = "0.35.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4e901edd733a1472f944a45116df3f846f54d37e67e68640ac8bb69689aca2aa"
+dependencies = [
+ "cssparser-macros",
+ "dtoa-short",
+ "itoa",
+ "phf 0.11.3",
+ "smallvec",
+]
+
+[[package]]
+name = "cssparser-macros"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331"
+dependencies = [
+ "quote",
+ "syn",
+]
+
[[package]]
name = "csv"
version = "1.4.0"
@@ -530,6 +566,21 @@ dependencies = [
"syn",
]
+[[package]]
+name = "dtoa"
+version = "1.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590"
+
+[[package]]
+name = "dtoa-short"
+version = "0.3.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87"
+dependencies = [
+ "dtoa",
+]
+
[[package]]
name = "ecow"
version = "0.2.6"
@@ -707,6 +758,16 @@ dependencies = [
"percent-encoding",
]
+[[package]]
+name = "futf"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843"
+dependencies = [
+ "mac",
+ "new_debug_unreachable",
+]
+
[[package]]
name = "getrandom"
version = "0.3.4"
@@ -804,7 +865,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10e7e97ce840a6a70e7901e240ec65ba61106b66b37a4a1b899a2ce484248463"
dependencies = [
"log",
- "phf",
+ "phf 0.13.1",
]
[[package]]
@@ -819,7 +880,7 @@ dependencies = [
"kurbo 0.12.0",
"log",
"moxcms",
- "phf",
+ "phf 0.13.1",
"rustc-hash",
"siphasher",
"skrifa",
@@ -867,6 +928,17 @@ version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
+[[package]]
+name = "html5ever"
+version = "0.35.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "55d958c2f74b664487a2035fe1dadb032c48718a03b63f3ab0b8537db8549ed4"
+dependencies = [
+ "log",
+ "markup5ever",
+ "match_token",
+]
+
[[package]]
name = "hypher"
version = "0.1.6"
@@ -1305,10 +1377,23 @@ version = "0.4.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94"
+[[package]]
+name = "mac"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
+
+[[package]]
+name = "maplit"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
+
[[package]]
name = "markdown-renderer"
version = "0.1.0"
dependencies = [
+ "ammonia",
"comrak",
"criterion",
"mini-moka",
@@ -1317,6 +1402,28 @@ dependencies = [
"wasm-bindgen",
]
+[[package]]
+name = "markup5ever"
+version = "0.35.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "311fe69c934650f8f19652b3946075f0fc41ad8757dbb68f1ca14e7900ecc1c3"
+dependencies = [
+ "log",
+ "tendril",
+ "web_atoms",
+]
+
+[[package]]
+name = "match_token"
+version = "0.35.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac84fd3f360fcc43dc5f5d186f02a94192761a080e8bc58621ad4d12296a58cf"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
[[package]]
name = "memchr"
version = "2.7.4"
@@ -1364,6 +1471,12 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13d2233c9842d08cfe13f9eac96e207ca6a2ea10b80259ebe8ad0268be27d2af"
+[[package]]
+name = "new_debug_unreachable"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
+
[[package]]
name = "num-bigint"
version = "0.4.6"
@@ -1478,17 +1591,47 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+[[package]]
+name = "phf"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
+dependencies = [
+ "phf_macros 0.11.3",
+ "phf_shared 0.11.3",
+]
+
[[package]]
name = "phf"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
dependencies = [
- "phf_macros",
- "phf_shared",
+ "phf_macros 0.13.1",
+ "phf_shared 0.13.1",
"serde",
]
+[[package]]
+name = "phf_codegen"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a"
+dependencies = [
+ "phf_generator 0.11.3",
+ "phf_shared 0.11.3",
+]
+
+[[package]]
+name = "phf_generator"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
+dependencies = [
+ "phf_shared 0.11.3",
+ "rand",
+]
+
[[package]]
name = "phf_generator"
version = "0.13.1"
@@ -1496,7 +1639,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
dependencies = [
"fastrand",
- "phf_shared",
+ "phf_shared 0.13.1",
+]
+
+[[package]]
+name = "phf_macros"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
+dependencies = [
+ "phf_generator 0.11.3",
+ "phf_shared 0.11.3",
+ "proc-macro2",
+ "quote",
+ "syn",
]
[[package]]
@@ -1505,13 +1661,22 @@ version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef"
dependencies = [
- "phf_generator",
- "phf_shared",
+ "phf_generator 0.13.1",
+ "phf_shared 0.13.1",
"proc-macro2",
"quote",
"syn",
]
+[[package]]
+name = "phf_shared"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
+dependencies = [
+ "siphasher",
+]
+
[[package]]
name = "phf_shared"
version = "0.13.1"
@@ -1636,6 +1801,12 @@ dependencies = [
"zerocopy",
]
+[[package]]
+name = "precomputed-hash"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
+
[[package]]
name = "proc-macro-hack"
version = "0.5.20+deprecated"
@@ -2087,6 +2258,31 @@ dependencies = [
"float-cmp",
]
+[[package]]
+name = "string_cache"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f"
+dependencies = [
+ "new_debug_unreachable",
+ "parking_lot",
+ "phf_shared 0.11.3",
+ "precomputed-hash",
+ "serde",
+]
+
+[[package]]
+name = "string_cache_codegen"
+version = "0.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0"
+dependencies = [
+ "phf_generator 0.11.3",
+ "phf_shared 0.11.3",
+ "proc-macro2",
+ "quote",
+]
+
[[package]]
name = "strum"
version = "0.27.2"
@@ -2180,6 +2376,17 @@ dependencies = [
"windows-sys",
]
+[[package]]
+name = "tendril"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0"
+dependencies = [
+ "futf",
+ "mac",
+ "utf-8",
+]
+
[[package]]
name = "thin-vec"
version = "0.2.14"
@@ -2501,7 +2708,7 @@ dependencies = [
"lipsum",
"memchr",
"palette",
- "phf",
+ "phf 0.13.1",
"png 0.17.16",
"qcms",
"rayon",
@@ -2806,6 +3013,12 @@ dependencies = [
"xmlwriter",
]
+[[package]]
+name = "utf-8"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
+
[[package]]
name = "utf8_iter"
version = "1.0.4"
@@ -2938,6 +3151,18 @@ dependencies = [
"wasm-bindgen",
]
+[[package]]
+name = "web_atoms"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "57ffde1dc01240bdf9992e3205668b235e59421fd085e8a317ed98da0178d414"
+dependencies = [
+ "phf 0.11.3",
+ "phf_codegen",
+ "string_cache",
+ "string_cache_codegen",
+]
+
[[package]]
name = "weezl"
version = "0.1.10"
diff --git a/markdown-renderer/Cargo.toml b/markdown-renderer/Cargo.toml
index 84ccfad..af276c8 100644
--- a/markdown-renderer/Cargo.toml
+++ b/markdown-renderer/Cargo.toml
@@ -11,10 +11,13 @@ wasm-bindgen = "0.2.105"
comrak = { git = "https://github.com/DoublePrecision/comrak.git", branch = "feat/math-renderer", default-features = false }
syntect = { version = "5.0.0", default-features = false, features = ["default-fancy"]}
mini-moka = { version = "0.10.3", optional = true }
+ammonia = { version = "4.1.2", optional = true }
+regex = "1.12.2"
[features]
default = ["mini-moka"]
cached-code = ["mini-moka"]
+sanitize = ["ammonia"]
[profile.release]
opt-level = 's'
@@ -24,7 +27,7 @@ panic = 'abort'
[dev-dependencies]
criterion = { version = "0.5", features = ["html_reports"] }
-regex = "1.12.2"
+
[[bench]]
name = "my_benchmark"
diff --git a/markdown-renderer/src/lib.rs b/markdown-renderer/src/lib.rs
index 3934dc0..b22e2fa 100644
--- a/markdown-renderer/src/lib.rs
+++ b/markdown-renderer/src/lib.rs
@@ -3,6 +3,9 @@ use std::{collections::HashMap, sync::LazyLock};
use syntect::{dumps::from_binary, highlighting::ThemeSet, parsing::SyntaxSet};
use wasm_bindgen::prelude::*;
+#[cfg(feature = "sanitize")]
+use std::collections::HashSet;
+
use crate::syntect_plugin::{SyntectAdapterCached, SyntectAdapterCachedBuilder};
mod syntect_plugin;
@@ -106,6 +109,7 @@ pub static SYNTAX_SET: LazyLock
=
pub static THEME_SET: LazyLock =
LazyLock::new(|| from_binary(include_bytes!("../sublime/themes/all.themedump")));
+#[cfg(feature = "sanitize")]
static OPTIONS: LazyLock = LazyLock::new(|| {
let mut options = Options::default();
options.extension.table = true;
@@ -118,6 +122,25 @@ static OPTIONS: LazyLock = LazyLock::new(|| {
options.extension.math_code = true;
options.extension.math_dollars = true;
options.extension.front_matter_delimiter = Some("---".into());
+ options.render.r#unsafe = true;
+
+ options
+});
+
+#[cfg(not(feature = "sanitize"))]
+static OPTIONS: LazyLock = LazyLock::new(|| {
+ let mut options = Options::default();
+ options.extension.table = true;
+ options.extension.tasklist = true;
+ options.extension.alerts = true;
+ options.extension.underline = true;
+ options.extension.strikethrough = true;
+ options.extension.spoiler = true;
+ options.extension.superscript = true;
+ options.extension.math_code = true;
+ options.extension.math_dollars = true;
+ options.extension.front_matter_delimiter = Some("---".into());
+ options.render.r#unsafe = true;
options
});
@@ -149,9 +172,350 @@ static PLUGINS: LazyLock>> = LazyLock::ne
map
});
+#[cfg(feature = "sanitize")]
+static SANITIZER: LazyLock> = LazyLock::new(|| {
+ use std::collections::HashSet;
+
+ let mut builder = ammonia::Builder::default();
+
+ // Get default tags and add iframe + SVG elements
+ let mut tags = HashSet::with_capacity(100);
+
+ // Standard HTML tags from ammonia defaults
+ for tag in [
+ "a",
+ "abbr",
+ "acronym",
+ "area",
+ "article",
+ "aside",
+ "b",
+ "bdi",
+ "bdo",
+ "blockquote",
+ "br",
+ "caption",
+ "center",
+ "cite",
+ "code",
+ "col",
+ "colgroup",
+ "data",
+ "dd",
+ "del",
+ "details",
+ "dfn",
+ "div",
+ "dl",
+ "dt",
+ "em",
+ "figcaption",
+ "figure",
+ "footer",
+ "h1",
+ "h2",
+ "h3",
+ "h4",
+ "h5",
+ "h6",
+ "header",
+ "hgroup",
+ "hr",
+ "i",
+ "img",
+ "ins",
+ "kbd",
+ "li",
+ "map",
+ "mark",
+ "menu",
+ "nav",
+ "ol",
+ "p",
+ "pre",
+ "q",
+ "rp",
+ "rt",
+ "rtc",
+ "ruby",
+ "s",
+ "samp",
+ "small",
+ "span",
+ "strike",
+ "strong",
+ "sub",
+ "summary",
+ "sup",
+ "table",
+ "tbody",
+ "td",
+ "tfoot",
+ "th",
+ "thead",
+ "time",
+ "tr",
+ "tt",
+ "u",
+ "ul",
+ "var",
+ "wbr",
+ // Additional tags for our use case
+ "iframe",
+ "input",
+ // SVG elements
+ "svg",
+ "g",
+ "path",
+ "use",
+ "defs",
+ "symbol",
+ "circle",
+ "ellipse",
+ "line",
+ "polygon",
+ "polyline",
+ "rect",
+ "text",
+ "tspan",
+ "clipPath",
+ "mask",
+ "linearGradient",
+ "radialGradient",
+ "stop",
+ "filter",
+ "feGaussianBlur",
+ "feOffset",
+ "feBlend",
+ "feMerge",
+ "feMergeNode",
+ "image",
+ "foreignObject",
+ "title",
+ "desc",
+ ] {
+ tags.insert(tag);
+ }
+ builder.tags(tags);
+
+ // Allow necessary attributes for various elements
+ let mut tag_attributes: HashMap<&str, HashSet<&str>> = HashMap::new();
+
+ // iframe attributes
+ let iframe_attrs = [
+ "src",
+ "width",
+ "height",
+ "frameborder",
+ "allow",
+ "allowfullscreen",
+ "loading",
+ "title",
+ "sandbox",
+ "referrerpolicy",
+ ]
+ .into_iter()
+ .collect::>();
+
+ tag_attributes.insert("iframe", iframe_attrs);
+
+ // SVG attributes - common attributes used across many SVG elements
+ let svg_common_attrs = [
+ "id",
+ "class",
+ "style",
+ "transform",
+ "fill",
+ "fill-rule",
+ "fill-opacity",
+ "stroke",
+ "stroke-width",
+ "stroke-linecap",
+ "stroke-linejoin",
+ "stroke-opacity",
+ "stroke-dasharray",
+ "stroke-dashoffset",
+ "opacity",
+ "clip-path",
+ "clip-rule",
+ "mask",
+ "filter",
+ "x",
+ "y",
+ "width",
+ "height",
+ "rx",
+ "ry",
+ "cx",
+ "cy",
+ "r",
+ "d",
+ "points",
+ "x1",
+ "y1",
+ "x2",
+ "y2",
+ "href",
+ "xlink:href",
+ "overflow",
+ "viewBox",
+ "preserveAspectRatio",
+ "xmlns",
+ "xmlns:xlink",
+ "xmlns:h5",
+ "version",
+ "dx",
+ "dy",
+ "text-anchor",
+ "dominant-baseline",
+ "font-family",
+ "font-size",
+ "font-weight",
+ "font-style",
+ "offset",
+ "stop-color",
+ "stop-opacity",
+ "gradientUnits",
+ "gradientTransform",
+ "spreadMethod",
+ "fx",
+ "fy",
+ "stdDeviation",
+ "result",
+ "in",
+ "in2",
+ "mode",
+ "requiredFeatures",
+ "systemLanguage",
+ ]
+ .into_iter()
+ .collect::>();
+
+ // Apply common SVG attributes to all SVG elements
+ for svg_tag in [
+ "svg",
+ "g",
+ "path",
+ "use",
+ "defs",
+ "symbol",
+ "circle",
+ "ellipse",
+ "line",
+ "polygon",
+ "polyline",
+ "rect",
+ "text",
+ "tspan",
+ "clipPath",
+ "mask",
+ "linearGradient",
+ "radialGradient",
+ "stop",
+ "filter",
+ "feGaussianBlur",
+ "feOffset",
+ "feBlend",
+ "feMerge",
+ "feMergeNode",
+ "image",
+ "foreignObject",
+ ] {
+ tag_attributes.insert(svg_tag, svg_common_attrs.clone());
+ }
+
+ // Input attributes (for tasklists)
+ let input_attrs = ["type", "checked", "disabled"]
+ .into_iter()
+ .collect::>();
+ tag_attributes.insert("input", input_attrs);
+
+ // Span attributes (for code block language labels)
+ let span_attrs = ["data-lang", "hidden"]
+ .into_iter()
+ .collect::>();
+ tag_attributes.insert("span", span_attrs);
+
+ // Image attributes
+ let img_attrs = ["src", "alt", "title", "width", "height"]
+ .into_iter()
+ .collect::>();
+
+ tag_attributes.insert("img", img_attrs);
+
+ // Link attributes (note: 'rel' is managed by ammonia automatically)
+ let a_attrs = ["href", "title"].into_iter().collect::>();
+ tag_attributes.insert("a", a_attrs);
+
+ // Table alignment
+ let td_attrs = ["align"].into_iter().collect::>();
+ tag_attributes.insert("td", td_attrs.clone());
+ tag_attributes.insert("th", td_attrs);
+
+ builder.tag_attributes(tag_attributes);
+
+ // Allow style and class on all elements
+ builder.add_generic_attributes(["style", "class"]);
+
+ // Allow certain URL schemes
+ let url_schemes = ["https", "http", "mailto", "data"]
+ .into_iter()
+ .collect::>();
+
+ builder.url_schemes(url_schemes);
+
+ // Use attribute filter to preserve SVG xlink:href fragment references (like #glyph123)
+ // which ammonia would otherwise strip as they don't match URL schemes
+ builder.attribute_filter(|_element, attribute, value| {
+ // Preserve xlink:href attributes with fragment references in SVG elements
+ if attribute == "xlink:href" && value.starts_with('#') {
+ Some(value.into())
+ } else if attribute == "href" && value.starts_with('#') {
+ // Also preserve regular href with fragments (for SVG 2.0 compatibility)
+ Some(value.into())
+ } else {
+ // Pass through all other attributes unchanged
+ Some(value.into())
+ }
+ });
+
+ builder
+});
+
+#[cfg(feature = "sanitize")]
+fn sanitize_html(html: &str) -> String {
+ SANITIZER.clean(html).to_string()
+}
+
+/// Regex to match sequences that follow code-block-wrapper structure.
+/// We look for which is the closing pattern for our syntax-highlighted code blocks.
+/// This is more specific than just to avoid affecting other pre tags.
+static CODE_BLOCK_CLOSE_REGEX: LazyLock =
+ LazyLock::new(|| regex::Regex::new(r"").unwrap());
+
+/// Post-process HTML to close code-block-wrapper divs.
+/// The SyntaxHighlighterAdapter trait only provides hooks for opening tags,
+/// so we need to close the wrapper div after each tag that belongs to a code block.
+fn close_code_block_wrappers(html: &str) -> String {
+ CODE_BLOCK_CLOSE_REGEX
+ .replace_all(html, " ")
+ .into_owned()
+}
+
#[wasm_bindgen]
pub fn render_md(markdown: &str, theme: Themes) -> String {
- markdown_to_html_with_plugins(markdown, &OPTIONS, &PLUGINS[theme.to_str()])
+ let html = markdown_to_html_with_plugins(markdown, &OPTIONS, &PLUGINS[theme.to_str()]);
+ let html = close_code_block_wrappers(&html);
+
+ #[cfg(feature = "sanitize")]
+ {
+ sanitize_html(&html)
+ }
+
+ #[cfg(not(feature = "sanitize"))]
+ {
+ html
+ }
}
#[cfg(test)]
@@ -298,7 +662,111 @@ mod test {
.to_string()
}
+ /// Test that XSS attacks are blocked when sanitize feature is enabled
+ #[test]
+ #[cfg(feature = "sanitize")]
+ fn test_xss_protection() {
+ // Test onerror XSS
+ let xss_input = r#"