docyard 0.9.0 → 1.0.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 (159) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +43 -0
  3. data/README.md +8 -253
  4. data/exe/docyard +6 -0
  5. data/lib/docyard/build/asset_bundler.rb +2 -2
  6. data/lib/docyard/build/file_copier.rb +12 -5
  7. data/lib/docyard/build/llms_txt_generator.rb +103 -0
  8. data/lib/docyard/build/sitemap_generator.rb +1 -1
  9. data/lib/docyard/build/static_generator.rb +115 -79
  10. data/lib/docyard/builder.rb +6 -2
  11. data/lib/docyard/cli.rb +14 -4
  12. data/lib/docyard/components/processors/callout_processor.rb +1 -1
  13. data/lib/docyard/components/processors/code_block_extended_fence_postprocessor.rb +24 -0
  14. data/lib/docyard/components/processors/code_block_extended_fence_preprocessor.rb +44 -0
  15. data/lib/docyard/components/processors/code_block_options_preprocessor.rb +11 -1
  16. data/lib/docyard/components/processors/code_block_processor.rb +5 -24
  17. data/lib/docyard/components/processors/code_group_processor.rb +6 -22
  18. data/lib/docyard/components/processors/code_snippet_import_preprocessor.rb +1 -0
  19. data/lib/docyard/components/processors/file_tree_processor.rb +1 -2
  20. data/lib/docyard/components/processors/icon_processor.rb +8 -2
  21. data/lib/docyard/components/processors/include_processor.rb +10 -10
  22. data/lib/docyard/components/processors/video_embed_processor.rb +14 -3
  23. data/lib/docyard/components/support/code_block/feature_extractor.rb +3 -1
  24. data/lib/docyard/components/support/code_block/icon_detector.rb +5 -12
  25. data/lib/docyard/components/support/code_block/line_number_resolver.rb +30 -0
  26. data/lib/docyard/components/support/code_detector.rb +2 -12
  27. data/lib/docyard/components/support/code_group/html_builder.rb +2 -6
  28. data/lib/docyard/components/support/tabs/icon_detector.rb +6 -2
  29. data/lib/docyard/components/support/tabs/parser.rb +6 -23
  30. data/lib/docyard/config/analytics_resolver.rb +24 -0
  31. data/lib/docyard/config/branding_resolver.rb +58 -27
  32. data/lib/docyard/config/key_validator.rb +30 -0
  33. data/lib/docyard/config/logo_detector.rb +8 -8
  34. data/lib/docyard/config/schema.rb +39 -0
  35. data/lib/docyard/config/section.rb +21 -0
  36. data/lib/docyard/config/validation_helpers.rb +83 -0
  37. data/lib/docyard/config/validator.rb +45 -144
  38. data/lib/docyard/config/validators/navigation.rb +43 -0
  39. data/lib/docyard/config/validators/section.rb +114 -0
  40. data/lib/docyard/config.rb +46 -102
  41. data/lib/docyard/constants.rb +59 -0
  42. data/lib/docyard/{utils/errors.rb → errors.rb} +6 -0
  43. data/lib/docyard/initializer.rb +100 -49
  44. data/lib/docyard/navigation/page_navigation_builder.rb +65 -0
  45. data/lib/docyard/navigation/sidebar/auto_builder.rb +107 -0
  46. data/lib/docyard/navigation/sidebar/cache.rb +96 -0
  47. data/lib/docyard/navigation/sidebar/config_builder.rb +179 -0
  48. data/lib/docyard/navigation/sidebar/distributed_builder.rb +145 -0
  49. data/lib/docyard/navigation/sidebar/local_config_loader.rb +69 -3
  50. data/lib/docyard/navigation/sidebar/renderer.rb +12 -1
  51. data/lib/docyard/navigation/sidebar_builder.rb +43 -81
  52. data/lib/docyard/rendering/branding_variables.rb +65 -0
  53. data/lib/docyard/rendering/icon_helpers.rb +14 -1
  54. data/lib/docyard/rendering/icons/devicons.rb +63 -0
  55. data/lib/docyard/rendering/icons.rb +26 -27
  56. data/lib/docyard/rendering/markdown.rb +5 -23
  57. data/lib/docyard/rendering/og_helpers.rb +36 -0
  58. data/lib/docyard/rendering/renderer.rb +87 -59
  59. data/lib/docyard/rendering/template_resolver.rb +14 -0
  60. data/lib/docyard/routing/fallback_resolver.rb +3 -3
  61. data/lib/docyard/search/build_indexer.rb +2 -2
  62. data/lib/docyard/search/dev_indexer.rb +36 -28
  63. data/lib/docyard/search/pagefind_support.rb +1 -1
  64. data/lib/docyard/server/asset_handler.rb +39 -15
  65. data/lib/docyard/server/dev_server.rb +90 -55
  66. data/lib/docyard/server/file_watcher.rb +68 -18
  67. data/lib/docyard/server/pagefind_handler.rb +1 -1
  68. data/lib/docyard/server/preview_server.rb +29 -33
  69. data/lib/docyard/server/rack_application.rb +38 -70
  70. data/lib/docyard/server/router.rb +11 -7
  71. data/lib/docyard/server/sse_server.rb +157 -0
  72. data/lib/docyard/server/static_file_app.rb +42 -0
  73. data/lib/docyard/templates/assets/css/components/banner.css +31 -0
  74. data/lib/docyard/templates/assets/css/components/breadcrumbs.css +2 -1
  75. data/lib/docyard/templates/assets/css/components/callout.css +26 -6
  76. data/lib/docyard/templates/assets/css/components/code-block.css +4 -2
  77. data/lib/docyard/templates/assets/css/components/code-group.css +20 -7
  78. data/lib/docyard/templates/assets/css/components/feedback.css +126 -0
  79. data/lib/docyard/templates/assets/css/components/file-tree.css +5 -4
  80. data/lib/docyard/templates/assets/css/components/icon.css +5 -0
  81. data/lib/docyard/templates/assets/css/components/nav-menu.css +20 -4
  82. data/lib/docyard/templates/assets/css/components/navigation.css +25 -3
  83. data/lib/docyard/templates/assets/css/components/page-actions.css +131 -0
  84. data/lib/docyard/templates/assets/css/components/prev-next.css +14 -7
  85. data/lib/docyard/templates/assets/css/components/search.css +6 -10
  86. data/lib/docyard/templates/assets/css/components/tab-bar.css +7 -4
  87. data/lib/docyard/templates/assets/css/components/table-of-contents.css +57 -11
  88. data/lib/docyard/templates/assets/css/components/tabs.css +12 -4
  89. data/lib/docyard/templates/assets/css/components/theme-toggle.css +3 -1
  90. data/lib/docyard/templates/assets/css/landing.css +82 -13
  91. data/lib/docyard/templates/assets/css/layout.css +17 -0
  92. data/lib/docyard/templates/assets/css/markdown.css +22 -2
  93. data/lib/docyard/templates/assets/css/variables.css +13 -1
  94. data/lib/docyard/templates/assets/js/components/code-group.js +4 -1
  95. data/lib/docyard/templates/assets/js/components/copy-page.js +115 -0
  96. data/lib/docyard/templates/assets/js/components/feedback.js +66 -0
  97. data/lib/docyard/templates/assets/js/components/file-tree.js +5 -5
  98. data/lib/docyard/templates/assets/js/components/navigation.js +3 -3
  99. data/lib/docyard/templates/assets/js/components/search.js +3 -3
  100. data/lib/docyard/templates/assets/js/components/table-of-contents.js +12 -6
  101. data/lib/docyard/templates/assets/js/components/tabs.js +45 -22
  102. data/lib/docyard/templates/assets/js/components/tooltip.js +4 -4
  103. data/lib/docyard/templates/assets/js/hot-reload.js +44 -0
  104. data/lib/docyard/templates/errors/404.html.erb +114 -5
  105. data/lib/docyard/templates/errors/500.html.erb +173 -10
  106. data/lib/docyard/templates/init/_sidebar.yml +36 -0
  107. data/lib/docyard/templates/init/docyard.yml +36 -0
  108. data/lib/docyard/templates/init/pages/components.md +146 -0
  109. data/lib/docyard/templates/init/pages/getting-started.md +94 -0
  110. data/lib/docyard/templates/init/pages/index.md +22 -0
  111. data/lib/docyard/templates/layouts/default.html.erb +10 -0
  112. data/lib/docyard/templates/layouts/splash.html.erb +14 -1
  113. data/lib/docyard/templates/partials/_analytics.html.erb +24 -0
  114. data/lib/docyard/templates/partials/_banner.html.erb +1 -1
  115. data/lib/docyard/templates/partials/_code_block.html.erb +1 -1
  116. data/lib/docyard/templates/partials/_feedback.html.erb +14 -0
  117. data/lib/docyard/templates/partials/_footer.html.erb +1 -1
  118. data/lib/docyard/templates/partials/_head.html.erb +79 -4
  119. data/lib/docyard/templates/partials/_icon_library.html.erb +8 -0
  120. data/lib/docyard/templates/partials/_page_actions.html.erb +21 -0
  121. data/lib/docyard/templates/partials/_scripts.html.erb +6 -3
  122. data/lib/docyard/templates/partials/_tabs.html.erb +4 -1
  123. data/lib/docyard/utils/git_info.rb +157 -0
  124. data/lib/docyard/utils/hash_utils.rb +31 -0
  125. data/lib/docyard/utils/html_helpers.rb +8 -0
  126. data/lib/docyard/utils/logging.rb +44 -3
  127. data/lib/docyard/utils/path_resolver.rb +0 -10
  128. data/lib/docyard/utils/path_utils.rb +73 -0
  129. data/lib/docyard/version.rb +1 -1
  130. data/lib/docyard.rb +2 -2
  131. metadata +77 -47
  132. data/.github/ISSUE_TEMPLATE/bug_report.md +0 -31
  133. data/.github/ISSUE_TEMPLATE/feature_request.md +0 -19
  134. data/.github/pull_request_template.md +0 -14
  135. data/.github/workflows/ci.yml +0 -49
  136. data/.rubocop.yml +0 -42
  137. data/CODE_OF_CONDUCT.md +0 -132
  138. data/CONTRIBUTING.md +0 -55
  139. data/LICENSE.vscode-icons +0 -42
  140. data/Rakefile +0 -8
  141. data/lib/docyard/config/constants.rb +0 -31
  142. data/lib/docyard/navigation/sidebar/children_discoverer.rb +0 -51
  143. data/lib/docyard/navigation/sidebar/config_parser.rb +0 -208
  144. data/lib/docyard/navigation/sidebar/file_resolver.rb +0 -90
  145. data/lib/docyard/navigation/sidebar/file_system_scanner.rb +0 -78
  146. data/lib/docyard/navigation/sidebar/metadata_extractor.rb +0 -71
  147. data/lib/docyard/navigation/sidebar/metadata_reader.rb +0 -51
  148. data/lib/docyard/navigation/sidebar/path_prefixer.rb +0 -34
  149. data/lib/docyard/navigation/sidebar/sorter.rb +0 -21
  150. data/lib/docyard/navigation/sidebar/title_extractor.rb +0 -25
  151. data/lib/docyard/navigation/sidebar/tree_builder.rb +0 -140
  152. data/lib/docyard/rendering/icons/LICENSE.phosphor +0 -21
  153. data/lib/docyard/rendering/icons/file_types.rb +0 -79
  154. data/lib/docyard/rendering/icons/phosphor.rb +0 -93
  155. data/lib/docyard/rendering/language_mapping.rb +0 -52
  156. data/lib/docyard/templates/assets/js/reload.js +0 -98
  157. data/lib/docyard/templates/partials/_icon.html.erb +0 -1
  158. data/lib/docyard/templates/partials/_icon_file_extension.html.erb +0 -1
  159. data/sig/docyard.rbs +0 -4
