docyard 0.7.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 (155) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +5 -1
  3. data/CHANGELOG.md +43 -1
  4. data/lib/docyard/build/asset_bundler.rb +22 -7
  5. data/lib/docyard/build/file_copier.rb +49 -27
  6. data/lib/docyard/build/sitemap_generator.rb +6 -6
  7. data/lib/docyard/build/static_generator.rb +85 -12
  8. data/lib/docyard/builder.rb +6 -6
  9. data/lib/docyard/components/aliases.rb +12 -0
  10. data/lib/docyard/components/processors/abbreviation_processor.rb +72 -0
  11. data/lib/docyard/components/processors/accordion_processor.rb +81 -0
  12. data/lib/docyard/components/processors/badge_processor.rb +72 -0
  13. data/lib/docyard/components/processors/callout_processor.rb +8 -2
  14. data/lib/docyard/components/processors/cards_processor.rb +100 -0
  15. data/lib/docyard/components/processors/code_block_options_preprocessor.rb +23 -2
  16. data/lib/docyard/components/processors/code_block_processor.rb +6 -0
  17. data/lib/docyard/components/processors/code_group_processor.rb +198 -0
  18. data/lib/docyard/components/processors/code_snippet_import_preprocessor.rb +6 -1
  19. data/lib/docyard/components/processors/custom_anchor_processor.rb +42 -0
  20. data/lib/docyard/components/processors/file_tree_processor.rb +151 -0
  21. data/lib/docyard/components/processors/image_caption_processor.rb +96 -0
  22. data/lib/docyard/components/processors/include_processor.rb +86 -0
  23. data/lib/docyard/components/processors/steps_processor.rb +89 -0
  24. data/lib/docyard/components/processors/tabs_processor.rb +9 -1
  25. data/lib/docyard/components/processors/tooltip_processor.rb +57 -0
  26. data/lib/docyard/components/processors/video_embed_processor.rb +196 -0
  27. data/lib/docyard/components/support/code_group/html_builder.rb +122 -0
  28. data/lib/docyard/components/support/markdown_code_block_helper.rb +56 -0
  29. data/lib/docyard/config/branding_resolver.rb +121 -17
  30. data/lib/docyard/config/constants.rb +6 -4
  31. data/lib/docyard/config/logo_detector.rb +39 -0
  32. data/lib/docyard/config/validator.rb +122 -99
  33. data/lib/docyard/config.rb +40 -42
  34. data/lib/docyard/initializer.rb +15 -76
  35. data/lib/docyard/navigation/breadcrumb_builder.rb +133 -0
  36. data/lib/docyard/navigation/prev_next_builder.rb +4 -1
  37. data/lib/docyard/navigation/sidebar/children_discoverer.rb +51 -0
  38. data/lib/docyard/navigation/sidebar/config_parser.rb +136 -108
  39. data/lib/docyard/navigation/sidebar/file_resolver.rb +90 -0
  40. data/lib/docyard/navigation/sidebar/file_system_scanner.rb +2 -1
  41. data/lib/docyard/navigation/sidebar/item.rb +50 -7
  42. data/lib/docyard/navigation/sidebar/local_config_loader.rb +51 -0
  43. data/lib/docyard/navigation/sidebar/metadata_extractor.rb +71 -0
  44. data/lib/docyard/navigation/sidebar/metadata_reader.rb +51 -0
  45. data/lib/docyard/navigation/sidebar/path_prefixer.rb +34 -0
  46. data/lib/docyard/navigation/sidebar/renderer.rb +60 -38
  47. data/lib/docyard/navigation/sidebar/sorter.rb +21 -0
  48. data/lib/docyard/navigation/sidebar/tree_builder.rb +100 -26
  49. data/lib/docyard/navigation/sidebar/tree_filter.rb +55 -0
  50. data/lib/docyard/navigation/sidebar_builder.rb +105 -36
  51. data/lib/docyard/rendering/icon_helpers.rb +13 -0
  52. data/lib/docyard/rendering/icons/phosphor.rb +26 -1
  53. data/lib/docyard/rendering/markdown.rb +29 -1
  54. data/lib/docyard/rendering/renderer.rb +75 -34
  55. data/lib/docyard/rendering/template_resolver.rb +172 -0
  56. data/lib/docyard/routing/fallback_resolver.rb +92 -0
  57. data/lib/docyard/search/build_indexer.rb +1 -1
  58. data/lib/docyard/search/dev_indexer.rb +51 -6
  59. data/lib/docyard/search/pagefind_support.rb +2 -0
  60. data/lib/docyard/server/asset_handler.rb +25 -19
  61. data/lib/docyard/server/pagefind_handler.rb +63 -0
  62. data/lib/docyard/server/preview_server.rb +1 -1
  63. data/lib/docyard/server/rack_application.rb +81 -64
  64. data/lib/docyard/templates/assets/css/code.css +18 -51
  65. data/lib/docyard/templates/assets/css/components/abbreviation.css +86 -0
  66. data/lib/docyard/templates/assets/css/components/accordion.css +138 -0
  67. data/lib/docyard/templates/assets/css/components/badges.css +47 -0
  68. data/lib/docyard/templates/assets/css/components/banner.css +202 -0
  69. data/lib/docyard/templates/assets/css/components/breadcrumbs.css +143 -0
  70. data/lib/docyard/templates/assets/css/components/callout.css +67 -67
  71. data/lib/docyard/templates/assets/css/components/cards.css +100 -0
  72. data/lib/docyard/templates/assets/css/components/code-block.css +190 -282
  73. data/lib/docyard/templates/assets/css/components/code-group.css +281 -0
  74. data/lib/docyard/templates/assets/css/components/figure.css +22 -0
  75. data/lib/docyard/templates/assets/css/components/file-tree.css +124 -0
  76. data/lib/docyard/templates/assets/css/components/heading-anchor.css +36 -15
  77. data/lib/docyard/templates/assets/css/components/icon.css +0 -1
  78. data/lib/docyard/templates/assets/css/components/lightbox.css +65 -0
  79. data/lib/docyard/templates/assets/css/components/logo.css +0 -2
  80. data/lib/docyard/templates/assets/css/components/nav-menu.css +237 -0
  81. data/lib/docyard/templates/assets/css/components/navigation.css +193 -167
  82. data/lib/docyard/templates/assets/css/components/prev-next.css +68 -48
  83. data/lib/docyard/templates/assets/css/components/search.css +186 -174
  84. data/lib/docyard/templates/assets/css/components/steps.css +122 -0
  85. data/lib/docyard/templates/assets/css/components/tab-bar.css +163 -0
  86. data/lib/docyard/templates/assets/css/components/table-of-contents.css +127 -114
  87. data/lib/docyard/templates/assets/css/components/tabs.css +119 -160
  88. data/lib/docyard/templates/assets/css/components/theme-toggle.css +48 -44
  89. data/lib/docyard/templates/assets/css/components/tooltip.css +113 -0
  90. data/lib/docyard/templates/assets/css/components/video.css +41 -0
  91. data/lib/docyard/templates/assets/css/landing.css +815 -0
  92. data/lib/docyard/templates/assets/css/layout.css +489 -87
  93. data/lib/docyard/templates/assets/css/main.css +1 -3
  94. data/lib/docyard/templates/assets/css/markdown.css +113 -93
  95. data/lib/docyard/templates/assets/css/reset.css +0 -3
  96. data/lib/docyard/templates/assets/css/typography.css +43 -41
  97. data/lib/docyard/templates/assets/css/variables.css +268 -208
  98. data/lib/docyard/templates/assets/favicon.svg +7 -8
  99. data/lib/docyard/templates/assets/fonts/Inter-Variable.ttf +0 -0
  100. data/lib/docyard/templates/assets/js/components/abbreviation.js +85 -0
  101. data/lib/docyard/templates/assets/js/components/banner.js +81 -0
  102. data/lib/docyard/templates/assets/js/components/code-block.js +24 -42
  103. data/lib/docyard/templates/assets/js/components/code-group.js +283 -0
  104. data/lib/docyard/templates/assets/js/components/file-tree.js +39 -0
  105. data/lib/docyard/templates/assets/js/components/heading-anchor.js +26 -24
  106. data/lib/docyard/templates/assets/js/components/lightbox.js +72 -0
  107. data/lib/docyard/templates/assets/js/components/navigation.js +181 -70
  108. data/lib/docyard/templates/assets/js/components/search.js +0 -75
  109. data/lib/docyard/templates/assets/js/components/sidebar-toggle.js +29 -0
  110. data/lib/docyard/templates/assets/js/components/tab-navigation.js +145 -0
  111. data/lib/docyard/templates/assets/js/components/table-of-contents.js +153 -66
  112. data/lib/docyard/templates/assets/js/components/tabs.js +31 -69
  113. data/lib/docyard/templates/assets/js/components/tooltip.js +118 -0
  114. data/lib/docyard/templates/assets/js/theme.js +0 -3
  115. data/lib/docyard/templates/assets/logo-dark.svg +8 -2
  116. data/lib/docyard/templates/assets/logo.svg +7 -4
  117. data/lib/docyard/templates/config/docyard.yml.erb +37 -34
  118. data/lib/docyard/templates/errors/404.html.erb +1 -1
  119. data/lib/docyard/templates/errors/500.html.erb +1 -1
  120. data/lib/docyard/templates/layouts/default.html.erb +19 -67
  121. data/lib/docyard/templates/layouts/splash.html.erb +177 -0
  122. data/lib/docyard/templates/partials/_accordion.html.erb +9 -0
  123. data/lib/docyard/templates/partials/_banner.html.erb +27 -0
  124. data/lib/docyard/templates/partials/_breadcrumbs.html.erb +24 -0
  125. data/lib/docyard/templates/partials/_card.html.erb +23 -0
  126. data/lib/docyard/templates/partials/_code_block.html.erb +5 -3
  127. data/lib/docyard/templates/partials/_doc_footer.html.erb +25 -0
  128. data/lib/docyard/templates/partials/_features.html.erb +15 -0
  129. data/lib/docyard/templates/partials/_footer.html.erb +42 -0
  130. data/lib/docyard/templates/partials/_head.html.erb +22 -0
  131. data/lib/docyard/templates/partials/_header.html.erb +49 -0
  132. data/lib/docyard/templates/partials/_heading_anchor.html.erb +3 -1
  133. data/lib/docyard/templates/partials/_hero.html.erb +27 -0
  134. data/lib/docyard/templates/partials/_nav_group.html.erb +31 -11
  135. data/lib/docyard/templates/partials/_nav_leaf.html.erb +4 -1
  136. data/lib/docyard/templates/partials/_nav_menu.html.erb +42 -0
  137. data/lib/docyard/templates/partials/_nav_nested_section.html.erb +11 -0
  138. data/lib/docyard/templates/partials/_nav_section.html.erb +1 -1
  139. data/lib/docyard/templates/partials/_prev_next.html.erb +8 -2
  140. data/lib/docyard/templates/partials/_scripts.html.erb +7 -0
  141. data/lib/docyard/templates/partials/_search_modal.html.erb +2 -6
  142. data/lib/docyard/templates/partials/_search_trigger.html.erb +2 -6
  143. data/lib/docyard/templates/partials/_sidebar.html.erb +21 -4
  144. data/lib/docyard/templates/partials/_step.html.erb +14 -0
  145. data/lib/docyard/templates/partials/_tab_bar.html.erb +25 -0
  146. data/lib/docyard/templates/partials/_table_of_contents.html.erb +12 -12
  147. data/lib/docyard/templates/partials/_table_of_contents_toggle.html.erb +1 -3
  148. data/lib/docyard/templates/partials/_tabs.html.erb +2 -2
  149. data/lib/docyard/templates/partials/_theme_toggle.html.erb +2 -11
  150. data/lib/docyard/version.rb +1 -1
  151. metadata +70 -5
  152. data/lib/docyard/templates/markdown/getting-started/installation.md.erb +0 -77
  153. data/lib/docyard/templates/markdown/guides/configuration.md.erb +0 -202
  154. data/lib/docyard/templates/markdown/guides/markdown-features.md.erb +0 -247
  155. data/lib/docyard/templates/markdown/index.md.erb +0 -82
