├── .gitignore ├── packages ├── use-mdbook │ ├── .gitignore │ ├── src │ │ └── lib.rs │ └── Cargo.toml ├── mdbook-gen-example │ ├── example-book │ │ ├── .gitignore │ │ ├── assets │ │ │ ├── some.css │ │ │ ├── logo.png │ │ │ ├── logo1.png │ │ │ └── logo2.png │ │ ├── SUMMARY.md │ │ ├── book.toml │ │ └── en │ │ │ ├── chapter_3.md │ │ │ ├── chapter_1.md │ │ │ └── chapter_2.md │ ├── src │ │ ├── included_example.rs │ │ └── lib.rs │ ├── Cargo.toml │ └── build.rs ├── mdbook-macro │ ├── .gitignore │ ├── README.md │ ├── src │ │ └── lib.rs │ └── Cargo.toml ├── mdbook-shared │ ├── .gitignore │ ├── src │ │ ├── errors.rs │ │ ├── lib.rs │ │ ├── query.rs │ │ ├── book.rs │ │ ├── summary.rs │ │ └── config.rs │ └── Cargo.toml ├── syntect-html │ ├── .gitignore │ ├── Cargo.toml │ ├── README.md │ └── src │ │ └── lib.rs └── mdbook-gen │ ├── Cargo.toml │ ├── src │ ├── transform_book.rs │ ├── lib.rs │ └── rsx.rs │ └── themes │ └── MonokaiDark.thTheme ├── .github ├── dependabot.yml ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── feature_requst.md │ └── bug_report.md └── workflows │ ├── macos.yml │ ├── windows.yml │ └── main.yml ├── Cargo.toml └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /Cargo.lock 3 | .DS_Store 4 | -------------------------------------------------------------------------------- /packages/use-mdbook/.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /Cargo.lock 3 | -------------------------------------------------------------------------------- /packages/mdbook-gen-example/example-book/.gitignore: -------------------------------------------------------------------------------- 1 | book 2 | -------------------------------------------------------------------------------- /packages/mdbook-macro/.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /Cargo.lock 3 | -------------------------------------------------------------------------------- /packages/mdbook-shared/.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /Cargo.lock 3 | -------------------------------------------------------------------------------- /packages/syntect-html/.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /Cargo.lock 3 | -------------------------------------------------------------------------------- /packages/mdbook-gen-example/src/included_example.rs: -------------------------------------------------------------------------------- 1 | fn it_works() {} 2 | -------------------------------------------------------------------------------- /packages/mdbook-gen-example/example-book/assets/some.css: -------------------------------------------------------------------------------- 1 | .demo { 2 | color: blue; 3 | } 4 | -------------------------------------------------------------------------------- /packages/mdbook-shared/src/errors.rs: -------------------------------------------------------------------------------- 1 | pub(crate) use anyhow::bail; 2 | pub use anyhow::{Error, Result}; 3 | -------------------------------------------------------------------------------- /packages/use-mdbook/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub use mdbook_shared; 2 | 3 | pub use mdbook_macro::*; 4 | pub use once_cell::sync::Lazy; 5 | pub use yazi; 6 | -------------------------------------------------------------------------------- /packages/mdbook-shared/src/lib.rs: -------------------------------------------------------------------------------- 1 | mod summary; 2 | pub use summary::*; 3 | 4 | pub mod query; 5 | pub use query::*; 6 | 7 | pub mod errors; 8 | pub use errors::*; 9 | -------------------------------------------------------------------------------- /packages/mdbook-gen-example/example-book/SUMMARY.md: -------------------------------------------------------------------------------- 1 | # Summary 2 | 3 | - [Chapter 1](./chapter_1.md) 4 | - [Chapter 2](./chapter_2.md) 5 | - [Chapter 3](./chapter_3.md) 6 | -------------------------------------------------------------------------------- /packages/mdbook-gen-example/example-book/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DioxusLabs/include_mdbook/main/packages/mdbook-gen-example/example-book/assets/logo.png -------------------------------------------------------------------------------- /packages/mdbook-gen-example/example-book/assets/logo1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DioxusLabs/include_mdbook/main/packages/mdbook-gen-example/example-book/assets/logo1.png -------------------------------------------------------------------------------- /packages/mdbook-gen-example/example-book/assets/logo2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DioxusLabs/include_mdbook/main/packages/mdbook-gen-example/example-book/assets/logo2.png -------------------------------------------------------------------------------- /packages/mdbook-gen-example/example-book/book.toml: -------------------------------------------------------------------------------- 1 | [book] 2 | authors = ["Jonathan Kelley"] 3 | language = "en" 4 | src = "en" 5 | title = "example-book" 6 | 7 | [language.en] 8 | name = "English" 9 | -------------------------------------------------------------------------------- /packages/mdbook-gen-example/src/lib.rs: -------------------------------------------------------------------------------- 1 | use dioxus::prelude::*; 2 | 3 | mod router; 4 | 5 | #[component] 6 | pub fn CodeBlock(contents: String, name: Option) -> Element { 7 | todo!() 8 | } 9 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # Maintain dependencies for GitHub Actions 4 | - package-ecosystem: "github-actions" 5 | directory: "/" 6 | schedule: 7 | interval: "weekly" 8 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: DioxusLabs # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | open_collective: dioxus-labs # Replace with a single Open Collective username 5 | -------------------------------------------------------------------------------- /packages/mdbook-gen-example/example-book/en/chapter_3.md: -------------------------------------------------------------------------------- 1 | # Assets 2 | 3 | Some assets: 4 | ![some_external](https://avatars.githubusercontent.com/u/79236386?s=200&v=4) 5 | ![some_local](/example-book/assets/logo.png) 6 | ![some_local1](/example-book/assets/logo1.png) 7 | ![some_local2](/example-book/assets/logo2.png) 8 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_requst.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature Request 3 | about: If you have any interesting advice, you can tell us. 4 | --- 5 | 6 | ## Specific Demand 7 | 8 | 11 | 12 | ## Implement Suggestion 13 | 14 | -------------------------------------------------------------------------------- /packages/syntect-html/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "syntect-html" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 7 | 8 | [lib] 9 | proc-macro = true 10 | 11 | [dependencies] 12 | proc-macro2 = { version = "1.0" } 13 | quote = "1.0" 14 | syn = "1.0.109" 15 | syntect = "5.0" 16 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | resolver = "2" 3 | members = [ 4 | "packages/mdbook-macro", 5 | "packages/use-mdbook", 6 | "packages/mdbook-shared", 7 | "packages/syntect-html", 8 | "packages/mdbook-gen", 9 | "packages/mdbook-gen-example", 10 | ] 11 | 12 | 13 | [workspace.dependencies] 14 | mdbook-gen = { path = "packages/mdbook-gen" } 15 | use-mdbook = { path = "packages/use-mdbook" } 16 | 17 | -------------------------------------------------------------------------------- /packages/mdbook-macro/README.md: -------------------------------------------------------------------------------- 1 | # mdbook-macro 2 | 3 | Compiles mdbooks with proc-macros and exposes their internals with TYPE SAFETY. 4 | 5 | Whaaaaaaaat??? 6 | 7 | Crazy. 8 | 9 | ```rust 10 | mdbook_router! {"../example-book"} 11 | ``` 12 | 13 | Integrates with the dioxus-router to create a router from a mdbook. 14 | 15 | 16 | ## Todo: 17 | 18 | - Incrementally recompile the mdbook as its contents change 19 | - Integrate with the `hot-reload` crate to allow live-editable dioxus websites that include mdbooks. 20 | -------------------------------------------------------------------------------- /packages/mdbook-gen-example/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "mdbook-gen-example" 3 | version = "0.0.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | serde = {version = "1.0.163", features = ["derive"]} 8 | use-mdbook = { workspace = true } 9 | dioxus = { version = "0.6.2", features = ["router"] } 10 | 11 | [build-dependencies] 12 | mdbook-gen = { path = "../mdbook-gen", features = ["manganis"] } 13 | mdbook-shared = { path = "../mdbook-shared" } 14 | dioxus-autofmt = { version = "0.6.0" } 15 | prettyplease = "0.2.20" 16 | syn = "2.0" 17 | -------------------------------------------------------------------------------- /packages/syntect-html/README.md: -------------------------------------------------------------------------------- 1 | # mdbook-macro 2 | 3 | Compiles mdbooks with proc-macros and exposes their internals with TYPE SAFETY. 4 | 5 | Whaaaaaaaat??? 6 | 7 | Crazy. 8 | 9 | ```rust 10 | static DOCS: MdBook = include_mdbook!("docs"); 11 | 12 | dbg!(DOCS.summary); 13 | ``` 14 | 15 | Will incrementally recompile the mdbook as its contents change. 16 | 17 | Integrates with the `use_mdbook` crate to allow live-editable dioxus websites that include mdbooks. 18 | 19 | 20 | ## Todo: 21 | 22 | - use static defs instead of serde on the boundary 23 | -------------------------------------------------------------------------------- /packages/mdbook-shared/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "mdbook-shared" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 7 | 8 | [dependencies] 9 | anyhow = "1.0.71" 10 | log = "0.4.18" 11 | memchr = "2.5.0" 12 | pulldown-cmark = "0.9.3" 13 | serde = { version = "1.0.163", features = ["derive"] } 14 | serde_json = "1.0.96" 15 | toml = "0.7.4" 16 | bytes = { version = "1.3.0", features = ["serde"] } 17 | slab = "0.4.8" 18 | 19 | [target.'cfg(arch = "wasm32")'.dependencies] 20 | getrandom = { version = "0.2", features = ["js"] } 21 | 22 | [features] 23 | -------------------------------------------------------------------------------- /packages/mdbook-gen-example/build.rs: -------------------------------------------------------------------------------- 1 | use std::{env::current_dir, path::PathBuf}; 2 | 3 | fn main() { 4 | // re-run only if the "example-book" directory changes 5 | println!("cargo:rerun-if-changed=example-book"); 6 | 7 | let manifest_dir = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()); 8 | let mdbook_dir = manifest_dir.join("./example-book").canonicalize().unwrap(); 9 | let out_dir = current_dir().unwrap().join("src"); 10 | 11 | let mut out = mdbook_gen::generate_router_build_script(mdbook_dir); 12 | out.push_str("\n"); 13 | out.push_str("use super::*;\n"); 14 | 15 | std::fs::write(out_dir.join("router.rs"), out).unwrap(); 16 | } 17 | -------------------------------------------------------------------------------- /packages/mdbook-macro/src/lib.rs: -------------------------------------------------------------------------------- 1 | use proc_macro::TokenStream; 2 | use quote::quote; 3 | use quote::ToTokens; 4 | use syn::LitStr; 5 | 6 | use mdbook_gen::*; 7 | 8 | #[proc_macro] 9 | pub fn mdbook_router(input: TokenStream) -> TokenStream { 10 | match syn::parse::(input).map(load_book_from_fs) { 11 | Ok(Ok((path, book))) => generate_router(path, book).into(), 12 | Ok(Err(err)) => write_book_err(err), 13 | Err(err) => err.to_compile_error().into(), 14 | } 15 | } 16 | 17 | fn write_book_err(err: anyhow::Error) -> TokenStream { 18 | let err = err.to_string(); 19 | println!("{}", err); 20 | quote! { compile_error!(#err); }.to_token_stream().into() 21 | } 22 | -------------------------------------------------------------------------------- /packages/mdbook-macro/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "mdbook-macro" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 7 | 8 | [lib] 9 | proc-macro = true 10 | 11 | [dependencies] 12 | proc-macro2 = { version = "1.0" } 13 | quote = "1.0" 14 | syn = "2.0" 15 | mdbook-shared = { path = "../mdbook-shared" } 16 | anyhow = "1.0.71" 17 | serde = { version = "1.0.163", features = ["derive"] } 18 | serde_json = "1.0.96" 19 | macro_state = "0.2.0" 20 | convert_case = "0.6.0" 21 | postcard = { version = "1.0.4", features = ["use-std"] } 22 | pulldown-cmark = "0.9.3" 23 | syntect = "5.0" 24 | dioxus-rsx = { version = "0.6.0" } 25 | mdbook-gen = { workspace = true } 26 | 27 | [features] 28 | manganis = [] 29 | -------------------------------------------------------------------------------- /packages/use-mdbook/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "use-mdbook" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 7 | 8 | [dependencies] 9 | lazy_static = "1.4.0" 10 | mdbook-macro = { path = "../mdbook-macro" } 11 | mdbook-shared = { path = "../mdbook-shared" } 12 | once_cell = "1.17.2" 13 | postcard = { version = "1.0.4", features = ["use-std"] } 14 | serde = { version = "1.0.163", features = ["derive"] } 15 | serde_json = "1.0.96" 16 | yazi = "0.1.5" 17 | 18 | [dev-dependencies] 19 | dioxus = { version = "0.6.0" } 20 | dioxus-router = { version = "0.6.0" } 21 | tokio = { version = "*", features = ["full"] } 22 | 23 | [features] 24 | default = ["manganis"] 25 | manganis = ["mdbook-macro/manganis"] 26 | -------------------------------------------------------------------------------- /packages/mdbook-gen/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "mdbook-gen" 3 | version = "0.0.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | proc-macro2 = { version = "1.0" } 8 | quote = "1.0" 9 | syn = "2.0" 10 | mdbook-shared = { path = "../mdbook-shared" } 11 | anyhow = "1.0.71" 12 | serde = { version = "1.0.163", features = ["derive"] } 13 | serde_json = "1.0.96" 14 | macro_state = "0.2.0" 15 | convert_case = "0.6.0" 16 | postcard = { version = "1.0.4", features = ["use-std"] } 17 | pulldown-cmark = "0.9.3" 18 | syntect = { version = "5.2.0", features = ["plist-load"] } 19 | dioxus-rsx = { version = "0.6.0" } 20 | dioxus-autofmt = { version = "0.6.0" } 21 | prettyplease = "0.2.20" 22 | once_cell = "1.20.2" 23 | 24 | [dev-dependencies] 25 | pretty_assertions = "1.3.0" 26 | 27 | [features] 28 | default = ["manganis"] 29 | manganis = [] 30 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve Include MD Book 4 | --- 5 | 6 | **Problem** 7 | 8 | 9 | 10 | **Steps To Reproduce** 11 | 12 | Steps to reproduce the behavior: 13 | 14 | - 15 | - 16 | - **Expected behavior** 17 | 18 | A clear and concise description of what you expected to happen. 19 | 20 | **Screenshots** 21 | 22 | If applicable, add screenshots to help explain your problem. 23 | 24 | **Environment:** 25 | 26 | - Renderer version: [`Dioxus master`, `Dioxus 0.3.0`] 27 | - Rust version: [e.g. 1.43.0, `nightly`] 28 | - OS info: [e.g. MacOS] 29 | 30 | **Questionnaire** 31 | 32 | 33 | 34 | - [ ] I'm interested in fixing this myself but don't know where to start 35 | - [ ] I would like to fix and I have a solution 36 | - [ ] I don't have time to fix this right now, but maybe later 37 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # this crate has moved! 2 | 3 | https://github.com/DioxusLabs/docsite/tree/main/packages/include_mdbook 4 | 5 | 6 | # `use_mdbook` - hooks and components for loading mdbooks with Dioxus 7 | 8 | This crate provides the `use_mdbook` hook and `include_mdbook` macro that allows access to the contents of MdBooks at compile time. 9 | 10 | This crate will integrate with a future Dioxus Assets system that allows image bundling outside the final output binary. 11 | 12 | The point of this project is to power the Dioxus MdBook component ecosystem which enables any Dioxus app to easily include and render an MdBook. 13 | 14 | Planned features for this crate: 15 | - MdBook components (search, navbars, renderers) 16 | - Hotreloading for mdbooks 17 | - Devtool integration for live mdbook editing 18 | 19 | 20 | ## Todo: 21 | 22 | - incremental processing with invalidation 23 | - search manifest generation 24 | - integration with dioxus bundle 25 | - extract all logic to a generic asset system 26 | - write mdbook as static str and not require json bouncing 27 | - investigate compile time performance impacts 28 | -------------------------------------------------------------------------------- /.github/workflows/macos.yml: -------------------------------------------------------------------------------- 1 | name: macOS tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | paths: 8 | - mdbook-macro/src/** 9 | - mdbook-macro/Cargo.toml 10 | - mdbook-shared/src/** 11 | - mdbook-shared/Cargo.toml 12 | - syntect-html/src/** 13 | - syntect-html/Cargo.toml 14 | - use-mdbook/src/** 15 | - use-mdbook/Cargo.toml 16 | - example-book/** 17 | - .github/** 18 | - Cargo.toml 19 | 20 | pull_request: 21 | types: [opened, synchronize, reopened, ready_for_review] 22 | branches: 23 | - master 24 | paths: 25 | - mdbook-macro/src/** 26 | - mdbook-macro/Cargo.toml 27 | - mdbook-shared/src/** 28 | - mdbook-shared/Cargo.toml 29 | - syntect-html/src/** 30 | - syntect-html/Cargo.toml 31 | - use-mdbook/src/** 32 | - use-mdbook/Cargo.toml 33 | - example-book/** 34 | - .github/** 35 | - Cargo.toml 36 | 37 | jobs: 38 | test: 39 | if: github.event.pull_request.draft == false 40 | name: Test Suite 41 | runs-on: macos-latest 42 | steps: 43 | - uses: dtolnay/rust-toolchain@stable 44 | - uses: Swatinem/rust-cache@v2 45 | - uses: actions/checkout@v4 46 | - run: cargo test --all --tests 47 | -------------------------------------------------------------------------------- /packages/mdbook-gen-example/example-book/en/chapter_1.md: -------------------------------------------------------------------------------- 1 | # Liveview 2 | 3 | Liveview allows apps to *run* on the server and *render* in the browser. It uses WebSockets to communicate between the server and the browser. 4 | 5 | Examples: 6 | - [Axum Example](https://github.com/DioxusLabs/dioxus/tree/master/packages/liveview/examples/axum.rs) 7 | - [Salvo Example](https://github.com/DioxusLabs/dioxus/tree/master/packages/liveview/examples/salvo.rs) 8 | - [Warp Example](https://github.com/DioxusLabs/dioxus/tree/master/packages/liveview/examples/warp.rs) 9 | 10 | 11 | ## Support 12 | 13 | Liveview is currently limited in capability when compared to the Web platform. Liveview apps run on the server in a native thread. This means that browser APIs are not available, so rendering WebGL, Canvas, etc is not as easy as the Web. However, native system APIs are accessible, so streaming, WebSockets, filesystem, etc are all viable APIs. 14 | 15 | 16 | ## Setup 17 | 18 | For this guide, we're going to show how to use Dioxus Liveview with [Axum](https://docs.rs/axum/latest/axum/). 19 | 20 | Make sure you have Rust and Cargo installed, and then create a new project: 21 | 22 | ```shell 23 | cargo new --bin demo 24 | cd app 25 | ``` 26 | 27 | Add Dioxus and the liveview renderer with the Axum feature as dependencies: 28 | 29 | ```shell 30 | cargo add dioxus 31 | cargo add dioxus-liveview --features axum 32 | ``` 33 | 34 | Next, add all the Axum dependencies. This will be different if you're using a different Web Framework 35 | 36 | ``` 37 | cargo add tokio --features full 38 | cargo add axum 39 | ``` 40 | 41 | Your dependencies should look roughly like this: 42 | 43 | ```toml 44 | [dependencies] 45 | axum = "0.4.5" 46 | dioxus = { version = "*" } 47 | dioxus-liveview = { version = "*", features = ["axum"] } 48 | tokio = { version = "1.15.0", features = ["full"] } 49 | ``` 50 | 51 | ```rust 52 | {{#include src/included_example.rs}} 53 | ``` 54 | 55 | ```sh 56 | {"timestamp":" 9.927s","level":"INFO","message":"Bundled app successfully!","target":"dx::cli::bundle"} 57 | {"timestamp":" 9.927s","level":"INFO","message":"App produced 2 outputs:","target":"dx::cli::bundle"} 58 | {"timestamp":" 9.927s","level":"INFO","message":"app - [target/dx/hot_dog/bundle/macos/bundle/macos/HotDog.app]","target":"dx::cli::bundle"} 59 | {"timestamp":" 9.927s","level":"INFO","message":"dmg - [target/dx/hot_dog/bundle/macos/bundle/dmg/HotDog_0.1.0_aarch64.dmg]","target":"dx::cli::bundle"} 60 | {"timestamp":" 9.927s","level":"DEBUG","json":"{\"BundleOutput\":{\"bundles\":[\"target/dx/hot_dog/bundle/macos/bundle/macos/HotDog.app\"]}}"} 61 | ``` 62 | -------------------------------------------------------------------------------- /.github/workflows/windows.yml: -------------------------------------------------------------------------------- 1 | name: windows 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | paths: 8 | - mdbook-macro/src/** 9 | - mdbook-macro/Cargo.toml 10 | - mdbook-shared/src/** 11 | - mdbook-shared/Cargo.toml 12 | - syntect-html/src/** 13 | - syntect-html/Cargo.toml 14 | - use-mdbook/src/** 15 | - use-mdbook/Cargo.toml 16 | - example-book/** 17 | - .github/** 18 | - Cargo.toml 19 | 20 | pull_request: 21 | types: [opened, synchronize, reopened, ready_for_review] 22 | branches: 23 | - master 24 | paths: 25 | - mdbook-macro/src/** 26 | - mdbook-macro/Cargo.toml 27 | - mdbook-shared/src/** 28 | - mdbook-shared/Cargo.toml 29 | - syntect-html/src/** 30 | - syntect-html/Cargo.toml 31 | - use-mdbook/src/** 32 | - use-mdbook/Cargo.toml 33 | - example-book/** 34 | - .github/** 35 | - Cargo.toml 36 | 37 | jobs: 38 | test: 39 | if: github.event.pull_request.draft == false 40 | runs-on: windows-latest 41 | name: (${{ matrix.target }}, ${{ matrix.cfg_release_channel }}) 42 | env: 43 | CFG_RELEASE_CHANNEL: ${{ matrix.cfg_release_channel }} 44 | strategy: 45 | # https://help.github.com/en/actions/getting-started-with-github-actions/about-github-actions#usage-limits 46 | # There's a limit of 60 concurrent jobs across all repos in the rust-lang organization. 47 | # In order to prevent overusing too much of that 60 limit, we throttle the 48 | # number of rustfmt jobs that will run concurrently. 49 | # max-parallel: 50 | # fail-fast: false 51 | matrix: 52 | target: [x86_64-pc-windows-gnu, x86_64-pc-windows-msvc] 53 | cfg_release_channel: [stable] 54 | 55 | steps: 56 | # The Windows runners have autocrlf enabled by default 57 | # which causes failures for some of rustfmt's line-ending sensitive tests 58 | - name: disable git eol translation 59 | run: git config --global core.autocrlf false 60 | 61 | # Run build 62 | - name: Install Rustup using win.rustup.rs 63 | run: | 64 | # Disable the download progress bar which can cause perf issues 65 | $ProgressPreference = "SilentlyContinue" 66 | Invoke-WebRequest https://win.rustup.rs/ -OutFile rustup-init.exe 67 | .\rustup-init.exe -y --default-host=x86_64-pc-windows-msvc --default-toolchain=none 68 | del rustup-init.exe 69 | rustup target add ${{ matrix.target }} 70 | shell: powershell 71 | 72 | - name: Add mingw64 to path for x86_64-gnu 73 | run: echo "C:\msys64\mingw64\bin" >> $GITHUB_PATH 74 | if: matrix.target == 'x86_64-pc-windows-gnu' && matrix.channel == 'nightly' 75 | shell: bash 76 | 77 | # - name: checkout 78 | # uses: actions/checkout@v3 79 | # with: 80 | # path: C:/dioxus.git 81 | # fetch-depth: 1 82 | 83 | # we need to use the C drive as the working directory 84 | 85 | - name: Checkout 86 | run: | 87 | mkdir C:/dioxus.git 88 | git clone https://github.com/dioxuslabs/dioxus.git C:/dioxus.git --depth 1 89 | 90 | - name: test 91 | working-directory: C:/dioxus.git 92 | run: | 93 | rustc -Vv 94 | cargo -V 95 | set RUST_BACKTRACE=1 96 | cargo build --all --tests --examples 97 | cargo test --all --tests 98 | shell: cmd 99 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Rust CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | paths: 8 | - mdbook-macro/src/** 9 | - mdbook-macro/Cargo.toml 10 | - mdbook-shared/src/** 11 | - mdbook-shared/Cargo.toml 12 | - syntect-html/src/** 13 | - syntect-html/Cargo.toml 14 | - use-mdbook/src/** 15 | - use-mdbook/Cargo.toml 16 | - example-book/** 17 | - .github/** 18 | - Cargo.toml 19 | 20 | pull_request: 21 | types: [opened, synchronize, reopened, ready_for_review] 22 | branches: 23 | - master 24 | paths: 25 | - mdbook-macro/src/** 26 | - mdbook-macro/Cargo.toml 27 | - mdbook-shared/src/** 28 | - mdbook-shared/Cargo.toml 29 | - syntect-html/src/** 30 | - syntect-html/Cargo.toml 31 | - use-mdbook/src/** 32 | - use-mdbook/Cargo.toml 33 | - example-book/** 34 | - .github/** 35 | - Cargo.toml 36 | 37 | jobs: 38 | check: 39 | if: github.event.pull_request.draft == false 40 | name: Check 41 | runs-on: ubuntu-latest 42 | steps: 43 | - uses: dtolnay/rust-toolchain@stable 44 | - uses: Swatinem/rust-cache@v2 45 | - run: sudo apt-get update 46 | - run: sudo apt install libwebkit2gtk-4.1-dev libgtk-3-dev 47 | - uses: actions/checkout@v4 48 | - run: cargo check --all --examples --tests 49 | 50 | test: 51 | if: github.event.pull_request.draft == false 52 | name: Test Suite 53 | runs-on: ubuntu-latest 54 | steps: 55 | - uses: dtolnay/rust-toolchain@stable 56 | - uses: Swatinem/rust-cache@v2 57 | - run: sudo apt-get update 58 | - run: sudo apt install libwebkit2gtk-4.1-dev libgtk-3-dev 59 | - uses: actions/checkout@v4 60 | - run: cargo test --lib --bins --tests --examples --workspace 61 | 62 | fmt: 63 | if: github.event.pull_request.draft == false 64 | name: Rustfmt 65 | runs-on: ubuntu-latest 66 | steps: 67 | - uses: dtolnay/rust-toolchain@stable 68 | - uses: Swatinem/rust-cache@v2 69 | - run: rustup component add rustfmt 70 | - uses: actions/checkout@v4 71 | - run: cargo fmt --all -- --check 72 | 73 | clippy: 74 | if: github.event.pull_request.draft == false 75 | name: Clippy 76 | runs-on: ubuntu-latest 77 | steps: 78 | - uses: dtolnay/rust-toolchain@stable 79 | - uses: Swatinem/rust-cache@v2 80 | - run: sudo apt-get update 81 | - run: sudo apt install libwebkit2gtk-4.1-dev libgtk-3-dev 82 | - run: rustup component add clippy 83 | - uses: actions/checkout@v4 84 | - run: cargo clippy --workspace --examples --tests -- -D warnings 85 | 86 | # Coverage is disabled until we can fix it 87 | # coverage: 88 | # name: Coverage 89 | # runs-on: ubuntu-latest 90 | # container: 91 | # image: xd009642/tarpaulin:develop-nightly 92 | # options: --security-opt seccomp=unconfined 93 | # steps: 94 | # - name: Checkout repository 95 | # uses: actions/checkout@v4 96 | # - name: Generate code coverage 97 | # run: | 98 | # apt-get update &&\ 99 | # apt-get install build-essential &&\ 100 | # apt install libwebkit2gtk-4.0-dev libgtk-3-dev libayatana-appindicator3-dev -y &&\ 101 | # cargo +nightly tarpaulin --verbose --all-features --workspace --timeout 120 --out Xml 102 | # - name: Upload to codecov.io 103 | # uses: codecov/codecov-action@v2 104 | # with: 105 | # fail_ci_if_error: false 106 | -------------------------------------------------------------------------------- /packages/syntect-html/src/lib.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use proc_macro::TokenStream; 4 | use proc_macro2::Span; 5 | use quote::quote; 6 | use quote::ToTokens; 7 | use syn::parse::Parse; 8 | use syn::parse::ParseStream; 9 | use syn::LitStr; 10 | use syn::Token; 11 | use syntect::highlighting::ThemeSet; 12 | use syntect::parsing::SyntaxSet; 13 | 14 | struct CodeBlock { 15 | html: String, 16 | } 17 | 18 | impl Parse for CodeBlock { 19 | fn parse(input: ParseStream) -> syn::Result { 20 | let code = input.parse::()?; 21 | let _ = input.parse::(); 22 | let extension = input 23 | .parse::() 24 | .unwrap_or_else(|_| LitStr::new("rs", Span::call_site())); 25 | let _ = input.parse::(); 26 | let theme = input 27 | .parse::() 28 | .unwrap_or_else(|_| LitStr::new("base16-ocean.dark", Span::call_site())); 29 | 30 | Self::new(code.value(), extension.value(), theme.value()) 31 | } 32 | } 33 | 34 | impl CodeBlock { 35 | fn new(code: String, extension: String, theme: String) -> syn::Result { 36 | let ss = SyntaxSet::load_defaults_newlines(); 37 | let ts = ThemeSet::load_defaults(); 38 | 39 | let maybe_theme = ts.themes.get(&theme); 40 | let theme = maybe_theme.ok_or_else(|| { 41 | syn::Error::new(Span::call_site(), format!("No theme found for {}", theme)) 42 | })?; 43 | let syntax = ss.find_syntax_by_extension(&extension).ok_or_else(|| { 44 | syn::Error::new( 45 | Span::call_site(), 46 | format!("No syntax found for extension {}", extension), 47 | ) 48 | })?; 49 | let html = syntect::html::highlighted_html_for_string(&code, &ss, syntax, theme).map_err( 50 | |err| { 51 | syn::Error::new( 52 | Span::call_site(), 53 | format!("Error while generating HTML: {}", err), 54 | ) 55 | }, 56 | )?; 57 | Ok(CodeBlock { html }) 58 | } 59 | } 60 | 61 | impl ToTokens for CodeBlock { 62 | fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) { 63 | let html = &self.html; 64 | 65 | tokens.extend(quote! { 66 | #html 67 | }) 68 | } 69 | } 70 | 71 | struct CodeBlockFs { 72 | html: String, 73 | } 74 | 75 | impl Parse for CodeBlockFs { 76 | fn parse(input: ParseStream) -> syn::Result { 77 | let code_path = input.parse::()?; 78 | let code_path = code_path.value(); 79 | let _ = input.parse::(); 80 | let theme = input 81 | .parse::() 82 | .unwrap_or_else(|_| LitStr::new("base16-ocean.dark", Span::call_site())); 83 | let theme = theme.value(); 84 | 85 | let path = PathBuf::from( 86 | std::env::var("CARGO_MANIFEST_DIR") 87 | .map_err(|_| syn::Error::new(Span::call_site(), "CARGO_MANIFEST_DIR not found"))?, 88 | ); 89 | let path = path.join(code_path); 90 | let code = std::fs::read_to_string(&path).map_err(|err| { 91 | syn::Error::new( 92 | Span::call_site(), 93 | format!( 94 | "Error while reading file: {} while reading {}", 95 | err, 96 | path.display() 97 | ), 98 | ) 99 | })?; 100 | let extension = path.extension(); 101 | let extension = &*extension.unwrap().to_string_lossy(); 102 | let html = syntect::html::highlighted_html_for_string( 103 | &code, 104 | &syntect::parsing::SyntaxSet::load_defaults_newlines(), 105 | syntect::parsing::SyntaxSet::load_defaults_newlines() 106 | .find_syntax_by_extension(extension) 107 | .unwrap(), 108 | syntect::highlighting::ThemeSet::load_defaults() 109 | .themes 110 | .get(&theme) 111 | .unwrap(), 112 | ) 113 | .unwrap(); 114 | 115 | Ok(CodeBlockFs { html }) 116 | } 117 | } 118 | 119 | /// Generate a HTML string from a code block path. 120 | #[proc_macro] 121 | pub fn syntect_html_fs(input: TokenStream) -> TokenStream { 122 | match syn::parse::(input) { 123 | Ok(block) => { 124 | let html = &block.html; 125 | quote! { 126 | #html 127 | } 128 | .into() 129 | } 130 | Err(err) => err.to_compile_error().into(), 131 | } 132 | } 133 | 134 | /// Generate a HTML string from a code block. 135 | /// 136 | /// # Example 137 | /// 138 | /// ```rust 139 | /// use syntect_html::syntect_html; 140 | /// 141 | /// let html = syntect_html! { 142 | /// r#" 143 | /// fn main() { 144 | /// println!("Hello, world!"); 145 | /// } 146 | /// "#, 147 | /// "rs", 148 | /// "base16-ocean.dark" 149 | /// }; 150 | #[proc_macro] 151 | pub fn syntect_html(input: TokenStream) -> TokenStream { 152 | match syn::parse::(input) { 153 | Ok(block) => quote! { 154 | #block 155 | } 156 | .into(), 157 | Err(err) => err.to_compile_error().into(), 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /packages/mdbook-gen/src/transform_book.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use mdbook_shared::MdBook; 4 | use mdbook_shared::Page; 5 | use mdbook_shared::Section; 6 | use mdbook_shared::Summary; 7 | use mdbook_shared::SummaryItem; 8 | use proc_macro2::TokenStream; 9 | use quote::quote; 10 | use quote::ToTokens; 11 | 12 | use crate::path_to_route_enum; 13 | 14 | /// Transforms the book to use enum routes instead of paths 15 | pub fn write_book_with_routes(book: &mdbook_shared::MdBook) -> TokenStream { 16 | let MdBook { summary, .. } = book; 17 | let summary = write_summary_with_routes(summary); 18 | let pages = book.pages().iter().map(|(id, v)| { 19 | let name = match path_to_route_enum(&v.url) { 20 | Ok(url) => url, 21 | Err(err) => { 22 | return err.to_token_stream(); 23 | } 24 | }; 25 | let page = write_page_with_routes(v); 26 | quote! { 27 | pages.push((#id, #page)); 28 | page_id_mapping.insert(#name, ::use_mdbook::mdbook_shared::PageId(#id)); 29 | } 30 | }); 31 | 32 | let out = quote! { 33 | { 34 | let mut page_id_mapping = ::std::collections::HashMap::new(); 35 | let mut pages = Vec::new(); 36 | #(#pages)* 37 | ::use_mdbook::mdbook_shared::MdBook { 38 | summary: #summary, 39 | pages: pages.into_iter().collect(), 40 | page_id_mapping, 41 | } 42 | } 43 | }; 44 | 45 | out.to_token_stream() 46 | } 47 | 48 | fn write_summary_with_routes(book: &mdbook_shared::Summary) -> TokenStream { 49 | let Summary { 50 | title, 51 | prefix_chapters, 52 | numbered_chapters, 53 | suffix_chapters, 54 | } = book; 55 | 56 | let prefix_chapters = prefix_chapters.iter().map(write_summary_item_with_routes); 57 | let numbered_chapters = numbered_chapters.iter().map(write_summary_item_with_routes); 58 | let suffix_chapters = suffix_chapters.iter().map(write_summary_item_with_routes); 59 | let title = match title { 60 | Some(title) => quote! { Some(#title.to_string()) }, 61 | None => quote! { None }, 62 | }; 63 | 64 | quote! { 65 | ::use_mdbook::mdbook_shared::Summary { 66 | title: #title, 67 | prefix_chapters: vec![#(#prefix_chapters),*], 68 | numbered_chapters: vec![#(#numbered_chapters),*], 69 | suffix_chapters: vec![#(#suffix_chapters),*], 70 | } 71 | } 72 | } 73 | 74 | fn write_summary_item_with_routes(item: &SummaryItem) -> TokenStream { 75 | match item { 76 | SummaryItem::Link(link) => { 77 | let link = write_link_with_routes(link); 78 | quote! { 79 | ::use_mdbook::mdbook_shared::SummaryItem::Link(#link) 80 | } 81 | } 82 | SummaryItem::Separator => { 83 | quote! { 84 | ::use_mdbook::mdbook_shared::SummaryItem::Separator 85 | } 86 | } 87 | SummaryItem::PartTitle(title) => { 88 | quote! { 89 | ::use_mdbook::mdbook_shared::SummaryItem::PartTitle(#title.to_string()) 90 | } 91 | } 92 | } 93 | } 94 | 95 | fn write_link_with_routes(book: &mdbook_shared::Link) -> TokenStream { 96 | let mdbook_shared::Link { 97 | name, 98 | location, 99 | number, 100 | nested_items, 101 | } = book; 102 | 103 | let location = match location { 104 | Some(loc) => { 105 | let inner = match path_to_route_enum(loc) { 106 | Ok(url) => url, 107 | Err(err) => { 108 | return err.to_token_stream(); 109 | } 110 | }; 111 | quote! { Some(#inner) } 112 | } 113 | None => quote! { None }, 114 | }; 115 | let number = match number { 116 | Some(number) => { 117 | let inner = write_number_with_routes(number); 118 | quote! { Some(#inner) } 119 | } 120 | None => quote! {None}, 121 | }; 122 | 123 | let nested_items = nested_items.iter().map(write_summary_item_with_routes); 124 | 125 | quote! { 126 | ::use_mdbook::mdbook_shared::Link { 127 | name: #name.to_string(), 128 | location: #location, 129 | number: #number, 130 | nested_items: vec![#(#nested_items,)*], 131 | } 132 | } 133 | } 134 | 135 | fn write_number_with_routes(number: &mdbook_shared::SectionNumber) -> TokenStream { 136 | let mdbook_shared::SectionNumber(number) = number; 137 | let numbers = number.iter().map(|num| { 138 | quote! { 139 | #num 140 | } 141 | }); 142 | 143 | quote! { 144 | ::use_mdbook::mdbook_shared::SectionNumber(vec![#(#numbers),*]) 145 | } 146 | } 147 | 148 | fn write_page_with_routes(book: &mdbook_shared::Page) -> TokenStream { 149 | let Page { 150 | title, 151 | url, 152 | segments, 153 | sections, 154 | raw: _, 155 | id, 156 | } = book; 157 | 158 | let segments = segments.iter().map(|segment| { 159 | quote! { 160 | #segment.to_string() 161 | } 162 | }); 163 | 164 | let sections = sections.iter().map(write_section_with_routes); 165 | 166 | let path = url; 167 | let url = match path_to_route_enum(path) { 168 | Ok(url) => url, 169 | Err(err) => { 170 | return err.to_token_stream(); 171 | } 172 | }; 173 | let id = id.0; 174 | 175 | quote! { 176 | { 177 | ::use_mdbook::mdbook_shared::Page { 178 | title: #title.to_string(), 179 | url: #url, 180 | segments: vec![#(#segments,)*], 181 | sections: vec![#(#sections,)*], 182 | raw: String::new(), 183 | id: ::use_mdbook::mdbook_shared::PageId(#id), 184 | } 185 | } 186 | } 187 | } 188 | 189 | fn write_section_with_routes(book: &mdbook_shared::Section) -> TokenStream { 190 | let Section { title, id, level } = book; 191 | 192 | quote! { 193 | ::use_mdbook::mdbook_shared::Section { 194 | title: #title.to_string(), 195 | id: #id.to_string(), 196 | level: #level, 197 | } 198 | } 199 | } 200 | -------------------------------------------------------------------------------- /packages/mdbook-shared/src/query.rs: -------------------------------------------------------------------------------- 1 | use crate::*; 2 | use anyhow::{Context, Ok}; 3 | use pulldown_cmark::{Event, Tag}; 4 | use serde::{Deserialize, Serialize}; 5 | use slab::Slab; 6 | use std::{ 7 | collections::HashMap, 8 | hash::Hash, 9 | path::{Path, PathBuf}, 10 | }; 11 | 12 | #[derive(Debug, Clone)] 13 | pub struct MdBook 14 | where 15 | R: Hash + Eq, 16 | { 17 | pub summary: Summary, 18 | 19 | // a mapping between urls and page ids 20 | pub page_id_mapping: HashMap, 21 | 22 | // rendered pages to HTML 23 | pub pages: Slab>, 24 | } 25 | 26 | impl MdBook { 27 | /// Get a page from the book 28 | pub fn get_page(&self, id: impl PageIndex) -> &Page { 29 | &self.pages[id.into_page_index(self).0] 30 | } 31 | 32 | /// Get the pages 33 | pub fn pages(&self) -> &Slab> { 34 | &self.pages 35 | } 36 | } 37 | 38 | pub trait PageIndex { 39 | fn into_page_index(self, book: &MdBook) -> PageId; 40 | } 41 | 42 | impl PageIndex for PageId { 43 | fn into_page_index(self, _book: &MdBook) -> PageId { 44 | self 45 | } 46 | } 47 | 48 | impl PageIndex for &T { 49 | fn into_page_index(self, book: &MdBook) -> PageId { 50 | book.page_id_mapping.get(self).copied().unwrap() 51 | } 52 | } 53 | 54 | pub fn get_book_content_path(mdbook_root: impl AsRef) -> Option { 55 | let mdbook_root = mdbook_root.as_ref(); 56 | let path = mdbook_root.join("en"); 57 | if path.exists() { 58 | return Some(path); 59 | } 60 | let path = mdbook_root.join("src"); 61 | if path.exists() { 62 | return Some(path); 63 | } 64 | None 65 | } 66 | 67 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] 68 | pub struct Page { 69 | pub title: String, 70 | 71 | pub url: R, 72 | 73 | pub segments: Vec, 74 | 75 | // the raw markdown 76 | pub raw: String, 77 | 78 | // headers 79 | pub sections: Vec
, 80 | 81 | pub id: PageId, 82 | } 83 | 84 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] 85 | pub struct Section { 86 | pub level: usize, 87 | pub title: String, 88 | pub id: String, 89 | } 90 | 91 | impl MdBook { 92 | pub fn new(mdbook_root: PathBuf) -> anyhow::Result { 93 | let buf = get_summary_path(&mdbook_root) 94 | .context("No SUMMARY.md found")? 95 | .canonicalize()?; 96 | 97 | let summary = std::fs::read_to_string(buf).map_err(|e| { 98 | anyhow::anyhow!( 99 | "Failed to read SUMMARY.md. Make sure you are running this command in the root of the book. {e}" 100 | ) 101 | })?; 102 | let summary = parse_summary(&mdbook_root, &summary)?; 103 | 104 | let mut book = Self { 105 | summary, 106 | page_id_mapping: Default::default(), 107 | pages: Default::default(), 108 | }; 109 | 110 | book.populate(mdbook_root)?; 111 | 112 | Ok(book) 113 | } 114 | 115 | pub fn populate(&mut self, mdbook_root: PathBuf) -> anyhow::Result<()> { 116 | let summary = self.summary.clone(); 117 | 118 | let chapters = summary 119 | .prefix_chapters 120 | .iter() 121 | .chain(summary.numbered_chapters.iter()) 122 | .chain(summary.suffix_chapters.iter()); 123 | 124 | for chapter in chapters { 125 | self.populate_page(mdbook_root.clone(), chapter)?; 126 | } 127 | 128 | Ok(()) 129 | } 130 | 131 | fn populate_page( 132 | &mut self, 133 | mdbook_root: PathBuf, 134 | chapter: &SummaryItem, 135 | ) -> anyhow::Result<()> { 136 | let Some(link) = chapter.maybe_link() else { 137 | return Ok(()); 138 | }; 139 | 140 | let url = link.location.as_ref().cloned().unwrap(); 141 | let md_file = get_book_content_path(&mdbook_root) 142 | .context("No book content found")? 143 | .join(&url) 144 | .canonicalize() 145 | .map_err(|e| { 146 | anyhow::anyhow!("Failed to canonicalize file for page {:?}: {}", url, e) 147 | })?; 148 | 149 | // create the file if it doesn't exist 150 | if !md_file.exists() { 151 | std::fs::write(&md_file, "").map_err(|e| { 152 | anyhow::anyhow!( 153 | "Failed to create file {:?} for page {:?}: {}", 154 | md_file, 155 | url, 156 | e 157 | ) 158 | })?; 159 | } 160 | 161 | let body = std::fs::read_to_string(&md_file).map_err(|e| { 162 | anyhow::anyhow!( 163 | "Failed to read file {:?} for page {:?}: {}", 164 | md_file, 165 | url, 166 | e 167 | ) 168 | })?; 169 | 170 | let parser = pulldown_cmark::Parser::new(&body); 171 | 172 | let mut last_heading = None; 173 | 174 | let mut sections = Vec::new(); 175 | 176 | parser.for_each(|event| match event { 177 | Event::Start(Tag::Heading(level, ..)) => { 178 | last_heading = Some(level); 179 | } 180 | Event::Text(text) => { 181 | if let Some(current_level) = &mut last_heading { 182 | let anchor = text 183 | .clone() 184 | .into_string() 185 | .trim() 186 | .to_lowercase() 187 | .replace(' ', "-"); 188 | sections.push(Section { 189 | level: *current_level as usize, 190 | title: text.to_string(), 191 | id: anchor, 192 | }); 193 | 194 | last_heading = None; 195 | } 196 | } 197 | _ => {} 198 | }); 199 | 200 | let entry = self.pages.vacant_entry(); 201 | let id = query::PageId(entry.key()); 202 | entry.insert(Page { 203 | raw: body, 204 | segments: vec![], 205 | url: url.to_owned(), 206 | title: link.name.clone(), 207 | sections, 208 | id, 209 | }); 210 | 211 | self.page_id_mapping.insert(url, id); 212 | 213 | for nested in link.nested_items.iter() { 214 | self.populate_page(mdbook_root.clone(), nested)?; 215 | } 216 | 217 | // proc_append_state("mdbook", &link.name).unwrap(); 218 | Ok(()) 219 | } 220 | 221 | // Insert a page via its path, autofilling the segments and title 222 | pub fn insert_page(&mut self, _path: PathBuf, markdown: String) { 223 | let parser = pulldown_cmark::Parser::new(&markdown); 224 | let mut out = String::new(); 225 | pulldown_cmark::html::push_html(&mut out, parser); 226 | } 227 | } 228 | 229 | /// An id for a page 230 | #[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq)] 231 | pub struct PageId(pub usize); 232 | -------------------------------------------------------------------------------- /packages/mdbook-gen-example/example-book/en/chapter_2.md: -------------------------------------------------------------------------------- 1 | # Roadmap & Feature-set 2 | 3 | This feature set and roadmap can help you decide if what Dioxus can do today works for you. 4 | 5 | If a feature that you need doesn't exist or you want to contribute to projects on the roadmap, feel free to get involved by [joining the discord](https://discord.gg/XgGxMSkvUM). 6 | 7 | Generally, here's the status of each platform: 8 | 9 | - **Web**: Dioxus is a great choice for pure web-apps – especially for CRUD/complex apps. However, it does lack the ecosystem of React, so you might be missing a component library or some useful hook. 10 | 11 | - **SSR**: Dioxus is a great choice for pre-rendering, hydration, and rendering HTML on a web endpoint. Be warned – the VirtualDom is not (currently) `Send + Sync`. 12 | 13 | - **Desktop**: You can build very competent single-window desktop apps right now. However, multi-window apps require support from Dioxus core and are not ready. 14 | 15 | - **Mobile**: Mobile support is very young. You'll be figuring things out as you go and there are not many support crates for peripherals. 16 | 17 | - **LiveView**: LiveView support is very young. You'll be figuring things out as you go. Thankfully, none of it is too hard and any work can be upstreamed into Dioxus. 18 | 19 | ```rust 20 | fn main() { 21 | dioxus_rocks; 22 | } 23 | ``` 24 | 25 | ## Features 26 | 27 | --- 28 | 29 | | Feature | Status | Description | 30 | | ------------------------- | ------ | -------------------------------------------------------------------- | 31 | | Conditional Rendering | ✅ | if/then to hide/show component | 32 | | Map, Iterator | ✅ | map/filter/reduce to produce rsx! | 33 | | Keyed Components | ✅ | advanced diffing with keys | 34 | | Web | ✅ | renderer for web browser | 35 | | Desktop (webview) | ✅ | renderer for desktop | 36 | | Shared State (Context) | ✅ | share state through the tree | 37 | | Hooks | ✅ | memory cells in components | 38 | | SSR | ✅ | render directly to string | 39 | | Component Children | ✅ | cx.children() as a list of nodes | 40 | | Headless components | ✅ | components that don't return real elements | 41 | | Fragments | ✅ | multiple elements without a real root | 42 | | Manual Props | ✅ | Manually pass in props with spread syntax | 43 | | Controlled Inputs | ✅ | stateful wrappers around inputs | 44 | | CSS/Inline Styles | ✅ | syntax for inline styles/attribute groups | 45 | | Custom elements | ✅ | Define new element primitives | 46 | | Suspense | ✅ | schedule future render from future/promise | 47 | | Integrated error handling | ✅ | Gracefully handle errors with ? syntax | 48 | | NodeRef | ✅ | gain direct access to nodes | 49 | | Re-hydration | ✅ | Pre-render to HTML to speed up first contentful paint | 50 | | Jank-Free Rendering | ✅ | Large diffs are segmented across frames for silky-smooth transitions | 51 | | Effects | ✅ | Run effects after a component has been committed to render | 52 | | Portals | 🛠 | Render nodes outside of the traditional tree structure | 53 | | Cooperative Scheduling | 🛠 | Prioritize important events over non-important events | 54 | | Server Components | 🛠 | Hybrid components for SPA and Server | 55 | | Bundle Splitting | 👀 | Efficiently and asynchronously load the app | 56 | | Lazy Components | 👀 | Dynamically load the new components as the page is loaded | 57 | | 1st class global state | ✅ | redux/recoil/mobx on top of context | 58 | | Runs natively | ✅ | runs as a portable binary w/o a runtime (Node) | 59 | | Subtree Memoization | ✅ | skip diffing static element subtrees | 60 | | High-efficiency templates | ✅ | rsx! calls are translated to templates on the DOM's side | 61 | | Compile-time correct | ✅ | Throw errors on invalid template layouts | 62 | | Heuristic Engine | ✅ | track component memory usage to minimize future allocations | 63 | | Fine-grained reactivity | 👀 | Skip diffing for fine-grain updates | 64 | 65 | - ✅ = implemented and working 66 | - 🛠 = actively being worked on 67 | - 👀 = not yet implemented or being worked on 68 | 69 | ## Roadmap 70 | 71 | These Features are planned for the future of Dioxus: 72 | 73 | ### Core 74 | 75 | - [x] Release of Dioxus Core 76 | - [x] Upgrade documentation to include more theory and be more comprehensive 77 | - [x] Support for HTML-side templates for lightning-fast dom manipulation 78 | - [ ] Support for multiple renderers for same virtualdom (subtrees) 79 | - [ ] Support for ThreadSafe (Send + Sync) 80 | - [ ] Support for Portals 81 | 82 | ### SSR 83 | 84 | - [x] SSR Support + Hydration 85 | - [ ] Integrated suspense support for SSR 86 | 87 | ### Desktop 88 | 89 | - [ ] Declarative window management 90 | - [ ] Templates for building/bundling 91 | - [ ] Access to Canvas/WebGL context natively 92 | 93 | ### Mobile 94 | 95 | - [ ] Mobile standard library 96 | - [ ] GPS 97 | - [ ] Camera 98 | - [ ] filesystem 99 | - [ ] Biometrics 100 | - [ ] WiFi 101 | - [ ] Bluetooth 102 | - [ ] Notifications 103 | - [ ] Clipboard 104 | - [ ] Animations 105 | 106 | ### Bundling (CLI) 107 | 108 | - [x] Translation from HTML into RSX 109 | - [x] Dev server 110 | - [x] Live reload 111 | - [x] Translation from JSX into RSX 112 | - [ ] Hot module replacement 113 | - [ ] Code splitting 114 | - [ ] Asset macros 115 | - [ ] Css pipeline 116 | - [ ] Image pipeline 117 | 118 | ### Essential hooks 119 | 120 | - [x] Router 121 | - [x] Global state management 122 | - [ ] Resize observer 123 | 124 | ## Work in Progress 125 | 126 | ### Build Tool 127 | 128 | We are currently working on our own build tool called [Dioxus CLI](https://github.com/DioxusLabs/dioxus/tree/master/packages/cli) which will support: 129 | 130 | - an interactive TUI 131 | - on-the-fly reconfiguration 132 | - hot CSS reloading 133 | - two-way data binding between browser and source code 134 | - an interpreter for `rsx!` 135 | - ability to publish to github/netlify/vercel 136 | - bundling for iOS/Desktop/etc 137 | 138 | ### Server Component Support 139 | 140 | While not currently fully implemented, the expectation is that LiveView apps can be a hybrid between Wasm and server-rendered where only portions of a page are "live" and the rest of the page is either server-rendered, statically generated, or handled by the host SPA. 141 | 142 | ### Native rendering 143 | 144 | We are currently working on a native renderer for Dioxus using WGPU called [Blitz](https://github.com/DioxusLabs/blitz/). This will allow you to build apps that are rendered natively for iOS, Android, and Desktop. 145 | 146 | ## Internal Links 147 | Internal links like [this](./chapter_1.md) are typechecked and will fail to compile if the file is not found. -------------------------------------------------------------------------------- /packages/mdbook-gen/themes/MonokaiDark.thTheme: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | name 6 | Monokai Dark 7 | settings 8 | 9 | 10 | settings 11 | 12 | background 13 | #0D0D0D 14 | caret 15 | #F8F8F0 16 | foreground 17 | #F8F8F2 18 | invisibles 19 | #3B3A32 20 | lineHighlight 21 | #3D3D3D55 22 | selection 23 | #A63A62 24 | 25 | 26 | 27 | name 28 | Comment 29 | scope 30 | comment 31 | settings 32 | 33 | foreground 34 | #8C8C8C 35 | 36 | 37 | 38 | name 39 | String 40 | scope 41 | string 42 | settings 43 | 44 | foreground 45 | #FFEE99 46 | 47 | 48 | 49 | name 50 | Number 51 | scope 52 | constant.numeric 53 | settings 54 | 55 | foreground 56 | #FF80F4 57 | 58 | 59 | 60 | name 61 | Built-in constant 62 | scope 63 | constant.language 64 | settings 65 | 66 | foreground 67 | #FF80F4 68 | 69 | 70 | 71 | name 72 | User-defined constant 73 | scope 74 | constant.character, constant.other 75 | settings 76 | 77 | foreground 78 | #FF80F4 79 | 80 | 81 | 82 | name 83 | Variable 84 | scope 85 | variable 86 | settings 87 | 88 | fontStyle 89 | 90 | 91 | 92 | 93 | name 94 | Keyword 95 | scope 96 | keyword 97 | settings 98 | 99 | foreground 100 | #F92672 101 | 102 | 103 | 104 | name 105 | Storage 106 | scope 107 | storage 108 | settings 109 | 110 | fontStyle 111 | 112 | foreground 113 | #F92672 114 | 115 | 116 | 117 | name 118 | Storage type 119 | scope 120 | storage.type 121 | settings 122 | 123 | fontStyle 124 | italic 125 | foreground 126 | #66D9EF 127 | 128 | 129 | 130 | name 131 | Class name 132 | scope 133 | entity.name.class 134 | settings 135 | 136 | fontStyle 137 | underline 138 | foreground 139 | #A6E22E 140 | 141 | 142 | 143 | name 144 | Inherited class 145 | scope 146 | entity.other.inherited-class 147 | settings 148 | 149 | fontStyle 150 | italic underline 151 | foreground 152 | #A6E22E 153 | 154 | 155 | 156 | name 157 | Function name 158 | scope 159 | entity.name.function 160 | settings 161 | 162 | fontStyle 163 | 164 | foreground 165 | #A6E22E 166 | 167 | 168 | 169 | name 170 | Function argument 171 | scope 172 | variable.parameter 173 | settings 174 | 175 | fontStyle 176 | italic 177 | foreground 178 | #FD971F 179 | 180 | 181 | 182 | name 183 | Tag name 184 | scope 185 | entity.name.tag 186 | settings 187 | 188 | fontStyle 189 | 190 | foreground 191 | #F92672 192 | 193 | 194 | 195 | name 196 | Tag attribute 197 | scope 198 | entity.other.attribute-name 199 | settings 200 | 201 | fontStyle 202 | 203 | foreground 204 | #A6E22E 205 | 206 | 207 | 208 | name 209 | Library function 210 | scope 211 | support.function 212 | settings 213 | 214 | fontStyle 215 | 216 | foreground 217 | #66D9EF 218 | 219 | 220 | 221 | name 222 | Library constant 223 | scope 224 | support.constant 225 | settings 226 | 227 | fontStyle 228 | 229 | foreground 230 | #66D9EF 231 | 232 | 233 | 234 | name 235 | Library class/type 236 | scope 237 | support.type, support.class 238 | settings 239 | 240 | fontStyle 241 | italic 242 | foreground 243 | #66D9EF 244 | 245 | 246 | 247 | name 248 | Library variable 249 | scope 250 | support.other.variable 251 | settings 252 | 253 | fontStyle 254 | 255 | 256 | 257 | 258 | name 259 | PHP Namespaces 260 | scope 261 | support.other.namespace, entity.name.type.namespace 262 | settings 263 | 264 | foreground 265 | #FFB2F9 266 | 267 | 268 | 269 | name 270 | PHP Namespace Alias 271 | scope 272 | support.other.namespace.use-as.php 273 | settings 274 | 275 | foreground 276 | #66D9EF 277 | 278 | 279 | 280 | name 281 | PHP Namespace Keyword 282 | scope 283 | variable.language.namespace.php 284 | settings 285 | 286 | foreground 287 | #D66990 288 | 289 | 290 | 291 | name 292 | PHP Namespace Separator 293 | scope 294 | punctuation.separator.inheritance.php 295 | settings 296 | 297 | foreground 298 | #F92672 299 | 300 | 301 | 302 | name 303 | CSS Functions / Property Values 304 | scope 305 | support.function.misc.css, support.constant.property-value.css, support.constant.font-name.css 306 | settings 307 | 308 | foreground 309 | #FFEE99 310 | 311 | 312 | 313 | name 314 | Twig Tagbraces 315 | scope 316 | entity.other.tagbraces.twig 317 | settings 318 | 319 | foreground 320 | #A6E22E 321 | 322 | 323 | 324 | name 325 | Twig Tag 326 | scope 327 | keyword.control.twig 328 | settings 329 | 330 | foreground 331 | #FD971F 332 | 333 | 334 | 335 | name 336 | Twig Variable 337 | scope 338 | variable.other.twig 339 | settings 340 | 341 | foreground 342 | #FF80F4 343 | 344 | 345 | 346 | name 347 | Twig Variable Filter 348 | scope 349 | support.function.filter.variable.twig 350 | settings 351 | 352 | foreground 353 | #FFCCFB 354 | 355 | 356 | 357 | name 358 | Twig Function 359 | scope 360 | entity.name.function.twig 361 | settings 362 | 363 | foreground 364 | #F92672 365 | 366 | 367 | 368 | name 369 | Twig Function Argument 370 | scope 371 | entity.other.argument.twig 372 | settings 373 | 374 | foreground 375 | #E5DB7E 376 | 377 | 378 | 379 | name 380 | Invalid 381 | scope 382 | invalid 383 | settings 384 | 385 | background 386 | #F92672 387 | fontStyle 388 | 389 | foreground 390 | #F8F8F0 391 | 392 | 393 | 394 | name 395 | Invalid deprecated 396 | scope 397 | invalid.deprecated 398 | settings 399 | 400 | background 401 | #FF80F4 402 | foreground 403 | #F8F8F0 404 | 405 | 406 | 407 | uuid 408 | 233F0694-0B9C-43E3-A44A-ECECF7DF6C73 409 | 410 | 411 | -------------------------------------------------------------------------------- /packages/mdbook-gen/src/lib.rs: -------------------------------------------------------------------------------- 1 | use std::path::Path; 2 | use std::path::PathBuf; 3 | 4 | use anyhow::Context; 5 | use convert_case::{Case, Casing}; 6 | use mdbook_shared::MdBook; 7 | use proc_macro2::Ident; 8 | use proc_macro2::Span; 9 | use proc_macro2::TokenStream as TokenStream2; 10 | use quote::format_ident; 11 | use quote::quote; 12 | use quote::ToTokens; 13 | use syn::LitStr; 14 | 15 | use crate::transform_book::write_book_with_routes; 16 | 17 | mod rsx; 18 | mod transform_book; 19 | 20 | /// Generate the contents of the mdbook from a router 21 | pub fn generate_router_build_script(mdbook_dir: PathBuf) -> String { 22 | let file_src = generate_router_as_file(mdbook_dir.clone(), MdBook::new(mdbook_dir).unwrap()); 23 | 24 | let stringified = prettyplease::unparse(&file_src); 25 | let prettifed = rustfmt_via_cli(&stringified); 26 | 27 | let as_file = syn::parse_file(&prettifed).unwrap(); 28 | let fmts = dioxus_autofmt::try_fmt_file(&prettifed, &as_file, Default::default()).unwrap(); 29 | dioxus_autofmt::apply_formats(&prettifed, fmts) 30 | } 31 | 32 | /// Load an mdbook from the filesystem using the target tokens 33 | /// ```ignore 34 | /// 35 | /// 36 | /// ``` 37 | pub fn load_book_from_fs( 38 | input: LitStr, 39 | ) -> anyhow::Result<(PathBuf, mdbook_shared::MdBook)> { 40 | let user_dir = input.value().parse::()?; 41 | let manifest_dir = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR")?); 42 | let path = manifest_dir.join(user_dir); 43 | let path = path.canonicalize().with_context(|| { 44 | anyhow::anyhow!( 45 | "Failed to canonicalize the path to the book at {:?}", 46 | path.display() 47 | ) 48 | })?; 49 | 50 | Ok((path.clone(), MdBook::new(path)?)) 51 | } 52 | 53 | pub fn generate_router_as_file( 54 | mdbook_dir: PathBuf, 55 | book: mdbook_shared::MdBook, 56 | ) -> syn::File { 57 | let router = generate_router(mdbook_dir, book); 58 | 59 | syn::parse_quote! { 60 | #router 61 | } 62 | } 63 | 64 | pub fn generate_router(mdbook_dir: PathBuf, book: mdbook_shared::MdBook) -> TokenStream2 { 65 | let mdbook = write_book_with_routes(&book); 66 | 67 | let book_pages = book.pages().iter().map(|(_, page)| { 68 | let name = path_to_route_variant(&page.url).unwrap(); 69 | 70 | // Rsx doesn't work very well in macros because the path for all the routes generated point to the same characters. We manually expand rsx here to get around that issue. 71 | match rsx::parse_markdown(mdbook_dir.clone(), page.url.clone(), &page.raw) { 72 | Ok(parsed) => { 73 | // for the sake of readability, we want to actually convert the CallBody back to Tokens 74 | let rsx = rsx::callbody_to_tokens(parsed.body); 75 | 76 | // Create the fragment enum for the section 77 | let section_enum = path_to_route_section(&page.url).unwrap(); 78 | let section_parse_error = format_ident!("{}ParseError", section_enum); 79 | let mut error_message = format!("Invalid section name. Expected one of {}", section_enum); 80 | for (i, section) in parsed.sections.iter().enumerate() { 81 | if i > 0 { 82 | error_message.push_str(", "); 83 | } 84 | error_message.push_str(§ion.fragment()); 85 | } 86 | let section_idents: Vec<_> = parsed 87 | .sections 88 | .iter() 89 | .filter_map(|section| Some(Ident::new(§ion.variant().ok()?, Span::call_site()))) 90 | .collect(); 91 | let section_names: Vec<_> = parsed 92 | .sections 93 | .iter() 94 | .map(|section| section.fragment()) 95 | .collect(); 96 | let fragment = quote! { 97 | #[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, Default, serde::Serialize, serde::Deserialize)] 98 | pub enum #section_enum { 99 | #[default] 100 | Empty, 101 | #(#section_idents),* 102 | } 103 | 104 | impl std::str::FromStr for #section_enum { 105 | type Err = #section_parse_error; 106 | 107 | fn from_str(s: &str) -> Result { 108 | match s { 109 | "" => Ok(Self::Empty), 110 | #( 111 | #section_names => Ok(Self::#section_idents), 112 | )* 113 | _ => Err(#section_parse_error) 114 | } 115 | } 116 | } 117 | 118 | impl std::fmt::Display for #section_enum { 119 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 120 | match self { 121 | Self::Empty => f.write_str(""), 122 | #( 123 | Self::#section_idents => f.write_str(#section_names), 124 | )* 125 | } 126 | } 127 | } 128 | 129 | #[derive(Debug)] 130 | pub struct #section_parse_error; 131 | 132 | impl std::fmt::Display for #section_parse_error { 133 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 134 | f.write_str(#error_message)?; 135 | Ok(()) 136 | } 137 | } 138 | 139 | impl std::error::Error for #section_parse_error {} 140 | }; 141 | 142 | quote! { 143 | #fragment 144 | 145 | #[component(no_case_check)] 146 | pub fn #name(section: #section_enum) -> dioxus::prelude::Element { 147 | use dioxus::prelude::*; 148 | rsx! { 149 | #rsx 150 | } 151 | } 152 | } 153 | } 154 | Err(err) => err.to_compile_error(), 155 | } 156 | }); 157 | 158 | let default_impl = book 159 | .pages() 160 | .iter() 161 | .min_by_key(|(_, page)| page.url.to_string_lossy().len()) 162 | .map(|(_, page)| { 163 | let name = path_to_route_enum(&page.url).unwrap(); 164 | quote! { 165 | impl Default for BookRoute { 166 | fn default() -> Self { 167 | #name 168 | } 169 | } 170 | } 171 | }); 172 | 173 | let book_routes = book.pages().iter().map(|(_, page)| { 174 | let name = path_to_route_variant(&page.url).unwrap(); 175 | let section = path_to_route_section(&page.url).unwrap(); 176 | let route_without_extension = page.url.with_extension(""); 177 | // remove any trailing "index" 178 | let route_without_extension = route_without_extension.to_string_lossy().to_string(); 179 | let mut url = route_without_extension; 180 | if let Some(stripped) = url.strip_suffix("index") { 181 | url = stripped.to_string(); 182 | } 183 | if !url.starts_with('/') { 184 | url = format!("/{}", url); 185 | } 186 | url += "#:section"; 187 | quote! { 188 | #[route(#url)] 189 | #name { 190 | section: #section 191 | }, 192 | } 193 | }); 194 | 195 | let match_page_id = book.pages().iter().map(|(_, page)| { 196 | let id = page.id.0; 197 | let variant = path_to_route_variant(&page.url).unwrap(); 198 | quote! { 199 | BookRoute::#variant { .. } => use_mdbook::mdbook_shared::PageId(#id), 200 | } 201 | }); 202 | 203 | quote! { 204 | use dioxus::prelude::*; 205 | 206 | #[derive(Clone, Copy, dioxus_router::prelude::Routable, PartialEq, Eq, Hash, Debug, serde::Serialize, serde::Deserialize)] 207 | pub enum BookRoute { 208 | #(#book_routes)* 209 | } 210 | 211 | impl BookRoute { 212 | pub fn sections(&self) -> &'static [use_mdbook::mdbook_shared::Section] { 213 | &self.page().sections 214 | } 215 | 216 | pub fn page(&self) -> &'static use_mdbook::mdbook_shared::Page { 217 | LAZY_BOOK.get_page(self) 218 | } 219 | 220 | pub fn page_id(&self) -> use_mdbook::mdbook_shared::PageId { 221 | match self { 222 | #( 223 | #match_page_id 224 | )* 225 | } 226 | } 227 | } 228 | 229 | #default_impl 230 | 231 | pub static LAZY_BOOK: use_mdbook::Lazy> = use_mdbook::Lazy::new(|| { 232 | #mdbook 233 | }); 234 | 235 | #( 236 | #book_pages 237 | )* 238 | } 239 | } 240 | 241 | pub(crate) fn path_to_route_variant_name(path: &Path) -> Result { 242 | let path_without_extension = path.with_extension(""); 243 | let mut title = String::new(); 244 | for segment in path_without_extension.components() { 245 | title.push(' '); 246 | title.push_str(&segment.as_os_str().to_string_lossy()); 247 | } 248 | let filtered = title 249 | .chars() 250 | .filter(|c| c.is_alphanumeric() || c.is_whitespace() || *c == '-' || *c == '_') 251 | .collect::(); 252 | 253 | to_upper_camel_case_for_ident(&filtered) 254 | } 255 | 256 | /// Convert a string to an upper camel case which will be a valid Rust identifier. Any leading numbers will be skipped. 257 | pub(crate) fn to_upper_camel_case_for_ident(title: &str) -> Result { 258 | let upper = title.to_case(Case::UpperCamel); 259 | Ok( 260 | if upper.chars().next().ok_or(EmptyIdentError)?.is_numeric() { 261 | format!("_{}", upper) 262 | } else { 263 | upper 264 | }, 265 | ) 266 | } 267 | 268 | #[derive(Debug)] 269 | pub(crate) struct EmptyIdentError; 270 | 271 | impl ToTokens for EmptyIdentError { 272 | fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) { 273 | let err = self.to_string(); 274 | tokens.extend(quote! { 275 | compile_error!(#err) 276 | }) 277 | } 278 | } 279 | 280 | impl std::error::Error for EmptyIdentError {} 281 | 282 | impl std::fmt::Display for EmptyIdentError { 283 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 284 | f.write_str("Empty identifiers are not allowed") 285 | } 286 | } 287 | 288 | pub(crate) fn path_to_route_variant(path: &Path) -> Result { 289 | let title = path_to_route_variant_name(path)?; 290 | Ok(Ident::new(&title, Span::call_site())) 291 | } 292 | 293 | pub(crate) fn path_to_route_section(path: &Path) -> Result { 294 | let title = path_to_route_variant_name(path)?; 295 | Ok(Ident::new(&format!("{}Section", title), Span::call_site())) 296 | } 297 | 298 | pub(crate) fn path_to_route_enum(path: &Path) -> Result { 299 | path_to_route_enum_with_section(path, Ident::new("Empty", Span::call_site())) 300 | } 301 | 302 | pub(crate) fn path_to_route_enum_with_section( 303 | path: &Path, 304 | section_variant: Ident, 305 | ) -> Result { 306 | let name = path_to_route_variant(path)?; 307 | let section = path_to_route_section(path)?; 308 | Ok(quote! { 309 | BookRoute::#name { 310 | section: #section::#section_variant 311 | } 312 | }) 313 | } 314 | 315 | fn rustfmt_via_cli(input: &str) -> String { 316 | let tmpfile = std::env::temp_dir().join(format!("mdbook-gen-{}.rs", std::process::id())); 317 | std::fs::write(&tmpfile, input).unwrap(); 318 | 319 | let file = std::fs::File::open(&tmpfile).unwrap(); 320 | let output = std::process::Command::new("rustfmt") 321 | .arg("--edition=2021") 322 | .stdin(file) 323 | .stdout(std::process::Stdio::piped()) 324 | .output() 325 | .unwrap(); 326 | 327 | _ = std::fs::remove_file(tmpfile); 328 | 329 | String::from_utf8(output.stdout).unwrap() 330 | } 331 | -------------------------------------------------------------------------------- /packages/mdbook-shared/src/book.rs: -------------------------------------------------------------------------------- 1 | use std::collections::{HashMap, VecDeque}; 2 | use std::fmt::{self, Display, Formatter}; 3 | use std::fs::{self, File}; 4 | use std::io::{Read, Write}; 5 | use std::path::{Path, PathBuf}; 6 | 7 | use super::summary::{parse_summary, Link, SectionNumber, Summary, SummaryItem}; 8 | // use crate::build_opts::BuildOpts; 9 | // use crate::config::Config; 10 | use crate::errors::*; 11 | 12 | /// Load a book into memory from its `src/` directory. 13 | pub fn load_book>( 14 | root_dir: P, 15 | cfg: &Config, 16 | build_opts: &BuildOpts, 17 | ) -> Result { 18 | if cfg.has_localized_dir_structure() { 19 | match build_opts.language_ident { 20 | // Build a single book's translation. 21 | Some(_) => Ok(LoadedBook::Single(load_single_book_translation( 22 | &root_dir, 23 | cfg, 24 | &build_opts.language_ident, 25 | )?)), 26 | // Build all available translations at once. 27 | None => { 28 | let mut translations = HashMap::new(); 29 | for (lang_ident, _) in cfg.language.0.iter() { 30 | let book = 31 | load_single_book_translation(&root_dir, cfg, &Some(lang_ident.clone()))?; 32 | translations.insert(lang_ident.clone(), book); 33 | } 34 | Ok(LoadedBook::Localized(LocalizedBooks(translations))) 35 | } 36 | } 37 | } else { 38 | Ok(LoadedBook::Single(load_single_book_translation( 39 | &root_dir, cfg, &None, 40 | )?)) 41 | } 42 | } 43 | 44 | fn load_single_book_translation>( 45 | root_dir: P, 46 | cfg: &Config, 47 | language_ident: &Option, 48 | ) -> Result { 49 | let localized_src_dir = root_dir 50 | .as_ref() 51 | .join(cfg.get_localized_src_path(language_ident.as_ref()).unwrap()); 52 | let fallback_src_dir = root_dir.as_ref().join(cfg.get_fallback_src_path()); 53 | 54 | let summary_md = localized_src_dir.join("SUMMARY.md"); 55 | 56 | let mut summary_content = String::new(); 57 | File::open(&summary_md) 58 | .with_context(|| { 59 | format!( 60 | "Couldn't open SUMMARY.md in {:?} directory", 61 | localized_src_dir 62 | ) 63 | })? 64 | .read_to_string(&mut summary_content)?; 65 | 66 | let summary = parse_summary(&summary_content) 67 | .with_context(|| format!("Summary parsing failed for file={:?}", summary_md))?; 68 | 69 | if cfg.build.create_missing { 70 | create_missing(&localized_src_dir, &summary) 71 | .with_context(|| "Unable to create missing chapters")?; 72 | } 73 | 74 | load_book_from_disk(&summary, localized_src_dir, fallback_src_dir, cfg) 75 | } 76 | 77 | fn create_missing(src_dir: &Path, summary: &Summary) -> Result<()> { 78 | let mut items: Vec<_> = summary 79 | .prefix_chapters 80 | .iter() 81 | .chain(summary.numbered_chapters.iter()) 82 | .chain(summary.suffix_chapters.iter()) 83 | .collect(); 84 | 85 | while !items.is_empty() { 86 | let next = items.pop().expect("already checked"); 87 | 88 | if let SummaryItem::Link(ref link) = *next { 89 | if let Some(ref location) = link.location { 90 | let filename = src_dir.join(location); 91 | if !filename.exists() { 92 | create_missing_link(&filename, link)?; 93 | } 94 | } 95 | 96 | items.extend(&link.nested_items); 97 | } 98 | } 99 | 100 | Ok(()) 101 | } 102 | 103 | fn create_missing_link(filename: &Path, link: &Link) -> Result<()> { 104 | if let Some(parent) = filename.parent() { 105 | if !parent.exists() { 106 | fs::create_dir_all(parent).map_err(|e| { 107 | Error::from(format!( 108 | "Unable to create missing directory {:?}: {}", 109 | parent, e 110 | )) 111 | })?; 112 | } 113 | } 114 | debug!("Creating missing file {}", filename.display()); 115 | 116 | let mut f = File::create(&filename)?; 117 | writeln!(f, "# {}", link.name)?; 118 | 119 | Ok(()) 120 | } 121 | 122 | /// A dumb tree structure representing a book. 123 | /// 124 | /// For the moment a book is just a collection of [`BookItems`] which are 125 | /// accessible by either iterating (immutably) over the book with [`iter()`], or 126 | /// recursively applying a closure to each section to mutate the chapters, using 127 | /// [`for_each_mut()`]. 128 | /// 129 | /// 130 | /// [`iter()`]: #method.iter 131 | /// [`for_each_mut()`]: #method.for_each_mut 132 | #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] 133 | pub struct Book { 134 | /// The sections in this book. 135 | pub sections: Vec, 136 | /// Chapter title overrides for this book. 137 | #[serde(default)] 138 | pub chapter_titles: HashMap, 139 | __non_exhaustive: (), 140 | } 141 | 142 | impl Book { 143 | /// Create an empty book. 144 | pub fn new() -> Self { 145 | Default::default() 146 | } 147 | 148 | /// Get a depth-first iterator over the items in the book. 149 | pub fn iter(&self) -> BookItems<'_> { 150 | BookItems { 151 | items: self.sections.iter().collect(), 152 | } 153 | } 154 | 155 | /// Recursively apply a closure to each item in the book, allowing you to 156 | /// mutate them. 157 | /// 158 | /// # Note 159 | /// 160 | /// Unlike the `iter()` method, this requires a closure instead of returning 161 | /// an iterator. This is because using iterators can possibly allow you 162 | /// to have iterator invalidation errors. 163 | pub fn for_each_mut(&mut self, mut func: F) 164 | where 165 | F: FnMut(&mut BookItem), 166 | { 167 | for_each_mut(&mut func, &mut self.sections); 168 | } 169 | 170 | /// Append a `BookItem` to the `Book`. 171 | pub fn push_item>(&mut self, item: I) -> &mut Self { 172 | self.sections.push(item.into()); 173 | self 174 | } 175 | } 176 | 177 | pub fn for_each_mut<'a, F, I>(func: &mut F, items: I) 178 | where 179 | F: FnMut(&mut BookItem), 180 | I: IntoIterator, 181 | { 182 | for item in items { 183 | if let BookItem::Chapter(ch) = item { 184 | for_each_mut(func, &mut ch.sub_items); 185 | } 186 | 187 | func(item); 188 | } 189 | } 190 | 191 | /// A collection of `Books`, each one a single localization. 192 | #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] 193 | pub struct LocalizedBooks(pub HashMap); 194 | 195 | impl LocalizedBooks { 196 | /// Get a depth-first iterator over the items in the book. 197 | pub fn iter(&self) -> BookItems<'_> { 198 | let mut items = VecDeque::new(); 199 | 200 | for (_, book) in self.0.iter() { 201 | items.extend(book.iter().items); 202 | } 203 | 204 | BookItems { items: items } 205 | } 206 | 207 | /// Recursively apply a closure to each item in the book, allowing you to 208 | /// mutate them. 209 | /// 210 | /// # Note 211 | /// 212 | /// Unlike the `iter()` method, this requires a closure instead of returning 213 | /// an iterator. This is because using iterators can possibly allow you 214 | /// to have iterator invalidation errors. 215 | pub fn for_each_mut(&mut self, mut func: F) 216 | where 217 | F: FnMut(&mut BookItem), 218 | { 219 | for (_, book) in self.0.iter_mut() { 220 | book.for_each_mut(&mut func); 221 | } 222 | } 223 | } 224 | 225 | /// A book which has been loaded and is ready for rendering. 226 | /// 227 | /// This exists because the result of loading a book directory can be multiple 228 | /// books, each one representing a separate translation, or a single book with 229 | /// no translations. 230 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] 231 | pub enum LoadedBook { 232 | /// The book was loaded with all translations. 233 | Localized(LocalizedBooks), 234 | /// The book was loaded without any additional translations. 235 | Single(Book), 236 | } 237 | 238 | impl LoadedBook { 239 | /// Get a depth-first iterator over the items in the book. 240 | pub fn iter(&self) -> BookItems<'_> { 241 | match self { 242 | LoadedBook::Localized(books) => books.iter(), 243 | LoadedBook::Single(book) => book.iter(), 244 | } 245 | } 246 | 247 | /// Recursively apply a closure to each item in the book, allowing you to 248 | /// mutate them. 249 | /// 250 | /// # Note 251 | /// 252 | /// Unlike the `iter()` method, this requires a closure instead of returning 253 | /// an iterator. This is because using iterators can possibly allow you 254 | /// to have iterator invalidation errors. 255 | pub fn for_each_mut(&mut self, mut func: F) 256 | where 257 | F: FnMut(&mut BookItem), 258 | { 259 | match self { 260 | LoadedBook::Localized(books) => books.for_each_mut(&mut func), 261 | LoadedBook::Single(book) => book.for_each_mut(&mut func), 262 | } 263 | } 264 | 265 | /// Returns one of the books loaded. Used for compatibility. 266 | pub fn first(&self) -> &Book { 267 | match self { 268 | LoadedBook::Localized(books) => books.0.iter().next().unwrap().1, 269 | LoadedBook::Single(book) => &book, 270 | } 271 | } 272 | } 273 | 274 | /// Enum representing any type of item which can be added to a book. 275 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] 276 | pub enum BookItem { 277 | /// A nested chapter. 278 | Chapter(Chapter), 279 | /// A section separator. 280 | Separator, 281 | /// A part title. 282 | PartTitle(String), 283 | } 284 | 285 | impl From for BookItem { 286 | fn from(other: Chapter) -> BookItem { 287 | BookItem::Chapter(other) 288 | } 289 | } 290 | 291 | /// The representation of a "chapter", usually mapping to a single file on 292 | /// disk however it may contain multiple sub-chapters. 293 | #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] 294 | pub struct Chapter { 295 | /// The chapter's name. 296 | pub name: String, 297 | /// The chapter's contents. 298 | pub content: String, 299 | /// The chapter's section number, if it has one. 300 | pub number: Option, 301 | /// Nested items. 302 | pub sub_items: Vec, 303 | /// The chapter's route 304 | pub path: Option, 305 | /// An ordered list of the names of each chapter above this one in the hierarchy. 306 | pub parent_names: Vec, 307 | } 308 | 309 | impl Chapter { 310 | /// Create a new chapter with the provided content. 311 | pub fn new>( 312 | name: &str, 313 | content: String, 314 | p: P, 315 | parent_names: Vec, 316 | ) -> Chapter { 317 | let path: R = p.into(); 318 | Chapter { 319 | name: name.to_string(), 320 | content, 321 | path: Some(path.clone()), 322 | parent_names, 323 | ..Default::default() 324 | } 325 | } 326 | 327 | /// Create a new draft chapter that is not attached to a source markdown file (and thus 328 | /// has no content). 329 | pub fn new_draft(name: &str, parent_names: Vec) -> Self { 330 | Chapter { 331 | name: name.to_string(), 332 | content: String::new(), 333 | path: None, 334 | parent_names, 335 | ..Default::default() 336 | } 337 | } 338 | 339 | /// Check if the chapter is a draft chapter, meaning it has no path to a source markdown file. 340 | pub fn is_draft_chapter(&self) -> bool { 341 | self.path.is_none() 342 | } 343 | } 344 | 345 | /// Use the provided `Summary` to load a `Book` from disk. 346 | /// 347 | /// You need to pass in the book's source directory because all the links in 348 | /// `SUMMARY.md` give the chapter locations relative to it. 349 | pub(crate) fn load_book_from_disk>( 350 | summary: &Summary, 351 | localized_src_dir: P, 352 | fallback_src_dir: P, 353 | cfg: &Config, 354 | ) -> Result { 355 | debug!("Loading the book from disk"); 356 | 357 | let prefix = summary.prefix_chapters.iter(); 358 | let numbered = summary.numbered_chapters.iter(); 359 | let suffix = summary.suffix_chapters.iter(); 360 | 361 | let summary_items = prefix.chain(numbered).chain(suffix); 362 | 363 | let mut chapters = Vec::new(); 364 | 365 | for summary_item in summary_items { 366 | let chapter = load_summary_item( 367 | summary_item, 368 | localized_src_dir.as_ref(), 369 | fallback_src_dir.as_ref(), 370 | Vec::new(), 371 | cfg, 372 | )?; 373 | chapters.push(chapter); 374 | } 375 | 376 | Ok(Book { 377 | sections: chapters, 378 | chapter_titles: HashMap::new(), 379 | __non_exhaustive: (), 380 | }) 381 | } 382 | 383 | fn load_summary_item + Clone>( 384 | item: &SummaryItem, 385 | localized_src_dir: P, 386 | fallback_src_dir: P, 387 | parent_names: Vec, 388 | cfg: &Config, 389 | ) -> Result { 390 | match item { 391 | SummaryItem::Separator => Ok(BookItem::Separator), 392 | SummaryItem::Link(ref link) => { 393 | load_chapter(link, localized_src_dir, fallback_src_dir, parent_names, cfg) 394 | .map(BookItem::Chapter) 395 | } 396 | SummaryItem::PartTitle(title) => Ok(BookItem::PartTitle(title.clone())), 397 | } 398 | } 399 | 400 | fn load_chapter>( 401 | link: &Link, 402 | localized_src_dir: P, 403 | fallback_src_dir: P, 404 | parent_names: Vec, 405 | cfg: &Config, 406 | ) -> Result { 407 | let src_dir_localized = localized_src_dir.as_ref(); 408 | let src_dir_fallback = fallback_src_dir.as_ref(); 409 | 410 | let mut ch = if let Some(ref link_location) = link.location { 411 | debug!("Loading {} ({})", link.name, link_location.display()); 412 | 413 | let mut src_dir = src_dir_localized; 414 | let mut location = if link_location.is_absolute() { 415 | link_location.clone() 416 | } else { 417 | src_dir.join(link_location) 418 | }; 419 | 420 | if !location.exists() && !link_location.is_absolute() { 421 | src_dir = src_dir_fallback; 422 | location = src_dir.join(link_location); 423 | debug!( 424 | "Falling back to default translation in path \"{}\"", 425 | location.display() 426 | ); 427 | } 428 | if !location.exists() && cfg.build.create_missing { 429 | create_missing_link(&location, &link) 430 | .with_context(|| "Unable to create missing link reference")?; 431 | } 432 | 433 | let mut f = File::open(&location) 434 | .with_context(|| format!("Chapter file not found, {}", link_location.display()))?; 435 | 436 | let mut content = String::new(); 437 | f.read_to_string(&mut content).with_context(|| { 438 | format!("Unable to read \"{}\" ({})", link.name, location.display()) 439 | })?; 440 | 441 | if content.as_bytes().starts_with(b"\xef\xbb\xbf") { 442 | content.replace_range(..3, ""); 443 | } 444 | 445 | let stripped = location 446 | .strip_prefix(&src_dir) 447 | .expect("Chapters are always inside a book"); 448 | 449 | Chapter::new(&link.name, content, stripped, parent_names.clone()) 450 | } else { 451 | Chapter::new_draft(&link.name, parent_names.clone()) 452 | }; 453 | 454 | let mut sub_item_parents = parent_names; 455 | 456 | ch.number = link.number.clone(); 457 | 458 | sub_item_parents.push(link.name.clone()); 459 | let sub_items = link 460 | .nested_items 461 | .iter() 462 | .map(|i| { 463 | load_summary_item( 464 | i, 465 | src_dir_localized, 466 | src_dir_fallback, 467 | sub_item_parents.clone(), 468 | cfg, 469 | ) 470 | }) 471 | .collect::>>()?; 472 | 473 | ch.sub_items = sub_items; 474 | 475 | Ok(ch) 476 | } 477 | 478 | /// A depth-first iterator over the items in a book. 479 | /// 480 | /// # Note 481 | /// 482 | /// This struct shouldn't be created directly, instead prefer the 483 | /// [`Book::iter()`] method. 484 | pub struct BookItems<'a> { 485 | items: VecDeque<&'a BookItem>, 486 | } 487 | 488 | impl<'a> Iterator for BookItems<'a> { 489 | type Item = &'a BookItem; 490 | 491 | fn next(&mut self) -> Option { 492 | let item = self.items.pop_front(); 493 | 494 | if let Some(&BookItem::Chapter(ref ch)) = item { 495 | // if we wanted a breadth-first iterator we'd `extend()` here 496 | for sub_item in ch.sub_items.iter().rev() { 497 | self.items.push_front(sub_item); 498 | } 499 | } 500 | 501 | item 502 | } 503 | } 504 | 505 | impl Display for Chapter { 506 | fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { 507 | if let Some(ref section_number) = self.number { 508 | write!(f, "{} ", section_number)?; 509 | } 510 | 511 | write!(f, "{}", self.name) 512 | } 513 | } 514 | -------------------------------------------------------------------------------- /packages/mdbook-gen/src/rsx.rs: -------------------------------------------------------------------------------- 1 | use mdbook_shared::get_book_content_path; 2 | use proc_macro2::{Span, TokenStream as TokenStream2}; 3 | use quote::{quote, ToTokens}; 4 | use std::{ 5 | iter::Peekable, 6 | path::{Path, PathBuf}, 7 | str::FromStr, 8 | vec, 9 | }; 10 | 11 | use dioxus_rsx::{BodyNode, CallBody, TemplateBody}; 12 | use pulldown_cmark::{Alignment, Event, Options, Parser, Tag}; 13 | use syn::{parse_quote, parse_str, Ident}; 14 | 15 | use syntect::highlighting::ThemeSet; 16 | use syntect::parsing::SyntaxSet; 17 | 18 | use crate::{ 19 | path_to_route_enum, path_to_route_enum_with_section, to_upper_camel_case_for_ident, 20 | EmptyIdentError, 21 | }; 22 | 23 | #[cfg(test)] 24 | use pretty_assertions::assert_eq; 25 | 26 | /// Convert a CallBody to a TokenStream 27 | pub fn callbody_to_tokens(cb: CallBody) -> TokenStream2 { 28 | // Get the tokens 29 | let out = dioxus_autofmt::write_block_out(&cb).unwrap(); 30 | 31 | // Parse the tokens 32 | TokenStream2::from_str(&out).unwrap() 33 | } 34 | 35 | pub(crate) struct Section { 36 | name: String, 37 | } 38 | 39 | impl Section { 40 | fn new(name: &str) -> Self { 41 | Self { 42 | name: name.to_string(), 43 | } 44 | } 45 | 46 | pub(crate) fn fragment(&self) -> String { 47 | self.name 48 | .trim() 49 | .to_lowercase() 50 | .chars() 51 | .filter_map(|char| match char { 52 | '-' | 'a'..='z' | '0'..='9' => Some(char), 53 | ' ' | '_' => Some('-'), 54 | _ => None, 55 | }) 56 | .collect() 57 | } 58 | 59 | pub(crate) fn variant(&self) -> Result { 60 | to_upper_camel_case_for_ident(&self.fragment()) 61 | } 62 | } 63 | 64 | pub(crate) struct ParsedMarkdown { 65 | pub(crate) body: CallBody, 66 | pub(crate) sections: Vec
, 67 | } 68 | 69 | pub fn parse_markdown( 70 | book_path: PathBuf, 71 | path: PathBuf, 72 | markdown: &str, 73 | ) -> syn::Result { 74 | let mut options = Options::empty(); 75 | options.insert( 76 | Options::ENABLE_TABLES 77 | | Options::ENABLE_FOOTNOTES 78 | | Options::ENABLE_STRIKETHROUGH 79 | | Options::ENABLE_TASKLISTS, 80 | ); 81 | 82 | let mut parser = Parser::new_ext(markdown, options); 83 | 84 | let mut rsx_parser = RsxMarkdownParser { 85 | element_stack: vec![], 86 | root_nodes: vec![], 87 | current_table: vec![], 88 | sections: vec![], 89 | in_table_header: false, 90 | iter: parser.by_ref().peekable(), 91 | book_path, 92 | path, 93 | phantom: std::marker::PhantomData, 94 | }; 95 | rsx_parser.parse()?; 96 | while !rsx_parser.element_stack.is_empty() { 97 | rsx_parser.end_node(); 98 | } 99 | 100 | let body = if rsx_parser.root_nodes.is_empty() { 101 | parse_quote! {} 102 | } else { 103 | CallBody::new(TemplateBody::new(rsx_parser.root_nodes)) 104 | }; 105 | 106 | Ok(ParsedMarkdown { 107 | body, 108 | sections: rsx_parser.sections, 109 | }) 110 | } 111 | 112 | struct RsxMarkdownParser<'a, I: Iterator>> { 113 | element_stack: Vec, 114 | root_nodes: Vec, 115 | current_table: Vec, 116 | in_table_header: bool, 117 | iter: Peekable, 118 | book_path: PathBuf, 119 | path: PathBuf, 120 | sections: Vec
, 121 | phantom: std::marker::PhantomData<&'a ()>, 122 | } 123 | 124 | impl<'a, I: Iterator>> RsxMarkdownParser<'a, I> { 125 | fn parse(&mut self) -> syn::Result<()> { 126 | while let Some(event) = self.iter.next() { 127 | self.parse_event(event)?; 128 | } 129 | Ok(()) 130 | } 131 | 132 | fn parse_event(&mut self, event: Event) -> syn::Result<()> { 133 | match event { 134 | pulldown_cmark::Event::Start(start) => { 135 | self.start_element(start)?; 136 | } 137 | pulldown_cmark::Event::End(_) => self.end_node(), 138 | pulldown_cmark::Event::Text(text) => { 139 | let text = escape_text(&text); 140 | self.create_node(BodyNode::Text(parse_quote!(#text))); 141 | } 142 | pulldown_cmark::Event::Code(code) => { 143 | let code = escape_text(&code); 144 | self.create_node(parse_quote! { 145 | code { 146 | #code 147 | } 148 | }) 149 | } 150 | pulldown_cmark::Event::Html(node) => { 151 | let code = escape_text(&node); 152 | self.create_node(parse_quote! { 153 | p { 154 | class: "inline-html-block", 155 | dangerous_inner_html: #code, 156 | } 157 | }) 158 | } 159 | pulldown_cmark::Event::FootnoteReference(_) => {} 160 | pulldown_cmark::Event::SoftBreak => {} 161 | pulldown_cmark::Event::HardBreak => {} 162 | pulldown_cmark::Event::Rule => self.create_node(parse_quote! { 163 | hr {} 164 | }), 165 | pulldown_cmark::Event::TaskListMarker(value) => { 166 | self.write_checkbox(value); 167 | } 168 | } 169 | Ok(()) 170 | } 171 | 172 | fn write_checkbox(&mut self, checked: bool) { 173 | let type_value = if checked { "true" } else { "false" }; 174 | self.create_node(parse_quote! { 175 | input { 176 | r#type: "checkbox", 177 | readonly: true, 178 | class: "mdbook-checkbox", 179 | value: #type_value, 180 | } 181 | }) 182 | } 183 | 184 | fn take_code_or_text(&mut self) -> String { 185 | let mut current_text = String::new(); 186 | loop { 187 | match self.iter.peek() { 188 | Some(pulldown_cmark::Event::Code(text) | pulldown_cmark::Event::Text(text)) => { 189 | current_text += text; 190 | self.iter.next().unwrap(); 191 | } 192 | // Ignore any softbreaks 193 | Some(pulldown_cmark::Event::SoftBreak) => {} 194 | _ => break, 195 | } 196 | } 197 | current_text 198 | } 199 | 200 | fn write_text(&mut self) { 201 | loop { 202 | match self.iter.peek() { 203 | Some(pulldown_cmark::Event::Text(text)) => { 204 | let mut all_text = text.to_string(); 205 | 206 | // Take the text or code event we just inserted 207 | let _ = self.iter.next().unwrap(); 208 | 209 | // If the next block after this is a code block, insert the space in the text before the code block 210 | if let Some(pulldown_cmark::Event::Code(_)) = self.iter.peek() { 211 | all_text.push(' '); 212 | } 213 | let all_text = escape_text(&all_text); 214 | 215 | let text = BodyNode::Text(parse_quote!(#all_text)); 216 | self.create_node(text); 217 | } 218 | Some(pulldown_cmark::Event::Code(code)) => { 219 | let code = code.to_string(); 220 | let code = escape_text(&code); 221 | self.create_node(parse_quote! { 222 | code { 223 | #code 224 | } 225 | }); 226 | 227 | // Take the text or code event we just inserted 228 | let _ = self.iter.next().unwrap(); 229 | } 230 | // Ignore any softbreaks 231 | Some(pulldown_cmark::Event::SoftBreak) => { 232 | let _ = self.iter.next().unwrap(); 233 | } 234 | _ => return, 235 | } 236 | } 237 | } 238 | 239 | fn take_text(&mut self) -> String { 240 | let mut current_text = String::new(); 241 | // pulldown_cmark will create a new text node for each newline. We insert a space 242 | // between each newline to avoid two lines being rendered right next to each other. 243 | let mut insert_space = false; 244 | loop { 245 | match self.iter.peek() { 246 | Some(pulldown_cmark::Event::Text(text) | pulldown_cmark::Event::Code(text)) => { 247 | let starts_with_space = 248 | text.chars().next().filter(|c| c.is_whitespace()).is_some(); 249 | let ends_with_space = 250 | text.chars().last().filter(|c| c.is_whitespace()).is_some(); 251 | if insert_space && !starts_with_space { 252 | current_text.push(' '); 253 | } 254 | current_text += text; 255 | insert_space = !ends_with_space; 256 | _ = self.iter.next().unwrap(); 257 | } 258 | // Ignore any softbreaks 259 | Some(pulldown_cmark::Event::SoftBreak) => { 260 | _ = self.iter.next().unwrap(); 261 | } 262 | _ => break, 263 | } 264 | } 265 | current_text 266 | } 267 | 268 | fn start_element(&mut self, tag: Tag) -> syn::Result<()> { 269 | match tag { 270 | Tag::Paragraph => { 271 | self.start_node(parse_quote! { 272 | p {} 273 | }); 274 | self.write_text(); 275 | } 276 | Tag::Heading(level, _, _) => { 277 | let text = self.take_text(); 278 | let section = Section::new(&text); 279 | let variant = section.variant(); 280 | let section_variant = variant.and_then(|variant| { 281 | path_to_route_enum_with_section( 282 | &self.path, 283 | Ident::new(&variant, Span::call_site()), 284 | ) 285 | }); 286 | let section_variant = match section_variant { 287 | Ok(section_variant) => section_variant, 288 | Err(err) => err.to_token_stream(), 289 | }; 290 | let anchor = section.fragment(); 291 | self.sections.push(section); 292 | let element_name = match level { 293 | pulldown_cmark::HeadingLevel::H1 => Ident::new("h1", Span::call_site()), 294 | pulldown_cmark::HeadingLevel::H2 => Ident::new("h2", Span::call_site()), 295 | pulldown_cmark::HeadingLevel::H3 => Ident::new("h3", Span::call_site()), 296 | pulldown_cmark::HeadingLevel::H4 => Ident::new("h4", Span::call_site()), 297 | pulldown_cmark::HeadingLevel::H5 => Ident::new("h5", Span::call_site()), 298 | pulldown_cmark::HeadingLevel::H6 => Ident::new("h6", Span::call_site()), 299 | }; 300 | let anchor = escape_text(&anchor); 301 | let text = escape_text(&text); 302 | let element = parse_quote! { 303 | #element_name { 304 | id: #anchor, 305 | Link { 306 | to: #section_variant, 307 | class: "header", 308 | #text 309 | } 310 | } 311 | }; 312 | self.start_node(element); 313 | } 314 | Tag::BlockQuote => { 315 | self.start_node(parse_quote! { 316 | blockquote {} 317 | }); 318 | self.write_text(); 319 | } 320 | Tag::CodeBlock(kind) => { 321 | let lang = match kind { 322 | pulldown_cmark::CodeBlockKind::Indented => None, 323 | pulldown_cmark::CodeBlockKind::Fenced(lang) => { 324 | (!lang.is_empty()).then_some(lang) 325 | } 326 | }; 327 | let raw_code = self.take_code_or_text(); 328 | 329 | if lang.as_deref() == Some("inject-dioxus") { 330 | self.start_node(parse_str::(&raw_code).unwrap()); 331 | } else { 332 | let (fname, html) = build_codeblock(raw_code, &self.path)?; 333 | let fname = if let Some(fname) = fname { 334 | quote! { name: #fname.to_string() } 335 | } else { 336 | quote! {} 337 | }; 338 | 339 | self.start_node(parse_quote! { 340 | CodeBlock { 341 | contents: #html, 342 | #fname 343 | } 344 | }); 345 | } 346 | } 347 | Tag::List(first) => { 348 | let name = match first { 349 | Some(_) => Ident::new("ol", Span::call_site()), 350 | None => Ident::new("ul", Span::call_site()), 351 | }; 352 | self.start_node(parse_quote! { 353 | #name {} 354 | }) 355 | } 356 | Tag::Item => self.start_node(parse_quote! { 357 | li {} 358 | }), 359 | Tag::FootnoteDefinition(_) => {} 360 | Tag::Table(alignments) => { 361 | self.current_table = alignments; 362 | self.start_node(parse_quote! { 363 | table {} 364 | }) 365 | } 366 | Tag::TableHead => { 367 | self.in_table_header = true; 368 | self.start_node(parse_quote! { 369 | thead {} 370 | }) 371 | } 372 | Tag::TableRow => self.start_node(parse_quote! { 373 | tr {} 374 | }), 375 | Tag::TableCell => { 376 | let name = if self.in_table_header { "th" } else { "td" }; 377 | let ident = Ident::new(name, Span::call_site()); 378 | self.start_node(parse_quote! { 379 | #ident {} 380 | }) 381 | } 382 | Tag::Emphasis => self.start_node(parse_quote! { 383 | em {} 384 | }), 385 | Tag::Strong => self.start_node(parse_quote! { 386 | strong {} 387 | }), 388 | Tag::Strikethrough => self.start_node(parse_quote! { 389 | s {} 390 | }), 391 | Tag::Link(ty, dest, title) => { 392 | let href = match ty { 393 | pulldown_cmark::LinkType::Email => format!("mailto:{}", dest).to_token_stream(), 394 | _ => { 395 | if dest.starts_with("http") || dest.starts_with("https") { 396 | escape_text(&dest).to_token_stream() 397 | } else { 398 | // If this is a relative link, resolve it relative to the current file 399 | let content_path = 400 | get_book_content_path(&self.book_path).ok_or_else(|| { 401 | syn::Error::new( 402 | Span::call_site(), 403 | "Failed to resolve the content path", 404 | ) 405 | })?; 406 | let content_path = content_path.canonicalize().unwrap(); 407 | let current_file_path = content_path.join(&self.path); 408 | let parent_of_current_file = current_file_path.parent().unwrap(); 409 | let hash; 410 | let dest_without_hash = match dest.split_once('#') { 411 | Some((without_hash, trailing_hash)) => { 412 | hash = Some(trailing_hash); 413 | if without_hash.is_empty() { 414 | current_file_path 415 | .strip_prefix(parent_of_current_file) 416 | .unwrap() 417 | .to_str() 418 | .unwrap() 419 | } else { 420 | without_hash 421 | } 422 | } 423 | None => { 424 | hash = None; 425 | &dest 426 | } 427 | }; 428 | let path = 429 | PathBuf::from(dest_without_hash.to_string()).with_extension("md"); 430 | if path.is_relative() { 431 | let relative_to_current_folder = parent_of_current_file.join(&path); 432 | match relative_to_current_folder 433 | .canonicalize() 434 | .map_err(|e| e.to_string()) 435 | .and_then(|p| { 436 | p.strip_prefix(&content_path) 437 | .map(PathBuf::from) 438 | .map_err(|_| format!("failed to strip prefix {content_path:?} from {p:?}")) 439 | }) { 440 | Ok(resolved) if content_path.join(&resolved).is_file() => { 441 | let result = if let Some(hash) = hash { 442 | let section = Section::new(hash); 443 | match section.variant() { 444 | Ok(variant) => { 445 | path_to_route_enum_with_section(&resolved, Ident::new(&variant, Span::call_site())) 446 | }, 447 | Err(_) => { 448 | Ok(quote! { 449 | compile_error!("Fragment cannot be empty") 450 | }) 451 | } 452 | } 453 | } else { 454 | path_to_route_enum(&resolved) 455 | }; 456 | 457 | match result { 458 | Ok(result) => result, 459 | Err(err) => { 460 | err.to_token_stream() 461 | } 462 | } 463 | }, 464 | Ok(resolved) => { 465 | let err = format!("The file {resolved:?} linked to in {current_file_path:?} does not exist"); 466 | quote! { 467 | compile_error!(#err) 468 | } 469 | }, 470 | Err(e) => { 471 | let err = format!( 472 | "Failed to resolve link {} relative to {}: {}", 473 | path.display(), current_file_path.display(), e 474 | ); 475 | quote! { 476 | compile_error!(#err) 477 | } 478 | } 479 | } 480 | } else { 481 | escape_text(&dest).to_token_stream() 482 | } 483 | } 484 | } 485 | }; 486 | 487 | let title = escape_text(&title); 488 | let title_attr = if !title.is_empty() { 489 | quote! { 490 | title: #title, 491 | } 492 | } else { 493 | quote! {} 494 | }; 495 | 496 | self.start_node(parse_quote! { 497 | Link { 498 | to: #href, 499 | #title_attr 500 | } 501 | }); 502 | 503 | self.write_text(); 504 | } 505 | Tag::Image(_, dest, title) => { 506 | let alt = escape_text(&self.take_text()); 507 | let dest: &str = &dest; 508 | let title = escape_text(&title); 509 | 510 | let should_asset_it = cfg!(feature = "manganis") 511 | && (dest.starts_with("/") 512 | || !(dest.starts_with("https://") || dest.starts_with("http://"))); 513 | 514 | let url = if should_asset_it { 515 | // todo(jon): recognize the url by parsing it and checking if it's external/internal - these might be unreliable heuristics 516 | if dest.ends_with(".png") || dest.ends_with(".jpg") || dest.ends_with(".jpeg") { 517 | let res = quote::quote! { 518 | asset!(#dest, ImageAssetOptions::new().with_avif()) 519 | }; 520 | 521 | res 522 | } else { 523 | quote::quote! { 524 | asset!(#dest) 525 | } 526 | } 527 | } else { 528 | let dest = escape_text(dest); 529 | quote::quote!(#dest) 530 | }; 531 | 532 | if dest.ends_with(".mp4") || dest.ends_with(".mov") { 533 | self.start_node(parse_quote! { 534 | video { 535 | src: #url, 536 | alt: #alt, 537 | title: #title, 538 | autoplay: true, 539 | muted: true, 540 | r#loop: true, 541 | playsinline: true, 542 | preload: "metadata" 543 | } 544 | }) 545 | } else { 546 | self.start_node(parse_quote! { 547 | img { 548 | src: #url, 549 | alt: #alt, 550 | title: #title, 551 | } 552 | }) 553 | } 554 | } 555 | } 556 | Ok(()) 557 | } 558 | 559 | fn start_node(&mut self, node: BodyNode) { 560 | self.element_stack.push(node); 561 | } 562 | 563 | fn end_node(&mut self) { 564 | if let Some(node) = self.element_stack.pop() { 565 | match self.last_mut() { 566 | Some(BodyNode::Element(element)) => { 567 | element.children.push(node); 568 | } 569 | Some(BodyNode::Component(element)) => { 570 | element.children.roots.push(node); 571 | } 572 | None => { 573 | self.root_nodes.push(node); 574 | } 575 | _ => {} 576 | } 577 | } 578 | } 579 | 580 | fn create_node(&mut self, node: BodyNode) { 581 | // Find the list of elements we should add the node to 582 | let element_list = match self.last_mut() { 583 | Some(BodyNode::Element(element)) => &mut element.children, 584 | Some(BodyNode::Component(element)) => &mut element.children.roots, 585 | None => &mut self.root_nodes, 586 | _ => return, 587 | }; 588 | 589 | // If the last element is a text node, add a space between them 590 | if let (Some(BodyNode::Text(last_text)), BodyNode::Text(new_text)) = 591 | (element_list.last_mut(), &node) 592 | { 593 | if !last_text 594 | .input 595 | .source 596 | .value() 597 | .chars() 598 | .last() 599 | .filter(|c| c.is_whitespace()) 600 | .is_some() 601 | && !new_text 602 | .input 603 | .source 604 | .value() 605 | .chars() 606 | .next() 607 | .filter(|c| c.is_whitespace()) 608 | .is_some() 609 | { 610 | element_list.push(parse_quote! { " " }); 611 | } 612 | } 613 | 614 | element_list.push(node); 615 | } 616 | 617 | fn last_mut(&mut self) -> Option<&mut BodyNode> { 618 | self.element_stack.last_mut() 619 | } 620 | } 621 | 622 | fn build_codeblock( 623 | raw_code: String, 624 | path: &PathBuf, 625 | ) -> Result<(Option, String), syn::Error> { 626 | let mut fname = None; 627 | let code = transform_code_block(&path, raw_code, &mut fname)?; 628 | static THEME: once_cell::sync::Lazy = 629 | once_cell::sync::Lazy::new(|| { 630 | let raw = include_str!("../themes/MonokaiDark.thTheme").to_string(); 631 | let mut reader = std::io::Cursor::new(raw.clone()); 632 | ThemeSet::load_from_reader(&mut reader).unwrap() 633 | }); 634 | 635 | let ss = SyntaxSet::load_defaults_newlines(); 636 | let syntax = ss.find_syntax_by_extension("rs").unwrap(); 637 | let html = 638 | syntect::html::highlighted_html_for_string(code.trim_end(), &ss, syntax, &THEME).unwrap(); 639 | 640 | let html = escape_text(&html); 641 | 642 | Ok((fname, html)) 643 | } 644 | 645 | fn transform_code_block( 646 | path: &Path, 647 | code_contents: String, 648 | fname: &mut Option, 649 | ) -> syn::Result { 650 | if !code_contents.starts_with("{{#include") { 651 | return Ok(code_contents); 652 | } 653 | 654 | let mut segments = code_contents.split("{{#"); 655 | let mut output = String::new(); 656 | for segment in segments { 657 | if let Some((plugin, after)) = segment.split_once("}}") { 658 | if plugin.starts_with("include") { 659 | output += &resolve_extension(path, plugin, fname)?; 660 | output += after; 661 | } 662 | } else { 663 | output += segment; 664 | } 665 | } 666 | Ok(output) 667 | } 668 | 669 | fn resolve_extension(path: &Path, ext: &str, fname: &mut Option) -> syn::Result { 670 | if let Some(file) = ext.strip_prefix("include") { 671 | let file = file.trim(); 672 | let mut segment = None; 673 | let file = if let Some((file, file_segment)) = file.split_once(':') { 674 | segment = Some(file_segment); 675 | file 676 | } else { 677 | file 678 | }; 679 | 680 | let result = std::fs::read_to_string(file).map_err(|e| { 681 | syn::Error::new( 682 | Span::call_site(), 683 | format!( 684 | "Failed to read file {}: {} from path {} at cwd {}", 685 | file, 686 | e, 687 | path.display(), 688 | std::env::current_dir().unwrap().display() 689 | ), 690 | ) 691 | })?; 692 | *fname = Some( 693 | PathBuf::from(file) 694 | .file_name() 695 | .unwrap() 696 | .to_string_lossy() 697 | .to_string(), 698 | ); 699 | if let Some(segment) = segment { 700 | // get the text between lines with ANCHOR: segment and ANCHOR_END: segment 701 | let lines = result.lines(); 702 | let mut output = String::new(); 703 | let mut in_segment: bool = false; 704 | // normalize indentation to the first line 705 | let mut first_line_indent = 0; 706 | for line in lines { 707 | if let Some((_, remaining)) = line.split_once("ANCHOR:") { 708 | if remaining.trim() == segment { 709 | in_segment = true; 710 | first_line_indent = line.chars().take_while(|c| c.is_whitespace()).count(); 711 | } 712 | } else if let Some((_, remaining)) = line.split_once("ANCHOR_END:") { 713 | if remaining.trim() == segment { 714 | in_segment = false; 715 | } 716 | } else if in_segment { 717 | for (_, char) in line 718 | .chars() 719 | .enumerate() 720 | .skip_while(|(i, c)| *i < first_line_indent && c.is_whitespace()) 721 | { 722 | output.push(char); 723 | } 724 | output += "\n"; 725 | } 726 | } 727 | if output.ends_with('\n') { 728 | output.pop(); 729 | } 730 | Ok(output) 731 | } else { 732 | Ok(result) 733 | } 734 | } else { 735 | todo!("Unknown extension: {}", ext); 736 | } 737 | } 738 | 739 | fn escape_text(text: &str) -> String { 740 | text.replace('{', "{{").replace('}', "}}") 741 | } 742 | 743 | #[test] 744 | fn parse_link() { 745 | let markdown = r#" 746 | # Chapter 1 747 | [Chapter 2](./chapter_2.md) 748 | 749 | Some assets: 750 | ![some_external](https://avatars.githubusercontent.com/u/79236386?s=200&v=4) 751 | ![some_local](/example-book/assetsasd/logo) 752 | ![some_local1](/example-book/assets1/logo.png) 753 | ![some_local2](/example-book/assets2/logo.png) 754 | "#; 755 | 756 | let mut options = Options::empty(); 757 | options.insert(Options::ENABLE_STRIKETHROUGH); 758 | options.insert(Options::ENABLE_TABLES); 759 | options.insert(Options::ENABLE_TASKLISTS); 760 | let mut parser = Parser::new_ext(markdown, options); 761 | 762 | let mut rsx_parser = RsxMarkdownParser { 763 | element_stack: vec![], 764 | root_nodes: vec![], 765 | current_table: vec![], 766 | sections: vec![], 767 | in_table_header: false, 768 | iter: parser.by_ref().peekable(), 769 | path: PathBuf::from("../../example-book/en/chapter_1.md"), 770 | book_path: PathBuf::from("../../example-book"), 771 | phantom: std::marker::PhantomData, 772 | }; 773 | 774 | rsx_parser.parse().unwrap(); 775 | while !rsx_parser.element_stack.is_empty() { 776 | rsx_parser.end_node(); 777 | } 778 | 779 | let body = CallBody::new(TemplateBody::new(rsx_parser.root_nodes)); 780 | 781 | dbg!(&body); 782 | 783 | let fmted = dioxus_autofmt::write_block_out(&body).unwrap(); 784 | println!("{}", fmted); 785 | 786 | // Parse the tokens 787 | let tokens_out = TokenStream2::from_str(&fmted).unwrap(); 788 | 789 | let out: syn::File = parse_quote! { 790 | #[component(no_case_check)] 791 | pub fn Hmm() -> dioxus::prelude::Element { 792 | use dioxus::prelude::*; 793 | rsx! { 794 | #tokens_out 795 | } 796 | } 797 | }; 798 | 799 | let fmted = prettyplease::unparse(&out); 800 | 801 | println!("{}", fmted); 802 | } 803 | 804 | #[test] 805 | fn parse_softbreaks() { 806 | let markdown = r#" 807 | # Programmatic Navigation 808 | 809 | Sometimes we want our application to navigate to another page without having the 810 | user click on a link. This is called programmatic navigation. 811 | 812 | ## Using a Navigator 813 | 814 | We can get a navigator with the `navigator` function which returns a `Navigator`. 815 | "#; 816 | 817 | let mut options = Options::empty(); 818 | options.insert(Options::ENABLE_STRIKETHROUGH); 819 | options.insert(Options::ENABLE_TABLES); 820 | options.insert(Options::ENABLE_TASKLISTS); 821 | let mut parser = Parser::new_ext(markdown, options); 822 | 823 | let mut rsx_parser = RsxMarkdownParser { 824 | element_stack: vec![], 825 | root_nodes: vec![], 826 | current_table: vec![], 827 | sections: vec![], 828 | in_table_header: false, 829 | iter: parser.by_ref().peekable(), 830 | path: PathBuf::from("../../example-book/en/chapter_1.md"), 831 | book_path: PathBuf::from("../../example-book"), 832 | phantom: std::marker::PhantomData, 833 | }; 834 | 835 | rsx_parser.parse().unwrap(); 836 | while !rsx_parser.element_stack.is_empty() { 837 | rsx_parser.end_node(); 838 | } 839 | 840 | let body = CallBody::new(TemplateBody::new(rsx_parser.root_nodes)); 841 | let fmted = dioxus_autofmt::write_block_out(&body).unwrap(); 842 | println!("{}", fmted); 843 | 844 | let expected_tokens: CallBody = parse_quote! { 845 | h1 { id: "programmatic-navigation", 846 | Link { 847 | to: BookRoute::ExampleBookEnChapter1 { 848 | section: ExampleBookEnChapter1Section::ProgrammaticNavigation 849 | }, 850 | class: "header", 851 | "Programmatic Navigation" 852 | } 853 | } 854 | p { 855 | "Sometimes we want our application to navigate to another page without having the" 856 | " " 857 | "user click on a link. This is called programmatic navigation." 858 | } 859 | h2 { id: "using-a-navigator", 860 | Link { 861 | to: BookRoute::ExampleBookEnChapter1 { 862 | section: ExampleBookEnChapter1Section::UsingANavigator 863 | }, 864 | class: "header", 865 | "Using a Navigator" 866 | } 867 | } 868 | p { 869 | "We can get a navigator with the " 870 | code { "navigator" } 871 | " function which returns a " 872 | code { "Navigator" } 873 | "." 874 | } 875 | }; 876 | 877 | assert_eq!(expected_tokens.body, body.body); 878 | } 879 | 880 | #[test] 881 | fn parse_code_headers() { 882 | let markdown = r#"# This `is` a header about `MdBook`"#; 883 | 884 | let mut options = Options::empty(); 885 | options.insert(Options::ENABLE_STRIKETHROUGH); 886 | options.insert(Options::ENABLE_TABLES); 887 | options.insert(Options::ENABLE_TASKLISTS); 888 | let mut parser = Parser::new_ext(markdown, options); 889 | 890 | let mut rsx_parser = RsxMarkdownParser { 891 | element_stack: vec![], 892 | root_nodes: vec![], 893 | current_table: vec![], 894 | sections: vec![], 895 | in_table_header: false, 896 | iter: parser.by_ref().peekable(), 897 | path: PathBuf::from("../../example-book/en/chapter_1.md"), 898 | book_path: PathBuf::from("../../example-book"), 899 | phantom: std::marker::PhantomData, 900 | }; 901 | 902 | rsx_parser.parse().unwrap(); 903 | while !rsx_parser.element_stack.is_empty() { 904 | rsx_parser.end_node(); 905 | } 906 | 907 | let body = CallBody::new(TemplateBody::new(rsx_parser.root_nodes)); 908 | let fmted = dioxus_autofmt::write_block_out(&body).unwrap(); 909 | println!("{}", fmted); 910 | 911 | let expected_tokens: CallBody = parse_quote! { 912 | h1 { id: "this-is-a-header-about-mdbook", 913 | Link { 914 | to: BookRoute::ExampleBookEnChapter1 { 915 | section: ExampleBookEnChapter1Section::ThisIsAHeaderAboutMdbook 916 | }, 917 | class: "header", 918 | "This is a header about MdBook" 919 | } 920 | } 921 | }; 922 | 923 | assert_eq!(expected_tokens.body, body.body); 924 | } 925 | 926 | #[test] 927 | fn syn_parsing_race() { 928 | let alt1 = "some_alt_text"; 929 | 930 | let res1 = quote::quote! { 931 | asset!(#alt1, ImageAssetOptions::new().with_avif()) 932 | }; 933 | 934 | let alt2 = "some_alt_text2"; 935 | 936 | let res2 = quote::quote! { 937 | asset!(#alt2, ImageAssetOptions::new().with_avif()) 938 | }; 939 | 940 | println!("{}", res1.to_string()); 941 | println!("{}", res2.to_string()); 942 | 943 | let out_toks1: BodyNode = parse_quote! { 944 | img { alt: #alt1, src: #res1 } 945 | }; 946 | 947 | let out_toks2: BodyNode = parse_quote! { 948 | img { alt: #alt2, src: #res2 } 949 | }; 950 | 951 | println!("{:?}", out_toks1); 952 | println!("{:?}", out_toks2); 953 | } 954 | 955 | #[test] 956 | fn parses_codeblocks() { 957 | let code = r##" 958 | {"timestamp":" 9.927s","level":"INFO","message":"Bundled app successfully!","target":"dx::cli::bundle"} 959 | {"timestamp":" 9.927s","level":"INFO","message":"App produced 2 outputs:","target":"dx::cli::bundle"} 960 | {"timestamp":" 9.927s","level":"INFO","message":"app - [target/dx/hot_dog/bundle/macos/bundle/macos/HotDog.app]","target":"dx::cli::bundle"} 961 | {"timestamp":" 9.927s","level":"INFO","message":"dmg - [target/dx/hot_dog/bundle/macos/bundle/dmg/HotDog_0.1.0_aarch64.dmg]","target":"dx::cli::bundle"} 962 | {"timestamp":" 9.927s","level":"DEBUG","json":"{\"BundleOutput\":{\"bundles\":[\"target/dx/hot_dog/bundle/macos/bundle/macos/HotDog.app\"]}}"} 963 | "##; 964 | 965 | let (name, contents) = build_codeblock( 966 | code.to_string(), 967 | &PathBuf::from("../../example-book/en/chapter_1.md"), 968 | ) 969 | .unwrap(); 970 | 971 | println!("{:?}", name); 972 | println!("{}", contents); 973 | } 974 | -------------------------------------------------------------------------------- /packages/mdbook-shared/src/summary.rs: -------------------------------------------------------------------------------- 1 | use crate::{errors::*, get_book_content_path}; 2 | use log::{debug, trace, warn}; 3 | use memchr::{self, Memchr}; 4 | use pulldown_cmark::{self, Event, HeadingLevel, Tag}; 5 | use serde::{Deserialize, Serialize}; 6 | use std::fmt::{self, Display, Formatter}; 7 | use std::iter::FromIterator; 8 | use std::ops::{Deref, DerefMut}; 9 | use std::path::{Path, PathBuf}; 10 | 11 | pub fn get_summary_path(mdbook_root: impl AsRef) -> Option { 12 | let mdbook_root = mdbook_root.as_ref(); 13 | let path = mdbook_root.join("SUMMARY.md"); 14 | if path.exists() { 15 | return Some(path); 16 | } 17 | let path = mdbook_root.join("src").join("SUMMARY.md"); 18 | if path.exists() { 19 | return Some(path); 20 | } 21 | None 22 | } 23 | 24 | /// Parse the text from a `SUMMARY.md` file into a sort of "recipe" to be 25 | /// used when loading a book from disk.a 26 | /// 27 | /// # Summary Format 28 | /// 29 | /// **Title:** It's common practice to begin with a title, generally 30 | /// "# Summary". It's not mandatory and the parser (currently) ignores it, so 31 | /// you can too if you feel like it. 32 | /// 33 | /// **Prefix Chapter:** Before the main numbered chapters you can add a couple 34 | /// of elements that will not be numbered. This is useful for forewords, 35 | /// introductions, etc. There are however some constraints. You can not nest 36 | /// prefix chapters, they should all be on the root level. And you can not add 37 | /// prefix chapters once you have added numbered chapters. 38 | /// 39 | /// ```markdown 40 | /// [Title of prefix element](relative/path/to/markdown.md) 41 | /// ``` 42 | /// 43 | /// **Part Title:** An optional title for the next collect of numbered chapters. The numbered 44 | /// chapters can be broken into as many parts as desired. 45 | /// 46 | /// **Numbered Chapter:** Numbered chapters are the main content of the book, 47 | /// they 48 | /// will be numbered and can be nested, resulting in a nice hierarchy (chapters, 49 | /// sub-chapters, etc.) 50 | /// 51 | /// ```markdown 52 | /// # Title of Part 53 | /// 54 | /// - [Title of the Chapter](relative/path/to/markdown.md) 55 | /// ``` 56 | /// 57 | /// You can either use - or * to indicate a numbered chapter, the parser doesn't 58 | /// care but you'll probably want to stay consistent. 59 | /// 60 | /// **Suffix Chapter:** After the numbered chapters you can add a couple of 61 | /// non-numbered chapters. They are the same as prefix chapters but come after 62 | /// the numbered chapters instead of before. 63 | /// 64 | /// All other elements are unsupported and will be ignored at best or result in 65 | /// an error. 66 | pub fn parse_summary(path: &Path, summary: &str) -> Result> { 67 | let parser = SummaryParser::new(Some(path), summary); 68 | parser.parse() 69 | } 70 | 71 | /// The parsed `SUMMARY.md`, specifying how the book should be laid out. 72 | #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] 73 | pub struct Summary { 74 | /// An optional title for the `SUMMARY.md`, currently just ignored. 75 | pub title: Option, 76 | /// Chapters before the main text (e.g. an introduction). 77 | pub prefix_chapters: Vec>, 78 | /// The main numbered chapters of the book, broken into one or more possibly named parts. 79 | pub numbered_chapters: Vec>, 80 | /// Items which come after the main document (e.g. a conclusion). 81 | pub suffix_chapters: Vec>, 82 | } 83 | 84 | /// A struct representing an entry in the `SUMMARY.md`, possibly with nested 85 | /// entries. 86 | /// 87 | /// This is roughly the equivalent of `[Some section](./path/to/file.md)`. 88 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] 89 | pub struct Link { 90 | /// The name of the chapter. 91 | pub name: String, 92 | /// The location of the chapter's source file, taking the book's `src` 93 | /// directory as the root. 94 | pub location: Option, 95 | /// The section number, if this chapter is in the numbered section. 96 | pub number: Option, 97 | /// Any nested items this chapter may contain. 98 | pub nested_items: Vec>, 99 | } 100 | 101 | impl Link { 102 | /// Create a new link with no nested items. 103 | pub fn new, P: Into>(name: S, location: P) -> Link { 104 | Link { 105 | name: name.into(), 106 | location: Some(location.into()), 107 | number: None, 108 | nested_items: Vec::new(), 109 | } 110 | } 111 | } 112 | 113 | impl Default for Link { 114 | fn default() -> Self { 115 | Link { 116 | name: String::new(), 117 | location: Some(R::default()), 118 | number: None, 119 | nested_items: Vec::new(), 120 | } 121 | } 122 | } 123 | 124 | /// An item in `SUMMARY.md` which could be either a separator or a `Link`. 125 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] 126 | pub enum SummaryItem { 127 | /// A link to a chapter. 128 | Link(Link), 129 | /// A separator (`---`). 130 | Separator, 131 | /// A part title. 132 | PartTitle(String), 133 | } 134 | 135 | impl SummaryItem { 136 | pub fn maybe_link_mut(&mut self) -> Option<&mut Link> { 137 | match *self { 138 | SummaryItem::Link(ref mut l) => Some(l), 139 | _ => None, 140 | } 141 | } 142 | pub fn maybe_link(&self) -> Option<&Link> { 143 | match *self { 144 | SummaryItem::Link(ref l) => Some(l), 145 | _ => None, 146 | } 147 | } 148 | } 149 | 150 | impl From> for SummaryItem { 151 | fn from(other: Link) -> SummaryItem { 152 | SummaryItem::Link(other) 153 | } 154 | } 155 | 156 | /// A recursive descent (-ish) parser for a `SUMMARY.md`. 157 | /// 158 | /// 159 | /// # Grammar 160 | /// 161 | /// The `SUMMARY.md` file has a grammar which looks something like this: 162 | /// 163 | /// ```text 164 | /// summary ::= title prefix_chapters numbered_chapters 165 | /// suffix_chapters 166 | /// title ::= "# " TEXT 167 | /// | EPSILON 168 | /// prefix_chapters ::= item* 169 | /// suffix_chapters ::= item* 170 | /// numbered_chapters ::= part+ 171 | /// part ::= title dotted_item+ 172 | /// dotted_item ::= INDENT* DOT_POINT item 173 | /// item ::= link 174 | /// | separator 175 | /// separator ::= "---" 176 | /// link ::= "[" TEXT "]" "(" TEXT ")" 177 | /// DOT_POINT ::= "-" 178 | /// | "*" 179 | /// ``` 180 | /// 181 | /// > **Note:** the `TEXT` terminal is "normal" text, and should (roughly) 182 | /// > match the following regex: "[^<>\n[]]+". 183 | struct SummaryParser<'a> { 184 | src_path: Option<&'a Path>, 185 | src: &'a str, 186 | stream: pulldown_cmark::OffsetIter<'a, 'a>, 187 | offset: usize, 188 | 189 | /// We can't actually put an event back into the `OffsetIter` stream, so instead we store it 190 | /// here until somebody calls `next_event` again. 191 | back: Option>, 192 | } 193 | 194 | /// Reads `Events` from the provided stream until the corresponding 195 | /// `Event::End` is encountered which matches the `$delimiter` pattern. 196 | /// 197 | /// This is the equivalent of doing 198 | /// `$stream.take_while(|e| e != $delimiter).collect()` but it allows you to 199 | /// use pattern matching and you won't get errors because `take_while()` 200 | /// moves `$stream` out of self. 201 | macro_rules! collect_events { 202 | ($stream:expr,start $delimiter:pat) => { 203 | collect_events!($stream, Event::Start($delimiter)) 204 | }; 205 | ($stream:expr,end $delimiter:pat) => { 206 | collect_events!($stream, Event::End($delimiter)) 207 | }; 208 | ($stream:expr, $delimiter:pat) => {{ 209 | let mut events = Vec::new(); 210 | 211 | loop { 212 | let event = $stream.next().map(|(ev, _range)| ev); 213 | trace!("Next event: {:?}", event); 214 | 215 | match event { 216 | Some($delimiter) => break, 217 | Some(other) => events.push(other), 218 | None => { 219 | debug!( 220 | "Reached end of stream without finding the closing pattern, {}", 221 | stringify!($delimiter) 222 | ); 223 | break; 224 | } 225 | } 226 | } 227 | 228 | events 229 | }}; 230 | } 231 | 232 | impl<'a> SummaryParser<'a> { 233 | fn new(path: Option<&'a Path>, text: &'a str) -> SummaryParser<'a> { 234 | let pulldown_parser = pulldown_cmark::Parser::new(text).into_offset_iter(); 235 | 236 | SummaryParser { 237 | src_path: path, 238 | src: text, 239 | stream: pulldown_parser, 240 | offset: 0, 241 | back: None, 242 | } 243 | } 244 | 245 | /// Get the current line and column to give the user more useful error 246 | /// messages. 247 | fn current_location(&self) -> (usize, usize) { 248 | let previous_text = self.src[..self.offset].as_bytes(); 249 | let line = Memchr::new(b'\n', previous_text).count() + 1; 250 | let start_of_line = memchr::memrchr(b'\n', previous_text).unwrap_or(0); 251 | let col = self.src[start_of_line..self.offset].chars().count(); 252 | 253 | (line, col) 254 | } 255 | 256 | /// Parse the text the `SummaryParser` was created with. 257 | fn parse(mut self) -> Result> { 258 | let title = self.parse_title(); 259 | 260 | let prefix_chapters = self.parse_affix(true).map_err(|err| { 261 | anyhow::anyhow!("There was an error parsing the prefix chapters: {err}") 262 | })?; 263 | let numbered_chapters = self.parse_parts().map_err(|err| { 264 | anyhow::anyhow!("There was an error parsing the numbered chapters: {err}") 265 | })?; 266 | let suffix_chapters = self.parse_affix(false).map_err(|err| { 267 | anyhow::anyhow!("There was an error parsing the suffix chapters: {err}") 268 | })?; 269 | 270 | Ok(Summary { 271 | title, 272 | prefix_chapters, 273 | numbered_chapters, 274 | suffix_chapters, 275 | }) 276 | } 277 | 278 | /// Parse the affix chapters. 279 | fn parse_affix(&mut self, is_prefix: bool) -> Result>> { 280 | let mut items = Vec::new(); 281 | debug!( 282 | "Parsing {} items", 283 | if is_prefix { "prefix" } else { "suffix" } 284 | ); 285 | 286 | loop { 287 | match self.next_event() { 288 | Some(ev @ Event::Start(Tag::List(..))) 289 | | Some(ev @ Event::Start(Tag::Heading(HeadingLevel::H1, ..))) => { 290 | if is_prefix { 291 | // we've finished prefix chapters and are at the start 292 | // of the numbered section. 293 | self.back(ev); 294 | break; 295 | } else { 296 | bail!(self.parse_error("Suffix chapters cannot be followed by a list")); 297 | } 298 | } 299 | Some(Event::Start(Tag::Link(_type, href, _title))) => { 300 | let link = self.parse_link(href.to_string())?; 301 | items.push(SummaryItem::Link(link)); 302 | } 303 | Some(Event::Rule) => items.push(SummaryItem::Separator), 304 | Some(_) => {} 305 | None => break, 306 | } 307 | } 308 | 309 | Ok(items) 310 | } 311 | 312 | fn parse_parts(&mut self) -> Result>> { 313 | let mut parts = vec![]; 314 | 315 | // We want the section numbers to be continues through all parts. 316 | let mut root_number = SectionNumber::default(); 317 | let mut root_items = 0; 318 | 319 | loop { 320 | // Possibly match a title or the end of the "numbered chapters part". 321 | let title = match self.next_event() { 322 | Some(ev @ Event::Start(Tag::Paragraph)) => { 323 | // we're starting the suffix chapters 324 | self.back(ev); 325 | break; 326 | } 327 | 328 | Some(Event::Start(Tag::Heading(HeadingLevel::H1, ..))) => { 329 | debug!("Found a h1 in the SUMMARY"); 330 | 331 | let tags = collect_events!(self.stream, end Tag::Heading(HeadingLevel::H1, ..)); 332 | Some(stringify_events(tags)) 333 | } 334 | 335 | Some(ev) => { 336 | self.back(ev); 337 | None 338 | } 339 | 340 | None => break, // EOF, bail... 341 | }; 342 | 343 | // Parse the rest of the part. 344 | let numbered_chapters = self 345 | .parse_numbered(&mut root_items, &mut root_number) 346 | .map_err(|err| { 347 | anyhow::anyhow!("There was an error parsing the numbered chapters: {err}") 348 | })?; 349 | 350 | if let Some(title) = title { 351 | parts.push(SummaryItem::PartTitle(title)); 352 | } 353 | parts.extend(numbered_chapters); 354 | } 355 | 356 | Ok(parts) 357 | } 358 | 359 | /// Finishes parsing a link once the `Event::Start(Tag::Link(..))` has been opened. 360 | fn parse_link(&mut self, href: String) -> Result> { 361 | let href = href.replace("%20", " "); 362 | let link_content = collect_events!(self.stream, end Tag::Link(..)); 363 | let name = stringify_events(link_content); 364 | 365 | let path = if href.is_empty() { 366 | None 367 | } else { 368 | let path_buf = PathBuf::from(href.clone()); 369 | if let Some(src_path) = self.src_path { 370 | // check if it under the en directory 371 | let full_path = get_book_content_path(PathBuf::from(&src_path)) 372 | .unwrap() 373 | .join(&path_buf); 374 | if !full_path.exists() { 375 | return Err(anyhow::anyhow!( 376 | "The path {:?} does not exist (created from {href})", 377 | full_path 378 | )); 379 | } 380 | } 381 | Some(path_buf) 382 | }; 383 | 384 | Ok(Link { 385 | name, 386 | location: path, 387 | number: None, 388 | nested_items: Vec::new(), 389 | }) 390 | } 391 | 392 | /// Parse the numbered chapters. 393 | fn parse_numbered( 394 | &mut self, 395 | root_items: &mut u32, 396 | root_number: &mut SectionNumber, 397 | ) -> Result>> { 398 | let mut items = Vec::new(); 399 | 400 | // For the first iteration, we want to just skip any opening paragraph tags, as that just 401 | // marks the start of the list. But after that, another opening paragraph indicates that we 402 | // have started a new part or the suffix chapters. 403 | let mut first = true; 404 | 405 | loop { 406 | match self.next_event() { 407 | Some(ev @ Event::Start(Tag::Paragraph)) => { 408 | if !first { 409 | // we're starting the suffix chapters 410 | self.back(ev); 411 | break; 412 | } 413 | } 414 | // The expectation is that pulldown cmark will terminate a paragraph before a new 415 | // heading, so we can always count on this to return without skipping headings. 416 | Some(ev @ Event::Start(Tag::Heading(HeadingLevel::H1, ..))) => { 417 | // we're starting a new part 418 | self.back(ev); 419 | break; 420 | } 421 | Some(ev @ Event::Start(Tag::List(..))) => { 422 | self.back(ev); 423 | let mut bunch_of_items = self.parse_nested_numbered(root_number)?; 424 | 425 | // if we've resumed after something like a rule the root sections 426 | // will be numbered from 1. We need to manually go back and update 427 | // them 428 | update_section_numbers(&mut bunch_of_items, 0, *root_items); 429 | *root_items += bunch_of_items.len() as u32; 430 | items.extend(bunch_of_items); 431 | } 432 | Some(Event::Start(other_tag)) => { 433 | trace!("Skipping contents of {:?}", other_tag); 434 | 435 | // Skip over the contents of this tag 436 | while let Some(event) = self.next_event() { 437 | if event == Event::End(other_tag.clone()) { 438 | break; 439 | } 440 | } 441 | } 442 | Some(Event::Rule) => { 443 | items.push(SummaryItem::Separator); 444 | } 445 | 446 | // something else... ignore 447 | Some(_) => {} 448 | 449 | // EOF, bail... 450 | None => { 451 | break; 452 | } 453 | } 454 | 455 | // From now on, we cannot accept any new paragraph opening tags. 456 | first = false; 457 | } 458 | 459 | Ok(items) 460 | } 461 | 462 | /// Push an event back to the tail of the stream. 463 | fn back(&mut self, ev: Event<'a>) { 464 | assert!(self.back.is_none()); 465 | trace!("Back: {:?}", ev); 466 | self.back = Some(ev); 467 | } 468 | 469 | fn next_event(&mut self) -> Option> { 470 | let next = self.back.take().or_else(|| { 471 | self.stream.next().map(|(ev, range)| { 472 | self.offset = range.start; 473 | ev 474 | }) 475 | }); 476 | 477 | trace!("Next event: {:?}", next); 478 | 479 | next 480 | } 481 | 482 | fn parse_nested_numbered( 483 | &mut self, 484 | parent: &SectionNumber, 485 | ) -> Result>> { 486 | debug!("Parsing numbered chapters at level {}", parent); 487 | let mut items = Vec::new(); 488 | 489 | loop { 490 | match self.next_event() { 491 | Some(Event::Start(Tag::Item)) => { 492 | let item = self.parse_nested_item(parent, items.len())?; 493 | items.push(item); 494 | } 495 | Some(Event::Start(Tag::List(..))) => { 496 | // Skip this tag after comment bacause it is not nested. 497 | if items.is_empty() { 498 | continue; 499 | } 500 | // recurse to parse the nested list 501 | let (_, last_item) = get_last_link(&mut items)?; 502 | let last_item_number = last_item 503 | .number 504 | .as_ref() 505 | .expect("All numbered chapters have numbers"); 506 | 507 | let sub_items = self.parse_nested_numbered(last_item_number)?; 508 | 509 | last_item.nested_items = sub_items; 510 | } 511 | Some(Event::End(Tag::List(..))) => break, 512 | Some(_) => {} 513 | None => break, 514 | } 515 | } 516 | 517 | Ok(items) 518 | } 519 | 520 | fn parse_nested_item( 521 | &mut self, 522 | parent: &SectionNumber, 523 | num_existing_items: usize, 524 | ) -> Result> { 525 | loop { 526 | match self.next_event() { 527 | Some(Event::Start(Tag::Paragraph)) => continue, 528 | Some(Event::Start(Tag::Link(_type, href, _title))) => { 529 | let mut link = self.parse_link(href.to_string())?; 530 | 531 | let mut number = parent.clone(); 532 | number.0.push(num_existing_items as u32 + 1); 533 | trace!( 534 | "Found chapter: {} {} ({})", 535 | number, 536 | link.name, 537 | link.location 538 | .as_ref() 539 | .map(|p| p.to_str().unwrap_or("")) 540 | .unwrap_or("[draft]") 541 | ); 542 | 543 | link.number = Some(number); 544 | 545 | return Ok(SummaryItem::Link(link)); 546 | } 547 | other => { 548 | warn!("Expected a start of a link, actually got {:?}", other); 549 | bail!(self.parse_error( 550 | "The link items for nested chapters must only contain a hyperlink" 551 | )); 552 | } 553 | } 554 | } 555 | } 556 | 557 | fn parse_error(&self, msg: D) -> Error { 558 | let (line, col) = self.current_location(); 559 | anyhow::anyhow!( 560 | "failed to parse SUMMARY.md line {}, column {}: {}", 561 | line, 562 | col, 563 | msg 564 | ) 565 | } 566 | 567 | /// Try to parse the title line. 568 | fn parse_title(&mut self) -> Option { 569 | loop { 570 | match self.next_event() { 571 | Some(Event::Start(Tag::Heading(HeadingLevel::H1, ..))) => { 572 | debug!("Found a h1 in the SUMMARY"); 573 | 574 | let tags = collect_events!(self.stream, end Tag::Heading(HeadingLevel::H1, ..)); 575 | return Some(stringify_events(tags)); 576 | } 577 | // Skip a HTML element such as a comment line. 578 | Some(Event::Html(_)) => {} 579 | // Otherwise, no title. 580 | _ => return None, 581 | } 582 | } 583 | } 584 | } 585 | 586 | fn update_section_numbers(sections: &mut [SummaryItem], level: usize, by: u32) { 587 | for section in sections { 588 | if let SummaryItem::Link(ref mut link) = *section { 589 | if let Some(ref mut number) = link.number { 590 | number.0[level] += by; 591 | } 592 | 593 | update_section_numbers(&mut link.nested_items, level, by); 594 | } 595 | } 596 | } 597 | 598 | /// Gets a pointer to the last `Link` in a list of `SummaryItem`s, and its 599 | /// index. 600 | fn get_last_link(links: &mut [SummaryItem]) -> Result<(usize, &mut Link)> { 601 | links 602 | .iter_mut() 603 | .enumerate() 604 | .filter_map(|(i, item)| item.maybe_link_mut().map(|l| (i, l))).next_back() 605 | .ok_or_else(|| 606 | anyhow::anyhow!("Unable to get last link because the list of SummaryItems doesn't contain any Links") 607 | ) 608 | } 609 | 610 | /// Removes the styling from a list of Markdown events and returns just the 611 | /// plain text. 612 | fn stringify_events(events: Vec>) -> String { 613 | events 614 | .into_iter() 615 | .filter_map(|t| match t { 616 | Event::Text(text) | Event::Code(text) => Some(text.into_string()), 617 | Event::SoftBreak => Some(String::from(" ")), 618 | _ => None, 619 | }) 620 | .collect() 621 | } 622 | 623 | /// A section number like "1.2.3", basically just a newtype'd `Vec` with 624 | /// a pretty `Display` impl. 625 | #[derive(Debug, PartialEq, Clone, Default, Serialize, Deserialize)] 626 | pub struct SectionNumber(pub Vec); 627 | 628 | impl Display for SectionNumber { 629 | fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { 630 | if self.0.is_empty() { 631 | write!(f, "0") 632 | } else { 633 | for item in &self.0 { 634 | write!(f, "{}.", item)?; 635 | } 636 | Ok(()) 637 | } 638 | } 639 | } 640 | 641 | impl Deref for SectionNumber { 642 | type Target = Vec; 643 | fn deref(&self) -> &Self::Target { 644 | &self.0 645 | } 646 | } 647 | 648 | impl DerefMut for SectionNumber { 649 | fn deref_mut(&mut self) -> &mut Self::Target { 650 | &mut self.0 651 | } 652 | } 653 | 654 | impl FromIterator for SectionNumber { 655 | fn from_iter>(it: I) -> Self { 656 | SectionNumber(it.into_iter().collect()) 657 | } 658 | } 659 | 660 | #[cfg(test)] 661 | mod tests { 662 | use super::*; 663 | 664 | #[test] 665 | fn section_number_has_correct_dotted_representation() { 666 | let inputs = vec![ 667 | (vec![0], "0."), 668 | (vec![1, 3], "1.3."), 669 | (vec![1, 2, 3], "1.2.3."), 670 | ]; 671 | 672 | for (input, should_be) in inputs { 673 | let section_number = SectionNumber(input).to_string(); 674 | assert_eq!(section_number, should_be); 675 | } 676 | } 677 | 678 | #[test] 679 | fn parse_initial_title() { 680 | let src = "# Summary"; 681 | let should_be = String::from("Summary"); 682 | 683 | let mut parser = SummaryParser::new(None, src); 684 | let got = parser.parse_title().unwrap(); 685 | 686 | assert_eq!(got, should_be); 687 | } 688 | 689 | #[test] 690 | fn parse_title_with_styling() { 691 | let src = "# My **Awesome** Summary"; 692 | let should_be = String::from("My Awesome Summary"); 693 | 694 | let mut parser = SummaryParser::new(None, src); 695 | let got = parser.parse_title().unwrap(); 696 | 697 | assert_eq!(got, should_be); 698 | } 699 | 700 | #[test] 701 | fn convert_markdown_events_to_a_string() { 702 | let src = "Hello *World*, `this` is some text [and a link](./path/to/link)"; 703 | let should_be = "Hello World, this is some text and a link"; 704 | 705 | let events = pulldown_cmark::Parser::new(src).collect(); 706 | let got = stringify_events(events); 707 | 708 | assert_eq!(got, should_be); 709 | } 710 | 711 | #[test] 712 | fn parse_some_prefix_items() { 713 | let src = "[First](./first.md)\n[Second](./second.md)\n"; 714 | let mut parser = SummaryParser::new(None, src); 715 | 716 | let should_be = vec![ 717 | SummaryItem::Link(Link { 718 | name: String::from("First"), 719 | location: Some(PathBuf::from("./first.md")), 720 | ..Default::default() 721 | }), 722 | SummaryItem::Link(Link { 723 | name: String::from("Second"), 724 | location: Some(PathBuf::from("./second.md")), 725 | ..Default::default() 726 | }), 727 | ]; 728 | 729 | let got = parser.parse_affix(true).unwrap(); 730 | 731 | assert_eq!(got, should_be); 732 | } 733 | 734 | #[test] 735 | fn parse_prefix_items_with_a_separator() { 736 | let src = "[First](./first.md)\n\n---\n\n[Second](./second.md)\n"; 737 | let mut parser = SummaryParser::new(None, src); 738 | 739 | let got = parser.parse_affix(true).unwrap(); 740 | 741 | assert_eq!(got.len(), 3); 742 | assert_eq!(got[1], SummaryItem::Separator); 743 | } 744 | 745 | #[test] 746 | fn suffix_items_cannot_be_followed_by_a_list() { 747 | let src = "[First](./first.md)\n- [Second](./second.md)\n"; 748 | let mut parser = SummaryParser::new(None, src); 749 | 750 | let got = parser.parse_affix(false); 751 | 752 | assert!(got.is_err()); 753 | } 754 | 755 | #[test] 756 | fn parse_a_link() { 757 | let src = "[First](./first.md)"; 758 | let should_be = Link { 759 | name: String::from("First"), 760 | location: Some(PathBuf::from("./first.md")), 761 | ..Default::default() 762 | }; 763 | 764 | let mut parser = SummaryParser::new(None, src); 765 | let _ = parser.stream.next(); // Discard opening paragraph 766 | 767 | let href = match parser.stream.next() { 768 | Some((Event::Start(Tag::Link(_type, href, _title)), _range)) => href.to_string(), 769 | other => panic!("Unreachable, {:?}", other), 770 | }; 771 | 772 | let got = parser.parse_link(href).unwrap(); 773 | assert_eq!(got, should_be); 774 | } 775 | 776 | #[test] 777 | fn parse_a_numbered_chapter() { 778 | let src = "- [First](./first.md)\n"; 779 | let link = Link { 780 | name: String::from("First"), 781 | location: Some(PathBuf::from("./first.md")), 782 | number: Some(SectionNumber(vec![1])), 783 | ..Default::default() 784 | }; 785 | let should_be = vec![SummaryItem::Link(link)]; 786 | 787 | let mut parser = SummaryParser::new(None, src); 788 | let got = parser 789 | .parse_numbered(&mut 0, &mut SectionNumber::default()) 790 | .unwrap(); 791 | 792 | assert_eq!(got, should_be); 793 | } 794 | 795 | #[test] 796 | fn parse_nested_numbered_chapters() { 797 | let src = "- [First](./first.md)\n - [Nested](./nested.md)\n- [Second](./second.md)"; 798 | 799 | let should_be = vec![ 800 | SummaryItem::Link(Link { 801 | name: String::from("First"), 802 | location: Some(PathBuf::from("./first.md")), 803 | number: Some(SectionNumber(vec![1])), 804 | nested_items: vec![SummaryItem::Link(Link { 805 | name: String::from("Nested"), 806 | location: Some(PathBuf::from("./nested.md")), 807 | number: Some(SectionNumber(vec![1, 1])), 808 | nested_items: Vec::new(), 809 | })], 810 | }), 811 | SummaryItem::Link(Link { 812 | name: String::from("Second"), 813 | location: Some(PathBuf::from("./second.md")), 814 | number: Some(SectionNumber(vec![2])), 815 | nested_items: Vec::new(), 816 | }), 817 | ]; 818 | 819 | let mut parser = SummaryParser::new(None, src); 820 | let got = parser 821 | .parse_numbered(&mut 0, &mut SectionNumber::default()) 822 | .unwrap(); 823 | 824 | assert_eq!(got, should_be); 825 | } 826 | 827 | #[test] 828 | fn parse_numbered_chapters_separated_by_comment() { 829 | let src = "- [First](./first.md)\n\n- [Second](./second.md)"; 830 | 831 | let should_be = vec![ 832 | SummaryItem::Link(Link { 833 | name: String::from("First"), 834 | location: Some(PathBuf::from("./first.md")), 835 | number: Some(SectionNumber(vec![1])), 836 | nested_items: Vec::new(), 837 | }), 838 | SummaryItem::Link(Link { 839 | name: String::from("Second"), 840 | location: Some(PathBuf::from("./second.md")), 841 | number: Some(SectionNumber(vec![2])), 842 | nested_items: Vec::new(), 843 | }), 844 | ]; 845 | 846 | let mut parser = SummaryParser::new(None, src); 847 | let got = parser 848 | .parse_numbered(&mut 0, &mut SectionNumber::default()) 849 | .unwrap(); 850 | 851 | assert_eq!(got, should_be); 852 | } 853 | 854 | #[test] 855 | fn parse_titled_parts() { 856 | let src = "- [First](./first.md)\n- [Second](./second.md)\n\ 857 | # Title 2\n- [Third](./third.md)\n\t- [Fourth](./fourth.md)"; 858 | 859 | let should_be = vec![ 860 | SummaryItem::Link(Link { 861 | name: String::from("First"), 862 | location: Some(PathBuf::from("./first.md")), 863 | number: Some(SectionNumber(vec![1])), 864 | nested_items: Vec::new(), 865 | }), 866 | SummaryItem::Link(Link { 867 | name: String::from("Second"), 868 | location: Some(PathBuf::from("./second.md")), 869 | number: Some(SectionNumber(vec![2])), 870 | nested_items: Vec::new(), 871 | }), 872 | SummaryItem::PartTitle(String::from("Title 2")), 873 | SummaryItem::Link(Link { 874 | name: String::from("Third"), 875 | location: Some(PathBuf::from("./third.md")), 876 | number: Some(SectionNumber(vec![3])), 877 | nested_items: vec![SummaryItem::Link(Link { 878 | name: String::from("Fourth"), 879 | location: Some(PathBuf::from("./fourth.md")), 880 | number: Some(SectionNumber(vec![3, 1])), 881 | nested_items: Vec::new(), 882 | })], 883 | }), 884 | ]; 885 | 886 | let mut parser = SummaryParser::new(None, src); 887 | let got = parser.parse_parts().unwrap(); 888 | 889 | assert_eq!(got, should_be); 890 | } 891 | 892 | /// This test ensures the book will continue to pass because it breaks the 893 | /// `SUMMARY.md` up using level 2 headers ([example]). 894 | /// 895 | /// [example]: https://github.com/rust-lang/book/blob/2c942dc094f4ddcdc7aba7564f80782801197c99/second-edition/src/SUMMARY.md#basic-rust-literacy 896 | #[test] 897 | fn can_have_a_subheader_between_nested_items() { 898 | let src = "- [First](./first.md)\n\n## Subheading\n\n- [Second](./second.md)\n"; 899 | let should_be = vec![ 900 | SummaryItem::Link(Link { 901 | name: String::from("First"), 902 | location: Some(PathBuf::from("./first.md")), 903 | number: Some(SectionNumber(vec![1])), 904 | nested_items: Vec::new(), 905 | }), 906 | SummaryItem::Link(Link { 907 | name: String::from("Second"), 908 | location: Some(PathBuf::from("./second.md")), 909 | number: Some(SectionNumber(vec![2])), 910 | nested_items: Vec::new(), 911 | }), 912 | ]; 913 | 914 | let mut parser = SummaryParser::new(None, src); 915 | let got = parser 916 | .parse_numbered(&mut 0, &mut SectionNumber::default()) 917 | .unwrap(); 918 | 919 | assert_eq!(got, should_be); 920 | } 921 | 922 | #[test] 923 | fn an_empty_link_location_is_a_draft_chapter() { 924 | let src = "- [Empty]()\n"; 925 | let mut parser = SummaryParser::new(None, src); 926 | 927 | let got = parser.parse_numbered(&mut 0, &mut SectionNumber::default()); 928 | let should_be = vec![SummaryItem::Link(Link { 929 | name: String::from("Empty"), 930 | location: None, 931 | number: Some(SectionNumber(vec![1])), 932 | nested_items: Vec::new(), 933 | })]; 934 | 935 | assert!(got.is_ok()); 936 | assert_eq!(got.unwrap(), should_be); 937 | } 938 | 939 | /// Regression test for https://github.com/rust-lang/mdBook/issues/779 940 | /// Ensure section numbers are correctly incremented after a horizontal separator. 941 | #[test] 942 | fn keep_numbering_after_separator() { 943 | let src = 944 | "- [First](./first.md)\n---\n- [Second](./second.md)\n---\n- [Third](./third.md)\n"; 945 | let should_be = vec![ 946 | SummaryItem::Link(Link { 947 | name: String::from("First"), 948 | location: Some(PathBuf::from("./first.md")), 949 | number: Some(SectionNumber(vec![1])), 950 | nested_items: Vec::new(), 951 | }), 952 | SummaryItem::Separator, 953 | SummaryItem::Link(Link { 954 | name: String::from("Second"), 955 | location: Some(PathBuf::from("./second.md")), 956 | number: Some(SectionNumber(vec![2])), 957 | nested_items: Vec::new(), 958 | }), 959 | SummaryItem::Separator, 960 | SummaryItem::Link(Link { 961 | name: String::from("Third"), 962 | location: Some(PathBuf::from("./third.md")), 963 | number: Some(SectionNumber(vec![3])), 964 | nested_items: Vec::new(), 965 | }), 966 | ]; 967 | 968 | let mut parser = SummaryParser::new(None, src); 969 | let got = parser 970 | .parse_numbered(&mut 0, &mut SectionNumber::default()) 971 | .unwrap(); 972 | 973 | assert_eq!(got, should_be); 974 | } 975 | 976 | /// Regression test for https://github.com/rust-lang/mdBook/issues/1218 977 | /// Ensure chapter names spread across multiple lines have spaces between all the words. 978 | #[test] 979 | fn add_space_for_multi_line_chapter_names() { 980 | let src = "- [Chapter\ntitle](./chapter.md)"; 981 | let should_be = vec![SummaryItem::Link(Link { 982 | name: String::from("Chapter title"), 983 | location: Some(PathBuf::from("./chapter.md")), 984 | number: Some(SectionNumber(vec![1])), 985 | nested_items: Vec::new(), 986 | })]; 987 | 988 | let mut parser = SummaryParser::new(None, src); 989 | let got = parser 990 | .parse_numbered(&mut 0, &mut SectionNumber::default()) 991 | .unwrap(); 992 | 993 | assert_eq!(got, should_be); 994 | } 995 | 996 | #[test] 997 | fn allow_space_in_link_destination() { 998 | let src = "- [test1](./test%20link1.md)\n- [test2](<./test link2.md>)"; 999 | let should_be = vec![ 1000 | SummaryItem::Link(Link { 1001 | name: String::from("test1"), 1002 | location: Some(PathBuf::from("./test link1.md")), 1003 | number: Some(SectionNumber(vec![1])), 1004 | nested_items: Vec::new(), 1005 | }), 1006 | SummaryItem::Link(Link { 1007 | name: String::from("test2"), 1008 | location: Some(PathBuf::from("./test link2.md")), 1009 | number: Some(SectionNumber(vec![2])), 1010 | nested_items: Vec::new(), 1011 | }), 1012 | ]; 1013 | let mut parser = SummaryParser::new(None, src); 1014 | let got = parser 1015 | .parse_numbered(&mut 0, &mut SectionNumber::default()) 1016 | .unwrap(); 1017 | 1018 | assert_eq!(got, should_be); 1019 | } 1020 | 1021 | #[test] 1022 | fn skip_html_comments() { 1023 | let src = r#" 1026 | # Title - Local 1027 | 1028 | 1032 | [Prefix 00-01 - Local](ch00-01.md) 1033 | [Prefix 00-02 - Local](ch00-02.md) 1034 | 1035 | 1038 | ## Section Title - Localized 1039 | 1040 | 1045 | - [Ch 01-00 - Local](ch01-00.md) 1046 | - [Ch 01-01 - Local](ch01-01.md) 1047 | - [Ch 01-02 - Local](ch01-02.md) 1048 | 1049 | 1052 | - [Ch 02-00 - Local](ch02-00.md) 1053 | 1054 | ` 1058 | [Appendix A - Local](appendix-01.md) 1059 | [Appendix B - Local](appendix-02.md) 1060 | "#; 1061 | 1062 | let mut parser = SummaryParser::new(None, src); 1063 | 1064 | // ---- Title ---- 1065 | let title = parser.parse_title(); 1066 | assert_eq!(title, Some(String::from("Title - Local"))); 1067 | 1068 | // ---- Prefix Chapters ---- 1069 | 1070 | let new_affix_item = |name, location| { 1071 | SummaryItem::Link(Link { 1072 | name: String::from(name), 1073 | location: Some(PathBuf::from(location)), 1074 | ..Default::default() 1075 | }) 1076 | }; 1077 | 1078 | let should_be = vec![ 1079 | new_affix_item("Prefix 00-01 - Local", "ch00-01.md"), 1080 | new_affix_item("Prefix 00-02 - Local", "ch00-02.md"), 1081 | ]; 1082 | 1083 | let got = parser.parse_affix(true).unwrap(); 1084 | assert_eq!(got, should_be); 1085 | 1086 | // ---- Numbered Chapters ---- 1087 | 1088 | let new_numbered_item = |name, location, numbers: &[u32], nested_items| { 1089 | SummaryItem::Link(Link { 1090 | name: String::from(name), 1091 | location: Some(PathBuf::from(location)), 1092 | number: Some(SectionNumber(numbers.to_vec())), 1093 | nested_items, 1094 | }) 1095 | }; 1096 | 1097 | let ch01_nested = vec![ 1098 | new_numbered_item("Ch 01-01 - Local", "ch01-01.md", &[1, 1], vec![]), 1099 | new_numbered_item("Ch 01-02 - Local", "ch01-02.md", &[1, 2], vec![]), 1100 | ]; 1101 | 1102 | let should_be = vec![ 1103 | new_numbered_item("Ch 01-00 - Local", "ch01-00.md", &[1], ch01_nested), 1104 | new_numbered_item("Ch 02-00 - Local", "ch02-00.md", &[2], vec![]), 1105 | ]; 1106 | let got = parser.parse_parts().unwrap(); 1107 | assert_eq!(got, should_be); 1108 | 1109 | // ---- Suffix Chapters ---- 1110 | 1111 | let should_be = vec![ 1112 | new_affix_item("Appendix A - Local", "appendix-01.md"), 1113 | new_affix_item("Appendix B - Local", "appendix-02.md"), 1114 | ]; 1115 | 1116 | let got = parser.parse_affix(false).unwrap(); 1117 | assert_eq!(got, should_be); 1118 | } 1119 | } 1120 | -------------------------------------------------------------------------------- /packages/mdbook-shared/src/config.rs: -------------------------------------------------------------------------------- 1 | //! Mdbook's configuration system. 2 | //! 3 | //! The main entrypoint of the `config` module is the `Config` struct. This acts 4 | //! essentially as a bag of configuration information, with a couple 5 | //! pre-determined tables ([`BookConfig`] and [`BuildConfig`]) as well as support 6 | //! for arbitrary data which is exposed to plugins and alternative backends. 7 | //! 8 | //! 9 | //! # Examples 10 | //! 11 | //! ```rust 12 | //! # use mdbook::errors::*; 13 | //! use std::path::PathBuf; 14 | //! use std::str::FromStr; 15 | //! use mdbook::Config; 16 | //! use toml::Value; 17 | //! 18 | //! # fn run() -> Result<()> { 19 | //! let src = r#" 20 | //! [book] 21 | //! title = "My Book" 22 | //! authors = ["Michael-F-Bryan"] 23 | //! 24 | //! [build] 25 | //! src = "out" 26 | //! 27 | //! [other-table.foo] 28 | //! bar = 123 29 | //! "#; 30 | //! 31 | //! // load the `Config` from a toml string 32 | //! let mut cfg = Config::from_str(src)?; 33 | //! 34 | //! // retrieve a nested value 35 | //! let bar = cfg.get("other-table.foo.bar").cloned(); 36 | //! assert_eq!(bar, Some(Value::Integer(123))); 37 | //! 38 | //! // Set the `output.html.theme` directory 39 | //! assert!(cfg.get("output.html").is_none()); 40 | //! cfg.set("output.html.theme", "./themes"); 41 | //! 42 | //! // then load it again, automatically deserializing to a `PathBuf`. 43 | //! let got: Option = cfg.get_deserialized_opt("output.html.theme")?; 44 | //! assert_eq!(got, Some(PathBuf::from("./themes"))); 45 | //! # Ok(()) 46 | //! # } 47 | //! # run().unwrap() 48 | //! ``` 49 | 50 | #![deny(missing_docs)] 51 | 52 | use anyhow::anyhow; 53 | use serde::{Deserialize, Deserializer, Serialize, Serializer}; 54 | use std::collections::HashMap; 55 | use std::env; 56 | use std::fs::File; 57 | use std::io::Read; 58 | use std::path::{Path, PathBuf}; 59 | use std::str::FromStr; 60 | use toml::value::Table; 61 | use toml::{self, Value}; 62 | 63 | use crate::errors::*; 64 | use crate::utils::{self, toml_ext::TomlExt}; 65 | 66 | /// The overall configuration object for MDBook, essentially an in-memory 67 | /// representation of `book.toml`. 68 | #[derive(Debug, Clone, PartialEq)] 69 | pub struct Config { 70 | /// Metadata about the book. 71 | pub book: BookConfig, 72 | /// Information about the build environment. 73 | pub build: BuildConfig, 74 | /// Information about Rust language support. 75 | pub rust: RustConfig, 76 | /// Information about localizations of this book. 77 | pub language: LanguageConfig, 78 | rest: Value, 79 | } 80 | 81 | impl FromStr for Config { 82 | type Err = Error; 83 | 84 | /// Load a `Config` from some string. 85 | fn from_str(src: &str) -> Result { 86 | toml::from_str(src).with_context(|| "Invalid configuration file") 87 | } 88 | } 89 | 90 | impl Config { 91 | /// Load the configuration file from disk. 92 | pub fn from_disk>(config_file: P) -> Result { 93 | let mut buffer = String::new(); 94 | File::open(config_file) 95 | .with_context(|| "Unable to open the configuration file")? 96 | .read_to_string(&mut buffer) 97 | .with_context(|| "Couldn't read the file")?; 98 | 99 | Config::from_str(&buffer) 100 | } 101 | 102 | /// Updates the `Config` from the available environment variables. 103 | /// 104 | /// Variables starting with `MDBOOK_` are used for configuration. The key is 105 | /// created by removing the `MDBOOK_` prefix and turning the resulting 106 | /// string into `kebab-case`. Double underscores (`__`) separate nested 107 | /// keys, while a single underscore (`_`) is replaced with a dash (`-`). 108 | /// 109 | /// For example: 110 | /// 111 | /// - `MDBOOK_foo` -> `foo` 112 | /// - `MDBOOK_FOO` -> `foo` 113 | /// - `MDBOOK_FOO__BAR` -> `foo.bar` 114 | /// - `MDBOOK_FOO_BAR` -> `foo-bar` 115 | /// - `MDBOOK_FOO_bar__baz` -> `foo-bar.baz` 116 | /// 117 | /// So by setting the `MDBOOK_BOOK__TITLE` environment variable you can 118 | /// override the book's title without needing to touch your `book.toml`. 119 | /// 120 | /// > **Note:** To facilitate setting more complex config items, the value 121 | /// > of an environment variable is first parsed as JSON, falling back to a 122 | /// > string if the parse fails. 123 | /// > 124 | /// > This means, if you so desired, you could override all book metadata 125 | /// > when building the book with something like 126 | /// > 127 | /// > ```text 128 | /// > $ export MDBOOK_BOOK='{"title": "My Awesome Book", "authors": ["Michael-F-Bryan"]}' 129 | /// > $ mdbook build 130 | /// > ``` 131 | /// 132 | /// The latter case may be useful in situations where `mdbook` is invoked 133 | /// from a script or CI, where it sometimes isn't possible to update the 134 | /// `book.toml` before building. 135 | pub fn update_from_env(&mut self) { 136 | debug!("Updating the config from environment variables"); 137 | 138 | let overrides = 139 | env::vars().filter_map(|(key, value)| parse_env(&key).map(|index| (index, value))); 140 | 141 | for (key, value) in overrides { 142 | trace!("{} => {}", key, value); 143 | let parsed_value = serde_json::from_str(&value) 144 | .unwrap_or_else(|_| serde_json::Value::String(value.to_string())); 145 | 146 | if key == "book" || key == "build" { 147 | if let serde_json::Value::Object(ref map) = parsed_value { 148 | // To `set` each `key`, we wrap them as `prefix.key` 149 | for (k, v) in map { 150 | let full_key = format!("{}.{}", key, k); 151 | self.set(&full_key, v).expect("unreachable"); 152 | } 153 | return; 154 | } 155 | } 156 | 157 | self.set(key, parsed_value).expect("unreachable"); 158 | } 159 | } 160 | 161 | /// Fetch an arbitrary item from the `Config` as a `toml::Value`. 162 | /// 163 | /// You can use dotted indices to access nested items (e.g. 164 | /// `output.html.playground` will fetch the "playground" out of the html output 165 | /// table). 166 | pub fn get(&self, key: &str) -> Option<&Value> { 167 | self.rest.read(key) 168 | } 169 | 170 | /// Fetch a value from the `Config` so you can mutate it. 171 | pub fn get_mut(&mut self, key: &str) -> Option<&mut Value> { 172 | self.rest.read_mut(key) 173 | } 174 | 175 | /// Convenience method for getting the html renderer's configuration. 176 | /// 177 | /// # Note 178 | /// 179 | /// This is for compatibility only. It will be removed completely once the 180 | /// HTML renderer is refactored to be less coupled to `mdbook` internals. 181 | #[doc(hidden)] 182 | pub fn html_config(&self) -> Option { 183 | match self 184 | .get_deserialized_opt("output.html") 185 | .with_context(|| "Parsing configuration [output.html]") 186 | { 187 | Ok(Some(config)) => Some(config), 188 | Ok(None) => None, 189 | Err(e) => { 190 | utils::log_backtrace(&e); 191 | None 192 | } 193 | } 194 | } 195 | 196 | /// Deprecated, use get_deserialized_opt instead. 197 | #[deprecated = "use get_deserialized_opt instead"] 198 | pub fn get_deserialized<'de, T: Deserialize<'de>, S: AsRef>(&self, name: S) -> Result { 199 | let name = name.as_ref(); 200 | match self.get_deserialized_opt(name)? { 201 | Some(value) => Ok(value), 202 | None => bail!("Key not found, {:?}", name), 203 | } 204 | } 205 | 206 | /// Convenience function to fetch a value from the config and deserialize it 207 | /// into some arbitrary type. 208 | pub fn get_deserialized_opt<'de, T: Deserialize<'de>, S: AsRef>( 209 | &self, 210 | name: S, 211 | ) -> Result> { 212 | let name = name.as_ref(); 213 | self.get(name) 214 | .map(|value| { 215 | value 216 | .clone() 217 | .try_into() 218 | .with_context(|| "Couldn't deserialize the value") 219 | }) 220 | .transpose() 221 | } 222 | 223 | /// Set a config key, clobbering any existing values along the way. 224 | /// 225 | /// The only way this can fail is if we can't serialize `value` into a 226 | /// `toml::Value`. 227 | pub fn set>(&mut self, index: I, value: S) -> Result<()> { 228 | let index = index.as_ref(); 229 | 230 | let value = Value::try_from(value) 231 | .with_context(|| "Unable to represent the item as a JSON Value")?; 232 | 233 | if index.starts_with("book.") { 234 | self.book.update_value(&index[5..], value); 235 | } else if index.starts_with("build.") { 236 | self.build.update_value(&index[6..], value); 237 | } else { 238 | self.rest.insert(index, value); 239 | } 240 | 241 | Ok(()) 242 | } 243 | 244 | /// Get the table associated with a particular renderer. 245 | pub fn get_renderer>(&self, index: I) -> Option<&Table> { 246 | let key = format!("output.{}", index.as_ref()); 247 | self.get(&key).and_then(Value::as_table) 248 | } 249 | 250 | /// Get the table associated with a particular preprocessor. 251 | pub fn get_preprocessor>(&self, index: I) -> Option<&Table> { 252 | let key = format!("preprocessor.{}", index.as_ref()); 253 | self.get(&key).and_then(Value::as_table) 254 | } 255 | 256 | /// Gets the language configured for a book. 257 | pub fn get_language>(&self, index: Option) -> Result> { 258 | match self.default_language() { 259 | // Languages have been specified, assume directory structure with 260 | // language subfolders. 261 | Some(ref default) => match index { 262 | // Make sure that the language we passed was actually declared 263 | // in the config, and return an `Err` if not. 264 | Some(lang_ident) => match self.language.0.get(lang_ident.as_ref()) { 265 | Some(_) => Ok(Some(lang_ident.as_ref().into())), 266 | None => Err(anyhow!( 267 | "Expected [language.{}] to be declared in book.toml", 268 | lang_ident.as_ref() 269 | )), 270 | }, 271 | // Use the default specified in book.toml. 272 | None => Ok(Some(default.to_string())), 273 | }, 274 | 275 | // No [language] table was declared in book.toml. 276 | None => match index { 277 | // We passed in a language from the frontend, but the config 278 | // offers no languages. 279 | Some(lang_ident) => Err(anyhow!( 280 | "No [language] table in book.toml, expected [language.{}] to be declared", 281 | lang_ident.as_ref() 282 | )), 283 | // Default to previous non-localized behavior. 284 | None => Ok(None), 285 | }, 286 | } 287 | } 288 | 289 | /// Get the source directory of a localized book corresponding to language ident `index`. 290 | pub fn get_localized_src_path>(&self, index: Option) -> Result { 291 | let language = self.get_language(index)?; 292 | 293 | match language { 294 | Some(lang_ident) => { 295 | let mut buf = PathBuf::new(); 296 | buf.push(self.book.src.clone()); 297 | buf.push(lang_ident); 298 | Ok(buf) 299 | } 300 | 301 | // No [language] table was declared in book.toml. Preserve backwards 302 | // compatibility by just returning `src`. 303 | None => Ok(self.book.src.clone()), 304 | } 305 | } 306 | 307 | /// Gets the localized title of the book. 308 | pub fn get_localized_title>(&self, index: Option) -> Option { 309 | let language = self.get_language(index).unwrap(); 310 | 311 | match language { 312 | Some(lang_ident) => self 313 | .language 314 | .0 315 | .get(&lang_ident) 316 | .unwrap() 317 | .title 318 | .clone() 319 | .or(self.book.title.clone()), 320 | None => self.book.title.clone(), 321 | } 322 | } 323 | 324 | /// Gets the localized description of the book. 325 | pub fn get_localized_description>(&self, index: Option) -> Option { 326 | let language = self.get_language(index).unwrap(); 327 | 328 | match language { 329 | Some(lang_ident) => self 330 | .language 331 | .0 332 | .get(&lang_ident) 333 | .unwrap() 334 | .description 335 | .clone() 336 | .or(self.book.description.clone()), 337 | None => self.book.description.clone(), 338 | } 339 | } 340 | 341 | /// Get the fallback source directory of a book. If chapters/sections are 342 | /// missing in a localization, any links to them will gracefully degrade to 343 | /// the files that exist in this directory. 344 | pub fn get_fallback_src_path(&self) -> PathBuf { 345 | match self.default_language() { 346 | // Languages have been specified, assume directory structure with 347 | // language subfolders. 348 | Some(default) => { 349 | let mut buf = PathBuf::new(); 350 | buf.push(self.book.src.clone()); 351 | buf.push(default); 352 | buf 353 | } 354 | 355 | // No default language was configured in book.toml. Preserve 356 | // backwards compatibility by just returning `src`. 357 | None => self.book.src.clone(), 358 | } 359 | } 360 | 361 | /// If true, mdBook should assume there are subdirectories under src/ 362 | /// corresponding to the localizations in the config. If false, src/ is a 363 | /// single directory containing the summary file and the rest. 364 | pub fn has_localized_dir_structure(&self) -> bool { 365 | !self.language.0.is_empty() 366 | } 367 | 368 | /// Obtains the default language for this config. 369 | pub fn default_language(&self) -> Option { 370 | if self.has_localized_dir_structure() { 371 | let language_ident = self 372 | .book 373 | .language 374 | .clone() 375 | .expect("Config has [language] table, but `book.language` not was declared"); 376 | self.language.0.get(&language_ident).expect(&format!( 377 | "Expected [language.{}] to be declared in book.toml", 378 | language_ident 379 | )); 380 | Some(language_ident) 381 | } else { 382 | None 383 | } 384 | } 385 | 386 | fn from_legacy(mut table: Value) -> Config { 387 | let mut cfg = Config::default(); 388 | 389 | // we use a macro here instead of a normal loop because the $out 390 | // variable can be different types. This way we can make type inference 391 | // figure out what try_into() deserializes to. 392 | macro_rules! get_and_insert { 393 | ($table:expr, $key:expr => $out:expr) => { 394 | let got = $table 395 | .as_table_mut() 396 | .and_then(|t| t.remove($key)) 397 | .and_then(|v| v.try_into().ok()); 398 | if let Some(value) = got { 399 | $out = value; 400 | } 401 | }; 402 | } 403 | 404 | get_and_insert!(table, "title" => cfg.book.title); 405 | get_and_insert!(table, "authors" => cfg.book.authors); 406 | get_and_insert!(table, "source" => cfg.book.src); 407 | get_and_insert!(table, "description" => cfg.book.description); 408 | 409 | if let Some(dest) = table.delete("output.html.destination") { 410 | if let Ok(destination) = dest.try_into() { 411 | cfg.build.build_dir = destination; 412 | } 413 | } 414 | 415 | cfg.rest = table; 416 | cfg 417 | } 418 | } 419 | 420 | impl Default for Config { 421 | fn default() -> Config { 422 | Config { 423 | book: BookConfig::default(), 424 | build: BuildConfig::default(), 425 | rust: RustConfig::default(), 426 | language: LanguageConfig::default(), 427 | rest: Value::Table(Table::default()), 428 | } 429 | } 430 | } 431 | 432 | impl<'de> Deserialize<'de> for Config { 433 | fn deserialize>(de: D) -> std::result::Result { 434 | let raw = Value::deserialize(de)?; 435 | 436 | if is_legacy_format(&raw) { 437 | warn!("It looks like you are using the legacy book.toml format."); 438 | warn!("We'll parse it for now, but you should probably convert to the new format."); 439 | warn!("See the mdbook documentation for more details, although as a rule of thumb"); 440 | warn!("just move all top level configuration entries like `title`, `author` and"); 441 | warn!("`description` under a table called `[book]`, move the `destination` entry"); 442 | warn!("from `[output.html]`, renamed to `build-dir`, under a table called"); 443 | warn!("`[build]`, and it should all work."); 444 | warn!("Documentation: http://rust-lang.github.io/mdBook/format/config.html"); 445 | return Ok(Config::from_legacy(raw)); 446 | } 447 | 448 | use serde::de::Error; 449 | let mut table = match raw { 450 | Value::Table(t) => t, 451 | _ => { 452 | return Err(D::Error::custom( 453 | "A config file should always be a toml table", 454 | )); 455 | } 456 | }; 457 | 458 | let book: BookConfig = table 459 | .remove("book") 460 | .map(|book| book.try_into().map_err(D::Error::custom)) 461 | .transpose()? 462 | .unwrap_or_default(); 463 | 464 | let build: BuildConfig = table 465 | .remove("build") 466 | .map(|build| build.try_into().map_err(D::Error::custom)) 467 | .transpose()? 468 | .unwrap_or_default(); 469 | 470 | let rust: RustConfig = table 471 | .remove("rust") 472 | .map(|rust| rust.try_into().map_err(D::Error::custom)) 473 | .transpose()? 474 | .unwrap_or_default(); 475 | 476 | let language: LanguageConfig = table 477 | .remove("language") 478 | .and_then(|value| value.try_into().ok()) 479 | .unwrap_or_default(); 480 | 481 | if !language.0.is_empty() { 482 | if book.language.is_none() { 483 | return Err(D::Error::custom( 484 | "If the [language] table is specified, then `book.language` must be declared", 485 | )); 486 | } 487 | let language_ident = book.language.clone().unwrap(); 488 | if language.0.get(&language_ident).is_none() { 489 | return Err(D::Error::custom(format!( 490 | "Expected [language.{}] to be declared in book.toml", 491 | language_ident 492 | ))); 493 | } 494 | for (ident, language) in language.0.iter() { 495 | if language.name.is_empty() { 496 | return Err(D::Error::custom(format!( 497 | "`name` property for [language.{}] must be non-empty", 498 | ident 499 | ))); 500 | } 501 | } 502 | } 503 | 504 | Ok(Config { 505 | book, 506 | build, 507 | language, 508 | rust, 509 | rest: Value::Table(table), 510 | }) 511 | } 512 | } 513 | 514 | impl Serialize for Config { 515 | fn serialize(&self, s: S) -> std::result::Result { 516 | // TODO: This should probably be removed and use a derive instead. 517 | let mut table = self.rest.clone(); 518 | 519 | let book_config = Value::try_from(&self.book).expect("should always be serializable"); 520 | table.insert("book", book_config); 521 | 522 | if self.build != BuildConfig::default() { 523 | let build_config = Value::try_from(&self.build).expect("should always be serializable"); 524 | table.insert("build", build_config); 525 | } 526 | 527 | if self.rust != RustConfig::default() { 528 | let rust_config = Value::try_from(&self.rust).expect("should always be serializable"); 529 | table.insert("rust", rust_config); 530 | } 531 | 532 | if !self.language.0.is_empty() { 533 | let language_config = 534 | Value::try_from(&self.language).expect("should always be serializable"); 535 | table.insert("language", language_config); 536 | } 537 | 538 | table.serialize(s) 539 | } 540 | } 541 | 542 | fn parse_env(key: &str) -> Option { 543 | const PREFIX: &str = "MDBOOK_"; 544 | 545 | if key.starts_with(PREFIX) { 546 | let key = &key[PREFIX.len()..]; 547 | 548 | Some(key.to_lowercase().replace("__", ".").replace("_", "-")) 549 | } else { 550 | None 551 | } 552 | } 553 | 554 | fn is_legacy_format(table: &Value) -> bool { 555 | let legacy_items = [ 556 | "title", 557 | "authors", 558 | "source", 559 | "description", 560 | "output.html.destination", 561 | ]; 562 | 563 | for item in &legacy_items { 564 | if table.read(item).is_some() { 565 | return true; 566 | } 567 | } 568 | 569 | false 570 | } 571 | 572 | /// Configuration options which are specific to the book and required for 573 | /// loading it from disk. 574 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] 575 | #[serde(default, rename_all = "kebab-case")] 576 | pub struct BookConfig { 577 | /// The book's title. 578 | pub title: Option, 579 | /// The book's authors. 580 | pub authors: Vec, 581 | /// An optional description for the book. 582 | pub description: Option, 583 | /// Location of the book source relative to the book's root directory. 584 | pub src: PathBuf, 585 | /// The main language of the book. 586 | pub language: Option, 587 | } 588 | 589 | impl Default for BookConfig { 590 | fn default() -> BookConfig { 591 | BookConfig { 592 | title: None, 593 | authors: Vec::new(), 594 | description: None, 595 | src: PathBuf::from("src"), 596 | language: Some(String::from("en")), 597 | } 598 | } 599 | } 600 | 601 | /// Configuration for the build procedure. 602 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] 603 | #[serde(default, rename_all = "kebab-case")] 604 | pub struct BuildConfig { 605 | /// Where to put built artefacts relative to the book's root directory. 606 | pub build_dir: PathBuf, 607 | /// Should non-existent markdown files specified in `SUMMARY.md` be created 608 | /// if they don't exist? 609 | pub create_missing: bool, 610 | /// Should the default preprocessors always be used when they are 611 | /// compatible with the renderer? 612 | pub use_default_preprocessors: bool, 613 | } 614 | 615 | impl Default for BuildConfig { 616 | fn default() -> BuildConfig { 617 | BuildConfig { 618 | build_dir: PathBuf::from("book"), 619 | create_missing: true, 620 | use_default_preprocessors: true, 621 | } 622 | } 623 | } 624 | 625 | /// Configuration for the Rust compiler(e.g., for playground) 626 | #[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)] 627 | #[serde(default, rename_all = "kebab-case")] 628 | pub struct RustConfig { 629 | /// Rust edition used in playground 630 | pub edition: Option, 631 | } 632 | 633 | #[derive(Debug, Copy, Clone, PartialEq, Serialize, Deserialize)] 634 | /// Rust edition to use for the code. 635 | pub enum RustEdition { 636 | /// The 2021 edition of Rust 637 | #[serde(rename = "2021")] 638 | E2021, 639 | /// The 2018 edition of Rust 640 | #[serde(rename = "2018")] 641 | E2018, 642 | /// The 2015 edition of Rust 643 | #[serde(rename = "2015")] 644 | E2015, 645 | } 646 | 647 | /// Configuration for the HTML renderer. 648 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] 649 | #[serde(default, rename_all = "kebab-case")] 650 | pub struct HtmlConfig { 651 | /// The theme directory, if specified. 652 | pub theme: Option, 653 | /// The default theme to use, defaults to 'light' 654 | pub default_theme: Option, 655 | /// The theme to use if the browser requests the dark version of the site. 656 | /// Defaults to 'navy'. 657 | pub preferred_dark_theme: Option, 658 | /// Use "smart quotes" instead of the usual `"` character. 659 | pub curly_quotes: bool, 660 | /// Should mathjax be enabled? 661 | pub mathjax_support: bool, 662 | /// Whether to fonts.css and respective font files to the output directory. 663 | pub copy_fonts: bool, 664 | /// An optional google analytics code. 665 | pub google_analytics: Option, 666 | /// Additional CSS stylesheets to include in the rendered page's ``. 667 | pub additional_css: Vec, 668 | /// Additional JS scripts to include at the bottom of the rendered page's 669 | /// ``. 670 | pub additional_js: Vec, 671 | /// Fold settings. 672 | pub fold: Fold, 673 | /// Playground settings. 674 | #[serde(alias = "playpen")] 675 | pub playground: Playground, 676 | /// Print settings. 677 | pub print: Print, 678 | /// Don't render section labels. 679 | pub no_section_label: bool, 680 | /// Search settings. If `None`, the default will be used. 681 | pub search: Option, 682 | /// Git repository url. If `None`, the git button will not be shown. 683 | pub git_repository_url: Option, 684 | /// FontAwesome icon class to use for the Git repository link. 685 | /// Defaults to `fa-github` if `None`. 686 | pub git_repository_icon: Option, 687 | /// Input path for the 404 file, defaults to 404.md, set to "" to disable 404 file output 688 | pub input_404: Option, 689 | /// Absolute url to site, used to emit correct paths for the 404 page, which might be accessed in a deeply nested directory 690 | pub site_url: Option, 691 | /// The DNS subdomain or apex domain at which your book will be hosted. This 692 | /// string will be written to a file named CNAME in the root of your site, 693 | /// as required by GitHub Pages (see [*Managing a custom domain for your 694 | /// GitHub Pages site*][custom domain]). 695 | /// 696 | /// [custom domain]: https://docs.github.com/en/github/working-with-github-pages/managing-a-custom-domain-for-your-github-pages-site 697 | pub cname: Option, 698 | /// Edit url template, when set shows a "Suggest an edit" button for 699 | /// directly jumping to editing the currently viewed page. 700 | /// Contains {path} that is replaced with chapter source file path 701 | pub edit_url_template: Option, 702 | /// This is used as a bit of a workaround for the `mdbook serve` command. 703 | /// Basically, because you set the websocket port from the command line, the 704 | /// `mdbook serve` command needs a way to let the HTML renderer know where 705 | /// to point livereloading at, if it has been enabled. 706 | /// 707 | /// This config item *should not be edited* by the end user. 708 | #[doc(hidden)] 709 | pub livereload_url: Option, 710 | /// The mapping from old pages to new pages/URLs to use when generating 711 | /// redirects. 712 | pub redirect: HashMap, 713 | } 714 | 715 | impl Default for HtmlConfig { 716 | fn default() -> HtmlConfig { 717 | HtmlConfig { 718 | theme: None, 719 | default_theme: None, 720 | preferred_dark_theme: None, 721 | curly_quotes: false, 722 | mathjax_support: false, 723 | copy_fonts: true, 724 | google_analytics: None, 725 | additional_css: Vec::new(), 726 | additional_js: Vec::new(), 727 | fold: Fold::default(), 728 | playground: Playground::default(), 729 | print: Print::default(), 730 | no_section_label: false, 731 | search: None, 732 | git_repository_url: None, 733 | git_repository_icon: None, 734 | edit_url_template: None, 735 | input_404: None, 736 | site_url: None, 737 | cname: None, 738 | livereload_url: None, 739 | redirect: HashMap::new(), 740 | } 741 | } 742 | } 743 | 744 | impl HtmlConfig { 745 | /// Returns the directory of theme from the provided root directory. If the 746 | /// directory is not present it will append the default directory of "theme" 747 | pub fn theme_dir(&self, root: &Path) -> PathBuf { 748 | match self.theme { 749 | Some(ref d) => root.join(d), 750 | None => root.join("theme"), 751 | } 752 | } 753 | } 754 | 755 | /// Configuration for how to render the print icon, print.html, and print.css. 756 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] 757 | #[serde(rename_all = "kebab-case")] 758 | pub struct Print { 759 | /// Whether print support is enabled. 760 | pub enable: bool, 761 | /// Insert page breaks between chapters. Default: `true`. 762 | pub page_break: bool, 763 | } 764 | 765 | impl Default for Print { 766 | fn default() -> Self { 767 | Self { 768 | enable: true, 769 | page_break: true, 770 | } 771 | } 772 | } 773 | 774 | /// Configuration for how to fold chapters of sidebar. 775 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 776 | #[serde(default, rename_all = "kebab-case")] 777 | pub struct Fold { 778 | /// When off, all folds are open. Default: `false`. 779 | pub enable: bool, 780 | /// The higher the more folded regions are open. When level is 0, all folds 781 | /// are closed. 782 | /// Default: `0`. 783 | pub level: u8, 784 | } 785 | 786 | /// Configuration for tweaking how the the HTML renderer handles the playground. 787 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] 788 | #[serde(default, rename_all = "kebab-case")] 789 | pub struct Playground { 790 | /// Should playground snippets be editable? Default: `false`. 791 | pub editable: bool, 792 | /// Display the copy button. Default: `true`. 793 | pub copyable: bool, 794 | /// Copy JavaScript files for the editor to the output directory? 795 | /// Default: `true`. 796 | pub copy_js: bool, 797 | /// Display line numbers on playground snippets. Default: `false`. 798 | pub line_numbers: bool, 799 | } 800 | 801 | impl Default for Playground { 802 | fn default() -> Playground { 803 | Playground { 804 | editable: false, 805 | copyable: true, 806 | copy_js: true, 807 | line_numbers: false, 808 | } 809 | } 810 | } 811 | 812 | /// Configuration of the search functionality of the HTML renderer. 813 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] 814 | #[serde(default, rename_all = "kebab-case")] 815 | pub struct Search { 816 | /// Enable the search feature. Default: `true`. 817 | pub enable: bool, 818 | /// Maximum number of visible results. Default: `30`. 819 | pub limit_results: u32, 820 | /// The number of words used for a search result teaser. Default: `30`. 821 | pub teaser_word_count: u32, 822 | /// Define the logical link between multiple search words. 823 | /// If true, all search words must appear in each result. Default: `false`. 824 | pub use_boolean_and: bool, 825 | /// Boost factor for the search result score if a search word appears in the header. 826 | /// Default: `2`. 827 | pub boost_title: u8, 828 | /// Boost factor for the search result score if a search word appears in the hierarchy. 829 | /// The hierarchy contains all titles of the parent documents and all parent headings. 830 | /// Default: `1`. 831 | pub boost_hierarchy: u8, 832 | /// Boost factor for the search result score if a search word appears in the text. 833 | /// Default: `1`. 834 | pub boost_paragraph: u8, 835 | /// True if the searchword `micro` should match `microwave`. Default: `true`. 836 | pub expand: bool, 837 | /// Documents are split into smaller parts, separated by headings. This defines, until which 838 | /// level of heading documents should be split. Default: `3`. (`### This is a level 3 heading`) 839 | pub heading_split_level: u8, 840 | /// Copy JavaScript files for the search functionality to the output directory? 841 | /// Default: `true`. 842 | pub copy_js: bool, 843 | } 844 | 845 | impl Default for Search { 846 | fn default() -> Search { 847 | // Please update the documentation of `Search` when changing values! 848 | Search { 849 | enable: true, 850 | limit_results: 30, 851 | teaser_word_count: 30, 852 | use_boolean_and: false, 853 | boost_title: 2, 854 | boost_hierarchy: 1, 855 | boost_paragraph: 1, 856 | expand: true, 857 | heading_split_level: 3, 858 | copy_js: true, 859 | } 860 | } 861 | } 862 | 863 | /// Configuration for localizations of this book 864 | #[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)] 865 | #[serde(transparent)] 866 | pub struct LanguageConfig(pub HashMap); 867 | 868 | /// Configuration for a single localization 869 | #[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)] 870 | #[serde(default, rename_all = "kebab-case")] 871 | pub struct Language { 872 | /// Human-readable name of the language. 873 | pub name: String, 874 | /// Localized title of the book. 875 | pub title: Option, 876 | /// The authors of the translation. 877 | pub authors: Option>, 878 | /// Localized description of the book. 879 | pub description: Option, 880 | } 881 | 882 | /// Allows you to "update" any arbitrary field in a struct by round-tripping via 883 | /// a `toml::Value`. 884 | /// 885 | /// This is definitely not the most performant way to do things, which means you 886 | /// should probably keep it away from tight loops... 887 | trait Updateable<'de>: Serialize + Deserialize<'de> { 888 | fn update_value(&mut self, key: &str, value: S) { 889 | let mut raw = Value::try_from(&self).expect("unreachable"); 890 | 891 | if let Ok(value) = Value::try_from(value) { 892 | let _ = raw.insert(key, value); 893 | } else { 894 | return; 895 | } 896 | 897 | if let Ok(updated) = raw.try_into() { 898 | *self = updated; 899 | } 900 | } 901 | } 902 | 903 | impl<'de, T> Updateable<'de> for T where T: Serialize + Deserialize<'de> {} 904 | 905 | #[cfg(test)] 906 | mod tests { 907 | use super::*; 908 | use crate::utils::fs::get_404_output_file; 909 | 910 | const COMPLEX_CONFIG: &str = r#" 911 | [book] 912 | title = "Some Book" 913 | authors = ["Michael-F-Bryan "] 914 | description = "A completely useless book" 915 | src = "source" 916 | language = "ja" 917 | 918 | [build] 919 | build-dir = "outputs" 920 | create-missing = false 921 | use-default-preprocessors = true 922 | 923 | [output.html] 924 | theme = "./themedir" 925 | default-theme = "rust" 926 | curly-quotes = true 927 | google-analytics = "123456" 928 | additional-css = ["./foo/bar/baz.css"] 929 | git-repository-url = "https://foo.com/" 930 | git-repository-icon = "fa-code-fork" 931 | 932 | [output.html.playground] 933 | editable = true 934 | editor = "ace" 935 | 936 | [output.html.redirect] 937 | "index.html" = "overview.html" 938 | "nexted/page.md" = "https://rust-lang.org/" 939 | 940 | [preprocessor.first] 941 | 942 | [preprocessor.second] 943 | 944 | [language.en] 945 | name = "English" 946 | 947 | [language.ja] 948 | name = "日本語" 949 | title = "なんかの本" 950 | description = "何の役にも立たない本" 951 | authors = ["Ruin0x11"] 952 | "#; 953 | 954 | #[test] 955 | fn load_a_complex_config_file() { 956 | let src = COMPLEX_CONFIG; 957 | 958 | let book_should_be = BookConfig { 959 | title: Some(String::from("Some Book")), 960 | authors: vec![String::from("Michael-F-Bryan ")], 961 | description: Some(String::from("A completely useless book")), 962 | src: PathBuf::from("source"), 963 | language: Some(String::from("ja")), 964 | }; 965 | let build_should_be = BuildConfig { 966 | build_dir: PathBuf::from("outputs"), 967 | create_missing: false, 968 | use_default_preprocessors: true, 969 | }; 970 | let rust_should_be = RustConfig { edition: None }; 971 | let playground_should_be = Playground { 972 | editable: true, 973 | copyable: true, 974 | copy_js: true, 975 | line_numbers: false, 976 | }; 977 | let html_should_be = HtmlConfig { 978 | curly_quotes: true, 979 | google_analytics: Some(String::from("123456")), 980 | additional_css: vec![PathBuf::from("./foo/bar/baz.css")], 981 | theme: Some(PathBuf::from("./themedir")), 982 | default_theme: Some(String::from("rust")), 983 | playground: playground_should_be, 984 | git_repository_url: Some(String::from("https://foo.com/")), 985 | git_repository_icon: Some(String::from("fa-code-fork")), 986 | redirect: vec![ 987 | (String::from("index.html"), String::from("overview.html")), 988 | ( 989 | String::from("nexted/page.md"), 990 | String::from("https://rust-lang.org/"), 991 | ), 992 | ] 993 | .into_iter() 994 | .collect(), 995 | ..Default::default() 996 | }; 997 | let mut language_should_be = LanguageConfig::default(); 998 | language_should_be.0.insert( 999 | String::from("en"), 1000 | Language { 1001 | name: String::from("English"), 1002 | title: None, 1003 | description: None, 1004 | authors: None, 1005 | }, 1006 | ); 1007 | language_should_be.0.insert( 1008 | String::from("ja"), 1009 | Language { 1010 | name: String::from("日本語"), 1011 | title: Some(String::from("なんかの本")), 1012 | description: Some(String::from("何の役にも立たない本")), 1013 | authors: Some(vec![String::from("Ruin0x11")]), 1014 | }, 1015 | ); 1016 | 1017 | let got = Config::from_str(src).unwrap(); 1018 | 1019 | assert_eq!(got.book, book_should_be); 1020 | assert_eq!(got.build, build_should_be); 1021 | assert_eq!(got.rust, rust_should_be); 1022 | assert_eq!(got.html_config().unwrap(), html_should_be); 1023 | assert_eq!(got.language, language_should_be); 1024 | assert_eq!(got.default_language(), Some(String::from("ja"))); 1025 | } 1026 | 1027 | #[test] 1028 | fn edition_2015() { 1029 | let src = r#" 1030 | [book] 1031 | title = "mdBook Documentation" 1032 | description = "Create book from markdown files. Like Gitbook but implemented in Rust" 1033 | authors = ["Mathieu David"] 1034 | src = "./source" 1035 | [rust] 1036 | edition = "2015" 1037 | "#; 1038 | 1039 | let book_should_be = BookConfig { 1040 | title: Some(String::from("mdBook Documentation")), 1041 | description: Some(String::from( 1042 | "Create book from markdown files. Like Gitbook but implemented in Rust", 1043 | )), 1044 | authors: vec![String::from("Mathieu David")], 1045 | src: PathBuf::from("./source"), 1046 | ..Default::default() 1047 | }; 1048 | 1049 | let got = Config::from_str(src).unwrap(); 1050 | assert_eq!(got.book, book_should_be); 1051 | 1052 | let rust_should_be = RustConfig { 1053 | edition: Some(RustEdition::E2015), 1054 | }; 1055 | let got = Config::from_str(src).unwrap(); 1056 | assert_eq!(got.rust, rust_should_be); 1057 | } 1058 | 1059 | #[test] 1060 | fn edition_2018() { 1061 | let src = r#" 1062 | [book] 1063 | title = "mdBook Documentation" 1064 | description = "Create book from markdown files. Like Gitbook but implemented in Rust" 1065 | authors = ["Mathieu David"] 1066 | src = "./source" 1067 | [rust] 1068 | edition = "2018" 1069 | "#; 1070 | 1071 | let rust_should_be = RustConfig { 1072 | edition: Some(RustEdition::E2018), 1073 | }; 1074 | 1075 | let got = Config::from_str(src).unwrap(); 1076 | assert_eq!(got.rust, rust_should_be); 1077 | } 1078 | 1079 | #[test] 1080 | fn edition_2021() { 1081 | let src = r#" 1082 | [book] 1083 | title = "mdBook Documentation" 1084 | description = "Create book from markdown files. Like Gitbook but implemented in Rust" 1085 | authors = ["Mathieu David"] 1086 | src = "./source" 1087 | [rust] 1088 | edition = "2021" 1089 | "#; 1090 | 1091 | let rust_should_be = RustConfig { 1092 | edition: Some(RustEdition::E2021), 1093 | }; 1094 | 1095 | let got = Config::from_str(src).unwrap(); 1096 | assert_eq!(got.rust, rust_should_be); 1097 | } 1098 | 1099 | #[test] 1100 | fn load_arbitrary_output_type() { 1101 | #[derive(Debug, Deserialize, PartialEq)] 1102 | struct RandomOutput { 1103 | foo: u32, 1104 | bar: String, 1105 | baz: Vec, 1106 | } 1107 | 1108 | let src = r#" 1109 | [output.random] 1110 | foo = 5 1111 | bar = "Hello World" 1112 | baz = [true, true, false] 1113 | "#; 1114 | 1115 | let should_be = RandomOutput { 1116 | foo: 5, 1117 | bar: String::from("Hello World"), 1118 | baz: vec![true, true, false], 1119 | }; 1120 | 1121 | let cfg = Config::from_str(src).unwrap(); 1122 | let got: RandomOutput = cfg.get_deserialized_opt("output.random").unwrap().unwrap(); 1123 | 1124 | assert_eq!(got, should_be); 1125 | 1126 | let got_baz: Vec = cfg 1127 | .get_deserialized_opt("output.random.baz") 1128 | .unwrap() 1129 | .unwrap(); 1130 | let baz_should_be = vec![true, true, false]; 1131 | 1132 | assert_eq!(got_baz, baz_should_be); 1133 | } 1134 | 1135 | #[test] 1136 | fn mutate_some_stuff() { 1137 | // really this is just a sanity check to make sure the borrow checker 1138 | // is happy... 1139 | let src = COMPLEX_CONFIG; 1140 | let mut config = Config::from_str(src).unwrap(); 1141 | let key = "output.html.playground.editable"; 1142 | 1143 | assert_eq!(config.get(key).unwrap(), &Value::Boolean(true)); 1144 | *config.get_mut(key).unwrap() = Value::Boolean(false); 1145 | assert_eq!(config.get(key).unwrap(), &Value::Boolean(false)); 1146 | } 1147 | 1148 | /// The config file format has slightly changed (metadata stuff is now under 1149 | /// the `book` table instead of being at the top level) so we're adding a 1150 | /// **temporary** compatibility check. You should be able to still load the 1151 | /// old format, emitting a warning. 1152 | #[test] 1153 | fn can_still_load_the_previous_format() { 1154 | let src = r#" 1155 | title = "mdBook Documentation" 1156 | description = "Create book from markdown files. Like Gitbook but implemented in Rust" 1157 | authors = ["Mathieu David"] 1158 | source = "./source" 1159 | 1160 | [output.html] 1161 | destination = "my-book" # the output files will be generated in `root/my-book` instead of `root/book` 1162 | theme = "my-theme" 1163 | curly-quotes = true 1164 | google-analytics = "123456" 1165 | additional-css = ["custom.css", "custom2.css"] 1166 | additional-js = ["custom.js"] 1167 | "#; 1168 | 1169 | let book_should_be = BookConfig { 1170 | title: Some(String::from("mdBook Documentation")), 1171 | description: Some(String::from( 1172 | "Create book from markdown files. Like Gitbook but implemented in Rust", 1173 | )), 1174 | authors: vec![String::from("Mathieu David")], 1175 | src: PathBuf::from("./source"), 1176 | ..Default::default() 1177 | }; 1178 | 1179 | let build_should_be = BuildConfig { 1180 | build_dir: PathBuf::from("my-book"), 1181 | create_missing: true, 1182 | use_default_preprocessors: true, 1183 | }; 1184 | 1185 | let html_should_be = HtmlConfig { 1186 | theme: Some(PathBuf::from("my-theme")), 1187 | curly_quotes: true, 1188 | google_analytics: Some(String::from("123456")), 1189 | additional_css: vec![PathBuf::from("custom.css"), PathBuf::from("custom2.css")], 1190 | additional_js: vec![PathBuf::from("custom.js")], 1191 | ..Default::default() 1192 | }; 1193 | 1194 | let got = Config::from_str(src).unwrap(); 1195 | assert_eq!(got.book, book_should_be); 1196 | assert_eq!(got.build, build_should_be); 1197 | assert_eq!(got.html_config().unwrap(), html_should_be); 1198 | } 1199 | 1200 | #[test] 1201 | fn set_a_config_item() { 1202 | let mut cfg = Config::default(); 1203 | let key = "foo.bar.baz"; 1204 | let value = "Something Interesting"; 1205 | 1206 | assert!(cfg.get(key).is_none()); 1207 | cfg.set(key, value).unwrap(); 1208 | 1209 | let got: String = cfg.get_deserialized_opt(key).unwrap().unwrap(); 1210 | assert_eq!(got, value); 1211 | } 1212 | 1213 | #[test] 1214 | fn parse_env_vars() { 1215 | let inputs = vec![ 1216 | ("FOO", None), 1217 | ("MDBOOK_foo", Some("foo")), 1218 | ("MDBOOK_FOO__bar__baz", Some("foo.bar.baz")), 1219 | ("MDBOOK_FOO_bar__baz", Some("foo-bar.baz")), 1220 | ]; 1221 | 1222 | for (src, should_be) in inputs { 1223 | let got = parse_env(src); 1224 | let should_be = should_be.map(ToString::to_string); 1225 | 1226 | assert_eq!(got, should_be); 1227 | } 1228 | } 1229 | 1230 | fn encode_env_var(key: &str) -> String { 1231 | format!( 1232 | "MDBOOK_{}", 1233 | key.to_uppercase().replace('.', "__").replace("-", "_") 1234 | ) 1235 | } 1236 | 1237 | #[test] 1238 | fn update_config_using_env_var() { 1239 | let mut cfg = Config::default(); 1240 | let key = "foo.bar"; 1241 | let value = "baz"; 1242 | 1243 | assert!(cfg.get(key).is_none()); 1244 | 1245 | let encoded_key = encode_env_var(key); 1246 | env::set_var(encoded_key, value); 1247 | 1248 | cfg.update_from_env(); 1249 | 1250 | assert_eq!( 1251 | cfg.get_deserialized_opt::(key).unwrap().unwrap(), 1252 | value 1253 | ); 1254 | } 1255 | 1256 | #[test] 1257 | #[allow(clippy::approx_constant)] 1258 | fn update_config_using_env_var_and_complex_value() { 1259 | let mut cfg = Config::default(); 1260 | let key = "foo-bar.baz"; 1261 | let value = json!({"array": [1, 2, 3], "number": 3.14}); 1262 | let value_str = serde_json::to_string(&value).unwrap(); 1263 | 1264 | assert!(cfg.get(key).is_none()); 1265 | 1266 | let encoded_key = encode_env_var(key); 1267 | env::set_var(encoded_key, value_str); 1268 | 1269 | cfg.update_from_env(); 1270 | 1271 | assert_eq!( 1272 | cfg.get_deserialized_opt::(key) 1273 | .unwrap() 1274 | .unwrap(), 1275 | value 1276 | ); 1277 | } 1278 | 1279 | #[test] 1280 | fn update_book_title_via_env() { 1281 | let mut cfg = Config::default(); 1282 | let should_be = "Something else".to_string(); 1283 | 1284 | assert_ne!(cfg.book.title, Some(should_be.clone())); 1285 | 1286 | env::set_var("MDBOOK_BOOK__TITLE", &should_be); 1287 | cfg.update_from_env(); 1288 | 1289 | assert_eq!(cfg.book.title, Some(should_be)); 1290 | } 1291 | 1292 | #[test] 1293 | fn file_404_default() { 1294 | let src = r#" 1295 | [output.html] 1296 | destination = "my-book" 1297 | "#; 1298 | 1299 | let got = Config::from_str(src).unwrap(); 1300 | let html_config = got.html_config().unwrap(); 1301 | assert_eq!(html_config.input_404, None); 1302 | assert_eq!(&get_404_output_file(&html_config.input_404), "404.html"); 1303 | } 1304 | 1305 | #[test] 1306 | fn file_404_custom() { 1307 | let src = r#" 1308 | [output.html] 1309 | input-404= "missing.md" 1310 | output-404= "missing.html" 1311 | "#; 1312 | 1313 | let got = Config::from_str(src).unwrap(); 1314 | let html_config = got.html_config().unwrap(); 1315 | assert_eq!(html_config.input_404, Some("missing.md".to_string())); 1316 | assert_eq!(&get_404_output_file(&html_config.input_404), "missing.html"); 1317 | } 1318 | 1319 | #[test] 1320 | #[should_panic(expected = "Invalid configuration file")] 1321 | fn invalid_language_type_error() { 1322 | let src = r#" 1323 | [book] 1324 | title = "mdBook Documentation" 1325 | language = ["en", "pt-br"] 1326 | description = "Create book from markdown files. Like Gitbook but implemented in Rust" 1327 | authors = ["Mathieu David"] 1328 | src = "./source" 1329 | "#; 1330 | 1331 | Config::from_str(src).unwrap(); 1332 | } 1333 | 1334 | #[test] 1335 | #[should_panic(expected = "Invalid configuration file")] 1336 | fn invalid_title_type() { 1337 | let src = r#" 1338 | [book] 1339 | title = 20 1340 | language = "en" 1341 | description = "Create book from markdown files. Like Gitbook but implemented in Rust" 1342 | authors = ["Mathieu David"] 1343 | src = "./source" 1344 | "#; 1345 | 1346 | Config::from_str(src).unwrap(); 1347 | } 1348 | 1349 | #[test] 1350 | #[should_panic(expected = "Invalid configuration file")] 1351 | fn invalid_build_dir_type() { 1352 | let src = r#" 1353 | [build] 1354 | build-dir = 99 1355 | create-missing = false 1356 | "#; 1357 | 1358 | Config::from_str(src).unwrap(); 1359 | } 1360 | 1361 | #[test] 1362 | #[should_panic(expected = "Invalid configuration file")] 1363 | fn invalid_rust_edition() { 1364 | let src = r#" 1365 | [rust] 1366 | edition = "1999" 1367 | "#; 1368 | 1369 | Config::from_str(src).unwrap(); 1370 | } 1371 | 1372 | #[test] 1373 | fn book_language_without_languages_table() { 1374 | let src = r#" 1375 | [book] 1376 | language = "en" 1377 | "#; 1378 | 1379 | let got = Config::from_str(src).unwrap(); 1380 | assert_eq!(got.default_language(), None); 1381 | } 1382 | 1383 | #[test] 1384 | #[should_panic(expected = "Invalid configuration file")] 1385 | fn default_language_must_exist_in_languages_table() { 1386 | let src = r#" 1387 | [language.ja] 1388 | name = "日本語" 1389 | "#; 1390 | 1391 | Config::from_str(src).unwrap(); 1392 | } 1393 | 1394 | #[test] 1395 | #[should_panic(expected = "Invalid configuration file")] 1396 | fn validate_language_config_must_have_name() { 1397 | let src = r#" 1398 | [book] 1399 | language = "en" 1400 | 1401 | [language.en] 1402 | "#; 1403 | 1404 | Config::from_str(src).unwrap(); 1405 | } 1406 | } 1407 | --------------------------------------------------------------------------------