docyard 0.8.0 → 0.9.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 (69) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +24 -1
  3. data/lib/docyard/components/aliases.rb +12 -0
  4. data/lib/docyard/components/processors/abbreviation_processor.rb +72 -0
  5. data/lib/docyard/components/processors/accordion_processor.rb +81 -0
  6. data/lib/docyard/components/processors/badge_processor.rb +72 -0
  7. data/lib/docyard/components/processors/callout_processor.rb +8 -2
  8. data/lib/docyard/components/processors/cards_processor.rb +100 -0
  9. data/lib/docyard/components/processors/code_block_options_preprocessor.rb +23 -2
  10. data/lib/docyard/components/processors/code_block_processor.rb +6 -0
  11. data/lib/docyard/components/processors/code_group_processor.rb +198 -0
  12. data/lib/docyard/components/processors/code_snippet_import_preprocessor.rb +6 -1
  13. data/lib/docyard/components/processors/custom_anchor_processor.rb +42 -0
  14. data/lib/docyard/components/processors/file_tree_processor.rb +151 -0
  15. data/lib/docyard/components/processors/image_caption_processor.rb +96 -0
  16. data/lib/docyard/components/processors/include_processor.rb +86 -0
  17. data/lib/docyard/components/processors/steps_processor.rb +89 -0
  18. data/lib/docyard/components/processors/tabs_processor.rb +9 -1
  19. data/lib/docyard/components/processors/tooltip_processor.rb +57 -0
  20. data/lib/docyard/components/processors/video_embed_processor.rb +196 -0
  21. data/lib/docyard/components/support/code_group/html_builder.rb +122 -0
  22. data/lib/docyard/components/support/markdown_code_block_helper.rb +56 -0
  23. data/lib/docyard/config/branding_resolver.rb +30 -35
  24. data/lib/docyard/config/logo_detector.rb +39 -0
  25. data/lib/docyard/config.rb +6 -1
  26. data/lib/docyard/navigation/sidebar/file_resolver.rb +16 -4
  27. data/lib/docyard/navigation/sidebar/item.rb +6 -1
  28. data/lib/docyard/navigation/sidebar/metadata_extractor.rb +4 -2
  29. data/lib/docyard/navigation/sidebar/metadata_reader.rb +8 -4
  30. data/lib/docyard/navigation/sidebar/renderer.rb +6 -2
  31. data/lib/docyard/navigation/sidebar/tree_builder.rb +2 -1
  32. data/lib/docyard/rendering/icons/phosphor.rb +3 -0
  33. data/lib/docyard/rendering/markdown.rb +24 -1
  34. data/lib/docyard/rendering/renderer.rb +2 -1
  35. data/lib/docyard/server/asset_handler.rb +1 -0
  36. data/lib/docyard/templates/assets/css/components/abbreviation.css +86 -0
  37. data/lib/docyard/templates/assets/css/components/accordion.css +138 -0
  38. data/lib/docyard/templates/assets/css/components/badges.css +47 -0
  39. data/lib/docyard/templates/assets/css/components/banner.css +202 -0
  40. data/lib/docyard/templates/assets/css/components/cards.css +100 -0
  41. data/lib/docyard/templates/assets/css/components/code-block.css +10 -0
  42. data/lib/docyard/templates/assets/css/components/code-group.css +281 -0
  43. data/lib/docyard/templates/assets/css/components/figure.css +22 -0
  44. data/lib/docyard/templates/assets/css/components/file-tree.css +124 -0
  45. data/lib/docyard/templates/assets/css/components/heading-anchor.css +21 -13
  46. data/lib/docyard/templates/assets/css/components/lightbox.css +65 -0
  47. data/lib/docyard/templates/assets/css/components/navigation.css +7 -0
  48. data/lib/docyard/templates/assets/css/components/prev-next.css +9 -18
  49. data/lib/docyard/templates/assets/css/components/steps.css +122 -0
  50. data/lib/docyard/templates/assets/css/components/tabs.css +1 -1
  51. data/lib/docyard/templates/assets/css/components/tooltip.css +113 -0
  52. data/lib/docyard/templates/assets/css/components/video.css +41 -0
  53. data/lib/docyard/templates/assets/css/markdown.css +5 -3
  54. data/lib/docyard/templates/assets/js/components/abbreviation.js +85 -0
  55. data/lib/docyard/templates/assets/js/components/banner.js +81 -0
  56. data/lib/docyard/templates/assets/js/components/code-group.js +283 -0
  57. data/lib/docyard/templates/assets/js/components/file-tree.js +39 -0
  58. data/lib/docyard/templates/assets/js/components/lightbox.js +72 -0
  59. data/lib/docyard/templates/assets/js/components/tooltip.js +118 -0
  60. data/lib/docyard/templates/layouts/default.html.erb +1 -0
  61. data/lib/docyard/templates/layouts/splash.html.erb +1 -0
  62. data/lib/docyard/templates/partials/_accordion.html.erb +9 -0
  63. data/lib/docyard/templates/partials/_banner.html.erb +27 -0
  64. data/lib/docyard/templates/partials/_card.html.erb +23 -0
  65. data/lib/docyard/templates/partials/_nav_group.html.erb +6 -0
  66. data/lib/docyard/templates/partials/_nav_leaf.html.erb +3 -0
  67. data/lib/docyard/templates/partials/_step.html.erb +14 -0
  68. data/lib/docyard/version.rb +1 -1
  69. metadata +38 -1
