docyard 0.9.0 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +57 -1
- data/README.md +8 -253
- data/exe/docyard +6 -0
- data/lib/docyard/build/asset_bundler.rb +24 -2
- data/lib/docyard/build/error_page_generator.rb +33 -0
- data/lib/docyard/build/file_copier.rb +12 -5
- data/lib/docyard/build/file_writer.rb +19 -0
- data/lib/docyard/build/llms_txt_generator.rb +103 -0
- data/lib/docyard/build/root_fallback_generator.rb +66 -0
- data/lib/docyard/build/sitemap_generator.rb +1 -1
- data/lib/docyard/build/static_generator.rb +119 -81
- 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/breadcrumb_builder.rb +45 -6
- 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 +96 -61
- 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 +39 -71
- 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/heading-anchor.css +2 -2
- 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 +9 -6
- data/lib/docyard/templates/assets/css/components/table-of-contents.css +63 -17
- 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 +32 -16
- data/lib/docyard/templates/assets/css/markdown.css +22 -2
- data/lib/docyard/templates/assets/css/variables.css +14 -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 +125 -5
- data/lib/docyard/templates/errors/500.html.erb +184 -10
- data/lib/docyard/templates/errors/redirect.html.erb +12 -0
- 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 +80 -5
- 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 +81 -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,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "item"
|
|
4
|
+
|
|
5
|
+
module Docyard
|
|
6
|
+
module Sidebar
|
|
7
|
+
class AutoBuilder
|
|
8
|
+
attr_reader :docs_path, :current_path
|
|
9
|
+
|
|
10
|
+
def initialize(docs_path, current_path: "/")
|
|
11
|
+
@docs_path = docs_path
|
|
12
|
+
@current_path = Utils::PathResolver.normalize(current_path)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def build
|
|
16
|
+
return [] unless File.directory?(docs_path)
|
|
17
|
+
|
|
18
|
+
scan_directory("").map(&:to_h)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def scan_directory(relative_path, depth: 1)
|
|
24
|
+
full_path = File.join(docs_path, relative_path)
|
|
25
|
+
return [] unless File.directory?(full_path)
|
|
26
|
+
|
|
27
|
+
entries = sorted_entries(full_path, relative_path)
|
|
28
|
+
entries.map { |entry| build_item(entry, relative_path, depth) }.compact
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def sorted_entries(full_path, relative_path)
|
|
32
|
+
Dir.children(full_path)
|
|
33
|
+
.reject { |entry| ignored_entry?(entry, relative_path) }
|
|
34
|
+
.sort_by(&:downcase)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def build_item(entry, relative_path, depth)
|
|
38
|
+
entry_relative_path = build_relative_path(relative_path, entry)
|
|
39
|
+
entry_full_path = File.join(docs_path, entry_relative_path)
|
|
40
|
+
|
|
41
|
+
if File.directory?(entry_full_path)
|
|
42
|
+
build_directory_item(entry, entry_relative_path, depth)
|
|
43
|
+
elsif entry.end_with?(".md")
|
|
44
|
+
build_file_item(entry, entry_relative_path)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def build_relative_path(relative_path, entry)
|
|
49
|
+
relative_path.empty? ? entry : File.join(relative_path, entry)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def build_directory_item(name, relative_path, depth)
|
|
53
|
+
children = scan_directory(relative_path, depth: depth + 1)
|
|
54
|
+
return nil if children.empty?
|
|
55
|
+
|
|
56
|
+
url_path = "/#{relative_path}"
|
|
57
|
+
has_index = File.file?(File.join(docs_path, relative_path, "index.md"))
|
|
58
|
+
|
|
59
|
+
Item.new(
|
|
60
|
+
slug: name,
|
|
61
|
+
text: Utils::TextFormatter.titleize(name),
|
|
62
|
+
path: has_index ? url_path : nil,
|
|
63
|
+
type: :directory,
|
|
64
|
+
section: depth == 1,
|
|
65
|
+
collapsed: depth > 1 && !child_active?(children),
|
|
66
|
+
has_index: has_index,
|
|
67
|
+
active: has_index && current_path == url_path,
|
|
68
|
+
items: children
|
|
69
|
+
)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def build_file_item(filename, relative_path)
|
|
73
|
+
slug = filename.delete_suffix(".md")
|
|
74
|
+
url_path = "/#{relative_path.delete_suffix('.md')}"
|
|
75
|
+
|
|
76
|
+
Item.new(
|
|
77
|
+
slug: slug,
|
|
78
|
+
text: Utils::TextFormatter.titleize(slug),
|
|
79
|
+
path: url_path,
|
|
80
|
+
type: :file,
|
|
81
|
+
section: false,
|
|
82
|
+
active: current_path == url_path,
|
|
83
|
+
items: []
|
|
84
|
+
)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def ignored_entry?(entry, relative_path)
|
|
88
|
+
entry.start_with?(".") ||
|
|
89
|
+
entry.start_with?("_") ||
|
|
90
|
+
root_index?(entry, relative_path) ||
|
|
91
|
+
public_folder?(entry, relative_path)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def root_index?(entry, relative_path)
|
|
95
|
+
entry == "index.md" && relative_path.empty?
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def public_folder?(entry, relative_path)
|
|
99
|
+
entry == "public" && relative_path.empty?
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def child_active?(children)
|
|
103
|
+
children.any? { |child| child.active || child_active?(child.items) }
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "config_builder"
|
|
4
|
+
require_relative "auto_builder"
|
|
5
|
+
require_relative "distributed_builder"
|
|
6
|
+
require_relative "local_config_loader"
|
|
7
|
+
|
|
8
|
+
module Docyard
|
|
9
|
+
module Sidebar
|
|
10
|
+
class Cache
|
|
11
|
+
attr_reader :docs_path, :config, :tree, :built_at
|
|
12
|
+
|
|
13
|
+
def initialize(docs_path:, config:)
|
|
14
|
+
@docs_path = docs_path
|
|
15
|
+
@config = config
|
|
16
|
+
@tree = nil
|
|
17
|
+
@built_at = nil
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def build
|
|
21
|
+
@tree = build_tree
|
|
22
|
+
@built_at = Time.now
|
|
23
|
+
@tree
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def get(current_path: "/")
|
|
27
|
+
return nil unless @tree
|
|
28
|
+
|
|
29
|
+
mark_active_items(@tree, current_path)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def invalidate
|
|
33
|
+
@tree = nil
|
|
34
|
+
@built_at = nil
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def valid?
|
|
38
|
+
!@tree.nil?
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def build_tree
|
|
44
|
+
case config.sidebar
|
|
45
|
+
when "auto"
|
|
46
|
+
AutoBuilder.new(docs_path, current_path: "/").build
|
|
47
|
+
when "distributed"
|
|
48
|
+
DistributedBuilder.new(docs_path, current_path: "/").build
|
|
49
|
+
else
|
|
50
|
+
build_config_tree
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def build_config_tree
|
|
55
|
+
config_items = LocalConfigLoader.new(docs_path).load
|
|
56
|
+
return [] unless config_items
|
|
57
|
+
|
|
58
|
+
ConfigBuilder.new(config_items, current_path: "/").build
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def mark_active_items(items, current_path)
|
|
62
|
+
deep_copy_with_active(items, current_path)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def deep_copy_with_active(items, current_path)
|
|
66
|
+
items.map do |item|
|
|
67
|
+
copied = item.dup
|
|
68
|
+
copied[:active] = path_matches?(copied[:path], current_path)
|
|
69
|
+
copied[:children] = deep_copy_with_active(item[:children] || [], current_path)
|
|
70
|
+
copied[:collapsed] = determine_collapsed_for_copy(copied)
|
|
71
|
+
copied
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def path_matches?(item_path, current_path)
|
|
76
|
+
return false if item_path.nil?
|
|
77
|
+
|
|
78
|
+
normalized_item = Utils::PathResolver.normalize(item_path)
|
|
79
|
+
normalized_current = Utils::PathResolver.normalize(current_path)
|
|
80
|
+
normalized_item == normalized_current
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def determine_collapsed_for_copy(item)
|
|
84
|
+
return false if item[:section]
|
|
85
|
+
return false if item[:active]
|
|
86
|
+
return false if child_active?(item[:children] || [])
|
|
87
|
+
|
|
88
|
+
item[:collapsed]
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def child_active?(children)
|
|
92
|
+
children.any? { |child| child[:active] || child_active?(child[:children] || []) }
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -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)
|