markly 0.15.3 → 0.17.0

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.
Files changed (55) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/context/getting-started.md +36 -0
  4. data/context/index.yaml +3 -3
  5. data/ext/markly/blocks.c +24 -6
  6. data/ext/markly/cmark-gfm.h +24 -0
  7. data/ext/markly/cmark-gfm_export.h +1 -0
  8. data/ext/markly/cmark-gfm_version.h +2 -2
  9. data/ext/markly/cmark.c +1 -1
  10. data/ext/markly/cmark_ctype.c +9 -0
  11. data/ext/markly/cmark_ctype.h +5 -0
  12. data/ext/markly/commonmark.c +21 -1
  13. data/ext/markly/config.h +0 -4
  14. data/ext/markly/extconf.rb +6 -1
  15. data/ext/markly/{table.c → extensions/table.c} +91 -46
  16. data/ext/markly/front_matter.c +141 -0
  17. data/ext/markly/front_matter.h +24 -0
  18. data/ext/markly/html.c +47 -23
  19. data/ext/markly/inlines.c +85 -3
  20. data/ext/markly/iterator.c +6 -1
  21. data/ext/markly/latex.c +3 -0
  22. data/ext/markly/man.c +3 -0
  23. data/ext/markly/markly.c +288 -201
  24. data/ext/markly/node.c +65 -5
  25. data/ext/markly/node.h +1 -0
  26. data/ext/markly/parser.h +19 -0
  27. data/ext/markly/plaintext.c +3 -0
  28. data/ext/markly/xml.c +19 -1
  29. data/lib/markly/flags.rb +10 -1
  30. data/lib/markly/node/inspect.rb +10 -3
  31. data/lib/markly/node.rb +53 -28
  32. data/lib/markly/renderer/generic.rb +42 -12
  33. data/lib/markly/renderer/headings.rb +24 -19
  34. data/lib/markly/renderer/html.rb +111 -10
  35. data/lib/markly/version.rb +4 -2
  36. data/lib/markly.rb +14 -13
  37. data/readme.md +22 -6
  38. data/releases.md +16 -0
  39. data.tar.gz.sig +0 -0
  40. metadata +23 -21
  41. metadata.gz.sig +0 -0
  42. /data/ext/markly/{autolink.c → extensions/autolink.c} +0 -0
  43. /data/ext/markly/{autolink.h → extensions/autolink.h} +0 -0
  44. /data/ext/markly/{cmark-gfm-core-extensions.h → extensions/cmark-gfm-core-extensions.h} +0 -0
  45. /data/ext/markly/{cmark-gfm-extensions_export.h → extensions/cmark-gfm-extensions_export.h} +0 -0
  46. /data/ext/markly/{core-extensions.c → extensions/core-extensions.c} +0 -0
  47. /data/ext/markly/{ext_scanners.c → extensions/ext_scanners.c} +0 -0
  48. /data/ext/markly/{ext_scanners.h → extensions/ext_scanners.h} +0 -0
  49. /data/ext/markly/{strikethrough.c → extensions/strikethrough.c} +0 -0
  50. /data/ext/markly/{strikethrough.h → extensions/strikethrough.h} +0 -0
  51. /data/ext/markly/{table.h → extensions/table.h} +0 -0
  52. /data/ext/markly/{tagfilter.c → extensions/tagfilter.c} +0 -0
  53. /data/ext/markly/{tagfilter.h → extensions/tagfilter.h} +0 -0
  54. /data/ext/markly/{tasklist.c → extensions/tasklist.c} +0 -0
  55. /data/ext/markly/{tasklist.h → extensions/tasklist.h} +0 -0
