diff --git a/.github/workflows/biome-check.yaml b/.github/workflows/biome-check.yaml index a97caae..56c9a83 100644 --- a/.github/workflows/biome-check.yaml +++ b/.github/workflows/biome-check.yaml @@ -20,7 +20,7 @@ jobs: - name: Run Clippy run: cd markdown-renderer && cargo clippy -- -D warnings - name: Run Tests - run: cd markdown-renderer && cargo test + run: cd markdown-renderer && cargo test --features sanitize biome: name: Run BiomeJS runs-on: ubuntu-latest diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index 7eea905..b1706e6 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -36,7 +36,7 @@ jobs: with: bun-version: latest - name: Build Rust project - run: cd markdown-renderer && wasm-pack build --target web --release + run: cd markdown-renderer && wasm-pack build --target web --features sanitize - name: Post-process wasm package run: bun process_wasm_pkg.js - name: Install dependencies (workspace) diff --git a/.github/workflows/github-pages.yml b/.github/workflows/github-pages.yml index 67666f6..bfa9095 100644 --- a/.github/workflows/github-pages.yml +++ b/.github/workflows/github-pages.yml @@ -3,7 +3,7 @@ name: Deploy Example to GitHub Pages on: push: branches: - - main + - main jobs: deploy: @@ -28,7 +28,7 @@ jobs: run: cargo install wasm-pack - name: Build Rust project - run: cd markdown-renderer && wasm-pack build --target web --release + run: cd markdown-renderer && wasm-pack build --target web --features sanitize - name: Post-process wasm package run: bun process_wasm_pkg.js @@ -43,11 +43,11 @@ jobs: run: cd example && bun install - name: Build example - run: cd example && bun run build + run: cd example && bun run build - name: Deploy to GitHub Pages uses: jamesives/github-pages-deploy-action@v4 with: token: ${{ secrets.GITHUB_TOKEN }} branch: gh-pages - folder: example/dist + folder: example/dist diff --git a/README.md b/README.md index bc7cbca..516986b 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,8 @@ - Math rendering support (using a forked version of [comrak](https://github.com/DoublePrecision/comrak "comrak fork")) - Syntax highlighting using [syntect](https://github.com/trishume/syntect "syntect github") - Huge support for languages and themes +- Support for iframes (e.g. YouTube embeds) +- Sanitization of HTML and links for security with ammonia library - Easy to use ## Users @@ -102,6 +104,106 @@ const App: Component = () => { export default App; ``` +## Customising Code Block Headers + +The rendered code blocks include a header with a language label and buttons for copying and collapsing code. You can customise these using CSS. + +### CSS Classes + +| Class | Description | +|-------|-------------| +| `.code-block-wrapper` | Container wrapping the entire code block | +| `.code-block-header` | The header bar containing language label and buttons | +| `.code-block-language` | The language label text (e.g., "typescript") | +| `.code-block-buttons` | Container for the action buttons | +| `.code-block-copy` | The copy button | +| `.code-block-collapse` | The collapse/expand button | +| `.code-block-wrapper.collapsed` | Applied when code block is collapsed | +| `.code-block-copy.copied` | Applied when code has been copied (for 2 seconds) | + +### Example CSS + +```css +/* Dark theme header */ +.markdown-body .code-block-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.5rem 1rem; + background-color: #151b23; + border-bottom: 1px solid #3d444db3; +} + +/* Language label */ +.markdown-body .code-block-language { + font-size: 0.75rem; + font-weight: 500; + color: #9198a1; +} + +/* Button container */ +.markdown-body .code-block-buttons { + display: flex; + align-items: center; + gap: 0.5rem; +} + +/* Copy and collapse buttons */ +.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: #9198a1; + background: transparent; + border: 1px solid #3d444db3; + border-radius: 0.25rem; + cursor: pointer; + transition: all 0.15s ease-in-out; +} + +.markdown-body .code-block-copy:hover, +.markdown-body .code-block-collapse:hover { + color: #f0f6fc; + background-color: #656c7633; + border-color: #3d444d; +} + +/* Icon sizing */ +.markdown-body .code-block-copy svg, +.markdown-body .code-block-collapse svg { + width: 1rem; + height: 1rem; +} + +/* Hide code when collapsed */ +.markdown-body .code-block-wrapper.collapsed pre { + display: none; +} + +/* Success state after copying */ +.markdown-body .code-block-copy.copied { + color: #5eeed8; + border-color: #5eeed8; +} +``` + +### CSS Variables + +The default styles use CSS variables that you can override: + +| Variable | Description | Default (dark) | +|----------|-------------|----------------| +| `--bgColor-muted` | Header background | `#151b23` | +| `--borderColor-muted` | Border color | `#3d444db3` | +| `--fgColor-muted` | Muted text/icon color | `#9198a1` | +| `--fgColor-default` | Default text/icon color | `#f0f6fc` | +| `--bgColor-neutral-muted` | Hover background | `#656c7633` | +| `--borderColor-default` | Hover border color | `#3d444d` | +| `--fgColor-success` | Success state color | `#5eeed8` | + ## Available Themes and supported Languages For a list of available themes and languages, please refer to [THEMES_AND_LANGS.md](./THEMES_AND_LANGS.md). Autocomplete is also supported via your IDE as the themes are exported as unions of string literals. diff --git a/bun.lockb b/bun.lockb index 4673ade..af34656 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/example/src/App.tsx b/example/src/App.tsx index 7216aeb..a6bc102 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -1,8 +1,68 @@ -import { type Component, createSignal, onCleanup, onMount } from "solid-js"; -import { MarkdownRenderer } from "solid-markdown-wasm"; +import { + type Component, + For, + createSignal, + onCleanup, + onMount, +} from "solid-js"; +import { MarkdownRenderer, type Themes } from "solid-markdown-wasm"; import { MonacoEditor } from "solid-monaco"; +import haxiomLogo from "../src/assets/haxiom.svg"; import initialMarkdown from "../src/assets/markdown_preview.md?raw"; +// All available themes from the Rust lib.rs (matches the Themes type) +const CODE_THEMES: Themes[] = [ + "1337", + "OneHalfDark", + "OneHalfLight", + "Tomorrow", + "agola-dark", + "ascetic-white", + "axar", + "ayu-dark", + "ayu-light", + "ayu-mirage", + "base16-atelierdune-light", + "base16-ocean-dark", + "base16-ocean-light", + "bbedit", + "boron", + "charcoal", + "cheerfully-light", + "classic-modified", + "demain", + "dimmed-fluid", + "dracula", + "gray-matter-dark", + "green", + "gruvbox-dark", + "gruvbox-light", + "idle", + "inspired-github", + "ir-white", + "kronuz", + "material-dark", + "material-light", + "monokai", + "nord", + "nyx-bold", + "one-dark", + "railsbase16-green-screen-dark", + "solarized-dark", + "solarized-light", + "subway-madrid", + "subway-moscow", + "two-dark", + "visual-studio-dark", + "zenburn", +]; + +const EDITOR_THEMES = [ + { value: "vs", label: "Light" }, + { value: "vs-dark", label: "Dark" }, + { value: "hc-black", label: "High Contrast" }, +] as const; + const LoadingFallback = () => (
@@ -15,6 +75,16 @@ const App: Component = () => { const [isDarkMode, setIsDarkMode] = createSignal( window.matchMedia("(prefers-color-scheme: dark)").matches, ); + const [codeTheme, setCodeTheme] = createSignal( + window.matchMedia("(prefers-color-scheme: dark)").matches + ? "ayu-dark" + : "ayu-light", + ); + const [editorTheme, setEditorTheme] = createSignal( + window.matchMedia("(prefers-color-scheme: dark)").matches + ? "vs-dark" + : "vs", + ); let timeoutId: number | NodeJS.Timeout | undefined; const debouncedSetMarkdown = (value: string) => { @@ -49,6 +119,8 @@ const App: Component = () => { const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); const handleChange = (e: MediaQueryListEvent) => { setIsDarkMode(e.matches); + setCodeTheme(e.matches ? "ayu-dark" : "ayu-light"); + setEditorTheme(e.matches ? "vs-dark" : "vs"); }; mediaQuery.addEventListener("change", handleChange); @@ -67,37 +139,151 @@ const App: Component = () => { const editorOptions = () => ({ fontFamily: "'Iosevka', monospace", fontSize: 22, - theme: isDarkMode() ? "vs-dark" : "vs-light", + theme: editorTheme(), }); + const selectClass = () => + `px-3 py-1.5 rounded border text-sm font-medium cursor-pointer ${ + isDarkMode() + ? "bg-gray-700 border-gray-600 text-gray-200 hover:bg-gray-600" + : "bg-white border-gray-300 text-gray-700 hover:bg-gray-50" + }`; + return (
-
- { - handleInput(val); - }} - /> -
+ {/* Toolbar */}
-
- } - onLoaded={() => console.log("WASM Loaded")} + {/* Left side - Logo and project name */} +
+ Haxiom + + 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 @@ +Brand Logo 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#""#; + let result = render_md(xss_input, Themes::OneHalfDark); + assert!( + !result.contains("onerror"), + "onerror attribute should be stripped: {}", + result + ); + assert!( + !result.contains("alert"), + "alert should be stripped: {}", + result + ); + + // Test onclick XSS + let xss_input2 = r#"
Click me
"#; + let result2 = render_md(xss_input2, Themes::OneHalfDark); + assert!( + !result2.contains("onclick"), + "onclick attribute should be stripped: {}", + result2 + ); + + // Test javascript: URL + let xss_input3 = r#"Click"#; + let result3 = render_md(xss_input3, Themes::OneHalfDark); + assert!( + !result3.contains("javascript:"), + "javascript: URL should be stripped: {}", + result3 + ); + + // Test script tag + let xss_input4 = r#""#; + let result4 = render_md(xss_input4, Themes::OneHalfDark); + assert!( + !result4.contains("