@@ -0,0 +1,196 @@
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 VideoEmbedProcessor < BaseProcessor
10
+ include Support::MarkdownCodeBlockHelper
11
+
12
+ YOUTUBE_PATTERN = /::youtube\[([^\]]+)\](?:\{([^}]*)\})?/
13
+ VIMEO_PATTERN = /::vimeo\[([^\]]+)\](?:\{([^}]*)\})?/
14
+ VIDEO_PATTERN = /::video\[([^\]]+)\](?:\{([^}]*)\})?/
15
+
16
+ self.priority = 5
17
+
18
+ def preprocess(content)
19
+ process_outside_code_blocks(content) do |segment|
20
+ process_video_embeds(segment)
21
+ end
22
+ end
23
+
24
+ private
25
+
26
+ def process_video_embeds(content)
27
+ result = content.gsub(YOUTUBE_PATTERN) do
28
+ video_id = Regexp.last_match(1)
29
+ attrs_string = Regexp.last_match(2)
30
+ build_youtube_embed(video_id, parse_attributes(attrs_string))
31
+ end
32
+
33
+ result = result.gsub(VIMEO_PATTERN) do
34
+ video_id = Regexp.last_match(1)
35
+ attrs_string = Regexp.last_match(2)
36
+ build_vimeo_embed(video_id, parse_attributes(attrs_string))
37
+ end
38
+
39
+ result.gsub(VIDEO_PATTERN) do
40
+ src = Regexp.last_match(1)
41
+ attrs_string = Regexp.last_match(2)
42
+ build_native_video(src, parse_attributes(attrs_string))
43
+ end
44
+ end
45
+
46
+ def parse_attributes(attrs_string)
47
+ return {} if attrs_string.nil? || attrs_string.empty?
48
+
49
+ attrs = {}
50
+
51
+ attrs_string.scan(/(\w+)="([^"]*)"/) do |key, value|
52
+ attrs[key] = value
53
+ end
54
+
55
+ %w[autoplay loop muted nofullscreen controls playsinline].each do |flag|
56
+ attrs[flag] = true if attrs_string.match?(/\b#{flag}\b/) && !attrs.key?(flag)
57
+ end
58
+
59
+ attrs
60
+ end
61
+
62
+ def build_youtube_embed(video_id, attrs)
63
+ params = youtube_params(attrs)
64
+ url = "https://www.youtube-nocookie.com/embed/#{escape_attr(video_id)}"
65
+ url += "?#{params.join('&')}" unless params.empty?
66
+
67
+ build_iframe(url, attrs, "youtube")
68
+ end
69
+
70
+ def build_vimeo_embed(video_id, attrs)
71
+ params = vimeo_params(attrs)
72
+ url = "https://player.vimeo.com/video/#{escape_attr(video_id)}"
73
+ url += "?#{params.join('&')}" unless params.empty?
74
+
75
+ build_iframe(url, attrs, "vimeo")
76
+ end
77
+
78
+ def build_native_video(src, attrs)
79
+ wrapper_style = build_wrapper_style(attrs)
80
+ video_attrs = build_video_attrs(src, attrs)
81
+
82
+ "\n\n" \
83
+ "<div class=\"docyard-video docyard-video--native\"#{wrapper_style} markdown=\"0\">\n " \
84
+ "<video #{video_attrs.join(' ')}></video>\n" \
85
+ "</div>" \
86
+ "\n\n"
87
+ end
88
+
89
+ def build_video_attrs(src, attrs)
90
+ video_attrs = ["src=\"#{escape_attr(src)}\""]
91
+ video_attrs.concat(video_optional_attrs(attrs))
92
+ video_attrs.concat(video_boolean_attrs(attrs))
93
+ video_attrs
94
+ end
95
+
96
+ def video_optional_attrs(attrs)
97
+ result = []
98
+ result << "poster=\"#{escape_attr(attrs['poster'])}\"" if attrs["poster"]
99
+ result << "preload=\"#{escape_attr(attrs['preload'])}\"" if attrs["preload"]
100
+ result
101
+ end
102
+
103
+ def video_boolean_attrs(attrs)
104
+ result = []
105
+ result << "controls" unless controls_disabled?(attrs)
106
+ result << "autoplay" if attrs["autoplay"]
107
+ result << "muted" if attrs["muted"]
108
+ result << "loop" if attrs["loop"]
109
+ result << "playsinline" if attrs["playsinline"]
110
+ result
111
+ end
112
+
113
+ def controls_disabled?(attrs)
114
+ ["false", false].include?(attrs["controls"])
115
+ end
116
+
117
+ def youtube_params(attrs)
118
+ params = []
119
+ params << "autoplay=1" if attrs["autoplay"]
120
+ params << "loop=1" if attrs["loop"]
121
+ params << "mute=1" if attrs["muted"]
122
+ params << "controls=0" if ["false", false].include?(attrs["controls"])
123
+ params << "start=#{attrs['start']}" if attrs["start"]
124
+ params << "rel=0"
125
+ params
126
+ end
127
+
128
+ def vimeo_params(attrs)
129
+ params = []
130
+ params << "autoplay=1" if attrs["autoplay"]
131
+ params << "loop=1" if attrs["loop"]
132
+ params << "muted=1" if attrs["muted"]
133
+ params << "controls=0" if ["false", false].include?(attrs["controls"])
134
+ params << "dnt=1"
135
+ params
136
+ end
137
+
138
+ def build_iframe(url, attrs, provider)
139
+ wrapper_style = build_wrapper_style(attrs)
140
+ iframe_attrs = build_iframe_attrs(url, attrs, provider)
141
+
142
+ "\n\n" \
143
+ "<div class=\"docyard-video docyard-video--#{provider}\"#{wrapper_style} markdown=\"0\">\n " \
144
+ "<iframe #{iframe_attrs.join(' ')}></iframe>\n" \
145
+ "</div>" \
146
+ "\n\n"
147
+ end
148
+
149
+ def build_wrapper_style(attrs)
150
+ return "" unless attrs["width"] || attrs["height"]
151
+
152
+ styles = []
153
+ styles << "max-width: #{escape_attr(attrs['width'])}px" if attrs["width"]
154
+ styles << "height: #{escape_attr(attrs['height'])}px" if attrs["height"]
155
+ " style=\"#{styles.join('; ')}\""
156
+ end
157
+
158
+ def build_iframe_attrs(url, attrs, provider)
159
+ iframe_attrs = [
160
+ "src=\"#{url}\"",
161
+ "title=\"#{escape_attr(attrs['title'] || default_title(provider))}\"",
162
+ "frameborder=\"0\""
163
+ ]
164
+
165
+ iframe_attrs << "allow=\"#{build_allow_attr(attrs)}\""
166
+ iframe_attrs << "allowfullscreen" unless attrs["nofullscreen"]
167
+
168
+ iframe_attrs
169
+ end
170
+
171
+ def build_allow_attr(attrs)
172
+ permissions = %w[encrypted-media picture-in-picture web-share]
173
+ permissions.unshift("autoplay") if attrs["autoplay"]
174
+ permissions << "fullscreen" unless attrs["nofullscreen"]
175
+ permissions.join("; ")
176
+ end
177
+
178
+ def default_title(provider)
179
+ case provider
180
+ when "youtube" then "YouTube video player"
181
+ when "vimeo" then "Vimeo video player"
182
+ else "Video player"
183
+ end
184
+ end
185
+
186
+ def escape_attr(text)
187
+ text.to_s
188
+ .gsub("&", "&amp;")
189
+ .gsub("<", "&lt;")
190
+ .gsub(">", "&gt;")
191
+ .gsub('"', "&quot;")
192
+ end
193
+ end
194
+ end
195
+ end
196
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../code_block/icon_detector"
4
+ require_relative "../../../rendering/icons"
5
+
6
+ module Docyard
7
+ module Components
8
+ module Support
9
+ module CodeGroup
10
+ class HtmlBuilder
11
+ def initialize(blocks, group_id)
12
+ @blocks = blocks
13
+ @group_id = group_id
14
+ end
15
+
16
+ def build
17
+ <<~HTML
18
+ <div class="docyard-code-group" data-code-group="#{@group_id}">
19
+ <div class="docyard-code-group__tabs-wrapper">
20
+ <div class="docyard-code-group__tabs-scroll-container">
21
+ <div role="tablist" aria-label="Code examples" class="docyard-code-group__tabs">
22
+ #{build_tabs}
23
+ <div class="docyard-code-group__indicator" aria-hidden="true"></div>
24
+ </div>
25
+ </div>
26
+ #{build_copy_button}
27
+ </div>
28
+ <div class="docyard-code-group__panels">
29
+ #{build_panels}
30
+ </div>
31
+ </div>
32
+ HTML
33
+ end
34
+
35
+ private
36
+
37
+ def build_tabs
38
+ @blocks.each_with_index.map do |block, index|
39
+ build_tab(block, index)
40
+ end.join("\n")
41
+ end
42
+
43
+ def build_tab(block, index)
44
+ selected = index.zero? ? "true" : "false"
45
+ tabindex = index.zero? ? "0" : "-1"
46
+ icon_html = render_icon(block[:lang])
47
+ <<~HTML.strip
48
+ <button
49
+ role="tab"
50
+ aria-selected="#{selected}"
51
+ aria-controls="cg-panel-#{@group_id}-#{index}"
52
+ id="cg-tab-#{@group_id}-#{index}"
53
+ class="docyard-code-group__tab"
54
+ tabindex="#{tabindex}"
55
+ data-label="#{escape_html(block[:label])}"
56
+ >#{icon_html}#{escape_html(block[:label])}</button>
57
+ HTML
58
+ end
59
+
60
+ def render_icon(lang)
61
+ return "" if lang.nil? || lang.empty?
62
+
63
+ icon, icon_source = CodeBlock::IconDetector.auto_detect_icon(lang)
64
+ return "" unless icon && icon_source == "file-extension"
65
+
66
+ Icons.render_file_extension(icon) || ""
67
+ end
68
+
69
+ def build_copy_button
70
+ copy_icon = Icons.render("copy", "regular") || ""
71
+ <<~HTML.strip
72
+ <button class="docyard-code-group__copy" aria-label="Copy code to clipboard">
73
+ <span class="docyard-code-group__copy-icon">#{copy_icon}</span>
74
+ <span class="docyard-code-group__copy-text">Copy</span>
75
+ </button>
76
+ HTML
77
+ end
78
+
79
+ def build_panels
80
+ @blocks.each_with_index.map do |block, index|
81
+ build_panel(block, index)
82
+ end.join("\n")
83
+ end
84
+
85
+ def build_panel(block, index)
86
+ hidden = index.zero? ? "false" : "true"
87
+ code_text = escape_html_attribute(block[:code_text] || "")
88
+ <<~HTML.strip
89
+ <div
90
+ role="tabpanel"
91
+ id="cg-panel-#{@group_id}-#{index}"
92
+ aria-labelledby="cg-tab-#{@group_id}-#{index}"
93
+ aria-hidden="#{hidden}"
94
+ class="docyard-code-group__panel"
95
+ tabindex="0"
96
+ data-code="#{code_text}"
97
+ >#{block[:content]}</div>
98
+ HTML
99
+ end
100
+
101
+ def escape_html(text)
102
+ text.to_s
103
+ .gsub("&", "&amp;")
104
+ .gsub("<", "&lt;")
105
+ .gsub(">", "&gt;")
106
+ .gsub('"', "&quot;")
107
+ end
108
+
109
+ def escape_html_attribute(text)
110
+ text.to_s
111
+ .gsub("&", "&amp;")
112
+ .gsub("<", "&lt;")
113
+ .gsub(">", "&gt;")
114
+ .gsub('"', "&quot;")
115
+ .gsub("'", "&#39;")
116
+ .gsub("\n", "&#10;")
117
+ end
118
+ end
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docyard
4
+ module Components
5
+ module Support
6
+ module MarkdownCodeBlockHelper
7
+ FENCED_CODE_BLOCK_REGEX = /^(`{3,}|~{3,})[^\n]*\n.*?^\1\s*$/m
8
+
9
+ def process_outside_code_blocks(content)
10
+ segments = split_by_code_blocks(content)
11
+
12
+ segments.map do |segment|
13
+ segment[:type] == :code ? segment[:content] : yield(segment[:content])
14
+ end.join
15
+ end
16
+
17
+ def find_code_block_ranges(content, exclude_language: nil)
18
+ ranges = []
19
+ content.scan(FENCED_CODE_BLOCK_REGEX) do
20
+ match = Regexp.last_match
21
+ next if exclude_language && match[0] =~ /\A[`~]{3,}#{exclude_language}\b/
22
+
23
+ ranges << (match.begin(0)...match.end(0))
24
+ end
25
+ ranges
26
+ end
27
+
28
+ def inside_code_block?(position, ranges)
29
+ ranges.any? { |range| range.cover?(position) }
30
+ end
31
+
32
+ private
33
+
34
+ def split_by_code_blocks(content)
35
+ segments = []
36
+ current_pos = 0
37
+
38
+ content.scan(FENCED_CODE_BLOCK_REGEX) do
39
+ match = Regexp.last_match
40
+ match_start = match.begin(0)
41
+ match_end = match.end(0)
42
+
43
+ segments << { type: :text, content: content[current_pos...match_start] } if match_start > current_pos
44
+
45
+ segments << { type: :code, content: content[match_start...match_end] }
46
+ current_pos = match_end
47
+ end
48
+
49
+ segments << { type: :text, content: content[current_pos..] } if current_pos < content.length
50
+
51
+ segments.empty? ? [{ type: :text, content: content }] : segments
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -1,11 +1,22 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "logo_detector"
4
+
3
5
  module Docyard
4
6
  class BrandingResolver
5
7
  def initialize(config)
6
8
  @config = config
7
9
  end
8
10
 
11
+ SOCIAL_ICON_MAP = {
12
+ "x" => "x-logo", "twitter" => "x-logo", "discord" => "discord-logo",
13
+ "linkedin" => "linkedin-logo", "youtube" => "youtube-logo", "instagram" => "instagram-logo",
14
+ "facebook" => "facebook-logo", "tiktok" => "tiktok-logo", "twitch" => "twitch-logo",
15
+ "reddit" => "reddit-logo", "mastodon" => "mastodon-logo", "threads" => "threads-logo",
16
+ "pinterest" => "pinterest-logo", "medium" => "medium-logo", "slack" => "slack-logo",
17
+ "gitlab" => "gitlab-logo"
18
+ }.freeze
19
+
9
20
  def resolve
10
21
  return default_branding unless config
11
22
 
@@ -23,52 +34,145 @@ module Docyard
23
34
  logo: Constants::DEFAULT_LOGO_PATH,
24
35
  logo_dark: Constants::DEFAULT_LOGO_DARK_PATH,
25
36
  favicon: nil,
26
- display_logo: true,
27
- display_title: true
37
+ credits: true,
38
+ social: []
28
39
  }
