docyard 0.8.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 (189) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +67 -1
  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/aliases.rb +12 -0
  13. data/lib/docyard/components/processors/abbreviation_processor.rb +72 -0
  14. data/lib/docyard/components/processors/accordion_processor.rb +81 -0
  15. data/lib/docyard/components/processors/badge_processor.rb +72 -0
  16. data/lib/docyard/components/processors/callout_processor.rb +9 -3
  17. data/lib/docyard/components/processors/cards_processor.rb +100 -0
  18. data/lib/docyard/components/processors/code_block_extended_fence_postprocessor.rb +24 -0
  19. data/lib/docyard/components/processors/code_block_extended_fence_preprocessor.rb +44 -0
  20. data/lib/docyard/components/processors/code_block_options_preprocessor.rb +34 -3
  21. data/lib/docyard/components/processors/code_block_processor.rb +11 -24
  22. data/lib/docyard/components/processors/code_group_processor.rb +182 -0
  23. data/lib/docyard/components/processors/code_snippet_import_preprocessor.rb +7 -1
  24. data/lib/docyard/components/processors/custom_anchor_processor.rb +42 -0
  25. data/lib/docyard/components/processors/file_tree_processor.rb +150 -0
  26. data/lib/docyard/components/processors/icon_processor.rb +8 -2
  27. data/lib/docyard/components/processors/image_caption_processor.rb +96 -0
  28. data/lib/docyard/components/processors/include_processor.rb +86 -0
  29. data/lib/docyard/components/processors/steps_processor.rb +89 -0
  30. data/lib/docyard/components/processors/tabs_processor.rb +9 -1
  31. data/lib/docyard/components/processors/tooltip_processor.rb +57 -0
  32. data/lib/docyard/components/processors/video_embed_processor.rb +207 -0
  33. data/lib/docyard/components/support/code_block/feature_extractor.rb +3 -1
  34. data/lib/docyard/components/support/code_block/icon_detector.rb +5 -12
  35. data/lib/docyard/components/support/code_block/line_number_resolver.rb +30 -0
  36. data/lib/docyard/components/support/code_detector.rb +2 -12
  37. data/lib/docyard/components/support/code_group/html_builder.rb +118 -0
  38. data/lib/docyard/components/support/markdown_code_block_helper.rb +56 -0
  39. data/lib/docyard/components/support/tabs/icon_detector.rb +6 -2
  40. data/lib/docyard/components/support/tabs/parser.rb +6 -23
  41. data/lib/docyard/config/analytics_resolver.rb +24 -0
  42. data/lib/docyard/config/branding_resolver.rb +84 -58
  43. data/lib/docyard/config/key_validator.rb +30 -0
  44. data/lib/docyard/config/logo_detector.rb +39 -0
  45. data/lib/docyard/config/schema.rb +39 -0
  46. data/lib/docyard/config/section.rb +21 -0
  47. data/lib/docyard/config/validation_helpers.rb +83 -0
  48. data/lib/docyard/config/validator.rb +45 -144
  49. data/lib/docyard/config/validators/navigation.rb +43 -0
  50. data/lib/docyard/config/validators/section.rb +114 -0
  51. data/lib/docyard/config.rb +45 -96
  52. data/lib/docyard/constants.rb +59 -0
  53. data/lib/docyard/{utils/errors.rb → errors.rb} +6 -0
  54. data/lib/docyard/initializer.rb +100 -49
  55. data/lib/docyard/navigation/page_navigation_builder.rb +65 -0
  56. data/lib/docyard/navigation/sidebar/auto_builder.rb +107 -0
  57. data/lib/docyard/navigation/sidebar/cache.rb +96 -0
  58. data/lib/docyard/navigation/sidebar/config_builder.rb +179 -0
  59. data/lib/docyard/navigation/sidebar/distributed_builder.rb +145 -0
  60. data/lib/docyard/navigation/sidebar/item.rb +6 -1
  61. data/lib/docyard/navigation/sidebar/local_config_loader.rb +69 -3
  62. data/lib/docyard/navigation/sidebar/renderer.rb +18 -3
  63. data/lib/docyard/navigation/sidebar_builder.rb +43 -81
  64. data/lib/docyard/rendering/branding_variables.rb +65 -0
  65. data/lib/docyard/rendering/icon_helpers.rb +14 -1
  66. data/lib/docyard/rendering/icons/devicons.rb +63 -0
  67. data/lib/docyard/rendering/icons.rb +26 -27
  68. data/lib/docyard/rendering/markdown.rb +20 -15
  69. data/lib/docyard/rendering/og_helpers.rb +36 -0
  70. data/lib/docyard/rendering/renderer.rb +87 -58
  71. data/lib/docyard/rendering/template_resolver.rb +14 -0
  72. data/lib/docyard/routing/fallback_resolver.rb +3 -3
  73. data/lib/docyard/search/build_indexer.rb +2 -2
  74. data/lib/docyard/search/dev_indexer.rb +36 -28
  75. data/lib/docyard/search/pagefind_support.rb +1 -1
  76. data/lib/docyard/server/asset_handler.rb +40 -15
  77. data/lib/docyard/server/dev_server.rb +90 -55
  78. data/lib/docyard/server/file_watcher.rb +68 -18
  79. data/lib/docyard/server/pagefind_handler.rb +1 -1
  80. data/lib/docyard/server/preview_server.rb +29 -33
  81. data/lib/docyard/server/rack_application.rb +38 -70
  82. data/lib/docyard/server/router.rb +11 -7
  83. data/lib/docyard/server/sse_server.rb +157 -0
  84. data/lib/docyard/server/static_file_app.rb +42 -0
  85. data/lib/docyard/templates/assets/css/components/abbreviation.css +86 -0
  86. data/lib/docyard/templates/assets/css/components/accordion.css +138 -0
  87. data/lib/docyard/templates/assets/css/components/badges.css +47 -0
  88. data/lib/docyard/templates/assets/css/components/banner.css +233 -0
  89. data/lib/docyard/templates/assets/css/components/breadcrumbs.css +2 -1
  90. data/lib/docyard/templates/assets/css/components/callout.css +26 -6
  91. data/lib/docyard/templates/assets/css/components/cards.css +100 -0
  92. data/lib/docyard/templates/assets/css/components/code-block.css +14 -2
  93. data/lib/docyard/templates/assets/css/components/code-group.css +294 -0
  94. data/lib/docyard/templates/assets/css/components/feedback.css +126 -0
  95. data/lib/docyard/templates/assets/css/components/figure.css +22 -0
  96. data/lib/docyard/templates/assets/css/components/file-tree.css +125 -0
  97. data/lib/docyard/templates/assets/css/components/heading-anchor.css +21 -13
  98. data/lib/docyard/templates/assets/css/components/icon.css +5 -0
  99. data/lib/docyard/templates/assets/css/components/lightbox.css +65 -0
  100. data/lib/docyard/templates/assets/css/components/nav-menu.css +20 -4
  101. data/lib/docyard/templates/assets/css/components/navigation.css +32 -3
  102. data/lib/docyard/templates/assets/css/components/page-actions.css +131 -0
  103. data/lib/docyard/templates/assets/css/components/prev-next.css +20 -22
  104. data/lib/docyard/templates/assets/css/components/search.css +6 -10
  105. data/lib/docyard/templates/assets/css/components/steps.css +122 -0
  106. data/lib/docyard/templates/assets/css/components/tab-bar.css +7 -4
  107. data/lib/docyard/templates/assets/css/components/table-of-contents.css +57 -11
  108. data/lib/docyard/templates/assets/css/components/tabs.css +13 -5
  109. data/lib/docyard/templates/assets/css/components/theme-toggle.css +3 -1
  110. data/lib/docyard/templates/assets/css/components/tooltip.css +113 -0
  111. data/lib/docyard/templates/assets/css/components/video.css +41 -0
  112. data/lib/docyard/templates/assets/css/landing.css +82 -13
  113. data/lib/docyard/templates/assets/css/layout.css +17 -0
  114. data/lib/docyard/templates/assets/css/markdown.css +25 -3
  115. data/lib/docyard/templates/assets/css/variables.css +13 -1
  116. data/lib/docyard/templates/assets/js/components/abbreviation.js +85 -0
  117. data/lib/docyard/templates/assets/js/components/banner.js +81 -0
  118. data/lib/docyard/templates/assets/js/components/code-group.js +286 -0
  119. data/lib/docyard/templates/assets/js/components/copy-page.js +115 -0
  120. data/lib/docyard/templates/assets/js/components/feedback.js +66 -0
  121. data/lib/docyard/templates/assets/js/components/file-tree.js +39 -0
  122. data/lib/docyard/templates/assets/js/components/lightbox.js +72 -0
  123. data/lib/docyard/templates/assets/js/components/navigation.js +3 -3
  124. data/lib/docyard/templates/assets/js/components/search.js +3 -3
  125. data/lib/docyard/templates/assets/js/components/table-of-contents.js +12 -6
  126. data/lib/docyard/templates/assets/js/components/tabs.js +45 -22
  127. data/lib/docyard/templates/assets/js/components/tooltip.js +118 -0
  128. data/lib/docyard/templates/assets/js/hot-reload.js +44 -0
  129. data/lib/docyard/templates/errors/404.html.erb +114 -5
  130. data/lib/docyard/templates/errors/500.html.erb +173 -10
  131. data/lib/docyard/templates/init/_sidebar.yml +36 -0
  132. data/lib/docyard/templates/init/docyard.yml +36 -0
  133. data/lib/docyard/templates/init/pages/components.md +146 -0
  134. data/lib/docyard/templates/init/pages/getting-started.md +94 -0
  135. data/lib/docyard/templates/init/pages/index.md +22 -0
  136. data/lib/docyard/templates/layouts/default.html.erb +11 -0
  137. data/lib/docyard/templates/layouts/splash.html.erb +15 -1
  138. data/lib/docyard/templates/partials/_accordion.html.erb +9 -0
  139. data/lib/docyard/templates/partials/_analytics.html.erb +24 -0
  140. data/lib/docyard/templates/partials/_banner.html.erb +27 -0
  141. data/lib/docyard/templates/partials/_card.html.erb +23 -0
  142. data/lib/docyard/templates/partials/_code_block.html.erb +1 -1
  143. data/lib/docyard/templates/partials/_feedback.html.erb +14 -0
  144. data/lib/docyard/templates/partials/_footer.html.erb +1 -1
  145. data/lib/docyard/templates/partials/_head.html.erb +79 -4
  146. data/lib/docyard/templates/partials/_icon_library.html.erb +8 -0
  147. data/lib/docyard/templates/partials/_nav_group.html.erb +6 -0
  148. data/lib/docyard/templates/partials/_nav_leaf.html.erb +3 -0
  149. data/lib/docyard/templates/partials/_page_actions.html.erb +21 -0
  150. data/lib/docyard/templates/partials/_scripts.html.erb +6 -3
  151. data/lib/docyard/templates/partials/_step.html.erb +14 -0
  152. data/lib/docyard/templates/partials/_tabs.html.erb +4 -1
  153. data/lib/docyard/utils/git_info.rb +157 -0
  154. data/lib/docyard/utils/hash_utils.rb +31 -0
  155. data/lib/docyard/utils/html_helpers.rb +8 -0
  156. data/lib/docyard/utils/logging.rb +44 -3
  157. data/lib/docyard/utils/path_resolver.rb +0 -10
  158. data/lib/docyard/utils/path_utils.rb +73 -0
  159. data/lib/docyard/version.rb +1 -1
  160. data/lib/docyard.rb +2 -2
  161. metadata +114 -47
  162. data/.github/ISSUE_TEMPLATE/bug_report.md +0 -31
  163. data/.github/ISSUE_TEMPLATE/feature_request.md +0 -19
  164. data/.github/pull_request_template.md +0 -14
  165. data/.github/workflows/ci.yml +0 -49
  166. data/.rubocop.yml +0 -42
  167. data/CODE_OF_CONDUCT.md +0 -132
  168. data/CONTRIBUTING.md +0 -55
  169. data/LICENSE.vscode-icons +0 -42
  170. data/Rakefile +0 -8
  171. data/lib/docyard/config/constants.rb +0 -31
  172. data/lib/docyard/navigation/sidebar/children_discoverer.rb +0 -51
  173. data/lib/docyard/navigation/sidebar/config_parser.rb +0 -208
  174. data/lib/docyard/navigation/sidebar/file_resolver.rb +0 -78
  175. data/lib/docyard/navigation/sidebar/file_system_scanner.rb +0 -78
  176. data/lib/docyard/navigation/sidebar/metadata_extractor.rb +0 -69
  177. data/lib/docyard/navigation/sidebar/metadata_reader.rb +0 -47
  178. data/lib/docyard/navigation/sidebar/path_prefixer.rb +0 -34
  179. data/lib/docyard/navigation/sidebar/sorter.rb +0 -21
  180. data/lib/docyard/navigation/sidebar/title_extractor.rb +0 -25
  181. data/lib/docyard/navigation/sidebar/tree_builder.rb +0 -139
  182. data/lib/docyard/rendering/icons/LICENSE.phosphor +0 -21
  183. data/lib/docyard/rendering/icons/file_types.rb +0 -79
  184. data/lib/docyard/rendering/icons/phosphor.rb +0 -90
  185. data/lib/docyard/rendering/language_mapping.rb +0 -52
  186. data/lib/docyard/templates/assets/js/reload.js +0 -98
  187. data/lib/docyard/templates/partials/_icon.html.erb +0 -1
  188. data/lib/docyard/templates/partials/_icon_file_extension.html.erb +0 -1
  189. data/sig/docyard.rbs +0 -4
