gitlab-glfm-markdown 0.0.38 → 0.0.39

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: 6e9457f3f2408a065bd1aae17f04f46ab02279cd28dee3eb4e66e6d62bf89084
4
+ data.tar.gz: ca43b48e916994c0745009692dfd5b2c5c0978ae954c2164f91e097b546d49be
5
5
  SHA512:
6
- metadata.gz: 741e900eb21ba3f4035c4670febadfb7311b443f20b1cc1ccc6f5782b57158eb4a6fc623525a91c727cf4eafb0ac0ec004388efc4896ee6765c1591693dbb05b
7
- data.tar.gz: '07567018fcd25a9448575f6cd2e164a9b4df7e0742c10e3d5c897d012765dfdf80ef5aed7a6b27b87f38f749ab94d2ce547e7345cd57381d1d59ae3dfc156560'
6
+ metadata.gz: 433d2c51fbe8d1e744def897f99a701a0eba27ebb1c59db2e0f55a21b1d73aba1642b63e97bfbbc4ebd79bf1d4a1a7c95ea10b45bafe8569ee38c8406588bf56
7
+ data.tar.gz: fa414e99b8f180c3279c0f292bae94cd379a656309abae06c4e318b53492116be775755f8ab0e3d2de8d51ccf9ca5a6ae39010b9b39b3d78f2550495ece2e633
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.48.0"
225
225
  source = "registry+https://github.com/rust-lang/crates.io-index"
226
- checksum = "dc151d9c800c4fadd542c949b84c1dae6d1b424315ebcfd154e611d4f7910a97"
226
+ checksum = "48bf2260aceee247c6c5639f5751dc635211895066d782d2a28fb87f2e0d5613"
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.39"
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.48.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.39"
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.48.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"]
@@ -1,104 +1,99 @@
1
1
  use std::fmt::{self, Write};
2
+ use std::sync::LazyLock;
2
3
 
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;
4
+ use comrak::html::{collect_text, format_node_default, render_sourcepos, ChildRendering, Context};
5
+ use comrak::nodes::{AstNode, ListType, NodeHeading, NodeLink, NodeList, NodeValue};
6
+ use comrak::{create_formatter, html, node_matches};
7
7
  use regex::Regex;
8
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
- }
9
+ use crate::glfm::RenderOptions;
10
+
11
+ static PLACEHOLDER_REGEX: LazyLock<Regex> =
12
+ LazyLock::new(|| Regex::new(r"%\{(?:\w{1,30})}").unwrap());
14
13
 
15
14
  pub struct RenderUserData {
16
15
  pub default_html: bool,
17
16
  pub inapplicable_tasks: bool,
18
17
  pub placeholder_detection: bool,
19
18
  pub only_escape_chars: Option<Vec<char>>,
20
- pub debug: bool,
19
+ pub header_accessibility: bool,
20
+
21
+ last_heading: Option<String>,
22
+ }
23
+
24
+ impl From<&RenderOptions> for RenderUserData {
25
+ fn from(options: &RenderOptions) -> Self {
26
+ RenderUserData {
27
+ default_html: options.default_html,
28
+ inapplicable_tasks: options.inapplicable_tasks,
29
+ placeholder_detection: options.placeholder_detection,
30
+ only_escape_chars: options.only_escape_chars.clone(),
31
+ header_accessibility: options.header_accessibility,
32
+ last_heading: None,
33
+ }
34
+ }
21
35
  }
22
36
 
23
37
  // The important thing to remember is that this overrides the default behavior of the
24
38
  // 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
39
+ // any changes in the Comrak code for those nodes is backported to here, such as when
26
40
  // `figcaption` support was added.
41
+ //
27
42
  // One idea to limit that would be having the ability to specify attributes that would
28
43
  // 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`.