29
40
  end
30
41
 
31
42
  def config_branding_options
32
- site_options.merge(logo_options).merge(search_options).merge(appearance_options)
43
+ site_options
44
+ .merge(logo_options)
45
+ .merge(search_options)
46
+ .merge(credits_options)
47
+ .merge(social_options)
48
+ .merge(navigation_options)
49
+ .merge(tabs_options)
50
+ .merge(announcement_options)
33
51
  end
34
52
 
35
53
  def site_options
36
54
  {
37
- site_title: config.site.title || Constants::DEFAULT_SITE_TITLE,
38
- site_description: config.site.description || "",
39
- favicon: config.branding.favicon
55
+ site_title: config.title || Constants::DEFAULT_SITE_TITLE,
56
+ site_description: config.description || "",
57
+ favicon: config.branding.favicon || LogoDetector.auto_detect_favicon
40
58
  }
41
59
  end
42
60
 
43
61
  def logo_options
44
62
  branding = config.branding
63
+ logo = branding.logo || LogoDetector.auto_detect_logo
64
+ has_custom_logo = !logo.nil?
45
65
  {
46
- logo: resolve_logo(branding.logo, branding.logo_dark),
47
- logo_dark: resolve_logo_dark(branding.logo, branding.logo_dark)
66
+ logo: logo || Constants::DEFAULT_LOGO_PATH,
67
+ logo_dark: LogoDetector.detect_dark_logo(logo) || Constants::DEFAULT_LOGO_DARK_PATH,
68
+ has_custom_logo: has_custom_logo
48
69
  }
