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,179 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "item"
|
|
4
|
+
|
|
5
|
+
module Docyard
|
|
6
|
+
module Sidebar
|
|
7
|
+
class ConfigBuilder
|
|
8
|
+
attr_reader :config_items, :current_path, :start_depth
|
|
9
|
+
|
|
10
|
+
def initialize(config_items, current_path: "/", start_depth: 1)
|
|
11
|
+
@config_items = config_items || []
|
|
12
|
+
@current_path = Utils::PathResolver.normalize(current_path)
|
|
13
|
+
@start_depth = start_depth
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def build
|
|
17
|
+
parse_items(config_items, base_path: "", depth: start_depth).map(&:to_h)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def parse_items(items, base_path:, depth:)
|
|
23
|
+
items.map { |item| parse_item(item, base_path: base_path, depth: depth) }.compact
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def parse_item(item_config, base_path:, depth:)
|
|
27
|
+
case item_config
|
|
28
|
+
when String
|
|
29
|
+
build_page_item(item_config, base_path: base_path)
|
|
30
|
+
when Hash
|
|
31
|
+
parse_hash_item(item_config, base_path: base_path, depth: depth)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def parse_hash_item(config, base_path:, depth:)
|
|
36
|
+
return build_external_link(config) if external_link?(config)
|
|
37
|
+
|
|
38
|
+
slug, options = extract_slug_and_options(config)
|
|
39
|
+
return build_page_item(slug, base_path: base_path, options: options) if leaf_item?(options)
|
|
40
|
+
|
|
41
|
+
build_group_item(slug, options: options, base_path: base_path, depth: depth)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def external_link?(config)
|
|
45
|
+
config.key?("link") || config.key?(:link)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def extract_slug_and_options(config)
|
|
49
|
+
if config.keys.first.is_a?(String) && !external_link?(config)
|
|
50
|
+
[config.keys.first.to_s, config.values.first || {}]
|
|
51
|
+
else
|
|
52
|
+
[nil, config]
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def leaf_item?(options)
|
|
57
|
+
return true if options.nil?
|
|
58
|
+
return false if options.is_a?(Hash) && (options.key?("items") || options.key?(:items))
|
|
59
|
+
|
|
60
|
+
true
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def build_page_item(slug, base_path:, options: {})
|
|
64
|
+
options = normalize_options(options)
|
|
65
|
+
url_path = build_url_path(base_path, slug)
|
|
66
|
+
|
|
67
|
+
Item.new(
|
|
68
|
+
slug: slug,
|
|
69
|
+
text: options[:text] || titleize_slug(slug),
|
|
70
|
+
path: url_path,
|
|
71
|
+
icon: options[:icon],
|
|
72
|
+
badge: options[:badge],
|
|
73
|
+
badge_type: options[:badge_type],
|
|
74
|
+
active: current_path == url_path,
|
|
75
|
+
type: :file,
|
|
76
|
+
section: false,
|
|
77
|
+
items: []
|
|
78
|
+
)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def build_group_item(slug, options:, base_path:, depth:)
|
|
82
|
+
options = normalize_options(options)
|
|
83
|
+
context = build_group_context(slug, options, base_path, depth)
|
|
84
|
+
children = parse_items(options[:items] || [], base_path: context[:new_base_path], depth: depth + 1)
|
|
85
|
+
|
|
86
|
+
create_group_item(slug, options, context, children)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def build_group_context(slug, options, base_path, depth)
|
|
90
|
+
is_section = section_at_depth?(options, depth)
|
|
91
|
+
is_virtual_group = options[:group] == true
|
|
92
|
+
has_index = options[:index] == true
|
|
93
|
+
new_base_path = compute_new_base_path(slug, base_path, is_virtual_group)
|
|
94
|
+
url_path = has_index && !is_section ? build_url_path(base_path, slug) : nil
|
|
95
|
+
|
|
96
|
+
{ is_section: is_section, has_index: has_index, new_base_path: new_base_path, url_path: url_path }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def compute_new_base_path(slug, base_path, is_virtual_group)
|
|
100
|
+
return base_path if is_virtual_group
|
|
101
|
+
return base_path unless slug
|
|
102
|
+
|
|
103
|
+
File.join(base_path, slug)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def create_group_item(slug, options, context, children)
|
|
107
|
+
Item.new(
|
|
108
|
+
slug: slug,
|
|
109
|
+
text: options[:text] || titleize_slug(slug),
|
|
110
|
+
path: context[:url_path],
|
|
111
|
+
icon: options[:icon],
|
|
112
|
+
badge: options[:badge],
|
|
113
|
+
badge_type: options[:badge_type],
|
|
114
|
+
active: context[:has_index] && current_path == context[:url_path],
|
|
115
|
+
type: :directory,
|
|
116
|
+
section: context[:is_section],
|
|
117
|
+
collapsed: determine_collapsed_state(context[:is_section], options, children),
|
|
118
|
+
has_index: context[:has_index],
|
|
119
|
+
items: children
|
|
120
|
+
)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def build_external_link(config)
|
|
124
|
+
config = normalize_options(config)
|
|
125
|
+
url = config[:link]
|
|
126
|
+
|
|
127
|
+
Item.new(
|
|
128
|
+
slug: nil,
|
|
129
|
+
text: config[:text],
|
|
130
|
+
path: url,
|
|
131
|
+
link: url,
|
|
132
|
+
icon: config[:icon],
|
|
133
|
+
target: config[:target] || "_blank",
|
|
134
|
+
type: :external,
|
|
135
|
+
section: false,
|
|
136
|
+
items: []
|
|
137
|
+
)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def section_at_depth?(options, depth)
|
|
141
|
+
return false if options[:collapsible] == true
|
|
142
|
+
return false if options[:group] == true
|
|
143
|
+
|
|
144
|
+
depth == 1
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def determine_collapsed_state(is_section, options, children)
|
|
148
|
+
return false if is_section
|
|
149
|
+
return false if child_active?(children)
|
|
150
|
+
return options[:collapsed] if options.key?(:collapsed)
|
|
151
|
+
|
|
152
|
+
true
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def child_active?(children)
|
|
156
|
+
children.any? { |child| child.active || child_active?(child.items) }
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def build_url_path(base_path, slug)
|
|
160
|
+
base_path = base_path.to_s.sub(%r{^/+}, "")
|
|
161
|
+
return base_path.empty? ? "/" : "/#{base_path}" if slug == "index"
|
|
162
|
+
|
|
163
|
+
path = [base_path, slug].reject(&:empty?).join("/")
|
|
164
|
+
"/#{path}"
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def titleize_slug(slug)
|
|
168
|
+
Utils::TextFormatter.titleize(slug.to_s)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def normalize_options(options)
|
|
172
|
+
return {} if options.nil?
|
|
173
|
+
return options if options.is_a?(Hash) && options.keys.all? { |k| k.is_a?(Symbol) }
|
|
174
|
+
|
|
175
|
+
options.transform_keys(&:to_sym)
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "item"
|
|
4
|
+
require_relative "local_config_loader"
|
|
5
|
+
require_relative "config_builder"
|
|
6
|
+
|
|
7
|
+
module Docyard
|
|
8
|
+
module Sidebar
|
|
9
|
+
class DistributedBuilder
|
|
10
|
+
attr_reader :docs_path, :current_path
|
|
11
|
+
|
|
12
|
+
def initialize(docs_path, current_path: "/")
|
|
13
|
+
@docs_path = docs_path
|
|
14
|
+
@current_path = Utils::PathResolver.normalize(current_path)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def build
|
|
18
|
+
root_config = load_root_config
|
|
19
|
+
return [] if root_config.empty?
|
|
20
|
+
|
|
21
|
+
root_config.map { |section_slug| build_section(section_slug) }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def load_root_config
|
|
27
|
+
loader = LocalConfigLoader.new(docs_path)
|
|
28
|
+
config = loader.load
|
|
29
|
+
raise_missing_root_config unless config
|
|
30
|
+
|
|
31
|
+
normalize_root_config(config)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def normalize_root_config(config)
|
|
35
|
+
config.map do |item|
|
|
36
|
+
case item
|
|
37
|
+
when String then item
|
|
38
|
+
when Hash then item.keys.first.to_s
|
|
39
|
+
end
|
|
40
|
+
end.compact
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def build_section(section_slug)
|
|
44
|
+
section_path = File.join(docs_path, section_slug)
|
|
45
|
+
section_config = load_section_config(section_slug, section_path)
|
|
46
|
+
|
|
47
|
+
build_section_item(section_slug, section_config)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def load_section_config(section_slug, section_path)
|
|
51
|
+
loader = LocalConfigLoader.new(section_path)
|
|
52
|
+
raise_missing_section_config(section_slug) unless loader.config_file_exists?
|
|
53
|
+
|
|
54
|
+
raw_config = YAML.load_file(File.join(section_path, "_sidebar.yml"))
|
|
55
|
+
normalize_section_config(raw_config)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def normalize_section_config(config)
|
|
59
|
+
return { items: config } if config.is_a?(Array)
|
|
60
|
+
|
|
61
|
+
config.transform_keys(&:to_sym)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def build_section_item(section_slug, section_config)
|
|
65
|
+
items = section_config[:items] || []
|
|
66
|
+
children = build_section_children(items, section_slug)
|
|
67
|
+
|
|
68
|
+
Item.new(
|
|
69
|
+
slug: section_slug,
|
|
70
|
+
text: section_config[:text] || Utils::TextFormatter.titleize(section_slug),
|
|
71
|
+
path: nil,
|
|
72
|
+
icon: section_config[:icon],
|
|
73
|
+
type: :directory,
|
|
74
|
+
section: true,
|
|
75
|
+
collapsed: false,
|
|
76
|
+
has_index: false,
|
|
77
|
+
active: false,
|
|
78
|
+
items: children
|
|
79
|
+
).to_h
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def build_section_children(items, section_slug)
|
|
83
|
+
adjusted_current_path = adjust_current_path_for_section(section_slug)
|
|
84
|
+
|
|
85
|
+
builder = ConfigBuilder.new(items, current_path: adjusted_current_path, start_depth: 2)
|
|
86
|
+
tree = builder.build
|
|
87
|
+
|
|
88
|
+
prefix_paths(tree, section_slug)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def adjust_current_path_for_section(section_slug)
|
|
92
|
+
prefix = "/#{section_slug}"
|
|
93
|
+
return current_path unless current_path.start_with?(prefix)
|
|
94
|
+
|
|
95
|
+
relative = current_path.sub(prefix, "")
|
|
96
|
+
relative.empty? ? "/" : relative
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def prefix_paths(items, prefix)
|
|
100
|
+
items.map do |item|
|
|
101
|
+
prefixed = prefix_item_path(item, prefix)
|
|
102
|
+
prefixed[:children] = prefix_paths(item[:children] || [], prefix) if item[:children]&.any?
|
|
103
|
+
prefixed
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def prefix_item_path(item, prefix)
|
|
108
|
+
item = item.dup
|
|
109
|
+
return item if item[:path].nil? || external_path?(item[:path])
|
|
110
|
+
|
|
111
|
+
prefixed_path = "/#{prefix}#{item[:path]}"
|
|
112
|
+
item[:path] = prefixed_path.chomp("/")
|
|
113
|
+
item[:path] = "/" if item[:path].empty?
|
|
114
|
+
item[:active] = current_path == item[:path]
|
|
115
|
+
item
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def external_path?(path)
|
|
119
|
+
path.start_with?("http://", "https://")
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def raise_missing_root_config
|
|
123
|
+
raise SidebarConfigError, <<~MSG.strip
|
|
124
|
+
Distributed sidebar mode requires docs/_sidebar.yml
|
|
125
|
+
|
|
126
|
+
Either:
|
|
127
|
+
1. Create docs/_sidebar.yml listing your sections
|
|
128
|
+
2. Change to 'sidebar: config' in docyard.yml
|
|
129
|
+
MSG
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def raise_missing_section_config(section_slug)
|
|
133
|
+
raise SidebarConfigError, <<~MSG.strip
|
|
134
|
+
Missing sidebar config for section '#{section_slug}'
|
|
135
|
+
|
|
136
|
+
Expected: docs/#{section_slug}/_sidebar.yml
|
|
137
|
+
|
|
138
|
+
Either:
|
|
139
|
+
1. Create docs/#{section_slug}/_sidebar.yml
|
|
140
|
+
2. Remove '#{section_slug}' from docs/_sidebar.yml
|
|
141
|
+
MSG
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
@@ -11,6 +11,7 @@ module Docyard
|
|
|
11
11
|
|
|
12
12
|
def initialize(docs_path)
|
|
13
13
|
@docs_path = docs_path
|
|
14
|
+
@key_errors = []
|
|
14
15
|
end
|
|
15
16
|
|
|
16
17
|
def load
|
|
@@ -31,12 +32,17 @@ module Docyard
|
|
|
31
32
|
|
|
32
33
|
def parse_config_file
|
|
33
34
|
content = YAML.load_file(config_file_path)
|
|
34
|
-
normalize_config(content)
|
|
35
|
+
items = normalize_config(content)
|
|
36
|
+
validate_items(items) if items
|
|
37
|
+
report_key_errors
|
|
38
|
+
items
|
|
35
39
|
rescue Psych::SyntaxError => e
|
|
36
|
-
warn
|
|
40
|
+
Docyard.logger.warn("Invalid YAML in #{config_file_path}: #{e.message}")
|
|
37
41
|
nil
|
|
42
|
+
rescue ConfigError
|
|
43
|
+
raise
|
|
38
44
|
rescue StandardError => e
|
|
39
|
-
warn
|
|
45
|
+
Docyard.logger.warn("Error reading #{config_file_path}: #{e.message}")
|
|
40
46
|
nil
|
|
41
47
|
end
|
|
42
48
|
|
|
@@ -46,6 +52,66 @@ module Docyard
|
|
|
46
52
|
|
|
47
53
|
content["items"] if content.is_a?(Hash)
|
|
48
54
|
end
|
|
55
|
+
|
|
56
|
+
def validate_items(items, path_prefix: "")
|
|
57
|
+
return unless items.is_a?(Array)
|
|
58
|
+
|
|
59
|
+
items.each_with_index do |item, idx|
|
|
60
|
+
validate_item(item, "#{path_prefix}[#{idx}]")
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def validate_item(item, context)
|
|
65
|
+
return unless item.is_a?(Hash)
|
|
66
|
+
|
|
67
|
+
if external_link?(item)
|
|
68
|
+
validate_external_link(item, context)
|
|
69
|
+
else
|
|
70
|
+
validate_sidebar_item(item, context)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def external_link?(item)
|
|
75
|
+
item.key?("link") || item.key?(:link)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def validate_external_link(item, context)
|
|
79
|
+
errors = Config::KeyValidator.validate(item, Config::Schema::SIDEBAR_EXTERNAL_LINK, context: context)
|
|
80
|
+
@key_errors.concat(errors)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def validate_sidebar_item(item, context)
|
|
84
|
+
slug, options = extract_slug_and_options(item)
|
|
85
|
+
return unless options.is_a?(Hash)
|
|
86
|
+
|
|
87
|
+
errors = Config::KeyValidator.validate(options, Config::Schema::SIDEBAR_ITEM, context: context)
|
|
88
|
+
@key_errors.concat(errors)
|
|
89
|
+
validate_nested_items(options, slug, context)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def extract_slug_and_options(item)
|
|
93
|
+
first_key = item.keys.first
|
|
94
|
+
if first_key.is_a?(String) && !external_link?(item)
|
|
95
|
+
[first_key, item[first_key]]
|
|
96
|
+
else
|
|
97
|
+
[nil, item]
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def validate_nested_items(options, slug, context)
|
|
102
|
+
nested = options["items"] || options[:items]
|
|
103
|
+
return unless nested
|
|
104
|
+
|
|
105
|
+
nested_context = slug ? "#{context}.#{slug}" : context
|
|
106
|
+
validate_items(nested, path_prefix: nested_context)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def report_key_errors
|
|
110
|
+
return if @key_errors.empty?
|
|
111
|
+
|
|
112
|
+
messages = @key_errors.map { |e| "#{e[:context]}: #{e[:message]}" }
|
|
113
|
+
raise ConfigError, "Error in #{config_file_path}:\n#{messages.join("\n")}"
|
|
114
|
+
end
|
|
49
115
|
end
|
|
50
116
|
end
|
|
51
117
|
end
|
|
@@ -19,6 +19,8 @@ module Docyard
|
|
|
19
19
|
@header_ctas = header_ctas
|
|
20
20
|
end
|
|
21
21
|
|
|
22
|
+
VALID_IVAR_PATTERN = /\A[a-z_][a-z0-9_]*\z/i
|
|
23
|
+
|
|
22
24
|
def render(tree)
|
|
23
25
|
return "" if tree.empty?
|
|
24
26
|
|
|
@@ -32,12 +34,21 @@ module Docyard
|
|
|
32
34
|
template_path = File.join(PARTIALS_PATH, "_#{name}.html.erb")
|
|
33
35
|
template = File.read(template_path)
|
|
34
36
|
|
|
35
|
-
locals.each
|
|
37
|
+
locals.each do |key, value|
|
|
38
|
+
validate_variable_name!(key)
|
|
39
|
+
instance_variable_set("@#{key}", value)
|
|
40
|
+
end
|
|
36
41
|
|
|
37
42
|
erb_binding = binding
|
|
38
43
|
ERB.new(template).result(erb_binding)
|
|
39
44
|
end
|
|
40
45
|
|
|
46
|
+
def validate_variable_name!(name)
|
|
47
|
+
return if name.to_s.match?(VALID_IVAR_PATTERN)
|
|
48
|
+
|
|
49
|
+
raise ArgumentError, "Invalid variable name: #{name}"
|
|
50
|
+
end
|
|
51
|
+
|
|
41
52
|
def render_tree_with_sections(items)
|
|
42
53
|
filtered_items = items.reject { |item| item[:title]&.downcase == site_title.downcase }
|
|
43
54
|
grouped = group_items_by_section(filtered_items)
|
|
@@ -1,27 +1,27 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require_relative "sidebar/
|
|
4
|
-
require_relative "sidebar/
|
|
5
|
-
require_relative "sidebar/
|
|
3
|
+
require_relative "sidebar/cache"
|
|
4
|
+
require_relative "sidebar/config_builder"
|
|
5
|
+
require_relative "sidebar/auto_builder"
|
|
6
|
+
require_relative "sidebar/distributed_builder"
|
|
6
7
|
require_relative "sidebar/renderer"
|
|
7
|
-
require_relative "sidebar/config_parser"
|
|
8
|
-
require_relative "sidebar/local_config_loader"
|
|
9
|
-
require_relative "sidebar/path_prefixer"
|
|
10
8
|
require_relative "sidebar/tree_filter"
|
|
9
|
+
require_relative "sidebar/local_config_loader"
|
|
11
10
|
|
|
12
11
|
module Docyard
|
|
13
12
|
class SidebarBuilder
|
|
14
|
-
attr_reader :docs_path, :current_path, :config, :header_ctas
|
|
13
|
+
attr_reader :docs_path, :current_path, :config, :header_ctas, :sidebar_cache
|
|
15
14
|
|
|
16
|
-
def initialize(docs_path:, current_path: "/", config: nil, header_ctas: [])
|
|
15
|
+
def initialize(docs_path:, current_path: "/", config: nil, header_ctas: [], sidebar_cache: nil)
|
|
17
16
|
@docs_path = docs_path
|
|
18
17
|
@current_path = current_path
|
|
19
18
|
@config = config
|
|
20
19
|
@header_ctas = header_ctas
|
|
20
|
+
@sidebar_cache = sidebar_cache
|
|
21
21
|
end
|
|
22
22
|
|
|
23
23
|
def tree
|
|
24
|
-
@tree ||=
|
|
24
|
+
@tree ||= build_tree
|
|
25
25
|
end
|
|
26
26
|
|
|
27
27
|
def to_html
|
|
@@ -30,101 +30,63 @@ module Docyard
|
|
|
30
30
|
|
|
31
31
|
private
|
|
32
32
|
|
|
33
|
-
def
|
|
34
|
-
|
|
35
|
-
return build_tree_for_path(docs_path) unless active_tab
|
|
36
|
-
|
|
37
|
-
build_tree_for_tab(active_tab)
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
def build_tree_for_tab(tab)
|
|
41
|
-
tab_path = tab["href"]&.chomp("/")
|
|
42
|
-
return build_tree_for_path(docs_path) if empty_tab_path?(tab_path)
|
|
33
|
+
def build_tree
|
|
34
|
+
return build_from_cache if sidebar_cache&.valid?
|
|
43
35
|
|
|
44
|
-
|
|
45
|
-
build_scoped_or_filtered_tree(scoped_docs_path, tab_path)
|
|
36
|
+
build_without_cache
|
|
46
37
|
end
|
|
47
38
|
|
|
48
|
-
def
|
|
49
|
-
|
|
39
|
+
def build_from_cache
|
|
40
|
+
base_tree = sidebar_cache.get(current_path: current_path)
|
|
41
|
+
apply_tab_scoping(base_tree)
|
|
50
42
|
end
|
|
51
43
|
|
|
52
|
-
def
|
|
53
|
-
|
|
54
|
-
|
|
44
|
+
def build_without_cache
|
|
45
|
+
base_tree = build_tree_for_mode
|
|
46
|
+
apply_tab_scoping(base_tree)
|
|
55
47
|
end
|
|
56
48
|
|
|
57
|
-
def
|
|
58
|
-
|
|
59
|
-
|
|
49
|
+
def build_tree_for_mode
|
|
50
|
+
case sidebar_mode
|
|
51
|
+
when "auto"
|
|
52
|
+
Sidebar::AutoBuilder.new(docs_path, current_path: current_path).build
|
|
53
|
+
when "distributed"
|
|
54
|
+
Sidebar::DistributedBuilder.new(docs_path, current_path: current_path).build
|
|
60
55
|
else
|
|
61
|
-
|
|
56
|
+
build_config_tree
|
|
62
57
|
end
|
|
63
58
|
end
|
|
64
59
|
|
|
65
|
-
def
|
|
66
|
-
|
|
67
|
-
|
|
60
|
+
def build_config_tree
|
|
61
|
+
config_items = Sidebar::LocalConfigLoader.new(docs_path).load
|
|
62
|
+
return [] unless config_items
|
|
68
63
|
|
|
69
|
-
|
|
70
|
-
config_items = Sidebar::LocalConfigLoader.new(path).load
|
|
71
|
-
tree = build_tree(config_items, path, base_url_prefix)
|
|
72
|
-
maybe_prepend_overview(tree, path, base_url_prefix)
|
|
64
|
+
Sidebar::ConfigBuilder.new(config_items, current_path: current_path).build
|
|
73
65
|
end
|
|
74
66
|
|
|
75
|
-
def
|
|
76
|
-
|
|
77
|
-
build_tree_from_config(config_items, path, base_url_prefix)
|
|
78
|
-
else
|
|
79
|
-
build_tree_from_filesystem(path, base_url_prefix)
|
|
80
|
-
end
|
|
67
|
+
def sidebar_mode
|
|
68
|
+
config&.sidebar || "config"
|
|
81
69
|
end
|
|
82
70
|
|
|
83
|
-
def
|
|
84
|
-
return
|
|
71
|
+
def apply_tab_scoping(base_tree)
|
|
72
|
+
return base_tree if sidebar_mode == "auto"
|
|
73
|
+
return base_tree unless tabs_configured?
|
|
85
74
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
def skip_overview?(tree, path, base_url_prefix)
|
|
90
|
-
base_url_prefix.empty? ||
|
|
91
|
-
tree.first&.dig(:section) ||
|
|
92
|
-
!File.file?(File.join(path, "index.md")) ||
|
|
93
|
-
tree.any? { |item| item[:path] == base_url_prefix }
|
|
94
|
-
end
|
|
95
|
-
|
|
96
|
-
def build_overview_item(base_url_prefix)
|
|
97
|
-
{
|
|
98
|
-
title: "Overview", path: base_url_prefix, icon: nil,
|
|
99
|
-
active: current_path == base_url_prefix, type: :file,
|
|
100
|
-
collapsed: false, collapsible: false, target: "_self",
|
|
101
|
-
has_index: false, section: false, children: []
|
|
102
|
-
}
|
|
103
|
-
end
|
|
104
|
-
|
|
105
|
-
def build_tree_from_config(items, path, base_url_prefix)
|
|
106
|
-
tree = Sidebar::ConfigParser.new(
|
|
107
|
-
items, docs_path: path, current_path: current_path_relative_to(base_url_prefix)
|
|
108
|
-
).parse.map(&:to_h)
|
|
75
|
+
active_tab = find_active_tab
|
|
76
|
+
return base_tree unless active_tab
|
|
109
77
|
|
|
110
|
-
|
|
78
|
+
filter_tree_for_tab(base_tree, active_tab)
|
|
111
79
|
end
|
|
112
80
|
|
|
113
|
-
def
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
docs_path: path, current_path: current_path_relative_to(base_url_prefix)
|
|
117
|
-
).build(file_items)
|
|
81
|
+
def filter_tree_for_tab(base_tree, tab)
|
|
82
|
+
tab_path = tab["href"]&.chomp("/")
|
|
83
|
+
return base_tree if empty_tab_path?(tab_path)
|
|
118
84
|
|
|
119
|
-
Sidebar::
|
|
85
|
+
Sidebar::TreeFilter.new(base_tree, tab_path).filter
|
|
120
86
|
end
|
|
121
87
|
|
|
122
|
-
def
|
|
123
|
-
|
|
124
|
-
return current_path unless current_path.start_with?(prefix)
|
|
125
|
-
|
|
126
|
-
relative = current_path.sub(prefix, "")
|
|
127
|
-
relative.empty? ? "/" : relative
|
|
88
|
+
def empty_tab_path?(tab_path)
|
|
89
|
+
tab_path.nil? || tab_path.empty? || tab_path == "/"
|
|
128
90
|
end
|
|
129
91
|
|
|
130
92
|
def renderer
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Docyard
|
|
4
|
+
module BrandingVariables
|
|
5
|
+
private
|
|
6
|
+
|
|
7
|
+
def assign_branding_variables(branding, current_path = "/")
|
|
8
|
+
assign_site_branding(branding)
|
|
9
|
+
assign_search_options(branding)
|
|
10
|
+
assign_credits_and_social(branding)
|
|
11
|
+
assign_tabs(branding, current_path)
|
|
12
|
+
assign_analytics(branding)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def assign_site_branding(branding)
|
|
16
|
+
@site_title = branding[:site_title] || Constants::DEFAULT_SITE_TITLE
|
|
17
|
+
@site_description = branding[:site_description] || ""
|
|
18
|
+
@logo = branding[:logo] || Constants::DEFAULT_LOGO_PATH
|
|
19
|
+
@logo_dark = branding[:logo_dark]
|
|
20
|
+
@favicon = branding[:favicon] || Constants::DEFAULT_FAVICON_PATH
|
|
21
|
+
@has_custom_logo = branding[:has_custom_logo] || false
|
|
22
|
+
@primary_color = branding[:primary_color]
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def assign_search_options(branding)
|
|
26
|
+
@search_enabled = branding[:search_enabled].nil? || branding[:search_enabled]
|
|
27
|
+
@search_placeholder = branding[:search_placeholder] || "Search documentation..."
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def assign_credits_and_social(branding)
|
|
31
|
+
@credits = branding[:credits] != false
|
|
32
|
+
@copyright = branding[:copyright]
|
|
33
|
+
@social = branding[:social] || []
|
|
34
|
+
@header_ctas = branding[:header_ctas] || []
|
|
35
|
+
@announcement = branding[:announcement]
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def assign_analytics(branding)
|
|
39
|
+
@has_analytics = branding[:has_analytics] || false
|
|
40
|
+
@analytics_google = branding[:analytics_google]
|
|
41
|
+
@analytics_plausible = branding[:analytics_plausible]
|
|
42
|
+
@analytics_fathom = branding[:analytics_fathom]
|
|
43
|
+
@analytics_script = branding[:analytics_script]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def assign_tabs(branding, current_path)
|
|
47
|
+
tabs = branding[:tabs] || []
|
|
48
|
+
@tabs = tabs.map { |tab| tab.merge(active: tab_active?(tab[:href], current_path)) }
|
|
49
|
+
@has_tabs = branding[:has_tabs] || false
|
|
50
|
+
@current_path = current_path
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def tab_active?(tab_href, current_path)
|
|
54
|
+
return false if tab_href.nil? || current_path.nil?
|
|
55
|
+
return false if tab_href.start_with?("http://", "https://")
|
|
56
|
+
|
|
57
|
+
normalized_tab = tab_href.chomp("/")
|
|
58
|
+
normalized_current = current_path.chomp("/")
|
|
59
|
+
|
|
60
|
+
return true if normalized_tab == normalized_current
|
|
61
|
+
|
|
62
|
+
current_path.start_with?("#{normalized_tab}/")
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|