docyard 0.6.0 → 0.7.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 (92) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +15 -1
  3. data/lib/docyard/build/static_generator.rb +2 -43
  4. data/lib/docyard/builder.rb +14 -4
  5. data/lib/docyard/cli.rb +6 -3
  6. data/lib/docyard/components/aliases.rb +29 -0
  7. data/lib/docyard/components/processors/callout_processor.rb +124 -0
  8. data/lib/docyard/components/processors/code_block_diff_preprocessor.rb +106 -0
  9. data/lib/docyard/components/processors/code_block_focus_preprocessor.rb +79 -0
  10. data/lib/docyard/components/processors/code_block_options_preprocessor.rb +78 -0
  11. data/lib/docyard/components/processors/code_block_processor.rb +175 -0
  12. data/lib/docyard/components/processors/code_snippet_import_preprocessor.rb +127 -0
  13. data/lib/docyard/components/processors/heading_anchor_processor.rb +39 -0
  14. data/lib/docyard/components/processors/icon_processor.rb +53 -0
  15. data/lib/docyard/components/processors/table_of_contents_processor.rb +68 -0
  16. data/lib/docyard/components/processors/table_wrapper_processor.rb +22 -0
  17. data/lib/docyard/components/processors/tabs_processor.rb +48 -0
  18. data/lib/docyard/components/support/code_block/feature_extractor.rb +117 -0
  19. data/lib/docyard/components/support/code_block/icon_detector.rb +44 -0
  20. data/lib/docyard/components/support/code_block/line_parser.rb +84 -0
  21. data/lib/docyard/components/support/code_block/line_wrapper.rb +50 -0
  22. data/lib/docyard/components/support/code_block/patterns.rb +55 -0
  23. data/lib/docyard/components/support/code_detector.rb +61 -0
  24. data/lib/docyard/components/support/tabs/icon_detector.rb +62 -0
  25. data/lib/docyard/components/support/tabs/parser.rb +195 -0
  26. data/lib/docyard/components/support/tabs/range_finder.rb +46 -0
  27. data/lib/docyard/config/branding_resolver.rb +74 -0
  28. data/lib/docyard/{constants.rb → config/constants.rb} +1 -0
  29. data/lib/docyard/config.rb +10 -1
  30. data/lib/docyard/{prev_next_builder.rb → navigation/prev_next_builder.rb} +2 -2
  31. data/lib/docyard/{sidebar → navigation/sidebar}/renderer.rb +3 -14
  32. data/lib/docyard/{sidebar → navigation/sidebar}/tree_builder.rb +9 -2
  33. data/lib/docyard/{sidebar_builder.rb → navigation/sidebar_builder.rb} +3 -15
  34. data/lib/docyard/{icons → rendering/icons}/phosphor.rb +4 -1
  35. data/lib/docyard/{markdown.rb → rendering/markdown.rb} +14 -13
  36. data/lib/docyard/{renderer.rb → rendering/renderer.rb} +20 -17
  37. data/lib/docyard/search/build_indexer.rb +74 -0
  38. data/lib/docyard/search/dev_indexer.rb +110 -0
  39. data/lib/docyard/search/pagefind_support.rb +31 -0
  40. data/lib/docyard/{asset_handler.rb → server/asset_handler.rb} +1 -1
  41. data/lib/docyard/{server.rb → server/dev_server.rb} +32 -9
  42. data/lib/docyard/{preview_server.rb → server/preview_server.rb} +1 -1
  43. data/lib/docyard/{rack_application.rb → server/rack_application.rb} +52 -49
  44. data/lib/docyard/server/resolution_result.rb +29 -0
  45. data/lib/docyard/{router.rb → server/router.rb} +4 -4
  46. data/lib/docyard/templates/assets/css/components/search.css +549 -0
  47. data/lib/docyard/templates/assets/css/layout.css +15 -1
  48. data/lib/docyard/templates/assets/js/components/search.js +685 -0
  49. data/lib/docyard/templates/layouts/default.html.erb +14 -2
  50. data/lib/docyard/templates/partials/_code_block.html.erb +1 -1
  51. data/lib/docyard/templates/partials/_heading_anchor.html.erb +1 -1
  52. data/lib/docyard/templates/partials/_prev_next.html.erb +1 -1
  53. data/lib/docyard/templates/partials/_search_modal.html.erb +45 -0
  54. data/lib/docyard/templates/partials/_search_trigger.html.erb +22 -0
  55. data/lib/docyard/utils/html_helpers.rb +14 -0
  56. data/lib/docyard/utils/path_resolver.rb +2 -1
  57. data/lib/docyard/utils/url_helpers.rb +20 -0
  58. data/lib/docyard/version.rb +1 -1
  59. data/lib/docyard.rb +22 -15
  60. metadata +57 -46
  61. data/lib/docyard/components/callout_processor.rb +0 -121
  62. data/lib/docyard/components/code_block_diff_preprocessor.rb +0 -104
  63. data/lib/docyard/components/code_block_feature_extractor.rb +0 -113
  64. data/lib/docyard/components/code_block_focus_preprocessor.rb +0 -77
  65. data/lib/docyard/components/code_block_icon_detector.rb +0 -40
  66. data/lib/docyard/components/code_block_line_wrapper.rb +0 -46
  67. data/lib/docyard/components/code_block_options_preprocessor.rb +0 -76
  68. data/lib/docyard/components/code_block_patterns.rb +0 -51
  69. data/lib/docyard/components/code_block_processor.rb +0 -176
  70. data/lib/docyard/components/code_detector.rb +0 -59
  71. data/lib/docyard/components/code_line_parser.rb +0 -80
  72. data/lib/docyard/components/code_snippet_import_preprocessor.rb +0 -125
  73. data/lib/docyard/components/heading_anchor_processor.rb +0 -34
  74. data/lib/docyard/components/icon_detector.rb +0 -57
  75. data/lib/docyard/components/icon_processor.rb +0 -51
  76. data/lib/docyard/components/table_of_contents_processor.rb +0 -64
  77. data/lib/docyard/components/table_wrapper_processor.rb +0 -18
  78. data/lib/docyard/components/tabs_parser.rb +0 -191
  79. data/lib/docyard/components/tabs_processor.rb +0 -44
  80. data/lib/docyard/components/tabs_range_finder.rb +0 -42
  81. data/lib/docyard/routing/resolution_result.rb +0 -31
  82. /data/lib/docyard/{sidebar → navigation/sidebar}/config_parser.rb +0 -0
  83. /data/lib/docyard/{sidebar → navigation/sidebar}/file_system_scanner.rb +0 -0
  84. /data/lib/docyard/{sidebar → navigation/sidebar}/item.rb +0 -0
  85. /data/lib/docyard/{sidebar → navigation/sidebar}/title_extractor.rb +0 -0
  86. /data/lib/docyard/{icons → rendering/icons}/LICENSE.phosphor +0 -0
  87. /data/lib/docyard/{icons → rendering/icons}/file_types.rb +0 -0
  88. /data/lib/docyard/{icons.rb → rendering/icons.rb} +0 -0
  89. /data/lib/docyard/{language_mapping.rb → rendering/language_mapping.rb} +0 -0
  90. /data/lib/docyard/{file_watcher.rb → server/file_watcher.rb} +0 -0
  91. /data/lib/docyard/{errors.rb → utils/errors.rb} +0 -0
  92. /data/lib/docyard/{logging.rb → utils/logging.rb} +0 -0
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../rendering/icons"
4
+ require_relative "../../rendering/language_mapping"
5
+ require_relative "../../rendering/renderer"
6
+ require_relative "../base_processor"
7
+ require_relative "../support/code_block/icon_detector"
8
+ require_relative "../support/code_block/line_wrapper"
9
+ require_relative "../support/tabs/range_finder"
10
+
11
+ module Docyard
12
+ module Components
13
+ module Processors
14
+ class CodeBlockProcessor < BaseProcessor
15
+ include Support::CodeBlock::LineWrapper
16
+ include Utils::HtmlHelpers
17
+
18
+ self.priority = 20
19
+
20
+ CodeBlockIconDetector = Support::CodeBlock::IconDetector
21
+ TabsRangeFinder = Support::Tabs::RangeFinder
22
+
23
+ def postprocess(html)
24
+ return html unless html.include?('<div class="highlight">')
25
+
26
+ initialize_postprocess_state(html)
27
+ process_all_highlight_blocks(html)
28
+ end
29
+
30
+ private
31
+
32
+ def initialize_postprocess_state(html)
33
+ @block_index = 0
34
+ @options = context[:code_block_options] || []
35
+ @diff_lines = context[:code_block_diff_lines] || []
36
+ @focus_lines = context[:code_block_focus_lines] || []
37
+ @error_lines = context[:code_block_error_lines] || []
38
+ @warning_lines = context[:code_block_warning_lines] || []
39
+ @global_line_numbers = context.dig(:config, "markdown", "lineNumbers") || false
40
+ @tabs_ranges = TabsRangeFinder.find_ranges(html)
41
+ end
42
+
43
+ def process_all_highlight_blocks(html)
44
+ result = +""
45
+ last_end = 0
46
+
47
+ html.scan(%r{<div class="highlight">(.*?)</div>}m) do
48
+ match = Regexp.last_match
49
+ result << html[last_end...match.begin(0)]
50
+ result << process_highlight_match(match)
51
+ last_end = match.end(0)
52
+ end
53
+
54
+ result << html[last_end..]
55
+ end
56
+
57
+ def process_highlight_match(match)
58
+ if inside_tabs?(match.begin(0))
59
+ match[0]
60
+ else
61
+ processed = process_code_block(match[0], match[1])
62
+ @block_index += 1
63
+ processed
64
+ end
65
+ end
66
+
67
+ def process_code_block(original_html, inner_html)
68
+ block_data = extract_block_data(inner_html)
69
+ processed_html = process_html_for_highlighting(original_html, block_data)
70
+
71
+ render_code_block_with_copy(block_data.merge(html: processed_html))
72
+ end
73
+
74
+ def extract_block_data(inner_html)
75
+ opts = current_block_options
76
+ code_text = extract_code_text(inner_html)
77
+ start_line = extract_start_line(opts[:option])
78
+ show_line_numbers = determine_line_numbers(opts[:option])
79
+ title_data = CodeBlockIconDetector.detect(opts[:title], opts[:lang])
80
+
81
+ build_block_data(code_text, opts, show_line_numbers, start_line, title_data)
82
+ end
83
+
84
+ def current_block_options
85
+ block_opts = @options[@block_index] || {}
86
+ {
87
+ option: block_opts[:option],
88
+ title: block_opts[:title],
89
+ lang: block_opts[:lang],
90
+ highlights: block_opts[:highlights] || []
91
+ }
92
+ end
93
+
94
+ def build_block_data(code_text, opts, show_line_numbers, start_line, title_data)
95
+ {
96
+ text: code_text,
97
+ highlights: opts[:highlights],
98
+ diff_lines: @diff_lines[@block_index] || {},
99
+ focus_lines: @focus_lines[@block_index] || {},
100
+ error_lines: @error_lines[@block_index] || {},
101
+ warning_lines: @warning_lines[@block_index] || {},
102
+ show_line_numbers: show_line_numbers,
103
+ line_numbers: show_line_numbers ? generate_line_numbers(code_text, start_line) : [],
104
+ start_line: start_line,
105
+ title: title_data[:title],
106
+ icon: title_data[:icon],
107
+ icon_source: title_data[:icon_source]
108
+ }
109
+ end
110
+
111
+ def process_html_for_highlighting(original_html, block_data)
112
+ needs_wrapping = block_data[:highlights].any? || block_data[:diff_lines].any? ||
113
+ block_data[:focus_lines].any? || block_data[:error_lines].any? ||
114
+ block_data[:warning_lines].any?
115
+ return original_html unless needs_wrapping
116
+
117
+ wrap_code_block(original_html, block_data)
118
+ end
119
+
120
+ def determine_line_numbers(block_option)
121
+ return false if block_option == ":no-line-numbers"
122
+ return true if block_option&.start_with?(":line-numbers")
123
+
124
+ @global_line_numbers
125
+ end
126
+
127
+ def extract_start_line(block_option)
128
+ return 1 unless block_option&.include?("=")
129
+
130
+ block_option.split("=").last.to_i
131
+ end
132
+
133
+ def generate_line_numbers(code_text, start_line)
134
+ line_count = code_text.lines.count
135
+ line_count = 1 if line_count.zero?
136
+ (start_line...(start_line + line_count)).to_a
137
+ end
138
+
139
+ def inside_tabs?(position)
140
+ @tabs_ranges.any? { |range| range.cover?(position) }
141
+ end
142
+
143
+ def extract_code_text(html)
144
+ text = html.gsub(/<[^>]+>/, "")
145
+ text = CGI.unescapeHTML(text)
146
+ text.strip
147
+ end
148
+
149
+ def render_code_block_with_copy(block_data)
150
+ Renderer.new.render_partial("_code_block", template_locals(block_data))
151
+ end
152
+
153
+ def template_locals(block_data)
154
+ base_locals(block_data).merge(line_feature_locals(block_data)).merge(title_locals(block_data))
155
+ end
156
+
157
+ def base_locals(block_data)
158
+ { code_block_html: block_data[:html], code_text: escape_html_attribute(block_data[:text]),
159
+ copy_icon: Icons.render("copy", "regular") || "", show_line_numbers: block_data[:show_line_numbers],
160
+ line_numbers: block_data[:line_numbers], start_line: block_data[:start_line] }
161
+ end
162
+
163
+ def line_feature_locals(block_data)
164
+ { highlights: block_data[:highlights], diff_lines: block_data[:diff_lines],
165
+ focus_lines: block_data[:focus_lines], error_lines: block_data[:error_lines],
166
+ warning_lines: block_data[:warning_lines] }
167
+ end
168
+
169
+ def title_locals(block_data)
170
+ { title: block_data[:title], icon: block_data[:icon], icon_source: block_data[:icon_source] }
171
+ end
172
+ end
173
+ end
174
+ end
175
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../base_processor"
4
+
5
+ module Docyard
6
+ module Components
7
+ module Processors
8
+ class CodeSnippetImportPreprocessor < BaseProcessor
9
+ EXTENSION_MAP = {
10
+ "rb" => "ruby",
11
+ "js" => "javascript",
12
+ "ts" => "typescript",
13
+ "py" => "python",
14
+ "yml" => "yaml",
15
+ "md" => "markdown",
16
+ "sh" => "bash",
17
+ "zsh" => "bash",
18
+ "jsx" => "jsx",
19
+ "tsx" => "tsx"
20
+ }.freeze
21
+
22
+ IMPORT_PATTERN = %r{^<<<\s+@/([^\s{#]+)(?:#([\w-]+))?(?:\{([^}]+)\})?\s*$}
23
+
24
+ self.priority = 1
25
+
26
+ def preprocess(content)
27
+ @docs_root = context[:docs_root] || "docs"
28
+ content.gsub(IMPORT_PATTERN) { |_| process_import(Regexp.last_match) }
29
+ end
30
+
31
+ private
32
+
33
+ def process_import(match)
34
+ filepath = match[1]
35
+ region = match[2]
36
+ options = match[3]
37
+
38
+ file_content = read_file(filepath)
39
+ return import_error(filepath, "File not found") unless file_content
40
+
41
+ file_content = extract_region(file_content, region) if region
42
+ return import_error(filepath, "Region '#{region}' not found") unless file_content
43
+
44
+ build_code_block(file_content, filepath, options)
45
+ end
46
+
47
+ def read_file(filepath)
48
+ full_path = File.join(@docs_root, filepath)
49
+ return nil unless File.exist?(full_path)
50
+
51
+ File.read(full_path)
52
+ end
53
+
54
+ def extract_region(content, region_name)
55
+ region_start = %r{^[ \t]*(?://|#|/\*)\s*#region\s+#{Regexp.escape(region_name)}\b.*$}
56
+ region_end = %r{^[ \t]*(?://|#|/\*|\*/)\s*#endregion\s*#{Regexp.escape(region_name)}?\b.*$}
57
+
58
+ lines = content.lines
59
+ start_index = lines.find_index { |line| line.match?(region_start) }
60
+ return nil unless start_index
61
+
62
+ end_index = lines[(start_index + 1)..].find_index { |line| line.match?(region_end) }
63
+ return nil unless end_index
64
+
65
+ end_index += start_index + 1
66
+ lines[(start_index + 1)...end_index].join
67
+ end
68
+
69
+ def build_code_block(content, filepath, options)
70
+ lang = detect_language(filepath)
71
+ highlights = nil
72
+
73
+ if options
74
+ parsed = parse_options(options)
75
+ lang = parsed[:lang] if parsed[:lang]
76
+ highlights = parsed[:highlights] if parsed[:highlights]
77
+ end
78
+
79
+ content = extract_line_range(content, highlights) if highlights&.include?("-") && !highlights.include?(",")
80
+
81
+ meta = build_meta_string(highlights, filepath)
82
+
83
+ "```#{lang}#{meta}\n#{content.chomp}\n```"
84
+ end
85
+
86
+ def parse_options(options)
87
+ parts = options.strip.split(/\s+/)
88
+ result = { highlights: nil, lang: nil }
89
+
90
+ parts.each do |part|
91
+ if part.match?(/^[\d,-]+$/)
92
+ result[:highlights] = part
93
+ else
94
+ result[:lang] = part
95
+ end
96
+ end
97
+
98
+ result
99
+ end
100
+
101
+ def extract_line_range(content, range_str)
102
+ return content unless range_str&.match?(/^\d+-\d+$/)
103
+
104
+ start_line, end_line = range_str.split("-").map(&:to_i)
105
+ lines = content.lines
106
+ lines[(start_line - 1)..(end_line - 1)]&.join || content
107
+ end
108
+
109
+ def build_meta_string(highlights, filepath)
110
+ parts = []
111
+ parts << " [#{File.basename(filepath)}]" if filepath
112
+ parts << " {#{highlights}}" if highlights && !highlights.match?(/^\d+-\d+$/)
113
+ parts.join
114
+ end
115
+
116
+ def detect_language(filepath)
117
+ ext = File.extname(filepath).delete_prefix(".")
118
+ EXTENSION_MAP[ext] || ext
119
+ end
120
+
121
+ def import_error(filepath, message)
122
+ "```\nError importing #{filepath}: #{message}\n```"
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../rendering/renderer"
4
+ require_relative "../base_processor"
5
+
6
+ module Docyard
7
+ module Components
8
+ module Processors
9
+ class HeadingAnchorProcessor < BaseProcessor
10
+ self.priority = 30
11
+
12
+ def postprocess(html)
13
+ add_anchor_links(html)
14
+ end
15
+
16
+ private
17
+
18
+ def add_anchor_links(html)
19
+ html.gsub(%r{<(h[2-6])\s+id="([^"]+)">(.*?)</\1>}m) do |_match|
20
+ tag = Regexp.last_match(1)
21
+ id = Regexp.last_match(2)
22
+ content = Regexp.last_match(3)
23
+
24
+ anchor_html = render_anchor_link(id)
25
+
26
+ "<#{tag} id=\"#{id}\">#{content}#{anchor_html}</#{tag}>"
27
+ end
28
+ end
29
+
30
+ def render_anchor_link(id)
31
+ renderer = Renderer.new
32
+ renderer.render_partial("_heading_anchor", {
33
+ id: id
34
+ })
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../rendering/icons"
4
+ require_relative "../base_processor"
5
+
6
+ module Docyard
7
+ module Components
8
+ module Processors
9
+ class IconProcessor < BaseProcessor
10
+ self.priority = 20
11
+
12
+ ICON_PATTERN = /:([a-z][a-z0-9-]*):(?:([a-z]+):)?/i
13
+
14
+ def postprocess(html)
15
+ segments = split_preserving_code_blocks(html)
16
+
17
+ segments.map do |segment|
18
+ segment[:type] == :code ? segment[:content] : process_segment(segment[:content])
19
+ end.join
20
+ end
21
+
22
+ private
23
+
24
+ def split_preserving_code_blocks(html)
25
+ segments = []
26
+ current_pos = 0
27
+
28
+ html.scan(%r{<(code|pre)[^>]*>.*?</\1>}m) do
29
+ match_start = Regexp.last_match.begin(0)
30
+ match_end = Regexp.last_match.end(0)
31
+
32
+ segments << { type: :text, content: html[current_pos...match_start] } if match_start > current_pos
33
+ segments << { type: :code, content: html[match_start...match_end] }
34
+
35
+ current_pos = match_end
36
+ end
37
+
38
+ segments << { type: :text, content: html[current_pos..] } if current_pos < html.length
39
+
40
+ segments.empty? ? [{ type: :text, content: html }] : segments
41
+ end
42
+
43
+ def process_segment(content)
44
+ content.gsub(ICON_PATTERN) do
45
+ icon_name = Regexp.last_match(1)
46
+ weight = Regexp.last_match(2) || "regular"
47
+ Icons.render(icon_name, weight) || Regexp.last_match(0)
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../base_processor"
4
+
5
+ module Docyard
6
+ module Components
7
+ module Processors
8
+ class TableOfContentsProcessor < BaseProcessor
9
+ self.priority = 35
10
+
11
+ def postprocess(html)
12
+ headings = extract_headings(html)
13
+ context[:toc] = headings
14
+ html
15
+ end
16
+
17
+ private
18
+
19
+ def extract_headings(html)
20
+ headings = []
21
+
22
+ html.scan(%r{<(h[2-4])\s+id="([^"]+)">(.*?)</\1>}m) do
23
+ level = Regexp.last_match(1)[1].to_i
24
+ id = Regexp.last_match(2)
25
+ text = strip_html(Regexp.last_match(3))
26
+
27
+ headings << {
28
+ level: level,
29
+ id: id,
30
+ text: text
31
+ }
32
+ end
33
+
34
+ build_hierarchy(headings)
35
+ end
36
+
37
+ def build_hierarchy(headings)
38
+ return [] if headings.empty?
39
+
40
+ root = []
41
+ stack = []
42
+
43
+ headings.each do |heading|
44
+ heading[:children] = []
45
+
46
+ stack.pop while stack.any? && stack.last[:level] >= heading[:level]
47
+
48
+ if stack.empty?
49
+ root << heading
50
+ else
51
+ stack.last[:children] << heading
52
+ end
53
+
54
+ stack << heading
55
+ end
56
+
57
+ root
58
+ end
59
+
60
+ def strip_html(text)
61
+ text.gsub(%r{<a[^>]*class="heading-anchor"[^>]*>.*?</a>}, "")
62
+ .gsub(/<[^>]+>/, "")
63
+ .strip
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../base_processor"
4
+
5
+ module Docyard
6
+ module Components
7
+ module Processors
8
+ class TableWrapperProcessor < BaseProcessor
9
+ self.priority = 100
10
+
11
+ def postprocess(html)
12
+ wrapped = html.gsub(/<table([^>]*)>/) do
13
+ attributes = Regexp.last_match(1)
14
+ "<div class=\"table-wrapper\"><table#{attributes}>"
15
+ end
16
+
17
+ wrapped.gsub("</table>", "</table></div>")
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../rendering/renderer"
4
+ require_relative "../base_processor"
5
+ require_relative "../support/tabs/parser"
6
+ require "securerandom"
7
+
8
+ module Docyard
9
+ module Components
10
+ module Processors
11
+ class TabsProcessor < BaseProcessor
12
+ self.priority = 15
13
+
14
+ TabsParser = Support::Tabs::Parser
15
+
16
+ def preprocess(content)
17
+ return content unless content.include?(":::tabs")
18
+
19
+ content.gsub(/^:::[ \t]*tabs[ \t]*\n(.*?)^:::[ \t]*$/m) do
20
+ process_tabs_block(Regexp.last_match(1))
21
+ end
22
+ end
23
+
24
+ private
25
+
26
+ def process_tabs_block(tabs_content)
27
+ tabs = TabsParser.parse(tabs_content)
28
+ return "" if tabs.empty?
29
+
30
+ wrap_in_nomarkdown(render_tabs(tabs))
31
+ end
32
+
33
+ def render_tabs(tabs)
34
+ Renderer.new.render_partial(
35
+ "_tabs", {
36
+ tabs: tabs,
37
+ group_id: SecureRandom.hex(4)
38
+ }
39
+ )
40
+ end
41
+
42
+ def wrap_in_nomarkdown(html)
43
+ "{::nomarkdown}\n#{html}\n{:/nomarkdown}"
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "patterns"
4
+
5
+ module Docyard
6
+ module Components
7
+ module Support
8
+ module CodeBlock
9
+ module FeatureExtractor
10
+ include Patterns
11
+
12
+ CODE_FENCE_REGEX = /^```(\w+)(?:\s*\[([^\]]+)\])?(:\S+)?(?:\s*\{([^}\n]+)\})?[ \t]*\n(.*?)^```/m
13
+
14
+ module_function
15
+
16
+ def process_markdown(markdown)
17
+ blocks = []
18
+ cleaned = markdown.gsub(CODE_FENCE_REGEX) do
19
+ block_data = extract_block_data(Regexp.last_match)
20
+ blocks << block_data
21
+ "```#{block_data[:lang]}\n#{block_data[:cleaned_content]}```"
22
+ end
23
+ { cleaned_markdown: cleaned, blocks: blocks }
24
+ end
25
+
26
+ def extract_block_data(match)
27
+ code_content = match[5]
28
+ diff_info = extract_diff_lines(code_content)
29
+ focus_info = extract_focus_lines(diff_info[:cleaned_content])
30
+ error_info = extract_error_lines(focus_info[:cleaned_content])
31
+ warning_info = extract_warning_lines(error_info[:cleaned_content])
32
+
33
+ build_block_result(match, diff_info, focus_info, error_info, warning_info)
34
+ end
35
+
36
+ def build_block_result(match, diff_info, focus_info, error_info, warning_info)
37
+ {
38
+ lang: match[1],
39
+ title: match[2],
40
+ option: match[3],
41
+ highlights: parse_highlights(match[4]),
42
+ diff_lines: diff_info[:lines],
43
+ focus_lines: focus_info[:lines],
44
+ error_lines: error_info[:lines],
45
+ warning_lines: warning_info[:lines],
46
+ cleaned_content: warning_info[:cleaned_content]
47
+ }
48
+ end
49
+
50
+ def parse_highlights(highlights_str)
51
+ return [] if highlights_str.nil? || highlights_str.strip.empty?
52
+
53
+ highlights_str.split(",").flat_map { |part| parse_highlight_part(part.strip) }.uniq.sort
54
+ end
55
+
56
+ def parse_highlight_part(part)
57
+ return (part.split("-")[0].to_i..part.split("-")[1].to_i).to_a if part.include?("-")
58
+
59
+ [part.to_i]
60
+ end
61
+
62
+ def extract_diff_lines(code_content)
63
+ lines = code_content.lines
64
+ diff_lines = {}
65
+ cleaned_lines = []
66
+
67
+ lines.each_with_index do |line, index|
68
+ line_num = index + 1
69
+
70
+ if (match = line.match(DIFF_MARKER_PATTERN))
71
+ diff_type = match.captures.compact.first
72
+ diff_lines[line_num] = diff_type == "++" ? :addition : :deletion
73
+ cleaned_line = line.gsub(DIFF_MARKER_PATTERN, "")
74
+ cleaned_lines << cleaned_line
75
+ else
76
+ cleaned_lines << line
77
+ end
78
+ end
79
+
80
+ { lines: diff_lines, cleaned_content: cleaned_lines.join }
81
+ end
82
+
83
+ def extract_focus_lines(code_content)
84
+ extract_marker_lines(code_content, FOCUS_MARKER_PATTERN)
85
+ end
86
+
87
+ def extract_error_lines(code_content)
88
+ extract_marker_lines(code_content, ERROR_MARKER_PATTERN)
89
+ end
90
+
91
+ def extract_warning_lines(code_content)
92
+ extract_marker_lines(code_content, WARNING_MARKER_PATTERN)
93
+ end
94
+
95
+ def extract_marker_lines(code_content, pattern)
96
+ lines = code_content.lines
97
+ marker_lines = {}
98
+ cleaned_lines = []
99
+
100
+ lines.each_with_index do |line, index|
101
+ line_num = index + 1
102
+
103
+ if line.match?(pattern)
104
+ marker_lines[line_num] = true
105
+ cleaned_lines << line.gsub(pattern, "")
106
+ else
107
+ cleaned_lines << line
108
+ end
109
+ end
110
+
111
+ { lines: marker_lines, cleaned_content: cleaned_lines.join }
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../../rendering/language_mapping"
4
+
5
+ module Docyard
6
+ module Components
7
+ module Support
8
+ module CodeBlock
9
+ module IconDetector
10
+ MANUAL_ICON_PATTERN = /^:([a-z0-9-]+):\s*(.+)$/i
11
+
12
+ module_function
13
+
14
+ def detect(title, language)
15
+ return { title: nil, icon: nil, icon_source: nil } if title.nil?
16
+
17
+ if (match = title.match(MANUAL_ICON_PATTERN))
18
+ return {
19
+ title: match[2].strip,
20
+ icon: match[1],
21
+ icon_source: "phosphor"
22
+ }
23
+ end
24
+
25
+ icon, icon_source = auto_detect_icon(language)
26
+ { title: title, icon: icon, icon_source: icon_source }
27
+ end
28
+
29
+ def auto_detect_icon(language)
30
+ return [nil, nil] if language.nil?
31
+
32
+ if LanguageMapping.terminal_language?(language)
33
+ %w[terminal-window phosphor]
34
+ elsif (ext = LanguageMapping.extension_for(language))
35
+ [ext, "file-extension"]
36
+ else
37
+ %w[file phosphor]
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end