docyard 0.9.0 → 1.0.1

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 (165) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +57 -1
  3. data/README.md +8 -253
  4. data/exe/docyard +6 -0
  5. data/lib/docyard/build/asset_bundler.rb +24 -2
  6. data/lib/docyard/build/error_page_generator.rb +33 -0
  7. data/lib/docyard/build/file_copier.rb +12 -5
  8. data/lib/docyard/build/file_writer.rb +19 -0
  9. data/lib/docyard/build/llms_txt_generator.rb +103 -0
  10. data/lib/docyard/build/root_fallback_generator.rb +66 -0
  11. data/lib/docyard/build/sitemap_generator.rb +1 -1
  12. data/lib/docyard/build/static_generator.rb +119 -81
  13. data/lib/docyard/builder.rb +6 -2
  14. data/lib/docyard/cli.rb +14 -4
  15. data/lib/docyard/components/processors/callout_processor.rb +1 -1
  16. data/lib/docyard/components/processors/code_block_extended_fence_postprocessor.rb +24 -0
  17. data/lib/docyard/components/processors/code_block_extended_fence_preprocessor.rb +44 -0
  18. data/lib/docyard/components/processors/code_block_options_preprocessor.rb +11 -1
  19. data/lib/docyard/components/processors/code_block_processor.rb +5 -24
  20. data/lib/docyard/components/processors/code_group_processor.rb +6 -22
  21. data/lib/docyard/components/processors/code_snippet_import_preprocessor.rb +1 -0
  22. data/lib/docyard/components/processors/file_tree_processor.rb +1 -2
  23. data/lib/docyard/components/processors/icon_processor.rb +8 -2
  24. data/lib/docyard/components/processors/include_processor.rb +10 -10
  25. data/lib/docyard/components/processors/video_embed_processor.rb +14 -3
  26. data/lib/docyard/components/support/code_block/feature_extractor.rb +3 -1
  27. data/lib/docyard/components/support/code_block/icon_detector.rb +5 -12
  28. data/lib/docyard/components/support/code_block/line_number_resolver.rb +30 -0
  29. data/lib/docyard/components/support/code_detector.rb +2 -12
  30. data/lib/docyard/components/support/code_group/html_builder.rb +2 -6
  31. data/lib/docyard/components/support/tabs/icon_detector.rb +6 -2
  32. data/lib/docyard/components/support/tabs/parser.rb +6 -23
  33. data/lib/docyard/config/analytics_resolver.rb +24 -0
  34. data/lib/docyard/config/branding_resolver.rb +58 -27
  35. data/lib/docyard/config/key_validator.rb +30 -0
  36. data/lib/docyard/config/logo_detector.rb +8 -8
  37. data/lib/docyard/config/schema.rb +39 -0
  38. data/lib/docyard/config/section.rb +21 -0
  39. data/lib/docyard/config/validation_helpers.rb +83 -0
  40. data/lib/docyard/config/validator.rb +45 -144
  41. data/lib/docyard/config/validators/navigation.rb +43 -0
  42. data/lib/docyard/config/validators/section.rb +114 -0
  43. data/lib/docyard/config.rb +46 -102
  44. data/lib/docyard/constants.rb +59 -0
  45. data/lib/docyard/{utils/errors.rb → errors.rb} +6 -0
  46. data/lib/docyard/initializer.rb +100 -49
  47. data/lib/docyard/navigation/breadcrumb_builder.rb +45 -6
  48. data/lib/docyard/navigation/page_navigation_builder.rb +65 -0
  49. data/lib/docyard/navigation/sidebar/auto_builder.rb +107 -0
  50. data/lib/docyard/navigation/sidebar/cache.rb +96 -0
  51. data/lib/docyard/navigation/sidebar/config_builder.rb +179 -0
  52. data/lib/docyard/navigation/sidebar/distributed_builder.rb +145 -0
  53. data/lib/docyard/navigation/sidebar/local_config_loader.rb +69 -3
  54. data/lib/docyard/navigation/sidebar/renderer.rb +12 -1
  55. data/lib/docyard/navigation/sidebar_builder.rb +43 -81
  56. data/lib/docyard/rendering/branding_variables.rb +65 -0
  57. data/lib/docyard/rendering/icon_helpers.rb +14 -1
  58. data/lib/docyard/rendering/icons/devicons.rb +63 -0
  59. data/lib/docyard/rendering/icons.rb +26 -27
  60. data/lib/docyard/rendering/markdown.rb +5 -23
  61. data/lib/docyard/rendering/og_helpers.rb +36 -0
  62. data/lib/docyard/rendering/renderer.rb +96 -61
  63. data/lib/docyard/rendering/template_resolver.rb +14 -0
  64. data/lib/docyard/routing/fallback_resolver.rb +3 -3
  65. data/lib/docyard/search/build_indexer.rb +2 -2
  66. data/lib/docyard/search/dev_indexer.rb +36 -28
  67. data/lib/docyard/search/pagefind_support.rb +1 -1
  68. data/lib/docyard/server/asset_handler.rb +39 -15
  69. data/lib/docyard/server/dev_server.rb +90 -55
  70. data/lib/docyard/server/file_watcher.rb +68 -18
  71. data/lib/docyard/server/pagefind_handler.rb +1 -1
  72. data/lib/docyard/server/preview_server.rb +29 -33
  73. data/lib/docyard/server/rack_application.rb +39 -71
  74. data/lib/docyard/server/router.rb +11 -7
  75. data/lib/docyard/server/sse_server.rb +157 -0
  76. data/lib/docyard/server/static_file_app.rb +42 -0
  77. data/lib/docyard/templates/assets/css/components/banner.css +31 -0
  78. data/lib/docyard/templates/assets/css/components/breadcrumbs.css +2 -1
  79. data/lib/docyard/templates/assets/css/components/callout.css +26 -6
  80. data/lib/docyard/templates/assets/css/components/code-block.css +4 -2
  81. data/lib/docyard/templates/assets/css/components/code-group.css +20 -7
  82. data/lib/docyard/templates/assets/css/components/feedback.css +126 -0
  83. data/lib/docyard/templates/assets/css/components/file-tree.css +5 -4
  84. data/lib/docyard/templates/assets/css/components/heading-anchor.css +2 -2
  85. data/lib/docyard/templates/assets/css/components/icon.css +5 -0
  86. data/lib/docyard/templates/assets/css/components/nav-menu.css +20 -4
  87. data/lib/docyard/templates/assets/css/components/navigation.css +25 -3
  88. data/lib/docyard/templates/assets/css/components/page-actions.css +131 -0
  89. data/lib/docyard/templates/assets/css/components/prev-next.css +14 -7
  90. data/lib/docyard/templates/assets/css/components/search.css +6 -10
  91. data/lib/docyard/templates/assets/css/components/tab-bar.css +9 -6
  92. data/lib/docyard/templates/assets/css/components/table-of-contents.css +63 -17
  93. data/lib/docyard/templates/assets/css/components/tabs.css +12 -4
  94. data/lib/docyard/templates/assets/css/components/theme-toggle.css +3 -1
  95. data/lib/docyard/templates/assets/css/landing.css +82 -13
  96. data/lib/docyard/templates/assets/css/layout.css +32 -16
  97. data/lib/docyard/templates/assets/css/markdown.css +22 -2
  98. data/lib/docyard/templates/assets/css/variables.css +14 -1
  99. data/lib/docyard/templates/assets/js/components/code-group.js +4 -1
  100. data/lib/docyard/templates/assets/js/components/copy-page.js +115 -0
  101. data/lib/docyard/templates/assets/js/components/feedback.js +66 -0
  102. data/lib/docyard/templates/assets/js/components/file-tree.js +5 -5
  103. data/lib/docyard/templates/assets/js/components/navigation.js +3 -3
  104. data/lib/docyard/templates/assets/js/components/search.js +3 -3
  105. data/lib/docyard/templates/assets/js/components/table-of-contents.js +12 -6
  106. data/lib/docyard/templates/assets/js/components/tabs.js +45 -22
  107. data/lib/docyard/templates/assets/js/components/tooltip.js +4 -4
  108. data/lib/docyard/templates/assets/js/hot-reload.js +44 -0
  109. data/lib/docyard/templates/errors/404.html.erb +125 -5
  110. data/lib/docyard/templates/errors/500.html.erb +184 -10
  111. data/lib/docyard/templates/errors/redirect.html.erb +12 -0
  112. data/lib/docyard/templates/init/_sidebar.yml +36 -0
  113. data/lib/docyard/templates/init/docyard.yml +36 -0
  114. data/lib/docyard/templates/init/pages/components.md +146 -0
  115. data/lib/docyard/templates/init/pages/getting-started.md +94 -0
  116. data/lib/docyard/templates/init/pages/index.md +22 -0
  117. data/lib/docyard/templates/layouts/default.html.erb +10 -0
  118. data/lib/docyard/templates/layouts/splash.html.erb +14 -1
  119. data/lib/docyard/templates/partials/_analytics.html.erb +24 -0
  120. data/lib/docyard/templates/partials/_banner.html.erb +1 -1
  121. data/lib/docyard/templates/partials/_code_block.html.erb +1 -1
  122. data/lib/docyard/templates/partials/_feedback.html.erb +14 -0
  123. data/lib/docyard/templates/partials/_footer.html.erb +1 -1
  124. data/lib/docyard/templates/partials/_head.html.erb +80 -5
  125. data/lib/docyard/templates/partials/_icon_library.html.erb +8 -0
  126. data/lib/docyard/templates/partials/_page_actions.html.erb +21 -0
  127. data/lib/docyard/templates/partials/_scripts.html.erb +6 -3
  128. data/lib/docyard/templates/partials/_tabs.html.erb +4 -1
  129. data/lib/docyard/utils/git_info.rb +157 -0
  130. data/lib/docyard/utils/hash_utils.rb +31 -0
  131. data/lib/docyard/utils/html_helpers.rb +8 -0
  132. data/lib/docyard/utils/logging.rb +44 -3
  133. data/lib/docyard/utils/path_resolver.rb +0 -10
  134. data/lib/docyard/utils/path_utils.rb +73 -0
  135. data/lib/docyard/version.rb +1 -1
  136. data/lib/docyard.rb +2 -2
  137. metadata +81 -47
  138. data/.github/ISSUE_TEMPLATE/bug_report.md +0 -31
  139. data/.github/ISSUE_TEMPLATE/feature_request.md +0 -19
  140. data/.github/pull_request_template.md +0 -14
  141. data/.github/workflows/ci.yml +0 -49
  142. data/.rubocop.yml +0 -42
  143. data/CODE_OF_CONDUCT.md +0 -132
  144. data/CONTRIBUTING.md +0 -55
  145. data/LICENSE.vscode-icons +0 -42
  146. data/Rakefile +0 -8
  147. data/lib/docyard/config/constants.rb +0 -31
  148. data/lib/docyard/navigation/sidebar/children_discoverer.rb +0 -51
  149. data/lib/docyard/navigation/sidebar/config_parser.rb +0 -208
  150. data/lib/docyard/navigation/sidebar/file_resolver.rb +0 -90
  151. data/lib/docyard/navigation/sidebar/file_system_scanner.rb +0 -78
  152. data/lib/docyard/navigation/sidebar/metadata_extractor.rb +0 -71
  153. data/lib/docyard/navigation/sidebar/metadata_reader.rb +0 -51
  154. data/lib/docyard/navigation/sidebar/path_prefixer.rb +0 -34
  155. data/lib/docyard/navigation/sidebar/sorter.rb +0 -21
  156. data/lib/docyard/navigation/sidebar/title_extractor.rb +0 -25
  157. data/lib/docyard/navigation/sidebar/tree_builder.rb +0 -140
  158. data/lib/docyard/rendering/icons/LICENSE.phosphor +0 -21
  159. data/lib/docyard/rendering/icons/file_types.rb +0 -79
  160. data/lib/docyard/rendering/icons/phosphor.rb +0 -93
  161. data/lib/docyard/rendering/language_mapping.rb +0 -52
  162. data/lib/docyard/templates/assets/js/reload.js +0 -98
  163. data/lib/docyard/templates/partials/_icon.html.erb +0 -1
  164. data/lib/docyard/templates/partials/_icon_file_extension.html.erb +0 -1
  165. data/sig/docyard.rbs +0 -4