44
+ // inject the changes we need. Such a feature would need to be added to Comrak.
30
45
  create_formatter!(CustomFormatter<RenderUserData>, {
31
- NodeValue::Text(_) => |context, node, entering| {
32
- return render_text(context, node, entering);
46
+ NodeValue::Text(ref literal) => |context, node, entering| {
47
+ return render_text(context, node, entering, literal);
33
48
  },
34
- NodeValue::Link(_) => |context, node, entering| {
35
- return render_link(context, node, entering);
49
+ NodeValue::Link(ref nl) => |context, node, entering| {
50
+ return render_link(context, node, entering, nl);
36
51
  },
37
- NodeValue::Image(_) => |context, node, entering| {
38
- return render_image(context, node, entering);
52
+ NodeValue::Image(ref nl) => |context, node, entering| {
53
+ return render_image(context, node, entering, nl);
39
54
  },
40
- NodeValue::List(_) => |context, node, entering| {
41
- return render_list(context, node, entering);
55
+ NodeValue::List(ref nl) => |context, node, entering| {
56
+ return render_list(context, node, entering, nl);
42
57
  },
43
- NodeValue::TaskItem(_) => |context, node, entering| {
44
- return render_task_item(context, node, entering);
58
+ NodeValue::TaskItem(symbol) => |context, node, entering| {
59
+ return render_task_item(context, node, entering, symbol);
45
60
  },
46
61
  NodeValue::Escaped => |context, node, entering| {
47
62
  return render_escaped(context, node, entering);
48
63
  },
64
+ NodeValue::Heading(ref nh) => |context, node, entering| {
65
+ return render_heading(context, node, entering, nh);
66
+ },
49
67
  });
50
68
 
51
- fn render_image<'a>(
69
+ fn render_text<'a>(
52
70
  context: &mut Context<RenderUserData>,
53
71
  node: &'a AstNode<'a>,
54
72
  entering: bool,
73
+ literal: &str,
55
74
  ) -> 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)) {
75
+ if !(context.user.placeholder_detection && PLACEHOLDER_REGEX.is_match(literal)) {
61
76
  return html::format_node_default(context, node, entering);
62
77
  }
63
78
 
64
79
  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)?;
80
+ let mut cursor: usize = 0;
81
+
82
+ for mat in PLACEHOLDER_REGEX.find_iter(literal) {
83
+ if mat.start() > cursor {
84
+ context.escape(&literal[cursor..mat.start()])?;
76
85
  }
77
- }
78
86
 
79
- context.write_str("\"")?;
87
+ context.write_str("<span data-placeholder>")?;
88
+ context.escape(&literal[mat.start()..mat.end()])?;
89
+ context.write_str("</span>")?;
80
90
 
81
- if PLACEHOLDER_REGEX.is_match(&nl.url) {
82
- context.write_str(" data-placeholder")?;
91
+ cursor = mat.end();
83
92
  }
84
93
 
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)?;
94
+ if cursor < literal.len() {
95
+ context.escape(&literal[cursor..])?;
92
96
  }
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
97
  }
103
98
 
104
99
  Ok(ChildRendering::HTML)
@@ -108,11 +103,8 @@ fn render_link<'a>(
108
103
  context: &mut Context<RenderUserData>,
109
104
  node: &'a AstNode<'a>,
110
105
  entering: bool,
106
+ nl: &NodeLink,
111
107
  ) -> Result<ChildRendering, fmt::Error> {
112
- let NodeValue::Link(ref nl) = node.data.borrow().value else {
113
- unreachable!()
114
- };
115
-
116
108
  if !(context.user.placeholder_detection && PLACEHOLDER_REGEX.is_match(&nl.url)) {
117
109
  return html::format_node_default(context, node, entering);
118
110
  }
@@ -144,9 +136,8 @@ fn render_link<'a>(
144
136
  context.escape(&nl.title)?;
145
137
  }
146
138
 
147
- if PLACEHOLDER_REGEX.is_match(&nl.url) {
148
- context.write_str(" data-placeholder")?;
149
- }
139
+ // This path only taken if placeholder detection is enabled, and the regex matched.
140
+ context.write_str(" data-placeholder")?;
150
141
 
151
142
  context.write_str(">")?;
152
143
  } else {
@@ -157,21 +148,70 @@ fn render_link<'a>(
157
148
  Ok(ChildRendering::HTML)
158
149
  }
159
150
 
151
+ fn render_image<'a>(
152
+ context: &mut Context<RenderUserData>,
153
+ node: &'a AstNode<'a>,
154
+ entering: bool,
155
+ nl: &NodeLink,
156
+ ) -> Result<ChildRendering, fmt::Error> {
157
+ if !(context.user.placeholder_detection && PLACEHOLDER_REGEX.is_match(&nl.url)) {
158
+ return html::format_node_default(context, node, entering);
159
+ }
160
+
161
+ if entering {
162
+ if context.options.render.figure_with_caption {
163
+ context.write_str("<figure>")?;
164
+ }
165
+ context.write_str("<img")?;
166
+ html::render_sourcepos(context, node)?;
167
+ context.write_str(" src=\"")?;
168
+ if context.options.render.r#unsafe || !html::dangerous_url(&nl.url) {
169
+ if let Some(rewriter) = &context.options.extension.image_url_rewriter {
170
+ context.escape_href(&rewriter.to_html(&nl.url))?;
171
+ } else {
172
+ context.escape_href(&nl.url)?;
173
+ }
174
+ }
175
+
176
+ context.write_str("\"")?;
177
+
178
+ // This path only taken if placeholder detection is enabled, and the regex matched.
179
+ context.write_str(" data-placeholder")?;
180
+
181
+ context.write_str(" alt=\"")?;
182
+
183
+ return Ok(ChildRendering::Plain);
184
+ } else {
185
+ if !nl.title.is_empty() {
186
+ context.write_str("\" title=\"")?;
187
+ context.escape(&nl.title)?;
188
+ }
189
+ context.write_str("\" />")?;
190
+ if context.options.render.figure_with_caption {
191
+ if !nl.title.is_empty() {
192
+ context.write_str("<figcaption>")?;
193
+ context.escape(&nl.title)?;
194
+ context.write_str("</figcaption>")?;
195
+ }
196
+ context.write_str("</figure>")?;
197
+ };
198
+ }
199
+
200
+ Ok(ChildRendering::HTML)
201
+ }
202
+
160
203
  // Overridden to use class `task-list` instead of `contains-task-list`
