├── .editorconfig ├── .gitattributes ├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ ├── benchmarks.yml │ ├── msrv.yml │ ├── release.yml │ └── rust.yml ├── .gitignore ├── .gitmodules ├── CODE_OF_CONDUCT.md ├── COPYING ├── Cargo.lock ├── Cargo.toml ├── Makefile ├── README.md ├── RELEASE_CHECKLIST.md ├── benches ├── bench.sh └── progit.rs ├── changelog.txt ├── examples ├── custom_formatter.rs ├── custom_formatter_alt_text.rs ├── custom_formatter_user.rs ├── custom_headings.rs ├── headers.rs ├── iterator_replace.rs ├── s-expr.rs ├── sample.rs ├── syntax_highlighter.rs ├── syntect.rs ├── traverse_demo.rs └── update-readme.rs ├── flake.lock ├── flake.nix ├── fuzz ├── .gitignore ├── Cargo.lock ├── Cargo.toml └── fuzz_targets │ ├── all_options.rs │ ├── cli_default.rs │ ├── fuzz_options.rs │ ├── gfm.rs │ ├── gfm_footnotes.rs │ ├── gfm_sourcepos.rs │ └── quadratic.rs ├── hooks └── pre-commit ├── rustfmt.toml ├── script ├── check-msrv-matches-workflow ├── cibuild ├── progit.md └── version ├── spec_out.txt └── src ├── adapters.rs ├── arena_tree.rs ├── character_set.rs ├── cm.rs ├── ctype.rs ├── entity.rs ├── html.rs ├── html ├── anchorizer.rs └── context.rs ├── lib.rs ├── main.rs ├── nodes.rs ├── parser ├── alert.rs ├── autolink.rs ├── inlines.rs ├── math.rs ├── mod.rs ├── multiline_block_quote.rs ├── shortcodes.rs └── table.rs ├── plugins ├── mod.rs └── syntect.rs ├── scanners.re ├── scanners.rs ├── strings.rs ├── tests.rs ├── tests ├── alerts.rs ├── api.rs ├── autolink.rs ├── commonmark.rs ├── core.rs ├── description_lists.rs ├── empty.rs ├── escaped_char_spans.rs ├── fixtures │ ├── alerts.md │ ├── description_lists.md │ ├── math_code.md │ ├── math_dollars.md │ ├── multiline_alerts.md │ ├── multiline_blockquote.md │ ├── wikilinks_title_after_pipe.md │ └── wikilinks_title_before_pipe.md ├── footnotes.rs ├── front_matter.rs ├── fuzz.rs ├── greentext.rs ├── header_ids.rs ├── math.rs ├── multiline_block_quotes.rs ├── options.rs ├── pathological.rs ├── plugins.rs ├── raw.rs ├── regressions.rs ├── rewriter.rs ├── shortcodes.rs ├── sourcepos.rs ├── spoiler.rs ├── strikethrough.rs ├── subscript.rs ├── superscript.rs ├── table.rs ├── tagfilter.rs ├── tasklist.rs ├── underline.rs ├── wikilinks.rs └── xml.rs └── xml.rs /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | charset = utf-8 6 | trim_trailing_whitespace = true 7 | insert_final_newline = true 8 | indent_style = space 9 | indent_size = 4 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | src/scanners.rs linguist-generated 2 | src/scanners.re linguist-language=Rust 3 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [kivikakk] 4 | # ko_fi: # Replace with a single Ko-fi username 5 | # tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 6 | # community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 7 | # liberapay: # Replace with a single Liberapay username 8 | # issuehunt: # Replace with a single IssueHunt username 9 | # otechie: # Replace with a single Otechie username 10 | # custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 11 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: weekly 7 | -------------------------------------------------------------------------------- /.github/workflows/benchmarks.yml: -------------------------------------------------------------------------------- 1 | name: benchmarks 2 | 3 | on: 4 | pull_request: 5 | types: 6 | - opened 7 | - reopened 8 | issue_comment: 9 | types: 10 | - created 11 | 12 | jobs: 13 | run_benchmarks: 14 | runs-on: ubuntu-latest 15 | permissions: 16 | pull-requests: write 17 | # run either when pull request is opened or when comment body (only on pr) is /run-bench 18 | if: (github.event_name == 'pull_request') || ((github.event.issue.pull_request != null) && github.event.comment.body == '/run-bench') 19 | steps: 20 | - uses: actions/checkout@v4 21 | with: 22 | submodules: true 23 | - name: Setup Rust toolchain 24 | uses: dtolnay/rust-toolchain@stable 25 | - name: Install hyperfine 26 | run: cargo install hyperfine 27 | - name: Install cmake 28 | run: sudo apt-get update && sudo apt-get install cmake -y 29 | - name: Build Binaries 30 | run: make binaries 31 | - name: Run Benchmarks 32 | run: make bench-all 33 | - name: Post result comment 34 | uses: mshick/add-pr-comment@v2 35 | with: 36 | message-path: bench-output.md -------------------------------------------------------------------------------- /.github/workflows/msrv.yml: -------------------------------------------------------------------------------- 1 | name: Ensure declared MSRV is tested 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | ensure_msrv: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v4 10 | - run: script/check-msrv-matches-workflow 11 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release and publish 2 | 3 | on: 4 | workflow_dispatch: 5 | pull_request_target: 6 | types: 7 | - closed 8 | 9 | permissions: 10 | contents: write 11 | pull-requests: write 12 | 13 | jobs: 14 | prepare: 15 | if: ${{ github.event_name == 'workflow_dispatch' }} 16 | runs-on: ubuntu-latest 17 | env: 18 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 19 | 20 | outputs: 21 | version: ${{ steps.version-label.outputs.version }} 22 | 23 | steps: 24 | - uses: actions/checkout@v4 25 | with: 26 | submodules: true 27 | 28 | - name: Configure Git 29 | run: | 30 | git config --local user.email "actions@github.com" 31 | git config --local user.name "Actions Auto Build" 32 | 33 | - name: Get current version 34 | id: version-label 35 | run: | 36 | VERSION=$(grep version Cargo.toml | head -n 1 | cut -d'"' -f2) 37 | echo "version=${VERSION}" >> $GITHUB_OUTPUT 38 | 39 | - name: Get previous version 40 | id: previous-version-label 41 | run: | 42 | PREVIOUS_VERSION=$(gh api "/repos/${{ github.repository }}/tags?per_page=1" | jq -r '.[] | .name?') 43 | echo "previous_version=${PREVIOUS_VERSION}" >> $GITHUB_OUTPUT 44 | 45 | - name: Generate Release Notes 46 | id: generate-release-notes 47 | run: | 48 | generate() { 49 | gh api \ 50 | --method POST \ 51 | -H "Accept: application/vnd.github+json" \ 52 | -H "X-GitHub-Api-Version: 2022-11-28" \ 53 | /repos/${{ github.repository }}/releases/generate-notes \ 54 | -f tag_name='v${{ steps.version-label.outputs.version }}' \ 55 | -f previous_tag='v${{ steps.previous-version-label.outputs.previous_version }}' \ 56 | | jq -r ".body" 57 | } 58 | echo "changelog<> $GITHUB_OUTPUT 59 | 60 | - name: Update changelog.txt 61 | run: | 62 | echo "# [v${{ steps.version-label.outputs.version }}] - `date +%Y-%m-%d`" >> changelog.txt.tmp 63 | echo "${{steps.generate-release-notes.outputs.changelog}}" >> changelog.txt.tmp 64 | echo '' >> changelog.txt 65 | cat changelog.txt >> changelog.txt.tmp 66 | mv changelog.txt.tmp changelog.txt 67 | 68 | - name: Update README 69 | run: | 70 | cargo run --example update-readme 71 | 72 | - name: Commit Changelog and README 73 | run: git add -f changelog.txt README.md 74 | 75 | - name: Create Pull Request 76 | id: cpr 77 | uses: peter-evans/create-pull-request@v7 78 | with: 79 | commit-message: "changelog.txt: generate." 80 | title: "Release v${{ steps.version-label.outputs.version }}." 81 | body: > 82 | This is an automated PR to build the latest changelog. Upon merging, 83 | a new release will be created and published to crates.io.
84 | Due to security considerations, PRs created by GitHub Actions cannot 85 | be merged automatically. Please review the changes and merge the PR.
86 | If you require the test suites to run, you can close the PR and reopen it to trigger 87 | those workflows. 88 | delete-branch: true 89 | labels: release 90 | branch: "release/v${{ steps.version-label.outputs.version }}" 91 | 92 | - name: Enable Pull Request Automerge 93 | uses: peter-evans/enable-pull-request-automerge@v3 94 | with: 95 | token: ${{ secrets.GITHUB_TOKEN }} 96 | pull-request-number: ${{ steps.cpr.outputs.pull-request-number }} 97 | 98 | release: 99 | if: ${{ (github.event.pull_request.merged == true) && (contains(github.event.pull_request.labels.*.name, 'release')) }} 100 | runs-on: ubuntu-latest 101 | env: 102 | CRATES_IO_TOKEN: ${{ secrets.CRATES_IO_TOKEN }} 103 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 104 | 105 | steps: 106 | - uses: actions/checkout@v4 107 | with: 108 | submodules: recursive 109 | 110 | - name: Configure Git 111 | run: | 112 | git config --local user.email "actions@github.com" 113 | git config --local user.name "Actions Auto Build" 114 | 115 | - name: Get current version 116 | id: version-label 117 | run: | 118 | VERSION=$(grep version Cargo.toml | head -n 1 | cut -d'"' -f2) 119 | echo "version=${VERSION}" >> $GITHUB_OUTPUT 120 | 121 | - name: Create tag 122 | run: | 123 | git tag -a v${{ steps.version-label.outputs.version }} -m "Release v${{ steps.version-label.outputs.version }}" 124 | git push origin --tags 125 | 126 | - name: Login to Crates.io 127 | run: cargo login ${CRATES_IO_TOKEN} 128 | 129 | - name: Publish GitHub release 130 | run: | 131 | gh release create v${{ steps.version-label.outputs.version }} --generate-notes 132 | 133 | - name: Publish crate 134 | run: cargo publish 135 | -------------------------------------------------------------------------------- /.github/workflows/rust.yml: -------------------------------------------------------------------------------- 1 | name: Rust 2 | 3 | on: [push, pull_request] 4 | 5 | env: 6 | MSRV: 1.65 7 | 8 | jobs: 9 | build_lib_test: 10 | runs-on: ubuntu-latest 11 | strategy: 12 | matrix: 13 | rust: 14 | - nightly 15 | - beta 16 | - stable 17 | - $MSRV 18 | steps: 19 | - uses: actions/checkout@v4 20 | with: 21 | submodules: true 22 | - name: Obtain Rust 23 | run: rustup override set ${{ matrix.rust }} 24 | - name: Build library 25 | run: cargo build --locked --verbose --lib 26 | - name: Build examples 27 | run: cargo build --locked --verbose --lib --examples 28 | - name: Run unit tests 29 | run: cargo test --locked --verbose 30 | - name: "Run README sample" 31 | run: cargo run --locked --example sample 32 | build_bin_spec: 33 | runs-on: ubuntu-latest 34 | strategy: 35 | matrix: 36 | rust: 37 | - nightly 38 | - beta 39 | - stable 40 | - $MSRV 41 | steps: 42 | - uses: actions/checkout@v4 43 | with: 44 | submodules: true 45 | - name: Obtain Rust 46 | run: rustup override set ${{ matrix.rust }} 47 | - name: Build binary 48 | run: cargo build --locked --verbose --bin comrak --release 49 | - name: Run spec tests 50 | run: script/cibuild 51 | build_wasm: 52 | runs-on: ubuntu-latest 53 | strategy: 54 | matrix: 55 | rust: 56 | - nightly 57 | - beta 58 | - stable 59 | - $MSRV 60 | steps: 61 | - uses: actions/checkout@v4 62 | with: 63 | submodules: true 64 | - name: Obtain Rust 65 | run: rustup override set ${{ matrix.rust }} 66 | - name: Setup for wasm 67 | run: rustup target add wasm32-unknown-unknown 68 | - name: Build 69 | run: cargo build --locked --verbose --target wasm32-unknown-unknown 70 | - name: Build examples 71 | run: cargo build --locked --verbose --target wasm32-unknown-unknown --examples 72 | no_features_build_test: 73 | runs-on: ubuntu-latest 74 | strategy: 75 | matrix: 76 | rust: 77 | - nightly 78 | - beta 79 | - stable 80 | - $MSRV 81 | steps: 82 | - uses: actions/checkout@v4 83 | with: 84 | submodules: true 85 | - name: Obtain Rust 86 | run: rustup override set ${{ matrix.rust }} 87 | - name: Build and test with no features 88 | run: cargo test --locked --no-default-features 89 | all_features_build_test: 90 | runs-on: ubuntu-latest 91 | strategy: 92 | matrix: 93 | rust: 94 | - nightly 95 | - beta 96 | - stable 97 | - $MSRV 98 | steps: 99 | - uses: actions/checkout@v4 100 | with: 101 | submodules: true 102 | - name: Obtain Rust 103 | run: rustup override set ${{ matrix.rust }} 104 | - name: Build and test with no features 105 | run: cargo test --locked --all-features 106 | clippy_format: 107 | runs-on: ubuntu-latest 108 | steps: 109 | - uses: actions/checkout@v4 110 | with: 111 | submodules: true 112 | - name: Obtain Rust 113 | run: rustup override set $MSRV 114 | - name: Check clippy 115 | run: rustup component add clippy && cargo clippy 116 | - name: Check formatting 117 | run: rustup component add rustfmt && cargo fmt -- --check 118 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | target 2 | comrak-* 3 | .vscode 4 | .idea 5 | vendor/comrak 6 | vendor/progit 7 | benches/cmark-gfm 8 | benches/comrak-* 9 | benches/pulldown-cmark 10 | benches/markdown-it 11 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "vendor/cmark-gfm"] 2 | path = vendor/cmark-gfm 3 | url = https://github.com/kivikakk/cmark-gfm.git 4 | [submodule "vendor/pulldown-cmark"] 5 | path = vendor/pulldown-cmark 6 | url = https://github.com/raphlinus/pulldown-cmark.git 7 | [submodule "vendor/markdown-it"] 8 | path = vendor/markdown-it 9 | url = https://github.com/rlidwka/markdown-it.rs.git 10 | [submodule "vendor/commonmark-spec"] 11 | path = vendor/commonmark-spec 12 | url = https://github.com/commonmark/commonmark-spec 13 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | We are sentient. 4 | To be sentient is to be limited. 5 | In our limitation, we make choices that are unwise or are flawed. 6 | 7 | If we make unwise choices because of our limitation, 8 | we cannot judge others for the same reason. 9 | 10 | So, we cannot judge, 11 | thus we forgive. 12 | 13 | This project and its results are intended as: 14 | a place of learning, 15 | a place of understanding, 16 | a place of teaching, 17 | a place of sharing, 18 | a place of creators creating the tools for other creators to create complicated things elegantly. 19 | 20 | Be well, Creator. Be well and create. 21 | 22 | --- 23 | 24 | Based on the [Creator's Code v2](https://github.com/Xe/creators-code). Please 25 | read the link for more information. 26 | -------------------------------------------------------------------------------- /COPYING: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017–2025, Comrak contributors 2 | 3 | All rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | * Redistributions of source code must retain the above copyright 9 | notice, this list of conditions and the following disclaimer. 10 | 11 | * Redistributions in binary form must reproduce the above 12 | copyright notice, this list of conditions and the following 13 | disclaimer in the documentation and/or other materials provided 14 | with the distribution. 15 | 16 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 17 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 18 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 19 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 20 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 21 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 22 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 23 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 24 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 25 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 26 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 27 | 28 | ----- 29 | 30 | cmark-gfm 31 | 32 | derived from https://github.com/github/cmark 33 | 34 | Copyright (c) 2014, John MacFarlane 35 | 36 | All rights reserved. 37 | 38 | Redistribution and use in source and binary forms, with or without 39 | modification, are permitted provided that the following conditions are met: 40 | 41 | * Redistributions of source code must retain the above copyright 42 | notice, this list of conditions and the following disclaimer. 43 | 44 | * Redistributions in binary form must reproduce the above 45 | copyright notice, this list of conditions and the following 46 | disclaimer in the documentation and/or other materials provided 47 | with the distribution. 48 | 49 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 50 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 51 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 52 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 53 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 54 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 55 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 56 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 57 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 58 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 59 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 60 | 61 | ----- 62 | 63 | houdini.h, houdini_href_e.c, houdini_html_e.c, houdini_html_u.c 64 | 65 | derive from https://github.com/vmg/houdini (with some modifications) 66 | 67 | Copyright (C) 2012 Vicent Martí 68 | 69 | Permission is hereby granted, free of charge, to any person obtaining a copy of 70 | this software and associated documentation files (the "Software"), to deal in 71 | the Software without restriction, including without limitation the rights to 72 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 73 | of the Software, and to permit persons to whom the Software is furnished to do 74 | so, subject to the following conditions: 75 | 76 | The above copyright notice and this permission notice shall be included in all 77 | copies or substantial portions of the Software. 78 | 79 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 80 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 81 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 82 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 83 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 84 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 85 | SOFTWARE. 86 | 87 | ----- 88 | 89 | buffer.h, buffer.c, chunk.h 90 | 91 | are derived from code (C) 2012 Github, Inc. 92 | 93 | Permission is hereby granted, free of charge, to any person obtaining a copy of 94 | this software and associated documentation files (the "Software"), to deal in 95 | the Software without restriction, including without limitation the rights to 96 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 97 | of the Software, and to permit persons to whom the Software is furnished to do 98 | so, subject to the following conditions: 99 | 100 | The above copyright notice and this permission notice shall be included in all 101 | copies or substantial portions of the Software. 102 | 103 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 104 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 105 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 106 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 107 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 108 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 109 | SOFTWARE. 110 | 111 | ----- 112 | 113 | utf8.c and utf8.c 114 | 115 | are derived from utf8proc 116 | (), 117 | (C) 2009 Public Software Group e. V., Berlin, Germany. 118 | 119 | Permission is hereby granted, free of charge, to any person obtaining a 120 | copy of this software and associated documentation files (the "Software"), 121 | to deal in the Software without restriction, including without limitation 122 | the rights to use, copy, modify, merge, publish, distribute, sublicense, 123 | and/or sell copies of the Software, and to permit persons to whom the 124 | Software is furnished to do so, subject to the following conditions: 125 | 126 | The above copyright notice and this permission notice shall be included in 127 | all copies or substantial portions of the Software. 128 | 129 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 130 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 131 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 132 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 133 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 134 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 135 | DEALINGS IN THE SOFTWARE. 136 | 137 | ----- 138 | 139 | The normalization code in normalize.py was derived from the 140 | markdowntest project, Copyright 2013 Karl Dubost: 141 | 142 | The MIT License (MIT) 143 | 144 | Copyright (c) 2013 Karl Dubost 145 | 146 | Permission is hereby granted, free of charge, to any person obtaining 147 | a copy of this software and associated documentation files (the 148 | "Software"), to deal in the Software without restriction, including 149 | without limitation the rights to use, copy, modify, merge, publish, 150 | distribute, sublicense, and/or sell copies of the Software, and to 151 | permit persons to whom the Software is furnished to do so, subject to 152 | the following conditions: 153 | 154 | The above copyright notice and this permission notice shall be 155 | included in all copies or substantial portions of the Software. 156 | 157 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 158 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 159 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 160 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 161 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 162 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 163 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 164 | 165 | ----- 166 | 167 | The CommonMark spec (test/spec.txt) is 168 | 169 | Copyright (C) 2014-15 John MacFarlane 170 | 171 | Released under the Creative Commons CC-BY-SA 4.0 license: 172 | . 173 | 174 | ----- 175 | 176 | The test software in test/ is 177 | 178 | Copyright (c) 2014, John MacFarlane 179 | 180 | All rights reserved. 181 | 182 | Redistribution and use in source and binary forms, with or without 183 | modification, are permitted provided that the following conditions are met: 184 | 185 | * Redistributions of source code must retain the above copyright 186 | notice, this list of conditions and the following disclaimer. 187 | 188 | * Redistributions in binary form must reproduce the above 189 | copyright notice, this list of conditions and the following 190 | disclaimer in the documentation and/or other materials provided 191 | with the distribution. 192 | 193 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 194 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 195 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 196 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 197 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 198 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 199 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 200 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 201 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 202 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 203 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 204 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "comrak" 3 | version = "0.39.0" 4 | authors = ["Talya Connor ", "Brett Walker ", "gjtorikian"] 5 | rust-version = "1.65" 6 | description = "A 100% CommonMark-compatible GitHub Flavored Markdown parser and formatter" 7 | documentation = "https://docs.rs/comrak" 8 | homepage = "https://github.com/kivikakk/comrak" 9 | repository = "https://github.com/kivikakk/comrak" 10 | readme = "README.md" 11 | keywords = ["markdown", "commonmark"] 12 | license = "BSD-2-Clause" 13 | categories = ["text-processing", "parsing", "command-line-utilities"] 14 | exclude = [ 15 | "/hooks/*", 16 | "/script/*", 17 | "/vendor/*", 18 | "/.travis.yml", 19 | "/Makefile", 20 | "/spec_out.txt", 21 | ] 22 | resolver = "2" 23 | edition = "2018" 24 | 25 | [package.metadata.docs.rs] 26 | all-features = true 27 | rustdoc-args = ["--cfg", "docsrs"] 28 | 29 | [profile.release] 30 | lto = true 31 | 32 | [[bin]] 33 | name = "comrak" 34 | required-features = ["cli", "syntect"] 35 | doc = false 36 | 37 | [dependencies] 38 | typed-arena = "2.0.2" 39 | entities = "1.0.1" 40 | unicode_categories = "0.1.1" 41 | memchr = "2" 42 | shell-words = { version = "1.0", optional = true } 43 | slug = "0.1.4" 44 | emojis = { version = "0.6.2", optional = true } 45 | arbitrary = { version = "1", optional = true, features = ["derive"] } 46 | bon = { version = "3", optional = true } 47 | caseless = "0.2.1" 48 | 49 | [dev-dependencies] 50 | ntest = "0.9" 51 | strum = { version = "0.26.3", features = ["derive"] } 52 | toml = "0.7.3" 53 | 54 | [features] 55 | default = ["cli", "syntect", "bon"] 56 | cli = ["clap", "shell-words", "xdg"] 57 | shortcodes = ["emojis"] 58 | bon = ["dep:bon"] 59 | 60 | [target.'cfg(all(not(windows), not(target_arch="wasm32")))'.dependencies] 61 | xdg = { version = "^2.5", optional = true } 62 | 63 | [target.'cfg(target_arch="wasm32")'.dependencies] 64 | syntect = { version = "5.0", optional = true, default-features = false, features = [ 65 | "default-fancy", 66 | ] } 67 | clap = { version = "4.0.32", optional = true, features = ["derive", "string"] } 68 | 69 | [target.'cfg(not(target_arch="wasm32"))'.dependencies] 70 | syntect = { version = "5.0", optional = true, default-features = false, features = [ 71 | "default-themes", 72 | "default-syntaxes", 73 | "html", 74 | "regex-onig", 75 | ] } 76 | clap = { version = "4.0", optional = true, features = [ 77 | "derive", 78 | "string", 79 | "wrap_help", 80 | ] } 81 | 82 | [[example]] 83 | name = "syntect" 84 | required-features = [ "syntect" ] 85 | 86 | [[example]] 87 | name = "s-expr" 88 | required-features = [ "bon" ] 89 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | ROOT:=$(shell git rev-parse --show-toplevel) 2 | COMMIT:=$(shell git rev-parse --short HEAD) 3 | MIN_RUNS:=25 4 | 5 | src/scanners.rs: src/scanners.re 6 | re2rust -W -Werror -i --no-generation-date -o $@ $< 7 | cargo fmt 8 | 9 | bench: 10 | cargo build --release 11 | (cd vendor/cmark-gfm/; make bench PROG=../../target/release/comrak) 12 | 13 | binaries: build-comrak-branch build-comrak-master build-cmark-gfm build-pulldown-cmark build-markdown-it 14 | 15 | build-comrak-branch: 16 | cargo build --release 17 | cp ${ROOT}/target/release/comrak ${ROOT}/benches/comrak-${COMMIT} 18 | 19 | build-comrak-master: 20 | git clone https://github.com/kivikakk/comrak.git --depth 1 --single-branch ${ROOT}/vendor/comrak || true 21 | cd ${ROOT}/vendor/comrak && \ 22 | cargo build --release && \ 23 | cp ./target/release/comrak ${ROOT}/benches/comrak-main 24 | 25 | build-cmark-gfm: 26 | cd ${ROOT}/vendor/cmark-gfm && \ 27 | make && \ 28 | cp build/src/cmark-gfm ${ROOT}/benches/cmark-gfm 29 | 30 | build-markdown-it: 31 | cd ${ROOT}/vendor/markdown-it && \ 32 | cargo build --release && \ 33 | cp target/release/markdown-it ${ROOT}/benches/markdown-it 34 | 35 | build-pulldown-cmark: 36 | cd ${ROOT}/vendor/pulldown-cmark && \ 37 | cargo build --release && \ 38 | cp target/release/pulldown-cmark ${ROOT}/benches/pulldown-cmark 39 | 40 | bench-comrak: build-comrak-branch 41 | git clone https://github.com/progit/progit.git ${ROOT}/vendor/progit || true > /dev/null 42 | cd benches && \ 43 | hyperfine --warmup 3 --min-runs ${MIN_RUNS} -L binary comrak-${COMMIT} './bench.sh ./{binary}' 44 | 45 | bench-all: binaries 46 | git clone https://github.com/progit/progit.git ${ROOT}/vendor/progit || true > /dev/null 47 | cd benches && \ 48 | hyperfine --warmup 10 --min-runs ${MIN_RUNS} -L binary comrak-${COMMIT},comrak-main,pulldown-cmark,cmark-gfm,markdown-it './bench.sh ./{binary}' --export-markdown ${ROOT}/bench-output.md &&\ 49 | echo "\n\nRun on" `date -u` >> ${ROOT}/bench-output.md 50 | 51 | -------------------------------------------------------------------------------- /RELEASE_CHECKLIST.md: -------------------------------------------------------------------------------- 1 | * Bump version in `Cargo.toml`. 2 | * Did `tests::exercise_full_api` change? if so, it's a semver-breaking change. 3 | * Run https://github.com/kivikakk/comrak/actions/workflows/release.yml. 4 | * Inspect the created PR, make any changes, and merge when ready. 5 | * This will automatically create a new git tag, GitHub release, and publish 6 | to crates.io. 7 | -------------------------------------------------------------------------------- /benches/bench.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | 3 | PROG=$1 4 | ROOTDIR=$(git rev-parse --show-toplevel) 5 | 6 | for lang in ar az be ca cs de en eo es es-ni fa fi fr hi hu id it ja ko mk nl no-nb pl pt-br ro ru sr th tr uk vi zh zh-tw; do \ 7 | cat $ROOTDIR/vendor/progit/$lang/*/*.markdown | $PROG > /dev/null 8 | done -------------------------------------------------------------------------------- /benches/progit.rs: -------------------------------------------------------------------------------- 1 | #![feature(test)] 2 | 3 | extern crate test; 4 | 5 | use comrak::{format_html, parse_document, Arena, Options}; 6 | use test::Bencher; 7 | 8 | #[bench] 9 | fn bench_progit(b: &mut Bencher) { 10 | use std::fs::File; 11 | use std::io::Read; 12 | 13 | let mut file = File::open("script/progit.md").unwrap(); 14 | let mut s = String::with_capacity(524288); 15 | file.read_to_string(&mut s).unwrap(); 16 | b.iter(|| { 17 | let arena = Arena::new(); 18 | let root = parse_document(&arena, &s, &Options::default()); 19 | let mut output = vec![]; 20 | format_html(root, &Options::default(), &mut output).unwrap() 21 | }); 22 | } 23 | -------------------------------------------------------------------------------- /examples/custom_formatter.rs: -------------------------------------------------------------------------------- 1 | use comrak::html::ChildRendering; 2 | use comrak::{create_formatter, nodes::NodeValue}; 3 | use std::io::Write; 4 | 5 | create_formatter!(CustomFormatter, { 6 | NodeValue::Emph => |context, entering| { 7 | if entering { 8 | context.write_all(b"")?; 9 | } else { 10 | context.write_all(b"")?; 11 | } 12 | }, 13 | NodeValue::Strong => |context, entering| { 14 | context.write_all(if entering { b"" } else { b"" })?; 15 | }, 16 | NodeValue::Image(ref nl) => |context, node, entering| { 17 | assert!(node.data.borrow().sourcepos == (3, 1, 3, 18).into()); 18 | if entering { 19 | context.write_all(nl.url.to_uppercase().as_bytes())?; 20 | } 21 | return Ok(ChildRendering::Skip); 22 | }, 23 | }); 24 | 25 | fn main() { 26 | use comrak::{parse_document, Arena, Options}; 27 | 28 | let options = Options::default(); 29 | let arena = Arena::new(); 30 | let doc = parse_document( 31 | &arena, 32 | "_Hello_, **world**.\n\n![title](/img.png)", 33 | &options, 34 | ); 35 | 36 | let mut buf: Vec = vec![]; 37 | CustomFormatter::format_document(doc, &options, &mut buf).unwrap(); 38 | 39 | assert_eq!( 40 | std::str::from_utf8(&buf).unwrap(), 41 | "