49
70
  end
50
71
 
51
72
  def search_options
52
73
  {
53
74
  search_enabled: config.search.enabled != false,
54
- search_placeholder: config.search.placeholder || "Search documentation..."
75
+ search_placeholder: config.search.placeholder || "Search..."
76
+ }
77
+ end
78
+
79
+ def credits_options
80
+ {
81
+ credits: config.branding.credits != false,
82
+ copyright: config.branding.copyright
83
+ }
84
+ end
85
+
86
+ def social_options
87
+ socials = config.socials || {}
88
+ {
89
+ social: normalize_social_links(socials)
90
+ }
91
+ end
92
+
93
+ def normalize_social_links(socials)
94
+ return [] unless socials.is_a?(Hash) && socials.any?
95
+
96
+ socials.filter_map { |platform, url| build_social_link(platform.to_s, url) }
97
+ end
98
+
99
+ def build_social_link(platform, url)
100
+ return if platform == "custom" || !valid_url?(url)
101
+
102
+ { platform: platform, url: url, icon: SOCIAL_ICON_MAP[platform] || platform }
103
+ end
104
+
105
+ def valid_url?(url)
106
+ url.is_a?(String) && !url.strip.empty?
107
+ end
108
+
109
+ def navigation_options
110
+ cta_items = config.navigation.cta || []
111
+ {
112
+ header_ctas: normalize_cta_items(cta_items)
55
113
  }