@@ -16,35 +16,34 @@ module Docyard
16
16
  def preprocess(content)
17
17
  @current_file = context[:current_file]
18
18
  @docs_root = context[:docs_root] || "docs"
19
- @included_files = Set.new
20
19
 
21
20
  process_outside_code_blocks(content) do |segment|
22
- process_includes(segment)
21
+ process_includes(segment, Set.new)
23
22
  end
24
23
  end
25
24
 
26
25
  private
27
26
 
28
- def process_includes(content)
29
- content.gsub(INCLUDE_PATTERN) { |_| process_include(Regexp.last_match) }
27
+ def process_includes(content, included_files)
28
+ content.gsub(INCLUDE_PATTERN) { |_| process_include(Regexp.last_match, included_files) }
30
29
  end
31
30
 
32
- def process_include(match)
31
+ def process_include(match, included_files)
33
32
  filepath = match[1]
34
33
  full_path = resolve_path(filepath)
35
34
 
36
- error = validate_include(filepath, full_path)
35
+ error = validate_include(filepath, full_path, included_files)
37
36
  return error if error
38
37
 
39
- @included_files.add(full_path)
38
+ updated_included_files = included_files.dup.add(full_path)
40
39
  file_content = File.read(full_path)
41
40
 
42
- process_includes(file_content.strip)
41
+ process_includes(file_content.strip, updated_included_files)
43
42
  end
