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.
Files changed (189) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +67 -1
  3. data/README.md +8 -253
  4. data/exe/docyard +6 -0
  5. data/lib/docyard/build/asset_bundler.rb +2 -2
  6. data/lib/docyard/build/file_copier.rb +12 -5
  7. data/lib/docyard/build/llms_txt_generator.rb +103 -0
  8. data/lib/docyard/build/sitemap_generator.rb +1 -1
  9. data/lib/docyard/build/static_generator.rb +115 -79
  10. data/lib/docyard/builder.rb +6 -2
  11. data/lib/docyard/cli.rb +14 -4
  12. data/lib/docyard/components/aliases.rb +12 -0
  13. data/lib/docyard/components/processors/abbreviation_processor.rb +72 -0
  14. data/lib/docyard/components/processors/accordion_processor.rb +81 -0
  15. data/lib/docyard/components/processors/badge_processor.rb +72 -0
  16. data/lib/docyard/components/processors/callout_processor.rb +9 -3
  17. data/lib/docyard/components/processors/cards_processor.rb +100 -0
  18. data/lib/docyard/components/processors/code_block_extended_fence_postprocessor.rb +24 -0
  19. data/lib/docyard/components/processors/code_block_extended_fence_preprocessor.rb +44 -0
  20. data/lib/docyard/components/processors/code_block_options_preprocessor.rb +34 -3
  21. data/lib/docyard/components/processors/code_block_processor.rb +11 -24
  22. data/lib/docyard/components/processors/code_group_processor.rb +182 -0
  23. data/lib/docyard/components/processors/code_snippet_import_preprocessor.rb +7 -1
  24. data/lib/docyard/components/processors/custom_anchor_processor.rb +42 -0
  25. data/lib/docyard/components/processors/file_tree_processor.rb +150 -0
  26. data/lib/docyard/components/processors/icon_processor.rb +8 -2
  27. data/lib/docyard/components/processors/image_caption_processor.rb +96 -0
  28. data/lib/docyard/components/processors/include_processor.rb +86 -0
  29. data/lib/docyard/components/processors/steps_processor.rb +89 -0
  30. data/lib/docyard/components/processors/tabs_processor.rb +9 -1
  31. data/lib/docyard/components/processors/tooltip_processor.rb +57 -0
  32. data/lib/docyard/components/processors/video_embed_processor.rb +207 -0
  33. data/lib/docyard/components/support/code_block/feature_extractor.rb +3 -1
  34. data/lib/docyard/components/support/code_block/icon_detector.rb +5 -12
  35. data/lib/docyard/components/support/code_block/line_number_resolver.rb +30 -0
  36. data/lib/docyard/components/support/code_detector.rb +2 -12
  37. data/lib/docyard/components/support/code_group/html_builder.rb +118 -0
  38. data/lib/docyard/components/support/markdown_code_block_helper.rb +56 -0
  39. data/lib/docyard/components/support/tabs/icon_detector.rb +6 -2
  40. data/lib/docyard/components/support/tabs/parser.rb +6 -23
  41. data/lib/docyard/config/analytics_resolver.rb +24 -0
  42. data/lib/docyard/config/branding_resolver.rb +84 -58
  43. data/lib/docyard/config/key_validator.rb +30 -0
  44. data/lib/docyard/config/logo_detector.rb +39 -0
  45. data/lib/docyard/config/schema.rb +39 -0
  46. data/lib/docyard/config/section.rb +21 -0
  47. data/lib/docyard/config/validation_helpers.rb +83 -0
  48. data/lib/docyard/config/validator.rb +45 -144
  49. data/lib/docyard/config/validators/navigation.rb +43 -0
  50. data/lib/docyard/config/validators/section.rb +114 -0
  51. data/lib/docyard/config.rb +45 -96
  52. data/lib/docyard/constants.rb +59 -0
  53. data/lib/docyard/{utils/errors.rb → errors.rb} +6 -0
  54. data/lib/docyard/initializer.rb +100 -49
  55. data/lib/docyard/navigation/page_navigation_builder.rb +65 -0
  56. data/lib/docyard/navigation/sidebar/auto_builder.rb +107 -0
  57. data/lib/docyard/navigation/sidebar/cache.rb +96 -0
  58. data/lib/docyard/navigation/sidebar/config_builder.rb +179 -0
  59. data/lib/docyard/navigation/sidebar/distributed_builder.rb +145 -0
  60. data/lib/docyard/navigation/sidebar/item.rb +6 -1
  61. data/lib/docyard/navigation/sidebar/local_config_loader.rb +69 -3
  62. data/lib/docyard/navigation/sidebar/renderer.rb +18 -3
  63. data/lib/docyard/navigation/sidebar_builder.rb +43 -81
  64. data/lib/docyard/rendering/branding_variables.rb +65 -0
  65. data/lib/docyard/rendering/icon_helpers.rb +14 -1
  66. data/lib/docyard/rendering/icons/devicons.rb +63 -0
  67. data/lib/docyard/rendering/icons.rb +26 -27
  68. data/lib/docyard/rendering/markdown.rb +20 -15
  69. data/lib/docyard/rendering/og_helpers.rb +36 -0
  70. data/lib/docyard/rendering/renderer.rb +87 -58
  71. data/lib/docyard/rendering/template_resolver.rb +14 -0
  72. data/lib/docyard/routing/fallback_resolver.rb +3 -3
  73. data/lib/docyard/search/build_indexer.rb +2 -2
  74. data/lib/docyard/search/dev_indexer.rb +36 -28
  75. data/lib/docyard/search/pagefind_support.rb +1 -1
  76. data/lib/docyard/server/asset_handler.rb +40 -15
  77. data/lib/docyard/server/dev_server.rb +90 -55
  78. data/lib/docyard/server/file_watcher.rb +68 -18
  79. data/lib/docyard/server/pagefind_handler.rb +1 -1
  80. data/lib/docyard/server/preview_server.rb +29 -33
  81. data/lib/docyard/server/rack_application.rb +38 -70
  82. data/lib/docyard/server/router.rb +11 -7
  83. data/lib/docyard/server/sse_server.rb +157 -0
  84. data/lib/docyard/server/static_file_app.rb +42 -0
  85. data/lib/docyard/templates/assets/css/components/abbreviation.css +86 -0
  86. data/lib/docyard/templates/assets/css/components/accordion.css +138 -0
  87. data/lib/docyard/templates/assets/css/components/badges.css +47 -0
  88. data/lib/docyard/templates/assets/css/components/banner.css +233 -0
  89. data/lib/docyard/templates/assets/css/components/breadcrumbs.css +2 -1
  90. data/lib/docyard/templates/assets/css/components/callout.css +26 -6
  91. data/lib/docyard/templates/assets/css/components/cards.css +100 -0
  92. data/lib/docyard/templates/assets/css/components/code-block.css +14 -2
  93. data/lib/docyard/templates/assets/css/components/code-group.css +294 -0
  94. data/lib/docyard/templates/assets/css/components/feedback.css +126 -0
  95. data/lib/docyard/templates/assets/css/components/figure.css +22 -0
  96. data/lib/docyard/templates/assets/css/components/file-tree.css +125 -0
  97. data/lib/docyard/templates/assets/css/components/heading-anchor.css +21 -13
  98. data/lib/docyard/templates/assets/css/components/icon.css +5 -0
  99. data/lib/docyard/templates/assets/css/components/lightbox.css +65 -0
  100. data/lib/docyard/templates/assets/css/components/nav-menu.css +20 -4
  101. data/lib/docyard/templates/assets/css/components/navigation.css +32 -3
  102. data/lib/docyard/templates/assets/css/components/page-actions.css +131 -0
  103. data/lib/docyard/templates/assets/css/components/prev-next.css +20 -22
  104. data/lib/docyard/templates/assets/css/components/search.css +6 -10
  105. data/lib/docyard/templates/assets/css/components/steps.css +122 -0
  106. data/lib/docyard/templates/assets/css/components/tab-bar.css +7 -4
  107. data/lib/docyard/templates/assets/css/components/table-of-contents.css +57 -11
  108. data/lib/docyard/templates/assets/css/components/tabs.css +13 -5
  109. data/lib/docyard/templates/assets/css/components/theme-toggle.css +3 -1
  110. data/lib/docyard/templates/assets/css/components/tooltip.css +113 -0
  111. data/lib/docyard/templates/assets/css/components/video.css +41 -0
  112. data/lib/docyard/templates/assets/css/landing.css +82 -13
  113. data/lib/docyard/templates/assets/css/layout.css +17 -0
  114. data/lib/docyard/templates/assets/css/markdown.css +25 -3
  115. data/lib/docyard/templates/assets/css/variables.css +13 -1
  116. data/lib/docyard/templates/assets/js/components/abbreviation.js +85 -0
  117. data/lib/docyard/templates/assets/js/components/banner.js +81 -0
  118. data/lib/docyard/templates/assets/js/components/code-group.js +286 -0
  119. data/lib/docyard/templates/assets/js/components/copy-page.js +115 -0
  120. data/lib/docyard/templates/assets/js/components/feedback.js +66 -0
  121. data/lib/docyard/templates/assets/js/components/file-tree.js +39 -0
  122. data/lib/docyard/templates/assets/js/components/lightbox.js +72 -0
  123. data/lib/docyard/templates/assets/js/components/navigation.js +3 -3
  124. data/lib/docyard/templates/assets/js/components/search.js +3 -3
  125. data/lib/docyard/templates/assets/js/components/table-of-contents.js +12 -6
  126. data/lib/docyard/templates/assets/js/components/tabs.js +45 -22
  127. data/lib/docyard/templates/assets/js/components/tooltip.js +118 -0
  128. data/lib/docyard/templates/assets/js/hot-reload.js +44 -0
  129. data/lib/docyard/templates/errors/404.html.erb +114 -5
  130. data/lib/docyard/templates/errors/500.html.erb +173 -10
  131. data/lib/docyard/templates/init/_sidebar.yml +36 -0
  132. data/lib/docyard/templates/init/docyard.yml +36 -0
  133. data/lib/docyard/templates/init/pages/components.md +146 -0
  134. data/lib/docyard/templates/init/pages/getting-started.md +94 -0
  135. data/lib/docyard/templates/init/pages/index.md +22 -0
  136. data/lib/docyard/templates/layouts/default.html.erb +11 -0
  137. data/lib/docyard/templates/layouts/splash.html.erb +15 -1
  138. data/lib/docyard/templates/partials/_accordion.html.erb +9 -0
  139. data/lib/docyard/templates/partials/_analytics.html.erb +24 -0
  140. data/lib/docyard/templates/partials/_banner.html.erb +27 -0
  141. data/lib/docyard/templates/partials/_card.html.erb +23 -0
  142. data/lib/docyard/templates/partials/_code_block.html.erb +1 -1
  143. data/lib/docyard/templates/partials/_feedback.html.erb +14 -0
  144. data/lib/docyard/templates/partials/_footer.html.erb +1 -1
  145. data/lib/docyard/templates/partials/_head.html.erb +79 -4
  146. data/lib/docyard/templates/partials/_icon_library.html.erb +8 -0
  147. data/lib/docyard/templates/partials/_nav_group.html.erb +6 -0
  148. data/lib/docyard/templates/partials/_nav_leaf.html.erb +3 -0
  149. data/lib/docyard/templates/partials/_page_actions.html.erb +21 -0
  150. data/lib/docyard/templates/partials/_scripts.html.erb +6 -3
  151. data/lib/docyard/templates/partials/_step.html.erb +14 -0
  152. data/lib/docyard/templates/partials/_tabs.html.erb +4 -1
  153. data/lib/docyard/utils/git_info.rb +157 -0
  154. data/lib/docyard/utils/hash_utils.rb +31 -0
  155. data/lib/docyard/utils/html_helpers.rb +8 -0
  156. data/lib/docyard/utils/logging.rb +44 -3
  157. data/lib/docyard/utils/path_resolver.rb +0 -10
  158. data/lib/docyard/utils/path_utils.rb +73 -0
  159. data/lib/docyard/version.rb +1 -1
  160. data/lib/docyard.rb +2 -2
  161. metadata +114 -47
  162. data/.github/ISSUE_TEMPLATE/bug_report.md +0 -31
  163. data/.github/ISSUE_TEMPLATE/feature_request.md +0 -19
  164. data/.github/pull_request_template.md +0 -14
  165. data/.github/workflows/ci.yml +0 -49
  166. data/.rubocop.yml +0 -42
  167. data/CODE_OF_CONDUCT.md +0 -132
  168. data/CONTRIBUTING.md +0 -55
  169. data/LICENSE.vscode-icons +0 -42
  170. data/Rakefile +0 -8
  171. data/lib/docyard/config/constants.rb +0 -31
  172. data/lib/docyard/navigation/sidebar/children_discoverer.rb +0 -51
  173. data/lib/docyard/navigation/sidebar/config_parser.rb +0 -208
  174. data/lib/docyard/navigation/sidebar/file_resolver.rb +0 -78
  175. data/lib/docyard/navigation/sidebar/file_system_scanner.rb +0 -78
  176. data/lib/docyard/navigation/sidebar/metadata_extractor.rb +0 -69
  177. data/lib/docyard/navigation/sidebar/metadata_reader.rb +0 -47
  178. data/lib/docyard/navigation/sidebar/path_prefixer.rb +0 -34
  179. data/lib/docyard/navigation/sidebar/sorter.rb +0 -21
  180. data/lib/docyard/navigation/sidebar/title_extractor.rb +0 -25
  181. data/lib/docyard/navigation/sidebar/tree_builder.rb +0 -139
  182. data/lib/docyard/rendering/icons/LICENSE.phosphor +0 -21
  183. data/lib/docyard/rendering/icons/file_types.rb +0 -79
  184. data/lib/docyard/rendering/icons/phosphor.rb +0 -90
  185. data/lib/docyard/rendering/language_mapping.rb +0 -52
  186. data/lib/docyard/templates/assets/js/reload.js +0 -98
  187. data/lib/docyard/templates/partials/_icon.html.erb +0 -1
  188. data/lib/docyard/templates/partials/_icon_file_extension.html.erb +0 -1
  189. 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 "Warning: Invalid YAML in #{config_file_path}: #{e.message}"
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 "Warning: Error reading #{config_file_path}: #{e.message}"
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 { |key, value| instance_variable_set("@#{key}", value) }
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