56
114
  end
57
115
 
58
- def appearance_options
59
- appearance = config.branding.appearance || {}
116
+ def normalize_cta_items(items)
117
+ return [] unless items.is_a?(Array)
118
+
119
+ items.first(2).filter_map do |item|
120
+ next unless item.is_a?(Hash) && item["text"] && item["href"]
121
+
122
+ {
123
+ text: item["text"],
124
+ href: item["href"],
125
+ variant: item["variant"] || "primary",
126
+ external: item["external"] == true
127
+ }
128
+ end
129
+ end
130
+
131
+ def tabs_options
132
+ tab_items = config.tabs || []
60
133
  {
61
- display_logo: appearance["logo"] != false,
62
- display_title: appearance["title"] != false
134
+ tabs: normalize_tab_items(tab_items),
135
+ has_tabs: tab_items.any?
63
136
  }
64
137
  end
65
138
 
66
- def resolve_logo(logo, logo_dark)
67
- logo || logo_dark || Constants::DEFAULT_LOGO_PATH
139
+ def normalize_tab_items(items)
140
+ return [] unless items.is_a?(Array)
141
+
142
+ items.filter_map do |item|
143
+ next unless item.is_a?(Hash) && item["text"] && item["href"]
144
+
145
+ {
146
+ text: item["text"],
147
+ href: item["href"],
148
+ icon: item["icon"],
149
+ external: item["external"] == true
150
+ }
151
+ end
152
+ end
153
+
154
+ def announcement_options
155
+ announcement = config.announcement
156
+ return { announcement: nil } unless announcement
157
+
158
+ {
159
+ announcement: {
160
+ text: announcement.text,
161
+ link: announcement.link,
162
+ button: build_announcement_button(announcement),
163
+ dismissible: announcement.dismissible != false
164
+ }
165
+ }
68
166
  end