44
43
 
45
- def validate_include(filepath, full_path)
44
+ def validate_include(filepath, full_path, included_files)
46
45
  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)
46
+ return include_error(filepath, "Circular include detected") if included_files.include?(full_path)
48
47
  return include_error(filepath, "Use code snippets for non-markdown files") unless markdown_file?(filepath)
49
48
 
50
49
  nil
@@ -78,6 +77,7 @@ module Docyard
78
77
  end
79
78
 
80
79
  def include_error(filepath, message)
80
+ Docyard.logger.warn("Include failed: #{filepath} - #{message}")
81
81
  "> [!WARNING]\n> Include error: #{filepath} - #{message}\n"
82
82
  end
83
83
  end
@@ -147,14 +147,25 @@ module Docyard
147
147
  end
148
148
 
149
149
  def build_wrapper_style(attrs)
150
- return "" unless attrs["width"] || attrs["height"]
150
+ width = validate_dimension(attrs["width"])
151
+ height = validate_dimension(attrs["height"])
152
+ return "" unless width || height
151
153
 
152
154
  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
+ styles << "max-width: #{width}px" if width
156
+ styles << "height: #{height}px" if height
155
157
  " style=\"#{styles.join('; ')}\""
156
158
  end
157
159
 
160
+ def validate_dimension(value)
161
+ return nil if value.nil? || value.to_s.empty?
162
+
163
+ int_value = value.to_s.to_i
164
+ return nil unless int_value.positive? && int_value <= 10_000
165
+
166
+ int_value
167
+ end
168
+
158
169
  def build_iframe_attrs(url, attrs, provider)
