gitlab-glfm-markdown 0.0.38 → 0.0.40

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1912c815b560bc967a5627e3d676a697a7bf3eec232168c6a8ba9cdc554eb300
4
- data.tar.gz: 3b36fb9b1f40453638d3ce053354dd9b047103d4c1d2e7be91f3e47a5961702e
3
+ metadata.gz: 958f895a64867a4cf0facd03a03f363398263613493019627df9ed9481dc0d74
4
+ data.tar.gz: c180c4b058f5c823b2dc00ad32bf63de261a070cba034326d0f29e1131ea6f53
5
5
  SHA512:
6
- metadata.gz: 741e900eb21ba3f4035c4670febadfb7311b443f20b1cc1ccc6f5782b57158eb4a6fc623525a91c727cf4eafb0ac0ec004388efc4896ee6765c1591693dbb05b
7
- data.tar.gz: '07567018fcd25a9448575f6cd2e164a9b4df7e0742c10e3d5c897d012765dfdf80ef5aed7a6b27b87f38f749ab94d2ce547e7345cd57381d1d59ae3dfc156560'
6
+ metadata.gz: f1887cab2213baf059cf86655333308a4cd021bed71438a16c32f6a234dde93c29cc0b87522bc6057d6f87acf4150634c712f98ce273032845a90899c6a4806f
7
+ data.tar.gz: e7c2789aa86e38930c55d9d31520d947bee79cb093338213259d0820b8015abe1d3419ba1ce282af35495ca73fa66846745f983fec574921d97b301f725d9b7a
data/Cargo.lock CHANGED
@@ -1,6 +1,6 @@
1
1
  # This file is automatically @generated by Cargo.
2
2
  # It is not intended for manual editing.
3
- version = 3
3
+ version = 4
4
4
 
5
5
  [[package]]
6
6
  name = "adler2"
@@ -221,9 +221,9 @@ checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
221
221
 
222
222
  [[package]]
223
223
  name = "comrak"
224
- version = "0.46.0"
224
+ version = "0.49.0"
225
225
  source = "registry+https://github.com/rust-lang/crates.io-index"