data/ext/markly/node.c CHANGED
@@ -148,12 +148,13 @@ cmark_node *cmark_node_new(cmark_node_type type) {
148
148
  static void free_node_as(cmark_node *node) {
149
149
  switch (node->type) {
150
150
  case CMARK_NODE_CODE_BLOCK:
151
+ case CMARK_NODE_FRONT_MATTER:
152
+ case CMARK_NODE_CODE:
151
153
  cmark_chunk_free(NODE_MEM(node), &node->as.code.info);
152
154
  cmark_chunk_free(NODE_MEM(node), &node->as.code.literal);
153
155
  break;
154
156
  case CMARK_NODE_TEXT:
155
157
  case CMARK_NODE_HTML_INLINE:
156
- case CMARK_NODE_CODE:
157
158
  case CMARK_NODE_HTML_BLOCK:
158
159
  case CMARK_NODE_FOOTNOTE_REFERENCE:
159
160
  case CMARK_NODE_FOOTNOTE_DEFINITION:
@@ -288,6 +289,8 @@ const char *cmark_node_get_type_string(cmark_node *node) {
288
289
  return "link";
289
290
  case CMARK_NODE_IMAGE:
290
291
  return "image";
292
+ case CMARK_NODE_FRONT_MATTER:
293
+ return "front_matter";
291
294
  }
292
295
 
293
296
  return "<unknown>";
@@ -375,12 +378,13 @@ const char *cmark_node_get_literal(cmark_node *node) {
375
378
  case CMARK_NODE_HTML_BLOCK:
376
379
  case CMARK_NODE_TEXT:
377
380
  case CMARK_NODE_HTML_INLINE:
378
- case CMARK_NODE_CODE:
379
381
  case CMARK_NODE_FOOTNOTE_REFERENCE:
380
382
  case CMARK_NODE_FOOTNOTE_DEFINITION:
381
383
  return cmark_chunk_to_cstr(NODE_MEM(node), &node->as.literal);
382
384
 
385
+ case CMARK_NODE_CODE:
383
386
  case CMARK_NODE_CODE_BLOCK:
387
+ case CMARK_NODE_FRONT_MATTER:
384
388
  return cmark_chunk_to_cstr(NODE_MEM(node), &node->as.code.literal);
385
389
 
386
390
  default:
@@ -399,12 +403,13 @@ int cmark_node_set_literal(cmark_node *node, const char *content) {
399
403
  case CMARK_NODE_HTML_BLOCK:
400
404
  case CMARK_NODE_TEXT:
401
405
  case CMARK_NODE_HTML_INLINE:
402
- case CMARK_NODE_CODE:
403
406
  case CMARK_NODE_FOOTNOTE_REFERENCE:
404
407
  cmark_chunk_set_cstr(NODE_MEM(node), &node->as.literal, content);
405
408
  return 1;
406
409
 
410
+ case CMARK_NODE_CODE:
407
411
  case CMARK_NODE_CODE_BLOCK:
412
+ case CMARK_NODE_FRONT_MATTER:
408
413
  cmark_chunk_set_cstr(NODE_MEM(node), &node->as.code.literal, content);
409
414
  return 1;
410
415
 
@@ -590,12 +595,66 @@ int cmark_node_set_item_index(cmark_node *node, int idx) {
590
595
  }
591
596
  }
592
597
 
598
+ const char *cmark_node_get_code_info(cmark_node *node) {
599
+ if (node == NULL) {
600
+ return NULL;
601
+ }
602
+
603
+ if (node->type == CMARK_NODE_CODE ||
604
+ node->type == CMARK_NODE_CODE_BLOCK ||
605
+ node->type == CMARK_NODE_FRONT_MATTER) {
606
+ return cmark_chunk_to_cstr(NODE_MEM(node), &node->as.code.info);
607
+ } else {
608
+ return NULL;
609
+ }
610
+ }
611
+
612
+ static int valid_inline_code_info(const char *info) {
613
+ if (info == NULL || *info == '\0') {
614
+ return 1;
615
+ }
616
+
617
+ if (!cmark_is_inline_code_info_start_char(*info)) {
618
+ return 0;
619
+ }
620
+
621
+ while (*++info) {
622
+ if (!cmark_is_inline_code_info_char(*info)) {
623
+ return 0;
624
+ }
625
+ }
626
+
627
+ return 1;
628
+ }
629
+
630
+ int cmark_node_set_code_info(cmark_node *node, const char *info) {
631
+ if (node == NULL) {
632
+ return 0;
633
+ }
634
+
635
+ if (node->type == CMARK_NODE_CODE) {
636
+ if (!valid_inline_code_info(info)) {
637
+ return 0;
638
+ }
639
+
640
+ cmark_chunk_set_cstr(NODE_MEM(node), &node->as.code.info, info);
641
+ return 1;
642
+ } else if (node->type == CMARK_NODE_CODE_BLOCK ||
643
+ node->type == CMARK_NODE_FRONT_MATTER) {
644
+ cmark_chunk_set_cstr(NODE_MEM(node), &node->as.code.info, info);
645
+ return 1;
646
+ } else {
647
+ return 0;
648
+ }
649
+ }
650
+
593
651
  const char *cmark_node_get_fence_info(cmark_node *node) {
594
652
  if (node == NULL) {
595
653
  return NULL;
596
654
  }
597
655
 
598
- if (node->type == CMARK_NODE_CODE_BLOCK) {
656
+ if (node->type == CMARK_NODE_CODE_BLOCK ||
657
+ node->type == CMARK_NODE_FRONT_MATTER) {
599
658
  return cmark_chunk_to_cstr(NODE_MEM(node), &node->as.code.info);
600
659
  } else {
601
660
  return NULL;
@@ -607,7 +666,8 @@ int cmark_node_set_fence_info(cmark_node *node, const char *info) {
607
666
  return 0;
608
667
  }
609
668
 
610
- if (node->type == CMARK_NODE_CODE_BLOCK) {
669
+ if (node->type == CMARK_NODE_CODE_BLOCK ||
670
+ node->type == CMARK_NODE_FRONT_MATTER) {
611
671
  cmark_chunk_set_cstr(NODE_MEM(node), &node->as.code.info, info);
612
672
  return 1;
613
673
  } else {
data/ext/markly/node.h CHANGED
@@ -105,6 +105,7 @@ struct cmark_node {
105
105
  cmark_link link;
106
106
  cmark_custom custom;
107
107
  int html_block_type;
108
+ int cell_index; // For keeping track of TABLE_CELL table alignments
108
109
  void *opaque;
109
110
  } as;
110
111
  };
data/ext/markly/parser.h CHANGED
@@ -50,6 +50,25 @@ struct cmark_parser {
50
50
  cmark_llist *syntax_extensions;
51
51
  cmark_llist *inline_syntax_extensions;
52
52
  cmark_ispunct_func backslash_ispunct;
53
+
54
+ /* Front matter scanning state (CMARK_OPT_FRONT_MATTER).
55
+ *
56
+ * cmark_front_matter_process_line() is called from S_process_line() in
57
+ * blocks.c immediately after parser->line_number is incremented, so the
58
+ * first line of the document arrives with line_number == 1. The function
59
+ * relies on this: it uses line_number == 1 as the trigger to decide
60
+ * whether the document opens with a front matter block.
61
+ *
62
+ * front_matter_scanning is set to true when a valid opening "---" is seen
63
+ * on line 1 and remains true until the matching closing "---" is found or
64
+ * the document ends. While scanning, each content line is accumulated in
65
+ * front_matter_buf. Both fields are reset to zero/empty by
66
+ * cmark_parser_reset() (via memset + strbuf re-init) and the strbuf is
67
+ * freed explicitly in cmark_parser_finish() and cmark_parser_free().
68
+ */
69
+ bool front_matter_scanning;
70
+ cmark_strbuf front_matter_buf; /* accumulated content lines */
71
+ cmark_strbuf front_matter_info; /* optional format hint from opening "--- <info>" */
53
72
  };
54
73
 
55
74
  #ifdef __cplusplus
@@ -196,6 +196,9 @@ static int S_render_node(cmark_renderer *renderer, cmark_node *node,
196
196
  cmark_strbuf_truncate(renderer->prefix, renderer->prefix->size - 4);
197
197
  }
198
198
  break;
199
+ case CMARK_NODE_FRONT_MATTER:
200
+ break;
201
+
199
202
  default:
200
203
  assert(false);
201
204
  break;
data/ext/markly/xml.c CHANGED
@@ -64,8 +64,14 @@ static int S_render_node(cmark_node *node, cmark_event_type ev_type,
64
64
  case CMARK_NODE_DOCUMENT:
65
65
  cmark_strbuf_puts(xml, " xmlns=\"http://commonmark.org/xml/1.0\"");
66
66
  break;
67
+ case CMARK_NODE_FRONT_MATTER:
68
+ cmark_strbuf_puts(xml, " xml:space=\"preserve\">");
69
+ escape_xml(xml, node->as.code.literal.data, node->as.code.literal.len);
70
+ cmark_strbuf_puts(xml, "</");
71
+ cmark_strbuf_puts(xml, cmark_node_get_type_string(node));
72
+ literal = true;
73
+ break;
67
74
  case CMARK_NODE_TEXT:
68
- case CMARK_NODE_CODE:
69
75
  case CMARK_NODE_HTML_BLOCK:
70
76
  case CMARK_NODE_HTML_INLINE:
71
77
  cmark_strbuf_puts(xml, " xml:space=\"preserve\">");
@@ -74,6 +80,18 @@ static int S_render_node(cmark_node *node, cmark_event_type ev_type,
74
80
  cmark_strbuf_puts(xml, cmark_node_get_type_string(node));
75
81
  literal = true;
76
82
  break;
83
+ case CMARK_NODE_CODE:
84
+ if (node->as.code.info.len > 0) {
85
+ cmark_strbuf_puts(xml, " info=\"");
86
+ escape_xml(xml, node->as.code.info.data, node->as.code.info.len);
87
+ cmark_strbuf_putc(xml, '"');
88
+ }
89
+ cmark_strbuf_puts(xml, " xml:space=\"preserve\">");
90
+ escape_xml(xml, node->as.code.literal.data, node->as.code.literal.len);
91
+ cmark_strbuf_puts(xml, "</");
92
+ cmark_strbuf_puts(xml, cmark_node_get_type_string(node));
93
+ literal = true;
94
+ break;
77
95
  case CMARK_NODE_LIST:
78
96
  switch (cmark_node_get_list_type(node)) {
79
97
  case CMARK_ORDERED_LIST:
data/lib/markly/flags.rb CHANGED
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2020-2023, by Samuel Williams.
4
+ # Copyright, 2020-2026, by Samuel Williams.
5
5
 
6
6
  module Markly
7
7
  # The default parsing system.
@@ -18,8 +18,17 @@ module Markly
18
18
  STRIKETHROUGH_DOUBLE_TILDE = 1 << 14
19
19
  # Allow raw/custom HTML and unsafe links.
20
20
  UNSAFE = 1 << 17
21
+ # Parse front matter ("---" delimited block at start of document).
22
+ # The raw content is available via node.string_content and the optional
23
+ # format hint (e.g. "yaml", "toml") via node.code_info; interpretation
24
+ # is left to the caller.
25
+ FRONT_MATTER = 1 << 18
26
+ # Parse language prefixes on inline code spans, e.g. ruby:`Object.new`.
27
+ INLINE_CODE_INFO = 1 << 19
21
28
 
22
29
  PARSE_FLAGS = {
30
+ front_matter: FRONT_MATTER,
31
+ inline_code_info: INLINE_CODE_INFO,
23
32
  validate_utf8: VALIDATE_UTF8,
24
33
  smart_quotes: SMART,
25
34
  liberal_html_tags: LIBERAL_HTML_TAG,
@@ -4,20 +4,27 @@
4
4
  # Copyright, 2017, by Goro Fuji.
5
5
  # Copyright, 2017-2019, by Garen Torikian.
6
6
  # Copyright, 2020, by Olle Jonsson.
7
- # Copyright, 2020-2025, by Samuel Williams.
7
+ # Copyright, 2020-2026, by Samuel Williams.
8
8
 
9
9
  require "pp"
10
10
 
11
11
  module Markly
12
12
  class Node
13
+ # Provides concise pretty-printing for Markdown nodes.
13
14
  module Inspect
15
+ # @constant [Integer] The indentation width used for nested node attributes.
14
16
  PP_INDENT_SIZE = 2
15
17
 
18
+ # Returns a pretty-printed representation of the node.
19
+ #
20
+ # @returns [String] The formatted node representation.
16
21
  def inspect
17
22
  PP.pp(self, +"", Float::INFINITY)
18
23
  end
19
24
 
20
- # @param printer [PrettyPrint] pp
25
+ # Pretty-print this node and its children.
26
+ #
27
+ # @parameter printer [PrettyPrint] The pretty-print formatter.
21
28
  def pretty_print(printer)
22
29
  printer.group(PP_INDENT_SIZE, "#<#{self.class}(#{type}):", ">") do
23
30
  printer.breakable
@@ -31,7 +38,7 @@ module Markly
31
38
  list_type
32
39
  list_start
33
40
  list_tight
34
- fence_info
41
+ code_info
35
42
  ].map do |name|
36
43
  begin
37
44
  [name, __send__(name)]
data/lib/markly/node.rb CHANGED
@@ -5,17 +5,20 @@
5
5
  # Copyright, 2016-2017, by Yuki Izumi.
6
6
  # Copyright, 2017, by Goro Fuji.
7
7
  # Copyright, 2018, by Jerry van Leeuwen.
8
- # Copyright, 2020-2025, by Samuel Williams.
8
+ # Copyright, 2020-2026, by Samuel Williams.
9
9
  # Copyright, 2025, by Olle Jonsson.
10
10
 
11
11
  require_relative "node/inspect"
12
12
 
13
13
  module Markly
14
+ # Represents a node in a parsed Markdown document tree.
14
15
  class Node
15
16
  include Enumerable
16
17
  include Inspect
17
18
 
18
- # Duplicate the current node and all children.
19
+ # Duplicate the current node and all its children.
20
+ #
21
+ # @returns [Markly::Node] The duplicated node tree.
19
22
  def dup
20
23
  # This is a bit crazy, but it's the best I can come up with right now:
21
24
  node = Markly.parse(self.to_markdown)
@@ -28,9 +31,11 @@ module Markly
28
31
  end
29
32
  end
30
33
 
31
- # Public: An iterator that "walks the tree," descending into children recursively.
34
+ # Walk the node tree recursively.
32
35
  #
33
- # block - A {Proc} representing the action to take for each child
36
+ # @yields {|node| ...} Each node in depth-first order, including this node.
37
+ # @parameter node [Markly::Node] The current node.
38
+ # @returns [Enumerator | Nil] An enumerator when no block is given.
34
39
  def walk(&block)
35
40
  return enum_for(:walk) unless block_given?
36
41
 
@@ -40,39 +45,47 @@ module Markly
40
45
  end
41
46
  end
42
47
 
43
- # Public: Convert the node to an HTML string.
44
- #
45
- # flags - A {Symbol} or {Array of Symbol}s indicating the render options
46
- # extensions - An {Array of Symbol}s indicating the extensions to use
48
+ # Convert the node to an HTML string.
47
49
  #
48
- # Returns a {String}.
50
+ # @parameter flags [Integer] The enabled rendering flags.
51
+ # @parameter extensions [Array(Symbol)] The extensions to enable.
52
+ # @returns [String] The rendered HTML.
49
53
  def to_html(flags: DEFAULT, extensions: [])
50
54
  _render_html(flags, extensions).force_encoding("utf-8")
51
55
  end
52
56
 
53
- # Public: Convert the node to a CommonMark string.
54
- #
55
- # flags - A {Symbol} or {Array of Symbol}s indicating the render options
56
- # width - Column to wrap the output at
57
+ # Convert the node to a CommonMark string.
57
58
  #
58
- # Returns a {String}.
59
+ # @parameter flags [Integer] The enabled rendering flags.
60
+ # @parameter width [Integer] The column at which to wrap output, or `0` to disable wrapping.
61
+ # @returns [String] The rendered CommonMark text.
59
62
  def to_commonmark(flags: DEFAULT, width: 0)
60
63
  _render_commonmark(flags, width).force_encoding("utf-8")
61
64
  end
62
65
 
63
66
  alias to_markdown to_commonmark
64
67
 
65
- # Public: Convert the node to a plain text string.
68
+ # Return the language identifier from the code info string.
66
69
  #
67
- # flags - A {Symbol} or {Array of Symbol}s indicating the render options
68
- # width - Column to wrap the output at
70
+ # @returns [String | Nil] The language identifier, or `nil` when none is present.
71
+ def code_language
72
+ code_info.split(/\s+/, 2).first
73
+ end
74
+
75
+ # Convert the node to a plain-text string.
69
76
  #
70
- # Returns a {String}.
77
+ # @parameter flags [Integer] The enabled rendering flags.
78
+ # @parameter width [Integer] The column at which to wrap output, or `0` to disable wrapping.
79
+ # @returns [String] The rendered plain text.
71
80
  def to_plaintext(flags: DEFAULT, width: 0)
72
81
  _render_plaintext(flags, width).force_encoding("utf-8")
73
82
  end
74
83
 
75
- # Public: Iterate over the children (if any) of the current pointer.
84
+ # Iterate over the direct children of this node.
85
+ #
86
+ # @yields {|child| ...} Each direct child of this node.
87
+ # @parameter child [Markly::Node] The current child node.
88
+ # @returns [Enumerator | Nil] An enumerator when no block is given.
76
89
  def each
77
90
  return enum_for(:each) unless block_given?
78
91
 
@@ -84,6 +97,10 @@ module Markly
84
97
  end
85
98
  end
86
99
 
100
+ # Finds a direct child header with the given text.
101
+ #
102
+ # @parameter title [String] The header text to match.
103
+ # @returns [Markly::Node | Nil] The matching header, if present.
87
104
  def find_header(title)
88
105
  each do |child|
89
106
  if child.type == :header && child.first_child.string_content == title
@@ -94,7 +111,9 @@ module Markly
94
111
 
95
112
  # Delete all nodes until the block returns true.
96
113
  #
97
- # @returns [Markly::Node] the node that returned true.
114
+ # @yields {|node| ...} Each node before it is deleted.
115
+ # @parameter node [Markly::Node] The current node.
116
+ # @returns [Markly::Node | Nil] The node for which the block returned `true`, if any.
98
117
  def delete_until
99
118
  current = self
100
119
  while current
@@ -107,9 +126,9 @@ module Markly
107
126
 
108
127
  # Replace a section (header + content) with a new node.
109
128
  #
110
- # @parameter new_node [Markly::Node] the node to replace the section with.
111
- # @parameter replace_header [Boolean] whether to replace the header itself or not.
112
- # @parameter remove_subsections [Boolean] whether to remove subsections or not.
129
+ # @parameter new_node [Markly::Node | Nil] The node with which to replace the section.
130
+ # @parameter replace_header [Boolean] Whether to replace the header itself.
131
+ # @parameter remove_subsections [Boolean] Whether to remove subsections.
113
132
  def replace_section(new_node, replace_header: true, remove_subsections: true)
114
133
  # Delete until the next heading:
115
134
  self.next&.delete_until do |node|
@@ -120,21 +139,27 @@ module Markly
120
139
  self.delete if replace_header
121
140
  end
122
141
 
123
- def next_heading
142
+ # Finds the next sibling header.
143
+ #
144
+ # @returns [Markly::Node | Nil] The next header, if present.
145
+ def next_header
124
146
  current = self.next
125
147
  while current
126
- if current.type == :heading
148
+ if current.type == :header
127
149
  return current
128
150
  end
129
151
  current = current.next
130
152
  end
131
153
  end
132
154
 
155
+ # An alias for {ruby Markly::Node#next_header}.
156
+ alias next_heading next_header
157
+
133
158
  # Append the given node after the current node.
134
159
  #
135
160
  # It's okay to provide a document node, its children will be appended.
136
161
  #
137
- # @parameter node [Markly::Node] the node to append.
162
+ # @parameter node [Markly::Node] The node to append.
138
163
  def append_after(node)
139
164
  if node.type == :document
140
165
  node = node.first_child
@@ -153,7 +178,7 @@ module Markly
153
178
  #
154
179
  # It's okay to provide a document node, its children will be appended.
155
180
  #
156
- # @parameter node [Markly::Node] the node to append.
181
+ # @parameter node [Markly::Node] The node to append.
157
182
  def append_before(node)
158
183
  if node.type == :document
159
184
  node = node.first_child
@@ -169,7 +194,7 @@ module Markly
169
194
 
170
195
  # Extract the children as a fragment.
171
196
  #
172
- # @returns [Markly::Node] the fragment.
197
+ # @returns [Markly::Node] The fragment.
173
198
  def extract_children
174
199
  fragment = Markly::Node.new(:custom_inline)
175
200
 
@@ -3,26 +3,37 @@
3
3
  # Released under the MIT License.
4
4
  # Copyright, 2015-2019, by Garen Torikian.
5
5
  # Copyright, 2016-2017, by Yuki Izumi.
6
- # Copyright, 2020-2025, by Samuel Williams.
6
+ # Copyright, 2020-2026, by Samuel Williams.
7
7
 
8
8
  require "set"
9
9
  require "stringio"
10
10
 
11
11
  module Markly
12
+ # @namespace
12
13
  module Renderer
14
+ # Base class for renderers implemented in Ruby.
13
15
  class Generic
16
+ # Initializes a renderer with rendering flags and extensions.
17
+ #
18
+ # @parameter flags [Integer] The enabled rendering flags.
19
+ # @parameter extensions [Array(Symbol)] The enabled extensions.
14
20
  def initialize(flags: DEFAULT, extensions: [])
15
21
  @flags = flags
16
22
  @stream = StringIO.new(+"")
17
- @need_blocksep = false
18
23
  @in_tight = false
19
24
  @in_plain = false
20
25
  @tagfilter = extensions.include?(:tagfilter)
21
26
  end
22
27
 
28
+ # @attribute [Boolean] Whether the renderer is inside a tight container.
23
29
  attr_accessor :in_tight
30
+
31
+ # @attribute [Boolean] Whether the renderer is emitting plain text.
24
32
  attr_accessor :in_plain
25
33
 
34
+ # Writes strings, nodes, arrays of nodes, or child-node markers to the output.
35
+ #
36
+ # @parameter args [Array(Object)] Values to append or render.
26
37
  def out(*args)
27
38
  args.each do |arg|
28
39
  if arg == :children
@@ -37,6 +48,10 @@ module Markly
37
48
  end
38
49
  end
39
50
 
51
+ # Renders a node and returns the completed output for document nodes.
52
+ #
53
+ # @parameter node [Markly::Node] The node to render.
54
+ # @returns [String | Nil] The output string when rendering a document.
40
55
  def render(node)
41
56
  @node = node
42
57
  if node.type == :document
@@ -49,42 +64,57 @@ module Markly
49
64
  end
50
65
  end
51
66
 
67
+ # Renders a document node and all of its children.
68
+ #
69
+ # @parameter _node [Markly::Node] The document node.
52
70
  def document(_node)
53
71
  out(:children)
54
72
  end
55
73
 
56
- def code_block(node)
57
- code_block(node)
74
+ # Renders a code block node.
75
+ #
76
+ # Subclasses should override this callback.
77
+ # @parameter _node [Markly::Node] The code block node.
78
+ def code_block(_node)
79
+ raise NotImplementedError, "#{self.class} must implement #code_block"
58
80
  end
59
81
 
82
+ # Ignores reference-definition nodes, which have no direct output.
83
+ #
84
+ # @parameter _node [Markly::Node] The reference-definition node.
60
85
  def reference_def(_node); end
61
86
 
87
+ # Writes a newline unless the output is empty or already ends with one.
88
+ #
62
89
  def cr
63
90
  return if @stream.string.empty? || @stream.string[-1] == "\n"
64
91
 
65
92
  out("\n")
66
93
  end
67
94
 
68
- def blocksep
69
- out("\n")
70
- end
71
-
72
- def containersep
73
- cr unless @in_tight
74
- end
75
-
95
+ # Renders a block surrounded by normalized newlines.
96
+ #
97
+ # @yields {|| ...} The block content to render.
76
98
  def block
77
99
  cr
78
100
  yield
79
101
  cr
80
102
  end
81
103
 
104
+ # Renders content between opening and closing strings.
105
+ #
106
+ # @parameter starter [String] The opening output.
107
+ # @parameter ender [String] The closing output.
108
+ # @yields {|| ...} The container content to render.
82
109
  def container(starter, ender)
83
110
  out(starter)
84
111
  yield
85
112
  out(ender)
86
113
  end
87
114
 
115
+ # Renders a block in plain-text mode, suppressing structural markup.
116
+ #
117
+ # @yields {|| ...} The content to render as plain text.
88
118
  def plain
89
119
  old_in_plain = @in_plain
90
120
  @in_plain = true
@@ -1,20 +1,22 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2025, by Samuel Williams.
4
+ # Copyright, 2025-2026, by Samuel Williams.
5
5
 
6
6
  module Markly
7
7
  module Renderer
8
8
  # Extracts headings from a markdown document with unique anchor IDs.
9
9
  # Handles duplicate heading text by appending counters (e.g., "deployment", "deployment-2", "deployment-3").
10
10
  class Headings
11
+ # Initializes an empty heading-anchor registry.
11
12
  def initialize
12
13
  @ids = {}
13
14
  end
14
15
 
15
16
  # Generate a unique anchor for a node.
16
- # @parameter node [Markly::Node] The heading node
17
- # @returns [String] A unique anchor ID
17
+ #
18
+ # @parameter node [Markly::Node] The heading node.
19
+ # @returns [String] A unique anchor ID.
18
20
  def anchor_for(node)
19
21
  base = base_anchor_for(node)
20
22
 
@@ -28,10 +30,11 @@ module Markly
28
30
  end
29
31
 
30
32
  # Extract all headings from a document root with unique anchors.
31
- # @parameter root [Markly::Node] The document root node
32
- # @parameter min_level [Integer] Minimum heading level to extract (default: 1)
33
- # @parameter max_level [Integer] Maximum heading level to extract (default: 6)
34
- # @returns [Array<Heading>] Array of heading objects with unique anchors
33
+ #
34
+ # @parameter root [Markly::Node] The document root node.
35
+ # @parameter min_level [Integer] The minimum heading level to extract.
36
+ # @parameter max_level [Integer] The maximum heading level to extract.
37
+ # @returns [Array(Heading)] The extracted headings with unique anchors.
35
38
  def extract(root, min_level: 1, max_level: 6)
36
39
  headings = []
37
40
  root.walk do |node|
@@ -50,11 +53,12 @@ module Markly
50
53
  headings
51
54
  end
52
55
 
53
- # Class method for convenience - creates a new instance and extracts headings.
54
- # @parameter root [Markly::Node] The document root node
55
- # @parameter min_level [Integer] Minimum heading level to extract (default: 1)
56
- # @parameter max_level [Integer] Maximum heading level to extract (default: 6)
57
- # @returns [Array<Heading>] Array of heading objects with unique anchors
56
+ # Extract all headings using a new heading-anchor registry.
57
+ #
58
+ # @parameter root [Markly::Node] The document root node.
59
+ # @parameter min_level [Integer] The minimum heading level to extract.
60
+ # @parameter max_level [Integer] The maximum heading level to extract.
61
+ # @returns [Array(Heading)] The extracted headings with unique anchors.
58
62
  def self.extract(root, min_level: 1, max_level: 6)
59
63
  new.extract(root, min_level: min_level, max_level: max_level)
60
64
  end
@@ -62,8 +66,9 @@ module Markly
62
66
  private
63
67
 
64
68
  # Generate a base anchor from a node's text content.
65
- # @parameter node [Markly::Node] The heading node
66
- # @returns [String] The base anchor (lowercase, hyphenated)
69
+ #
70
+ # @parameter node [Markly::Node] The heading node.
71
+ # @returns [String] The lowercase, hyphenated base anchor.
67
72
  def base_anchor_for(node)
68
73
  text = node.to_plaintext.chomp.downcase
69
74
  text.gsub(/\s+/, "-")
@@ -71,11 +76,11 @@ module Markly
71
76
  end
72
77
 
73
78
  # Represents a heading extracted from a document.
74
- # @attribute node [Markly::Node] The original heading node
75
- # @attribute level [Integer] The heading level (1-6)
76
- # @attribute text [String] The plain text content of the heading
77
- # @attribute anchor [String] The unique anchor ID for this heading
79
+ #
80
+ # The structure contains the original `node`, its `level`, its plain-text
81
+ # `text`, and its unique `anchor`.
82
+ #
83
+ # @constant [Class] The heading data class.
78
84
  Heading = Struct.new(:node, :level, :text, :anchor, keyword_init: true)
79
85
  end
80
86
  end
81
-