159
170
  iframe_attrs = [
160
171
  "src=\"#{url}\"",
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "patterns"
4
+ require_relative "../../../rendering/icons"
4
5
 
5
6
  module Docyard
6
7
  module Components
@@ -18,7 +19,8 @@ module Docyard
18
19
  cleaned = markdown.gsub(CODE_FENCE_REGEX) do
19
20
  block_data = extract_block_data(Regexp.last_match)
20
21
  blocks << block_data
21
- "```#{block_data[:lang]}\n#{block_data[:cleaned_content]}```"
22
+ highlight_lang = Icons.highlight_language(block_data[:lang])
23
+ "```#{highlight_lang}\n#{block_data[:cleaned_content]}```"
22
24
  end
23
25
  { cleaned_markdown: cleaned, blocks: blocks }
24
26
  end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "../../../rendering/language_mapping"
3
+ require_relative "../../../rendering/icons"
4
4
 
5
5
  module Docyard
6
6
  module Components
@@ -22,20 +22,13 @@ module Docyard
22
22
  }
23
23
  end
24
24
 
25
- icon, icon_source = auto_detect_icon(language)
26
- { title: title, icon: icon, icon_source: icon_source }
25
+ { title: title, icon: language, icon_source: "language" }
27
26
  end
28
27
 
29
- def auto_detect_icon(language)
30
- return [nil, nil] if language.nil?
28
+ def render_icon(language)
29
+ return "" if language.nil? || language.to_s.empty?
31
30
 
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
31
+ Icons.render_for_language(language)
39
32
  end