@@ -1,20 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "logo_detector"
4
+ require_relative "analytics_resolver"
5
+
3
6
  module Docyard
4
7
  class BrandingResolver
8
+ include AnalyticsResolver
9
+
5
10
  def initialize(config)
6
11
  @config = config
7
12
  end
8
13
 
9
- SOCIAL_ICON_MAP = {
10
- "x" => "x-logo", "twitter" => "x-logo", "discord" => "discord-logo",
11
- "linkedin" => "linkedin-logo", "youtube" => "youtube-logo", "instagram" => "instagram-logo",
12
- "facebook" => "facebook-logo", "tiktok" => "tiktok-logo", "twitch" => "twitch-logo",
13
- "reddit" => "reddit-logo", "mastodon" => "mastodon-logo", "threads" => "threads-logo",
14
- "pinterest" => "pinterest-logo", "medium" => "medium-logo", "slack" => "slack-logo",
15
- "gitlab" => "gitlab-logo"
16
- }.freeze
17
-
18
14
  def resolve
19
15
  return default_branding unless config
20
16
 
@@ -38,66 +34,35 @@ module Docyard
38
34
  end
39
35
 
40
36
  def config_branding_options
41
- site_options
42
- .merge(logo_options)
43
- .merge(search_options)
44
- .merge(credits_options)
45
- .merge(social_options)
46
- .merge(navigation_options)
47
- .merge(tabs_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)
48
42
  end
