markdown_it_ruby 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +171 -0
- data/CODE_OF_CONDUCT.md +84 -0
- data/Cargo.lock +1255 -0
- data/Cargo.toml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +37 -0
- data/Rakefile +22 -0
- data/ext/markdown_it_ruby/Cargo.toml +18 -0
- data/ext/markdown_it_ruby/extconf.rb +6 -0
- data/ext/markdown_it_ruby/src/driver/options.rs +298 -0
- data/ext/markdown_it_ruby/src/driver.rs +56 -0
- data/ext/markdown_it_ruby/src/extensions/heading_level_modification.rs +139 -0
- data/ext/markdown_it_ruby/src/extensions/link_with_target.rs +473 -0
- data/ext/markdown_it_ruby/src/extensions/table_decoration.rs +88 -0
- data/ext/markdown_it_ruby/src/extensions.rs +18 -0
- data/ext/markdown_it_ruby/src/lib.rs +35 -0
- data/lib/markdown_it_ruby/version.rb +5 -0
- data/lib/markdown_it_ruby.rb +15 -0
- data/sig/markdown_it_ruby.rbs +4 -0
- metadata +78 -0
data/Cargo.toml
ADDED
data/LICENSE.txt
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2024 Koji Onishi
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
13
|
+
all copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,37 @@
|
|
1
|
+
# MarkdownItRubyfiedSample
|
2
|
+
|
3
|
+
The gem is built based on [markdown-it-rust](https://github.com/markdown-it-rust/markdown-it), which provides a super powerful parser mechanisms with highly-customizeable modular design. (I recommend you to check markdown-it-rust github page if you are interested!)
|
4
|
+
|
5
|
+
On top of default parser plugins, this gem adds a few utility add-ons that can be activated via options
|
6
|
+
Packaged as Ruby gem (NOTE: it's still experimental)
|
7
|
+
|
8
|
+
## Installation
|
9
|
+
|
10
|
+
Just clone the repository and try `bundle exec rake compile` to compile the executable.
|
11
|
+
(bundler >= 2.4, RubyGems 3.4.6 required)
|
12
|
+
|
13
|
+
## Usage
|
14
|
+
|
15
|
+
* using with console
|
16
|
+
```
|
17
|
+
$ bin/console
|
18
|
+
>
|
19
|
+
```
|
20
|
+
|
21
|
+
* benchmark
|
22
|
+
```
|
23
|
+
$ cd bench
|
24
|
+
$ ruby bench.rb
|
25
|
+
```
|
26
|
+
|
27
|
+
## Contributing
|
28
|
+
|
29
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/markdown_it_ruby. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/[USERNAME]/markdown_it_ruby/blob/main/CODE_OF_CONDUCT.md).
|
30
|
+
|
31
|
+
## License
|
32
|
+
|
33
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
34
|
+
|
35
|
+
## Code of Conduct
|
36
|
+
|
37
|
+
Everyone interacting in the MarkdownItRubyfiedSample project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/[USERNAME]/markdown_it_ruby/blob/main/CODE_OF_CONDUCT.md).
|
data/Rakefile
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "bundler/gem_tasks"
|
4
|
+
require "rspec/core/rake_task"
|
5
|
+
|
6
|
+
RSpec::Core::RakeTask.new(:spec)
|
7
|
+
|
8
|
+
require "rubocop/rake_task"
|
9
|
+
|
10
|
+
RuboCop::RakeTask.new
|
11
|
+
|
12
|
+
require "rb_sys/extensiontask"
|
13
|
+
|
14
|
+
task build: :compile
|
15
|
+
|
16
|
+
GEMSPEC = Gem::Specification.load("markdown_it_ruby.gemspec")
|
17
|
+
|
18
|
+
RbSys::ExtensionTask.new("markdown_it_ruby", GEMSPEC) do |ext|
|
19
|
+
ext.lib_dir = "lib/markdown_it_ruby"
|
20
|
+
end
|
21
|
+
|
22
|
+
task default: %i[compile spec rubocop]
|
@@ -0,0 +1,18 @@
|
|
1
|
+
[package]
|
2
|
+
name = "markdown_it_ruby"
|
3
|
+
version = "0.1.1"
|
4
|
+
edition = "2021"
|
5
|
+
authors = ["Koji Onishi <fursich0@gmail.com>"]
|
6
|
+
license = "MIT"
|
7
|
+
publish = false
|
8
|
+
|
9
|
+
[lib]
|
10
|
+
crate-type = ["cdylib"]
|
11
|
+
doc = false
|
12
|
+
|
13
|
+
[dependencies]
|
14
|
+
magnus = { version = "0.7.1" }
|
15
|
+
markdown-it = ">= 0.6"
|
16
|
+
url = ">= 2.5"
|
17
|
+
regex = ">= 1.10"
|
18
|
+
uuid = { version = ">= 1.8", features = ["v4"] }
|
@@ -0,0 +1,298 @@
|
|
1
|
+
use markdown_it::parser::extset::MarkdownItExt;
|
2
|
+
use markdown_it::MarkdownIt;
|
3
|
+
use std::collections::HashMap;
|
4
|
+
use url::Url;
|
5
|
+
|
6
|
+
#[derive(Debug)]
|
7
|
+
pub struct MarkdonwItOptions {
|
8
|
+
options: HashMap<String, String>,
|
9
|
+
}
|
10
|
+
|
11
|
+
#[derive(Debug, Clone)]
|
12
|
+
pub struct InternalDomain {
|
13
|
+
base_url: Url,
|
14
|
+
}
|
15
|
+
|
16
|
+
impl MarkdownItExt for MarkdonwItOptions {}
|
17
|
+
impl Clone for MarkdonwItOptions {
|
18
|
+
fn clone(&self) -> Self {
|
19
|
+
Self {
|
20
|
+
options: self.options.clone(),
|
21
|
+
}
|
22
|
+
}
|
23
|
+
}
|
24
|
+
impl Default for MarkdonwItOptions {
|
25
|
+
fn default() -> Self {
|
26
|
+
Self {
|
27
|
+
options: HashMap::new(),
|
28
|
+
}
|
29
|
+
}
|
30
|
+
}
|
31
|
+
|
32
|
+
impl MarkdonwItOptions {
|
33
|
+
pub fn new(options: HashMap<String, String>) -> Self {
|
34
|
+
Self { options }
|
35
|
+
}
|
36
|
+
|
37
|
+
pub fn add(self, md: &mut MarkdownIt) {
|
38
|
+
md.ext.insert::<Self>(self);
|
39
|
+
}
|
40
|
+
|
41
|
+
pub fn is_enabled(&self, key: &str, default: bool) -> bool {
|
42
|
+
match self.get_option_or_default(key, "_NOT_APPLICABLE").as_str() {
|
43
|
+
"true" => true,
|
44
|
+
"false" => false,
|
45
|
+
_ => default,
|
46
|
+
}
|
47
|
+
}
|
48
|
+
|
49
|
+
pub fn get_option(&self, key: &str) -> Option<&String> {
|
50
|
+
self.options.get(key)
|
51
|
+
}
|
52
|
+
|
53
|
+
pub fn get_option_or_default(&self, key: &str, default: &str) -> String {
|
54
|
+
self.options
|
55
|
+
.get(key)
|
56
|
+
.cloned()
|
57
|
+
.unwrap_or_else(|| default.to_string())
|
58
|
+
}
|
59
|
+
|
60
|
+
pub fn internal_domain(&self) -> Option<InternalDomain> {
|
61
|
+
let domain_name = self.get_option("internal_domain_name");
|
62
|
+
match domain_name {
|
63
|
+
None => None,
|
64
|
+
Some(domain_name) => InternalDomain::new(domain_name.clone()),
|
65
|
+
}
|
66
|
+
}
|
67
|
+
}
|
68
|
+
|
69
|
+
impl InternalDomain {
|
70
|
+
fn new(domain_name: String) -> Option<Self> {
|
71
|
+
if let Ok(base_url) = Url::parse(domain_name.as_str()) {
|
72
|
+
if base_url.domain().is_some() {
|
73
|
+
return Some(InternalDomain { base_url });
|
74
|
+
}
|
75
|
+
}
|
76
|
+
|
77
|
+
let domain_with_scheme = format!("https://{}", domain_name);
|
78
|
+
if let Ok(base_url) = Url::parse(&domain_with_scheme) {
|
79
|
+
if base_url.domain().is_some() {
|
80
|
+
return Some(InternalDomain { base_url });
|
81
|
+
}
|
82
|
+
}
|
83
|
+
|
84
|
+
None
|
85
|
+
}
|
86
|
+
|
87
|
+
pub fn matches(&self, url: &str) -> bool {
|
88
|
+
// let url_parser = Url::options().base_url(Some(&self.base_url));
|
89
|
+
let internal_domain_name = self.base_url.domain().unwrap();
|
90
|
+
|
91
|
+
// url with scheme
|
92
|
+
if Self::check_scheme(url) {
|
93
|
+
return self.check_internal_domain(url, internal_domain_name, false);
|
94
|
+
}
|
95
|
+
|
96
|
+
// 一部の「schemeなし、パス付きURL表記」をヒューリスティックに判定できるが
|
97
|
+
// 仕様が複雑になるためいったんやらない
|
98
|
+
// (google.com/foo は外部サイトとして判定しうるが、google.com は foo.pdf
|
99
|
+
// と判別がつかないため内部リンク扱いとなり、ルールがわかりにくくなるため)
|
100
|
+
// // possiblly a domain name
|
101
|
+
// // heuristics: if the url contains a dot, it's a relative path with domain
|
102
|
+
// // we do not regard them as internal links
|
103
|
+
// // e.g. google.com/foo/bar
|
104
|
+
// let domain_regex = regex!(r"^[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)+/");
|
105
|
+
// if domain_regex.is_match(url) {
|
106
|
+
// println!(
|
107
|
+
// "check #2 - url: {}, internal_domain_name: {}",
|
108
|
+
// url, internal_domain_name
|
109
|
+
// );
|
110
|
+
// return false;
|
111
|
+
// }
|
112
|
+
|
113
|
+
// should be a relative path
|
114
|
+
// NOTE: google.com(外部サイト), google.pdf(ファイル名)は区別がつかないため全て内部リンク扱いとする
|
115
|
+
// 外部サイトの場合はスキームをつけること
|
116
|
+
self.check_internal_domain(&url, internal_domain_name, true)
|
117
|
+
}
|
118
|
+
|
119
|
+
fn check_scheme(url: &str) -> bool {
|
120
|
+
if let Ok(url) = Url::parse(url) {
|
121
|
+
if !url.scheme().is_empty() {
|
122
|
+
return true;
|
123
|
+
}
|
124
|
+
}
|
125
|
+
false
|
126
|
+
}
|
127
|
+
|
128
|
+
fn check_internal_domain(
|
129
|
+
&self,
|
130
|
+
url: &str,
|
131
|
+
internal_domain_name: &str,
|
132
|
+
is_base_url_required: bool,
|
133
|
+
) -> bool {
|
134
|
+
let url = if is_base_url_required {
|
135
|
+
Url::options().base_url(Some(&self.base_url)).parse(url)
|
136
|
+
} else {
|
137
|
+
Url::parse(url)
|
138
|
+
};
|
139
|
+
if let Ok(url) = url {
|
140
|
+
if let Some(domain) = url.domain() {
|
141
|
+
// for `example.com`, `foo.example.com` is also considered as an internal link
|
142
|
+
if domain == internal_domain_name
|
143
|
+
|| domain.ends_with(format!(".{}", internal_domain_name).as_str())
|
144
|
+
{
|
145
|
+
return true;
|
146
|
+
}
|
147
|
+
}
|
148
|
+
}
|
149
|
+
false
|
150
|
+
}
|
151
|
+
}
|
152
|
+
|
153
|
+
#[test]
|
154
|
+
fn test_get_option() {
|
155
|
+
let raw_options = HashMap::from([
|
156
|
+
("some_random_option".to_string(), "true".to_string()),
|
157
|
+
("another_option".to_string(), "some-string".to_string()),
|
158
|
+
]);
|
159
|
+
let options = MarkdonwItOptions::new(raw_options);
|
160
|
+
|
161
|
+
assert_eq!(
|
162
|
+
options.get_option("some_random_option"),
|
163
|
+
Some(&"true".to_string())
|
164
|
+
);
|
165
|
+
assert_eq!(
|
166
|
+
options.get_option("another_option"),
|
167
|
+
Some(&"some-string".to_string())
|
168
|
+
);
|
169
|
+
}
|
170
|
+
|
171
|
+
#[test]
|
172
|
+
fn test_get_option_or_default() {
|
173
|
+
let raw_options = HashMap::from([("some_random_option".to_string(), "true".to_string())]);
|
174
|
+
let options = MarkdonwItOptions::new(raw_options);
|
175
|
+
|
176
|
+
assert_eq!(
|
177
|
+
options.get_option_or_default("some_random_option", "false"),
|
178
|
+
"true".to_string()
|
179
|
+
);
|
180
|
+
assert_eq!(
|
181
|
+
options.get_option_or_default("another_option", "default value"),
|
182
|
+
"default value".to_string()
|
183
|
+
);
|
184
|
+
}
|
185
|
+
|
186
|
+
#[test]
|
187
|
+
fn test_is_enabled() {
|
188
|
+
{
|
189
|
+
// with values set to "true"
|
190
|
+
let raw_options = HashMap::from([("some_option".to_string(), "true".to_string())]);
|
191
|
+
let options = MarkdonwItOptions::new(raw_options);
|
192
|
+
assert_eq!(options.is_enabled("some_option", false), true);
|
193
|
+
assert_eq!(options.is_enabled("some_option", true), true);
|
194
|
+
}
|
195
|
+
|
196
|
+
{
|
197
|
+
// with values set to "false"
|
198
|
+
let raw_options = HashMap::from([("some_option".to_string(), "false".to_string())]);
|
199
|
+
let options = MarkdonwItOptions::new(raw_options);
|
200
|
+
assert_eq!(options.is_enabled("some_option", false), false);
|
201
|
+
assert_eq!(options.is_enabled("some_option", true), false);
|
202
|
+
}
|
203
|
+
|
204
|
+
{
|
205
|
+
// with values set to other than "true" or "false"
|
206
|
+
let raw_options =
|
207
|
+
HashMap::from([("some_option".to_string(), "some_random-value".to_string())]);
|
208
|
+
let options = MarkdonwItOptions::new(raw_options);
|
209
|
+
assert_eq!(options.is_enabled("some_option", false), false);
|
210
|
+
assert_eq!(options.is_enabled("some_option", true), true);
|
211
|
+
}
|
212
|
+
|
213
|
+
{
|
214
|
+
// with no value set
|
215
|
+
let options = MarkdonwItOptions::new(HashMap::new());
|
216
|
+
assert_eq!(options.is_enabled("some_option", false), false);
|
217
|
+
assert_eq!(options.is_enabled("some_option", true), true);
|
218
|
+
}
|
219
|
+
}
|
220
|
+
|
221
|
+
#[test]
|
222
|
+
fn test_internal_domain_matches() {
|
223
|
+
{
|
224
|
+
// with base_url that has scheme part
|
225
|
+
let base_url = "https://shizuoka.jp".to_string();
|
226
|
+
let internal_domain = InternalDomain::new(base_url).unwrap();
|
227
|
+
|
228
|
+
{
|
229
|
+
// when the url shares the same domain
|
230
|
+
assert_eq!(internal_domain.matches("https://shizuoka.jp"), true);
|
231
|
+
assert_eq!(internal_domain.matches("https://fuji.shizuoka.jp"), true);
|
232
|
+
assert_eq!(internal_domain.matches("http://fuji.shizuoka.jp"), true);
|
233
|
+
assert_eq!(
|
234
|
+
internal_domain.matches("https://www.city.fuji.shizuoka.jp"),
|
235
|
+
true
|
236
|
+
);
|
237
|
+
}
|
238
|
+
|
239
|
+
{
|
240
|
+
// when the url does not share the same domain
|
241
|
+
assert_eq!(internal_domain.matches("https://fuji.shizu-oka.jp"), false);
|
242
|
+
}
|
243
|
+
|
244
|
+
{
|
245
|
+
// when the url does not have a scheme part
|
246
|
+
assert_eq!(internal_domain.matches("fuji.shizuoka.jp"), true);
|
247
|
+
assert_eq!(internal_domain.matches("www.city.fuji.shizuoka.jp"), true);
|
248
|
+
assert_eq!(
|
249
|
+
internal_domain.matches("totally-different-domain.com"),
|
250
|
+
true
|
251
|
+
);
|
252
|
+
}
|
253
|
+
|
254
|
+
{
|
255
|
+
//with relative path
|
256
|
+
assert_eq!(internal_domain.matches("/foo/bar"), true);
|
257
|
+
assert_eq!(internal_domain.matches("/foo/bar/baz.jpg"), true);
|
258
|
+
}
|
259
|
+
}
|
260
|
+
|
261
|
+
{
|
262
|
+
// with base_url that does NOT have scheme part
|
263
|
+
let base_url = "shizuoka.jp".to_string();
|
264
|
+
let internal_domain = InternalDomain::new(base_url).unwrap();
|
265
|
+
|
266
|
+
{
|
267
|
+
// when the url shares the same domain
|
268
|
+
assert_eq!(internal_domain.matches("https://shizuoka.jp"), true);
|
269
|
+
assert_eq!(internal_domain.matches("https://fuji.shizuoka.jp"), true);
|
270
|
+
assert_eq!(internal_domain.matches("http://fuji.shizuoka.jp"), true);
|
271
|
+
assert_eq!(
|
272
|
+
internal_domain.matches("https://www.city.fuji.shizuoka.jp"),
|
273
|
+
true
|
274
|
+
);
|
275
|
+
}
|
276
|
+
|
277
|
+
{
|
278
|
+
// when the url does not share the same domain
|
279
|
+
assert_eq!(internal_domain.matches("https://fuji.shizu-oka.jp"), false);
|
280
|
+
}
|
281
|
+
|
282
|
+
{
|
283
|
+
// when the url does not have a scheme part
|
284
|
+
assert_eq!(internal_domain.matches("fuji.shizuoka.jp"), true);
|
285
|
+
assert_eq!(internal_domain.matches("www.city.fuji.shizuoka.jp"), true);
|
286
|
+
assert_eq!(
|
287
|
+
internal_domain.matches("totally-different-domain.com"),
|
288
|
+
true
|
289
|
+
);
|
290
|
+
}
|
291
|
+
|
292
|
+
{
|
293
|
+
//with relative path
|
294
|
+
assert_eq!(internal_domain.matches("/foo/bar"), true);
|
295
|
+
assert_eq!(internal_domain.matches("/foo/bar/baz.jpg"), true);
|
296
|
+
}
|
297
|
+
}
|
298
|
+
}
|
@@ -0,0 +1,56 @@
|
|
1
|
+
mod options;
|
2
|
+
|
3
|
+
use crate::extensions;
|
4
|
+
use markdown_it::plugins::{cmark, extra, html};
|
5
|
+
use markdown_it::{MarkdownIt, Node};
|
6
|
+
pub use options::{InternalDomain, MarkdonwItOptions};
|
7
|
+
use std::collections::HashMap;
|
8
|
+
use std::sync::OnceLock;
|
9
|
+
|
10
|
+
pub(super) struct MarkdownDriver {
|
11
|
+
md: MarkdownIt,
|
12
|
+
source: OnceLock<String>,
|
13
|
+
contents: OnceLock<Node>,
|
14
|
+
}
|
15
|
+
|
16
|
+
impl MarkdownDriver {
|
17
|
+
pub(super) fn new(env: HashMap<String, String>) -> Self {
|
18
|
+
// create markdown parser
|
19
|
+
let mut md = MarkdownIt::new();
|
20
|
+
let option = MarkdonwItOptions::new(env.clone());
|
21
|
+
Self::prepare(&mut md, option);
|
22
|
+
|
23
|
+
Self {
|
24
|
+
md,
|
25
|
+
source: OnceLock::new(),
|
26
|
+
contents: OnceLock::new(),
|
27
|
+
}
|
28
|
+
}
|
29
|
+
|
30
|
+
pub(super) fn parse(&self, contents: String) {
|
31
|
+
if self.source.set(contents.clone()).is_err() {
|
32
|
+
return;
|
33
|
+
}
|
34
|
+
|
35
|
+
let root = self.md.parse(contents.as_str());
|
36
|
+
self.contents.set(root).unwrap();
|
37
|
+
}
|
38
|
+
|
39
|
+
pub(super) fn render(&self) -> String {
|
40
|
+
let contents = self.contents.get();
|
41
|
+
match contents {
|
42
|
+
None => String::new(),
|
43
|
+
Some(contents) => contents.render(),
|
44
|
+
}
|
45
|
+
}
|
46
|
+
|
47
|
+
fn prepare(md: &mut MarkdownIt, option: MarkdonwItOptions) {
|
48
|
+
html::add(md);
|
49
|
+
cmark::add(md);
|
50
|
+
extra::add(md);
|
51
|
+
|
52
|
+
// add custom three rules described above
|
53
|
+
extensions::add(md, &option);
|
54
|
+
option.add(md);
|
55
|
+
}
|
56
|
+
}
|
@@ -0,0 +1,139 @@
|
|
1
|
+
use crate::driver::MarkdonwItOptions;
|
2
|
+
use markdown_it::parser::core::CoreRule;
|
3
|
+
use markdown_it::plugins::cmark::block::heading::ATXHeading;
|
4
|
+
use markdown_it::{MarkdownIt, Node, NodeValue, Renderer};
|
5
|
+
|
6
|
+
#[derive(Debug)]
|
7
|
+
struct PlainTextElement {
|
8
|
+
text: String,
|
9
|
+
}
|
10
|
+
|
11
|
+
impl NodeValue for PlainTextElement {
|
12
|
+
fn render(&self, node: &Node, fmt: &mut dyn Renderer) {
|
13
|
+
fmt.open("p", &[]);
|
14
|
+
fmt.text(&self.text);
|
15
|
+
fmt.contents(&node.children);
|
16
|
+
fmt.close("p");
|
17
|
+
fmt.cr();
|
18
|
+
}
|
19
|
+
}
|
20
|
+
|
21
|
+
struct HeadingLevelModificationRule;
|
22
|
+
|
23
|
+
impl CoreRule for HeadingLevelModificationRule {
|
24
|
+
fn run(root: &mut Node, md: &MarkdownIt) {
|
25
|
+
let options = md.ext.get::<MarkdonwItOptions>();
|
26
|
+
let heading_level_offset = match options {
|
27
|
+
Some(options) => options
|
28
|
+
.get_option_or_default("heading_level_offset", "0")
|
29
|
+
.parse::<u8>()
|
30
|
+
.unwrap_or(0),
|
31
|
+
None => 0,
|
32
|
+
};
|
33
|
+
// walk through AST recursively and count the number of two
|
34
|
+
// custom nodes added by other two rules
|
35
|
+
root.walk_mut(|node, _| {
|
36
|
+
if let Some(heading) = node.cast::<ATXHeading>() {
|
37
|
+
// do not render level 1 and level > 4 headings (# and ####+)
|
38
|
+
// illegal `headings` has to be treated as plain text
|
39
|
+
let new_heading_level = heading.level + heading_level_offset;
|
40
|
+
// if the new heading level is out of the accepted range (1-6), treat it as plain text
|
41
|
+
if new_heading_level < 1 || new_heading_level > 6 {
|
42
|
+
node.replace(PlainTextElement {
|
43
|
+
text: "#".repeat(heading.level as usize) + " ",
|
44
|
+
});
|
45
|
+
} else {
|
46
|
+
node.replace(ATXHeading {
|
47
|
+
level: new_heading_level,
|
48
|
+
});
|
49
|
+
}
|
50
|
+
}
|
51
|
+
});
|
52
|
+
}
|
53
|
+
}
|
54
|
+
|
55
|
+
pub fn add(md: &mut MarkdownIt) {
|
56
|
+
md.add_rule::<HeadingLevelModificationRule>();
|
57
|
+
}
|
58
|
+
|
59
|
+
#[test]
|
60
|
+
fn test_heading_modification() {
|
61
|
+
use std::collections::HashMap;
|
62
|
+
|
63
|
+
let mut md = MarkdownIt::new();
|
64
|
+
markdown_it::plugins::cmark::add(&mut md);
|
65
|
+
add(&mut md);
|
66
|
+
|
67
|
+
{
|
68
|
+
// without options
|
69
|
+
{
|
70
|
+
// for heading levels out of the accepted range (h7)
|
71
|
+
let src = "####### heading 7";
|
72
|
+
let html = md.parse(src).render();
|
73
|
+
assert_eq!(html, "<p>####### heading 7</p>\n");
|
74
|
+
}
|
75
|
+
|
76
|
+
{
|
77
|
+
// for heading levels within the accepted range(h1-h6)
|
78
|
+
let src = "# heading 1";
|
79
|
+
let html = md.parse(src).render();
|
80
|
+
assert_eq!(html, "<h1>heading 1</h1>\n");
|
81
|
+
|
82
|
+
let src = "### heading 3";
|
83
|
+
let html = md.parse(src).render();
|
84
|
+
assert_eq!(html, "<h3>heading 3</h3>\n");
|
85
|
+
|
86
|
+
let src = "###### heading 6";
|
87
|
+
let html = md.parse(src).render();
|
88
|
+
assert_eq!(html, "<h6>heading 6</h6>\n");
|
89
|
+
}
|
90
|
+
|
91
|
+
let src =
|
92
|
+
"# heading 1\n## heading 2\n### heading 3\n#### heading 4\n##### heading 5\n###### heading 6\n####### heading 7";
|
93
|
+
let html = md.parse(src).render();
|
94
|
+
|
95
|
+
assert_eq!(
|
96
|
+
html,
|
97
|
+
"<h1>heading 1</h1>\n<h2>heading 2</h2>\n<h3>heading 3</h3>\n<h4>heading 4</h4>\n<h5>heading 5</h5>\n<h6>heading 6</h6>\n<p>####### heading 7</p>\n"
|
98
|
+
);
|
99
|
+
}
|
100
|
+
|
101
|
+
{
|
102
|
+
// with options
|
103
|
+
let options = MarkdonwItOptions::new(HashMap::from([(
|
104
|
+
"heading_level_offset".to_string(),
|
105
|
+
"3".to_string(),
|
106
|
+
)]));
|
107
|
+
options.add(&mut md);
|
108
|
+
{
|
109
|
+
// for heading levels out of the accepted range (h4 - shifted to h7)
|
110
|
+
let src = "#### heading 4";
|
111
|
+
let html = md.parse(src).render();
|
112
|
+
assert_eq!(html, "<p>#### heading 4</p>\n");
|
113
|
+
}
|
114
|
+
|
115
|
+
{
|
116
|
+
// for heading levels within the accepted range(h1-h3)
|
117
|
+
let src = "# heading 1";
|
118
|
+
let html = md.parse(src).render();
|
119
|
+
assert_eq!(html, "<h4>heading 1</h4>\n");
|
120
|
+
|
121
|
+
let src = "## heading 2";
|
122
|
+
let html = md.parse(src).render();
|
123
|
+
assert_eq!(html, "<h5>heading 2</h5>\n");
|
124
|
+
|
125
|
+
let src = "### heading 3";
|
126
|
+
let html = md.parse(src).render();
|
127
|
+
assert_eq!(html, "<h6>heading 3</h6>\n");
|
128
|
+
}
|
129
|
+
|
130
|
+
let src =
|
131
|
+
"# heading 1\n## heading 2\n### heading 3\n#### heading 4\n##### heading 5\n###### heading 6\n####### heading 7";
|
132
|
+
let html = md.parse(src).render();
|
133
|
+
|
134
|
+
assert_eq!(
|
135
|
+
html,
|
136
|
+
"<h4>heading 1</h4>\n<h5>heading 2</h5>\n<h6>heading 3</h6>\n<p>#### heading 4</p>\n<p>##### heading 5</p>\n<p>###### heading 6</p>\n<p>####### heading 7</p>\n"
|
137
|
+
);
|
138
|
+
}
|
139
|
+
}
|