40
33
  end
41
34
  end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docyard
4
+ module Components
5
+ module Support
6
+ module CodeBlock
7
+ module LineNumberResolver
8
+ module_function
9
+
10
+ def enabled?(option)
11
+ return false if option == ":no-line-numbers"
12
+
13
+ option&.start_with?(":line-numbers") || false
14
+ end
15
+
16
+ def start_line(option)
17
+ return 1 unless option&.include?("=")
18
+
19
+ option.split("=").last.to_i
20
+ end
21
+
22
+ def generate_numbers(code_text, start = 1)
23
+ line_count = [code_text.lines.count, 1].max
24
+ (start...(start + line_count)).to_a
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "../../rendering/language_mapping"
3
+ require_relative "../../rendering/icons"
4
4
 
5
5
  module Docyard
6
6
  module Components
@@ -20,7 +20,7 @@ module Docyard
20
20
  language = extract_language
21
21
  return nil unless language
22
22
 
23
- icon_for_language(language)
23
+ { language: language }
24
24
  end
25
25
 
26
26
  private
@@ -45,16 +45,6 @@ module Docyard
45
45
 
46
46
  lang_line.downcase
47
47
  end
48
-
49
- def icon_for_language(language)
50
- if LanguageMapping.terminal_language?(language)
51
- { icon: "terminal-window", source: "phosphor" }
52
- elsif (extension = LanguageMapping.extension_for(language))
53
- { icon: extension, source: "file-extension" }
54
- else
55
- { icon: "file", source: "phosphor" }
56
- end
57
- end
58
48
  end
59
49
  end
60
50
  end
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "../code_block/icon_detector"
4
3
  require_relative "../../../rendering/icons"
5
4
 
6
5
  module Docyard
@@ -53,17 +52,14 @@ module Docyard
53
52
  class="docyard-code-group__tab"
54
53
  tabindex="#{tabindex}"
55
54
  data-label="#{escape_html(block[:label])}"
56
- >#{icon_html}#{escape_html(block[:label])}</button>
55
+ >#{icon_html}<span class="docyard-code-group__tab-label">#{escape_html(block[:label])}</span></button>
57
56
  HTML
58
57
  end
59
58
 
60
59
  def render_icon(lang)
61
60
  return "" if lang.nil? || lang.empty?
62
61
 
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) || ""
62
+ Icons.render_for_language(lang)
67
63
  end
68
64
 
69
65
  def build_copy_button
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../code_detector"
4
+ require_relative "../../../rendering/icons"
4
5
 
5
6
  module Docyard
6
7
  module Components
@@ -41,10 +42,13 @@ module Docyard
41
42
  detected = CodeDetector.detect(tab_content)
42
43
  return nil unless detected
43
44
 
45
+ language = detected[:language]
46
+ return nil unless Icons.devicon?(language)
47
+
44
48
  {
45
49
  name: tab_name,
46
- icon: detected[:icon],
47
- icon_source: detected[:source]
50
+ icon: language,
51
+ icon_source: "language"
48
52
  }
49
53
  end
50
54
 
@@ -3,6 +3,7 @@
3
3
  require_relative "../code_block/feature_extractor"
4
4
  require_relative "../code_block/icon_detector"
5
5
  require_relative "../code_block/line_wrapper"
6
+ require_relative "../code_block/line_number_resolver"
6
7
  require_relative "icon_detector"
7
8
  require_relative "../../../rendering/icons"
8
9
  require_relative "../../../rendering/renderer"
@@ -21,6 +22,7 @@ module Docyard
21
22
  CodeBlockFeatureExtractor = CodeBlock::FeatureExtractor
22
23
  CodeBlockIconDetector = CodeBlock::IconDetector
23
24
  CodeBlockLineWrapper = CodeBlock::LineWrapper
25
+ LineNumbers = CodeBlock::LineNumberResolver
24
26
 
25
27
  def self.parse(content)
26
28
  new(content).parse
@@ -120,15 +122,15 @@ module Docyard
120
122
  focus_lines: block_data[:focus_lines] || {},