Hello, world.

\n

/IMG.PNG

\n" 42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /examples/custom_formatter_alt_text.rs: -------------------------------------------------------------------------------- 1 | // Example provided by https://github.com/slonkazoid --- thank you! 2 | // (https://github.com/kivikakk/comrak/issues/557) 3 | // 4 | // Defaults image title text to alt text, if provided. 5 | 6 | use std::cell::RefCell; 7 | 8 | use comrak::arena_tree::Node; 9 | use comrak::nodes::{Ast, NodeLink, NodeValue}; 10 | use comrak::{parse_document, Arena}; 11 | 12 | fn autotitle_images<'a>( 13 | nl: &mut NodeLink, 14 | _context: &mut comrak::html::Context, 15 | node: &'a Node<'a, RefCell>, 16 | entering: bool, 17 | ) { 18 | if !entering || !nl.title.is_empty() { 19 | return; 20 | } 21 | 22 | let mut s = String::new(); 23 | 24 | for child in node.children() { 25 | if let Some(text) = child.data.borrow().value.text() { 26 | s += text; 27 | } 28 | } 29 | 30 | nl.title = s; 31 | } 32 | 33 | fn formatter<'a>( 34 | context: &mut comrak::html::Context, 35 | node: &'a comrak::nodes::AstNode<'a>, 36 | entering: bool, 37 | ) -> std::io::Result { 38 | let mut borrow = node.data.borrow_mut(); 39 | if let NodeValue::Image(ref mut nl) = borrow.value { 40 | autotitle_images(nl, context, node, entering); 41 | } 42 | drop(borrow); 43 | comrak::html::format_node_default(context, node, entering) 44 | } 45 | 46 | fn main() { 47 | let arena = Arena::new(); 48 | let parsed = parse_document(&arena, "![my epic image](/img.png)", &Default::default()); 49 | 50 | let mut out = Vec::new(); 51 | comrak::html::format_document_with_formatter( 52 | parsed, 53 | &Default::default(), 54 | &mut out, 55 | &Default::default(), 56 | formatter, 57 | (), 58 | ) 59 | .unwrap_or_else(|_| unreachable!("writing to Vec cannot fail")); 60 | 61 | // SAFETY: the formatter always emits valid UTF-8 62 | println!("{}", unsafe { String::from_utf8_unchecked(out) }); 63 | } 64 | -------------------------------------------------------------------------------- /examples/custom_formatter_user.rs: -------------------------------------------------------------------------------- 1 | use comrak::html::ChildRendering; 2 | use comrak::{create_formatter, nodes::NodeValue}; 3 | use std::io::Write; 4 | 5 | create_formatter!(CustomFormatter, { 6 | NodeValue::Emph => |context, entering| { 7 | context.user += 1; 8 | if entering { 9 | context.write_all(b"")?; 10 | } else { 11 | context.write_all(b"")?; 12 | } 13 | }, 14 | NodeValue::Strong => |context, entering| { 15 | context.user += 1; 16 | context.write_all(if entering { b"" } else { b"" })?; 17 | }, 18 | NodeValue::Image(ref nl) => |context, node, entering| { 19 | assert!(node.data.borrow().sourcepos == (3, 1, 3, 18).into()); 20 | if entering { 21 | context.write_all(nl.url.to_uppercase().as_bytes())?; 22 | } 23 | return Ok(ChildRendering::Skip); 24 | }, 25 | }); 26 | 27 | fn main() { 28 | use comrak::{parse_document, Arena, Options}; 29 | 30 | let options = Options::default(); 31 | let arena = Arena::new(); 32 | let doc = parse_document( 33 | &arena, 34 | "_Hello_, **world**.\n\n![title](/img.png)", 35 | &options, 36 | ); 37 | 38 | let mut buf: Vec = vec![]; 39 | let converted_count = CustomFormatter::format_document(doc, &options, &mut buf, 0).unwrap(); 40 | 41 | assert_eq!( 42 | std::str::from_utf8(&buf).unwrap(), 43 | "