69
167
 
70
- def resolve_logo_dark(logo, logo_dark)
71
- logo_dark || logo || Constants::DEFAULT_LOGO_DARK_PATH
168
+ def build_announcement_button(announcement)
169
+ button = announcement.button
170
+ return nil unless button.is_a?(Hash) && button["text"]
171
+
172
+ {
173
+ text: button["text"],
174
+ link: button["link"] || announcement.link
175
+ }
72
176
  end
73
177
  end
74
178
  end
@@ -8,8 +8,9 @@ module Docyard
8
8
  CONTENT_TYPE_JS = "application/javascript; charset=utf-8"
9
9
 
10
10
  RELOAD_ENDPOINT = "/_docyard/reload"
11
- ASSETS_PREFIX = "/assets/"
11
+ DOCYARD_ASSETS_PREFIX = "/_docyard/"
12
12
  PAGEFIND_PREFIX = "/pagefind/"
13
+ PUBLIC_DIR = "docs/public"
13
14
 
14
15
  INDEX_FILE = "index"
15
16
  INDEX_TITLE = "Home"
@@ -18,12 +19,13 @@ module Docyard
18
19
  HTML_EXTENSION = ".html"
19
20
 
20
21
  STATUS_OK = 200
22
+ STATUS_REDIRECT = 302
21
23
  STATUS_NOT_FOUND = 404