121
123
  error_lines: block_data[:error_lines] || {},
122
124
  warning_lines: block_data[:warning_lines] || {},
123
- start_line: extract_start_line(block_data[:option])
125
+ start_line: LineNumbers.start_line(block_data[:option])
124
126
  }
125
127
  CodeBlockLineWrapper.wrap_code_block(html, wrapper_data)
126
128
  end
127
129
 
128
130
  def build_full_locals(processed_html, code_text, block_data)
129
131
  title_data = CodeBlockIconDetector.detect(block_data[:title], block_data[:lang])
130
- show_line_numbers = line_numbers_enabled?(block_data[:option])
131
- start_line = extract_start_line(block_data[:option])
132
+ show_line_numbers = LineNumbers.enabled?(block_data[:option])
133
+ start_line = LineNumbers.start_line(block_data[:option])
132
134
 
133
135
  base_locals(processed_html, code_text, show_line_numbers, start_line)
134
136
  .merge(feature_locals(block_data))
@@ -141,7 +143,7 @@ module Docyard
141
143
  code_text: escape_html_attribute(code_text),
142
144
  copy_icon: Icons.render("copy", "regular") || "",
143
145
  show_line_numbers: show_line_numbers,
144
- line_numbers: show_line_numbers ? generate_line_numbers(code_text, start_line) : [],
146
+ line_numbers: show_line_numbers ? LineNumbers.generate_numbers(code_text, start_line) : [],
145
147
  start_line: start_line
146
148
  }
147
149
  end
@@ -164,25 +166,6 @@ module Docyard
164
166
  }
165
167
  end
166
168
 
167
- def line_numbers_enabled?(block_option)
168
- return false if block_option == ":no-line-numbers"
169
- return true if block_option&.start_with?(":line-numbers")
170
-
171
- false
172
- end
173
-
174
- def extract_start_line(block_option)
175
- return 1 unless block_option&.include?("=")
176
-
177
- block_option.split("=").last.to_i
178
- end
179
-
180
- def generate_line_numbers(code_text, start_line)
181
- line_count = code_text.lines.count
182
- line_count = 1 if line_count.zero?
183
- (start_line...(start_line + line_count)).to_a
184
- end
185
-
186
169
  def extract_code_text(html)
187
170
  text = html.gsub(/<[^>]+>/, "")
188
171
  text = CGI.unescapeHTML(text)
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docyard
4
+ module AnalyticsResolver
5
+ def analytics_options
6
+ analytics = config.analytics
7
+ {
8
+ analytics_google: analytics.google,
9
+ analytics_plausible: analytics.plausible,
10
+ analytics_fathom: analytics.fathom,
11
+ analytics_script: analytics.script,
12
+ has_analytics: any_analytics_configured?(analytics)
13
+ }
14
+ end
15
+
16
+ private
17
+
18
+ def any_analytics_configured?(analytics)
19
+ [analytics.google, analytics.plausible, analytics.fathom, analytics.script].any? do |value|
20
+ value.is_a?(String) && !value.strip.empty?
21
+ end
22
+ end
23
+ end
24
+ end
@@ -1,22 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "logo_detector"
4
+ require_relative "analytics_resolver"
4
5
 
5
6
  module Docyard
6
7
  class BrandingResolver
8
+ include AnalyticsResolver
9
+
7
10
  def initialize(config)
8
11
  @config = config
9
12
  end
10
13
 
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
-
20
14
  def resolve
21
15
  return default_branding unless config
22
16
 
@@ -40,31 +34,31 @@ module Docyard
40
34
  end
41
35
 
42
36
  def config_branding_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)
37
+ [
38
+ site_options, logo_options, search_options, credits_options, social_options,
39
+ navigation_options, tabs_options, announcement_options, repo_options, analytics_options,
40
+ color_options
41
+ ].reduce({}, :merge)
51
42
  end
52
43
 
53
44
  def site_options
54
45
  {
55
46
  site_title: config.title || Constants::DEFAULT_SITE_TITLE,
56
47
  site_description: config.description || "",
57
- favicon: config.branding.favicon || LogoDetector.auto_detect_favicon
48
+ site_url: config.url,
49
+ og_image: config.og_image,
50
+ twitter: config.twitter,
51
+ favicon: config.branding.favicon || LogoDetector.auto_detect_favicon(public_dir: config.public_dir)
58
52
  }