Hello, world.

\n

/IMG.PNG

\n" 44 | ); 45 | 46 | assert_eq!(converted_count, 4); 47 | } 48 | -------------------------------------------------------------------------------- /examples/custom_headings.rs: -------------------------------------------------------------------------------- 1 | use comrak::{ 2 | adapters::{HeadingAdapter, HeadingMeta}, 3 | markdown_to_html_with_plugins, 4 | nodes::Sourcepos, 5 | Options, Plugins, 6 | }; 7 | use std::io::{self, Write}; 8 | 9 | fn main() { 10 | let adapter = CustomHeadingAdapter; 11 | let mut options = Options::default(); 12 | let mut plugins = Plugins::default(); 13 | plugins.render.heading_adapter = Some(&adapter); 14 | 15 | print_html( 16 | "Some text.\n\n## Please hide me from search\n\nSome other text", 17 | &options, 18 | &plugins, 19 | ); 20 | print_html( 21 | "Some text.\n\n### Here is some `code`\n\nSome other text", 22 | &options, 23 | &plugins, 24 | ); 25 | print_html( 26 | "Some text.\n\n### Here is some **bold** text and some *italicized* text\n\nSome other text", 27 | &options, 28 | &plugins 29 | ); 30 | options.render.sourcepos = true; 31 | print_html("# Here is a [link](/)", &options, &plugins); 32 | } 33 | 34 | struct CustomHeadingAdapter; 35 | 36 | impl HeadingAdapter for CustomHeadingAdapter { 37 | fn enter( 38 | &self, 39 | output: &mut dyn Write, 40 | heading: &HeadingMeta, 41 | sourcepos: Option, 42 | ) -> io::Result<()> { 43 | let id = slug::slugify(&heading.content); 44 | 45 | let search_include = !&heading.content.contains("hide"); 46 | 47 | write!(output, "", 56 | id, search_include 57 | ) 58 | } 59 | 60 | fn exit(&self, output: &mut dyn Write, heading: &HeadingMeta) -> io::Result<()> { 61 | write!(output, "", heading.level) 62 | } 63 | } 64 | 65 | fn print_html(document: &str, options: &Options, plugins: &Plugins) { 66 | let html = markdown_to_html_with_plugins(document, options, plugins); 67 | println!("{}", html); 68 | } 69 | -------------------------------------------------------------------------------- /examples/headers.rs: -------------------------------------------------------------------------------- 1 | // Extract the document title by srching for a level-one header at the root level. 2 | 3 | use comrak::{ 4 | nodes::{AstNode, NodeCode, NodeValue}, 5 | parse_document, Arena, Options, 6 | }; 7 | 8 | fn main() { 9 | println!("{:?}", get_document_title("# Hello\n")); 10 | println!("{:?}", get_document_title("## Hello\n")); 11 | println!("{:?}", get_document_title("# `hi` **there**\n")); 12 | } 13 | 14 | fn get_document_title(document: &str) -> String { 15 | let arena = Arena::new(); 16 | let root = parse_document(&arena, document, &Options::default()); 17 | 18 | for node in root.children() { 19 | let header = match node.data.clone().into_inner().value { 20 | NodeValue::Heading(c) => c, 21 | _ => continue, 22 | }; 23 | 24 | if header.level != 1 { 25 | continue; 26 | } 27 | 28 | let mut text = String::new(); 29 | collect_text(node, &mut text); 30 | 31 | // The input was already known good UTF-8 (document: &str) so comrak 32 | // guarantees the output will be too. 33 | return text; 34 | } 35 | 36 | "Untitled Document".to_string() 37 | } 38 | 39 | fn collect_text<'a>(node: &'a AstNode<'a>, output: &mut String) { 40 | match node.data.borrow().value { 41 | NodeValue::Text(ref literal) | NodeValue::Code(NodeCode { ref literal, .. }) => { 42 | output.push_str(literal) 43 | } 44 | NodeValue::LineBreak | NodeValue::SoftBreak => output.push(' '), 45 | _ => { 46 | for n in node.children() { 47 | collect_text(n, output); 48 | } 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /examples/iterator_replace.rs: -------------------------------------------------------------------------------- 1 | extern crate comrak; 2 | use comrak::nodes::NodeValue; 3 | use comrak::{format_html, parse_document, Arena, Options}; 4 | 5 | fn replace_text(document: &str, orig_string: &str, replacement: &str) -> String { 6 | // The returned nodes are created in the supplied Arena, and are bound by its lifetime. 7 | let arena = Arena::new(); 8 | 9 | // Parse the document into a root `AstNode` 10 | let root = parse_document(&arena, document, &Options::default()); 11 | 12 | // Iterate over all the descendants of root. 13 | for node in root.descendants() { 14 | if let NodeValue::Text(ref mut text) = node.data.borrow_mut().value { 15 | // If the node is a text node, replace `orig_string` with `replacement`. 16 | *text = text.replace(orig_string, replacement) 17 | } 18 | } 19 | 20 | let mut html = vec![]; 21 | format_html(root, &Options::default(), &mut html).unwrap(); 22 | 23 | String::from_utf8(html).unwrap() 24 | } 25 | 26 | fn main() { 27 | let doc = "This is my input.\n\n1. Also [my](#) input.\n2. Certainly *my* input.\n"; 28 | let orig = "my"; 29 | let repl = "your"; 30 | let html = replace_text(doc, orig, repl); 31 | 32 | println!("{}", html); 33 | } 34 | 35 | #[cfg(test)] 36 | mod tests { 37 | use super::*; 38 | use ntest::{assert_false, assert_true}; 39 | 40 | #[test] 41 | fn sample_replace() { 42 | let doc = "Replace deeply nested *[foo](https://example.com)* with bar.\n\nReplace shallow foo with bar."; 43 | let orig = "foo"; 44 | let repl = "bar"; 45 | let html = replace_text(&doc, &orig, &repl); 46 | println!("{:?}", html); 47 | assert_false!(html.contains("foo")); 48 | assert_true!(html.contains("bar")); 49 | assert_true!(html.contains("( 24 | node: &'a AstNode<'a>, 25 | writer: &mut W, 26 | indent: usize, 27 | ) -> io::Result<()> { 28 | use NodeValue::*; 29 | 30 | macro_rules! try_node_inline { 31 | ($node:expr, $name:ident) => {{ 32 | if let $name(t) = $node { 33 | return write!(writer, concat!(stringify!($name), "({:?})"), t,); 34 | } 35 | }}; 36 | } 37 | 38 | match &node.data.borrow().value { 39 | Text(t) => write!(writer, "{:?}", t)?, 40 | value => { 41 | try_node_inline!(value, FootnoteDefinition); 42 | try_node_inline!(value, FootnoteReference); 43 | try_node_inline!(value, HtmlInline); 44 | 45 | if let Code(code) = value { 46 | return write!(writer, "Code({:?}, {})", code.literal, code.num_backticks); 47 | } 48 | 49 | let has_blocks = node.children().any(|c| c.data.borrow().value.block()); 50 | 51 | write!(writer, "({:?}", value)?; 52 | for child in node.children() { 53 | if has_blocks { 54 | write!(writer, "\n{1:0$}", indent + INDENT, " ")?; 55 | } else { 56 | write!(writer, " ")?; 57 | } 58 | iter_nodes(child, writer, indent + INDENT)?; 59 | } 60 | 61 | if indent == 0 { 62 | write!(writer, "\n)\n")?; 63 | } else if CLOSE_NEWLINE && has_blocks { 64 | write!(writer, "\n{1:0$})", indent, " ")?; 65 | } else { 66 | write!(writer, ")")?; 67 | } 68 | } 69 | } 70 | 71 | Ok(()) 72 | } 73 | 74 | fn dump(source: &str) -> io::Result<()> { 75 | let arena = Arena::new(); 76 | 77 | let extension = ExtensionOptions::builder() 78 | .strikethrough(true) 79 | .tagfilter(true) 80 | .table(true) 81 | .autolink(true) 82 | .tasklist(true) 83 | .superscript(true) 84 | .footnotes(true) 85 | .description_lists(true) 86 | .multiline_block_quotes(true) 87 | .math_dollars(true) 88 | .math_code(true) 89 | .wikilinks_title_after_pipe(true) 90 | .wikilinks_title_before_pipe(true) 91 | .build(); 92 | 93 | let opts = Options { 94 | extension, 95 | ..Options::default() 96 | }; 97 | 98 | let doc = parse_document(&arena, source, &opts); 99 | 100 | let mut output = BufWriter::new(io::stdout()); 101 | iter_nodes(doc, &mut output, 0) 102 | } 103 | 104 | fn main() -> Result<(), Box> { 105 | let mut args = env::args_os().skip(1).peekable(); 106 | let mut body = String::new(); 107 | 108 | if args.peek().is_none() { 109 | io::stdin().read_to_string(&mut body)?; 110 | dump(&body)?; 111 | } 112 | 113 | for filename in args { 114 | println!("{:?}", filename); 115 | 116 | body.clear(); 117 | File::open(&filename)?.read_to_string(&mut body)?; 118 | dump(&body)?; 119 | } 120 | 121 | Ok(()) 122 | } 123 | -------------------------------------------------------------------------------- /examples/sample.rs: -------------------------------------------------------------------------------- 1 | // Samples used in the README. Wanna make sure they work as advertised. 2 | 3 | fn small() { 4 | use comrak::{markdown_to_html, Options}; 5 | 6 | assert_eq!( 7 | markdown_to_html("Hello, **世界**!", &Options::default()), 8 | "

Hello, 世界!

\n" 9 | ); 10 | } 11 | 12 | fn large() { 13 | use comrak::nodes::NodeValue; 14 | use comrak::{format_html, parse_document, Arena, Options}; 15 | 16 | fn replace_text(document: &str, orig_string: &str, replacement: &str) -> String { 17 | // The returned nodes are created in the supplied Arena, and are bound by its lifetime. 18 | let arena = Arena::new(); 19 | 20 | // Parse the document into a root `AstNode` 21 | let root = parse_document(&arena, document, &Options::default()); 22 | 23 | // Iterate over all the descendants of root. 24 | for node in root.descendants() { 25 | if let NodeValue::Text(ref mut text) = node.data.borrow_mut().value { 26 | // If the node is a text node, perform the string replacement. 27 | *text = text.replace(orig_string, replacement) 28 | } 29 | } 30 | 31 | let mut html = vec![]; 32 | format_html(root, &Options::default(), &mut html).unwrap(); 33 | 34 | String::from_utf8(html).unwrap() 35 | } 36 | 37 | fn main() { 38 | let doc = "This is my input.\n\n1. Also [my](#) input.\n2. Certainly *my* input.\n"; 39 | let orig = "my"; 40 | let repl = "your"; 41 | let html = replace_text(doc, orig, repl); 42 | 43 | println!("{}", html); 44 | // Output: 45 | // 46 | //

This is your input.

47 | //
    48 | //
  1. Also your input.
  2. 49 | //
  3. Certainly your input.
  4. 50 | //
51 | } 52 | 53 | main() 54 | } 55 | 56 | fn main() { 57 | small(); 58 | large(); 59 | } 60 | -------------------------------------------------------------------------------- /examples/syntax_highlighter.rs: -------------------------------------------------------------------------------- 1 | //! This example shows how to implement a syntax highlighter plugin. 2 | 3 | use comrak::adapters::SyntaxHighlighterAdapter; 4 | use comrak::{markdown_to_html_with_plugins, Options, Plugins}; 5 | use std::collections::HashMap; 6 | use std::io::{self, Write}; 7 | 8 | #[derive(Debug, Copy, Clone)] 9 | pub struct PotatoSyntaxAdapter { 10 | potato_size: i32, 11 | } 12 | 13 | impl PotatoSyntaxAdapter { 14 | pub fn new(potato_size: i32) -> Self { 15 | PotatoSyntaxAdapter { potato_size } 16 | } 17 | } 18 | 19 | impl SyntaxHighlighterAdapter for PotatoSyntaxAdapter { 20 | fn write_highlighted( 21 | &self, 22 | output: &mut dyn Write, 23 | lang: Option<&str>, 24 | code: &str, 25 | ) -> io::Result<()> { 26 | write!( 27 | output, 28 | "{}potato", 29 | lang.unwrap(), 30 | code, 31 | self.potato_size 32 | ) 33 | } 34 | 35 | fn write_pre_tag( 36 | &self, 37 | output: &mut dyn Write, 38 | attributes: HashMap, 39 | ) -> io::Result<()> { 40 | if attributes.contains_key("lang") { 41 | write!(output, "
", attributes["lang"])
42 |         } else {
43 |             output.write_all(b"
")
44 |         }
45 |     }
46 | 
47 |     fn write_code_tag(
48 |         &self,
49 |         output: &mut dyn Write,
50 |         attributes: HashMap,
51 |     ) -> io::Result<()> {
52 |         if attributes.contains_key("class") {
53 |             write!(output, "", attributes["class"])
54 |         } else {
55 |             output.write_all(b"")
56 |         }
57 |     }
58 | }
59 | 
60 | fn main() {
61 |     let adapter = PotatoSyntaxAdapter::new(42);
62 |     let options = Options::default();
63 |     let mut plugins = Plugins::default();
64 | 
65 |     plugins.render.codefence_syntax_highlighter = Some(&adapter);
66 | 
67 |     let input = concat!("```Rust\n", "fn main<'a>();\n", "```");
68 | 
69 |     let formatted = markdown_to_html_with_plugins(input, &options, &plugins);
70 | 
71 |     println!("{}", formatted);
72 | }
73 | 


--------------------------------------------------------------------------------
/examples/syntect.rs:
--------------------------------------------------------------------------------
 1 | //! This example shows how to use the bundled syntect plugin.
 2 | 
 3 | use comrak::plugins::syntect::SyntectAdapterBuilder;
 4 | use comrak::{markdown_to_html_with_plugins, Options, Plugins};
 5 | 
 6 | fn main() {
 7 |     run_with(SyntectAdapterBuilder::new().theme("base16-ocean.dark"));
 8 |     run_with(SyntectAdapterBuilder::new().css());
 9 | }
10 | 
11 | fn run_with(builder: SyntectAdapterBuilder) {
12 |     let adapter = builder.build();
13 |     let options = Options::default();
14 |     let mut plugins = Plugins::default();
15 | 
16 |     plugins.render.codefence_syntax_highlighter = Some(&adapter);
17 | 
18 |     let input = concat!("```Rust\n", "fn main<'a>();\n", "```");
19 | 
20 |     let formatted = markdown_to_html_with_plugins(input, &options, &plugins);
21 | 
22 |     println!("{}", formatted);
23 | }
24 | 


--------------------------------------------------------------------------------
/examples/traverse_demo.rs:
--------------------------------------------------------------------------------
 1 | use comrak::{
 2 |     arena_tree::NodeEdge,
 3 |     nodes::{AstNode, NodeValue},
 4 |     parse_document, Arena, ComrakOptions,
 5 | };
 6 | 
 7 | // `node.traverse()`` creates an itertor that will traverse
 8 | // the current node and all descendants in order.
 9 | // The iterator yields `NodeEdges`. `NodeEdges` can have the