49
43
 
50
44
  def site_options
51
45
  {
52
46
  site_title: config.title || Constants::DEFAULT_SITE_TITLE,
53
47
  site_description: config.description || "",
54
- favicon: config.branding.favicon || 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)
55
52
  }
56
53
  end
57
54
 
58
55
  def logo_options
59
56
  branding = config.branding
60
- logo = branding.logo || auto_detect_logo
57
+ logo = branding.logo || LogoDetector.auto_detect_logo(public_dir: config.public_dir)
61
58
  has_custom_logo = !logo.nil?
62
59
  {
63
60
  logo: logo || Constants::DEFAULT_LOGO_PATH,
64
- logo_dark: 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,
65
62
  has_custom_logo: has_custom_logo
66
63
  }
67
64
  end
68
65
 
69
- def auto_detect_logo
70
- detect_public_file("logo", %w[svg png])
71
- end
72
-
73
- def auto_detect_favicon
74
- detect_public_file("favicon", %w[ico svg png])
75
- end
76
-
77
- def detect_public_file(name, extensions)
78
- extensions.each do |ext|
79
- path = File.join(Constants::PUBLIC_DIR, "#{name}.#{ext}")
80
- return "#{name}.#{ext}" if File.exist?(path)
81
- end
82
- nil
83
- end
84
-
85
- def detect_dark_logo(logo)
86
- return nil unless logo
87
-
88
- ext = File.extname(logo)
89
- base = File.basename(logo, ext)
90
- dark_filename = "#{base}-dark#{ext}"
91
-
92
- if File.absolute_path?(logo)
93
- dark_path = File.join(File.dirname(logo), dark_filename)
94
- File.exist?(dark_path) ? dark_path : logo
95
- else
96
- dark_path = File.join("docs/public", dark_filename)
97
- File.exist?(dark_path) ? dark_filename : logo
98
- end
99
- end
100
-
101
66
  def search_options