22
24
  STATUS_INTERNAL_ERROR = 500
23
25
 
24
26
  DEFAULT_SITE_TITLE = "Documentation"
25
- DEFAULT_LOGO_PATH = "assets/logo.svg"
26
- DEFAULT_LOGO_DARK_PATH = "assets/logo-dark.svg"
27
- DEFAULT_FAVICON_PATH = "assets/favicon.svg"
27
+ DEFAULT_LOGO_PATH = "_docyard/logo.svg"
28
+ DEFAULT_LOGO_DARK_PATH = "_docyard/logo-dark.svg"
29
+ DEFAULT_FAVICON_PATH = "_docyard/favicon.svg"
28
30
  end
29
31
  end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docyard
4
+ module LogoDetector
5
+ module_function
6
+
7
+ def auto_detect_logo
8
+ detect_public_file("logo", %w[svg png])
9
+ end
10
+
11
+ def auto_detect_favicon
12
+ detect_public_file("favicon", %w[ico svg png])
13
+ end
14
+
15
+ def detect_public_file(name, extensions)
16
+ extensions.each do |ext|
17
+ path = File.join(Constants::PUBLIC_DIR, "#{name}.#{ext}")
18
+ return "#{name}.#{ext}" if File.exist?(path)
19
+ end
20
+ nil
21
+ end
22
+
23
+ def detect_dark_logo(logo)
24
+ return nil unless logo
25
+
26
+ ext = File.extname(logo)
27
+ base = File.basename(logo, ext)
28
+ dark_filename = "#{base}-dark#{ext}"
29
+
30
+ if File.absolute_path?(logo)
31
+ dark_path = File.join(File.dirname(logo), dark_filename)
32
+ File.exist?(dark_path) ? dark_path : logo
33
+ else
34
+ dark_path = File.join("docs/public", dark_filename)
35
+ File.exist?(dark_path) ? dark_filename : logo
36
+ end
37
+ end
38
+ end
39
+ end