226
- checksum = "dc151d9c800c4fadd542c949b84c1dae6d1b424315ebcfd154e611d4f7910a97"
226
+ checksum = "ab87129dce2f2d7e75e753b1df0e5093b27dec8fa5970b6eb51280faacb25bd6"
227
227
  dependencies = [
228
228
  "caseless",
229
229
  "emojis",
@@ -306,8 +306,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
306
306
  checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
307
307
 
308
308
  [[package]]
309
- name = "glfm_markdown"
310
- version = "0.0.38"
309
+ name = "gitlab-glfm-markdown"
310
+ version = "0.0.40"
311
311
  dependencies = [
312
312
  "clap",
313
313
  "comrak",
data/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
  [![Latest Release](https://gitlab.com/gitlab-org/ruby/gems/gitlab-glfm-markdown/-/badges/release.svg)](https://gitlab.com/gitlab-org/ruby/gems/gitlab-glfm-markdown/-/releases)
5
5
 
6
6
  Implements GLFM (as used by GitLab) using the Rust-based Markdown parser
7
- [Comrak](https://github.com/kivikakk/comrak) (0.46.0), providing a Ruby interface.
7
+ [Comrak](https://github.com/kivikakk/comrak) (0.49.0), providing a Ruby interface.
8
8
 
9
9
  This project is still in constant flux, so interfaces and functionality can change at any time.
10
10
 
@@ -26,7 +26,7 @@ Try on command line:
26
26
  rake compile
27
27
  bin/console
28
28
 
29
- require 'glfm_markdown'
29
+ require 'gitlab-glfm-markdown'
30
30
 
31
31
  GLFMMarkdown.to_html('# header', options: { sourcepos: true })
32
32
  ```
@@ -37,9 +37,10 @@ GLFMMarkdown.to_html('# header', options: { sourcepos: true })
37
37
  |-------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
38
38
  | `autolink` | Enable the `autolink` extension |
39
39
  | `cjk_friendly_emphasis` | Enable the [`cjk_friendly_emphasis` extension](https://github.com/tats-u/markdown-cjk-friendly) |
40
+ | `default_html` | Disables any custom HTML, and returns default HTML from `comrak` |
40
41
  | `description_lists` | Enable the `description-lists` extension |
41
- | `escape` | Escape raw HTML instead of clobbering it |
42
42
  | `escape_char_spans` | Wrap escaped characters in a `<span>` to allow any post-processing to recognize them |
43
+ | `escape` | Escape raw HTML instead of clobbering it |
43
44
  | `figure_with_caption` | Render the image as a figure element with the title as its caption |
44
45
  | `footnotes` | Enable the `footnotes` extension |
45
46
  | `full_info_string` | Enable full info strings for code blocks |
@@ -48,6 +49,7 @@ GLFMMarkdown.to_html('# header', options: { sourcepos: true })
48
49
  | `github_pre_lang` | Use GitHub-style `<pre lang>` for code blocks |
49
50
  | `greentext` | Enable the `greentext` extension - requires at least one space after a `>` character to generate a blockquote, and restarts blockquote nesting across unique lines of input |
50
51
  | `hardbreaks` | Treat newlines as hard line breaks |
52
+ | `header_accessibility` | Use new header/anchor combination in HTML output, per [!112](https://gitlab.com/gitlab-org/ruby/gems/gitlab-glfm-markdown/-/merge_requests/112). |
51
53
  | `header_ids <PREFIX>` | Enable the `header-id` extension, with the given ID prefix |
52
54
  | `ignore_empty_links` | Ignore empty links in input |
53
55
  | `ignore_setext` | Ignore setext headings in input |
@@ -55,24 +57,24 @@ GLFMMarkdown.to_html('# header', options: { sourcepos: true })
55
57
  | `math_code` | Enables `math code` extension, using math code syntax |
56
58
  | `math_dollars` | Enables `math dollars` extension, using math dollar syntax |
57
59
  | `multiline_block_quotes` | Enable the `multiline-block-quotes` extension |
58
- | `default_html` | Disables any custom HTML, and returns default HTML from `comrak` |
60
+ | `only_escape_chars <CHARS>` | When the 'escaped_char_spans' render option is enabled, only emit `<span data-escaped-char>` around the given characters. |
59
61
  | `placeholder_detection` | Detect placeholder variables in the format `%{placeholder}` |
60
62
  | `relaxed_autolinks` | Enable relaxing of autolink parsing, allowing links to be recognized when in brackets, with any scheme, and with dot-less hostnames |
61
63
  | `relaxed_tasklist_character` | Enable relaxing which character is allowed in tasklists |
62
- | `sourcepos` | Include source mappings in HTML attributes |
63
64
  | `smart` | Use smart punctuation |
65
+ | `sourcepos` | Include source mappings in HTML attributes |
64
66
  | `spoiler` | Enable the `spoiler` extension - use double vertical bars |
65
67
  | `strikethrough` | Enable the `strikethrough` extension |
66
68
  | `superscript` | Enable the `superscript` extension |
67
69
  | `table` | Enable the `table` extension |
68
70
  | `tagfilter` | Enable the `tagfilter` extension |
69
- | `tasklist` | Enable the `tasklist` extension |
70
71
  | `tasklist_classes` | Output classes on tasklist elements so that they can be styled with CSS |
72
+ | `tasklist_in_table` | Enables parsing tasklist items within tables when they're the only content of a table cell |
73
+ | `tasklist` | Enable the `tasklist` extension |
71
74
  | `underline` | Enables the `underline` extension - use double underscores |
72
75
  | `unsafe` | Allow raw HTML and dangerous URLs |
73
76
  | `wikilinks_title_after_pipe` | Enable the `wikilinks_title_after_pipe` extension |
74
77
  | `wikilinks_title_before_pipe` | Enable the `wikilinks_title_before_pipe` extension |
75
- | `debug` | Show debug information |
76
78
 
77
79
  ## Dingus / Demo
78
80
 
@@ -85,8 +87,8 @@ https://gitlab-org.gitlab.io/ruby/gems/gitlab-glfm-markdown
85
87
  A command line executable can be built for debugging.
86
88
 
87
89
  ```
88
- cargo run --bin glfm_markdown --features="cli" -- --help
89
- cargo run --bin glfm_markdown --features="cli" -- --sourcepos
90
+ cargo run --bin gitlab-glfm-markdown --features="cli" -- --help
91
+ cargo run --bin gitlab-glfm-markdown --features="cli" -- --sourcepos
90
92
  ```
91
93
 
92
94
  There is a VSCode workspace that allows you to `Debug executable`
@@ -1,6 +1,6 @@
1
1
  [package]
2
- name = "glfm_markdown"
3
- version = "0.0.38"
2
+ name = "gitlab-glfm-markdown"
3
+ version = "0.0.40"
4
4
  edition = "2021"
5
5
  authors = ["digitalmoksha <bwalker@gitlab.com>", "Asherah Connor <aconnor@gitlab.com>"]
6
6
  description = "GitLab Flavored Markdown parser and formatter. 100% CommonMark-compatible. Experimental."
@@ -10,12 +10,12 @@ publish = false
10
10
  crate-type = ["cdylib"]
11
11
 
12
12
  [[bin]]
13
- name = "glfm_markdown"
13
+ name = "gitlab-glfm-markdown"
14
14
  required-features = ["cli"]
15
15
 
16
16
  [dependencies]
17
17
  clap = { version = "=4.4.18", optional = true, features = ["derive", "string"] }
18
- comrak = { version = "0.46.0", default-features = false, features = ["shortcodes"] }
18
+ comrak = { version = "0.49.0", default-features = false, features = ["shortcodes"] }
19
19
  magnus = "0.8.2"
20
20
  rb-sys = { version = "0.9.117", default-features = false, features = ["stable-api-compiled-fallback"] }
21
21
  regex = "1.11.1"
@@ -3,7 +3,7 @@
3
3
  require 'mkmf'
4
4
  require 'rb_sys/mkmf'
5
5
 
6
- create_rust_makefile('glfm_markdown/glfm_markdown') do |r|
6
+ create_rust_makefile('gitlab_glfm_markdown/gitlab_glfm_markdown') do |r|
7
7
  r.auto_install_rust_toolchain = false
8
8
  # Ensure all Rust dependencies are pinned when building
9
9
  r.extra_cargo_args = ["--locked"]
@@ -0,0 +1,456 @@
1
+ use std::fmt::{self, Write};
2
+ use std::sync::LazyLock;
3
+
4
+ use comrak::html::{collect_text, format_node_default, render_sourcepos, ChildRendering, Context};
5
+ use comrak::nodes::{ListType, NodeHeading, NodeLink, NodeList, NodeTaskItem, NodeValue};
6
+ use comrak::{create_formatter, html, node_matches, Node};
7
+ use regex::Regex;
8
+
9
+ use crate::glfm::RenderOptions;
10
+
11
+ static PLACEHOLDER_REGEX: LazyLock<Regex> =
12
+ LazyLock::new(|| Regex::new(r"%\{(?:\w{1,30})}").unwrap());
13
+
14
+ #[derive(Default)]
15
+ pub struct RenderUserData {
16
+ pub default_html: bool,
17
+ pub inapplicable_tasks: bool,
18
+ pub placeholder_detection: bool,
19
+ pub only_escape_chars: Option<Vec<char>>,
20
+ pub header_accessibility: bool,
21
+
22
+ last_heading: Option<String>,
23
+ }
24
+
25
+ impl From<&RenderOptions> for RenderUserData {
26
+ fn from(options: &RenderOptions) -> Self {
27
+ RenderUserData {
28
+ default_html: options.default_html,
29
+ inapplicable_tasks: options.inapplicable_tasks,
30
+ placeholder_detection: options.placeholder_detection,
31
+ only_escape_chars: options.only_escape_chars.clone(),
32
+ header_accessibility: options.header_accessibility,
33
+ last_heading: None,
34
+ }
35
+ }
36
+ }
37
+
38
+ // The important thing to remember is that this overrides the default behavior of the
39
+ // specified nodes. If we do override a node, then it's our responsibility to ensure that
40
+ // any changes in the Comrak code for those nodes is backported to here, such as when
41
+ // `figcaption` support was added.
42
+ //
43
+ // One idea to limit that would be having the ability to specify attributes that would
44
+ // be inserted when a node is rendered. That would allow us to (in many cases) just
45
+ // inject the changes we need. Such a feature would need to be added to Comrak.
46
+ create_formatter!(CustomFormatter<RenderUserData>, {
47
+ NodeValue::Text(ref literal) => |context, node, entering| {
48
+ return render_text(context, node, entering, literal);
49
+ },
50
+ NodeValue::Link(ref nl) => |context, node, entering| {
51
+ return render_link(context, node, entering, nl);
52
+ },
53
+ NodeValue::Image(ref nl) => |context, node, entering| {
54
+ return render_image(context, node, entering, nl);
55
+ },
56
+ NodeValue::List(ref nl) => |context, node, entering| {
57
+ return render_list(context, node, entering, nl);
58
+ },
59
+ NodeValue::TaskItem(ref nti) => |context, node, entering| {
60
+ return render_task_item(context, node, entering, nti);
61
+ },
62
+ NodeValue::Escaped => |context, node, entering| {
63
+ return render_escaped(context, node, entering);
64
+ },
65
+ NodeValue::Heading(ref nh) => |context, node, entering| {
66
+ return render_heading(context, node, entering, nh);
67
+ },
68
+ });
69
+
70
+ fn render_text(
71
+ context: &mut Context<RenderUserData>,
72
+ node: Node<'_>,
73
+ entering: bool,
74
+ literal: &str,
75
+ ) -> Result<ChildRendering, fmt::Error> {
76
+ if !(context.user.placeholder_detection && PLACEHOLDER_REGEX.is_match(literal)) {
77
+ return html::format_node_default(context, node, entering);
78
+ }
79
+
80
+ if entering {
81
+ let mut cursor: usize = 0;
82
+
83
+ for mat in PLACEHOLDER_REGEX.find_iter(literal) {
84
+ if mat.start() > cursor {
85
+ context.escape(&literal[cursor..mat.start()])?;
86
+ }
87
+
88
+ context.write_str("<span data-placeholder>")?;
89
+ context.escape(&literal[mat.start()..mat.end()])?;
90
+ context.write_str("</span>")?;
91
+
92
+ cursor = mat.end();
93
+ }
94
+
95
+ if cursor < literal.len() {
96
+ context.escape(&literal[cursor..])?;
97
+ }
98
+ }
99
+
100
+ Ok(ChildRendering::HTML)
101
+ }
102
+
103
+ fn render_link(
104
+ context: &mut Context<RenderUserData>,
105
+ node: Node<'_>,
106
+ entering: bool,
107
+ nl: &NodeLink,
108
+ ) -> Result<ChildRendering, fmt::Error> {
109
+ if !(context.user.placeholder_detection && PLACEHOLDER_REGEX.is_match(&nl.url)) {
110
+ return html::format_node_default(context, node, entering);
111
+ }
112
+
113
+ let parent_node = node.parent();
114
+
115
+ if !context.options.parse.relaxed_autolinks
116
+ || (parent_node.is_none()
117
+ || !matches!(
118
+ parent_node.unwrap().data.borrow().value,
119
+ NodeValue::Link(..)
120
+ ))
121
+ {
122
+ if entering {
123
+ context.write_str("<a")?;
124
+ html::render_sourcepos(context, node)?;
125
+ context.write_str(" href=\"")?;
126
+ if context.options.render.r#unsafe || !html::dangerous_url(&nl.url) {
127
+ if let Some(rewriter) = &context.options.extension.link_url_rewriter {
128
+ context.escape_href(&rewriter.to_html(&nl.url))?;
129
+ } else {
130
+ context.escape_href(&nl.url)?;
131
+ }
132
+ }
133
+ context.write_str("\"")?;
134
+
135
+ if !nl.title.is_empty() {
136
+ context.write_str(" title=\"")?;
137
+ context.escape(&nl.title)?;
138
+ }
139
+
140
+ // This path only taken if placeholder detection is enabled, and the regex matched.
141
+ context.write_str(" data-placeholder")?;
142
+
143
+ context.write_str(">")?;
144
+ } else {
145
+ context.write_str("</a>")?;
146
+ }
147
+ }
148
+
149
+ Ok(ChildRendering::HTML)
150
+ }
151
+
152
+ fn render_image(
153
+ context: &mut Context<RenderUserData>,
154
+ node: Node<'_>,
155
+ entering: bool,
156
+ nl: &NodeLink,
157
+ ) -> Result<ChildRendering, fmt::Error> {
158
+ if !(context.user.placeholder_detection && PLACEHOLDER_REGEX.is_match(&nl.url)) {
159
+ return html::format_node_default(context, node, entering);
160
+ }
161
+
162
+ if entering {
163
+ if context.options.render.figure_with_caption {
164
+ context.write_str("<figure>")?;
165
+ }
166
+ context.write_str("<img")?;
167
+ html::render_sourcepos(context, node)?;
168
+ context.write_str(" src=\"")?;
169
+ if context.options.render.r#unsafe || !html::dangerous_url(&nl.url) {
170
+ if let Some(rewriter) = &context.options.extension.image_url_rewriter {
171
+ context.escape_href(&rewriter.to_html(&nl.url))?;
172
+ } else {
173
+ context.escape_href(&nl.url)?;
174
+ }
175
+ }
176
+
177
+ context.write_str("\"")?;
178
+
179
+ // This path only taken if placeholder detection is enabled, and the regex matched.
180
+ context.write_str(" data-placeholder")?;
181
+
182
+ context.write_str(" alt=\"")?;
183
+
184
+ return Ok(ChildRendering::Plain);
185
+ } else {
186
+ if !nl.title.is_empty() {
187
+ context.write_str("\" title=\"")?;
188
+ context.escape(&nl.title)?;
189
+ }
190
+ context.write_str("\" />")?;
191
+ if context.options.render.figure_with_caption {
192
+ if !nl.title.is_empty() {
193
+ context.write_str("<figcaption>")?;
194
+ context.escape(&nl.title)?;
195
+ context.write_str("</figcaption>")?;
196
+ }
197
+ context.write_str("</figure>")?;
198
+ };
199
+ }
200
+
201
+ Ok(ChildRendering::HTML)
202
+ }
203
+
204
+ // Overridden to use class `task-list` instead of `contains-task-list`
205
+ // to align with GitLab class usage
206
+ fn render_list(
207
+ context: &mut Context<RenderUserData>,
208
+ node: Node<'_>,
209
+ entering: bool,
210
+ nl: &NodeList,
211
+ ) -> Result<ChildRendering, fmt::Error> {
212
+ if !entering || !context.options.render.tasklist_classes {
213
+ return html::format_node_default(context, node, entering);
214
+ }
215
+
216
+ context.cr()?;
217
+ match nl.list_type {
218
+ ListType::Bullet => {
219
+ context.write_str("<ul")?;
220
+ if nl.is_task_list {
221
+ context.write_str(" class=\"task-list\"")?;
222
+ }
223
+ html::render_sourcepos(context, node)?;
224
+ context.write_str(">\n")?;
225
+ }
226
+ ListType::Ordered => {
227
+ context.write_str("<ol")?;
228
+ if nl.is_task_list {
229
+ context.write_str(" class=\"task-list\"")?;
230
+ }
231
+ html::render_sourcepos(context, node)?;
232
+ if nl.start == 1 {
233
+ context.write_str(">\n")?;
234
+ } else {
235
+ writeln!(context, " start=\"{}\">", nl.start)?;
236
+ }
237
+ }
238
+ }
239
+
240
+ Ok(ChildRendering::HTML)
241
+ }
242
+
243
+ // Overridden to:
244
+ // 1. Detect inapplicable task list items; and,
245
+ // 2. Output checkbox sourcepos.
246
+ fn render_task_item(
247
+ context: &mut Context<RenderUserData>,
248
+ node: Node<'_>,
249
+ entering: bool,
250
+ nti: &NodeTaskItem,
251
+ ) -> Result<ChildRendering, fmt::Error> {
252
+ let write_li = node
253
+ .parent()
254
+ .map(|p| node_matches!(p, NodeValue::List(_)))
255
+ .unwrap_or_default();
256
+
257
+ if !entering {
258
+ if write_li {
259
+ context.write_str("</li>\n")?;
260
+ }
261
+ return Ok(ChildRendering::HTML);
262
+ }
263
+
264
+ match nti.symbol {
265
+ Some('~') => {
266
+ render_task_item_with(
267
+ context,
268
+ node,
269
+ nti,
270
+ if context.user.inapplicable_tasks {
271
+ TaskItemState::Inapplicable
272
+ } else {
273
+ TaskItemState::Checked
274
+ },
275
+ write_li,
276
+ )?;
277
+ }
278
+ None => {
279
+ render_task_item_with(context, node, nti, TaskItemState::Unchecked, write_li)?;
280
+ }
281
+ Some(ws) if ws.is_whitespace() => {
282
+ render_task_item_with(context, node, nti, TaskItemState::Unchecked, write_li)?;
283
+ }
284
+ Some('x' | 'X') => {
285
+ render_task_item_with(context, node, nti, TaskItemState::Checked, write_li)?;
286
+ }
287
+ Some(symbol) => {
288
+ context.cr()?;
289
+ if write_li {
290
+ context.write_str("<li")?;
291
+ if context.options.render.tasklist_classes {
292
+ context.write_str(" class=\"task-list-item\"")?;
293
+ }
294
+ html::render_sourcepos(context, node)?;
295
+ context.write_str(">")?;
296
+ }
297
+ context.write_str("[")?;
298
+ context.escape(&symbol.to_string())?;
299
+ context.write_str("] ")?;
300
+ }
301
+ }
302
+
303
+ Ok(ChildRendering::HTML)
304
+ }
305
+
306
+ #[derive(PartialEq)]
307
+ enum TaskItemState {
308
+ Checked,
309
+ Unchecked,
310
+ Inapplicable,
311
+ }
312
+
313
+ fn render_task_item_with(
314
+ context: &mut Context<RenderUserData>,
315
+ node: Node<'_>,
316
+ nti: &NodeTaskItem,
317
+ state: TaskItemState,
318
+ write_li: bool,
319
+ ) -> fmt::Result {
320
+ context.cr()?;
321
+ if write_li {
322
+ context.write_str("<li")?;
323
+ if context.options.render.tasklist_classes {
324
+ context.write_str(" class=\"")?;
325
+ if state == TaskItemState::Inapplicable {
326
+ context.write_str("inapplicable ")?;
327
+ }
328
+ context.write_str("task-list-item\"")?;
329
+ }
330
+ render_sourcepos(context, node)?;
331
+ context.write_str(">")?;
332
+ }
333
+
334
+ context.write_str("<input type=\"checkbox\"")?;
335
+ if !write_li {
336
+ html::render_sourcepos(context, node)?;
337
+ }
338
+ if context.options.render.sourcepos {
339
+ write!(
340
+ context,
341
+ " data-checkbox-sourcepos=\"{:?}\"",
342
+ nti.symbol_sourcepos
343
+ )?;
344
+ }
345
+
346
+ if context.options.render.tasklist_classes {
347
+ context.write_str(" class=\"task-list-item-checkbox\"")?;
348
+ }
349
+
350
+ match state {
351
+ TaskItemState::Checked => {
352
+ context.write_str(" checked=\"\"")?;
353
+ }
354
+ TaskItemState::Unchecked => {}
355
+ TaskItemState::Inapplicable => {
356
+ context.write_str(" data-inapplicable")?;
357
+ }
358
+ }
359
+ context.write_str(" disabled=\"\" /> ")?;
360
+
361
+ Ok(())
362
+ }
363
+
364
+ fn render_escaped(
365
+ context: &mut Context<RenderUserData>,
366
+ node: Node<'_>,
367
+ entering: bool,
368
+ ) -> Result<ChildRendering, fmt::Error> {
369
+ if !context.options.render.escaped_char_spans {
370
+ return Ok(ChildRendering::HTML);
371
+ }
372
+
373
+ if context
374
+ .user
375
+ .only_escape_chars
376
+ .as_ref()
377
+ .is_none_or(|only_escape_chars| {
378
+ with_node_text_content(node, false, |content| {
379
+ content.chars().count() == 1
380
+ && only_escape_chars.contains(&content.chars().next().unwrap())
381
+ })
382
+ })
383
+ {
384
+ if entering {
385
+ context.write_str("<span data-escaped-char")?;
386
+ render_sourcepos(context, node)?;
387
+ context.write_str(">")?;
388
+ } else {
389
+ context.write_str("</span>")?;
390
+ }
391
+ }
392
+
393
+ Ok(ChildRendering::HTML)
394
+ }
395
+
396
+ fn render_heading(
397
+ context: &mut Context<RenderUserData>,
398
+ node: Node<'_>,
399
+ entering: bool,
400
+ nh: &NodeHeading,
401
+ ) -> Result<ChildRendering, fmt::Error> {
402
+ if !context.user.header_accessibility || context.plugins.render.heading_adapter.is_some() {
403
+ return format_node_default(context, node, entering);
404
+ }
405
+
406
+ if entering {
407
+ context.cr()?;
408
+ write!(context, "<h{}", nh.level)?;
409
+
410
+ if let Some(ref prefix) = context.options.extension.header_ids {
411
+ let text_content = collect_text(node);
412
+ let id = context.anchorizer.anchorize(&text_content);
413
+ write!(context, r##" id="{prefix}{id}""##)?;
414
+ context.user.last_heading = Some(id);
415
+ }
416
+
417
+ render_sourcepos(context, node)?;
418
+ context.write_str(">")?;
419
+ } else {
420
+ if context.options.extension.header_ids.is_some() {
421
+ let id = context.user.last_heading.take().unwrap();
422
+ let text_content = collect_text(node);
423
+ write!(
424
+ context,
425
+ r##"<a href="#{id}" aria-label="Link to heading '"##
426
+ )?;
427
+ context.escape(&text_content)?;
428
+ context.write_str(r#"'" data-heading-content=""#)?;
429
+ context.escape(&text_content)?;
430
+ context.write_str(r#"" class="anchor"></a>"#)?;
431
+ }
432
+ writeln!(context, "</h{}>", nh.level)?;
433
+ }
434
+ Ok(ChildRendering::HTML)
435
+ }
436
+
437
+ /// If the given node has a single text child, apply a function to the text content
438
+ /// of that node. Otherwise, return the given default value.
439
+ fn with_node_text_content<U, F>(node: Node<'_>, default: U, f: F) -> U
440
+ where
441
+ F: FnOnce(&str) -> U,
442
+ {
443
+ let Some(child) = node.first_child() else {
444
+ return default;
445
+ };
446
+ let Some(last_child) = node.last_child() else {
447
+ return default;
448
+ };
449
+ if !child.same_node(last_child) {
450
+ return default;
451
+ }
452
+ let NodeValue::Text(ref text) = child.data.borrow().value else {
453
+ return default;
454
+ };
455
+ f(text)
456
+ }
@@ -10,6 +10,8 @@ pub struct RenderOptions {
10
10
  pub alerts: bool,
11
11
  pub autolink: bool,
12
12
  pub cjk_friendly_emphasis: bool,
13
+ /// Only use default comrak HTML formatting
14
+ pub default_html: bool,
13
15
  // pub default_info_string: String,
14
16
  pub description_lists: bool,
15
17
  pub escape: bool,
@@ -23,16 +25,27 @@ pub struct RenderOptions {
23
25
  pub github_pre_lang: bool,
24
26
  pub greentext: bool,
25
27
  pub hardbreaks: bool,
28
+ /// Use new header/anchor combination in HTML output, per
29
+ /// https://gitlab.com/gitlab-org/ruby/gems/gitlab-glfm-markdown/-/merge_requests/112.
30
+ pub header_accessibility: bool,
26
31
  pub header_ids: Option<String>,
27
32
  pub ignore_empty_links: bool,
28
33
  pub ignore_setext: bool,
34
+ /// Detect inapplicable tasks (`- [~]`)
35
+ pub inapplicable_tasks: bool,
29
36
  pub math_code: bool,
30
37
  pub math_dollars: bool,
31
38
  pub multiline_block_quotes: bool,
39
+ /// When the 'escaped_char_spans' render option is enabled, only emit `<span
40
+ /// data-escaped-char>` around the given characters.
41
+ pub only_escape_chars: Option<Vec<char>>,
42
+ /// Detect and mark potential placeholder variables, which
43
+ /// have the format `%{PLACEHOLDER}`
44
+ pub placeholder_detection: bool,
32
45
  pub relaxed_autolinks: bool,
33
46
  pub relaxed_tasklist_character: bool,
34
- pub sourcepos: bool,
35
47
  pub smart: bool,
48
+ pub sourcepos: bool,
36
49
  pub spoiler: bool,
37
50
  pub strikethrough: bool,
38
51
  pub subscript: bool,
@@ -42,40 +55,11 @@ pub struct RenderOptions {
42
55
  pub tagfilter: bool,
43
56
  pub tasklist: bool,
44
57
  pub tasklist_classes: bool,
58
+ pub tasklist_in_table: bool,
45
59
  pub underline: bool,
46
60
  pub r#unsafe: bool,
47
61
  pub wikilinks_title_after_pipe: bool,
48
62
  pub wikilinks_title_before_pipe: bool,
49
-
50
- /// GLFM specific options
51
-
52
- /// Only use default comrak HTML formatting
53
- pub default_html: bool,
54
-
55
- /// Detect inapplicable tasks (`- [~]`)
56
- pub inapplicable_tasks: bool,
57
-
58
- /// Detect and mark potential placeholder variables, which
59
- /// have the format `%{PLACEHOLDER}`
60
- pub placeholder_detection: bool,
61
-
62
- /// When the 'escaped_char_spans' render option is enabled, only emit `<span
63
- /// data-escaped-char>` around the given characters.
64
- pub only_escape_chars: Option<Vec<char>>,
65
-
66
- pub debug: bool,
67
- }
68
-
69
- impl From<&RenderOptions> for RenderUserData {
70
- fn from(options: &RenderOptions) -> Self {
71
- RenderUserData {
72
- default_html: options.default_html,
73
- inapplicable_tasks: options.inapplicable_tasks,
74
- placeholder_detection: options.placeholder_detection,
75
- only_escape_chars: options.only_escape_chars.clone(),
76
- debug: options.debug,
77
- }
78
- }
79
63
  }
80
64
 
81
65
  impl From<&RenderOptions> for comrak::Options<'_> {
@@ -120,10 +104,11 @@ impl From<&RenderOptions> for comrak::Options<'_> {
120
104
  comrak_options.render.r#unsafe = options.r#unsafe;
121
105
 
122
106
  // comrak_options.parse.default_info_string = options.default_info_string;
107
+ comrak_options.parse.ignore_setext = options.ignore_setext;
123
108
  comrak_options.parse.relaxed_autolinks = options.relaxed_autolinks;
124
109
  comrak_options.parse.relaxed_tasklist_matching = options.relaxed_tasklist_character;
125
110
  comrak_options.parse.smart = options.smart;
126
- comrak_options.parse.ignore_setext = options.ignore_setext;
111
+ comrak_options.parse.tasklist_in_table = options.tasklist_in_table;
127
112
 
128
113
  comrak_options
129
114
  }
@@ -14,6 +14,10 @@ struct Args {
14
14
  #[arg(value_name = "FILE")]
15
15
  file: Option<String>,
16
16
 
17
+ /// Write output to FILE instead of stdout
18
+ #[arg(short, long, value_name = "FILE")]
19
+ output: Option<String>,
20
+
17
21
  /// Enable 'alerts' extension
18
22
  #[arg(long)]
19
23
  alerts: bool,
@@ -22,6 +26,14 @@ struct Args {
22
26
  #[arg(long)]
23
27
  autolink: bool,
24
28
 
29
+ /// Enable 'cjk_friendly_emphasis' extension
30
+ #[arg(long)]
31
+ cjk_friendly_emphasis: bool,
32
+
33
+ /// Only use default comrak HTML formatting
34
+ #[arg(long)]
35
+ default_html: bool,
36
+
25
37
  /// Enable 'description-lists' extension
26
38
  #[arg(long)]
27
39
  description_lists: bool,
@@ -72,6 +84,11 @@ struct Args {
72
84
  #[arg(long)]
73
85
  hardbreaks: bool,
74
86
 
87
+ /// Use new header/anchor combination in HTML output, per
88
+ /// https://gitlab.com/gitlab-org/ruby/gems/gitlab-glfm-markdown/-/merge_requests/112.
89
+ #[arg(long)]
90
+ header_accessibility: bool,
91
+
75
92
  /// Enable the 'header IDs' extension, with the given ID prefix
76
93
  #[arg(long, value_name = "PREFIX")]
77
94
  header_ids: Option<String>,
@@ -84,6 +101,10 @@ struct Args {
84
101
  #[arg(long)]
85
102
  ignore_setext: bool,
86
103
 
104
+ /// Detect inapplicable tasks (`- [~]`)
105
+ #[arg(long)]
106
+ inapplicable_tasks: bool,
107
+
87
108
  /// Enables `math code` extension, using math code syntax
88
109
  #[arg(long)]
89
110
  math_code: bool,
@@ -96,9 +117,15 @@ struct Args {
96
117
  #[arg(long)]
97
118
  multiline_block_quotes: bool,
98
119
 
99
- /// Write output to FILE instead of stdout
100
- #[arg(short, long, value_name = "FILE")]
101
- output: Option<String>,
120
+ /// When the 'escaped_char_spans' render option is enabled, only emit `<span
121
+ /// data-escaped-char>` around the characters in the supplied string.
122
+ #[arg(long)]
123
+ only_escape_chars: Option<String>,
124
+
125
+ /// Detect and marks potential placeholder variables, which
126
+ /// have the format `%{PLACEHOLDER}`
127
+ #[arg(long)]
128
+ placeholder_detection: bool,
102
129
 
103
130
  /// Enable relaxing of autolink parsing, allowing links to be recognized when in brackets
104
131
  #[arg(long)]
@@ -108,14 +135,14 @@ struct Args {
108
135
  #[arg(long)]
109
136
  relaxed_tasklist_character: bool,
110
137
 
111
- /// Include source mappings in HTML attributes
112
- #[arg(long)]
113
- sourcepos: bool,
114
-
115
138
  /// Use smart punctuation
116
139
  #[arg(long)]
117
140
  smart: bool,
118
141
 
142
+ /// Include source mappings in HTML attributes
143
+ #[arg(long)]
144
+ sourcepos: bool,
145
+
119
146
  /// Enables spoilers using double vertical bars
120
147
  #[arg(long)]
121
148
  spoiler: bool,
@@ -152,6 +179,10 @@ struct Args {
152
179
  #[arg(long)]
153
180
  tasklist_classes: bool,
154
181
 
182
+ /// Enables parsing tasklist items within tables when they're the only content of a table cell
183
+ #[arg(long)]
184
+ tasklist_in_table: bool,
185
+
155
186
  /// Enables underlines using double underscores
156
187
  #[arg(long)]
157
188
  underline: bool,
@@ -167,34 +198,6 @@ struct Args {
167
198
  /// Enable 'wikilink_title_before_pipe' extension
168
199
  #[arg(long)]
169
200
  wikilinks_title_before_pipe: bool,
170
-
171
- /// Only use default comrak HTML formatting
172
- #[arg(long)]
173
- default_html: bool,
174
-
175
- /// GLFM specific options
176
-
177
- /// Detect inapplicable tasks (`- [~]`)
178
- #[arg(long)]
179
- inapplicable_tasks: bool,
180
-
181
- /// Detect and marks potential placeholder variables, which
182
- /// have the format `%{PLACEHOLDER}`
183
- #[arg(long)]
184
- placeholder_detection: bool,
185
-
186
- /// When the 'escaped_char_spans' render option is enabled, only emit `<span
187
- /// data-escaped-char>` around the characters in the supplied string.
188
- #[arg(long)]
189
- only_escape_chars: Option<String>,
190
-
191
- /// Enable 'cjk_friendly_emphasis' extension
192
- #[arg(long)]
193
- cjk_friendly_emphasis: bool,
194
-
195
- /// Show debug information
196
- #[arg(long)]
197
- debug: bool,
198
201
  }
199
202
 
200
203
  fn main() {
@@ -216,6 +219,7 @@ fn main() {
216
219
  autolink: cli.autolink,
217
220
  cjk_friendly_emphasis: cli.cjk_friendly_emphasis,
218
221
  // default_info_string:
222
+ default_html: cli.default_html,
219
223
  description_lists: cli.description_lists,
220
224
  escape: cli.escape,
221
225
  escaped_char_spans: cli.escaped_char_spans,
@@ -228,6 +232,7 @@ fn main() {
228
232
  github_pre_lang: cli.github_pre_lang,
229
233
  greentext: cli.greentext,
230
234
  hardbreaks: cli.hardbreaks,
235
+ header_accessibility: cli.header_accessibility,
231
236
  header_ids: cli.header_ids,
232
237
  ignore_empty_links: cli.ignore_empty_links,
233
238
  ignore_setext: cli.ignore_setext,
@@ -235,10 +240,12 @@ fn main() {
235
240
  math_code: cli.math_code,
236
241
  math_dollars: cli.math_dollars,
237
242
  multiline_block_quotes: cli.multiline_block_quotes,
243
+ only_escape_chars: cli.only_escape_chars.map(|s| s.chars().collect()),
244
+ placeholder_detection: cli.placeholder_detection,
238
245
  relaxed_autolinks: cli.relaxed_autolinks,
239
246
  relaxed_tasklist_character: cli.relaxed_tasklist_character,
240
- sourcepos: cli.sourcepos,
241
247
  smart: cli.smart,
248
+ sourcepos: cli.sourcepos,
242
249
  spoiler: cli.spoiler,
243
250
  strikethrough: cli.strikethrough,
244
251
  subscript: cli.subscript,
@@ -248,15 +255,11 @@ fn main() {
248
255
  tagfilter: cli.tagfilter,
249
256
  tasklist: cli.tasklist,
250
257
  tasklist_classes: cli.tasklist_classes,
258
+ tasklist_in_table: cli.tasklist_in_table,
251
259
  underline: cli.underline,
252
260
  r#unsafe: cli.r#unsafe,
253
261
  wikilinks_title_after_pipe: cli.wikilinks_title_after_pipe,
254
262
  wikilinks_title_before_pipe: cli.wikilinks_title_before_pipe,
255
-
256
- default_html: cli.default_html,
257
- placeholder_detection: cli.placeholder_detection,
258
- only_escape_chars: cli.only_escape_chars.map(|s| s.chars().collect()),
259
- debug: cli.debug,
260
263
  };
261
264
 
262
265
  let result = render(&source, &options);
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative 'glfm_markdown/version'
4
- require_relative 'glfm_markdown/loader'
3
+ require_relative 'gitlab_glfm_markdown/version'
4
+ require_relative 'gitlab_glfm_markdown/loader'
5
5
 
6
6
  load_rust_extension
7
7
 
@@ -27,9 +27,7 @@ module GLFMMarkdown
27
27
  tagfilter: false,
28
28
  tasklist: true,
29
29
  tasklist_classes: true,
30
- unsafe: true,
31
-
32
- debug: false
30
+ unsafe: true
33
31
  }.freeze
34
32
 
35
33
  class << self
@@ -2,7 +2,7 @@
2
2
 
3
3
  def load_rust_extension
4
4
  ruby_version = /(\d+\.\d+)/.match(RUBY_VERSION)
5
- require "glfm_markdown/#{ruby_version}/glfm_markdown"
5
+ require "gitlab_glfm_markdown/#{ruby_version}/gitlab_glfm_markdown"
6
6
  rescue LoadError
7
- require 'glfm_markdown/glfm_markdown'
7
+ require 'gitlab_glfm_markdown/gitlab_glfm_markdown'
8
8
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module GLFMMarkdown
4
- VERSION = '0.0.38'
4
+ VERSION = '0.0.40'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: gitlab-glfm-markdown
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.38
4
+ version: 0.0.40
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brett Walker
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2025-10-29 00:00:00.000000000 Z
12
+ date: 2025-12-10 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: rb_sys
@@ -59,22 +59,22 @@ email:
59
59
  - aconnor@gitlab.com
60
60
  executables: []
61
61
  extensions:
62
- - ext/glfm_markdown/extconf.rb
62
+ - ext/gitlab_glfm_markdown/extconf.rb
63
63
  extra_rdoc_files: []
64
64
  files:
65
65
  - Cargo.lock
66
66
  - LICENSE
67
67
  - README.md
68
- - ext/glfm_markdown/Cargo.lock
69
- - ext/glfm_markdown/Cargo.toml
70
- - ext/glfm_markdown/extconf.rb
71
- - ext/glfm_markdown/src/formatter.rs
72
- - ext/glfm_markdown/src/glfm.rs
73
- - ext/glfm_markdown/src/lib.rs
74
- - ext/glfm_markdown/src/main.rs
75
- - lib/glfm_markdown.rb
76
- - lib/glfm_markdown/loader.rb
77
- - lib/glfm_markdown/version.rb
68
+ - ext/gitlab_glfm_markdown/Cargo.lock
69
+ - ext/gitlab_glfm_markdown/Cargo.toml
70
+ - ext/gitlab_glfm_markdown/extconf.rb
71
+ - ext/gitlab_glfm_markdown/src/formatter.rs
72
+ - ext/gitlab_glfm_markdown/src/glfm.rs
73
+ - ext/gitlab_glfm_markdown/src/lib.rs
74
+ - ext/gitlab_glfm_markdown/src/main.rs
75
+ - lib/gitlab-glfm-markdown.rb
76
+ - lib/gitlab_glfm_markdown/loader.rb
77
+ - lib/gitlab_glfm_markdown/version.rb
78
78
  homepage: https://gitlab.com/gitlab-org/ruby/gems/gitlab-glfm-markdown
79
79
  licenses:
80
80
  - MIT
@@ -1,366 +0,0 @@
1
- use std::fmt::{self, Write};
2
-
3
- use comrak::html::{render_sourcepos, ChildRendering, Context};
4
- use comrak::nodes::{AstNode, ListType, NodeValue};
5
- use comrak::{create_formatter, html};
6
- use lazy_static::lazy_static;
7
- use regex::Regex;
8
-
9
- // TODO: Use std::sync:LazyLock once we're on 1.80+.
10
- // https://doc.rust-lang.org/std/sync/struct.LazyLock.html
11
- lazy_static! {
12
- static ref PLACEHOLDER_REGEX: Regex = Regex::new(r"%(\{|%7B)(\w{1,30})(}|%7D)").unwrap();
13
- }
14
-
15
- pub struct RenderUserData {
16
- pub default_html: bool,
17
- pub inapplicable_tasks: bool,
18
- pub placeholder_detection: bool,
19
- pub only_escape_chars: Option<Vec<char>>,
20
- pub debug: bool,
21
- }
22
-
23
- // The important thing to remember is that this overrides the default behavior of the
24
- // specified nodes. If we do override a node, then it's our responsibility to ensure that
25
- // any changes in the `comrak` code for those nodes is backported to here, such as when
26
- // `figcaption` support was added.
27
- // One idea to limit that would be having the ability to specify attributes that would
28
- // be inserted when a node is rendered. That would allow us to (in many cases) just
29
- // inject the changes we need. Such a feature would need to be added to `comrak`.
30
- create_formatter!(CustomFormatter<RenderUserData>, {
31
- NodeValue::Text(_) => |context, node, entering| {
32
- return render_text(context, node, entering);
33
- },
34
- NodeValue::Link(_) => |context, node, entering| {
35
- return render_link(context, node, entering);
36
- },
37
- NodeValue::Image(_) => |context, node, entering| {
38
- return render_image(context, node, entering);
39
- },
40
- NodeValue::List(_) => |context, node, entering| {
41
- return render_list(context, node, entering);
42
- },
43
- NodeValue::TaskItem(_) => |context, node, entering| {
44
- return render_task_item(context, node, entering);
45
- },
46
- NodeValue::Escaped => |context, node, entering| {
47
- return render_escaped(context, node, entering);
48
- },
49
- });
50
-
51
- fn render_image<'a>(
52
- context: &mut Context<RenderUserData>,
53
- node: &'a AstNode<'a>,
54
- entering: bool,
55
- ) -> Result<ChildRendering, fmt::Error> {
56
- let NodeValue::Image(ref nl) = node.data.borrow().value else {
57
- unreachable!()
58
- };
59
-
60
- if !(context.user.placeholder_detection && PLACEHOLDER_REGEX.is_match(&nl.url)) {
61
- return html::format_node_default(context, node, entering);
62
- }
63
-
64
- if entering {
65
- if context.options.render.figure_with_caption {
66
- context.write_str("<figure>")?;
67
- }
68
- context.write_str("<img")?;
69
- html::render_sourcepos(context, node)?;
70
- context.write_str(" src=\"")?;
71
- if context.options.render.r#unsafe || !html::dangerous_url(&nl.url) {
72
- if let Some(rewriter) = &context.options.extension.image_url_rewriter {
73
- context.escape_href(&rewriter.to_html(&nl.url))?;
74
- } else {
75
- context.escape_href(&nl.url)?;
76
- }
77
- }
78
-
79
- context.write_str("\"")?;
80
-
81
- if PLACEHOLDER_REGEX.is_match(&nl.url) {
82
- context.write_str(" data-placeholder")?;
83
- }
84
-
85
- context.write_str(" alt=\"")?;
86
-
87
- return Ok(ChildRendering::Plain);
88
- } else {
89
- if !nl.title.is_empty() {
90
- context.write_str("\" title=\"")?;
91
- context.escape(&nl.title)?;
92
- }
93
- context.write_str("\" />")?;
94
- if context.options.render.figure_with_caption {
95
- if !nl.title.is_empty() {
96
- context.write_str("<figcaption>")?;
97
- context.escape(&nl.title)?;
98
- context.write_str("</figcaption>")?;
99
- }
100
- context.write_str("</figure>")?;
101
- };
102
- }
103
-
104
- Ok(ChildRendering::HTML)
105
- }
106
-
107
- fn render_link<'a>(
108
- context: &mut Context<RenderUserData>,
109
- node: &'a AstNode<'a>,
110
- entering: bool,
111
- ) -> Result<ChildRendering, fmt::Error> {
112
- let NodeValue::Link(ref nl) = node.data.borrow().value else {
113
- unreachable!()
114
- };
115
-
116
- if !(context.user.placeholder_detection && PLACEHOLDER_REGEX.is_match(&nl.url)) {
117
- return html::format_node_default(context, node, entering);
118
- }
119
-
120
- let parent_node = node.parent();
121
-
122
- if !context.options.parse.relaxed_autolinks
123
- || (parent_node.is_none()
124
- || !matches!(
125
- parent_node.unwrap().data.borrow().value,
126
- NodeValue::Link(..)
127
- ))
128
- {
129
- if entering {
130
- context.write_str("<a")?;
131
- html::render_sourcepos(context, node)?;
132
- context.write_str(" href=\"")?;
133
- if context.options.render.r#unsafe || !html::dangerous_url(&nl.url) {
134
- if let Some(rewriter) = &context.options.extension.link_url_rewriter {
135
- context.escape_href(&rewriter.to_html(&nl.url))?;
136
- } else {
137
- context.escape_href(&nl.url)?;
138
- }
139
- }
140
- context.write_str("\"")?;
141
-
142
- if !nl.title.is_empty() {
143
- context.write_str(" title=\"")?;
144
- context.escape(&nl.title)?;
145
- }
146
-
147
- if PLACEHOLDER_REGEX.is_match(&nl.url) {
148
- context.write_str(" data-placeholder")?;
149
- }
150
-
151
- context.write_str(">")?;
152
- } else {
153
- context.write_str("</a>")?;
154
- }
155
- }
156
-
157
- Ok(ChildRendering::HTML)
158
- }
159
-
160
- // Overridden to use class `task-list` instead of `contains-task-list`
161
- // to align with GitLab class usage
162
- fn render_list<'a>(
163
- context: &mut Context<RenderUserData>,
164
- node: &'a AstNode<'a>,
165
- entering: bool,
166
- ) -> Result<ChildRendering, fmt::Error> {
167
- if !entering || !context.options.render.tasklist_classes {
168
- return html::format_node_default(context, node, entering);
169
- }
170
-
171
- let NodeValue::List(ref nl) = node.data.borrow().value else {
172
- unreachable!()
173
- };
174
-
175
- context.cr()?;
176
- match nl.list_type {
177
- ListType::Bullet => {
178
- context.write_str("<ul")?;
179
- if nl.is_task_list {
180
- context.write_str(" class=\"task-list\"")?;
181
- }
182
- html::render_sourcepos(context, node)?;
183
- context.write_str(">\n")?;
184
- }
185
- ListType::Ordered => {
186
- context.write_str("<ol")?;
187
- if nl.is_task_list {
188
- context.write_str(" class=\"task-list\"")?;
189
- }
190
- html::render_sourcepos(context, node)?;
191
- if nl.start == 1 {
192
- context.write_str(">\n")?;
193
- } else {
194
- writeln!(context, " start=\"{}\">", nl.start)?;
195
- }
196
- }
197
- }
198
-
199
- Ok(ChildRendering::HTML)
200
- }
201
-
202
- // Overridden to detect inapplicable task list items
203
- fn render_task_item<'a>(
204
- context: &mut Context<RenderUserData>,
205
- node: &'a AstNode<'a>,
206
- entering: bool,
207
- ) -> Result<ChildRendering, fmt::Error> {
208
- if !context.user.inapplicable_tasks {
209
- return html::format_node_default(context, node, entering);
210
- }
211
-
212
- let NodeValue::TaskItem(symbol) = node.data.borrow().value else {
213
- unreachable!()
214
- };
215
-
216
- if symbol.is_none() || matches!(symbol, Some('x' | 'X')) {
217
- return html::format_node_default(context, node, entering);
218
- }
219
-
220
- if entering {
221
- // Handle an inapplicable task symbol.
222
- if matches!(symbol, Some('~')) {
223
- context.cr()?;
224
- context.write_str("<li")?;
225
- context.write_str(" class=\"inapplicable")?;
226
-
227
- if context.options.render.tasklist_classes {
228
- context.write_str(" task-list-item")?;
229
- }
230
- context.write_str("\"")?;
231
-
232
- html::render_sourcepos(context, node)?;
233
- context.write_str(">")?;
234
- context.write_str("<input type=\"checkbox\"")?;
235
-
236
- if context.options.render.tasklist_classes {
237
- context.write_str(" class=\"task-list-item-checkbox\"")?;
238
- }
239
-
240
- context.write_str(" data-inapplicable disabled=\"\"> ")?;
241
- } else {
242
- // Don't allow unsupported symbols to render a checkbox
243
- context.cr()?;
244
- context.write_str("<li")?;
245
-
246
- if context.options.render.tasklist_classes {
247
- context.write_str(" class=\"task-list-item\"")?;
248
- }
249
-
250
- html::render_sourcepos(context, node)?;
251
- context.write_str(">")?;
252
- context.write_str("[")?;
253
- context.escape(&symbol.unwrap().to_string())?;
254
- context.write_str("] ")?;
255
- }
256
- } else {
257
- context.write_str("</li>\n")?;
258
- }
259
-
260
- Ok(ChildRendering::HTML)
261
- }
262
-
263
- fn render_text<'a>(
264
- context: &mut Context<RenderUserData>,
265
- node: &'a AstNode<'a>,
266
- entering: bool,
267
- ) -> Result<ChildRendering, fmt::Error> {
268
- let NodeValue::Text(ref literal) = node.data.borrow().value else {
269
- unreachable!()
270
- };
271
-
272
- if !(context.user.placeholder_detection && PLACEHOLDER_REGEX.is_match(literal)) {
273
- return html::format_node_default(context, node, entering);
274
- }
275
-
276
- // Don't currently support placeholders in the text inside links or images.
277
- // If the text has an underscore in it, then the parser will not combine
278
- // the multiple text nodes in `comrak`'s `postprocess_text_nodes`, breaking up
279
- // the placeholder into multiple text nodes.
280
- // For example, `[%{a_b}](link)`.
281
- let parent = node.parent().unwrap();
282
- if matches!(
283
- parent.data.borrow().value,
284
- NodeValue::Link(_) | NodeValue::Image(_)
285
- ) {
286
- return html::format_node_default(context, node, entering);
287
- }
288
-
289
- if entering {
290
- let mut cursor: usize = 0;
291
-
292
- for mat in PLACEHOLDER_REGEX.find_iter(literal) {
293
- if mat.start() > cursor {
294
- context.escape(&literal[cursor..mat.start()])?;
295
- }
296
-
297
- context.write_str("<span data-placeholder>")?;
298
- context.escape(&literal[mat.start()..mat.end()])?;
299
- context.write_str("</span>")?;
300
-
301
- cursor = mat.end();
302
- }
303
-
304
- if cursor < literal.len() {
305
- context.escape(&literal[cursor..literal.len()])?;
306
- }
307
- }
308
-
309
- Ok(ChildRendering::HTML)
310
- }
311
-
312
- fn render_escaped<'a>(
313
- context: &mut Context<RenderUserData>,
314
- node: &'a AstNode<'a>,
315
- entering: bool,
316
- ) -> Result<ChildRendering, fmt::Error> {
317
- if !context.options.render.escaped_char_spans {
318
- return Ok(ChildRendering::HTML);
319
- }
320
-
321
- if context.user.only_escape_chars.is_none()
322
- || with_node_text_content(node, false, |content| {
323
- if content.len() != 1 {
324
- return false;
325
- }
326
- let c = content.chars().next().unwrap();
327
- context
328
- .user
329
- .only_escape_chars
330
- .as_ref()
331
- .unwrap()
332
- .contains(&c)
333
- })
334
- {
335
- if entering {
336
- context.write_str("<span data-escaped-char")?;
337
- render_sourcepos(context, node)?;
338
- context.write_str(">")?;
339
- } else {
340
- context.write_str("</span>")?;
341
- }
342
- }
343
-
344
- Ok(ChildRendering::HTML)
345
- }
346
-
347
- /// If the given node has a single text child, apply a function to the text content
348
- /// of that node. Otherwise, return the given default value.
349
- fn with_node_text_content<'a, U, F>(node: &'a AstNode<'a>, default: U, f: F) -> U
350
- where
351
- F: FnOnce(&str) -> U,
352
- {
353
- let Some(child) = node.first_child() else {
354
- return default;
355
- };
356
- let Some(last_child) = node.last_child() else {
357
- return default;
358
- };
359
- if !child.same_node(last_child) {
360
- return default;
361
- }
362
- let NodeValue::Text(ref text) = child.data.borrow().value else {
363
- return default;
364
- };
365
- f(text)
366
- }