102
67
  {
103
68
  search_enabled: config.search.enabled != false,
@@ -113,22 +78,28 @@ module Docyard
113
78
  end
114
79
 
115
80
  def social_options
116
- socials = config.socials || {}
117
- {
118
- social: normalize_social_links(socials)
119
- }
81
+ { social: normalize_social_links(config.socials || {}) }
120
82
  end
121
83
 
122
84
  def normalize_social_links(socials)
123
85
  return [] unless socials.is_a?(Hash) && socials.any?
124
86
 
125
- 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"])
126
89
  end
127
90
 
128
91
  def build_social_link(platform, url)
129
- 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
94
+
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"])
130
100
 
131
- { platform: platform, url: url, icon: SOCIAL_ICON_MAP[platform] || platform }
101
+ { platform: "custom", url: item["href"], icon: item["icon"] }
102
+ end
132
103
  end
133
104
 
134
105
  def valid_url?(url)
@@ -179,5 +150,60 @@ module Docyard
179
150
  }
180
151
  end
181
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
+ }
166
+ end
167
+
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
+ }
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
182
208
  end
183
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
@@ -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(public_dir: "docs/public")
8
+ detect_public_file("logo", %w[svg png], public_dir: public_dir)
9
+ end
10
+
11
+ def auto_detect_favicon(public_dir: "docs/public")
12
+ detect_public_file("favicon", %w[ico svg png], public_dir: public_dir)
13
+ end
14
+
15
+ def detect_public_file(name, extensions, public_dir: "docs/public")
16
+ extensions.each do |ext|
17
+ path = File.join(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, public_dir: "docs/public")
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(public_dir, dark_filename)
35
+ File.exist?(dark_path) ? dark_filename : logo
36
+ end
37
+ end
38
+ end
39
+ 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
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docyard
4
+ class Config
5
+ module ValidationHelpers
6
+ private
7
+
8
+ def validate_string(value, field_name)
9
+ return if value.nil? || value.is_a?(String)
10
+
11
+ add_error(field: field_name, error: "must be a string", got: value.class.name, fix: "Change to a string value")
12
+ end
13
+
14
+ def validate_boolean(value, field_name)
15
+ return if [true, false].include?(value)
16
+
17
+ add_error(field: field_name, error: "must be true or false", got: value.inspect, fix: "Change to true or false")
18
+ end
19
+
20
+ def validate_url(value, field_name)
21
+ return if value.nil? || value.is_a?(String)
22
+
23
+ add_error(field: field_name, error: "must be a URL string", got: value.class.name,
24
+ fix: "Change to a URL string")
25
+ end
26
+
27
+ def validate_array(value, field_name)
28
+ return if value.nil? || value.is_a?(Array)
29
+
30
+ add_array_error(field_name)
31
+ end
32
+
33
+ def validate_file_path_or_url(value, field_name)
34
+ return if value.nil?
35
+ return add_type_error(field_name, "file path or URL (string)", value.class.name) unless value.is_a?(String)
36
+ return if url?(value)
37
+
38
+ public_dir = File.join(@config["source"] || "docs", "public")
39
+ file_path = File.absolute_path?(value) ? value : File.join(public_dir, value)
40
+ return if File.exist?(file_path)
41
+
42
+ add_error(field: field_name, error: "file not found", got: value,
43
+ fix: "Place the file in #{public_dir}/ directory (e.g., 'logo.svg' for #{public_dir}/logo.svg)")
44
+ end
45
+
46
+ def validate_no_slashes(value, field_name)
47
+ return if value.nil? || !value.is_a?(String)
48
+ return unless value.include?("/") || value.include?("\\")
49
+
50
+ add_error(field: field_name, error: "cannot contain slashes", got: value,
51
+ fix: "Use a simple directory name like 'dist' or '_site'")
52
+ end
53
+
54
+ def validate_starts_with_slash(value, field_name)
55
+ return if value.nil? || value.start_with?("/")
56
+
57
+ add_error(field: field_name, error: "must start with /", got: value, fix: "Change to '/#{value}'")
58
+ end
59
+
60
+ def url?(value)
61
+ value.match?(%r{\Ahttps?://})
62
+ end
63
+
64
+ def add_error(error_data)
65
+ @errors << error_data
66
+ end
67
+
68
+ def add_type_error(field, expected, got)
69
+ add_error(field: field, error: "must be a #{expected}", got: got, fix: "Change to a #{expected}")
70
+ end
71
+
72
+ def add_hash_error(field)
73
+ add_error(field: field, error: "must be a hash", got: @config[field].class.name,
74
+ fix: "Change to a hash with platform names as keys and URLs as values")
75
+ end
76
+
77
+ def add_array_error(field)
78
+ value = field.split(".").reduce(@config) { |h, k| h&.[](k) }
79
+ add_error(field: field, error: "must be an array", got: value.class.name, fix: "Change to an array")
80
+ end
81
+ end
82
+ end
83
+ end
@@ -1,194 +1,95 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "validation_helpers"
4
+ require_relative "schema"
5
+ require_relative "key_validator"
6
+ require_relative "validators/section"
7
+ require_relative "validators/navigation"
8
+
3
9
  module Docyard
4
10
  class Config
5
11
  class Validator
12
+ include ValidationHelpers
13
+ include Validators::Section
14
+ include Validators::Navigation
15
+
6
16
  def initialize(config_data)
7
17
  @config = config_data
8
18
  @errors = []
19
+ @key_errors = []
9
20
  end
10
21
 
11
22
  def validate!
23
+ validate_unknown_keys
12
24
  validate_top_level
13
25
  validate_branding_section
14
26
  validate_socials_section
15
27
  validate_tabs_section
28
+ validate_sidebar_setting
16
29
  validate_build_section
17
30
  validate_search_section
18
31
  validate_navigation_section
32
+ validate_announcement_section
33
+ validate_feedback_section
19
34
 
35
+ raise_key_errors if @key_errors.any?
20
36
  raise ConfigError, format_errors if @errors.any?
21
37
  end
22
38
 
23
39
  private
24
40
 
25
- def validate_top_level
26
- validate_string(@config["title"], "title")
27
- validate_string(@config["description"], "description")
41
+ def validate_unknown_keys
42
+ validate_top_level_keys
43
+ validate_section_keys
44
+ validate_array_item_keys
28
45
  end
29
46
 
30
- def validate_branding_section
31
- branding = @config["branding"]
32
- return unless branding
33
-
34
- validate_file_path_or_url(branding["logo"], "branding.logo")
35
- validate_file_path_or_url(branding["favicon"], "branding.favicon")
36
- validate_boolean(branding["credits"], "branding.credits") if branding.key?("credits")
47
+ def validate_top_level_keys
48
+ @key_errors.concat(KeyValidator.validate(@config, Schema::TOP_LEVEL, context: "docyard.yml"))
37
49
  end
38
50
 
39
- def validate_socials_section
40
- socials = @config["socials"]
41
- return unless socials
42
- return add_hash_error("socials") unless socials.is_a?(Hash)
51
+ def validate_section_keys
52
+ Schema::SECTIONS.each do |section, valid_keys|
53
+ next unless @config[section].is_a?(Hash)
43
54
 
44
- socials.each { |platform, url| validate_url(url, "socials.#{platform}") unless platform == "custom" }
45
- validate_custom_socials(socials["custom"]) if socials.key?("custom")
55
+ @key_errors.concat(KeyValidator.validate(@config[section], valid_keys, context: section))
56
+ end
46
57
  end
47
58
 
48
- def validate_custom_socials(custom)
49
- return if custom.nil?
50
- return add_array_error("socials.custom") unless custom.is_a?(Array)
51
-
52
- custom.each_with_index do |item, index|
53
- validate_string(item["icon"], "socials.custom[#{index}].icon")
54
- validate_url(item["href"], "socials.custom[#{index}].href")
55
- end
59
+ def validate_array_item_keys
60
+ validate_tabs_keys
61
+ validate_cta_keys
62
+ validate_announcement_button_keys
56
63
  end
57
64
 
58
- def validate_tabs_section
65
+ def validate_tabs_keys
59
66
  tabs = @config["tabs"]
60
- return unless tabs
61
- return add_array_error("tabs") unless tabs.is_a?(Array)
67
+ return unless tabs.is_a?(Array)
62
68
 
63
- tabs.each_with_index do |tab, index|
64
- validate_string(tab["text"], "tabs[#{index}].text")
65
- validate_string(tab["href"], "tabs[#{index}].href")
66
- validate_boolean(tab["external"], "tabs[#{index}].external") if tab.key?("external")
69
+ tabs.each_with_index do |tab, idx|
70
+ @key_errors.concat(KeyValidator.validate(tab, Schema::TAB, context: "tabs[#{idx}]"))
67
71
  end
68
72
  end
69
73
 
70
- def validate_build_section
71
- build = @config["build"]
72
- return unless build
73
-
74
- validate_string(build["output"], "build.output")
75
- validate_no_slashes(build["output"], "build.output")
76
- validate_string(build["base"], "build.base")
77
- validate_starts_with_slash(build["base"], "build.base")
78
- end
79
-
80
- def validate_search_section
81
- search = @config["search"]
82
- return unless search
83
-
84
- validate_boolean(search["enabled"], "search.enabled") if search.key?("enabled")
85
- validate_string(search["placeholder"], "search.placeholder") if search.key?("placeholder")
86
- validate_array(search["exclude"], "search.exclude") if search.key?("exclude")
87
- end
88
-
89
- def validate_navigation_section
74
+ def validate_cta_keys
90
75
  cta = @config.dig("navigation", "cta")
91
- return if cta.nil?
92
- return add_array_error("navigation.cta") unless cta.is_a?(Array)
76
+ return unless cta.is_a?(Array)
93
77
 
94
- validate_cta_max_count(cta)
95
- validate_cta_items(cta)
96
- end
97
-
98
- def validate_cta_items(cta)
99
78
  cta.each_with_index do |item, idx|
100
- validate_string(item["text"], "navigation.cta[#{idx}].text")
101
- validate_string(item["href"], "navigation.cta[#{idx}].href")
102
- validate_cta_variant(item["variant"], idx) if item.key?("variant")
103
- validate_boolean(item["external"], "navigation.cta[#{idx}].external") if item.key?("external")
79
+ @key_errors.concat(KeyValidator.validate(item, Schema::CTA, context: "navigation.cta[#{idx}]"))
104
80
  end
105
81
  end
106
82
 
107
- def validate_cta_max_count(cta)
108
- return if cta.length <= 2
109
-
110
- add_error(field: "navigation.cta", error: "maximum 2 CTAs allowed",
111
- got: "#{cta.length} items", fix: "Remove extra CTA items to have at most 2")
112
- end
113
-
114
- def validate_cta_variant(variant, idx)
115
- return if variant.nil? || %w[primary secondary].include?(variant)
116
-
117
- add_error(field: "navigation.cta[#{idx}].variant", error: "must be 'primary' or 'secondary'",
118
- got: variant, fix: "Change to 'primary' or 'secondary'")
119
- end
120
-
121
- def validate_string(value, field_name)
122
- return if value.nil? || value.is_a?(String)
123
-
124
- add_error(field: field_name, error: "must be a string", got: value.class.name, fix: "Change to a string value")
125
- end
126
-
127
- def validate_boolean(value, field_name)
128
- return if [true, false].include?(value)
129
-
130
- add_error(field: field_name, error: "must be true or false", got: value.inspect, fix: "Change to true or false")
131
- end
132
-
133
- def validate_url(value, field_name)
134
- return if value.nil? || value.is_a?(String)
135
-
136
- add_error(field: field_name, error: "must be a URL string",
137
- got: value.class.name, fix: "Change to a URL string")
138
- end
139
-
140
- def validate_array(value, field_name)
141
- return if value.nil? || value.is_a?(Array)
142
-
143
- add_array_error(field_name)
144
- end
145
-
146
- def validate_file_path_or_url(value, field_name)
147
- return if value.nil?
148
- return add_type_error(field_name, "file path or URL (string)", value.class.name) unless value.is_a?(String)
149
- return if url?(value)
150
-
151
- file_path = File.absolute_path?(value) ? value : File.join("docs/public", value)
152
- return if File.exist?(file_path)
153
-
154
- add_error(field: field_name, error: "file not found", got: value,
155
- fix: "Place the file in docs/public/ directory (e.g., 'logo.svg' for docs/public/logo.svg)")
156
- end
157
-
158
- def validate_no_slashes(value, field_name)
159
- return if value.nil? || !value.is_a?(String)
160
- return unless value.include?("/") || value.include?("\\")
161
-
162
- add_error(field: field_name, error: "cannot contain slashes", got: value,
163
- fix: "Use a simple directory name like 'dist' or '_site'")
164
- end
165
-
166
- def validate_starts_with_slash(value, field_name)
167
- return if value.nil? || value.start_with?("/")
168
-
169
- add_error(field: field_name, error: "must start with /", got: value, fix: "Change to '/#{value}'")
170
- end
171
-
172
- def url?(value)
173
- value.match?(%r{\Ahttps?://})
174
- end
175
-
176
- def add_error(error_data)
177
- @errors << error_data
178
- end
179
-
180
- def add_type_error(field, expected, got)
181
- add_error(field: field, error: "must be a #{expected}", got: got, fix: "Change to a #{expected}")
182
- end
83
+ def validate_announcement_button_keys
84
+ button = @config.dig("announcement", "button")
85
+ return unless button.is_a?(Hash)
183
86
 
184
- def add_hash_error(field)
185
- add_error(field: field, error: "must be a hash", got: @config[field].class.name,
186
- fix: "Change to a hash with platform names as keys and URLs as values")
87
+ @key_errors.concat(KeyValidator.validate(button, Schema::ANNOUNCEMENT_BUTTON, context: "announcement.button"))
187
88
  end
188
89
 
189
- def add_array_error(field)
190
- value = field.split(".").reduce(@config) { |h, k| h&.[](k) }
191
- add_error(field: field, error: "must be an array", got: value.class.name, fix: "Change to an array")
90
+ def raise_key_errors
91
+ messages = @key_errors.map { |e| "#{e[:context]}: #{e[:message]}" }
92
+ raise ConfigError, "Error in docyard.yml:\n#{messages.join("\n")}"
192
93
  end
193
94
 
194
95
  def format_errors