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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +43 -0
- data/README.md +8 -253
- data/exe/docyard +6 -0
- data/lib/docyard/build/asset_bundler.rb +2 -2
- data/lib/docyard/build/file_copier.rb +12 -5
- data/lib/docyard/build/llms_txt_generator.rb +103 -0
- data/lib/docyard/build/sitemap_generator.rb +1 -1
- data/lib/docyard/build/static_generator.rb +115 -79
- data/lib/docyard/builder.rb +6 -2
- data/lib/docyard/cli.rb +14 -4
- data/lib/docyard/components/processors/callout_processor.rb +1 -1
- data/lib/docyard/components/processors/code_block_extended_fence_postprocessor.rb +24 -0
- data/lib/docyard/components/processors/code_block_extended_fence_preprocessor.rb +44 -0
- data/lib/docyard/components/processors/code_block_options_preprocessor.rb +11 -1
- data/lib/docyard/components/processors/code_block_processor.rb +5 -24
- data/lib/docyard/components/processors/code_group_processor.rb +6 -22
- data/lib/docyard/components/processors/code_snippet_import_preprocessor.rb +1 -0
- data/lib/docyard/components/processors/file_tree_processor.rb +1 -2
- data/lib/docyard/components/processors/icon_processor.rb +8 -2
- data/lib/docyard/components/processors/include_processor.rb +10 -10
- data/lib/docyard/components/processors/video_embed_processor.rb +14 -3
- data/lib/docyard/components/support/code_block/feature_extractor.rb +3 -1
- data/lib/docyard/components/support/code_block/icon_detector.rb +5 -12
- data/lib/docyard/components/support/code_block/line_number_resolver.rb +30 -0
- data/lib/docyard/components/support/code_detector.rb +2 -12
- data/lib/docyard/components/support/code_group/html_builder.rb +2 -6
- data/lib/docyard/components/support/tabs/icon_detector.rb +6 -2
- data/lib/docyard/components/support/tabs/parser.rb +6 -23
- data/lib/docyard/config/analytics_resolver.rb +24 -0
- data/lib/docyard/config/branding_resolver.rb +58 -27
- data/lib/docyard/config/key_validator.rb +30 -0
- data/lib/docyard/config/logo_detector.rb +8 -8
- data/lib/docyard/config/schema.rb +39 -0
- data/lib/docyard/config/section.rb +21 -0
- data/lib/docyard/config/validation_helpers.rb +83 -0
- data/lib/docyard/config/validator.rb +45 -144
- data/lib/docyard/config/validators/navigation.rb +43 -0
- data/lib/docyard/config/validators/section.rb +114 -0
- data/lib/docyard/config.rb +46 -102
- data/lib/docyard/constants.rb +59 -0
- data/lib/docyard/{utils/errors.rb → errors.rb} +6 -0
- data/lib/docyard/initializer.rb +100 -49
- data/lib/docyard/navigation/page_navigation_builder.rb +65 -0
- data/lib/docyard/navigation/sidebar/auto_builder.rb +107 -0
- data/lib/docyard/navigation/sidebar/cache.rb +96 -0
- data/lib/docyard/navigation/sidebar/config_builder.rb +179 -0
- data/lib/docyard/navigation/sidebar/distributed_builder.rb +145 -0
- data/lib/docyard/navigation/sidebar/local_config_loader.rb +69 -3
- data/lib/docyard/navigation/sidebar/renderer.rb +12 -1
- data/lib/docyard/navigation/sidebar_builder.rb +43 -81
- data/lib/docyard/rendering/branding_variables.rb +65 -0
- data/lib/docyard/rendering/icon_helpers.rb +14 -1
- data/lib/docyard/rendering/icons/devicons.rb +63 -0
- data/lib/docyard/rendering/icons.rb +26 -27
- data/lib/docyard/rendering/markdown.rb +5 -23
- data/lib/docyard/rendering/og_helpers.rb +36 -0
- data/lib/docyard/rendering/renderer.rb +87 -59
- data/lib/docyard/rendering/template_resolver.rb +14 -0
- data/lib/docyard/routing/fallback_resolver.rb +3 -3
- data/lib/docyard/search/build_indexer.rb +2 -2
- data/lib/docyard/search/dev_indexer.rb +36 -28
- data/lib/docyard/search/pagefind_support.rb +1 -1
- data/lib/docyard/server/asset_handler.rb +39 -15
- data/lib/docyard/server/dev_server.rb +90 -55
- data/lib/docyard/server/file_watcher.rb +68 -18
- data/lib/docyard/server/pagefind_handler.rb +1 -1
- data/lib/docyard/server/preview_server.rb +29 -33
- data/lib/docyard/server/rack_application.rb +38 -70
- data/lib/docyard/server/router.rb +11 -7
- data/lib/docyard/server/sse_server.rb +157 -0
- data/lib/docyard/server/static_file_app.rb +42 -0
- data/lib/docyard/templates/assets/css/components/banner.css +31 -0
- data/lib/docyard/templates/assets/css/components/breadcrumbs.css +2 -1
- data/lib/docyard/templates/assets/css/components/callout.css +26 -6
- data/lib/docyard/templates/assets/css/components/code-block.css +4 -2
- data/lib/docyard/templates/assets/css/components/code-group.css +20 -7
- data/lib/docyard/templates/assets/css/components/feedback.css +126 -0
- data/lib/docyard/templates/assets/css/components/file-tree.css +5 -4
- data/lib/docyard/templates/assets/css/components/icon.css +5 -0
- data/lib/docyard/templates/assets/css/components/nav-menu.css +20 -4
- data/lib/docyard/templates/assets/css/components/navigation.css +25 -3
- data/lib/docyard/templates/assets/css/components/page-actions.css +131 -0
- data/lib/docyard/templates/assets/css/components/prev-next.css +14 -7
- data/lib/docyard/templates/assets/css/components/search.css +6 -10
- data/lib/docyard/templates/assets/css/components/tab-bar.css +7 -4
- data/lib/docyard/templates/assets/css/components/table-of-contents.css +57 -11
- data/lib/docyard/templates/assets/css/components/tabs.css +12 -4
- data/lib/docyard/templates/assets/css/components/theme-toggle.css +3 -1
- data/lib/docyard/templates/assets/css/landing.css +82 -13
- data/lib/docyard/templates/assets/css/layout.css +17 -0
- data/lib/docyard/templates/assets/css/markdown.css +22 -2
- data/lib/docyard/templates/assets/css/variables.css +13 -1
- data/lib/docyard/templates/assets/js/components/code-group.js +4 -1
- data/lib/docyard/templates/assets/js/components/copy-page.js +115 -0
- data/lib/docyard/templates/assets/js/components/feedback.js +66 -0
- data/lib/docyard/templates/assets/js/components/file-tree.js +5 -5
- data/lib/docyard/templates/assets/js/components/navigation.js +3 -3
- data/lib/docyard/templates/assets/js/components/search.js +3 -3
- data/lib/docyard/templates/assets/js/components/table-of-contents.js +12 -6
- data/lib/docyard/templates/assets/js/components/tabs.js +45 -22
- data/lib/docyard/templates/assets/js/components/tooltip.js +4 -4
- data/lib/docyard/templates/assets/js/hot-reload.js +44 -0
- data/lib/docyard/templates/errors/404.html.erb +114 -5
- data/lib/docyard/templates/errors/500.html.erb +173 -10
- data/lib/docyard/templates/init/_sidebar.yml +36 -0
- data/lib/docyard/templates/init/docyard.yml +36 -0
- data/lib/docyard/templates/init/pages/components.md +146 -0
- data/lib/docyard/templates/init/pages/getting-started.md +94 -0
- data/lib/docyard/templates/init/pages/index.md +22 -0
- data/lib/docyard/templates/layouts/default.html.erb +10 -0
- data/lib/docyard/templates/layouts/splash.html.erb +14 -1
- data/lib/docyard/templates/partials/_analytics.html.erb +24 -0
- data/lib/docyard/templates/partials/_banner.html.erb +1 -1
- data/lib/docyard/templates/partials/_code_block.html.erb +1 -1
- data/lib/docyard/templates/partials/_feedback.html.erb +14 -0
- data/lib/docyard/templates/partials/_footer.html.erb +1 -1
- data/lib/docyard/templates/partials/_head.html.erb +79 -4
- data/lib/docyard/templates/partials/_icon_library.html.erb +8 -0
- data/lib/docyard/templates/partials/_page_actions.html.erb +21 -0
- data/lib/docyard/templates/partials/_scripts.html.erb +6 -3
- data/lib/docyard/templates/partials/_tabs.html.erb +4 -1
- data/lib/docyard/utils/git_info.rb +157 -0
- data/lib/docyard/utils/hash_utils.rb +31 -0
- data/lib/docyard/utils/html_helpers.rb +8 -0
- data/lib/docyard/utils/logging.rb +44 -3
- data/lib/docyard/utils/path_resolver.rb +0 -10
- data/lib/docyard/utils/path_utils.rb +73 -0
- data/lib/docyard/version.rb +1 -1
- data/lib/docyard.rb +2 -2
- metadata +77 -47
- data/.github/ISSUE_TEMPLATE/bug_report.md +0 -31
- data/.github/ISSUE_TEMPLATE/feature_request.md +0 -19
- data/.github/pull_request_template.md +0 -14
- data/.github/workflows/ci.yml +0 -49
- data/.rubocop.yml +0 -42
- data/CODE_OF_CONDUCT.md +0 -132
- data/CONTRIBUTING.md +0 -55
- data/LICENSE.vscode-icons +0 -42
- data/Rakefile +0 -8
- data/lib/docyard/config/constants.rb +0 -31
- data/lib/docyard/navigation/sidebar/children_discoverer.rb +0 -51
- data/lib/docyard/navigation/sidebar/config_parser.rb +0 -208
- data/lib/docyard/navigation/sidebar/file_resolver.rb +0 -90
- data/lib/docyard/navigation/sidebar/file_system_scanner.rb +0 -78
- data/lib/docyard/navigation/sidebar/metadata_extractor.rb +0 -71
- data/lib/docyard/navigation/sidebar/metadata_reader.rb +0 -51
- data/lib/docyard/navigation/sidebar/path_prefixer.rb +0 -34
- data/lib/docyard/navigation/sidebar/sorter.rb +0 -21
- data/lib/docyard/navigation/sidebar/title_extractor.rb +0 -25
- data/lib/docyard/navigation/sidebar/tree_builder.rb +0 -140
- data/lib/docyard/rendering/icons/LICENSE.phosphor +0 -21
- data/lib/docyard/rendering/icons/file_types.rb +0 -79
- data/lib/docyard/rendering/icons/phosphor.rb +0 -93
- data/lib/docyard/rendering/language_mapping.rb +0 -52
- data/lib/docyard/templates/assets/js/reload.js +0 -98
- data/lib/docyard/templates/partials/_icon.html.erb +0 -1
- data/lib/docyard/templates/partials/_icon_file_extension.html.erb +0 -1
- 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
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
31
|
-
|
|
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
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
45
|
-
|
|
55
|
+
@key_errors.concat(KeyValidator.validate(@config[section], valid_keys, context: section))
|
|
56
|
+
end
|
|
46
57
|
end
|
|
47
58
|
|
|
48
|
-
def
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
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,
|
|
64
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
108
|
-
|
|
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
|
-
|
|
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
|
|
190
|
-
|
|
191
|
-
|
|
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
|
data/lib/docyard/config.rb
CHANGED
|
@@ -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 "
|
|
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
|
-
"
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
"
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
},
|
|
24
|
-
"
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
def
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
def
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
def
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
def
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
def
|
|
74
|
-
|
|
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"] ?
|
|
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
|
-
|
|
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
|