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
|
@@ -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
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
module Docyard
|
|
4
4
|
module Sidebar
|
|
5
5
|
class Item
|
|
6
|
-
attr_reader :slug, :text, :icon, :link, :target, :collapsed, :items, :path, :active, :type, :has_index, :section
|
|
6
|
+
attr_reader :slug, :text, :icon, :link, :target, :collapsed, :items, :path, :active, :type, :has_index, :section,
|
|
7
|
+
:badge, :badge_type
|
|
7
8
|
|
|
8
9
|
DEFAULTS = {
|
|
9
10
|
target: "_self",
|
|
@@ -27,6 +28,8 @@ module Docyard
|
|
|
27
28
|
@text = options[:text]
|
|
28
29
|
@icon = options[:icon]
|
|
29
30
|
@link = options[:link]
|
|
31
|
+
@badge = options[:badge]
|
|
32
|
+
@badge_type = options[:badge_type]
|
|
30
33
|
end
|
|
31
34
|
|
|
32
35
|
def assign_optional_attributes(options)
|
|
@@ -88,6 +91,8 @@ module Docyard
|
|
|
88
91
|
target: target,
|
|
89
92
|
has_index: has_index,
|
|
90
93
|
section: section,
|
|
94
|
+
badge: badge,
|
|
95
|
+
badge_type: badge_type,
|
|
91
96
|
children: children.map(&:to_h)
|
|
92
97
|
}
|
|
93
98
|
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)
|
|
@@ -112,7 +123,9 @@ module Docyard
|
|
|
112
123
|
title: item[:title],
|
|
113
124
|
active: item[:active],
|
|
114
125
|
icon: item[:icon],
|
|
115
|
-
target: item[:target]
|
|
126
|
+
target: item[:target],
|
|
127
|
+
badge: item[:badge],
|
|
128
|
+
badge_type: item[:badge_type]
|
|
116
129
|
)
|
|
117
130
|
end
|
|
118
131
|
|
|
@@ -136,7 +149,9 @@ module Docyard
|
|
|
136
149
|
children_html: children_html,
|
|
137
150
|
icon: item[:icon],
|
|
138
151
|
collapsed: item[:collapsed],
|
|
139
|
-
has_index: item[:has_index]
|
|
152
|
+
has_index: item[:has_index],
|
|
153
|
+
badge: item[:badge],
|
|
154
|
+
badge_type: item[:badge_type]
|
|
140
155
|
)
|
|
141
156
|
end
|
|
142
157
|
end
|