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
@@ -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
@@ -1,36 +1,35 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "yaml"
4
+ require_relative "config/section"
5
+ require_relative "config/schema"
4
6
  require_relative "config/validator"
5
- require_relative "config/constants"
7
+ require_relative "constants"
8
+ require_relative "utils/hash_utils"
6
9
 
7
10
  module Docyard
8
11
  class Config
12
+ SIDEBAR_MODES = %w[config auto distributed].freeze
13
+
9
14
  DEFAULT_CONFIG = {
10
15
  "title" => Constants::DEFAULT_SITE_TITLE,
11
16
  "description" => "",
12
- "branding" => {
13
- "logo" => nil,
14
- "favicon" => nil,
15
- "credits" => true,
16
- "copyright" => nil
17
- },
17
+ "url" => nil,
18
+ "og_image" => nil,
19
+ "twitter" => nil,
20
+ "source" => "docs",
21
+ "branding" => { "logo" => nil, "favicon" => nil, "credits" => true, "copyright" => nil, "color" => nil },
18
22
  "socials" => {},
19
23
  "tabs" => [],
20
- "build" => {
21
- "output" => "dist",
22
- "base" => "/"
23
- },
24
- "search" => {
25
- "enabled" => true,
26
- "placeholder" => "Search...",
27
- "exclude" => []
28
- },
29
- "navigation" => {
30
- "cta" => [],
31
- "breadcrumbs" => true
32
- },
33
- "announcement" => nil
24
+ "sidebar" => "config",
25
+ "build" => { "output" => "dist", "base" => "/" },
26
+ "search" => { "enabled" => true, "placeholder" => "Search...", "exclude" => [] },
27
+ "navigation" => { "cta" => [], "breadcrumbs" => true },
28
+ "announcement" => nil,
29
+ "repo" => { "url" => nil, "branch" => "main", "edit_path" => nil, "edit_link" => true,
30
+ "last_updated" => true },
31
+ "analytics" => { "google" => nil, "plausible" => nil, "fathom" => nil, "script" => nil },
32
+ "feedback" => { "enabled" => false, "question" => "Was this page helpful?" }
34
33
  }.freeze
35
34
 
36
35
  attr_reader :data, :file_path
@@ -50,87 +49,50 @@ module Docyard
50
49
  File.exist?(file_path)
51
50
  end
52
51
 
53
- def title
54
- data["title"]
55
- end
56
-
57
- def description
58
- data["description"]
59
- end
60
-
61
- def branding
62
- @branding ||= ConfigSection.new(data["branding"])
63
- end
64
-
65
- def socials
66
- data["socials"]
67
- end
68
-
69
- def tabs
70
- data["tabs"]
71
- end
72
-
73
- def build
74
- @build ||= ConfigSection.new(data["build"])
75
- end
76
-
77
- def search
78
- @search ||= ConfigSection.new(data["search"])
79
- end
80
-
81
- def navigation
82
- @navigation ||= ConfigSection.new(data["navigation"])
83
- end
52
+ def title = data["title"]
53
+ def description = data["description"]
54
+ def url = data["url"]
55
+ def og_image = data["og_image"]
56
+ def twitter = data["twitter"]
57
+ def source = data["source"]
58
+ def public_dir = File.join(source, "public")
59
+ def socials = data["socials"]
60
+ def tabs = data["tabs"]
61
+ def sidebar = data["sidebar"]
62
+
63
+ def sidebar_config? = sidebar == "config"
64
+ def sidebar_auto? = sidebar == "auto"
65
+ def sidebar_distributed? = sidebar == "distributed"
66
+
67
+ def branding = @branding ||= Section.new(data["branding"])
68
+ def build = @build ||= Section.new(data["build"])
69
+ def search = @search ||= Section.new(data["search"])
70
+ def navigation = @navigation ||= Section.new(data["navigation"])
71
+ def repo = @repo ||= Section.new(data["repo"])
72
+ def analytics = @analytics ||= Section.new(data["analytics"])
73
+ def feedback = @feedback ||= Section.new(data["feedback"])
84
74
 
85
75
  def announcement
86
- @announcement ||= data["announcement"] ? ConfigSection.new(data["announcement"]) : nil
76
+ @announcement ||= data["announcement"] ? Section.new(data["announcement"]) : nil
87
77
  end
88
78
 
89
79
  private
90
80
 
91
81
  def load_config_data
92
- if file_exists?
93
- load_and_merge_config
94
- else
95
- deep_dup(DEFAULT_CONFIG)
96
- end
82
+ file_exists? ? load_and_merge_config : Utils::HashUtils.deep_dup(DEFAULT_CONFIG)
97
83
  end
98
84
 
99
85
  def load_and_merge_config
100
86
  yaml_content = YAML.load_file(file_path)
101
- deep_merge(deep_dup(DEFAULT_CONFIG), yaml_content || {})
87
+ Utils::HashUtils.deep_merge(Utils::HashUtils.deep_dup(DEFAULT_CONFIG), yaml_content || {})
102
88
  rescue Psych::SyntaxError => e
103
89
  raise ConfigError, build_yaml_error_message(e)
104
90
  rescue StandardError => e
105
91
  raise ConfigError, "Error loading docyard.yml: #{e.message}"
106
92
  end
107
93
 
108
- def deep_merge(hash1, hash2)
109
- hash1.merge(hash2) do |_key, v1, v2|
110
- if v2.nil?
111
- v1
112
- elsif v1.is_a?(Hash) && v2.is_a?(Hash)
113
- deep_merge(v1, v2)
114
- else
115
- v2
116
- end
117
- end
118
- end
119
-
120
- def deep_dup(hash)
121
- hash.transform_values do |value|
122
- case value
123
- when Hash then deep_dup(value)
124
- when Array then value.map { |v| v.is_a?(Hash) ? deep_dup(v) : v }
125
- else value
126
- end
127
- end
128
- end
129
-
130
94
  def build_yaml_error_message(error)
131
- message = "Invalid YAML in docyard.yml:\n\n"
132
- message += " #{error.message}\n\n"
133
- message += "Fix: Check YAML syntax"
95
+ message = "Invalid YAML in docyard.yml:\n\n #{error.message}\n\nFix: Check YAML syntax"
134
96
  message += " at line #{error.line}" if error.respond_to?(:line)
135
97
  message
136
98
  end
@@ -139,22 +101,4 @@ module Docyard
139
101
  Validator.new(data).validate!
140
102
  end
141
103
  end
142
-
143
- class ConfigSection
144
- def initialize(data)
145
- @data = data || {}
146
- end
147
-
148
- def method_missing(method, *args)
149
- return @data[method.to_s] if args.empty?
150
-
151
- super
152
- end
153
-
154
- def respond_to_missing?(method, include_private = false)
155
- @data.key?(method.to_s) || super
156
- end
157
- end
158
-
159
- class ConfigError < StandardError; end
160
104
  end