59
53
  end
60
54
 
61
55
  def logo_options
62
56
  branding = config.branding
63
- logo = branding.logo || LogoDetector.auto_detect_logo
57
+ logo = branding.logo || LogoDetector.auto_detect_logo(public_dir: config.public_dir)
64
58
  has_custom_logo = !logo.nil?
65
59
  {
66
60
  logo: logo || Constants::DEFAULT_LOGO_PATH,
67
- logo_dark: LogoDetector.detect_dark_logo(logo) || Constants::DEFAULT_LOGO_DARK_PATH,
61
+ logo_dark: LogoDetector.detect_dark_logo(logo, public_dir: config.public_dir) || Constants::DEFAULT_LOGO_DARK_PATH,
68
62
  has_custom_logo: has_custom_logo
69
63
  }
70
64
  end
@@ -84,22 +78,28 @@ module Docyard
84
78
  end
85
79
 
86
80
  def social_options
87
- socials = config.socials || {}
88
- {
89
- social: normalize_social_links(socials)
90
- }
81
+ { social: normalize_social_links(config.socials || {}) }
91
82
  end
92
83
 
93
84
  def normalize_social_links(socials)
94
85
  return [] unless socials.is_a?(Hash) && socials.any?
95
86
 
96
- socials.filter_map { |platform, url| build_social_link(platform.to_s, url) }
87
+ socials.except("custom").filter_map { |platform, url| build_social_link(platform.to_s, url) } +
88
+ build_custom_social_links(socials["custom"])
97
89
  end
98
90
 
99
91
  def build_social_link(platform, url)
100
- return if platform == "custom" || !valid_url?(url)
92
+ { platform: platform, url: url, icon: Constants::SOCIAL_ICON_MAP[platform] || platform } if valid_url?(url)
93
+ end
101
94
 
102
- { platform: platform, url: url, icon: SOCIAL_ICON_MAP[platform] || platform }
95
+ def build_custom_social_links(custom)
96
+ return [] unless custom.is_a?(Array)
97
+
98
+ custom.filter_map do |item|
99
+ next unless item.is_a?(Hash) && item["icon"] && valid_url?(item["href"])
100
+
101
+ { platform: "custom", url: item["href"], icon: item["icon"] }
102
+ end
103
103
  end
104
104
 
105
105
  def valid_url?(url)
@@ -174,5 +174,36 @@ module Docyard
174
174
  link: button["link"] || announcement.link
175
175
  }
176
176
  end
177
+
178
+ def repo_options
179
+ repo = config.repo
180
+ has_repo_url = !repo.url.nil? && !repo.url.empty?
181
+ {
182
+ repo_url: repo.url,
183
+ repo_branch: repo.branch || "main",
184
+ repo_edit_path: repo.edit_path || config.source,
185
+ show_edit_link: has_repo_url && repo.edit_link != false,
186
+ show_last_updated: has_repo_url && repo.last_updated != false
187
+ }
188
+ end
189
+
190
+ def color_options
191
+ color = config.branding.color
192
+ { primary_color: normalize_color(color) }
193
+ end
194
+
195
+ def normalize_color(color)
196
+ return nil if color.nil?
197
+
198
+ if color.is_a?(Hash)
199
+ light = color["light"]
200
+ dark = color["dark"]
201
+ return nil if light.nil? && dark.nil?
202
+
203
+ { light: light, dark: dark }.compact
204
+ elsif color.is_a?(String) && !color.strip.empty?
205
+ { light: color.strip }
206
+ end
207
+ end
177
208
  end
178
209
  end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docyard