@@ -0,0 +1,198 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../base_processor"
4
+ require_relative "../support/code_block/feature_extractor"
5
+ require_relative "../support/code_block/line_wrapper"
6
+ require_relative "../support/code_group/html_builder"
7
+ require_relative "../support/markdown_code_block_helper"
8
+ require_relative "../../rendering/icons"
9
+ require_relative "../../rendering/renderer"
10
+ require "securerandom"
11
+ require "kramdown"
12
+ require "kramdown-parser-gfm"
13
+ require "cgi"
14
+
15
+ module Docyard
16
+ module Components
17
+ module Processors
18
+ class CodeGroupProcessor < BaseProcessor
19
+ include Utils::HtmlHelpers
20
+ include Support::MarkdownCodeBlockHelper
21
+
22
+ self.priority = 12
23
+
24
+ CODE_GROUP_PATTERN = /^:::[ \t]*code-group[ \t]*\n(.*?)^:::[ \t]*$/m
25
+ CODE_BLOCK_PATTERN = /```(\w*)\s*\[([^\]]+)\]([^\n]*)\n(.*?)```/m
26
+
27
+ CodeBlockFeatureExtractor = Support::CodeBlock::FeatureExtractor
28
+ CodeBlockLineWrapper = Support::CodeBlock::LineWrapper
29
+ CodeGroupHtmlBuilder = Support::CodeGroup::HtmlBuilder
30
+
31
+ def preprocess(content)
32
+ return content unless content.include?(":::code-group")
33
+
34
+ @code_block_ranges = find_code_block_ranges(content)
35
+
36
+ content.gsub(CODE_GROUP_PATTERN) do
37
+ match = ::Regexp.last_match
38
+ next match[0] if inside_code_block?(match.begin(0), @code_block_ranges)
39
+
40
+ process_code_group(match[1])
41
+ end
42
+ end
43
+
44
+ private
45
+
46
+ def process_code_group(inner_content)
47
+ blocks = extract_code_blocks(inner_content)
48
+ return "" if blocks.empty?
49
+
50
+ group_id = SecureRandom.hex(4)
51
+ html = CodeGroupHtmlBuilder.new(blocks, group_id).build
52
+ wrap_in_nomarkdown(html)
53
+ end
54
+
55
+ def extract_code_blocks(content)
56
+ blocks = []
57
+ content.scan(CODE_BLOCK_PATTERN) do
58
+ blocks << build_block_data(::Regexp.last_match)
59
+ end
60
+ blocks
61
+ end
62
+
63
+ def build_block_data(match)
64
+ lang = match[1] || ""
65
+ label = match[2]
66
+ options = match[3].strip
67
+ code = match[4]
68
+
69
+ code_with_newline = code.end_with?("\n") ? code : "#{code}\n"
70
+ markdown = "```#{lang}#{format_options(options)}\n#{code_with_newline}```"
71
+ extracted = CodeBlockFeatureExtractor.process_markdown(markdown)
72
+ rendered = render_and_enhance(extracted)
73
+
74
+ { label: label, lang: lang, content: rendered, code_text: code.strip }
75
+ end
76
+
77
+ def format_options(options)
78
+ return "" if options.empty?
79
+ return options if options.start_with?(":")
80
+
81
+ " #{options}"
82
+ end
83
+
84
+ def render_and_enhance(extracted)
85
+ html = render_markdown(extracted[:cleaned_markdown])
86
+ enhance_code_blocks(html, extracted[:blocks])
87
+ end
88
+
89
+ def render_markdown(markdown_content)
90
+ return "" if markdown_content.empty?
91
+
92
+ Kramdown::Document.new(
93
+ markdown_content,
94
+ input: "GFM",
95
+ hard_wrap: false,
96
+ syntax_highlighter: "rouge"
97
+ ).to_html
98
+ end
99
+
100
+ def enhance_code_blocks(html, blocks)
101
+ return html unless html.include?('<div class="highlight">')
102
+
103
+ block_index = 0
104
+ html.gsub(%r{<div class="highlight">(.*?)</div>}m) do
105
+ block_data = blocks[block_index] || {}
106
+ block_index += 1
107
+ render_enhanced_code_block(Regexp.last_match, block_data)
108
+ end
109
+ end
110
+
111
+ def render_enhanced_code_block(match, block_data)
112
+ original_html = match[0]
113
+ inner_html = match[1]
114
+ code_text = extract_code_text(inner_html)
115
+
116
+ processed_html = process_html_if_needed(original_html, block_data)
117
+ Renderer.new.render_partial("_code_block", build_locals(processed_html, code_text, block_data))
118
+ end
119
+
120
+ def process_html_if_needed(original_html, block_data)
121
+ return original_html unless needs_line_wrapping?(block_data)
122
+
123
+ CodeBlockLineWrapper.wrap_code_block(original_html, wrapper_data(block_data))
124
+ end
125
+
126
+ def needs_line_wrapping?(block_data)
127
+ %i[highlights diff_lines focus_lines error_lines warning_lines].any? do |key|
128
+ block_data[key]&.any?
129
+ end
130
+ end
131
+
132
+ def wrapper_data(block_data)
133
+ {
134
+ highlights: block_data[:highlights] || [],
135
+ diff_lines: block_data[:diff_lines] || {},
136
+ focus_lines: block_data[:focus_lines] || {},
137
+ error_lines: block_data[:error_lines] || {},
138
+ warning_lines: block_data[:warning_lines] || {},
139
+ start_line: extract_start_line(block_data[:option])
140
+ }
141
+ end
142
+
143
+ def build_locals(processed_html, code_text, block_data)
144
+ base_locals(processed_html, code_text, block_data).merge(feature_locals(block_data)).merge(title_locals)
145
+ end
146
+
147
+ def base_locals(processed_html, code_text, block_data)
148
+ show_ln = line_numbers_enabled?(block_data[:option])
149
+ start = extract_start_line(block_data[:option])
150
+
151
+ {
152
+ code_block_html: processed_html, code_text: escape_html_attribute(code_text),
153
+ copy_icon: Icons.render("copy", "regular") || "", show_line_numbers: show_ln,
154
+ line_numbers: show_ln ? generate_line_numbers(code_text, start) : [], start_line: start
155
+ }
156
+ end
157
+
158
+ def feature_locals(block_data)
159
+ {
160
+ highlights: block_data[:highlights] || [], diff_lines: block_data[:diff_lines] || {},
161
+ focus_lines: block_data[:focus_lines] || {}, error_lines: block_data[:error_lines] || {},
162
+ warning_lines: block_data[:warning_lines] || {}
163
+ }
164
+ end
165
+
166
+ def title_locals
167
+ { title: nil, icon: nil, icon_source: nil }
168
+ end
169
+
170
+ def line_numbers_enabled?(option)
171
+ return false if option == ":no-line-numbers"
172
+ return true if option&.start_with?(":line-numbers")
173
+
174
+ false
175
+ end
176
+
177
+ def extract_start_line(option)
178
+ return 1 unless option&.include?("=")
179
+
180
+ option.split("=").last.to_i
181
+ end
182
+
183
+ def generate_line_numbers(code_text, start_line)
184
+ count = [code_text.lines.count, 1].max
185
+ (start_line...(start_line + count)).to_a
186
+ end
187
+
188
+ def extract_code_text(html)
189
+ CGI.unescapeHTML(html.gsub(/<[^>]+>/, "")).strip
190
+ end
191
+
192
+ def wrap_in_nomarkdown(html)
193
+ "{::nomarkdown}\n#{html}\n{:/nomarkdown}"
194
+ end
195
+ end
196
+ end
197
+ end
198
+ end
@@ -1,11 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../base_processor"
4
+ require_relative "../support/markdown_code_block_helper"
4
5
 
