markly 0.16.0 → 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.
data/ext/markly/node.c CHANGED
@@ -149,12 +149,12 @@ static void free_node_as(cmark_node *node) {
149
149
  switch (node->type) {
150
150
  case CMARK_NODE_CODE_BLOCK:
151
151
  case CMARK_NODE_FRONT_MATTER:
152
+ case CMARK_NODE_CODE:
152
153
  cmark_chunk_free(NODE_MEM(node), &node->as.code.info);
153
154
  cmark_chunk_free(NODE_MEM(node), &node->as.code.literal);
154
155
  break;
155
156
  case CMARK_NODE_TEXT:
156
157
  case CMARK_NODE_HTML_INLINE:
157
- case CMARK_NODE_CODE:
158
158
  case CMARK_NODE_HTML_BLOCK:
159
159
  case CMARK_NODE_FOOTNOTE_REFERENCE:
160
160
  case CMARK_NODE_FOOTNOTE_DEFINITION:
@@ -378,11 +378,11 @@ const char *cmark_node_get_literal(cmark_node *node) {
378
378
  case CMARK_NODE_HTML_BLOCK:
379
379
  case CMARK_NODE_TEXT:
380
380
  case CMARK_NODE_HTML_INLINE:
381
- case CMARK_NODE_CODE:
382
381
  case CMARK_NODE_FOOTNOTE_REFERENCE:
383
382
  case CMARK_NODE_FOOTNOTE_DEFINITION:
384
383
  return cmark_chunk_to_cstr(NODE_MEM(node), &node->as.literal);
385
384
 
385
+ case CMARK_NODE_CODE:
386
386
  case CMARK_NODE_CODE_BLOCK:
387
387
  case CMARK_NODE_FRONT_MATTER:
388
388
  return cmark_chunk_to_cstr(NODE_MEM(node), &node->as.code.literal);
@@ -403,11 +403,11 @@ int cmark_node_set_literal(cmark_node *node, const char *content) {
403
403
  case CMARK_NODE_HTML_BLOCK:
404
404
  case CMARK_NODE_TEXT:
405
405
  case CMARK_NODE_HTML_INLINE:
406
- case CMARK_NODE_CODE:
407
406
  case CMARK_NODE_FOOTNOTE_REFERENCE:
408
407
  cmark_chunk_set_cstr(NODE_MEM(node), &node->as.literal, content);
409
408
  return 1;
410
409
 
410
+ case CMARK_NODE_CODE:
411
411
  case CMARK_NODE_CODE_BLOCK:
412
412
  case CMARK_NODE_FRONT_MATTER:
413
413
  cmark_chunk_set_cstr(NODE_MEM(node), &node->as.code.literal, content);
@@ -595,6 +595,59 @@ int cmark_node_set_item_index(cmark_node *node, int idx) {
595
595
  }
596
596
  }
597
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
+
598
651
  const char *cmark_node_get_fence_info(cmark_node *node) {
599
652
  if (node == NULL) {
600
653
  return NULL;
data/ext/markly/xml.c CHANGED
@@ -72,7 +72,6 @@ static int S_render_node(cmark_node *node, cmark_event_type ev_type,
72
72
  literal = true;
73
73
  break;
74
74
  case CMARK_NODE_TEXT:
75
- case CMARK_NODE_CODE:
76
75
  case CMARK_NODE_HTML_BLOCK:
77
76
  case CMARK_NODE_HTML_INLINE:
78
77
  cmark_strbuf_puts(xml, " xml:space=\"preserve\">");
@@ -81,6 +80,18 @@ static int S_render_node(cmark_node *node, cmark_event_type ev_type,
81
80
  cmark_strbuf_puts(xml, cmark_node_get_type_string(node));
82
81
  literal = true;
83
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;
84
95
  case CMARK_NODE_LIST:
85
96
  switch (cmark_node_get_list_type(node)) {
86
97
  case CMARK_ORDERED_LIST:
data/lib/markly/flags.rb CHANGED
@@ -20,12 +20,15 @@ module Markly
20
20
  UNSAFE = 1 << 17
21
21
  # Parse front matter ("---" delimited block at start of document).
22
22
  # The raw content is available via node.string_content and the optional
23
- # format hint (e.g. "yaml", "toml") via node.fence_info; interpretation
23
+ # format hint (e.g. "yaml", "toml") via node.code_info; interpretation
24
24
  # is left to the caller.
25
25
  FRONT_MATTER = 1 << 18
26
+ # Parse language prefixes on inline code spans, e.g. ruby:`Object.new`.
27
+ INLINE_CODE_INFO = 1 << 19
26
28
 
27
29
  PARSE_FLAGS = {
28
30
  front_matter: FRONT_MATTER,
31
+ inline_code_info: INLINE_CODE_INFO,
29
32
  validate_utf8: VALIDATE_UTF8,
30
33
  smart_quotes: SMART,
31
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
-