10 | // following values:
11 | //
12 | // `NodeEdge::Start(node)` Start of node.
13 | // `NodeEdge::End(node)` End of node.
14 | // `None` End of iterator at bottom of last branch.
15 | //
16 | // This example extracts plain text ignoring nested
17 | // markup.
18 | 
19 | // Note: root can be any AstNode, not just document root.
20 | 
21 | fn extract_text_traverse<'a>(root: &'a AstNode<'a>) -> String {
22 |     let mut output_text = String::new();
23 | 
24 |     // Use `traverse` to get an iterator of `NodeEdge` and process each.
25 |     for edge in root.traverse() {
26 |         if let NodeEdge::Start(node) = edge {
27 |             // Handle the Start edge to process the node's value.
28 |             if let NodeValue::Text(ref text) = node.data.borrow().value {
29 |                 // If the node is a text node, append its text to `output_text`.
30 |                 output_text.push_str(text);
31 |             }
32 |         }
33 |     }
34 | 
35 |     output_text
36 | }
37 | 
38 | fn main() {
39 |     let markdown_input = "Hello, *worl[d](https://example.com/)*";
40 |     // Nested inline markup. Equivalent html should look like this:
41 |     //"

Hello, world

42 | 43 | println!("INPUT: {}", markdown_input); 44 | 45 | // setup parser 46 | let arena = Arena::new(); 47 | let options = ComrakOptions::default(); 48 | 49 | // parse document and return root. 50 | let root = parse_document(&arena, markdown_input, &options); 51 | 52 | // extract text and print 53 | println!("OUTPUT: {}", extract_text_traverse(root).as_str()) 54 | } 55 | 56 | #[cfg(test)] 57 | mod tests { 58 | // Import everything from the outer module to make it available for tests 59 | use super::*; 60 | 61 | #[test] 62 | fn extract_text_traverse_test() { 63 | let markdown_input = "Hello, *worl[d](https://example.com/)*"; 64 | let arena = Arena::new(); 65 | let options = ComrakOptions::default(); 66 | let root = parse_document(&arena, markdown_input, &options); 67 | assert_eq!("Hello, world", extract_text_traverse(root)); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /examples/update-readme.rs: -------------------------------------------------------------------------------- 1 | // Update the "comrak --help" text in Comrak's own README. 2 | 3 | use std::error::Error; 4 | use std::fmt::Write; 5 | use std::str; 6 | use toml::Table; 7 | 8 | use comrak::nodes::NodeValue; 9 | use comrak::{format_commonmark, parse_document, Arena, Options}; 10 | 11 | const DEPENDENCIES: &str = "[dependencies]\ncomrak = "; 12 | const HELP: &str = "$ comrak --help\n"; 13 | const HELP_START: &str = 14 | "A 100% CommonMark-compatible GitHub Flavored Markdown parser and formatter\n"; 15 | 16 | fn main() -> Result<(), Box> { 17 | let arena = Arena::new(); 18 | 19 | let readme = std::fs::read_to_string("README.md")?; 20 | let doc = parse_document(&arena, &readme, &Options::default()); 21 | 22 | let cargo_toml = std::fs::read_to_string("Cargo.toml")?.parse::()?; 23 | let msrv = cargo_toml["package"].as_table().unwrap()["rust-version"] 24 | .as_str() 25 | .unwrap(); 26 | 27 | let mut in_msrv = false; 28 | let mut next_block_is_help_body = false; 29 | 30 | for node in doc.descendants() { 31 | match node.data.borrow_mut().value { 32 | NodeValue::CodeBlock(ref mut ncb) => { 33 | // Look for the Cargo.toml example block. 34 | if ncb.info == "toml" && ncb.literal.starts_with(DEPENDENCIES) { 35 | let mut content = DEPENDENCIES.to_string(); 36 | let mut version_parts = comrak::version().split('.').collect::>(); 37 | version_parts.pop(); 38 | write!(content, "\"{}\"", version_parts.join(".")).unwrap(); 39 | ncb.literal = content; 40 | continue; 41 | } 42 | 43 | // Look for a console code block whose contents starts with the HELP string. 44 | // The *next* code block contains our help, minus the starting string. 45 | if ncb.info == "console" && ncb.literal.starts_with(HELP) { 46 | next_block_is_help_body = true; 47 | continue; 48 | } 49 | 50 | if next_block_is_help_body { 51 | next_block_is_help_body = false; 52 | assert!(ncb.info.is_empty() && ncb.literal.starts_with(HELP_START)); 53 | let mut content = String::new(); 54 | let mut cmd = std::process::Command::new("cargo"); 55 | content.push_str( 56 | str::from_utf8( 57 | &cmd.args(["run", "--all-features", "--", "--help"]) 58 | .output() 59 | .unwrap() 60 | .stdout, 61 | ) 62 | .unwrap(), 63 | ); 64 | ncb.literal = content; 65 | continue; 66 | } 67 | } 68 | NodeValue::HtmlInline(ref mut s) => { 69 | if s == "" { 70 | in_msrv = true; 71 | } else if in_msrv && s == "" { 72 | in_msrv = false; 73 | } 74 | } 75 | NodeValue::Text(ref mut t) => { 76 | if in_msrv { 77 | std::mem::swap(t, &mut msrv.to_string()); 78 | } 79 | } 80 | _ => {} 81 | } 82 | } 83 | 84 | let mut options = Options::default(); 85 | options.render.prefer_fenced = true; 86 | options.render.experimental_minimize_commonmark = true; 87 | 88 | let mut out = vec![]; 89 | format_commonmark(doc, &options, &mut out)?; 90 | std::fs::write("README.md", &out)?; 91 | 92 | Ok(()) 93 | } 94 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "advisory-db": { 4 | "flake": false, 5 | "locked": { 6 | "lastModified": 1748619192, 7 | "narHash": "sha256-qgUH+uEV90y/uPlLTGS9JQRbmq6hUepKgbzmgHorIRE=", 8 | "owner": "rustsec", 9 | "repo": "advisory-db", 10 | "rev": "7727c950e41f37f03297583c21f800546318d7f1", 11 | "type": "github" 12 | }, 13 | "original": { 14 | "owner": "rustsec", 15 | "repo": "advisory-db", 16 | "type": "github" 17 | } 18 | }, 19 | "crane": { 20 | "locked": { 21 | "lastModified": 1748047550, 22 | "narHash": "sha256-t0qLLqb4C1rdtiY8IFRH5KIapTY/n3Lqt57AmxEv9mk=", 23 | "owner": "ipetkov", 24 | "repo": "crane", 25 | "rev": "b718a78696060df6280196a6f992d04c87a16aef", 26 | "type": "github" 27 | }, 28 | "original": { 29 | "owner": "ipetkov", 30 | "repo": "crane", 31 | "type": "github" 32 | } 33 | }, 34 | "flake-utils": { 35 | "inputs": { 36 | "systems": "systems" 37 | }, 38 | "locked": { 39 | "lastModified": 1731533236, 40 | "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", 41 | "owner": "numtide", 42 | "repo": "flake-utils", 43 | "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", 44 | "type": "github" 45 | }, 46 | "original": { 47 | "owner": "numtide", 48 | "repo": "flake-utils", 49 | "type": "github" 50 | } 51 | }, 52 | "nixpkgs": { 53 | "locked": { 54 | "lastModified": 1748437600, 55 | "narHash": "sha256-hYKMs3ilp09anGO7xzfGs3JqEgUqFMnZ8GMAqI6/k04=", 56 | "owner": "NixOS", 57 | "repo": "nixpkgs", 58 | "rev": "7282cb574e0607e65224d33be8241eae7cfe0979", 59 | "type": "github" 60 | }, 61 | "original": { 62 | "owner": "NixOS", 63 | "ref": "nixos-25.05", 64 | "repo": "nixpkgs", 65 | "type": "github" 66 | } 67 | }, 68 | "root": { 69 | "inputs": { 70 | "advisory-db": "advisory-db", 71 | "crane": "crane", 72 | "flake-utils": "flake-utils", 73 | "nixpkgs": "nixpkgs" 74 | } 75 | }, 76 | "systems": { 77 | "locked": { 78 | "lastModified": 1681028828, 79 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 80 | "owner": "nix-systems", 81 | "repo": "default", 82 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 83 | "type": "github" 84 | }, 85 | "original": { 86 | "owner": "nix-systems", 87 | "repo": "default", 88 | "type": "github" 89 | } 90 | } 91 | }, 92 | "root": "root", 93 | "version": 7 94 | } 95 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "comrak"; 3 | 4 | inputs = { 5 | nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05"; 6 | crane.url = "github:ipetkov/crane"; 7 | flake-utils.url = "github:numtide/flake-utils"; 8 | advisory-db = { 9 | url = "github:rustsec/advisory-db"; 10 | flake = false; 11 | }; 12 | }; 13 | 14 | outputs = 15 | { 16 | self, 17 | nixpkgs, 18 | crane, 19 | flake-utils, 20 | advisory-db, 21 | ... 22 | }: 23 | flake-utils.lib.eachDefaultSystem ( 24 | system: 25 | let 26 | pkgs = import nixpkgs { inherit system; }; 27 | 28 | inherit (pkgs) lib; 29 | 30 | craneLib = crane.mkLib pkgs; 31 | src = craneLib.cleanCargoSource (craneLib.path ./.); 32 | 33 | commonArgs = { 34 | inherit src; 35 | 36 | buildInputs = lib.optionals pkgs.stdenv.isDarwin [ pkgs.libiconv ]; 37 | }; 38 | 39 | cargoArtifacts = craneLib.buildDepsOnly commonArgs; 40 | 41 | comrak = craneLib.buildPackage ( 42 | commonArgs 43 | // { 44 | inherit cargoArtifacts; 45 | 46 | doCheck = false; 47 | } 48 | ); 49 | in 50 | { 51 | checks = { 52 | inherit comrak; 53 | 54 | comrak-clippy = craneLib.cargoClippy ( 55 | commonArgs 56 | // { 57 | inherit cargoArtifacts; 58 | # cargoClippyExtraArgs = "--lib --bins --examples --tests -- --deny warnings"; 59 | # XXX Not sure if we can fix all these and retain our current MSRV. 60 | cargoClippyExtraArgs = "--lib --bins --examples --tests"; 61 | } 62 | ); 63 | 64 | comrak-doc = craneLib.cargoDoc (commonArgs // { inherit cargoArtifacts; }); 65 | 66 | comrak-fmt = craneLib.cargoFmt { inherit src; }; 67 | 68 | comrak-nextest = craneLib.cargoNextest ( 69 | commonArgs 70 | // { 71 | inherit cargoArtifacts; 72 | partitions = 1; 73 | partitionType = "count"; 74 | } 75 | ); 76 | }; 77 | 78 | packages = { 79 | default = comrak; 80 | }; 81 | 82 | apps.default = flake-utils.lib.mkApp { drv = comrak; }; 83 | 84 | formatter = pkgs.nixfmt-rfc-style; 85 | 86 | devShells.default = craneLib.devShell { 87 | name = "comrak"; 88 | 89 | inputsFrom = builtins.attrValues self.checks.${system}; 90 | 91 | packages = [ 92 | pkgs.rust-analyzer 93 | pkgs.clippy 94 | pkgs.cargo-fuzz 95 | pkgs.python3 96 | ]; 97 | }; 98 | } 99 | ); 100 | } 101 | -------------------------------------------------------------------------------- /fuzz/.gitignore: -------------------------------------------------------------------------------- 1 | target 2 | corpus 3 | artifacts 4 | coverage 5 | -------------------------------------------------------------------------------- /fuzz/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "comrak-fuzz" 3 | version = "0.0.0" 4 | publish = false 5 | edition = "2018" 6 | 7 | [package.metadata] 8 | cargo-fuzz = true 9 | 10 | [dependencies] 11 | libfuzzer-sys = "0.4" 12 | arbitrary = { version = "1", features = ["derive"] } 13 | comrak = { path = "..", features = ["shortcodes", "arbitrary"] } 14 | 15 | # Prevent this from interfering with workspaces 16 | [workspace] 17 | members = ["."] 18 | 19 | [profile.release] 20 | debug = 1 21 | 22 | [[bin]] 23 | name = "all_options" 24 | path = "fuzz_targets/all_options.rs" 25 | test = false 26 | doc = false 27 | 28 | [[bin]] 29 | name = "fuzz_options" 30 | path = "fuzz_targets/fuzz_options.rs" 31 | test = false 32 | doc = false 33 | 34 | [[bin]] 35 | name = "cli_default" 36 | path = "fuzz_targets/cli_default.rs" 37 | test = false 38 | doc = false 39 | 40 | [[bin]] 41 | name = "gfm" 42 | path = "fuzz_targets/gfm.rs" 43 | test = false 44 | doc = false 45 | 46 | [[bin]] 47 | name = "quadratic" 48 | path = "fuzz_targets/quadratic.rs" 49 | test = false 50 | doc = false 51 | 52 | [[bin]] 53 | name = "gfm_sourcepos" 54 | path = "fuzz_targets/gfm_sourcepos.rs" 55 | test = false 56 | doc = false 57 | 58 | [[bin]] 59 | name = "gfm_footnotes" 60 | path = "fuzz_targets/gfm_footnotes.rs" 61 | test = false 62 | doc = false 63 | -------------------------------------------------------------------------------- /fuzz/fuzz_targets/all_options.rs: -------------------------------------------------------------------------------- 1 | #![no_main] 2 | 3 | use libfuzzer_sys::fuzz_target; 4 | 5 | use comrak::{ 6 | markdown_to_html, BrokenLinkReference, ExtensionOptions, ListStyleType, Options, ParseOptions, 7 | RenderOptions, ResolvedReference, 8 | }; 9 | use std::sync::Arc; 10 | 11 | fuzz_target!(|s: &str| { 12 | let mut extension = ExtensionOptions::default(); 13 | extension.strikethrough = true; 14 | extension.tagfilter = true; 15 | extension.table = true; 16 | extension.autolink = true; 17 | extension.tasklist = true; 18 | extension.superscript = true; 19 | extension.header_ids = Some("user-content-".to_string()); 20 | extension.footnotes = true; 21 | extension.description_lists = true; 22 | extension.front_matter_delimiter = Some("---".to_string()); 23 | extension.multiline_block_quotes = true; 24 | extension.math_dollars = true; 25 | extension.math_code = true; 26 | extension.shortcodes = true; 27 | extension.wikilinks_title_after_pipe = true; 28 | extension.wikilinks_title_before_pipe = true; 29 | extension.underline = true; 30 | extension.spoiler = true; 31 | extension.greentext = true; 32 | extension.alerts = true; 33 | 34 | let mut parse = ParseOptions::default(); 35 | parse.smart = true; 36 | parse.default_info_string = Some("rust".to_string()); 37 | parse.relaxed_tasklist_matching = true; 38 | parse.relaxed_autolinks = true; 39 | let cb = |link_ref: BrokenLinkReference| { 40 | Some(ResolvedReference { 41 | url: link_ref.normalized.to_string(), 42 | title: link_ref.original.to_string(), 43 | }) 44 | }; 45 | parse.broken_link_callback = Some(Arc::new(cb)); 46 | 47 | let mut render = RenderOptions::default(); 48 | render.hardbreaks = true; 49 | render.github_pre_lang = true; 50 | render.full_info_string = true; 51 | render.width = 80; 52 | render.unsafe_ = true; 53 | render.escape = true; 54 | render.list_style = ListStyleType::Star; 55 | render.sourcepos = true; 56 | render.escaped_char_spans = true; 57 | render.ignore_setext = true; 58 | render.ignore_empty_links = true; 59 | render.gfm_quirks = true; 60 | render.prefer_fenced = true; 61 | render.tasklist_classes = true; 62 | 63 | markdown_to_html( 64 | s, 65 | &Options { 66 | extension, 67 | parse, 68 | render, 69 | }, 70 | ); 71 | }); 72 | -------------------------------------------------------------------------------- /fuzz/fuzz_targets/cli_default.rs: -------------------------------------------------------------------------------- 1 | #![no_main] 2 | 3 | use libfuzzer_sys::fuzz_target; 4 | 5 | use comrak::{markdown_to_html_with_plugins, plugins::syntect::SyntectAdapter, Plugins}; 6 | 7 | // Note that we end up fuzzing Syntect here. 8 | 9 | fuzz_target!(|s: &str| { 10 | let adapter = SyntectAdapter::new("base16-ocean.dark"); 11 | 12 | let mut plugins = Plugins::default(); 13 | plugins.render.codefence_syntax_highlighter = Some(&adapter); 14 | 15 | markdown_to_html_with_plugins(s, &Default::default(), &plugins); 16 | }); 17 | -------------------------------------------------------------------------------- /fuzz/fuzz_targets/fuzz_options.rs: -------------------------------------------------------------------------------- 1 | #![no_main] 2 | 3 | use libfuzzer_sys::fuzz_target; 4 | 5 | use comrak::{markdown_to_html, Options}; 6 | 7 | #[derive(Debug, arbitrary::Arbitrary)] 8 | struct FuzzInput<'s> { 9 | s: &'s str, 10 | opts: Options, 11 | } 12 | 13 | fuzz_target!(|i: FuzzInput| { 14 | markdown_to_html(i.s, &i.opts); 15 | }); 16 | -------------------------------------------------------------------------------- /fuzz/fuzz_targets/gfm.rs: -------------------------------------------------------------------------------- 1 | #![no_main] 2 | 3 | use libfuzzer_sys::fuzz_target; 4 | 5 | use comrak::{markdown_to_html, ExtensionOptions, Options, RenderOptions}; 6 | 7 | // Note that what I'm targetting here isn't exactly the same 8 | // as --gfm, but rather an approximation of what cmark-gfm 9 | // options are routinely used by Commonmarker users. 10 | 11 | fuzz_target!(|s: &str| { 12 | let mut extension = ExtensionOptions::default(); 13 | extension.strikethrough = true; 14 | extension.tagfilter = true; 15 | extension.table = true; 16 | extension.autolink = true; 17 | 18 | let mut render = RenderOptions::default(); 19 | render.hardbreaks = true; 20 | render.github_pre_lang = true; 21 | render.unsafe_ = true; 22 | 23 | markdown_to_html( 24 | s, 25 | &Options { 26 | extension, 27 | parse: Default::default(), 28 | render, 29 | }, 30 | ); 31 | }); 32 | -------------------------------------------------------------------------------- /fuzz/fuzz_targets/gfm_footnotes.rs: -------------------------------------------------------------------------------- 1 | #![no_main] 2 | 3 | use libfuzzer_sys::fuzz_target; 4 | 5 | use comrak::{markdown_to_html, ExtensionOptions, Options, RenderOptions}; 6 | 7 | // Note that what I'm targetting here isn't exactly the same 8 | // as --gfm, but rather an approximation of what cmark-gfm 9 | // options are routinely used by Commonmarker users. 10 | 11 | fuzz_target!(|s: &str| { 12 | let mut extension = ExtensionOptions::default(); 13 | extension.strikethrough = true; 14 | extension.tagfilter = true; 15 | extension.table = true; 16 | extension.autolink = true; 17 | extension.footnotes = true; 18 | 19 | let mut render = RenderOptions::default(); 20 | render.hardbreaks = true; 21 | render.github_pre_lang = true; 22 | render.unsafe_ = true; 23 | 24 | markdown_to_html( 25 | s, 26 | &Options { 27 | extension, 28 | parse: Default::default(), 29 | render, 30 | }, 31 | ); 32 | }); 33 | -------------------------------------------------------------------------------- /fuzz/fuzz_targets/gfm_sourcepos.rs: -------------------------------------------------------------------------------- 1 | #![no_main] 2 | 3 | use libfuzzer_sys::fuzz_target; 4 | 5 | use comrak::{markdown_to_html, ExtensionOptions, Options, RenderOptions}; 6 | 7 | // Note that what I'm targetting here isn't exactly the same 8 | // as --gfm, but rather an approximation of what cmark-gfm 9 | // options are routinely used by Commonmarker users. 10 | 11 | fuzz_target!(|s: &str| { 12 | let mut extension = ExtensionOptions::default(); 13 | extension.strikethrough = true; 14 | extension.tagfilter = true; 15 | extension.table = true; 16 | extension.autolink = true; 17 | 18 | let mut render = RenderOptions::default(); 19 | render.hardbreaks = true; 20 | render.github_pre_lang = true; 21 | render.unsafe_ = true; 22 | render.sourcepos = true; 23 | 24 | markdown_to_html( 25 | s, 26 | &Options { 27 | extension, 28 | parse: Default::default(), 29 | render, 30 | }, 31 | ); 32 | }); 33 | -------------------------------------------------------------------------------- /hooks/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | exec 1>&2 4 | set -e 5 | 6 | if [ "$RUN_PRECOMMIT_HOOK" != 1 ]; then 7 | exit 0 8 | fi 9 | 10 | echo "roll me into examples/update-readme.rs" 11 | exit 1 12 | 13 | if [ "$(git status --porcelain README.md | cut -b2-2 | tr -d '[:space:]')" != "" ]; then 14 | echo "README.md has unstaged changes, commit hook cannot run" 15 | exit 1 16 | fi 17 | 18 | 19 | CARGO_TARGET_DIR=/tmp/comrak_test cargo build 20 | (cd vendor/cmark-gfm/test; RUST_BACKTRACE=1 python3 spec_tests.py --program=/tmp/comrak_test/debug/comrak || true) > spec_out.txt 21 | r=$(tail -n 1 spec_out.txt | sed -e 's/ passed, /,/' -e 's/ failed, /,/' -e 's/ errored, /,/' -e 's/ skipped//') 22 | passed=$(echo "$r" | cut -f1 -d,) 23 | failed=$(echo "$r" | cut -f2 -d,) 24 | errored=$(echo "$r" | cut -f3 -d,) 25 | skipped=$(echo "$r" | cut -f4 -d,) 26 | total=$(($passed + $failed + $errored)) 27 | if [ "$errored" -gt 0 ]; then 28 | color=red 29 | elif [ "$failed" -gt 0 ]; then 30 | color=yellow 31 | elif [ "$skipped" -gt 0 ]; then 32 | color=blue 33 | else 34 | color=brightgreen 35 | fi 36 | 37 | sed -i '' \ 38 | -e '2s@\[.*\]@[Spec Status: '"$passed"/"$total"']@' \ 39 | -e '2s@(.*)@('https://img.shields.io/badge/specs-"$passed"%2F"$total"-"$color".svg')@' \ 40 | README.md 41 | 42 | git diff README.md 43 | git diff spec_out.txt | grep ^+ || true 44 | git add README.md 45 | git add spec_out.txt 46 | -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | reorder_imports = true 2 | -------------------------------------------------------------------------------- /script/check-msrv-matches-workflow: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | CI_FILE=".github/workflows/rust.yml" 4 | 5 | MSRV=$( 6 | grep -E '^rust-version = "[^"]+"$' Cargo.toml | 7 | head -n 1 | 8 | cut -d\" -f2 9 | ) 10 | 11 | TESTED=$( 12 | grep -E '^\s*MSRV: \S+$' $CI_FILE | 13 | awk '{ print $2 }' 14 | ) 15 | 16 | if test "$MSRV" != "$TESTED"; then 17 | echo "MSRV used in $CI_FILE ($TESTED) doesn't match Cargo.toml's rust-version declaration ($MSRV)." 18 | exit 1 19 | fi 20 | 21 | echo "MSRV: $MSRV" 22 | -------------------------------------------------------------------------------- /script/cibuild: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -evx 4 | 5 | if command -v apt-get &>/dev/null; then 6 | sudo apt-get install python3 7 | fi 8 | 9 | failed=0 10 | 11 | cargo build --verbose --release 12 | 13 | cd vendor/cmark-gfm/test 14 | 15 | PROGRAM_ARG="--program=../../../target/release/comrak --syntax-highlighting none" 16 | 17 | set +e 18 | 19 | # Upstream CommonMark specs. 20 | python3 spec_tests.py --no-normalize --spec ../../commonmark-spec/spec.txt "$PROGRAM_ARG" \ 21 | || failed=1 22 | 23 | python3 spec_tests.py --no-normalize --spec spec.txt "$PROGRAM_ARG --gfm-quirks" \ 24 | || failed=1 25 | python3 pathological_tests.py "$PROGRAM_ARG" \ 26 | || failed=1 27 | python3 roundtrip_tests.py --spec spec.txt "$PROGRAM_ARG" \ 28 | || failed=1 29 | python3 entity_tests.py "$PROGRAM_ARG" \ 30 | || failed=1 31 | python3 spec_tests.py --no-normalize --spec smart_punct.txt "$PROGRAM_ARG --smart" \ 32 | || failed=1 33 | 34 | python3 spec_tests.py --no-normalize --spec extensions.txt "$PROGRAM_ARG" --extensions "table strikethrough autolink tagfilter footnotes tasklist" \ 35 | || failed=1 36 | python3 roundtrip_tests.py --spec extensions.txt "$PROGRAM_ARG" --extensions "table strikethrough autolink tagfilter footnotes tasklist" \ 37 | || failed=1 38 | # python3 roundtrip_tests.py --spec extensions-table-prefer-style-attributes.txt "$PROGRAM_ARG --table-prefer-style-attributes" --extensions "table strikethrough autolink tagfilter footnotes tasklist" || failed=1 39 | python3 roundtrip_tests.py --spec extensions-full-info-string.txt "$PROGRAM_ARG --full-info-string" \ 40 | || failed=1 41 | python3 spec_tests.py --no-normalize --spec ../../../src/tests/fixtures/multiline_blockquote.md "$PROGRAM_ARG -e multiline-block-quotes" \ 42 | || failed=1 43 | python3 spec_tests.py --no-normalize --spec ../../../src/tests/fixtures/math_dollars.md "$PROGRAM_ARG -e math-dollars" \ 44 | || failed=1 45 | python3 spec_tests.py --no-normalize --spec ../../../src/tests/fixtures/math_code.md "$PROGRAM_ARG -e math-code" \ 46 | || failed=1 47 | python3 spec_tests.py --no-normalize --spec ../../../src/tests/fixtures/wikilinks_title_after_pipe.md "$PROGRAM_ARG -e wikilinks-title-after-pipe" \ 48 | || failed=1 49 | python3 spec_tests.py --no-normalize --spec ../../../src/tests/fixtures/wikilinks_title_before_pipe.md "$PROGRAM_ARG -e wikilinks-title-before-pipe" \ 50 | || failed=1 51 | python3 spec_tests.py --no-normalize --spec ../../../src/tests/fixtures/description_lists.md "$PROGRAM_ARG -e description-lists" \ 52 | || failed=1 53 | python3 spec_tests.py --no-normalize --spec ../../../src/tests/fixtures/alerts.md "$PROGRAM_ARG -e alerts" \ 54 | || failed=1 55 | python3 spec_tests.py --no-normalize --spec ../../../src/tests/fixtures/multiline_alerts.md "$PROGRAM_ARG -e alerts -e multiline-block-quotes" \ 56 | || failed=1 57 | 58 | python3 spec_tests.py --no-normalize --spec regression.txt "$PROGRAM_ARG" \ 59 | || failed=1 60 | 61 | set -e 62 | 63 | exit $failed 64 | -------------------------------------------------------------------------------- /script/version: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | cat Cargo.toml | grep ^version | head -n 1 | awk '{ gsub(/"/, "", $3); print $3 }' 3 | -------------------------------------------------------------------------------- /spec_out.txt: -------------------------------------------------------------------------------- 1 | 671 passed, 0 failed, 0 errored, 0 skipped 2 | -------------------------------------------------------------------------------- /src/adapters.rs: -------------------------------------------------------------------------------- 1 | //! Adapter traits for plugins. 2 | //! 3 | //! Each plugin has to implement one of the traits available in this module. 4 | 5 | use std::collections::HashMap; 6 | use std::io::{self, Write}; 7 | 8 | use crate::nodes::Sourcepos; 9 | 10 | /// Implement this adapter for creating a plugin for custom syntax highlighting of codefence blocks. 11 | pub trait SyntaxHighlighterAdapter: Send + Sync { 12 | /// Generates a syntax highlighted HTML output. 13 | /// 14 | /// lang: Name of the programming language (the info string of the codefence block after the initial "```" part). 15 | /// code: The source code to be syntax highlighted. 16 | fn write_highlighted( 17 | &self, 18 | output: &mut dyn Write, 19 | lang: Option<&str>, 20 | code: &str, 21 | ) -> io::Result<()>; 22 | 23 | /// Generates the opening `
` tag. Some syntax highlighter libraries might include their own
24 |     /// `
` tag possibly with some HTML attribute pre-filled.
25 |     ///
26 |     /// `attributes`: A map of HTML attributes provided by comrak.
27 |     fn write_pre_tag(
28 |         &self,
29 |         output: &mut dyn Write,
30 |         attributes: HashMap,
31 |     ) -> io::Result<()>;
32 | 
33 |     /// Generates the opening `` tag. Some syntax highlighter libraries might include their own
34 |     /// `` tag possibly with some HTML attribute pre-filled.
35 |     ///
36 |     /// `attributes`: A map of HTML attributes provided by comrak.
37 |     fn write_code_tag(
38 |         &self,
39 |         output: &mut dyn Write,
40 |         attributes: HashMap,
41 |     ) -> io::Result<()>;
42 | }
43 | 
44 | /// The struct passed to the [`HeadingAdapter`] for custom heading implementations.
45 | #[derive(Clone, Debug)]
46 | pub struct HeadingMeta {
47 |     /// The level of the heading; from 1 to 6 for ATX headings, 1 or 2 for setext headings.
48 |     pub level: u8,
49 | 
50 |     /// The content of the heading as a "flattened" string—flattened in the sense that any
51 |     /// `` or other tags are removed. In the Markdown heading `## This is **bold**`, for
52 |     /// example, the would be the string `"This is bold"`.
53 |     pub content: String,
54 | }
55 | 
56 | /// Implement this adapter for creating a plugin for custom headings (`h1`, `h2`, etc.). The `enter`
57 | /// method defines what's rendered prior the AST content of the heading while the `exit` method
58 | /// defines what's rendered after it. Both methods provide access to a [`HeadingMeta`] struct and
59 | /// leave the AST content of the heading unchanged.
60 | pub trait HeadingAdapter: Send + Sync {
61 |     /// Render the opening tag.
62 |     fn enter(
63 |         &self,
64 |         output: &mut dyn Write,
65 |         heading: &HeadingMeta,
66 |         sourcepos: Option,
67 |     ) -> io::Result<()>;
68 | 
69 |     /// Render the closing tag.
70 |     fn exit(&self, output: &mut dyn Write, heading: &HeadingMeta) -> io::Result<()>;
71 | }
72 | 