5
6
  module Docyard
6
7
  module Components
7
8
  module Processors
8
9
  class CodeSnippetImportPreprocessor < BaseProcessor
10
+ include Support::MarkdownCodeBlockHelper
11
+
9
12
  EXTENSION_MAP = {
10
13
  "rb" => "ruby",
11
14
  "js" => "javascript",
@@ -25,7 +28,9 @@ module Docyard
25
28
 
26
29
  def preprocess(content)
27
30
  @docs_root = context[:docs_root] || "docs"
28
- content.gsub(IMPORT_PATTERN) { |_| process_import(Regexp.last_match) }
31
+ process_outside_code_blocks(content) do |segment|
32
+ segment.gsub(IMPORT_PATTERN) { |_| process_import(Regexp.last_match) }
33
+ end
29
34
  end
30
35
 
31
36
  private
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../base_processor"
4
+
5
+ module Docyard
6
+ module Components
7
+ module Processors
8
+ class CustomAnchorProcessor < BaseProcessor
9
+ CUSTOM_ID_PATTERN = /\s*\{#([\w-]+)\}\s*$/
10
+
11
+ self.priority = 25
12
+
13
+ def postprocess(html)
14
+ process_custom_anchors(html)
15
+ end
16
+
17
+ private
18
+
19
+ def process_custom_anchors(html)
20
+ html.gsub(%r{<(h[1-6])(\s+id="[^"]*")?>(.+?)</\1>}m) do
21
+ tag = Regexp.last_match(1)
22
+ existing_attr = Regexp.last_match(2) || ""
23
+ content = Regexp.last_match(3)
24
+
25
+ if content.match?(CUSTOM_ID_PATTERN)
26
+ process_heading_with_custom_id(tag, content)
27
+ else
28
+ "<#{tag}#{existing_attr}>#{content}</#{tag}>"
29
+ end
30
+ end
31
+ end
32
+
33
+ def process_heading_with_custom_id(tag, content)
34
+ custom_id = content.match(CUSTOM_ID_PATTERN)[1]
35
+ clean_content = content.sub(CUSTOM_ID_PATTERN, "")
36
+
37
+ "<#{tag} id=\"#{custom_id}\">#{clean_content}</#{tag}>"
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../base_processor"
4
+ require_relative "../support/markdown_code_block_helper"
5
+ require_relative "../../rendering/icons"
6
+
7
+ module Docyard
8
+ module Components
9
+ module Processors
10
+ class FileTreeProcessor < BaseProcessor
11
+ include Support::MarkdownCodeBlockHelper
12
+
13
+ FILETREE_PATTERN = /```filetree\n(.*?)```/m
14
+
15
+ self.priority = 8
16
+
17
+ def preprocess(content)
18
+ @code_block_ranges = find_code_block_ranges(content, exclude_language: "filetree")
19
+
20
+ content.gsub(FILETREE_PATTERN) do
21
+ match = Regexp.last_match
22
+ next match[0] if inside_code_block?(match.begin(0), @code_block_ranges)
23
+
24
+ build_file_tree(match[1])
25
+ end
26
+ end
27
+
28
+ private
29
+
30
+ def build_file_tree(content)
31
+ lines = content.lines.map(&:chomp).reject(&:empty?)
32
+
33
+ items = parse_tree_structure(lines)
34
+ html = render_tree(items)
35
+
36
+ "\n\n<div class=\"docyard-filetree\" markdown=\"0\">\n#{html}</div>\n\n"
37
+ end
38
+
39
+ def parse_tree_structure(lines)
40
+ root_items = []
41
+ stack = [{ indent: -1, children: root_items }]
42
+
43
+ lines.each { |line| process_line(line, stack) }
44
+
45
+ root_items
46
+ end
47
+
48
+ def process_line(line, stack)
49
+ indent = line[/\A */].length
50
+ name = line.strip
51
+ return if name.empty?
52
+
53
+ item = build_item(name, indent)
54
+ unwind_stack(stack, indent)
55
+ stack.last[:children] << item
56
+
57
+ add_folder_to_stack(item, indent, stack) if item[:type] == :folder
58
+ end
59
+
60
+ def build_item(name, indent)
61
+ item = parse_item(name)
62
+ item[:indent] = indent
63
+ item
64
+ end
65
+
66
+ def unwind_stack(stack, indent)
67
+ stack.pop while stack.length > 1 && stack.last[:indent] >= indent
68
+ end
69
+
70
+ def add_folder_to_stack(item, indent, stack)
71
+ item[:children] = []
72
+ stack.push({ indent: indent, children: item[:children] })
73
+ end
74
+
75
+ def parse_item(name)
76
+ highlighted = name.end_with?(" *")
77
+ name = name.chomp(" *") if highlighted
78
+
79
+ name, comment = extract_comment(name)
80
+ type = name.end_with?("/") ? :folder : :file
81
+ name = name.chomp("/") if type == :folder
82
+
83
+ { name: name, type: type, highlighted: highlighted, comment: comment }
84
+ end
85
+
86
+ def extract_comment(name)
87
+ return [name, nil] unless name.include?(" # ")
88
+
89
+ name.split(" # ", 2)
90
+ end
91
+
92
+ def render_tree(items, depth = 0)
93
+ return "" if items.empty?
94
+
95
+ html = "<ul class=\"docyard-filetree__list\">\n"
96
+ items.each { |item| html += render_item(item, depth) }
97
+ html += "</ul>\n"
98
+ html
99
+ end
100
+
101
+ def render_item(item, depth)
102
+ classes = item_classes(item)
103
+
104
+ html = "<li class=\"#{classes}\">\n"
105
+ html += render_entry(item)
106
+ html += render_tree(item[:children], depth + 1) if render_children?(item)
107
+ html += "</li>\n"
108
+ html
109
+ end
110
+
111
+ def item_classes(item)
112
+ classes = ["docyard-filetree__item", "docyard-filetree__item--#{item[:type]}"]
113
+ classes << "docyard-filetree__item--highlighted" if item[:highlighted]
114
+ classes.join(" ")
115
+ end
116
+
117
+ def render_entry(item)
118
+ html = "<span class=\"docyard-filetree__entry\">"
119
+ html += icon_for(item[:type])
120
+ html += "<span class=\"docyard-filetree__name\">#{escape_html(item[:name])}</span>"
121
+ html += render_comment(item[:comment])
122
+ html += "</span>"
123
+ html
124
+ end
125
+
126
+ def render_comment(comment)
127
+ return "" unless comment
128
+
129
+ "<span class=\"docyard-filetree__comment\">#{escape_html(comment)}</span>"
130
+ end
131
+
132
+ def render_children?(item)
133
+ item[:type] == :folder && item[:children] && !item[:children].empty?
134
+ end
135
+
136
+ def icon_for(type)
137
+ icon_name = type == :folder ? "folder-open" : "file-text"
138
+ Icons.render(icon_name)
139
+ end
140
+
141
+ def escape_html(text)
142
+ text.to_s
143
+ .gsub("&", "&amp;")
144
+ .gsub("<", "&lt;")
145
+ .gsub(">", "&gt;")
146
+ .gsub('"', "&quot;")
147
+ end
148
+ end
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../base_processor"
4
+ require_relative "../support/markdown_code_block_helper"
5
+
6
+ module Docyard
7
+ module Components
8
+ module Processors
9
+ class ImageCaptionProcessor < BaseProcessor
10
+ include Support::MarkdownCodeBlockHelper
11
+
12
+ IMAGE_ATTRS_PATTERN = /!\[([^\]]*)\]\(([^)]+)\)\{([^}]+)\}/
13
+
14
+ self.priority = 5
15
+
16
+ def preprocess(content)
17
+ process_outside_code_blocks(content) do |segment|
18
+ process_images_with_attrs(segment)
19
+ end
20
+ end
21
+
22
+ private
23
+
24
+ def process_images_with_attrs(content)
25
+ content.gsub(IMAGE_ATTRS_PATTERN) do
26
+ alt = Regexp.last_match(1)
27
+ src = Regexp.last_match(2)
28
+ attrs_string = Regexp.last_match(3)
29
+
30
+ attrs = parse_attributes(attrs_string)
31
+ build_image_html(alt, src, attrs)
32
+ end
33
+ end
34
+
35
+ def parse_attributes(attrs_string)
36
+ attrs = {}
37
+
38
+ attrs_string.scan(/(\w+)="([^"]*)"/) do |key, value|
39
+ attrs[key] = value
40
+ end
41
+
42
+ attrs[:nozoom] = true if attrs_string.include?("nozoom")
43
+
44
+ attrs
45
+ end
46
+
47
+ def build_image_html(alt, src, attrs)
48
+ if attrs["caption"]
49
+ build_figure(alt, src, attrs)
50
+ else
51
+ build_img(alt, src, attrs)
52
+ end
53
+ end
54
+
55
+ def build_figure(alt, src, attrs)
56
+ "\n\n" \
57
+ "<figure class=\"docyard-figure\" markdown=\"0\">\n" \
58
+ "#{build_img_tag(alt, src, attrs)}\n" \
59
+ "<figcaption>#{escape_html(attrs['caption'])}</figcaption>\n" \
60
+ "</figure>" \
61
+ "\n\n"
62
+ end
63
+
64
+ def build_img(alt, src, attrs)
65
+ "\n\n#{build_img_tag(alt, src, attrs)}\n\n"
66
+ end
67
+
68
+ def build_img_tag(alt, src, attrs)
69
+ parts = base_img_attrs(alt, src)
70
+ parts.concat(dimension_attrs(attrs))
71
+ parts << "data-no-zoom" if attrs[:nozoom]
72
+ "<img #{parts.join(' ')}>"
73
+ end
74
+
75
+ def base_img_attrs(alt, src)
76
+ ["src=\"#{escape_html(src)}\"", "alt=\"#{escape_html(alt)}\""]
77
+ end
78
+
79
+ def dimension_attrs(attrs)
80
+ result = []
81
+ result << "width=\"#{escape_html(attrs['width'])}\"" if attrs["width"]
82
+ result << "height=\"#{escape_html(attrs['height'])}\"" if attrs["height"]
83
+ result
84
+ end
85
+
86
+ def escape_html(text)
87
+ text.to_s
88
+ .gsub("&", "&amp;")
89
+ .gsub("<", "&lt;")
90
+ .gsub(">", "&gt;")
91
+ .gsub('"', "&quot;")
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../base_processor"
4
+ require_relative "../support/markdown_code_block_helper"
5
+
6
+ module Docyard
7
+ module Components
8
+ module Processors
9
+ class IncludeProcessor < BaseProcessor
10
+ include Support::MarkdownCodeBlockHelper
11
+
12
+ INCLUDE_PATTERN = /<!--\s*@include:\s*([^\s]+)\s*-->/
13
+
14
+ self.priority = 0
15
+
16
+ def preprocess(content)
17
+ @current_file = context[:current_file]
18
+ @docs_root = context[:docs_root] || "docs"
19
+ @included_files = Set.new
20
+
21
+ process_outside_code_blocks(content) do |segment|
22
+ process_includes(segment)
23
+ end
24
+ end
25
+
26
+ private
27
+
28
+ def process_includes(content)
29
+ content.gsub(INCLUDE_PATTERN) { |_| process_include(Regexp.last_match) }
30
+ end
31
+
32
+ def process_include(match)
33
+ filepath = match[1]
34
+ full_path = resolve_path(filepath)
35
+
36
+ error = validate_include(filepath, full_path)
37
+ return error if error
38
+
39
+ @included_files.add(full_path)
40
+ file_content = File.read(full_path)
41
+
42
+ process_includes(file_content.strip)
43
+ end
44
+
45
+ def validate_include(filepath, full_path)
46
+ return include_error(filepath, "File not found") unless full_path && File.exist?(full_path)
47
+ return include_error(filepath, "Circular include detected") if @included_files.include?(full_path)
48
+ return include_error(filepath, "Use code snippets for non-markdown files") unless markdown_file?(filepath)
49
+
50
+ nil
51
+ end
52
+
53
+ def resolve_path(filepath)
54
+ if filepath.start_with?("./", "../")
55
+ resolve_relative_path(filepath)
56
+ else
57
+ resolve_docs_path(filepath)
58
+ end
59
+ end
60
+
61
+ def resolve_relative_path(filepath)
62
+ return nil unless @current_file
63
+
64
+ base_dir = File.dirname(@current_file)
65
+ full_path = File.expand_path(filepath, base_dir)
66
+
67
+ full_path if File.exist?(full_path)
68
+ end
69
+
70
+ def resolve_docs_path(filepath)
71
+ full_path = File.join(@docs_root, filepath)
72
+ full_path if File.exist?(full_path)
73
+ end
74
+
75
+ def markdown_file?(filepath)
76
+ ext = File.extname(filepath).downcase
77
+ %w[.md .markdown .mdx].include?(ext)
78
+ end
79
+
80
+ def include_error(filepath, message)
81
+ "> [!WARNING]\n> Include error: #{filepath} - #{message}\n"
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../rendering/renderer"
4
+ require_relative "../base_processor"
5
+ require_relative "../support/markdown_code_block_helper"
6
+ require "kramdown"
7
+ require "kramdown-parser-gfm"
8
+
9
+ module Docyard
10
+ module Components
11
+ module Processors
12
+ class StepsProcessor < BaseProcessor
13
+ include Support::MarkdownCodeBlockHelper
14
+
15
+ self.priority = 10
16
+
17
+ STEPS_PATTERN = /^:::steps\s*\n(.*?)^:::\s*$/m
18
+ STEP_HEADING_PATTERN = /^###\s+(.+)$/
19
+
20
+ def preprocess(markdown)
21
+ @code_block_ranges = find_code_block_ranges(markdown)
22
+
23
+ markdown.gsub(STEPS_PATTERN) do
24
+ match = Regexp.last_match
25
+ next match[0] if inside_code_block?(match.begin(0), @code_block_ranges)
26
+
27
+ content = match[1]
28
+ steps = parse_steps(content)
29
+
30
+ wrap_in_nomarkdown(render_steps_html(steps))
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ def parse_steps(content)
37
+ steps = []
38
+ current_step = nil
39
+
40
+ content.lines.each do |line|
41
+ if line.match(STEP_HEADING_PATTERN)
42
+ steps << current_step if current_step
43
+ current_step = { title: Regexp.last_match(1).strip, content: "" }
44
+ elsif current_step
45
+ current_step[:content] += line
46
+ end
47
+ end
48
+
49
+ steps << current_step if current_step
50
+ steps
51
+ end
52
+
53
+ def render_steps_html(steps)
54
+ renderer = Renderer.new
55
+
56
+ steps_html = steps.map.with_index(1) do |step, index|
57
+ content_html = render_markdown_content(step[:content].strip)
58
+
59
+ renderer.render_partial(
60
+ "_step", {
61
+ number: index,
62
+ title: step[:title],
63
+ content_html: content_html,
64
+ is_last: index == steps.length
65
+ }
66
+ )
67
+ end.join("\n")
68
+
69
+ "<div class=\"docyard-steps\">\n#{steps_html}\n</div>"
70
+ end
71
+
72
+ def render_markdown_content(content_markdown)
73
+ return "" if content_markdown.empty?
74
+
75
+ Kramdown::Document.new(
76
+ content_markdown,
77
+ input: "GFM",
78
+ hard_wrap: false,
79
+ syntax_highlighter: "rouge"
80
+ ).to_html
81
+ end
82
+
83
+ def wrap_in_nomarkdown(html)
84
+ "{::nomarkdown}\n#{html}\n{:/nomarkdown}"
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end