161
204
  // to align with GitLab class usage
162
205
  fn render_list<'a>(
163
206
  context: &mut Context<RenderUserData>,
164
207
  node: &'a AstNode<'a>,
165
208
  entering: bool,
209
+ nl: &NodeList,
166
210
  ) -> Result<ChildRendering, fmt::Error> {
167
211
  if !entering || !context.options.render.tasklist_classes {
168
212
  return html::format_node_default(context, node, entering);
169
213
  }
170
214
 
171
- let NodeValue::List(ref nl) = node.data.borrow().value else {
172
- unreachable!()
173
- };
174
-
175
215
  context.cr()?;
176
216
  match nl.list_type {
177
217
  ListType::Bullet => {
@@ -204,143 +244,172 @@ fn render_task_item<'a>(
204
244
  context: &mut Context<RenderUserData>,
205
245
  node: &'a AstNode<'a>,
206
246
  entering: bool,
247
+ symbol: Option<char>,
207
248
  ) -> Result<ChildRendering, fmt::Error> {
208
249
  if !context.user.inapplicable_tasks {
209
250
  return html::format_node_default(context, node, entering);
210
251
  }
211
252
 
212
- let NodeValue::TaskItem(symbol) = node.data.borrow().value else {
213
- unreachable!()
253
+ let Some(symbol) = symbol else {
254
+ return html::format_node_default(context, node, entering);
214
255
  };
215
256
 
216
- if symbol.is_none() || matches!(symbol, Some('x' | 'X')) {
257
+ if symbol == 'x' || symbol == 'X' {
217
258
  return html::format_node_default(context, node, entering);
218
259
  }
219
260
 
261
+ // We only proceed past this point if:
262
+ //
263
+ // * inapplicable_tasks is enabled; and,
264
+ // * the symbol is present (the tasklist didn't contain a ' '), and isn't 'x' or 'X'.
265
+ //
266
+ // There are three possibilities remaining:
267
+ //
268
+ // * the symbol is '~': we write out an inapplicable task item.
269
+ // * the symbol is a different Unicode whitespace: we write out an incomplete task item,
270
+ // per Comrak.
271
+ // * the symbol is something else: we write out the source Markdown that would've been entered,
272
+ // to act like a non-match.
273
+ //
274
+ // TODO: have Comrak accept a list of acceptable tasklist symbols instead. :)
275
+
276
+ let write_li = node
277
+ .parent()
278
+ .map(|p| node_matches!(p, NodeValue::List(_)))
279
+ .unwrap_or_default();
280
+
220
281
  if entering {
221
- // Handle an inapplicable task symbol.
222
- if matches!(symbol, Some('~')) {
282
+ if symbol == '~' {
223
283
  context.cr()?;
224
- context.write_str("<li")?;
225
- context.write_str(" class=\"inapplicable")?;
284
+ if write_li {
285
+ context.write_str("<li")?;
286
+ context.write_str(" class=\"inapplicable")?;
226
287
 
227
- if context.options.render.tasklist_classes {
228
- context.write_str(" task-list-item")?;
288
+ if context.options.render.tasklist_classes {
289
+ context.write_str(" task-list-item")?;
290
+ }
291
+ context.write_str("\"")?;
292
+ html::render_sourcepos(context, node)?;
293
+ context.write_str(">")?;
229
294
  }
230
- context.write_str("\"")?;
231
-
232
- html::render_sourcepos(context, node)?;
233
- context.write_str(">")?;
234
295
  context.write_str("<input type=\"checkbox\"")?;
235
-
296
+ if !write_li {
297
+ html::render_sourcepos(context, node)?;
298
+ }
236
299
  if context.options.render.tasklist_classes {
237
300
  context.write_str(" class=\"task-list-item-checkbox\"")?;
238
301
  }
239
302
 
240
303
  context.write_str(" data-inapplicable disabled=\"\"> ")?;
241
- } else {
242
- // Don't allow unsupported symbols to render a checkbox
304
+ } else if symbol.is_whitespace() {
243
305
  context.cr()?;
244
- context.write_str("<li")?;
245
-
306
+ if write_li {
307
+ context.write_str("<li")?;
308
+ if context.options.render.tasklist_classes {
309
+ context.write_str(" class=\"task-list-item\"")?;
310
+ }
311
+ render_sourcepos(context, node)?;
312
+ context.write_str(">")?;
313
+ }
314
+ context.write_str("<input type=\"checkbox\"")?;
315
+ if !write_li {
316
+ render_sourcepos(context, node)?;
317
+ }
246
318
  if context.options.render.tasklist_classes {
247
- context.write_str(" class=\"task-list-item\"")?;
319
+ context.write_str(" class=\"task-list-item-checkbox\"")?;
320
+ }
321
+ context.write_str(" disabled=\"\" /> ")?;
322
+ } else {
323
+ context.cr()?;
324
+ if write_li {
325
+ context.write_str("<li")?;
326
+ if context.options.render.tasklist_classes {
327
+ context.write_str(" class=\"task-list-item\"")?;
328
+ }
329
+ html::render_sourcepos(context, node)?;
330
+ context.write_str(">")?;
248
331
  }
249
-
250
- html::render_sourcepos(context, node)?;
251
- context.write_str(">")?;
252
332
  context.write_str("[")?;
253
- context.escape(&symbol.unwrap().to_string())?;
333
+ context.escape(&symbol.to_string())?;
254
334
  context.write_str("] ")?;
255
335
  }
256
- } else {
336
+ } else if write_li {
257
337
  context.write_str("</li>\n")?;
258
338
  }
259
339
 
260
340
  Ok(ChildRendering::HTML)
261
341
  }
262
342
 
263
- fn render_text<'a>(
343
+ fn render_escaped<'a>(
264
344
  context: &mut Context<RenderUserData>,
265
345
  node: &'a AstNode<'a>,
266
346
  entering: bool,
267
347
  ) -> 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);
348
+ if !context.options.render.escaped_char_spans {
349
+ return Ok(ChildRendering::HTML);
287
350
  }
288
351
 
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()])?;
352
+ if context
353
+ .user
354
+ .only_escape_chars
355
+ .as_ref()
356
+ .map_or(true, |only_escape_chars| {
357
+ with_node_text_content(node, false, |content| {
358
+ content.chars().count() == 1
359
+ && only_escape_chars.contains(&content.chars().next().unwrap())
360
+ })
361
+ })
362
+ {
363
+ if entering {
364
+ context.write_str("<span data-escaped-char")?;
365
+ render_sourcepos(context, node)?;
366
+ context.write_str(">")?;
367
+ } else {
299
368
  context.write_str("</span>")?;
300
-
301
- cursor = mat.end();
302
- }
303
-
304
- if cursor < literal.len() {
305
- context.escape(&literal[cursor..literal.len()])?;
306
369
  }
307
370
  }
308
371
 
309
372
  Ok(ChildRendering::HTML)
310
373
  }
311
374
 
312
- fn render_escaped<'a>(
375
+ fn render_heading<'a>(
313
376
  context: &mut Context<RenderUserData>,
314
377
  node: &'a AstNode<'a>,
315
378
  entering: bool,
379
+ nh: &NodeHeading,
316
380
  ) -> Result<ChildRendering, fmt::Error> {
317
- if !context.options.render.escaped_char_spans {
318
- return Ok(ChildRendering::HTML);
381
+ if !context.user.header_accessibility || context.plugins.render.heading_adapter.is_some() {
382
+ return format_node_default(context, node, entering);
319
383
  }
320
384
 
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>")?;
385
+ if entering {
386
+ context.cr()?;
387
+ write!(context, "<h{}", nh.level)?;
388
+
389
+ if let Some(ref prefix) = context.options.extension.header_ids {
390
+ let text_content = collect_text(node);
391
+ let id = context.anchorizer.anchorize(&text_content);
392
+ write!(context, r##" id="{prefix}{id}""##)?;
393
+ context.user.last_heading = Some(id);
341
394
  }
342
- }
343
395
 
396
+ render_sourcepos(context, node)?;
397
+ context.write_str(">")?;
398
+ } else {
399
+ if context.options.extension.header_ids.is_some() {
400
+ let id = context.user.last_heading.take().unwrap();
401
+ let text_content = collect_text(node);
402
+ write!(
403
+ context,
404
+ r##"<a href="#{id}" aria-label="Link to heading '"##
405
+ )?;
406
+ context.escape(&text_content)?;
407
+ context.write_str(r#"'" data-heading-content=""#)?;
408
+ context.escape(&text_content)?;
409
+ context.write_str(r#"" class="anchor"></a>"#)?;
410
+ }
411
+ writeln!(context, "</h{}>", nh.level)?;
412
+ }
344
413
  Ok(ChildRendering::HTML)
345
414
  }
346
415
 
@@ -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.39'
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.39
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-11-14 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