--------------------------------------------------------------------------------
/src/character_set.rs:
--------------------------------------------------------------------------------
 1 | macro_rules! character_set {
 2 |     () => {{
 3 |         [false; 256]
 4 |     }};
 5 | 
 6 |     ($value:literal $(,$rest:literal)*) => {{
 7 |         const A: &[u8] = $value;
 8 |         let mut a = character_set!($($rest),*);
 9 |         let mut i = 0;
10 |         while i < A.len() {
11 |             a[A[i] as usize] = true;
12 |             i += 1;
13 |         }
14 |         a
15 |     }}
16 | }
17 | 
18 | pub(crate) use character_set;
19 | 


--------------------------------------------------------------------------------
/src/ctype.rs:
--------------------------------------------------------------------------------
 1 | #[rustfmt::skip]
 2 | const CMARK_CTYPE_CLASS: [u8; 256] = [
 3 |     /*      0  1  2  3  4  5  6  7  8  9  a  b  c  d  e  f */
 4 |     /* 0 */ 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 1, 0, 0,
 5 |     /* 1 */ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
 6 |     /* 2 */ 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2,
 7 |     /* 3 */ 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 2, 2, 2, 2, 2,
 8 |     /* 4 */ 2, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4,
 9 |     /* 5 */ 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 2, 2, 2, 2, 2,
10 |     /* 6 */ 2, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4,
11 |     /* 7 */ 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 2, 2, 2, 2, 0,
12 |     /* 8 */ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
13 |     /* 9 */ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
14 |     /* a */ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
15 |     /* b */ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
16 |     /* c */ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
17 |     /* d */ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
18 |     /* e */ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
19 |     /* f */ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
20 | ];
21 | 
22 | pub fn isspace(ch: u8) -> bool {
23 |     CMARK_CTYPE_CLASS[ch as usize] == 1
24 | }
25 | 
26 | pub fn ispunct(ch: u8) -> bool {
27 |     CMARK_CTYPE_CLASS[ch as usize] == 2
28 | }
29 | 
30 | pub fn isdigit(ch: u8) -> bool {
31 |     CMARK_CTYPE_CLASS[ch as usize] == 3
32 | }
33 | 
34 | pub fn isalpha(ch: u8) -> bool {
35 |     CMARK_CTYPE_CLASS[ch as usize] == 4
36 | }
37 | 
38 | pub fn isalnum(ch: u8) -> bool {
39 |     CMARK_CTYPE_CLASS[ch as usize] == 3 || CMARK_CTYPE_CLASS[ch as usize] == 4
40 | }
41 | 