4
+ class Config
5
+ module KeyValidator
6
+ class << self
7
+ def validate(hash, valid_keys, context:)
8
+ return [] unless hash.is_a?(Hash)
9
+
10
+ unknown = hash.keys.map(&:to_s) - valid_keys
11
+ unknown.map { |key| build_error(key, valid_keys, context) }
12
+ end
13
+
14
+ private
15
+
16
+ def build_error(key, valid_keys, context)
17
+ suggestion = find_suggestion(key, valid_keys)
18
+ msg = "unknown key '#{key}'"
19
+ msg += ". Did you mean '#{suggestion}'?" if suggestion
20
+ { context: context, message: msg }
21
+ end
22
+
23
+ def find_suggestion(key, valid_keys)
24
+ checker = DidYouMean::SpellChecker.new(dictionary: valid_keys)
25
+ checker.correct(key).first
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -4,23 +4,23 @@ module Docyard
4
4
  module LogoDetector
5
5
  module_function
6
6
 
7
- def auto_detect_logo
8
- detect_public_file("logo", %w[svg png])
7
+ def auto_detect_logo(public_dir: "docs/public")
8
+ detect_public_file("logo", %w[svg png], public_dir: public_dir)
9
9
  end
10
10
 
11
- def auto_detect_favicon
12
- detect_public_file("favicon", %w[ico svg png])
11
+ def auto_detect_favicon(public_dir: "docs/public")
12
+ detect_public_file("favicon", %w[ico svg png], public_dir: public_dir)
13
13
  end
14
14
 
15
- def detect_public_file(name, extensions)
15
+ def detect_public_file(name, extensions, public_dir: "docs/public")
16
16
  extensions.each do |ext|
17
- path = File.join(Constants::PUBLIC_DIR, "#{name}.#{ext}")
17
+ path = File.join(public_dir, "#{name}.#{ext}")
18
18
  return "#{name}.#{ext}" if File.exist?(path)
19
19
  end
20
20
  nil
21
21
  end
22
22
 
23
- def detect_dark_logo(logo)
23
+ def detect_dark_logo(logo, public_dir: "docs/public")
24
24
  return nil unless logo
25
25
 
26
26
  ext = File.extname(logo)
@@ -31,7 +31,7 @@ module Docyard
31
31
  dark_path = File.join(File.dirname(logo), dark_filename)
32
32
  File.exist?(dark_path) ? dark_path : logo
33
33
  else
34
- dark_path = File.join("docs/public", dark_filename)
34
+ dark_path = File.join(public_dir, dark_filename)
35
35
  File.exist?(dark_path) ? dark_filename : logo
36
36
  end
37
37
  end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docyard
4
+ class Config
5
+ module Schema
6
+ TOP_LEVEL = %w[
7
+ title description url og_image twitter source
8
+ branding socials tabs sidebar
9
+ build search navigation announcement
10
+ repo analytics feedback
11
+ ].freeze
12
+
13
+ SECTIONS = {
14
+ "branding" => %w[logo favicon credits copyright color],
15
+ "build" => %w[output base],
16
+ "search" => %w[enabled placeholder exclude],
17
+ "navigation" => %w[cta breadcrumbs],
18
+ "repo" => %w[url branch edit_path edit_link last_updated],
19
+ "analytics" => %w[google plausible fathom script],
20
+ "announcement" => %w[text link button dismissible],
21
+ "feedback" => %w[enabled question]
22
+ }.freeze
23
+
24
+ TAB = %w[text href icon external].freeze
25
+
26
+ CTA = %w[text href variant external].freeze
27
+
28
+ ANNOUNCEMENT_BUTTON = %w[text link].freeze
29
+
30
+ SIDEBAR_ITEM = %w[text icon badge badge_type items collapsed index group collapsible].freeze
31
+
32
+ SIDEBAR_EXTERNAL_LINK = %w[link text icon target].freeze
33
+
34
+ SOCIALS_BUILTIN = %w[github twitter discord slack linkedin youtube bluesky custom].freeze
35
+
36
+ CUSTOM_SOCIAL = %w[icon href].freeze
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docyard
4
+ class Config
5
+ class Section
6
+ def initialize(data)
7
+ @data = data || {}
8
+ end
9
+
10
+ def method_missing(method, *args)
11
+ return super unless args.empty?
12
+
13
+ @data[method.to_s]
14
+ end
15
+
16
+ def respond_to_missing?(method, include_private = false)
17
+ @data.key?(method.to_s) || super
18
+ end
19
+ end
20
+ end
21
+ end