Compare commits

..

22 Commits

Author SHA1 Message Date
Tom Milligan
92b441a950 bin: better error handling 2022-11-26 21:16:40 +00:00
dependabot[bot]
5530da074b chore(deps): bump JamesIves/github-pages-deploy-action
Bumps [JamesIves/github-pages-deploy-action](https://github.com/JamesIves/github-pages-deploy-action) from 4.4.0 to 4.4.1.
- [Release notes](https://github.com/JamesIves/github-pages-deploy-action/releases)
- [Commits](https://github.com/JamesIves/github-pages-deploy-action/compare/v4.4.0...v4.4.1)

---
updated-dependencies:
- dependency-name: JamesIves/github-pages-deploy-action
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-11-01 15:57:30 +00:00
Tom Milligan
0fe2ad52ed chore: cargo dep updates 2022-11-01 15:18:58 +00:00
Tom Milligan
787744f1f1 chore: prep v1.8.0 release 2022-10-23 15:20:31 +01:00
Tom Milligan
603c83e2eb chore: upgrade to clap v4 2022-10-16 09:32:06 +01:00
Tom Milligan
fbdffaa723 chore: upgrade outdated dep versions 2022-10-16 09:32:06 +01:00
dependabot[bot]
7c20d5b2d1 chore(deps): bump actions/cache from 2 to 3
Bumps [actions/cache](https://github.com/actions/cache) from 2 to 3.
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](https://github.com/actions/cache/compare/v2...v3)

---
updated-dependencies:
- dependency-name: actions/cache
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-10-16 08:32:45 +01:00
dependabot[bot]
3507d6b5e0 chore(deps): bump JamesIves/github-pages-deploy-action
Bumps [JamesIves/github-pages-deploy-action](https://github.com/JamesIves/github-pages-deploy-action) from 4.3.3 to 4.4.0.
- [Release notes](https://github.com/JamesIves/github-pages-deploy-action/releases)
- [Commits](https://github.com/JamesIves/github-pages-deploy-action/compare/v4.3.3...v4.4.0)

---
updated-dependencies:
- dependency-name: JamesIves/github-pages-deploy-action
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-10-02 10:51:41 +01:00
dependabot[bot]
f81d4d40dd chore(deps): bump actions/download-artifact from 2 to 3
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 2 to 3.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v2...v3)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-10-02 10:51:08 +01:00
dependabot[bot]
ed019a92d9 chore(deps): bump actions/checkout from 2 to 3
Bumps [actions/checkout](https://github.com/actions/checkout) from 2 to 3.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v2...v3)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-10-02 10:51:00 +01:00
dependabot[bot]
9dd2ca128c chore(deps): bump actions/upload-artifact from 2 to 3
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 2 to 3.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v2...v3)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-10-02 10:50:51 +01:00
Matthias Beyer
51120acfd9 Add monthly dependabot
Signed-off-by: Matthias Beyer <mail@beyermatthias.de>
2022-10-02 10:08:38 +01:00
Tom Milligan
650123645b chore: upgrade dependencies 2022-10-02 09:22:46 +01:00
Tom Milligan
438c1dff5a ci: automatically deploy docs after publish 2022-06-11 10:23:30 +01:00
Tom Milligan
78b7451e49 chore: refactor scripts 2022-06-11 10:23:30 +01:00
Tom Milligan
bb937dc2d2 docs: update readme, documentation 2022-06-11 10:23:30 +01:00
Tom Milligan
db7101cb12 chore: prepare v1.7.0 release 2022-06-11 10:23:30 +01:00
Tom Milligan
fd6c2d0bd0 fix: try config v2 before v1 2022-06-11 10:23:30 +01:00
gggto
72deb8421c feat: add collapsible support 2022-05-18 13:24:00 +01:00
Tom Milligan
7b5a13d6af feat: make anchor links hoverable 2022-05-15 19:09:19 +01:00
Tom Milligan
28dfc5b6c3 feat: better bug output, option to fail mdbook build 2022-05-01 22:12:15 +01:00
Tom Milligan
beb640077f feat: add key/value configuration 2022-05-01 21:48:35 +01:00
30 changed files with 1674 additions and 900 deletions

10
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,10 @@
version: 2
updates:
- package-ecosystem: cargo
directory: "/"
schedule:
interval: monthly
- package-ecosystem: github-actions
directory: "/"
schedule:
interval: monthly

View File

@@ -9,9 +9,9 @@ jobs:
runs-on: ubuntu-20.04
steps:
- name: Checkout sources
uses: actions/checkout@v2
uses: actions/checkout@v3
- name: Cache build files
uses: actions/cache@v2
uses: actions/cache@v3
with:
path: |
~/.cargo/registry
@@ -35,9 +35,9 @@ jobs:
runs-on: ubuntu-20.04
steps:
- name: Checkout sources
uses: actions/checkout@v2
uses: actions/checkout@v3
- name: Cache build files
uses: actions/cache@v2
uses: actions/cache@v3
with:
path: |
~/.cargo/registry
@@ -70,7 +70,7 @@ jobs:
rust:
- stable
- beta
- 1.58.0
- 1.60.0
experimental:
- false
# Run a canary test on nightly that's allowed to fail
@@ -87,9 +87,9 @@ jobs:
continue-on-error: ${{ matrix.experimental }}
steps:
- name: Checkout sources
uses: actions/checkout@v2
uses: actions/checkout@v3
- name: Cache build files
uses: actions/cache@v2
uses: actions/cache@v3
with:
path: |
~/.cargo/registry

View File

@@ -37,11 +37,11 @@ jobs:
runs-on: ${{ matrix.os }}
steps:
- name: Setup | Checkout
uses: actions/checkout@v2
uses: actions/checkout@v3
# Cache files between builds
- name: Setup | Cache Cargo
uses: actions/cache@v2
uses: actions/cache@v3
with:
path: |
~/.cargo/registry
@@ -90,7 +90,7 @@ jobs:
tar czvf ../../stage/${{ env.CRATE_NAME }}-${{ steps.extract_tag.outputs.tag }}-${{ matrix.name }} ${{ env.CRATE_NAME }}
cd -
- name: Post Setup | Upload artifacts
uses: actions/upload-artifact@v2
uses: actions/upload-artifact@v3
with:
name: ${{ env.CRATE_NAME }}-${{ steps.extract_tag.outputs.tag }}-${{ matrix.name }}
path: target/stage/*
@@ -102,12 +102,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Setup | Checkout
uses: actions/checkout@v2
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Setup | Artifacts
uses: actions/download-artifact@v2
uses: actions/download-artifact@v3
- name: Setup | Release notes
run: |

39
.github/workflows/docs.yml vendored Normal file
View File

@@ -0,0 +1,39 @@
name: docs
on:
release:
types: [published]
workflow_dispatch:
permissions:
contents: write
jobs:
publish:
runs-on: ubuntu-20.04
steps:
- uses: actions/checkout@v3
- uses: actions/cache@v3
with:
path: |
~/.cargo/registry
~/.cargo/git
target
~/.cargo/bin
cargo_target
# We reuse the cache from our detailed test environment, if available
key: detailed-test-${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.toml') }}
- name: Install toolchain
uses: actions-rs/toolchain@v1
with:
toolchain: stable
override: true
- name: Install mdbook
run: ./scripts/install-mdbook
- name: Build book
run: ./scripts/build-book
- name: Push docs
uses: JamesIves/github-pages-deploy-action@v4.4.1
with:
branch: gh-pages
folder: book/book

View File

@@ -9,8 +9,8 @@ jobs:
publish:
runs-on: ubuntu-20.04
steps:
- uses: actions/checkout@v2
- uses: actions/cache@v2
- uses: actions/checkout@v3
- uses: actions/cache@v3
with:
path: |
~/.cargo/registry

View File

@@ -1,5 +1,28 @@
## Changelog
## Unreleased
## 1.8.0
### Changed
- MSRV (minimum supported rust version) is now 1.60.0 for clap v4
## 1.7.0
### Changed
- Required styles version is now `^2.0.0` (release `1.7.0`). Run `mdbook-admonish install` to update.
### Added
- Support key/value configuration ([#24](https://github.com/tommilligan/mdbook-admonish/pull/24), thanks [@gggto](https://github.com/gggto) and [@schungx](https://github.com/schungx) for design input)
- Support collapsiable admonition bodies ([#26](https://github.com/tommilligan/mdbook-admonish/pull/26), thanks [@gggto](https://github.com/gggto) for the suggestion and implementation!)
- Make anchor links hoverable ([#27](https://github.com/tommilligan/mdbook-admonish/pull/27))
- Better handling for misconfigured admonitions ([#25](https://github.com/tommilligan/mdbook-admonish/pull/25))
- Nicer in-book error messages
- Option to fail the build instead
## 1.6.0
**Please note:** If updating from an older version, this release requires `mdboook-admonish install` to be rerun after installation.

1054
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,12 @@
[package]
name = "mdbook-admonish"
version = "1.6.0"
version = "1.8.0"
edition = "2021"
authors = ["Tom Milligan <code@tommilligan.net>"]
description = "A preprocessor for mdbook to add Material Design admonishments."
repository = "https://github.com/tommilligan/mdbook-admonish"
documentation = "https://docs.rs/mdbook-admonish"
documentation = "https://tommilligan.github.io/mdbook-admonish/"
license = "MIT"
keywords = ["mdbook", "markdown", "material", "design", "ui"]
@@ -22,18 +22,22 @@ name = "mdbook_admonish"
path = "src/lib.rs"
[dependencies]
anyhow = "1.0.57"
clap = { version = "3.0.14", default_features = false, features = ["std", "cargo"], optional = true }
env_logger = { version = "0.9.0", default_features = false, optional = true }
log = { version = "0.4.14", optional = true }
mdbook = "0.4.15"
pulldown-cmark = "0.9.1"
semver = "1.0.7"
serde_json = "1.0.79"
toml_edit = { version = "0.13.4", optional = true }
anyhow = "1.0.65"
clap = { version = "4", default_features = false, features = ["std", "derive"], optional = true }
env_logger = { version = "0.9.1", default_features = false, optional = true }
log = { version = "0.4.17", optional = true }
mdbook = "0.4.21"
once_cell = "1.15.0"
pulldown-cmark = "0.9.2"
regex = "1.6.0"
semver = "1.0.14"
serde = { version = "1.0.145", features = ["derive"] }
serde_json = "1.0.85"
toml = "0.5.9"
toml_edit = { version = "0.15.0", optional = true }
[dev-dependencies]
pretty_assertions = "1.1.0"
pretty_assertions = "1.3.0"
[features]
default = ["cli", "cli-install"]

View File

@@ -1,7 +1,7 @@
# mdbook-admonish
[![Latest version](https://img.shields.io/crates/v/mdbook-admonish.svg)](https://crates.io/crates/mdbook-admonish)
[![docs.rs](https://img.shields.io/docsrs/mdbook-admonish)](https://docs.rs/mdbook-admonish)
[![docs.rs](https://img.shields.io/badge/docs-available-brightgreen)](https://tommilligan.github.io/mdbook-admonish/)
A preprocessor for [mdbook](https://github.com/rust-lang-nursery/mdBook) to add [Material Design](https://material.io/design) admonishments, based on the [mkdocs-material](https://squidfunk.github.io/mkdocs-material/reference/admonitions/) implementation.
@@ -19,7 +19,7 @@ into this:
## Examples
Read the usage and reference [here](https://tommilligan.github.io/mdbook-admonish/), to see the actual examples in action. You can see the source in the [`./book`](./book) subdirectory.
Read the documentation [here](https://tommilligan.github.io/mdbook-admonish/), to see the actual examples in action. You can see the source in the [`./book`](./book) subdirectory.
Other projects using mdbook-admonish:
@@ -58,72 +58,11 @@ A plain note.
### Additional Options
#### Custom title
See the [`mdbook-admonish` book](https://tommilligan.github.io/mdbook-admonish/) for additional options, such as:
A custom title can be provided, contained in a double quoted JSON string.
Note that JSON escapes must be escaped again - for instance, write `\"` as `\\"`.
````
```admonish warning "Data loss"
The following steps can lead to irrecoverable data corruption.
```
````
![Data Loss](img/data-loss.png)
You can also remove the title bar entirely, by specifying the empty string:
````
```admonish success ""
This will take a while, go and grab a drink of water.
```
````
![No Title Bar](img/no-title-bar.png)
#### Nested Markdown/HTML
Markdown and HTML can be used in the inner content, as you'd expect:
````
```admonish tip "_Referencing_ and <i>dereferencing</i>"
The opposite of *referencing* by using `&` is *dereferencing*, which is
accomplished with the <span style="color: hotpink">dereference operator</span>, `*`.
```
````
![Complex Message](img/complex-message.png)
If you have code blocks you want to include in the content, use [tildes for the outer code fence](https://spec.commonmark.org/0.30/#fenced-code-blocks):
````
~~~admonish bug
This syntax won't work in Python 3:
```python
print "Hello, world!"
```
~~~
````
![Code Bug](img/code-bug.png)
#### Custom styling
If you want to provide custom styling to a specific admonition, you can attach one or more custom classnames:
````
```admonish note.custom-0.custom-1
Styled with my custom CSS class.
```
````
Will yield something like the following HTML, which you can then apply styles to:
```html
<div class="admonition note custom-0 custom-1"
...
</div>
```
- Custom titles
- Custom styling
- Collapsible blocks
## Installation
@@ -184,6 +123,19 @@ Alternatively, pin to a specific version for a reproducible installation:
cargo install mdbook-admonish --vers "1.5.0" --locked
```
### Bail on error
By default, if an adomnition is incorrectly configured, an error will be shown in the book.
You can force it to break the build instead, with the following configuration:
```toml
[preprocessor.admonish]
on_failure = "bail"
```
This may be useful for non-interative workflows.
### Semantic Versioning
Guarantees provided are as follows:
@@ -191,6 +143,10 @@ Guarantees provided are as follows:
- Major versions: Contain breaking changes to the user facing markdown API, or the public API of the crate itself.
- Minor versions: Feature release. May contain changes to generated CSS/HTML requiring `mdbook-admonish install` to be rerun.
- **Note:** updating acrosss minor versions without running `mdbook-admonish install` to reinstall assets may break your build.
- This is due to limitations in the `mdbook` preprocessor architecture. Relevant issues that may alleviate this:
- https://github.com/rust-lang/mdBook/issues/1222
- https://github.com/rust-lang/mdBook/issues/1687
- https://github.com/rust-lang/mdBook/issues/1689
- Patch versions: Bug fixes only.
## Development

View File

@@ -9,7 +9,7 @@ title = "The mdbook-admonish book"
[preprocessor.admonish]
command = "mdbook-admonish"
assets_version = "1.0.0" # do not edit: managed by `mdbook-admonish install`
assets_version = "2.0.0" # do not edit: managed by `mdbook-admonish install`
[output]

View File

@@ -58,28 +58,28 @@ A plain note.
#### Custom title
A custom title can be provided, contained in a double quoted JSON string.
Note that JSON escapes must be escaped again - for instance, write `\"` as `\\"`.
A custom title can be provided, contained in a double quoted TOML string.
Note that TOML escapes must be escaped again - for instance, write `\"` as `\\"`.
````
```admonish warning "Data loss"
```admonish warning title="Data loss"
The following steps can lead to irrecoverable data corruption.
```
````
```admonish warning "Data loss"
```admonish warning title="Data loss"
The following steps can lead to irrecoverable data corruption.
```
You can also remove the title bar entirely, by specifying the empty string:
````
```admonish success ""
```admonish success title=""
This will take a while, go and grab a drink of water.
```
````
```admonish success ""
```admonish success title=""
This will take a while, go and grab a drink of water.
```
@@ -88,13 +88,13 @@ This will take a while, go and grab a drink of water.
Markdown and HTML can be used in the inner content, as you'd expect:
````
```admonish tip "_Referencing_ and <i>dereferencing</i>"
```admonish tip title="_Referencing_ and <i>dereferencing</i>"
The opposite of *referencing* by using `&` is *dereferencing*, which is
accomplished with the <span style="color: hotpink">dereference operator</span>, `*`.
```
````
```admonish tip "_Referencing_ and <i>dereferencing</i>"
```admonish tip title="_Referencing_ and <i>dereferencing</i>"
The opposite of *referencing* by using `&` is *dereferencing*, which is
accomplished with the <span style="color: hotpink">dereference operator</span>, `*`.
```
@@ -122,7 +122,7 @@ print "Hello, world!"
If you want to provide custom styling to a specific admonition, you can attach one or more custom classnames:
````
```admonish note.custom-0.custom-1
```admonish note class="custom-0 custom-1"
Styled with my custom CSS class.
```
````
@@ -134,3 +134,33 @@ Will yield something like the following HTML, which you can then apply styles to
...
</div>
```
#### Collapsible
For a block to be initially collapsible, and then be openable, set `collapsible=true`:
````
```admonish collapsible=true
Content will be hidden initially.
```
````
Will yield something like the following HTML, which you can then apply styles to:
```admonish collapsible=true
Content will be hidden initially.
```
#### Invalid blocks
If a rendering error occurs, an error will be rendered in the output:
````
```admonish title="\j"
This block will error
```
````
```admonish title="\j"
This block will error
```

View File

@@ -65,6 +65,8 @@ $admonitions: (
--md-admonition-icon--#{nth($names, 1)}:
url("data:image/svg+xml;charset=utf-8,#{nth($props, 2)}");
}
--md-details-icon:
url("data:image/svg+xml;charset=utf-8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><path d='M8.59 16.58 13.17 12 8.59 7.41 10 6l6 6-6 6-1.41-1.42Z'/></svg>");
}
// ----------------------------------------------------------------------------
@@ -116,6 +118,15 @@ $admonitions: (
// Anchor links
a.admonition-anchor-link {
// Don't display the link by default
display: none;
// Position to the left of the element to link to
position: absolute;
left: -1.2rem;
// Ensure we have enough padding, so that we can move the mouse to click on it
padding-right: 1.0rem;
&:link, &:visited {
// Don't make links colored (override to standard text color)
// variable provided downstream by mdbook
@@ -125,6 +136,10 @@ a.admonition-anchor-link {
// No underline on hover
text-decoration: none;
}
&::before {
content: '§';
}
}
// Admonition title
@@ -165,6 +180,36 @@ a.admonition-anchor-link {
-webkit-mask-size: contain;
content: "";
}
// Show anchor link on hover over title
&:hover a.admonition-anchor-link {
display: initial
}
}
summary.admonition-title {
details.admonition > &::after {
position: absolute;
top: .625em;
inset-inline-end: 1.6rem;
height: 2rem;
width: 2rem;
background-color: currentcolor;
mask-image: var(--md-details-icon);
-webkit-mask-image: var(--md-details-icon);
mask-repeat: no-repeat;
-webkit-mask-repeat: no-repeat;
mask-size: contain;
-webkit-mask-size: contain;
content: "";
transform: rotate(0deg);
transition: transform .25s;
}
details[open].admonition > &::after {
transform: rotate(90deg);
}
}
// ----------------------------------------------------------------------------

View File

@@ -9,7 +9,7 @@ title = "mdbook-admonish-integration"
[preprocessor.admonish]
command = "mdbook-admonish"
assets_version = "1.0.0" # do not edit: managed by `mdbook-admonish install`
assets_version = "2.0.0" # do not edit: managed by `mdbook-admonish install`
[output]

View File

@@ -9,7 +9,7 @@ title = "mdbook-admonish-integration"
[preprocessor.admonish]
command = "mdbook-admonish"
assets_version = "1.0.0" # do not edit: managed by `mdbook-admonish install`
assets_version = "2.0.0" # do not edit: managed by `mdbook-admonish install`
[output]

View File

@@ -1,9 +1,8 @@
<h1 id="chapter-1"><a class="header" href="#chapter-1">Chapter 1</a></h1>
<div id="admonition-what-is-this" class="admonition abstract">
<div class="admonition-title">
<a class="admonition-anchor-link" href="#admonition-what-is-this">
<p>What <i>is</i> this?</p>
</a>
<p><a class="admonition-anchor-link" href="#admonition-what-is-this"></a></p>
</div>
<div>
<p>This book acts as an integration test for <code>mdbook-admonish</code>.</p>
@@ -12,9 +11,8 @@
</div>
<div id="admonition-note" class="admonition note">
<div class="admonition-title">
<a class="admonition-anchor-link" href="#admonition-note">
<p>Note</p>
</a>
<p><a class="admonition-anchor-link" href="#admonition-note"></a></p>
</div>
<div>
<p>Simples</p>
@@ -25,4 +23,27 @@
<p>No title, only body</p>
</div>
</div>
<div id="admonition-error-rendering-admonishment" class="admonition bug">
<div class="admonition-title">
<p>Error rendering admonishment</p>
<p><a class="admonition-anchor-link" href="#admonition-error-rendering-admonishment"></a></p>
</div>
<div>
<p>Failed with: TOML parsing error: unterminated string at line 1 column 7</p>
<p>Original markdown input:</p>
<pre><code>```admonish title=&quot;
No title, only body
```
</code></pre>
</div>
</div>
<details id="admonition-note-1" class="admonition note">
<summary class="admonition-title">
<p>Note</p>
<p><a class="admonition-anchor-link" href="#admonition-note-1"></a></p>
</summary>
<div>
<p>Hidden on load</p>
</div>
</details>

View File

@@ -13,3 +13,11 @@ Simples
```admonish warning ""
No title, only body
```
```admonish title="
No title, only body
```
```admonish collapsible=true
Hidden on load
```

24
scripts/build-book Executable file
View File

@@ -0,0 +1,24 @@
#!/bin/bash
# Build book, using styles present in the repository.
set -euo pipefail
cd "$(dirname "$0")"/..
function eprintln() {
>&2 echo "$1"
}
eprintln "Installing mdbook-admonish (to system)"
cargo install --path . --force
pushd book
eprintln "Installing mdbook-admonish (to book)"
mdbook-admonish install .
eprintln "Building book"
mdbook build
popd
eprintln "Book generated at ./book/book/index.html"

View File

@@ -15,10 +15,12 @@ cargo fmt -- --check
# - RUSTSEC-2020-0071 known unlikely segfault in `time`
# - RUSTSEC-2020-0016 `net2` is unmaintained
# - RUSTSEC-2020-0159 known unlikely segfault in `chrono`
# - RUSTSEC-2021-0145 known unmaintained atty transitive dep
eprintln "Auditing dependencies"
cargo audit --deny warnings \
--ignore RUSTSEC-2020-0071 \
--ignore RUSTSEC-2020-0016 \
--ignore RUSTSEC-2021-0145 \
--ignore RUSTSEC-2020-0159
eprintln "Linting sources"

View File

@@ -1,5 +1,9 @@
#!/bin/bash
# Install everything for development/CI
#
# Does not include offline node development stack (i.e. yarn)
set -exuo pipefail
cd "$(dirname "$0")"/..
@@ -9,6 +13,5 @@ rustup component add rustfmt clippy
if ! cargo audit --version; then
cargo install cargo-audit --force
fi
if ! mdbook --version; then
cargo install mdbook --force
fi
./scripts/install-mdbook

9
scripts/install-mdbook Executable file
View File

@@ -0,0 +1,9 @@
#!/bin/bash
set -exuo pipefail
cd "$(dirname "$0")"/..
if ! mdbook --version; then
cargo install mdbook --force
fi

View File

@@ -1,5 +1,7 @@
#!/bin/bash
# Development only. Rebuilds the book, including recompiling styles.
set -euo pipefail
cd "$(dirname "$0")"/..
@@ -13,15 +15,4 @@ pushd compile_assets
yarn run build
popd
eprintln "Installing mdbook-admonish (to system)"
cargo install --path . --force
pushd book
eprintln "Installing mdbook-admonish (to book)"
mdbook-admonish install .
eprintln "Building book"
mdbook build
popd
eprintln "Book generated at ./book/book/index.html"
./scripts/build-book

View File

@@ -1 +1 @@
^1.0.0
^2.0.0

View File

@@ -1 +1 @@
1.0.0
2.0.0

View File

@@ -1,3 +1,4 @@
@charset "UTF-8";
:root {
--md-admonition-icon--note:
url("data:image/svg+xml;charset=utf-8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><path d='M20.71 7.04c.39-.39.39-1.04 0-1.41l-2.34-2.34c-.37-.39-1.02-.39-1.41 0l-1.84 1.83 3.75 3.75M3 17.25V21h3.75L17.81 9.93l-3.75-3.75L3 17.25z'/></svg>");
@@ -23,6 +24,8 @@
url("data:image/svg+xml;charset=utf-8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><path d='M7 13v-2h14v2H7m0 6v-2h14v2H7M7 7V5h14v2H7M3 8V5H2V4h2v4H3m-1 9v-1h3v4H2v-1h2v-.5H3v-1h1V17H2m2.25-7a.75.75 0 0 1 .75.75c0 .2-.08.39-.21.52L3.12 13H5v1H2v-.92L4 11H2v-1h2.25z'/></svg>");
--md-admonition-icon--quote:
url("data:image/svg+xml;charset=utf-8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><path d='M14 17h3l2-4V7h-6v6h3M6 17h3l2-4V7H5v6h3l-2 4z'/></svg>");
--md-details-icon:
url("data:image/svg+xml;charset=utf-8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><path d='M8.59 16.58 13.17 12 8.59 7.41 10 6l6 6-6 6-1.41-1.42Z'/></svg>");
}
:is(.admonition) {
@@ -56,12 +59,21 @@ html :is(.admonition) > :last-child {
margin-bottom: 1.2rem;
}
a.admonition-anchor-link {
display: none;
position: absolute;
left: -1.2rem;
padding-right: 1rem;
}
a.admonition-anchor-link:link, a.admonition-anchor-link:visited {
color: var(--fg);
}
a.admonition-anchor-link:link:hover, a.admonition-anchor-link:visited:hover {
text-decoration: none;
}
a.admonition-anchor-link::before {
content: "§";
}
:is(.admonition-title, summary) {
position: relative;
@@ -94,6 +106,30 @@ html :is(.admonition-title, summary):last-child {
-webkit-mask-size: contain;
content: "";
}
:is(.admonition-title, summary):hover a.admonition-anchor-link {
display: initial;
}
details.admonition > summary.admonition-title::after {
position: absolute;
top: 0.625em;
inset-inline-end: 1.6rem;
height: 2rem;
width: 2rem;
background-color: currentcolor;
mask-image: var(--md-details-icon);
-webkit-mask-image: var(--md-details-icon);
mask-repeat: no-repeat;
-webkit-mask-repeat: no-repeat;
mask-size: contain;
-webkit-mask-size: contain;
content: "";
transform: rotate(0deg);
transition: transform 0.25s;
}
details[open].admonition > summary.admonition-title::after {
transform: rotate(90deg);
}
:is(.admonition):is(.note) {
border-color: #448aff;

View File

@@ -1,58 +1,70 @@
use clap::{crate_version, Arg, ArgMatches, Command};
use anyhow::Result;
use clap::{Parser, Subcommand};
use mdbook::{
errors::Error,
preprocess::{CmdPreprocessor, Preprocessor},
};
use mdbook_admonish::Admonish;
#[cfg(feature = "cli-install")]
use std::path::PathBuf;
use std::{io, process};
pub fn make_app() -> Command<'static> {
let mut command = Command::new("mdbook-admonish")
.version(crate_version!())
.about("mdbook preprocessor to add support for admonitions");
command = command.subcommand(
Command::new("supports")
.arg(Arg::new("renderer").required(true))
.about("Check whether a renderer is supported by this preprocessor"),
);
/// mdbook preprocessor to add support for admonitions
#[derive(Parser)]
#[command(author, version, about, long_about = None)]
struct Cli {
#[command(subcommand)]
command: Option<Commands>,
}
#[derive(Subcommand)]
enum Commands {
/// Check whether a renderer is supported by this preprocessor
Supports { renderer: String },
#[cfg(feature = "cli-install")]
{
command = command.subcommand(
Command::new("install")
.arg(Arg::new("css-dir").long("css-dir").default_value(".").help(
"Relative directory for the css assets,\nfrom the book directory root",
))
.arg(Arg::new("dir").default_value(".").help(
"Root directory for the book,\nshould contain the configuration file (`book.toml`)",
))
.about("Install the required assset files and include it in the config"));
}
command
/// Install the required assset files and include it in the config
Install {
/// Root directory for the book, should contain the configuration file (`book.toml`)
///
/// If not set, defaults to the current directory.
dir: Option<PathBuf>,
/// Relative directory for the css assets, from the book directory root
///
/// If not set, defaults to the current directory.
#[arg(long)]
css_dir: Option<PathBuf>,
},
}
fn main() {
env_logger::init_from_env(env_logger::Env::default().default_filter_or("info"));
let matches = make_app().get_matches();
if let Some(sub_args) = matches.subcommand_matches("supports") {
handle_supports(sub_args);
} else if let Some(sub_args) = matches.subcommand_matches("install") {
#[cfg(feature = "cli-install")]
{
install::handle_install(sub_args);
let cli = Cli::parse();
if let Err(error) = run(cli) {
log::error!("Fatal error: {}", error);
for error in error.chain() {
log::error!(" - {}", error);
}
#[cfg(not(feature = "cli-install"))]
{
panic!("cli-install feature not enabled: {:?}", sub_args)
}
} else if let Err(e) = handle_preprocessing() {
eprintln!("{}", e);
process::exit(1);
}
}
fn run(cli: Cli) -> Result<()> {
match cli.command {
None => handle_preprocessing(),
Some(Commands::Supports { renderer }) => {
handle_supports(renderer);
}
#[cfg(feature = "cli-install")]
Some(Commands::Install { dir, css_dir }) => install::handle_install(
dir.unwrap_or_else(|| PathBuf::from(".")),
css_dir.unwrap_or_else(|| PathBuf::from(".")),
),
}
}
fn handle_preprocessing() -> Result<(), Error> {
let (ctx, book) = CmdPreprocessor::parse_input(io::stdin())?;
@@ -71,9 +83,8 @@ fn handle_preprocessing() -> Result<(), Error> {
Ok(())
}
fn handle_supports(sub_args: &ArgMatches) -> ! {
let renderer = sub_args.value_of("renderer").expect("Required argument");
let supported = Admonish.supports_renderer(renderer);
fn handle_supports(renderer: String) -> ! {
let supported = Admonish.supports_renderer(&renderer);
// Signal whether the renderer is supported by exiting with 1 or 0.
if supported {
@@ -85,12 +96,11 @@ fn handle_supports(sub_args: &ArgMatches) -> ! {
#[cfg(feature = "cli-install")]
mod install {
use clap::ArgMatches;
use anyhow::{Context, Result};
use std::{
fs::{self, File},
io::Write,
path::PathBuf,
process,
};
use toml_edit::{self, Array, Document, Item, Table, Value};
@@ -112,22 +122,14 @@ mod install {
}
}
pub fn handle_install(sub_args: &ArgMatches) {
let dir = sub_args.value_of("dir").expect("Required argument");
let css_dir = sub_args.value_of("css-dir").expect("Required argument");
let proj_dir = PathBuf::from(dir);
pub fn handle_install(proj_dir: PathBuf, css_dir: PathBuf) -> Result<()> {
let config = proj_dir.join("book.toml");
if !config.exists() {
log::error!("Configuration file '{}' missing", config.display());
process::exit(1);
}
log::info!("Reading configuration file '{}'", config.display());
let toml = fs::read_to_string(&config).expect("can't read configuration file");
let toml = fs::read_to_string(&config)
.with_context(|| format!("can't read configuration file '{}'", config.display()))?;
let mut doc = toml
.parse::<Document>()
.expect("configuration is not valid TOML");
.context("configuration is not valid TOML")?;
if let Ok(preprocessor) = preprocessor(&mut doc) {
const ASSETS_VERSION: &str = std::include_str!("./assets/VERSION");
@@ -142,8 +144,8 @@ mod install {
let mut additional_css = additional_css(&mut doc);
for (name, content) in ADMONISH_CSS_FILES {
let filepath = proj_dir.join(css_dir).join(name);
let filepath_str = filepath.to_str().expect("non-utf8 filepath");
let filepath = proj_dir.join(&css_dir).join(name);
let filepath_str = filepath.to_str().context("non-utf8 filepath")?;
if let Ok(ref mut additional_css) = additional_css {
if !additional_css.contains_str(filepath_str) {
@@ -158,18 +160,18 @@ mod install {
"Copying '{name}' to '{filepath}'",
filepath = filepath.display()
);
let mut file = File::create(filepath).expect("can't open file for writing");
let mut file = File::create(&filepath).context("can't open file for writing")?;
file.write_all(content)
.expect("can't write content to file");
.context("can't write content to file")?;
}
let new_toml = doc.to_string();
if new_toml != toml {
log::info!("Saving changed configuration to '{}'", config.display());
let mut file =
File::create(config).expect("can't open configuration file for writing.");
File::create(config).context("can't open configuration file for writing.")?;
file.write_all(new_toml.as_bytes())
.expect("can't write configuration");
.context("can't write configuration")?;
} else {
log::info!("Configuration '{}' already up to date", config.display());
}
@@ -179,8 +181,7 @@ mod install {
A beautifully styled message.
```"#;
log::info!("Add a code block like:\n{}", codeblock);
process::exit(0);
Ok(())
}
/// Return the `additional-css` field, initializing if required.

158
src/config/mod.rs Normal file
View File

@@ -0,0 +1,158 @@
use crate::types::Directive;
use std::str::FromStr;
mod v1;
mod v2;
#[derive(Debug, PartialEq)]
pub(crate) struct AdmonitionInfoRaw {
directive: String,
title: Option<String>,
additional_classnames: Vec<String>,
collapsible: bool,
}
/// Extract the remaining info string, if this is an admonition block.
fn admonition_config_string(info_string: &str) -> Option<&str> {
const ADMONISH_BLOCK_KEYWORD: &str = "admonish";
// Get the rest of the info string if this is an admonition
if info_string == ADMONISH_BLOCK_KEYWORD {
return Some("");
}
match info_string.split_once(' ') {
Some((keyword, rest)) if keyword == ADMONISH_BLOCK_KEYWORD => Some(rest),
_ => None,
}
}
impl AdmonitionInfoRaw {
/// Returns:
/// - `None` if this is not an `admonish` block.
/// - `Some(AdmonitionInfoRaw)` if this is an `admonish` block
pub fn from_info_string(info_string: &str) -> Option<Result<Self, String>> {
let config_string = admonition_config_string(info_string)?;
// If we succeed at parsing v2, return that. Otherwise hold onto the error
let config_v2_error = match v2::from_config_string(config_string) {
Ok(config) => return Some(Ok(config)),
Err(config) => config,
};
Some(
if let Ok(info_raw) = v1::from_config_string(config_string) {
// If we succeed at parsing v1, return that.
Ok(info_raw)
} else {
// Otherwise return our v2 error.
Err(config_v2_error)
},
)
}
}
#[derive(Debug, PartialEq)]
pub(crate) struct AdmonitionInfo {
pub directive: Directive,
pub title: Option<String>,
pub additional_classnames: Vec<String>,
pub collapsible: bool,
}
impl AdmonitionInfo {
pub fn from_info_string(info_string: &str) -> Option<Result<Self, String>> {
AdmonitionInfoRaw::from_info_string(info_string).map(|result| result.map(Into::into))
}
}
impl From<AdmonitionInfoRaw> for AdmonitionInfo {
fn from(other: AdmonitionInfoRaw) -> Self {
let AdmonitionInfoRaw {
directive: raw_directive,
title,
additional_classnames,
collapsible,
} = other;
let (directive, title) = match (Directive::from_str(&raw_directive), title) {
(Ok(directive), None) => (directive, ucfirst(&raw_directive)),
(Err(_), None) => (Directive::Note, "Note".to_owned()),
(Ok(directive), Some(title)) => (directive, title),
(Err(_), Some(title)) => (Directive::Note, title),
};
// If the user explicitly gave no title, then disable the title bar
let title = if title.is_empty() { None } else { Some(title) };
Self {
directive,
title,
additional_classnames,
collapsible,
}
}
}
/// Make the first letter of `input` upppercase.
///
/// source: https://stackoverflow.com/a/38406885
fn ucfirst(input: &str) -> String {
let mut chars = input.chars();
match chars.next() {
None => String::new(),
Some(f) => f.to_uppercase().collect::<String>() + chars.as_str(),
}
}
#[cfg(test)]
mod test {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn test_from_info_string() {
// Not admonition blocks
assert_eq!(AdmonitionInfoRaw::from_info_string(""), None);
assert_eq!(AdmonitionInfoRaw::from_info_string("adm"), None);
// v1 syntax is supported back compatibly
assert_eq!(
AdmonitionInfoRaw::from_info_string("admonish note.additional-classname")
.unwrap()
.unwrap(),
AdmonitionInfoRaw {
directive: "note".to_owned(),
title: None,
additional_classnames: vec!["additional-classname".to_owned()],
collapsible: false,
}
);
// v2 syntax is supported
assert_eq!(
AdmonitionInfoRaw::from_info_string(r#"admonish title="Custom Title" type="question""#)
.unwrap()
.unwrap(),
AdmonitionInfoRaw {
directive: "question".to_owned(),
title: Some("Custom Title".to_owned()),
additional_classnames: Vec::new(),
collapsible: false,
}
);
}
#[test]
fn test_admonition_info_from_raw() {
assert_eq!(
AdmonitionInfo::from(AdmonitionInfoRaw {
directive: " ".to_owned(),
title: None,
additional_classnames: Vec::new(),
collapsible: false,
}),
AdmonitionInfo {
directive: Directive::Note,
title: Some("Note".to_owned()),
additional_classnames: Vec::new(),
collapsible: false,
}
);
}
}

130
src/config/v1.rs Normal file
View File

@@ -0,0 +1,130 @@
use super::AdmonitionInfoRaw;
use once_cell::sync::Lazy;
use regex::Regex;
pub(crate) fn from_config_string(config_string: &str) -> Result<AdmonitionInfoRaw, String> {
let config_string = config_string.trim();
static RX_CONFIG_STRING_V1: Lazy<Regex> = Lazy::new(|| {
let directive = r#"[a-z]+"#;
let css_classname = r#"-?[_a-zA-Z]+[_a-zA-Z0-9-]*"#;
let title = r#"".*""#;
Regex::new(&format!(
"^({directive})?(\\.({css_classname})?)*( {title})?$"
))
.expect("config string v1 regex")
});
// Check if this is a valid looking v1 directive
if !RX_CONFIG_STRING_V1.is_match(config_string) {
return Err("Invalid configuration string".to_owned());
}
// If we're just given the directive, handle that
let (directive, title) = config_string
.split_once(' ')
.map(|(directive, title)| (directive, Some(title)))
.unwrap_or_else(|| (config_string, None));
// The title is expected to be a quoted JSON string
// If parsing fails, output the error message as the title for the user to correct
let title = title
.map(|title| {
serde_json::from_str::<String>(title)
.map_err(|error| format!("Error parsing JSON string: {error}"))
})
.transpose()?;
// If the directive contains additional classes, parse them out
const CLASSNAME_SEPARATOR: char = '.';
let (directive, additional_classnames) = match directive.split_once(CLASSNAME_SEPARATOR) {
None => (directive, Vec::new()),
Some((directive, additional_classnames)) => (
directive,
additional_classnames
.split(CLASSNAME_SEPARATOR)
.filter(|classname| !classname.is_empty())
.map(|classname| classname.to_owned())
.collect(),
),
};
Ok(AdmonitionInfoRaw {
directive: directive.to_owned(),
title,
additional_classnames,
collapsible: false,
})
}
#[cfg(test)]
mod test {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn test_from_config_string() {
assert_eq!(
from_config_string("").unwrap(),
AdmonitionInfoRaw {
directive: "".to_owned(),
title: None,
additional_classnames: Vec::new(),
collapsible: false,
}
);
assert_eq!(
from_config_string(" ").unwrap(),
AdmonitionInfoRaw {
directive: "".to_owned(),
title: None,
additional_classnames: Vec::new(),
collapsible: false,
}
);
assert_eq!(
from_config_string("unknown").unwrap(),
AdmonitionInfoRaw {
directive: "unknown".to_owned(),
title: None,
additional_classnames: Vec::new(),
collapsible: false,
}
);
assert_eq!(
from_config_string("note").unwrap(),
AdmonitionInfoRaw {
directive: "note".to_owned(),
title: None,
additional_classnames: Vec::new(),
collapsible: false,
}
);
assert_eq!(
from_config_string("note.additional-classname").unwrap(),
AdmonitionInfoRaw {
directive: "note".to_owned(),
title: None,
additional_classnames: vec!["additional-classname".to_owned()],
collapsible: false,
}
);
}
#[test]
fn test_from_config_string_invalid_title_json() {
// Test invalid JSON title
assert_eq!(
from_config_string(r#"note "\""#).unwrap_err(),
"Error parsing JSON string: EOF while parsing a string at line 1 column 3".to_owned()
);
}
#[test]
fn test_from_config_string_v2_format() {
assert_eq!(
from_config_string(r#"note title="Custom""#).unwrap_err(),
"Invalid configuration string".to_owned()
);
}
}

172
src/config/v2.rs Normal file
View File

@@ -0,0 +1,172 @@
use super::AdmonitionInfoRaw;
use once_cell::sync::Lazy;
use regex::Regex;
use serde::Deserialize;
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
struct AdmonitionInfoConfig {
#[serde(default)]
r#type: Option<String>,
#[serde(default)]
title: Option<String>,
#[serde(default)]
class: Option<String>,
#[serde(default)]
collapsible: bool,
}
/// Transform our config string into valid toml
fn bare_key_value_pairs_to_toml(pairs: &str) -> String {
use regex::Captures;
static RX_BARE_KEY_ASSIGNMENT: Lazy<Regex> = Lazy::new(|| {
let bare_key = r#"[A-Za-z0-9_-]+"#;
Regex::new(&format!("(?:{bare_key}) *=")).expect("bare key assignment regex")
});
fn prefix_with_newline(captures: &Captures) -> String {
format!(
"\n{}",
captures
.get(0)
.expect("capture to have group zero")
.as_str()
)
}
RX_BARE_KEY_ASSIGNMENT
.replace_all(pairs, prefix_with_newline)
.into_owned()
}
/// Parse and return the config assuming v2 format.
///
/// Note that if an error occurs, a parsed struct that can be returned to
/// show the error message will be returned.
pub(crate) fn from_config_string(config_string: &str) -> Result<AdmonitionInfoRaw, String> {
let config_toml = bare_key_value_pairs_to_toml(config_string);
let config_toml = config_toml.trim();
let config: AdmonitionInfoConfig = match toml::from_str(config_toml) {
Ok(config) => config,
Err(error) => {
let original_error = Err(format!("TOML parsing error: {error}"));
// For ergonomic reasons, we allow users to specify the directive without
// a key. So if parsing fails initially, take the first line,
// use that as the directive, and reparse.
let (directive, config_toml) = match config_toml.split_once('\n') {
Some((directive, config_toml)) => (directive.trim(), config_toml),
None => (config_toml, ""),
};
static RX_DIRECTIVE: Lazy<Regex> =
Lazy::new(|| Regex::new(r#"^[A-Za-z0-9_-]+$"#).expect("directive regex"));
if !RX_DIRECTIVE.is_match(directive) {
return original_error;
}
let mut config: AdmonitionInfoConfig = match toml::from_str(config_toml) {
Ok(config) => config,
Err(_) => return original_error,
};
config.r#type = Some(directive.to_owned());
config
}
};
let additional_classnames = config
.class
.map(|class| {
class
.split(' ')
.filter(|classname| !classname.is_empty())
.map(|classname| classname.to_owned())
.collect()
})
.unwrap_or_default();
Ok(AdmonitionInfoRaw {
directive: config.r#type.unwrap_or_default(),
title: config.title,
additional_classnames,
collapsible: config.collapsible,
})
}
#[cfg(test)]
mod test {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn test_from_config_string_v2() {
assert_eq!(
from_config_string("").unwrap(),
AdmonitionInfoRaw {
directive: "".to_owned(),
title: None,
additional_classnames: Vec::new(),
collapsible: false,
}
);
assert_eq!(
from_config_string(" ").unwrap(),
AdmonitionInfoRaw {
directive: "".to_owned(),
title: None,
additional_classnames: Vec::new(),
collapsible: false,
}
);
assert_eq!(
from_config_string(r#"type="note" class="additional classname" title="Никита""#)
.unwrap(),
AdmonitionInfoRaw {
directive: "note".to_owned(),
title: Some("Никита".to_owned()),
additional_classnames: vec!["additional".to_owned(), "classname".to_owned()],
collapsible: false,
}
);
// Specifying unknown keys is okay, as long as they're valid
assert_eq!(
from_config_string(r#"unkonwn="but valid toml""#).unwrap(),
AdmonitionInfoRaw {
directive: "".to_owned(),
title: None,
additional_classnames: Vec::new(),
collapsible: false,
}
);
// Just directive is fine
assert_eq!(
from_config_string(r#"info"#).unwrap(),
AdmonitionInfoRaw {
directive: "info".to_owned(),
title: None,
additional_classnames: Vec::new(),
collapsible: false,
}
);
// Directive plus toml config
assert_eq!(
from_config_string(r#"info title="Information""#).unwrap(),
AdmonitionInfoRaw {
directive: "info".to_owned(),
title: Some("Information".to_owned()),
additional_classnames: Vec::new(),
collapsible: false,
}
);
// Directive after toml config is an error
assert!(from_config_string(r#"title="Information" info"#).is_err());
}
#[test]
fn test_from_config_string_invalid_toml_value() {
assert_eq!(
from_config_string(r#"note titlel=""#).unwrap_err(),
"TOML parsing error: expected an equals, found a newline at line 1 column 6".to_owned()
);
}
}

View File

@@ -8,6 +8,46 @@ use mdbook::{
use pulldown_cmark::{CodeBlockKind::*, Event, Options, Parser, Tag};
use std::{borrow::Cow, str::FromStr};
mod config;
mod types;
use crate::{config::AdmonitionInfo, types::Directive};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum OnFailure {
Bail,
Continue,
}
impl Default for OnFailure {
fn default() -> Self {
Self::Continue
}
}
impl FromStr for OnFailure {
type Err = ();
fn from_str(string: &str) -> Result<Self, ()> {
match string {
"bail" => Ok(Self::Bail),
"continue" => Ok(Self::Continue),
_ => Ok(Self::Continue),
}
}
}
impl OnFailure {
fn from_context(context: &PreprocessorContext) -> Self {
context
.config
.get("preprocessor.admonish.on_failure")
.and_then(|value| value.as_str())
.map(|value| OnFailure::from_str(value).unwrap_or_default())
.unwrap_or_default()
}
}
pub struct Admonish;
impl Preprocessor for Admonish {
@@ -17,6 +57,8 @@ impl Preprocessor for Admonish {
fn run(&self, ctx: &PreprocessorContext, mut book: Book) -> MdbookResult<Book> {
ensure_compatible_assets_version(ctx)?;
let on_failure = OnFailure::from_context(ctx);
let mut res = None;
book.for_each_mut(|item: &mut BookItem| {
if let Some(Err(_)) = res {
@@ -24,7 +66,7 @@ impl Preprocessor for Admonish {
}
if let BookItem::Chapter(ref mut chapter) = *item {
res = Some(preprocess(&chapter.content).map(|md| {
res = Some(preprocess(&chapter.content, on_failure).map(|md| {
chapter.content = md;
}));
}
@@ -76,44 +118,6 @@ fn ensure_compatible_assets_version(ctx: &PreprocessorContext) -> Result<()> {
Ok(())
}
#[derive(Debug, PartialEq)]
enum Directive {
Note,
Abstract,
Info,
Tip,
Success,
Question,
Warning,
Failure,
Danger,
Bug,
Example,
Quote,
}
impl FromStr for Directive {
type Err = ();
fn from_str(string: &str) -> Result<Self, ()> {
match string {
"note" => Ok(Self::Note),
"abstract" | "summary" | "tldr" => Ok(Self::Abstract),
"info" | "todo" => Ok(Self::Info),
"tip" | "hint" | "important" => Ok(Self::Tip),
"success" | "check" | "done" => Ok(Self::Success),
"question" | "help" | "faq" => Ok(Self::Question),
"warning" | "caution" | "attention" => Ok(Self::Warning),
"failure" | "fail" | "missing" => Ok(Self::Failure),
"danger" | "error" => Ok(Self::Danger),
"bug" => Ok(Self::Bug),
"example" => Ok(Self::Example),
"quote" | "cite" => Ok(Self::Quote),
_ => Err(()),
}
}
}
impl Directive {
fn classname(&self) -> &'static str {
match self {
@@ -133,63 +137,29 @@ impl Directive {
}
}
#[derive(Debug, PartialEq)]
struct AdmonitionInfoRaw<'a> {
directive: &'a str,
title: Option<String>,
additional_classnames: Option<Vec<&'a str>>,
}
#[derive(Debug, PartialEq)]
struct AdmonitionInfo<'a> {
directive: Directive,
title: Option<String>,
additional_classnames: Option<Vec<&'a str>>,
}
impl<'a> From<AdmonitionInfoRaw<'a>> for AdmonitionInfo<'a> {
fn from(other: AdmonitionInfoRaw<'a>) -> Self {
let AdmonitionInfoRaw {
directive: raw_directive,
title,
additional_classnames,
} = other;
let (directive, title) = match (Directive::from_str(raw_directive), title) {
(Ok(directive), None) => (directive, ucfirst(raw_directive)),
(Err(_), None) => (Directive::Note, "Note".to_owned()),
(Ok(directive), Some(title)) => (directive, title),
(Err(_), Some(title)) => (Directive::Note, title),
};
// If the user explicitly gave no title, then disable the title bar
let title = if title.is_empty() { None } else { Some(title) };
Self {
directive,
title,
additional_classnames,
}
}
}
#[derive(Debug, PartialEq)]
struct Admonition<'a> {
directive: Directive,
title: Option<String>,
content: &'a str,
additional_classnames: Option<Vec<&'a str>>,
content: Cow<'a, str>,
additional_classnames: Vec<String>,
collapsible: bool,
}
impl<'a> Admonition<'a> {
pub fn new(info: AdmonitionInfo<'a>, content: &'a str) -> Self {
pub fn new(info: AdmonitionInfo, content: &'a str) -> Self {
let AdmonitionInfo {
directive,
title,
additional_classnames,
collapsible,
} = info;
Self {
directive,
title,
content,
content: Cow::Borrowed(content),
additional_classnames,
collapsible,
}
}
@@ -198,25 +168,26 @@ impl<'a> Admonition<'a> {
let title = &self.title;
let content = &self.content;
let title_block = if self.collapsible { "summary" } else { "div" };
let title_html = title
.as_ref()
.map(|title| {
Cow::Owned(format!(
r##"<div class="admonition-title">
<a class="admonition-anchor-link" href="#{ANCHOR_ID_PREFIX}-{anchor_id}">
r##"<{title_block} class="admonition-title">
{title}
</a>
</div>
<a class="admonition-anchor-link" href="#{ANCHOR_ID_PREFIX}-{anchor_id}"></a>
</{title_block}>
"##
))
})
.unwrap_or(Cow::Borrowed(""));
if let Some(additional_classnames) = &self.additional_classnames {
if !self.additional_classnames.is_empty() {
let mut buffer = additional_class.into_owned();
for additional_classname in additional_classnames {
for additional_classname in &self.additional_classnames {
buffer.push(' ');
buffer.push_str(additional_classname);
}
@@ -224,86 +195,26 @@ impl<'a> Admonition<'a> {
additional_class = Cow::Owned(buffer);
}
let admonition_block = if self.collapsible { "details" } else { "div" };
// Notes on the HTML template:
// - the additional whitespace around the content are deliberate
// In line with the commonmark spec, this allows the inner content to be
// rendered as markdown paragraphs.
format!(
r#"<div id="{ANCHOR_ID_PREFIX}-{anchor_id}" class="admonition {additional_class}">
r#"<{admonition_block} id="{ANCHOR_ID_PREFIX}-{anchor_id}" class="admonition {additional_class}">
{title_html}<div>
{content}
</div>
</div>"#,
</{admonition_block}>"#,
)
}
}
const ADMONISH_BLOCK_KEYWORD: &str = "admonish";
const ANCHOR_ID_PREFIX: &str = "admonition";
const ANCHOR_ID_DEFAULT: &str = "default";
/// Returns:
/// - `None` if this is not an `admonish` block.
/// - `Some(AdmonitionInfoRaw)` if this is an `admonish` block
fn parse_info_string(info_string: &str) -> Option<AdmonitionInfoRaw> {
// Get the rest of the info string if this is an admonition
let directive_title = if info_string == ADMONISH_BLOCK_KEYWORD {
""
} else {
match info_string.split_once(' ') {
Some((ADMONISH_BLOCK_KEYWORD, rest)) => rest,
_ => return None,
}
};
// If we're just given the directive, handle that
let (directive, title) = directive_title
.split_once(' ')
.map(|(directive, title)| (directive, Some(title)))
.unwrap_or_else(|| (directive_title, None));
// The title is expected to be a quoted JSON string
// If parsing fails, output the error message as the title for the user to correct
let title = title.map(|title| {
serde_json::from_str::<String>(title)
.unwrap_or_else(|error| format!("Error parsing JSON string: {error}"))
});
// If the directive contains additional classes, parse them out
const CLASSNAME_SEPARATOR: char = '.';
let (directive, additional_classnames) = match directive.split_once(CLASSNAME_SEPARATOR) {
None => (directive, None),
Some((directive, additional_classnames)) => (
directive,
Some(
additional_classnames
.split(CLASSNAME_SEPARATOR)
.filter(|additional_classname| !additional_classname.is_empty())
.collect(),
),
),
};
Some(AdmonitionInfoRaw {
directive,
title,
additional_classnames,
})
}
/// Make the first letter of `input` upppercase.
///
/// source: https://stackoverflow.com/a/38406885
fn ucfirst(input: &str) -> String {
let mut chars = input.chars();
match chars.next() {
None => String::new(),
Some(f) => f.to_uppercase().collect::<String>() + chars.as_str(),
}
}
fn extract_admonish_body(content: &str) -> &str {
const PRE_END: char = '\n';
const POST: &str = "```";
@@ -329,15 +240,49 @@ fn extract_admonish_body(content: &str) -> &str {
/// Given the content in the span of the code block, and the info string,
/// return `Some(Admonition)` if the code block is an admonition.
///
/// If there is an error parsing the admonition, either:
///
/// - Display a UI error message output in the book.
/// - If configured, break the build.
///
/// If the code block is not an admonition, return `None`.
fn parse_admonition<'a>(info_string: &'a str, content: &'a str) -> Option<Admonition<'a>> {
let info = parse_info_string(info_string)?;
let info = AdmonitionInfo::from(info);
fn parse_admonition<'a>(
info_string: &'a str,
content: &'a str,
on_failure: OnFailure,
) -> Option<MdbookResult<Admonition<'a>>> {
let info = AdmonitionInfo::from_info_string(info_string)?;
let info = match info {
Ok(info) => info,
// FIXME return error messages to break build if configured
// Err(message) => return Some(Err(content)),
Err(message) => {
return Some(match on_failure {
OnFailure::Continue => Ok(Admonition {
directive: Directive::Bug,
title: Some("Error rendering admonishment".to_owned()),
additional_classnames: Vec::new(),
collapsible: false,
content: Cow::Owned(format!(
r#"Failed with: {message}
Original markdown input:
``````
{content}
``````
"#
)),
}),
OnFailure::Bail => Err(anyhow!("Error processing admonition, bailing:\n{content}")),
})
}
};
let body = extract_admonish_body(content);
Some(Admonition::new(info, body))
Some(Ok(Admonition::new(info, body)))
}
fn preprocess(content: &str) -> MdbookResult<String> {
fn preprocess(content: &str, on_failure: OnFailure) -> MdbookResult<String> {
let mut id_counter = Default::default();
let mut opts = Options::empty();
opts.insert(Options::ENABLE_TABLES);
@@ -351,10 +296,12 @@ fn preprocess(content: &str) -> MdbookResult<String> {
for (e, span) in events.into_offset_iter() {
if let Event::Start(Tag::CodeBlock(Fenced(info_string))) = e.clone() {
let span_content = &content[span.start..span.end];
let admonition = match parse_admonition(info_string.as_ref(), span_content) {
let admonition = match parse_admonition(info_string.as_ref(), span_content, on_failure)
{
Some(admonition) => admonition,
None => continue,
};
let admonition = admonition?;
let anchor_id = unique_id_from_content(
admonition.title.as_deref().unwrap_or(ANCHOR_ID_DEFAULT),
&mut id_counter,
@@ -378,53 +325,7 @@ mod test {
use pretty_assertions::assert_eq;
fn prep(content: &str) -> String {
preprocess(content).unwrap()
}
#[test]
fn test_parse_info_string() {
assert_eq!(parse_info_string(""), None);
assert_eq!(parse_info_string("adm"), None);
assert_eq!(
parse_info_string("admonish"),
Some(AdmonitionInfoRaw {
directive: "",
title: None,
additional_classnames: None
})
);
assert_eq!(
parse_info_string("admonish "),
Some(AdmonitionInfoRaw {
directive: "",
title: None,
additional_classnames: None
})
);
assert_eq!(
parse_info_string("admonish unknown"),
Some(AdmonitionInfoRaw {
directive: "unknown",
title: None,
additional_classnames: None
})
);
assert_eq!(
parse_info_string("admonish note"),
Some(AdmonitionInfoRaw {
directive: "note",
title: None,
additional_classnames: None
})
);
assert_eq!(
parse_info_string("admonish note.additional-classname"),
Some(AdmonitionInfoRaw {
directive: "note",
title: None,
additional_classnames: Some(vec!["additional-classname"])
})
);
preprocess(content, OnFailure::Continue).unwrap()
}
#[test]
@@ -440,11 +341,10 @@ Text
<div id="admonition-note" class="admonition note">
<div class="admonition-title">
<a class="admonition-anchor-link" href="#admonition-note">
Note
</a>
<a class="admonition-anchor-link" href="#admonition-note"></a>
</div>
<div>
@@ -471,11 +371,10 @@ Text
<div id="admonition-warning" class="admonition warning">
<div class="admonition-title">
<a class="admonition-anchor-link" href="#admonition-warning">
Warning
</a>
<a class="admonition-anchor-link" href="#admonition-warning"></a>
</div>
<div>
@@ -502,11 +401,10 @@ Text
<div id="admonition-read-this" class="admonition warning">
<div class="admonition-title">
<a class="admonition-anchor-link" href="#admonition-read-this">
Read **this**!
</a>
<a class="admonition-anchor-link" href="#admonition-read-this"></a>
</div>
<div>
@@ -597,11 +495,10 @@ hello
<div id="admonition-and-in-the-title" class="admonition note">
<div class="admonition-title">
<a class="admonition-anchor-link" href="#admonition-and-in-the-title">
And "<i>in</i>" the title
</a>
<a class="admonition-anchor-link" href="#admonition-and-in-the-title"></a>
</div>
<div>
@@ -628,11 +525,10 @@ hello
<div id="admonition-trademark" class="admonition warning">
<div class="admonition-title">
<a class="admonition-anchor-link" href="#admonition-trademark">
Trademark™
</a>
<a class="admonition-anchor-link" href="#admonition-trademark"></a>
</div>
<div>
@@ -658,11 +554,10 @@ Will have bonus classnames
<div id="admonition-tip" class="admonition tip my-style other-style">
<div class="admonition-title">
<a class="admonition-anchor-link" href="#admonition-tip">
Tip
</a>
<a class="admonition-anchor-link" href="#admonition-tip"></a>
</div>
<div>
@@ -687,11 +582,10 @@ Will have bonus classnames
<div id="admonition-developers-dont-want-you-to-know-this-one-weird-tip" class="admonition tip my-style other-style">
<div class="admonition-title">
<a class="admonition-anchor-link" href="#admonition-developers-dont-want-you-to-know-this-one-weird-tip">
Developers don't want you to know this one weird tip!
</a>
<a class="admonition-anchor-link" href="#admonition-developers-dont-want-you-to-know-this-one-weird-tip"></a>
</div>
<div>
@@ -741,11 +635,10 @@ Content one.
<div id="admonition-my-note" class="admonition note">
<div class="admonition-title">
<a class="admonition-anchor-link" href="#admonition-my-note">
My Note
</a>
<a class="admonition-anchor-link" href="#admonition-my-note"></a>
</div>
<div>
@@ -757,11 +650,10 @@ Content zero.
<div id="admonition-my-note-1" class="admonition note">
<div class="admonition-title">
<a class="admonition-anchor-link" href="#admonition-my-note-1">
My Note
</a>
<a class="admonition-anchor-link" href="#admonition-my-note-1"></a>
</div>
<div>
@@ -769,6 +661,119 @@ Content one.
</div>
</div>
"##;
assert_eq!(expected, prep(content));
}
#[test]
fn v2_config_works() {
let content = r#"
```admonish tip class="my other-style" title="Article Heading"
Bonus content!
```
"#;
let expected = r##"
<div id="admonition-article-heading" class="admonition tip my other-style">
<div class="admonition-title">
Article Heading
<a class="admonition-anchor-link" href="#admonition-article-heading"></a>
</div>
<div>
Bonus content!
</div>
</div>
"##;
assert_eq!(expected, prep(content));
}
#[test]
fn continue_on_error_output() {
let content = r#"
```admonish title="
Bonus content!
```
"#;
let expected = r##"
<div id="admonition-error-rendering-admonishment" class="admonition bug">
<div class="admonition-title">
Error rendering admonishment
<a class="admonition-anchor-link" href="#admonition-error-rendering-admonishment"></a>
</div>
<div>
Failed with: TOML parsing error: unterminated string at line 1 column 7
Original markdown input:
``````
```admonish title="
Bonus content!
```
``````
</div>
</div>
"##;
assert_eq!(expected, prep(content));
}
#[test]
fn bail_on_error_output() {
let content = r#"
```admonish title="
Bonus content!
```
"#;
assert_eq!(
preprocess(content, OnFailure::Bail)
.unwrap_err()
.to_string(),
r#"Error processing admonition, bailing:
```admonish title="
Bonus content!
```"#
.to_owned()
)
}
#[test]
fn block_collapsible() {
let content = r#"
```admonish collapsible=true
Hidden
```
"#;
let expected = r##"
<details id="admonition-note" class="admonition note">
<summary class="admonition-title">
Note
<a class="admonition-anchor-link" href="#admonition-note"></a>
</summary>
<div>
Hidden
</div>
</details>
"##;
assert_eq!(expected, prep(content));

39
src/types.rs Normal file
View File

@@ -0,0 +1,39 @@
use std::str::FromStr;
#[derive(Debug, PartialEq)]
pub(crate) enum Directive {
Note,
Abstract,
Info,
Tip,
Success,
Question,
Warning,
Failure,
Danger,
Bug,
Example,
Quote,
}
impl FromStr for Directive {
type Err = ();
fn from_str(string: &str) -> Result<Self, ()> {
match string {
"note" => Ok(Self::Note),
"abstract" | "summary" | "tldr" => Ok(Self::Abstract),
"info" | "todo" => Ok(Self::Info),
"tip" | "hint" | "important" => Ok(Self::Tip),
"success" | "check" | "done" => Ok(Self::Success),
"question" | "help" | "faq" => Ok(Self::Question),
"warning" | "caution" | "attention" => Ok(Self::Warning),
"failure" | "fail" | "missing" => Ok(Self::Failure),
"danger" | "error" => Ok(Self::Danger),
"bug" => Ok(Self::Bug),
"example" => Ok(Self::Example),
"quote" | "cite" => Ok(Self::Quote),
_ => Err(()),
}
}
}