--------------------------------------------------------------------------------
/src/entity.rs:
--------------------------------------------------------------------------------
  1 | use crate::ctype::isdigit;
  2 | use entities::ENTITIES;
  3 | use std::char;
  4 | use std::cmp::min;
  5 | use std::str;
  6 | 
  7 | pub const ENTITY_MIN_LENGTH: usize = 2;
  8 | pub const ENTITY_MAX_LENGTH: usize = 32;
  9 | 
 10 | fn isxdigit(ch: &u8) -> bool {
 11 |     (*ch >= b'0' && *ch <= b'9') || (*ch >= b'a' && *ch <= b'f') || (*ch >= b'A' && *ch <= b'F')
 12 | }
 13 | 
 14 | pub fn unescape(text: &[u8]) -> Option<(Vec, usize)> {
 15 |     if text.len() >= 3 && text[0] == b'#' {
 16 |         let mut codepoint: u32 = 0;
 17 |         let mut i = 0;
 18 | 
 19 |         let num_digits = if isdigit(text[1]) {
 20 |             i = 1;
 21 |             while i < text.len() && isdigit(text[i]) {
 22 |                 codepoint = (codepoint * 10) + (text[i] as u32 - '0' as u32);
 23 |                 codepoint = min(codepoint, 0x11_0000);
 24 |                 i += 1;
 25 |             }
 26 |             i - 1
 27 |         } else if text[1] == b'x' || text[1] == b'X' {
 28 |             i = 2;
 29 |             while i < text.len() && isxdigit(&text[i]) {
 30 |                 codepoint = (codepoint * 16) + ((text[i] as u32 | 32) % 39 - 9);
 31 |                 codepoint = min(codepoint, 0x11_0000);
 32 |                 i += 1;
 33 |             }
 34 |             i - 2
 35 |         } else {
 36 |             0
 37 |         };
 38 | 
 39 |         if i < text.len()
 40 |             && text[i] == b';'
 41 |             && (((text[1] == b'x' || text[1] == b'X') && (1..=6).contains(&num_digits))
 42 |                 || (1..=7).contains(&num_digits))
 43 |         {
 44 |             if codepoint == 0 || (0xD800..=0xE000).contains(&codepoint) || codepoint >= 0x110000 {
 45 |                 codepoint = 0xFFFD;
 46 |             }
 47 |             return Some((
 48 |                 char::from_u32(codepoint)
 49 |                     .unwrap_or('\u{FFFD}')
 50 |                     .to_string()
 51 |                     .into_bytes(),
 52 |                 i + 1,
 53 |             ));
 54 |         }
 55 |     }
 56 | 
 57 |     let size = min(text.len(), ENTITY_MAX_LENGTH);
 58 |     for i in ENTITY_MIN_LENGTH..size {
 59 |         if text[i] == b' ' {
 60 |             return None;
 61 |         }
 62 | 
 63 |         if text[i] == b';' {
 64 |             return lookup(&text[..i]).map(|e| (e.to_vec(), i + 1));
 65 |         }
 66 |     }
 67 | 
 68 |     None
 69 | }
 70 | 
 71 | fn lookup(text: &[u8]) -> Option<&[u8]> {
 72 |     let entity_str = format!("&{};", unsafe { str::from_utf8_unchecked(text) });
 73 | 
 74 |     let entity = ENTITIES.iter().find(|e| e.entity == entity_str);
 75 | 
 76 |     match entity {
 77 |         Some(e) => Some(e.characters.as_bytes()),
 78 |         None => None,
 79 |     }
 80 | }
 81 | 
 82 | pub fn unescape_html(src: &[u8]) -> Vec {
 83 |     let size = src.len();
 84 |     let mut i = 0;
 85 |     let mut v = Vec::with_capacity(size);
 86 | 
 87 |     while i < size {
 88 |         let org = i;
 89 |         while i < size && src[i] != b'&' {
 90 |             i += 1;
 91 |         }
 92 | 
 93 |         if i > org {
 94 |             if org == 0 && i >= size {
 95 |                 return src.to_vec();
 96 |             }
 97 | 
 98 |             v.extend_from_slice(&src[org..i]);
 99 |         }
100 | 
101 |         if i >= size {
102 |             return v;
103 |         }
104 | 
105 |         i += 1;
106 |         match unescape(&src[i..]) {
107 |             Some((chs, size)) => {
108 |                 v.extend_from_slice(&chs);
109 |                 i += size;
110 |             }
111 |             None => v.push(b'&'),
112 |         }
113 |     }
114 | 
115 |     v
116 | }
117 | 


--------------------------------------------------------------------------------
/src/html/anchorizer.rs:
--------------------------------------------------------------------------------
 1 | use std::borrow::Cow;
 2 | use std::collections::HashSet;
 3 | use unicode_categories::UnicodeCategories;
 4 | 
 5 | /// Converts header strings to canonical, unique, but still human-readable,
 6 | /// anchors.
 7 | ///
 8 | /// To guarantee uniqueness, an anchorizer keeps track of the anchors it has
 9 | /// returned; use one per output file.
10 | ///
11 | /// ## Example
12 | ///
13 | /// ```
14 | /// # use comrak::Anchorizer;
15 | /// let mut anchorizer = Anchorizer::new();
16 | /// // First "stuff" is unsuffixed.
17 | /// assert_eq!("stuff".to_string(), anchorizer.anchorize("Stuff".to_string()));
18 | /// // Second "stuff" has "-1" appended to make it unique.
19 | /// assert_eq!("stuff-1".to_string(), anchorizer.anchorize("Stuff".to_string()));
20 | /// ```
21 | #[derive(Debug, Default)]
22 | #[doc(hidden)]
23 | pub struct Anchorizer(HashSet);
24 | 
25 | impl Anchorizer {
26 |     /// Construct a new anchorizer.
27 |     pub fn new() -> Self {
28 |         Anchorizer(HashSet::new())
29 |     }
30 | 
31 |     /// Returns a String that has been converted into an anchor using the
32 |     /// GFM algorithm, which involves changing spaces to dashes, removing
33 |     /// problem characters and, if needed, adding a suffix to make the
34 |     /// resultant anchor unique.
35 |     ///
36 |     /// ```
37 |     /// # use comrak::Anchorizer;
38 |     /// let mut anchorizer = Anchorizer::new();
39 |     /// let source = "Ticks aren't in";
40 |     /// assert_eq!("ticks-arent-in".to_string(), anchorizer.anchorize(source.to_string()));
41 |     /// ```
42 |     pub fn anchorize(&mut self, header: String) -> String {
43 |         fn is_permitted_char(&c: &char) -> bool {
44 |             c == ' '
45 |                 || c == '-'
46 |                 || c.is_letter()
47 |                 || c.is_mark()
48 |                 || c.is_number()
49 |                 || c.is_punctuation_connector()
50 |         }
51 | 
52 |         let mut id = header.to_lowercase();
53 |         id = id
54 |             .chars()
55 |             .filter(is_permitted_char)
56 |             .map(|c| if c == ' ' { '-' } else { c })
57 |             .collect();
58 | 
59 |         let mut uniq = 0;
60 |         id = loop {
61 |             let anchor = if uniq == 0 {
62 |                 Cow::from(&id)
63 |             } else {
64 |                 Cow::from(format!("{}-{}", id, uniq))
65 |             };
66 | 
67 |             if !self.0.contains(&*anchor) {
68 |                 break anchor.into_owned();
69 |             }
70 | 
71 |             uniq += 1;
72 |         };
73 |         self.0.insert(id.clone());
74 |         id
75 |     }
76 | }
77 | 


--------------------------------------------------------------------------------
/src/html/context.rs:
--------------------------------------------------------------------------------
 1 | use crate::html::{self, Anchorizer};
 2 | use crate::{Options, Plugins};
 3 | 
 4 | use std::cell::Cell;
 5 | use std::io::{self, Write};
 6 | 
 7 | /// Context struct given to formatter functions as taken by
 8 | /// [`html::format_document_with_formatter`].  Output can be appended to through
 9 | /// this struct's [`Write`] interface.
10 | pub struct Context<'o, 'c, T = ()> {
11 |     output: &'o mut dyn Write,
12 |     last_was_lf: Cell,
13 | 
14 |     /// [`Options`] in use in this render.
15 |     pub options: &'o Options<'c>,
16 |     /// [`Plugins`] in use in this render.
17 |     pub plugins: &'o Plugins<'o>,
18 |     /// [`Anchorizer`] instance used in this render.
19 |     pub anchorizer: Anchorizer,
20 |     /// Any user data used by the [`Context`].
21 |     pub user: T,
22 | 
23 |     pub(super) footnote_ix: u32,
24 |     pub(super) written_footnote_ix: u32,
25 | }
26 | 
27 | impl<'o, 'c, T> Context<'o, 'c, T> {
28 |     pub(super) fn new(
29 |         output: &'o mut dyn Write,
30 |         options: &'o Options<'c>,
31 |         plugins: &'o Plugins<'o>,
32 |         user: T,
33 |     ) -> Self {
34 |         Context {
35 |             output,
36 |             last_was_lf: Cell::new(true),
37 |             options,
38 |             plugins,
39 |             anchorizer: Anchorizer::new(),
40 |             user,
41 |             footnote_ix: 0,
42 |             written_footnote_ix: 0,
43 |         }
44 |     }
45 | 
46 |     pub(super) fn finish(mut self) -> io::Result {
47 |         if self.footnote_ix > 0 {
48 |             self.write_all(b"\n\n")?;
49 |         }
50 |         Ok(self.user)
51 |     }
52 | 
53 |     /// If the last byte written to ts [`Write`] interface was **not** a U+000A
54 |     /// LINE FEED, writes one. Otherwise, does nothing.
55 |     ///
56 |     /// (In other words, ensures the output is at a new line.)
57 |     pub fn cr(&mut self) -> io::Result<()> {
58 |         if !self.last_was_lf.get() {
59 |             self.write_all(b"\n")?;
60 |         }
61 |         Ok(())
62 |     }
63 | 
64 |     /// Convenience wrapper for [`html::escape`].
65 |     pub fn escape(&mut self, buffer: &[u8]) -> io::Result<()> {
66 |         html::escape(self, buffer)
67 |     }
68 | 
69 |     /// Convenience wrapper for [`html::escape_href`].
70 |     pub fn escape_href(&mut self, buffer: &[u8]) -> io::Result<()> {
71 |         html::escape_href(self, buffer)
72 |     }
73 | }
74 | 
75 | impl<'o, 'c, T> Write for Context<'o, 'c, T> {
76 |     fn flush(&mut self) -> io::Result<()> {
77 |         self.output.flush()
78 |     }
79 | 
80 |     fn write(&mut self, buf: &[u8]) -> io::Result {
81 |         let l = buf.len();
82 |         if l > 0 {
83 |             self.last_was_lf.set(buf[l - 1] == 10);
84 |         }
85 |         self.output.write(buf)
86 |     }
87 | }
88 | 
89 | impl<'o, 'c, T> std::fmt::Debug for Context<'o, 'c, T> {
90 |     fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> {
91 |         formatter.write_str("")
92 |     }
93 | }
94 | 


--------------------------------------------------------------------------------
/src/lib.rs:
--------------------------------------------------------------------------------
  1 | //! A 100% [CommonMark](http://commonmark.org/) and [GFM](https://github.github.com/gfm/)
  2 | //! compatible Markdown parser.
  3 | //!
  4 | //! Source repository and detailed `README` is at .
  5 | //!
  6 | //! You can use `comrak::markdown_to_html` directly:
  7 | //!
  8 | //! ```
  9 | //! use comrak::{markdown_to_html, Options};
 10 | //! assert_eq!(markdown_to_html("Hello, **世界**!", &Options::default()),
 11 | //!            "

Hello, 世界!