@@ -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
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docyard
4
+ class Config
5
+ module Validators
6
+ module Navigation
7
+ private
8
+
9
+ def validate_navigation_section
10
+ cta = @config.dig("navigation", "cta")
11
+ return if cta.nil?
12
+ return add_array_error("navigation.cta") unless cta.is_a?(Array)
13
+
14
+ validate_cta_max_count(cta)
15
+ validate_cta_items(cta)
16
+ end
17
+
18
+ def validate_cta_items(cta)
19
+ cta.each_with_index do |item, idx|
20
+ validate_string(item["text"], "navigation.cta[#{idx}].text")
21
+ validate_string(item["href"], "navigation.cta[#{idx}].href")
22
+ validate_cta_variant(item["variant"], idx) if item.key?("variant")
23
+ validate_boolean(item["external"], "navigation.cta[#{idx}].external") if item.key?("external")
24
+ end
25
+ end
26
+
27
+ def validate_cta_max_count(cta)
28
+ return if cta.length <= 2
29
+
30
+ add_error(field: "navigation.cta", error: "maximum 2 CTAs allowed",
31
+ got: "#{cta.length} items", fix: "Remove extra CTA items to have at most 2")
32
+ end
33
+
34
+ def validate_cta_variant(variant, idx)
35
+ return if variant.nil? || %w[primary secondary].include?(variant)
36
+
37
+ add_error(field: "navigation.cta[#{idx}].variant", error: "must be 'primary' or 'secondary'",
38
+ got: variant, fix: "Change to 'primary' or 'secondary'")
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docyard
4
+ class Config
5
+ module Validators
6
+ module Section
7
+ private
8
+
9
+ def validate_top_level
10
+ validate_string(@config["title"], "title")
11
+ validate_string(@config["description"], "description")
12
+ end
13
+
14
+ def validate_branding_section
15
+ branding = @config["branding"]
16
+ return unless branding
17
+
18
+ validate_file_path_or_url(branding["logo"], "branding.logo")
19
+ validate_file_path_or_url(branding["favicon"], "branding.favicon")
20
+ validate_boolean(branding["credits"], "branding.credits") if branding.key?("credits")
21
+ end
22
+
23
+ def validate_socials_section
24
+ socials = @config["socials"]
25
+ return unless socials
26
+ return add_hash_error("socials") unless socials.is_a?(Hash)
27
+
28
+ socials.each { |platform, url| validate_url(url, "socials.#{platform}") unless platform == "custom" }
29
+ validate_custom_socials(socials["custom"]) if socials.key?("custom")
30
+ end
31
+
32
+ def validate_custom_socials(custom)
33
+ return if custom.nil?
34
+ return add_array_error("socials.custom") unless custom.is_a?(Array)
35
+
36
+ custom.each_with_index do |item, index|
37
+ validate_string(item["icon"], "socials.custom[#{index}].icon")
38
+ validate_url(item["href"], "socials.custom[#{index}].href")
39
+ end
40
+ end
41
+
42
+ def validate_tabs_section
43
+ tabs = @config["tabs"]
44
+ return unless tabs
45
+ return add_array_error("tabs") unless tabs.is_a?(Array)
46
+
47
+ tabs.each_with_index do |tab, index|
48
+ validate_string(tab["text"], "tabs[#{index}].text")
49
+ validate_string(tab["href"], "tabs[#{index}].href")
50
+ validate_boolean(tab["external"], "tabs[#{index}].external") if tab.key?("external")
51
+ end
52
+ end
53
+
54
+ def validate_sidebar_setting
55
+ sidebar = @config["sidebar"]
56
+ return if sidebar.nil? || Config::SIDEBAR_MODES.include?(sidebar)
57
+
58
+ add_error(
59
+ field: "sidebar",
60
+ error: "must be one of: #{Config::SIDEBAR_MODES.join(', ')}",
61
+ got: sidebar.inspect,
62
+ fix: "Change to 'config', 'auto', or 'distributed'"
63
+ )
64
+ end
65
+
66
+ def validate_build_section
67
+ build = @config["build"]
68
+ return unless build
69
+
70
+ validate_string(build["output"], "build.output")
71
+ validate_no_slashes(build["output"], "build.output")
72
+ validate_string(build["base"], "build.base")
73
+ validate_starts_with_slash(build["base"], "build.base")
74
+ end
75
+
76
+ def validate_search_section
77
+ search = @config["search"]
78
+ return unless search
79
+
80
+ validate_boolean(search["enabled"], "search.enabled") if search.key?("enabled")
81
+ validate_string(search["placeholder"], "search.placeholder") if search.key?("placeholder")
82
+ validate_array(search["exclude"], "search.exclude") if search.key?("exclude")
83
+ end
84
+
85
+ def validate_announcement_section
86
+ announcement = @config["announcement"]
87
+ return unless announcement.is_a?(Hash)
88
+
89
+ validate_string(announcement["text"], "announcement.text") if announcement.key?("text")
90
+ end
91
+
92
+ def validate_feedback_section
93
+ feedback = @config["feedback"]
94
+ return unless feedback.is_a?(Hash) && feedback["enabled"] == true
95
+ return if analytics_configured?
96
+
97
+ add_error(
98
+ field: "feedback.enabled",
99
+ error: "requires analytics to be configured",
100
+ got: "feedback enabled without analytics",
101
+ fix: "Configure analytics (google, plausible, fathom, or script) to collect feedback responses"
102
+ )
103
+ end
104
+
105
+ def analytics_configured?
106
+ analytics = @config["analytics"]
107
+ return false unless analytics.is_a?(Hash)
108
+
109
+ analytics["google"] || analytics["plausible"] || analytics["fathom"] || analytics["script"]
110
+ end
111
+ end
112
+ end
113
+ end
114
+ end