docyard 0.8.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +67 -1
- 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/aliases.rb +12 -0
- data/lib/docyard/components/processors/abbreviation_processor.rb +72 -0
- data/lib/docyard/components/processors/accordion_processor.rb +81 -0
- data/lib/docyard/components/processors/badge_processor.rb +72 -0
- data/lib/docyard/components/processors/callout_processor.rb +9 -3
- data/lib/docyard/components/processors/cards_processor.rb +100 -0
- 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 +34 -3
- data/lib/docyard/components/processors/code_block_processor.rb +11 -24
- data/lib/docyard/components/processors/code_group_processor.rb +182 -0
- data/lib/docyard/components/processors/code_snippet_import_preprocessor.rb +7 -1
- data/lib/docyard/components/processors/custom_anchor_processor.rb +42 -0
- data/lib/docyard/components/processors/file_tree_processor.rb +150 -0
- data/lib/docyard/components/processors/icon_processor.rb +8 -2
- data/lib/docyard/components/processors/image_caption_processor.rb +96 -0
- data/lib/docyard/components/processors/include_processor.rb +86 -0
- data/lib/docyard/components/processors/steps_processor.rb +89 -0
- data/lib/docyard/components/processors/tabs_processor.rb +9 -1
- data/lib/docyard/components/processors/tooltip_processor.rb +57 -0
- data/lib/docyard/components/processors/video_embed_processor.rb +207 -0
- 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 +118 -0
- data/lib/docyard/components/support/markdown_code_block_helper.rb +56 -0
- 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 +84 -58
- data/lib/docyard/config/key_validator.rb +30 -0
- data/lib/docyard/config/logo_detector.rb +39 -0
- 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 +45 -96
- 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/item.rb +6 -1
- data/lib/docyard/navigation/sidebar/local_config_loader.rb +69 -3
- data/lib/docyard/navigation/sidebar/renderer.rb +18 -3
- 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 +20 -15
- data/lib/docyard/rendering/og_helpers.rb +36 -0
- data/lib/docyard/rendering/renderer.rb +87 -58
- 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 +40 -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/abbreviation.css +86 -0
- data/lib/docyard/templates/assets/css/components/accordion.css +138 -0
- data/lib/docyard/templates/assets/css/components/badges.css +47 -0
- data/lib/docyard/templates/assets/css/components/banner.css +233 -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/cards.css +100 -0
- data/lib/docyard/templates/assets/css/components/code-block.css +14 -2
- data/lib/docyard/templates/assets/css/components/code-group.css +294 -0
- data/lib/docyard/templates/assets/css/components/feedback.css +126 -0
- data/lib/docyard/templates/assets/css/components/figure.css +22 -0
- data/lib/docyard/templates/assets/css/components/file-tree.css +125 -0
- data/lib/docyard/templates/assets/css/components/heading-anchor.css +21 -13
- data/lib/docyard/templates/assets/css/components/icon.css +5 -0
- data/lib/docyard/templates/assets/css/components/lightbox.css +65 -0
- data/lib/docyard/templates/assets/css/components/nav-menu.css +20 -4
- data/lib/docyard/templates/assets/css/components/navigation.css +32 -3
- data/lib/docyard/templates/assets/css/components/page-actions.css +131 -0
- data/lib/docyard/templates/assets/css/components/prev-next.css +20 -22
- data/lib/docyard/templates/assets/css/components/search.css +6 -10
- data/lib/docyard/templates/assets/css/components/steps.css +122 -0
- 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 +13 -5
- data/lib/docyard/templates/assets/css/components/theme-toggle.css +3 -1
- data/lib/docyard/templates/assets/css/components/tooltip.css +113 -0
- data/lib/docyard/templates/assets/css/components/video.css +41 -0
- 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 +25 -3
- data/lib/docyard/templates/assets/css/variables.css +13 -1
- data/lib/docyard/templates/assets/js/components/abbreviation.js +85 -0
- data/lib/docyard/templates/assets/js/components/banner.js +81 -0
- data/lib/docyard/templates/assets/js/components/code-group.js +286 -0
- 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 +39 -0
- data/lib/docyard/templates/assets/js/components/lightbox.js +72 -0
- 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 +118 -0
- 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 +11 -0
- data/lib/docyard/templates/layouts/splash.html.erb +15 -1
- data/lib/docyard/templates/partials/_accordion.html.erb +9 -0
- data/lib/docyard/templates/partials/_analytics.html.erb +24 -0
- data/lib/docyard/templates/partials/_banner.html.erb +27 -0
- data/lib/docyard/templates/partials/_card.html.erb +23 -0
- 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/_nav_group.html.erb +6 -0
- data/lib/docyard/templates/partials/_nav_leaf.html.erb +3 -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/_step.html.erb +14 -0
- 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 +114 -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 -78
- data/lib/docyard/navigation/sidebar/file_system_scanner.rb +0 -78
- data/lib/docyard/navigation/sidebar/metadata_extractor.rb +0 -69
- data/lib/docyard/navigation/sidebar/metadata_reader.rb +0 -47
- 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 -139
- 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 -90
- 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
|
@@ -1,20 +1,16 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "logo_detector"
|
|
4
|
+
require_relative "analytics_resolver"
|
|
5
|
+
|
|
3
6
|
module Docyard
|
|
4
7
|
class BrandingResolver
|
|
8
|
+
include AnalyticsResolver
|
|
9
|
+
|
|
5
10
|
def initialize(config)
|
|
6
11
|
@config = config
|
|
7
12
|
end
|
|
8
13
|
|
|
9
|
-
SOCIAL_ICON_MAP = {
|
|
10
|
-
"x" => "x-logo", "twitter" => "x-logo", "discord" => "discord-logo",
|
|
11
|
-
"linkedin" => "linkedin-logo", "youtube" => "youtube-logo", "instagram" => "instagram-logo",
|
|
12
|
-
"facebook" => "facebook-logo", "tiktok" => "tiktok-logo", "twitch" => "twitch-logo",
|
|
13
|
-
"reddit" => "reddit-logo", "mastodon" => "mastodon-logo", "threads" => "threads-logo",
|
|
14
|
-
"pinterest" => "pinterest-logo", "medium" => "medium-logo", "slack" => "slack-logo",
|
|
15
|
-
"gitlab" => "gitlab-logo"
|
|
16
|
-
}.freeze
|
|
17
|
-
|
|
18
14
|
def resolve
|
|
19
15
|
return default_branding unless config
|
|
20
16
|
|
|
@@ -38,66 +34,35 @@ module Docyard
|
|
|
38
34
|
end
|
|
39
35
|
|
|
40
36
|
def config_branding_options
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
.merge(navigation_options)
|
|
47
|
-
.merge(tabs_options)
|
|
37
|
+
[
|
|
38
|
+
site_options, logo_options, search_options, credits_options, social_options,
|
|
39
|
+
navigation_options, tabs_options, announcement_options, repo_options, analytics_options,
|
|
40
|
+
color_options
|
|
41
|
+
].reduce({}, :merge)
|
|
48
42
|
end
|
|
49
43
|
|
|
50
44
|
def site_options
|
|
51
45
|
{
|
|
52
46
|
site_title: config.title || Constants::DEFAULT_SITE_TITLE,
|
|
53
47
|
site_description: config.description || "",
|
|
54
|
-
|
|
48
|
+
site_url: config.url,
|
|
49
|
+
og_image: config.og_image,
|
|
50
|
+
twitter: config.twitter,
|
|
51
|
+
favicon: config.branding.favicon || LogoDetector.auto_detect_favicon(public_dir: config.public_dir)
|
|
55
52
|
}
|
|
56
53
|
end
|
|
57
54
|
|
|
58
55
|
def logo_options
|
|
59
56
|
branding = config.branding
|
|
60
|
-
logo = branding.logo || auto_detect_logo
|
|
57
|
+
logo = branding.logo || LogoDetector.auto_detect_logo(public_dir: config.public_dir)
|
|
61
58
|
has_custom_logo = !logo.nil?
|
|
62
59
|
{
|
|
63
60
|
logo: logo || Constants::DEFAULT_LOGO_PATH,
|
|
64
|
-
logo_dark: detect_dark_logo(logo) || Constants::DEFAULT_LOGO_DARK_PATH,
|
|
61
|
+
logo_dark: LogoDetector.detect_dark_logo(logo, public_dir: config.public_dir) || Constants::DEFAULT_LOGO_DARK_PATH,
|
|
65
62
|
has_custom_logo: has_custom_logo
|
|
66
63
|
}
|
|
67
64
|
end
|
|
68
65
|
|
|
69
|
-
def auto_detect_logo
|
|
70
|
-
detect_public_file("logo", %w[svg png])
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
def auto_detect_favicon
|
|
74
|
-
detect_public_file("favicon", %w[ico svg png])
|
|
75
|
-
end
|
|
76
|
-
|
|
77
|
-
def detect_public_file(name, extensions)
|
|
78
|
-
extensions.each do |ext|
|
|
79
|
-
path = File.join(Constants::PUBLIC_DIR, "#{name}.#{ext}")
|
|
80
|
-
return "#{name}.#{ext}" if File.exist?(path)
|
|
81
|
-
end
|
|
82
|
-
nil
|
|
83
|
-
end
|
|
84
|
-
|
|
85
|
-
def detect_dark_logo(logo)
|
|
86
|
-
return nil unless logo
|
|
87
|
-
|
|
88
|
-
ext = File.extname(logo)
|
|
89
|
-
base = File.basename(logo, ext)
|
|
90
|
-
dark_filename = "#{base}-dark#{ext}"
|
|
91
|
-
|
|
92
|
-
if File.absolute_path?(logo)
|
|
93
|
-
dark_path = File.join(File.dirname(logo), dark_filename)
|
|
94
|
-
File.exist?(dark_path) ? dark_path : logo
|
|
95
|
-
else
|
|
96
|
-
dark_path = File.join("docs/public", dark_filename)
|
|
97
|
-
File.exist?(dark_path) ? dark_filename : logo
|
|
98
|
-
end
|
|
99
|
-
end
|
|
100
|
-
|
|
101
66
|
def search_options
|
|
102
67
|
{
|
|
103
68
|
search_enabled: config.search.enabled != false,
|
|
@@ -113,22 +78,28 @@ module Docyard
|
|
|
113
78
|
end
|
|
114
79
|
|
|
115
80
|
def social_options
|
|
116
|
-
|
|
117
|
-
{
|
|
118
|
-
social: normalize_social_links(socials)
|
|
119
|
-
}
|
|
81
|
+
{ social: normalize_social_links(config.socials || {}) }
|
|
120
82
|
end
|
|
121
83
|
|
|
122
84
|
def normalize_social_links(socials)
|
|
123
85
|
return [] unless socials.is_a?(Hash) && socials.any?
|
|
124
86
|
|
|
125
|
-
socials.filter_map { |platform, url| build_social_link(platform.to_s, url) }
|
|
87
|
+
socials.except("custom").filter_map { |platform, url| build_social_link(platform.to_s, url) } +
|
|
88
|
+
build_custom_social_links(socials["custom"])
|
|
126
89
|
end
|
|
127
90
|
|
|
128
91
|
def build_social_link(platform, url)
|
|
129
|
-
|
|
92
|
+
{ platform: platform, url: url, icon: Constants::SOCIAL_ICON_MAP[platform] || platform } if valid_url?(url)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def build_custom_social_links(custom)
|
|
96
|
+
return [] unless custom.is_a?(Array)
|
|
97
|
+
|
|
98
|
+
custom.filter_map do |item|
|
|
99
|
+
next unless item.is_a?(Hash) && item["icon"] && valid_url?(item["href"])
|
|
130
100
|
|
|
131
|
-
|
|
101
|
+
{ platform: "custom", url: item["href"], icon: item["icon"] }
|
|
102
|
+
end
|
|
132
103
|
end
|
|
133
104
|
|
|
134
105
|
def valid_url?(url)
|
|
@@ -179,5 +150,60 @@ module Docyard
|
|
|
179
150
|
}
|
|
180
151
|
end
|
|
181
152
|
end
|
|
153
|
+
|
|
154
|
+
def announcement_options
|
|
155
|
+
announcement = config.announcement
|
|
156
|
+
return { announcement: nil } unless announcement
|
|
157
|
+
|
|
158
|
+
{
|
|
159
|
+
announcement: {
|
|
160
|
+
text: announcement.text,
|
|
161
|
+
link: announcement.link,
|
|
162
|
+
button: build_announcement_button(announcement),
|
|
163
|
+
dismissible: announcement.dismissible != false
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def build_announcement_button(announcement)
|
|
169
|
+
button = announcement.button
|
|
170
|
+
return nil unless button.is_a?(Hash) && button["text"]
|
|
171
|
+
|
|
172
|
+
{
|
|
173
|
+
text: button["text"],
|
|
174
|
+
link: button["link"] || announcement.link
|
|
175
|
+
}
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def repo_options
|
|
179
|
+
repo = config.repo
|
|
180
|
+
has_repo_url = !repo.url.nil? && !repo.url.empty?
|
|
181
|
+
{
|
|
182
|
+
repo_url: repo.url,
|
|
183
|
+
repo_branch: repo.branch || "main",
|
|
184
|
+
repo_edit_path: repo.edit_path || config.source,
|
|
185
|
+
show_edit_link: has_repo_url && repo.edit_link != false,
|
|
186
|
+
show_last_updated: has_repo_url && repo.last_updated != false
|
|
187
|
+
}
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def color_options
|
|
191
|
+
color = config.branding.color
|
|
192
|
+
{ primary_color: normalize_color(color) }
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def normalize_color(color)
|
|
196
|
+
return nil if color.nil?
|
|
197
|
+
|
|
198
|
+
if color.is_a?(Hash)
|
|
199
|
+
light = color["light"]
|
|
200
|
+
dark = color["dark"]
|
|
201
|
+
return nil if light.nil? && dark.nil?
|
|
202
|
+
|
|
203
|
+
{ light: light, dark: dark }.compact
|
|
204
|
+
elsif color.is_a?(String) && !color.strip.empty?
|
|
205
|
+
{ light: color.strip }
|
|
206
|
+
end
|
|
207
|
+
end
|
|
182
208
|
end
|
|
183
209
|
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Docyard
|
|
4
|
+
class Config
|
|
5
|
+
module KeyValidator
|
|
6
|
+
class << self
|
|
7
|
+
def validate(hash, valid_keys, context:)
|
|
8
|
+
return [] unless hash.is_a?(Hash)
|
|
9
|
+
|
|
10
|
+
unknown = hash.keys.map(&:to_s) - valid_keys
|
|
11
|
+
unknown.map { |key| build_error(key, valid_keys, context) }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def build_error(key, valid_keys, context)
|
|
17
|
+
suggestion = find_suggestion(key, valid_keys)
|
|
18
|
+
msg = "unknown key '#{key}'"
|
|
19
|
+
msg += ". Did you mean '#{suggestion}'?" if suggestion
|
|
20
|
+
{ context: context, message: msg }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def find_suggestion(key, valid_keys)
|
|
24
|
+
checker = DidYouMean::SpellChecker.new(dictionary: valid_keys)
|
|
25
|
+
checker.correct(key).first
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Docyard
|
|
4
|
+
module LogoDetector
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def auto_detect_logo(public_dir: "docs/public")
|
|
8
|
+
detect_public_file("logo", %w[svg png], public_dir: public_dir)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def auto_detect_favicon(public_dir: "docs/public")
|
|
12
|
+
detect_public_file("favicon", %w[ico svg png], public_dir: public_dir)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def detect_public_file(name, extensions, public_dir: "docs/public")
|
|
16
|
+
extensions.each do |ext|
|
|
17
|
+
path = File.join(public_dir, "#{name}.#{ext}")
|
|
18
|
+
return "#{name}.#{ext}" if File.exist?(path)
|
|
19
|
+
end
|
|
20
|
+
nil
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def detect_dark_logo(logo, public_dir: "docs/public")
|
|
24
|
+
return nil unless logo
|
|
25
|
+
|
|
26
|
+
ext = File.extname(logo)
|
|
27
|
+
base = File.basename(logo, ext)
|
|
28
|
+
dark_filename = "#{base}-dark#{ext}"
|
|
29
|
+
|
|
30
|
+
if File.absolute_path?(logo)
|
|
31
|
+
dark_path = File.join(File.dirname(logo), dark_filename)
|
|
32
|
+
File.exist?(dark_path) ? dark_path : logo
|
|
33
|
+
else
|
|
34
|
+
dark_path = File.join(public_dir, dark_filename)
|
|
35
|
+
File.exist?(dark_path) ? dark_filename : logo
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Docyard
|
|
4
|
+
class Config
|
|
5
|
+
module Schema
|
|
6
|
+
TOP_LEVEL = %w[
|
|
7
|
+
title description url og_image twitter source
|
|
8
|
+
branding socials tabs sidebar
|
|
9
|
+
build search navigation announcement
|
|
10
|
+
repo analytics feedback
|
|
11
|
+
].freeze
|
|
12
|
+
|
|
13
|
+
SECTIONS = {
|
|
14
|
+
"branding" => %w[logo favicon credits copyright color],
|
|
15
|
+
"build" => %w[output base],
|
|
16
|
+
"search" => %w[enabled placeholder exclude],
|
|
17
|
+
"navigation" => %w[cta breadcrumbs],
|
|
18
|
+
"repo" => %w[url branch edit_path edit_link last_updated],
|
|
19
|
+
"analytics" => %w[google plausible fathom script],
|
|
20
|
+
"announcement" => %w[text link button dismissible],
|
|
21
|
+
"feedback" => %w[enabled question]
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
24
|
+
TAB = %w[text href icon external].freeze
|
|
25
|
+
|
|
26
|
+
CTA = %w[text href variant external].freeze
|
|
27
|
+
|
|
28
|
+
ANNOUNCEMENT_BUTTON = %w[text link].freeze
|
|
29
|
+
|
|
30
|
+
SIDEBAR_ITEM = %w[text icon badge badge_type items collapsed index group collapsible].freeze
|
|
31
|
+
|
|
32
|
+
SIDEBAR_EXTERNAL_LINK = %w[link text icon target].freeze
|
|
33
|
+
|
|
34
|
+
SOCIALS_BUILTIN = %w[github twitter discord slack linkedin youtube bluesky custom].freeze
|
|
35
|
+
|
|
36
|
+
CUSTOM_SOCIAL = %w[icon href].freeze
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Docyard
|
|
4
|
+
class Config
|
|
5
|
+
class Section
|
|
6
|
+
def initialize(data)
|
|
7
|
+
@data = data || {}
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def method_missing(method, *args)
|
|
11
|
+
return super unless args.empty?
|
|
12
|
+
|
|
13
|
+
@data[method.to_s]
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def respond_to_missing?(method, include_private = false)
|
|
17
|
+
@data.key?(method.to_s) || super
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Docyard
|
|
4
|
+
class Config
|
|
5
|
+
module ValidationHelpers
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
def validate_string(value, field_name)
|
|
9
|
+
return if value.nil? || value.is_a?(String)
|
|
10
|
+
|
|
11
|
+
add_error(field: field_name, error: "must be a string", got: value.class.name, fix: "Change to a string value")
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def validate_boolean(value, field_name)
|
|
15
|
+
return if [true, false].include?(value)
|
|
16
|
+
|
|
17
|
+
add_error(field: field_name, error: "must be true or false", got: value.inspect, fix: "Change to true or false")
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def validate_url(value, field_name)
|
|
21
|
+
return if value.nil? || value.is_a?(String)
|
|
22
|
+
|
|
23
|
+
add_error(field: field_name, error: "must be a URL string", got: value.class.name,
|
|
24
|
+
fix: "Change to a URL string")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def validate_array(value, field_name)
|
|
28
|
+
return if value.nil? || value.is_a?(Array)
|
|
29
|
+
|
|
30
|
+
add_array_error(field_name)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def validate_file_path_or_url(value, field_name)
|
|
34
|
+
return if value.nil?
|
|
35
|
+
return add_type_error(field_name, "file path or URL (string)", value.class.name) unless value.is_a?(String)
|
|
36
|
+
return if url?(value)
|
|
37
|
+
|
|
38
|
+
public_dir = File.join(@config["source"] || "docs", "public")
|
|
39
|
+
file_path = File.absolute_path?(value) ? value : File.join(public_dir, value)
|
|
40
|
+
return if File.exist?(file_path)
|
|
41
|
+
|
|
42
|
+
add_error(field: field_name, error: "file not found", got: value,
|
|
43
|
+
fix: "Place the file in #{public_dir}/ directory (e.g., 'logo.svg' for #{public_dir}/logo.svg)")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def validate_no_slashes(value, field_name)
|
|
47
|
+
return if value.nil? || !value.is_a?(String)
|
|
48
|
+
return unless value.include?("/") || value.include?("\\")
|
|
49
|
+
|
|
50
|
+
add_error(field: field_name, error: "cannot contain slashes", got: value,
|
|
51
|
+
fix: "Use a simple directory name like 'dist' or '_site'")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def validate_starts_with_slash(value, field_name)
|
|
55
|
+
return if value.nil? || value.start_with?("/")
|
|
56
|
+
|
|
57
|
+
add_error(field: field_name, error: "must start with /", got: value, fix: "Change to '/#{value}'")
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def url?(value)
|
|
61
|
+
value.match?(%r{\Ahttps?://})
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def add_error(error_data)
|
|
65
|
+
@errors << error_data
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def add_type_error(field, expected, got)
|
|
69
|
+
add_error(field: field, error: "must be a #{expected}", got: got, fix: "Change to a #{expected}")
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def add_hash_error(field)
|
|
73
|
+
add_error(field: field, error: "must be a hash", got: @config[field].class.name,
|
|
74
|
+
fix: "Change to a hash with platform names as keys and URLs as values")
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def add_array_error(field)
|
|
78
|
+
value = field.split(".").reduce(@config) { |h, k| h&.[](k) }
|
|
79
|
+
add_error(field: field, error: "must be an array", got: value.class.name, fix: "Change to an array")
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -1,194 +1,95 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "validation_helpers"
|
|
4
|
+
require_relative "schema"
|
|
5
|
+
require_relative "key_validator"
|
|
6
|
+
require_relative "validators/section"
|
|
7
|
+
require_relative "validators/navigation"
|
|
8
|
+
|
|
3
9
|
module Docyard
|
|
4
10
|
class Config
|
|
5
11
|
class Validator
|
|
12
|
+
include ValidationHelpers
|
|
13
|
+
include Validators::Section
|
|
14
|
+
include Validators::Navigation
|
|
15
|
+
|
|
6
16
|
def initialize(config_data)
|
|
7
17
|
@config = config_data
|
|
8
18
|
@errors = []
|
|
19
|
+
@key_errors = []
|
|
9
20
|
end
|
|
10
21
|
|
|
11
22
|
def validate!
|
|
23
|
+
validate_unknown_keys
|
|
12
24
|
validate_top_level
|
|
13
25
|
validate_branding_section
|
|
14
26
|
validate_socials_section
|
|
15
27
|
validate_tabs_section
|
|
28
|
+
validate_sidebar_setting
|
|
16
29
|
validate_build_section
|
|
17
30
|
validate_search_section
|
|
18
31
|
validate_navigation_section
|
|
32
|
+
validate_announcement_section
|
|
33
|
+
validate_feedback_section
|
|
19
34
|
|
|
35
|
+
raise_key_errors if @key_errors.any?
|
|
20
36
|
raise ConfigError, format_errors if @errors.any?
|
|
21
37
|
end
|
|
22
38
|
|
|
23
39
|
private
|
|
24
40
|
|
|
25
|
-
def
|
|
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
|