\n"); 12 | //! ``` 13 | //! 14 | //! Or you can parse the input into an AST yourself, manipulate it, and then use your desired 15 | //! formatter: 16 | //! 17 | //! ``` 18 | //! use comrak::{Arena, parse_document, format_html, Options}; 19 | //! use comrak::nodes::{AstNode, NodeValue}; 20 | //! 21 | //! # fn main() { 22 | //! let arena = Arena::new(); 23 | //! 24 | //! let root = parse_document( 25 | //! &arena, 26 | //! "This is my input.\n\n1. Also [my](#) input.\n2. Certainly *my* input.\n", 27 | //! &Options::default()); 28 | //! 29 | //! for node in root.descendants() { 30 | //! if let NodeValue::Text(ref mut text) = node.data.borrow_mut().value { 31 | //! *text = text.replace("my", "your"); 32 | //! } 33 | //! } 34 | //! 35 | //! let mut html = vec![]; 36 | //! format_html(root, &Options::default(), &mut html).unwrap(); 37 | //! 38 | //! assert_eq!( 39 | //! String::from_utf8(html).unwrap(), 40 | //! "

This is your input.

\n\ 41 | //!
    \n\ 42 | //!
  1. Also your input.
  2. \n\ 43 | //!
  3. Certainly your input.
  4. \n\ 44 | //!
\n"); 45 | //! # } 46 | //! ``` 47 | 48 | #![cfg_attr(docsrs, feature(doc_cfg))] 49 | #![deny( 50 | missing_docs, 51 | missing_debug_implementations, 52 | missing_copy_implementations, 53 | trivial_casts, 54 | trivial_numeric_casts, 55 | unstable_features, 56 | unused_import_braces 57 | )] 58 | #![allow( 59 | unknown_lints, 60 | clippy::doc_markdown, 61 | cyclomatic_complexity, 62 | clippy::bool_to_int_with_if, 63 | clippy::too_many_arguments 64 | )] 65 | 66 | use std::io::BufWriter; 67 | 68 | pub mod adapters; 69 | pub mod arena_tree; 70 | mod character_set; 71 | mod cm; 72 | mod ctype; 73 | mod entity; 74 | pub mod html; 75 | pub mod nodes; 76 | mod parser; 77 | pub mod plugins; 78 | mod scanners; 79 | mod strings; 80 | #[cfg(test)] 81 | mod tests; 82 | mod xml; 83 | 84 | pub use cm::format_document as format_commonmark; 85 | pub use cm::format_document_with_plugins as format_commonmark_with_plugins; 86 | pub use html::format_document as format_html; 87 | pub use html::format_document_with_plugins as format_html_with_plugins; 88 | #[doc(inline)] 89 | pub use html::Anchorizer; 90 | #[allow(deprecated)] 91 | pub use parser::parse_document_with_broken_link_callback; 92 | pub use parser::{ 93 | parse_document, BrokenLinkCallback, BrokenLinkReference, ExtensionOptions, ListStyleType, 94 | Options, ParseOptions, Plugins, RenderOptions, RenderPlugins, ResolvedReference, URLRewriter, 95 | WikiLinksMode, 96 | }; 97 | pub use typed_arena::Arena; 98 | pub use xml::format_document as format_xml; 99 | pub use xml::format_document_with_plugins as format_xml_with_plugins; 100 | 101 | #[cfg(feature = "bon")] 102 | pub use parser::{ 103 | ExtensionOptionsBuilder, ParseOptionsBuilder, PluginsBuilder, RenderOptionsBuilder, 104 | RenderPluginsBuilder, 105 | }; 106 | 107 | /// Legacy naming of [`ExtensionOptions`] 108 | pub type ComrakExtensionOptions<'c> = ExtensionOptions<'c>; 109 | /// Legacy naming of [`Options`] 110 | pub type ComrakOptions<'c> = Options<'c>; 111 | /// Legacy naming of [`ParseOptions`] 112 | pub type ComrakParseOptions<'c> = ParseOptions<'c>; 113 | /// Legacy naming of [`Plugins`] 114 | pub type ComrakPlugins<'a> = Plugins<'a>; 115 | /// Legacy naming of [`RenderOptions`] 116 | pub type ComrakRenderOptions = RenderOptions; 117 | /// Legacy naming of [`RenderPlugins`] 118 | pub type ComrakRenderPlugins<'a> = RenderPlugins<'a>; 119 | 120 | /// Render Markdown to HTML. 121 | /// 122 | /// See the documentation of the crate root for an example. 123 | pub fn markdown_to_html(md: &str, options: &Options) -> String { 124 | markdown_to_html_with_plugins(md, options, &Plugins::default()) 125 | } 126 | 127 | /// Render Markdown to HTML using plugins. 128 | /// 129 | /// See the documentation of the crate root for an example. 130 | pub fn markdown_to_html_with_plugins(md: &str, options: &Options, plugins: &Plugins) -> String { 131 | let arena = Arena::new(); 132 | let root = parse_document(&arena, md, options); 133 | let mut bw = BufWriter::new(Vec::new()); 134 | format_html_with_plugins(root, options, &mut bw, plugins).unwrap(); 135 | String::from_utf8(bw.into_inner().unwrap()).unwrap() 136 | } 137 | 138 | /// Return the version of the crate. 139 | pub fn version() -> &'static str { 140 | env!("CARGO_PKG_VERSION") 141 | } 142 | 143 | /// Render Markdown back to CommonMark. 144 | pub fn markdown_to_commonmark(md: &str, options: &Options) -> String { 145 | let arena = Arena::new(); 146 | let root = parse_document(&arena, md, options); 147 | let mut bw = BufWriter::new(Vec::new()); 148 | format_commonmark(root, options, &mut bw).unwrap(); 149 | String::from_utf8(bw.into_inner().unwrap()).unwrap() 150 | } 151 | 152 | /// Render Markdown to CommonMark XML. 153 | /// See . 154 | pub fn markdown_to_commonmark_xml(md: &str, options: &Options) -> String { 155 | markdown_to_commonmark_xml_with_plugins(md, options, &Plugins::default()) 156 | } 157 | 158 | /// Render Markdown to CommonMark XML using plugins. 159 | /// See . 160 | pub fn markdown_to_commonmark_xml_with_plugins( 161 | md: &str, 162 | options: &Options, 163 | plugins: &Plugins, 164 | ) -> String { 165 | let arena = Arena::new(); 166 | let root = parse_document(&arena, md, options); 167 | let mut bw = BufWriter::new(Vec::new()); 168 | format_xml_with_plugins(root, options, &mut bw, plugins).unwrap(); 169 | String::from_utf8(bw.into_inner().unwrap()).unwrap() 170 | } 171 | -------------------------------------------------------------------------------- /src/parser/alert.rs: -------------------------------------------------------------------------------- 1 | /// The metadata of an Alert node. 2 | #[derive(Debug, Clone, PartialEq, Eq)] 3 | pub struct NodeAlert { 4 | /// Type of alert 5 | pub alert_type: AlertType, 6 | 7 | /// Overridden title. If None, then use the default title. 8 | pub title: Option, 9 | 10 | /// Originated from a multiline blockquote. 11 | pub multiline: bool, 12 | 13 | /// The length of the fence (multiline only). 14 | pub fence_length: usize, 15 | 16 | /// The indentation level of the fence marker (multiline only) 17 | pub fence_offset: usize, 18 | } 19 | 20 | /// The type of alert. 21 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] 22 | pub enum AlertType { 23 | /// Useful information that users should know, even when skimming content 24 | #[default] 25 | Note, 26 | 27 | /// Helpful advice for doing things better or more easily 28 | Tip, 29 | 30 | /// Key information users need to know to achieve their goal 31 | Important, 32 | 33 | /// Urgent info that needs immediate user attention to avoid problems 34 | Warning, 35 | 36 | /// Advises about risks or negative outcomes of certain actions 37 | Caution, 38 | } 39 | 40 | impl AlertType { 41 | /// Returns the default title for an alert type 42 | pub fn default_title(&self) -> String { 43 | match *self { 44 | AlertType::Note => String::from("Note"), 45 | AlertType::Tip => String::from("Tip"), 46 | AlertType::Important => String::from("Important"), 47 | AlertType::Warning => String::from("Warning"), 48 | AlertType::Caution => String::from("Caution"), 49 | } 50 | } 51 | 52 | /// Returns the CSS class to use for an alert type 53 | pub fn css_class(&self) -> String { 54 | match *self { 55 | AlertType::Note => String::from("markdown-alert-note"), 56 | AlertType::Tip => String::from("markdown-alert-tip"), 57 | AlertType::Important => String::from("markdown-alert-important"), 58 | AlertType::Warning => String::from("markdown-alert-warning"), 59 | AlertType::Caution => String::from("markdown-alert-caution"), 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/parser/math.rs: -------------------------------------------------------------------------------- 1 | /// An inline math span 2 | #[derive(Default, Debug, Clone, PartialEq, Eq)] 3 | pub struct NodeMath { 4 | /// Whether this is dollar math (`$` or `$$`). 5 | /// `false` indicates it is code math 6 | pub dollar_math: bool, 7 | 8 | /// Whether this is display math (using `$$`) 9 | pub display_math: bool, 10 | 11 | /// The literal contents of the math span. 12 | /// As the contents are not interpreted as Markdown at all, 13 | /// they are contained within this structure, 14 | /// rather than inserted into a child inline of any kind. 15 | pub literal: String, 16 | } 17 | -------------------------------------------------------------------------------- /src/parser/multiline_block_quote.rs: -------------------------------------------------------------------------------- 1 | /// The metadata of a multiline blockquote. 2 | #[derive(Default, Debug, Clone, Copy, PartialEq, Eq)] 3 | pub struct NodeMultilineBlockQuote { 4 | /// The length of the fence. 5 | pub fence_length: usize, 6 | 7 | /// The indentation level of the fence marker. 8 | pub fence_offset: usize, 9 | } 10 | -------------------------------------------------------------------------------- /src/parser/shortcodes.rs: -------------------------------------------------------------------------------- 1 | /// The details of an inline "shortcode" emoji/gemoji. 2 | /// 3 | /// ("gemoji" name context: https://github.com/github/gemoji) 4 | #[derive(Debug, Clone, PartialEq, Eq)] 5 | pub struct NodeShortCode { 6 | /// The shortcode that was resolved, e.g. "rabbit". 7 | pub code: String, 8 | 9 | /// The emoji `code` resolved to, e.g. "🐰". 10 | pub emoji: String, 11 | } 12 | 13 | impl NodeShortCode { 14 | /// Checks whether the input is a valid short code. 15 | pub fn resolve(code: &str) -> Option { 16 | let emoji = emojis::get_by_shortcode(code)?; 17 | Some(NodeShortCode { 18 | code: code.to_string(), 19 | emoji: emoji.to_string(), 20 | }) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/plugins/mod.rs: -------------------------------------------------------------------------------- 1 | //! Plugins for enhancing the default implementation of comrak can be defined in this module. 2 | 3 | #[cfg(feature = "syntect")] 4 | #[cfg_attr(docsrs, doc(cfg(feature = "syntect")))] 5 | pub mod syntect; 6 | -------------------------------------------------------------------------------- /src/plugins/syntect.rs: -------------------------------------------------------------------------------- 1 | //! Adapter for the Syntect syntax highlighter plugin. 2 | 3 | use crate::adapters::SyntaxHighlighterAdapter; 4 | use crate::html; 5 | use std::collections::{hash_map, HashMap}; 6 | use std::io::{self, Write}; 7 | use syntect::easy::HighlightLines; 8 | use syntect::highlighting::{Color, ThemeSet}; 9 | use syntect::html::{ 10 | append_highlighted_html_for_styled_line, ClassStyle, ClassedHTMLGenerator, IncludeBackground, 11 | }; 12 | use syntect::parsing::{SyntaxReference, SyntaxSet}; 13 | use syntect::util::LinesWithEndings; 14 | use syntect::Error; 15 | 16 | #[derive(Debug)] 17 | /// Syntect syntax highlighter plugin. 18 | pub struct SyntectAdapter { 19 | theme: Option, 20 | syntax_set: SyntaxSet, 21 | theme_set: ThemeSet, 22 | } 23 | 24 | impl SyntectAdapter { 25 | /// Construct a new `SyntectAdapter` object and set the syntax highlighting theme. 26 | /// If None is specified, apply CSS classes instead. 27 | pub fn new(theme: Option<&str>) -> Self { 28 | SyntectAdapter { 29 | theme: theme.map(String::from), 30 | syntax_set: SyntaxSet::load_defaults_newlines(), 31 | theme_set: ThemeSet::load_defaults(), 32 | } 33 | } 34 | 35 | fn highlight_html(&self, code: &str, syntax: &SyntaxReference) -> Result { 36 | match &self.theme { 37 | Some(theme) => { 38 | // syntect::html::highlighted_html_for_string, without the opening/closing
.
 39 |                 let theme = &self.theme_set.themes[theme];
 40 |                 let mut highlighter = HighlightLines::new(syntax, theme);
 41 | 
 42 |                 let bg = theme.settings.background.unwrap_or(Color::WHITE);
 43 | 
 44 |                 let mut output = String::new();
 45 |                 for line in LinesWithEndings::from(code) {
 46 |                     let regions = highlighter.highlight_line(line, &self.syntax_set)?;
 47 |                     append_highlighted_html_for_styled_line(
 48 |                         ®ions[..],
 49 |                         IncludeBackground::IfDifferent(bg),
 50 |                         &mut output,
 51 |                     )?;
 52 |                 }
 53 |                 Ok(output)
 54 |             }
 55 |             None => {
 56 |                 // fall back to HTML classes.
 57 |                 let mut html_generator = ClassedHTMLGenerator::new_with_class_style(
 58 |                     syntax,
 59 |                     &self.syntax_set,
 60 |                     ClassStyle::Spaced,
 61 |                 );
 62 |                 for line in LinesWithEndings::from(code) {
 63 |                     html_generator.parse_html_for_line_which_includes_newline(line)?;
 64 |                 }
 65 |                 Ok(html_generator.finalize())
 66 |             }
 67 |         }
 68 |     }
 69 | }
 70 | 
 71 | impl SyntaxHighlighterAdapter for SyntectAdapter {
 72 |     fn write_highlighted(
 73 |         &self,
 74 |         output: &mut dyn Write,
 75 |         lang: Option<&str>,
 76 |         code: &str,
 77 |     ) -> io::Result<()> {
 78 |         let fallback_syntax = "Plain Text";
 79 | 
 80 |         let lang: &str = match lang {
 81 |             Some(l) if !l.is_empty() => l,
 82 |             _ => fallback_syntax,
 83 |         };
 84 | 
 85 |         let syntax = self
 86 |             .syntax_set
 87 |             .find_syntax_by_token(lang)
 88 |             .unwrap_or_else(|| {
 89 |                 self.syntax_set
 90 |                     .find_syntax_by_first_line(code)
 91 |                     .unwrap_or_else(|| self.syntax_set.find_syntax_plain_text())
 92 |             });
 93 | 
 94 |         match self.highlight_html(code, syntax) {
 95 |             Ok(highlighted_code) => output.write_all(highlighted_code.as_bytes()),
 96 |             Err(_) => output.write_all(code.as_bytes()),
 97 |         }
 98 |     }
 99 | 
100 |     fn write_pre_tag(
101 |         &self,
102 |         output: &mut dyn Write,
103 |         attributes: HashMap,
104 |     ) -> io::Result<()> {
105 |         match &self.theme {
106 |             Some(theme) => {
107 |                 let theme = &self.theme_set.themes[theme];
108 |                 let colour = theme.settings.background.unwrap_or(Color::WHITE);
109 | 
110 |                 let style = format!(
111 |                     "background-color:#{:02x}{:02x}{:02x};",
112 |                     colour.r, colour.g, colour.b
113 |                 );
114 | 
115 |                 let mut pre_attributes = SyntectPreAttributes::new(attributes, &style);
116 |                 html::write_opening_tag(output, "pre", pre_attributes.iter_mut())
117 |             }
118 |             None => {
119 |                 let mut attributes: HashMap<&str, &str> = HashMap::new();
120 |                 attributes.insert("class", "syntax-highlighting");
121 |                 html::write_opening_tag(output, "pre", attributes)
122 |             }
123 |         }
124 |     }
125 | 
126 |     fn write_code_tag(
127 |         &self,
128 |         output: &mut dyn Write,
129 |         attributes: HashMap,
130 |     ) -> io::Result<()> {
131 |         html::write_opening_tag(output, "code", attributes)
132 |     }
133 | }
134 | 
135 | struct SyntectPreAttributes {
136 |     syntect_style: String,
137 |     attributes: HashMap,
138 | }
139 | 
140 | impl SyntectPreAttributes {
141 |     fn new(attributes: HashMap, syntect_style: &str) -> Self {
142 |         Self {
143 |             syntect_style: syntect_style.into(),
144 |             attributes,
145 |         }
146 |     }
147 | 
148 |     fn iter_mut(&mut self) -> SyntectPreAttributesIter {
149 |         SyntectPreAttributesIter {
150 |             iter_mut: self.attributes.iter_mut(),
151 |             syntect_style: &self.syntect_style,
152 |             style_written: false,
153 |         }
154 |     }
155 | }
156 | 
157 | struct SyntectPreAttributesIter<'a> {
158 |     iter_mut: hash_map::IterMut<'a, String, String>,
159 |     syntect_style: &'a str,
160 |     style_written: bool,
161 | }
162 | 
163 | impl<'a> Iterator for SyntectPreAttributesIter<'a> {
164 |     type Item = (&'a str, &'a str);
165 | 
166 |     fn next(&mut self) -> Option {
167 |         match self.iter_mut.next() {
168 |             Some((k, v)) if k == "style" && !self.style_written => {
169 |                 self.style_written = true;
170 |                 v.insert_str(0, self.syntect_style);
171 |                 Some((k, v))
172 |             }
173 |             Some((k, v)) => Some((k, v)),
174 |             None if !self.style_written => {
175 |                 self.style_written = true;
176 |                 Some(("style", self.syntect_style))
177 |             }
178 |             None => None,
179 |         }
180 |     }
181 | }
182 | 
183 | #[derive(Debug)]
184 | /// A builder for [`SyntectAdapter`].
185 | ///
186 | /// Allows customization of `Theme`, [`ThemeSet`], and [`SyntaxSet`].
187 | pub struct SyntectAdapterBuilder {
188 |     theme: Option,
189 |     syntax_set: Option,
190 |     theme_set: Option,
191 | }
192 | 
193 | impl Default for SyntectAdapterBuilder {
194 |     fn default() -> Self {
195 |         SyntectAdapterBuilder {
196 |             theme: Some("InspiredGitHub".into()),
197 |             syntax_set: None,
198 |             theme_set: None,
199 |         }
200 |     }
201 | }
202 | 
203 | impl SyntectAdapterBuilder {
204 |     /// Create a new empty [`SyntectAdapterBuilder`].
205 |     pub fn new() -> Self {
206 |         Default::default()
207 |     }
208 | 
209 |     /// Set the theme.
210 |     pub fn theme(mut self, s: &str) -> Self {
211 |         self.theme.replace(s.into());
212 |         self
213 |     }
214 | 
215 |     /// Uses CSS classes instead of a Syntect theme.
216 |     pub fn css(mut self) -> Self {
217 |         self.theme = None;
218 |         self
219 |     }
220 | 
221 |     /// Set the syntax set.
222 |     pub fn syntax_set(mut self, s: SyntaxSet) -> Self {
223 |         self.syntax_set.replace(s);
224 |         self
225 |     }
226 | 
227 |     /// Set the theme set.
228 |     pub fn theme_set(mut self, s: ThemeSet) -> Self {
229 |         self.theme_set.replace(s);
230 |         self
231 |     }
232 | 
233 |     /// Builds the [`SyntectAdapter`]. Default values:
234 |     /// - `theme`: `InspiredGitHub`
235 |     /// - `syntax_set`: [`SyntaxSet::load_defaults_newlines()`]
236 |     /// - `theme_set`: [`ThemeSet::load_defaults()`]
237 |     pub fn build(self) -> SyntectAdapter {
238 |         SyntectAdapter {
239 |             theme: self.theme,
240 |             syntax_set: self
241 |                 .syntax_set
242 |                 .unwrap_or_else(SyntaxSet::load_defaults_newlines),
243 |             theme_set: self.theme_set.unwrap_or_else(ThemeSet::load_defaults),
244 |         }
245 |     }
246 | }
247 | 


--------------------------------------------------------------------------------
/src/tests/alerts.rs:
--------------------------------------------------------------------------------
 1 | use super::*;
 2 | 
 3 | #[test]
 4 | fn alerts() {
 5 |     html_opts!(
 6 |         [extension.alerts],
 7 |         concat!("> [!note]\n", "> Pay attention\n",),
 8 |         concat!(
 9 |             "
\n", 10 | "

Note

\n", 11 | "

Pay attention

\n", 12 | "
\n", 13 | ), 14 | ); 15 | } 16 | 17 | #[test] 18 | fn multiline_alerts() { 19 | html_opts!( 20 | [extension.alerts, extension.multiline_block_quotes], 21 | concat!(">>> [!note]\n", "Pay attention\n", ">>>",), 22 | concat!( 23 | "
\n", 24 | "

Note

\n", 25 | "

Pay attention

\n", 26 | "
\n", 27 | ), 28 | ); 29 | } 30 | 31 | #[test] 32 | fn sourcepos() { 33 | assert_ast_match!( 34 | [extension.alerts], 35 | "> [!note]\n" 36 | "> Pay attention\n", 37 | (document (1:1-2:15) [ 38 | (alert (1:1-2:15) [ 39 | (paragraph (2:3-2:15) [ 40 | (text (2:3-2:15) "Pay attention") 41 | ]) 42 | ]) 43 | ]) 44 | ); 45 | } 46 | 47 | #[test] 48 | fn sourcepos_in_list() { 49 | assert_ast_match!( 50 | [extension.alerts], 51 | "- item one\n" 52 | "\n" 53 | " > [!note]\n" 54 | " > Pay attention\n", 55 | (document (1:1-4:17) [ 56 | (list (1:1-4:17) [ 57 | (item (1:1-4:17) [ 58 | (paragraph (1:3-1:10) [ 59 | (text (1:3-1:10) "item one") 60 | ]) 61 | (alert (3:3-4:17) [ 62 | (paragraph (4:5-4:17) [ 63 | (text (4:5-4:17) "Pay attention") 64 | ]) 65 | ]) 66 | ]) 67 | ]) 68 | ]) 69 | ); 70 | } 71 | 72 | #[test] 73 | fn sourcepos_multiline() { 74 | assert_ast_match!( 75 | [extension.alerts, extension.multiline_block_quotes], 76 | ">>> [!note]\n" 77 | "Pay attention\n" 78 | ">>>\n", 79 | (document (1:1-3:3) [ 80 | (alert (1:1-2:13) [ 81 | (paragraph (2:1-2:13) [ 82 | (text (2:1-2:13) "Pay attention") 83 | ]) 84 | ]) 85 | ]) 86 | ); 87 | } 88 | -------------------------------------------------------------------------------- /src/tests/commonmark.rs: -------------------------------------------------------------------------------- 1 | use std::cell::RefCell; 2 | 3 | use self::nodes::{Ast, LineColumn, ListType, NodeList}; 4 | 5 | use super::*; 6 | use ntest::test_case; 7 | 8 | #[test] 9 | fn commonmark_removes_redundant_strong() { 10 | let input = "This is **something **even** better**"; 11 | let output = "This is **something even better**\n"; 12 | commonmark(input, output, None); 13 | } 14 | 15 | #[test] 16 | fn commonmark_avoids_spurious_backslash() { 17 | let arena = Arena::new(); 18 | let options = Options::default(); 19 | let empty = LineColumn { line: 0, column: 0 }; 20 | 21 | let ast = |val: NodeValue| arena.alloc(AstNode::new(RefCell::new(Ast::new(val, empty)))); 22 | let root = ast(NodeValue::Document); 23 | 24 | let p1 = ast(NodeValue::Paragraph); 25 | p1.append(ast(NodeValue::Text("Line 1".to_owned()))); 26 | p1.append(ast(NodeValue::LineBreak)); 27 | root.append(p1); 28 | 29 | let p2 = ast(NodeValue::Paragraph); 30 | p2.append(ast(NodeValue::Text("Line 2".to_owned()))); 31 | root.append(p2); 32 | 33 | let mut output = vec![]; 34 | cm::format_document(root, &options, &mut output).unwrap(); 35 | 36 | compare_strs( 37 | &String::from_utf8(output).unwrap(), 38 | "Line 1\n\nLine 2\n", 39 | "rendered", 40 | "", 41 | ); 42 | } 43 | 44 | #[test] 45 | fn commonmark_renders_single_list_item() { 46 | let arena = Arena::new(); 47 | let options = Options::default(); 48 | let empty = LineColumn { line: 0, column: 0 }; 49 | let ast = |val: NodeValue| arena.alloc(AstNode::new(RefCell::new(Ast::new(val, empty)))); 50 | let list_options = NodeList { 51 | list_type: ListType::Ordered, 52 | start: 1, 53 | ..Default::default() 54 | }; 55 | let list = ast(NodeValue::List(list_options)); 56 | let item = ast(NodeValue::Item(list_options)); 57 | let p = ast(NodeValue::Paragraph); 58 | p.append(ast(NodeValue::Text("Item 1".to_owned()))); 59 | item.append(p); 60 | list.append(item); 61 | let mut output = vec![]; 62 | cm::format_document(item, &options, &mut output).unwrap(); 63 | compare_strs( 64 | &String::from_utf8(output).unwrap(), 65 | "1. Item 1\n", 66 | "rendered", 67 | "", 68 | ); 69 | } 70 | 71 | #[test_case("$$x^2$$ and $1 + 2$ and $`y^2`$", "$$x^2$$ and $1 + 2$ and $`y^2`$\n")] 72 | #[test_case("$$\nx^2\n$$", "$$\nx^2\n$$\n")] 73 | #[test_case("```math\nx^2\n```", "``` math\nx^2\n```\n")] 74 | fn commonmark_math(markdown: &str, cm: &str) { 75 | let mut options = Options::default(); 76 | options.extension.math_dollars = true; 77 | options.extension.math_code = true; 78 | 79 | commonmark(markdown, cm, None); 80 | } 81 | 82 | #[test_case("This [[url]] that", "This [[url|url]] that\n")] 83 | #[test_case("This [[url|link label]] that", "This [[url|link%20label]] that\n")] 84 | fn commonmark_wikilinks(markdown: &str, cm: &str) { 85 | let mut options = Options::default(); 86 | options.extension.wikilinks_title_before_pipe = true; 87 | 88 | commonmark(markdown, cm, Some(&options)); 89 | } 90 | #[test] 91 | fn commonmark_relist() { 92 | commonmark( 93 | concat!("3. one\n", "5. two\n",), 94 | // Note that right now we always include enough room for up to an user 95 | // defined number of digits. TODO: Ideally we determine the maximum 96 | // digit length before getting this far. 97 | concat!("3. one\n", "4. two\n",), 98 | None, 99 | ); 100 | 101 | let mut options = Options::default(); 102 | options.extension.tasklist = true; 103 | commonmark( 104 | concat!("3. [ ] one\n", "5. [ ] two\n",), 105 | concat!("3. [ ] one\n", "4. [ ] two\n",), 106 | Some(&options), 107 | ); 108 | } 109 | 110 | #[test_case("> [!note]\n> A note", "> [!NOTE]\n> A note\n")] 111 | #[test_case("> [!note] Title\n> A note", "> [!NOTE] Title\n> A note\n")] 112 | fn commonmark_alerts(markdown: &str, cm: &str) { 113 | let mut options = Options::default(); 114 | options.extension.alerts = true; 115 | 116 | commonmark(markdown, cm, Some(&options)); 117 | } 118 | -------------------------------------------------------------------------------- /src/tests/description_lists.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | #[test] 4 | fn description_lists_loose() { 5 | html_opts!( 6 | [extension.description_lists], 7 | concat!( 8 | "Term 1\n", 9 | "\n", 10 | ": Definition 1\n", 11 | "\n", 12 | "Term 2 with *inline markup*\n", 13 | "\n", 14 | ": Definition 2\n" 15 | ), 16 | concat!( 17 | "
\n", 18 | "
Term 1
\n", 19 | "
\n", 20 | "

Definition 1

\n", 21 | "
\n", 22 | "
Term 2 with inline markup
\n", 23 | "
\n", 24 | "

Definition 2

\n", 25 | "
\n", 26 | "
\n", 27 | ), 28 | ); 29 | 30 | html_opts!( 31 | [extension.description_lists], 32 | concat!( 33 | "* Nested\n", 34 | "\n", 35 | " Term 1\n\n", 36 | " : Definition 1\n\n", 37 | " Term 2 with *inline markup*\n\n", 38 | " : Definition 2\n\n" 39 | ), 40 | concat!( 41 | "
    \n", 42 | "
  • \n", 43 | "

    Nested

    \n", 44 | "
    \n", 45 | "
    Term 1
    \n", 46 | "
    \n", 47 | "

    Definition 1

    \n", 48 | "
    \n", 49 | "
    Term 2 with inline markup
    \n", 50 | "
    \n", 51 | "

    Definition 2

    \n", 52 | "
    \n", 53 | "
    \n", 54 | "
  • \n", 55 | "
\n", 56 | ), 57 | ); 58 | } 59 | 60 | #[test] 61 | fn description_lists_tight() { 62 | html_opts!( 63 | [extension.description_lists], 64 | concat!( 65 | "Term 1\n", 66 | ": Definition 1\n", 67 | "\n", 68 | "Term 2 with *inline markup*\n", 69 | ": Definition 2\n" 70 | ), 71 | concat!( 72 | "
\n", 73 | "
Term 1
\n", 74 | "
Definition 1
\n", 75 | "
Term 2 with inline markup
\n", 76 | "
Definition 2
\n", 77 | "
\n", 78 | ), 79 | no_roundtrip, 80 | ); 81 | 82 | html_opts!( 83 | [extension.description_lists], 84 | concat!( 85 | "* Nested\n", 86 | "\n", 87 | " Term 1\n", 88 | " : Definition 1\n\n", 89 | " Term 2 with *inline markup*\n", 90 | " : Definition 2\n\n" 91 | ), 92 | concat!( 93 | "
    \n", 94 | "
  • \n", 95 | "

    Nested

    \n", 96 | "
    \n", 97 | "
    Term 1
    \n", 98 | "
    Definition 1
    \n", 99 | "
    Term 2 with inline markup
    \n", 100 | "
    Definition 2
    \n", 101 | "
    \n", 102 | "
  • \n", 103 | "
\n", 104 | ), 105 | no_roundtrip, 106 | ); 107 | } 108 | #[test] 109 | fn description_lists_edge_cases() { 110 | html_opts!( 111 | [extension.description_lists], 112 | concat!(":"), 113 | concat!("

:

\n"), 114 | ); 115 | 116 | html_opts!( 117 | [extension.description_lists], 118 | concat!(": foo"), 119 | concat!("

: foo

\n"), 120 | ); 121 | 122 | html_opts!( 123 | [extension.description_lists], 124 | concat!("a\n:"), 125 | concat!("

a\n:

\n"), 126 | ); 127 | 128 | html_opts!( 129 | [extension.description_lists], 130 | concat!("- foo\n", "- : bar\n", " - baz\n",), 131 | concat!( 132 | "
    \n", 133 | "
  • foo
  • \n", 134 | "
  • : bar\n", 135 | "
      \n", 136 | "
    • baz
    • \n", 137 | "
    \n", 138 | "
  • \n", 139 | "
\n", 140 | ), 141 | ); 142 | } 143 | #[test] 144 | fn sourcepos() { 145 | // TODO There's plenty of work to do here still. The test currently represents 146 | // how things *are* -- see comments for what should be different. 147 | // See partner comment in crate::parser::Parser::parse_desc_list_details. 148 | assert_ast_match!( 149 | [extension.description_lists], 150 | "ta\n" 151 | "\n" 152 | ": da\n" 153 | "\n" 154 | "t*b*\n" 155 | "\n" 156 | ": d*b*\n" 157 | "\n" 158 | "tc\n" 159 | "\n" 160 | ": dc\n", 161 | (document (1:1-11:4) [ 162 | (description_list (1:1-11:4) [ 163 | (description_item (1:1-4:0) [ // (description_item (1:1-3:4) [ 164 | (description_term (3:1-3:0) [ // (description_term (1:1-1:2) [ 165 | (paragraph (1:1-1:2) [ 166 | (text (1:1-1:2) "ta") 167 | ]) 168 | ]) 169 | (description_details (3:1-4:0) [ // (description_details (3:1-3:4) [ 170 | (paragraph (3:3-3:4) [ 171 | (text (3:3-3:4) "da") 172 | ]) 173 | ]) 174 | ]) 175 | (description_item (5:1-8:0) [ // (description_item (5:1-7:6) [ 176 | (description_term (7:1-7:0) [ // (description_term (5:1-5:4) [ 177 | (paragraph (5:1-5:4) [ 178 | (text (5:1-5:1) "t") 179 | (emph (5:2-5:4) [ 180 | (text (5:3-5:3) "b") 181 | ]) 182 | ]) 183 | ]) 184 | (description_details (7:1-8:0) [ // (description_details (7:1-7:6) [ 185 | (paragraph (7:3-7:6) [ 186 | (text (7:3-7:3) "d") 187 | (emph (7:4-7:6) [ 188 | (text (7:5-7:5) "b") 189 | ]) 190 | ]) 191 | ]) 192 | ]) 193 | (description_item (9:1-11:4) [ 194 | (description_term (11:1-11:0) [ // (description_term (9:1-11:4) [ 195 | (paragraph (9:1-9:2) [ 196 | (text (9:1-9:2) "tc") 197 | ]) 198 | ]) 199 | (description_details (11:1-11:4) [ 200 | (paragraph (11:3-11:4) [ 201 | (text (11:3-11:4) "dc") 202 | ]) 203 | ]) 204 | ]) 205 | ]) 206 | ]) 207 | ); 208 | } 209 | -------------------------------------------------------------------------------- /src/tests/empty.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | #[test] 4 | fn no_empty_link() { 5 | html_opts!( 6 | [render.ignore_empty_links], 7 | "[](https://example.com/evil-link-for-seo-spam)", 8 | "

[](https://example.com/evil-link-for-seo-spam)

\n", 9 | ); 10 | 11 | html_opts!( 12 | [render.ignore_empty_links], 13 | "[ ](https://example.com/evil-link-for-seo-spam)", 14 | "

[ ](https://example.com/evil-link-for-seo-spam)

\n", 15 | ); 16 | } 17 | 18 | #[test] 19 | fn empty_image_allowed() { 20 | html_opts!( 21 | [render.ignore_empty_links], 22 | "![ ](https://example.com/evil-link-for-seo-spam)", 23 | "

\"

\n", 24 | ); 25 | } 26 | 27 | #[test] 28 | fn image_inside_link_allowed() { 29 | html_opts!( 30 | [render.ignore_empty_links], 31 | "[![](https://example.com/image.png)](https://example.com/)", 32 | "

\"\"

\n", 33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /src/tests/escaped_char_spans.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | use ntest::test_case; 3 | 4 | #[test_case("\\@user", "

@user

\n")] 5 | #[test_case("This\\@that", "

This@that

\n")] 6 | fn escaped_char_spans(markdown: &str, html: &str) { 7 | html_opts!([render.escaped_char_spans], markdown, html, no_roundtrip); 8 | } 9 | 10 | #[test_case("\\@user", "

@user

\n")] 11 | #[test_case("This\\@that", "

This@that

\n")] 12 | fn disabled_escaped_char_spans(markdown: &str, expected: &str) { 13 | html(markdown, expected); 14 | } 15 | -------------------------------------------------------------------------------- /src/tests/fixtures/alerts.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Alerts 3 | based_on: https://github.com/jgm/commonmark-hs/blob/master/commonmark-extensions/test/alerts.md 4 | --- 5 | 6 | ## Alerts 7 | 8 | GitHub style alerts look like this: 9 | 10 | ```````````````````````````````` example 11 | > [!NOTE] 12 | > Highlights information that users should take into account, even when skimming. 13 | . 14 |
15 |

Note

16 |

Highlights information that users should take into account, even when skimming.

17 |
18 | ```````````````````````````````` 19 | 20 | It shouldn't matter if there's a soft break or hard break after 21 | the `[!NOTE]`: 22 | 23 | ```````````````````````````````` example 24 | > [!NOTE] 25 | > Highlights information that users should take into account, even when skimming. 26 | . 27 |
28 |

Note

29 |

Highlights information that users should take into account, even when skimming.

30 |
31 | ```````````````````````````````` 32 | 33 | Uppercase isn't required: 34 | 35 | ```````````````````````````````` example 36 | > [!note] 37 | > Highlights information that users should take into account, even when skimming. 38 | . 39 |
40 |

Note

41 |

Highlights information that users should take into account, even when skimming.

42 |
43 | ```````````````````````````````` 44 | 45 | 46 | Alerts can contain multiple blocks: 47 | 48 | ```````````````````````````````` example 49 | > [!NOTE] 50 | > Highlights information that users should take into account, even when skimming. 51 | > 52 | > Paragraph two. 53 | . 54 |
55 |

Note

56 |

Highlights information that users should take into account, even when skimming.

57 |

Paragraph two.

58 |
59 | ```````````````````````````````` 60 | 61 | Other kinds of alerts: 62 | 63 | ```````````````````````````````` example 64 | > [!TIP] 65 | > Optional information to help a user be more successful. 66 | . 67 |
68 |

Tip

69 |

Optional information to help a user be more successful.

70 |
71 | ```````````````````````````````` 72 | 73 | ```````````````````````````````` example 74 | > [!IMPORTANT] 75 | > Crucial information necessary for users to succeed. 76 | . 77 |
78 |

Important

79 |

Crucial information necessary for users to succeed.

80 |
81 | ```````````````````````````````` 82 | 83 | ```````````````````````````````` example 84 | > [!WARNING] 85 | > Critical content demanding immediate user attention due to potential risks. 86 | . 87 |
88 |

Warning

89 |

Critical content demanding immediate user attention due to potential risks.

90 |
91 | ```````````````````````````````` 92 | 93 | ```````````````````````````````` example 94 | > [!CAUTION] 95 | > Negative potential consequences of an action. 96 | . 97 |
98 |

Caution

99 |

Negative potential consequences of an action.

100 |
101 | ```````````````````````````````` 102 | 103 | A title can be specified to override the default title: 104 | 105 | ```````````````````````````````` example 106 | > [!NOTE] Pay attention 107 | > Highlights information that users should take into account, even when skimming. 108 | . 109 |
110 |

Pay attention

111 |

Highlights information that users should take into account, even when skimming.

112 |
113 | ```````````````````````````````` 114 | 115 | The title does not process markdown and is escaped: 116 | 117 | ```````````````````````````````` example 118 | > [!NOTE] **Pay** attention