docyard 0.9.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +43 -0
  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/processors/callout_processor.rb +1 -1
  13. data/lib/docyard/components/processors/code_block_extended_fence_postprocessor.rb +24 -0
  14. data/lib/docyard/components/processors/code_block_extended_fence_preprocessor.rb +44 -0
  15. data/lib/docyard/components/processors/code_block_options_preprocessor.rb +11 -1
  16. data/lib/docyard/components/processors/code_block_processor.rb +5 -24
  17. data/lib/docyard/components/processors/code_group_processor.rb +6 -22
  18. data/lib/docyard/components/processors/code_snippet_import_preprocessor.rb +1 -0
  19. data/lib/docyard/components/processors/file_tree_processor.rb +1 -2
  20. data/lib/docyard/components/processors/icon_processor.rb +8 -2
  21. data/lib/docyard/components/processors/include_processor.rb +10 -10
  22. data/lib/docyard/components/processors/video_embed_processor.rb +14 -3
  23. data/lib/docyard/components/support/code_block/feature_extractor.rb +3 -1
  24. data/lib/docyard/components/support/code_block/icon_detector.rb +5 -12
  25. data/lib/docyard/components/support/code_block/line_number_resolver.rb +30 -0
  26. data/lib/docyard/components/support/code_detector.rb +2 -12
  27. data/lib/docyard/components/support/code_group/html_builder.rb +2 -6
  28. data/lib/docyard/components/support/tabs/icon_detector.rb +6 -2
  29. data/lib/docyard/components/support/tabs/parser.rb +6 -23
  30. data/lib/docyard/config/analytics_resolver.rb +24 -0
  31. data/lib/docyard/config/branding_resolver.rb +58 -27
  32. data/lib/docyard/config/key_validator.rb +30 -0
  33. data/lib/docyard/config/logo_detector.rb +8 -8
  34. data/lib/docyard/config/schema.rb +39 -0
  35. data/lib/docyard/config/section.rb +21 -0
  36. data/lib/docyard/config/validation_helpers.rb +83 -0
  37. data/lib/docyard/config/validator.rb +45 -144
  38. data/lib/docyard/config/validators/navigation.rb +43 -0
  39. data/lib/docyard/config/validators/section.rb +114 -0
  40. data/lib/docyard/config.rb +46 -102
  41. data/lib/docyard/constants.rb +59 -0
  42. data/lib/docyard/{utils/errors.rb → errors.rb} +6 -0
  43. data/lib/docyard/initializer.rb +100 -49
  44. data/lib/docyard/navigation/page_navigation_builder.rb +65 -0
  45. data/lib/docyard/navigation/sidebar/auto_builder.rb +107 -0
  46. data/lib/docyard/navigation/sidebar/cache.rb +96 -0
  47. data/lib/docyard/navigation/sidebar/config_builder.rb +179 -0
  48. data/lib/docyard/navigation/sidebar/distributed_builder.rb +145 -0
  49. data/lib/docyard/navigation/sidebar/local_config_loader.rb +69 -3
  50. data/lib/docyard/navigation/sidebar/renderer.rb +12 -1
  51. data/lib/docyard/navigation/sidebar_builder.rb +43 -81
  52. data/lib/docyard/rendering/branding_variables.rb +65 -0
  53. data/lib/docyard/rendering/icon_helpers.rb +14 -1
  54. data/lib/docyard/rendering/icons/devicons.rb +63 -0
  55. data/lib/docyard/rendering/icons.rb +26 -27
  56. data/lib/docyard/rendering/markdown.rb +5 -23
  57. data/lib/docyard/rendering/og_helpers.rb +36 -0
  58. data/lib/docyard/rendering/renderer.rb +87 -59
  59. data/lib/docyard/rendering/template_resolver.rb +14 -0
  60. data/lib/docyard/routing/fallback_resolver.rb +3 -3
  61. data/lib/docyard/search/build_indexer.rb +2 -2
  62. data/lib/docyard/search/dev_indexer.rb +36 -28
  63. data/lib/docyard/search/pagefind_support.rb +1 -1
  64. data/lib/docyard/server/asset_handler.rb +39 -15
  65. data/lib/docyard/server/dev_server.rb +90 -55
  66. data/lib/docyard/server/file_watcher.rb +68 -18
  67. data/lib/docyard/server/pagefind_handler.rb +1 -1
  68. data/lib/docyard/server/preview_server.rb +29 -33
  69. data/lib/docyard/server/rack_application.rb +38 -70
  70. data/lib/docyard/server/router.rb +11 -7
  71. data/lib/docyard/server/sse_server.rb +157 -0
  72. data/lib/docyard/server/static_file_app.rb +42 -0
  73. data/lib/docyard/templates/assets/css/components/banner.css +31 -0
  74. data/lib/docyard/templates/assets/css/components/breadcrumbs.css +2 -1
  75. data/lib/docyard/templates/assets/css/components/callout.css +26 -6
  76. data/lib/docyard/templates/assets/css/components/code-block.css +4 -2
  77. data/lib/docyard/templates/assets/css/components/code-group.css +20 -7
  78. data/lib/docyard/templates/assets/css/components/feedback.css +126 -0
  79. data/lib/docyard/templates/assets/css/components/file-tree.css +5 -4
  80. data/lib/docyard/templates/assets/css/components/icon.css +5 -0
  81. data/lib/docyard/templates/assets/css/components/nav-menu.css +20 -4
  82. data/lib/docyard/templates/assets/css/components/navigation.css +25 -3
  83. data/lib/docyard/templates/assets/css/components/page-actions.css +131 -0
  84. data/lib/docyard/templates/assets/css/components/prev-next.css +14 -7
  85. data/lib/docyard/templates/assets/css/components/search.css +6 -10
  86. data/lib/docyard/templates/assets/css/components/tab-bar.css +7 -4
  87. data/lib/docyard/templates/assets/css/components/table-of-contents.css +57 -11
  88. data/lib/docyard/templates/assets/css/components/tabs.css +12 -4
  89. data/lib/docyard/templates/assets/css/components/theme-toggle.css +3 -1
  90. data/lib/docyard/templates/assets/css/landing.css +82 -13
  91. data/lib/docyard/templates/assets/css/layout.css +17 -0
  92. data/lib/docyard/templates/assets/css/markdown.css +22 -2
  93. data/lib/docyard/templates/assets/css/variables.css +13 -1
  94. data/lib/docyard/templates/assets/js/components/code-group.js +4 -1
  95. data/lib/docyard/templates/assets/js/components/copy-page.js +115 -0
  96. data/lib/docyard/templates/assets/js/components/feedback.js +66 -0
  97. data/lib/docyard/templates/assets/js/components/file-tree.js +5 -5
  98. data/lib/docyard/templates/assets/js/components/navigation.js +3 -3
  99. data/lib/docyard/templates/assets/js/components/search.js +3 -3
  100. data/lib/docyard/templates/assets/js/components/table-of-contents.js +12 -6
  101. data/lib/docyard/templates/assets/js/components/tabs.js +45 -22
  102. data/lib/docyard/templates/assets/js/components/tooltip.js +4 -4
  103. data/lib/docyard/templates/assets/js/hot-reload.js +44 -0
  104. data/lib/docyard/templates/errors/404.html.erb +114 -5
  105. data/lib/docyard/templates/errors/500.html.erb +173 -10
  106. data/lib/docyard/templates/init/_sidebar.yml +36 -0
  107. data/lib/docyard/templates/init/docyard.yml +36 -0
  108. data/lib/docyard/templates/init/pages/components.md +146 -0
  109. data/lib/docyard/templates/init/pages/getting-started.md +94 -0
  110. data/lib/docyard/templates/init/pages/index.md +22 -0
  111. data/lib/docyard/templates/layouts/default.html.erb +10 -0
  112. data/lib/docyard/templates/layouts/splash.html.erb +14 -1
  113. data/lib/docyard/templates/partials/_analytics.html.erb +24 -0
  114. data/lib/docyard/templates/partials/_banner.html.erb +1 -1
  115. data/lib/docyard/templates/partials/_code_block.html.erb +1 -1
  116. data/lib/docyard/templates/partials/_feedback.html.erb +14 -0
  117. data/lib/docyard/templates/partials/_footer.html.erb +1 -1
  118. data/lib/docyard/templates/partials/_head.html.erb +79 -4
  119. data/lib/docyard/templates/partials/_icon_library.html.erb +8 -0
  120. data/lib/docyard/templates/partials/_page_actions.html.erb +21 -0
  121. data/lib/docyard/templates/partials/_scripts.html.erb +6 -3
  122. data/lib/docyard/templates/partials/_tabs.html.erb +4 -1
  123. data/lib/docyard/utils/git_info.rb +157 -0
  124. data/lib/docyard/utils/hash_utils.rb +31 -0
  125. data/lib/docyard/utils/html_helpers.rb +8 -0
  126. data/lib/docyard/utils/logging.rb +44 -3
  127. data/lib/docyard/utils/path_resolver.rb +0 -10
  128. data/lib/docyard/utils/path_utils.rb +73 -0
  129. data/lib/docyard/version.rb +1 -1
  130. data/lib/docyard.rb +2 -2
  131. metadata +77 -47
  132. data/.github/ISSUE_TEMPLATE/bug_report.md +0 -31
  133. data/.github/ISSUE_TEMPLATE/feature_request.md +0 -19
  134. data/.github/pull_request_template.md +0 -14
  135. data/.github/workflows/ci.yml +0 -49
  136. data/.rubocop.yml +0 -42
  137. data/CODE_OF_CONDUCT.md +0 -132
  138. data/CONTRIBUTING.md +0 -55
  139. data/LICENSE.vscode-icons +0 -42
  140. data/Rakefile +0 -8
  141. data/lib/docyard/config/constants.rb +0 -31
  142. data/lib/docyard/navigation/sidebar/children_discoverer.rb +0 -51
  143. data/lib/docyard/navigation/sidebar/config_parser.rb +0 -208
  144. data/lib/docyard/navigation/sidebar/file_resolver.rb +0 -90
  145. data/lib/docyard/navigation/sidebar/file_system_scanner.rb +0 -78
  146. data/lib/docyard/navigation/sidebar/metadata_extractor.rb +0 -71
  147. data/lib/docyard/navigation/sidebar/metadata_reader.rb +0 -51
  148. data/lib/docyard/navigation/sidebar/path_prefixer.rb +0 -34
  149. data/lib/docyard/navigation/sidebar/sorter.rb +0 -21
  150. data/lib/docyard/navigation/sidebar/title_extractor.rb +0 -25
  151. data/lib/docyard/navigation/sidebar/tree_builder.rb +0 -140
  152. data/lib/docyard/rendering/icons/LICENSE.phosphor +0 -21
  153. data/lib/docyard/rendering/icons/file_types.rb +0 -79
  154. data/lib/docyard/rendering/icons/phosphor.rb +0 -93
  155. data/lib/docyard/rendering/language_mapping.rb +0 -52
  156. data/lib/docyard/templates/assets/js/reload.js +0 -98
  157. data/lib/docyard/templates/partials/_icon.html.erb +0 -1
  158. data/lib/docyard/templates/partials/_icon_file_extension.html.erb +0 -1
  159. data/sig/docyard.rbs +0 -4
@@ -0,0 +1,179 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "item"
4
+
5
+ module Docyard
6
+ module Sidebar
7
+ class ConfigBuilder
8
+ attr_reader :config_items, :current_path, :start_depth
9
+
10
+ def initialize(config_items, current_path: "/", start_depth: 1)
11
+ @config_items = config_items || []
12
+ @current_path = Utils::PathResolver.normalize(current_path)
13
+ @start_depth = start_depth
14
+ end
15
+
16
+ def build
17
+ parse_items(config_items, base_path: "", depth: start_depth).map(&:to_h)
18
+ end
19
+
20
+ private
21
+
22
+ def parse_items(items, base_path:, depth:)
23
+ items.map { |item| parse_item(item, base_path: base_path, depth: depth) }.compact
24
+ end
25
+
26
+ def parse_item(item_config, base_path:, depth:)
27
+ case item_config
28
+ when String
29
+ build_page_item(item_config, base_path: base_path)
30
+ when Hash
31
+ parse_hash_item(item_config, base_path: base_path, depth: depth)
32
+ end
33
+ end
34
+
35
+ def parse_hash_item(config, base_path:, depth:)
36
+ return build_external_link(config) if external_link?(config)
37
+
38
+ slug, options = extract_slug_and_options(config)
39
+ return build_page_item(slug, base_path: base_path, options: options) if leaf_item?(options)
40
+
41
+ build_group_item(slug, options: options, base_path: base_path, depth: depth)
42
+ end
43
+
44
+ def external_link?(config)
45
+ config.key?("link") || config.key?(:link)
46
+ end
47
+
48
+ def extract_slug_and_options(config)
49
+ if config.keys.first.is_a?(String) && !external_link?(config)
50
+ [config.keys.first.to_s, config.values.first || {}]
51
+ else
52
+ [nil, config]
53
+ end
54
+ end
55
+
56
+ def leaf_item?(options)
57
+ return true if options.nil?
58
+ return false if options.is_a?(Hash) && (options.key?("items") || options.key?(:items))
59
+
60
+ true
61
+ end
62
+
63
+ def build_page_item(slug, base_path:, options: {})
64
+ options = normalize_options(options)
65
+ url_path = build_url_path(base_path, slug)
66
+
67
+ Item.new(
68
+ slug: slug,
69
+ text: options[:text] || titleize_slug(slug),
70
+ path: url_path,
71
+ icon: options[:icon],
72
+ badge: options[:badge],
73
+ badge_type: options[:badge_type],
74
+ active: current_path == url_path,
75
+ type: :file,
76
+ section: false,
77
+ items: []
78
+ )
79
+ end
80
+
81
+ def build_group_item(slug, options:, base_path:, depth:)
82
+ options = normalize_options(options)
83
+ context = build_group_context(slug, options, base_path, depth)
84
+ children = parse_items(options[:items] || [], base_path: context[:new_base_path], depth: depth + 1)
85
+
86
+ create_group_item(slug, options, context, children)
87
+ end
88
+
89
+ def build_group_context(slug, options, base_path, depth)
90
+ is_section = section_at_depth?(options, depth)
91
+ is_virtual_group = options[:group] == true
92
+ has_index = options[:index] == true
93
+ new_base_path = compute_new_base_path(slug, base_path, is_virtual_group)
94
+ url_path = has_index && !is_section ? build_url_path(base_path, slug) : nil
95
+
96
+ { is_section: is_section, has_index: has_index, new_base_path: new_base_path, url_path: url_path }
97
+ end
98
+
99
+ def compute_new_base_path(slug, base_path, is_virtual_group)
100
+ return base_path if is_virtual_group
101
+ return base_path unless slug
102
+
103
+ File.join(base_path, slug)
104
+ end
105
+
106
+ def create_group_item(slug, options, context, children)
107
+ Item.new(
108
+ slug: slug,
109
+ text: options[:text] || titleize_slug(slug),
110
+ path: context[:url_path],
111
+ icon: options[:icon],
112
+ badge: options[:badge],
113
+ badge_type: options[:badge_type],
114
+ active: context[:has_index] && current_path == context[:url_path],
115
+ type: :directory,
116
+ section: context[:is_section],
117
+ collapsed: determine_collapsed_state(context[:is_section], options, children),
118
+ has_index: context[:has_index],
119
+ items: children
120
+ )
121
+ end
122
+
123
+ def build_external_link(config)
124
+ config = normalize_options(config)
125
+ url = config[:link]
126
+
127
+ Item.new(
128
+ slug: nil,
129
+ text: config[:text],
130
+ path: url,
131
+ link: url,
132
+ icon: config[:icon],
133
+ target: config[:target] || "_blank",
134
+ type: :external,
135
+ section: false,
136
+ items: []
137
+ )
138
+ end
139
+
140
+ def section_at_depth?(options, depth)
141
+ return false if options[:collapsible] == true
142
+ return false if options[:group] == true
143
+
144
+ depth == 1
145
+ end
146
+
147
+ def determine_collapsed_state(is_section, options, children)
148
+ return false if is_section
149
+ return false if child_active?(children)
150
+ return options[:collapsed] if options.key?(:collapsed)
151
+
152
+ true
153
+ end
154
+
155
+ def child_active?(children)
156
+ children.any? { |child| child.active || child_active?(child.items) }
157
+ end
158
+
159
+ def build_url_path(base_path, slug)
160
+ base_path = base_path.to_s.sub(%r{^/+}, "")
161
+ return base_path.empty? ? "/" : "/#{base_path}" if slug == "index"
162
+
163
+ path = [base_path, slug].reject(&:empty?).join("/")
164
+ "/#{path}"
165
+ end
166
+
167
+ def titleize_slug(slug)
168
+ Utils::TextFormatter.titleize(slug.to_s)
169
+ end
170
+
171
+ def normalize_options(options)
172
+ return {} if options.nil?
173
+ return options if options.is_a?(Hash) && options.keys.all? { |k| k.is_a?(Symbol) }
174
+
175
+ options.transform_keys(&:to_sym)
176
+ end
177
+ end
178
+ end
179
+ end
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "item"
4
+ require_relative "local_config_loader"
5
+ require_relative "config_builder"
6
+
7
+ module Docyard
8
+ module Sidebar
9
+ class DistributedBuilder
10
+ attr_reader :docs_path, :current_path
11
+
12
+ def initialize(docs_path, current_path: "/")
13
+ @docs_path = docs_path
14
+ @current_path = Utils::PathResolver.normalize(current_path)
15
+ end
16
+
17
+ def build
18
+ root_config = load_root_config
19
+ return [] if root_config.empty?
20
+
21
+ root_config.map { |section_slug| build_section(section_slug) }
22
+ end
23
+
24
+ private
25
+
26
+ def load_root_config
27
+ loader = LocalConfigLoader.new(docs_path)
28
+ config = loader.load
29
+ raise_missing_root_config unless config
30
+
31
+ normalize_root_config(config)
32
+ end
33
+
34
+ def normalize_root_config(config)
35
+ config.map do |item|
36
+ case item
37
+ when String then item
38
+ when Hash then item.keys.first.to_s
39
+ end
40
+ end.compact
41
+ end
42
+
43
+ def build_section(section_slug)
44
+ section_path = File.join(docs_path, section_slug)
45
+ section_config = load_section_config(section_slug, section_path)
46
+
47
+ build_section_item(section_slug, section_config)
48
+ end
49
+
50
+ def load_section_config(section_slug, section_path)
51
+ loader = LocalConfigLoader.new(section_path)
52
+ raise_missing_section_config(section_slug) unless loader.config_file_exists?
53
+
54
+ raw_config = YAML.load_file(File.join(section_path, "_sidebar.yml"))
55
+ normalize_section_config(raw_config)
56
+ end
57
+
58
+ def normalize_section_config(config)
59
+ return { items: config } if config.is_a?(Array)
60
+
61
+ config.transform_keys(&:to_sym)
62
+ end
63
+
64
+ def build_section_item(section_slug, section_config)
65
+ items = section_config[:items] || []
66
+ children = build_section_children(items, section_slug)
67
+
68
+ Item.new(
69
+ slug: section_slug,
70
+ text: section_config[:text] || Utils::TextFormatter.titleize(section_slug),
71
+ path: nil,
72
+ icon: section_config[:icon],
73
+ type: :directory,
74
+ section: true,
75
+ collapsed: false,
76
+ has_index: false,
77
+ active: false,
78
+ items: children
79
+ ).to_h
80
+ end
81
+
82
+ def build_section_children(items, section_slug)
83
+ adjusted_current_path = adjust_current_path_for_section(section_slug)
84
+
85
+ builder = ConfigBuilder.new(items, current_path: adjusted_current_path, start_depth: 2)
86
+ tree = builder.build
87
+
88
+ prefix_paths(tree, section_slug)
89
+ end
90
+
91
+ def adjust_current_path_for_section(section_slug)
92
+ prefix = "/#{section_slug}"
93
+ return current_path unless current_path.start_with?(prefix)
94
+
95
+ relative = current_path.sub(prefix, "")
96
+ relative.empty? ? "/" : relative
97
+ end
98
+
99
+ def prefix_paths(items, prefix)
100
+ items.map do |item|
101
+ prefixed = prefix_item_path(item, prefix)
102
+ prefixed[:children] = prefix_paths(item[:children] || [], prefix) if item[:children]&.any?
103
+ prefixed
104
+ end
105
+ end
106
+
107
+ def prefix_item_path(item, prefix)
108
+ item = item.dup
109
+ return item if item[:path].nil? || external_path?(item[:path])
110
+
111
+ prefixed_path = "/#{prefix}#{item[:path]}"
112
+ item[:path] = prefixed_path.chomp("/")
113
+ item[:path] = "/" if item[:path].empty?
114
+ item[:active] = current_path == item[:path]
115
+ item
116
+ end
117
+
118
+ def external_path?(path)
119
+ path.start_with?("http://", "https://")
120
+ end
121
+
122
+ def raise_missing_root_config
123
+ raise SidebarConfigError, <<~MSG.strip
124
+ Distributed sidebar mode requires docs/_sidebar.yml
125
+
126
+ Either:
127
+ 1. Create docs/_sidebar.yml listing your sections
128
+ 2. Change to 'sidebar: config' in docyard.yml
129
+ MSG
130
+ end
131
+
132
+ def raise_missing_section_config(section_slug)
133
+ raise SidebarConfigError, <<~MSG.strip
134
+ Missing sidebar config for section '#{section_slug}'
135
+
136
+ Expected: docs/#{section_slug}/_sidebar.yml
137
+
138
+ Either:
139
+ 1. Create docs/#{section_slug}/_sidebar.yml
140
+ 2. Remove '#{section_slug}' from docs/_sidebar.yml
141
+ MSG
142
+ end
143
+ end
144
+ end
145
+ end
@@ -11,6 +11,7 @@ module Docyard
11
11
 
12
12
  def initialize(docs_path)
13
13
  @docs_path = docs_path
14
+ @key_errors = []
14
15
  end
15
16
 
16
17
  def load
@@ -31,12 +32,17 @@ module Docyard
31
32
 
32
33
  def parse_config_file
33
34
  content = YAML.load_file(config_file_path)
34
- normalize_config(content)
35
+ items = normalize_config(content)
36
+ validate_items(items) if items
37
+ report_key_errors
38
+ items
35
39
  rescue Psych::SyntaxError => e
36
- warn "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)
@@ -1,27 +1,27 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "sidebar/file_system_scanner"
4
- require_relative "sidebar/title_extractor"
5
- require_relative "sidebar/tree_builder"
3
+ require_relative "sidebar/cache"
4
+ require_relative "sidebar/config_builder"
5
+ require_relative "sidebar/auto_builder"
6
+ require_relative "sidebar/distributed_builder"
6
7
  require_relative "sidebar/renderer"
7
- require_relative "sidebar/config_parser"
8
- require_relative "sidebar/local_config_loader"
9
- require_relative "sidebar/path_prefixer"
10
8
  require_relative "sidebar/tree_filter"
9
+ require_relative "sidebar/local_config_loader"
11
10
 
12
11
  module Docyard
13
12
  class SidebarBuilder
14
- attr_reader :docs_path, :current_path, :config, :header_ctas
13
+ attr_reader :docs_path, :current_path, :config, :header_ctas, :sidebar_cache
15
14
 
16
- def initialize(docs_path:, current_path: "/", config: nil, header_ctas: [])
15
+ def initialize(docs_path:, current_path: "/", config: nil, header_ctas: [], sidebar_cache: nil)
17
16
  @docs_path = docs_path
18
17
  @current_path = current_path
19
18
  @config = config
20
19
  @header_ctas = header_ctas
20
+ @sidebar_cache = sidebar_cache
21
21
  end
22
22
 
23
23
  def tree
24
- @tree ||= build_scoped_tree
24
+ @tree ||= build_tree
25
25
  end
26
26
 
27
27
  def to_html
@@ -30,101 +30,63 @@ module Docyard
30
30
 
31
31
  private
32
32
 
33
- def build_scoped_tree
34
- active_tab = find_active_tab
35
- return build_tree_for_path(docs_path) unless active_tab
36
-
37
- build_tree_for_tab(active_tab)
38
- end
39
-
40
- def build_tree_for_tab(tab)
41
- tab_path = tab["href"]&.chomp("/")
42
- return build_tree_for_path(docs_path) if empty_tab_path?(tab_path)
33
+ def build_tree
34
+ return build_from_cache if sidebar_cache&.valid?
43
35
 
44
- scoped_docs_path = resolve_scoped_path(tab_path)
45
- build_scoped_or_filtered_tree(scoped_docs_path, tab_path)
36
+ build_without_cache
46
37
  end
47
38
 
48
- def empty_tab_path?(tab_path)
49
- tab_path.nil? || tab_path.empty? || tab_path == "/"
39
+ def build_from_cache
40
+ base_tree = sidebar_cache.get(current_path: current_path)
41
+ apply_tab_scoping(base_tree)
50
42
  end
51
43
 
52
- def resolve_scoped_path(tab_path)
53
- tab_folder = tab_path.sub(%r{^/}, "")
54
- File.join(docs_path, tab_folder)
44
+ def build_without_cache
45
+ base_tree = build_tree_for_mode
46
+ apply_tab_scoping(base_tree)
55
47
  end
56
48
 
57
- def build_scoped_or_filtered_tree(scoped_docs_path, tab_path)
58
- if scoped_sidebar_available?(scoped_docs_path)
59
- build_tree_for_path(scoped_docs_path, base_url_prefix: tab_path)
49
+ def build_tree_for_mode
50
+ case sidebar_mode
51
+ when "auto"
52
+ Sidebar::AutoBuilder.new(docs_path, current_path: current_path).build
53
+ when "distributed"
54
+ Sidebar::DistributedBuilder.new(docs_path, current_path: current_path).build
60
55
  else
61
- Sidebar::TreeFilter.new(build_tree_for_path(docs_path), tab_path).filter
56
+ build_config_tree
62
57
  end
63
58
  end
64
59
 
65
- def scoped_sidebar_available?(path)
66
- File.directory?(path) && Sidebar::LocalConfigLoader.new(path).config_file_exists?
67
- end
60
+ def build_config_tree
61
+ config_items = Sidebar::LocalConfigLoader.new(docs_path).load
62
+ return [] unless config_items
68
63
 
69
- def build_tree_for_path(path, base_url_prefix: "")
70
- config_items = Sidebar::LocalConfigLoader.new(path).load
71
- tree = build_tree(config_items, path, base_url_prefix)
72
- maybe_prepend_overview(tree, path, base_url_prefix)
64
+ Sidebar::ConfigBuilder.new(config_items, current_path: current_path).build
73
65
  end
74
66
 
75
- def build_tree(config_items, path, base_url_prefix)
76
- if config_items&.any?
77
- build_tree_from_config(config_items, path, base_url_prefix)
78
- else
79
- build_tree_from_filesystem(path, base_url_prefix)
80
- end
67
+ def sidebar_mode
68
+ config&.sidebar || "config"
81
69
  end
82
70
 
83
- def maybe_prepend_overview(tree, path, base_url_prefix)
84
- return tree if skip_overview?(tree, path, base_url_prefix)
71
+ def apply_tab_scoping(base_tree)
72
+ return base_tree if sidebar_mode == "auto"
73
+ return base_tree unless tabs_configured?
85
74
 
86
- [build_overview_item(base_url_prefix)] + tree
87
- end
88
-
89
- def skip_overview?(tree, path, base_url_prefix)
90
- base_url_prefix.empty? ||
91
- tree.first&.dig(:section) ||
92
- !File.file?(File.join(path, "index.md")) ||
93
- tree.any? { |item| item[:path] == base_url_prefix }
94
- end
95
-
96
- def build_overview_item(base_url_prefix)
97
- {
98
- title: "Overview", path: base_url_prefix, icon: nil,
99
- active: current_path == base_url_prefix, type: :file,
100
- collapsed: false, collapsible: false, target: "_self",
101
- has_index: false, section: false, children: []
102
- }
103
- end
104
-
105
- def build_tree_from_config(items, path, base_url_prefix)
106
- tree = Sidebar::ConfigParser.new(
107
- items, docs_path: path, current_path: current_path_relative_to(base_url_prefix)
108
- ).parse.map(&:to_h)
75
+ active_tab = find_active_tab
76
+ return base_tree unless active_tab
109
77
 
110
- Sidebar::PathPrefixer.new(tree, base_url_prefix).prefix
78
+ filter_tree_for_tab(base_tree, active_tab)
111
79
  end
112
80
 
113
- def build_tree_from_filesystem(path, base_url_prefix)
114
- file_items = Sidebar::FileSystemScanner.new(path).scan
115
- tree = Sidebar::TreeBuilder.new(
116
- docs_path: path, current_path: current_path_relative_to(base_url_prefix)
117
- ).build(file_items)
81
+ def filter_tree_for_tab(base_tree, tab)
82
+ tab_path = tab["href"]&.chomp("/")
83
+ return base_tree if empty_tab_path?(tab_path)
118
84
 
119
- Sidebar::PathPrefixer.new(tree, base_url_prefix).prefix
85
+ Sidebar::TreeFilter.new(base_tree, tab_path).filter
120
86
  end
121
87
 
122
- def current_path_relative_to(prefix)
123
- return current_path if prefix.empty?
124
- return current_path unless current_path.start_with?(prefix)
125
-
126
- relative = current_path.sub(prefix, "")
127
- relative.empty? ? "/" : relative
88
+ def empty_tab_path?(tab_path)
89
+ tab_path.nil? || tab_path.empty? || tab_path == "/"
128
90
  end
129
91
 
130
92
  def renderer
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docyard
4
+ module BrandingVariables
5
+ private
6
+
7
+ def assign_branding_variables(branding, current_path = "/")
8
+ assign_site_branding(branding)
9
+ assign_search_options(branding)
10
+ assign_credits_and_social(branding)
11
+ assign_tabs(branding, current_path)
12
+ assign_analytics(branding)
13
+ end
14
+
15
+ def assign_site_branding(branding)
16
+ @site_title = branding[:site_title] || Constants::DEFAULT_SITE_TITLE
17
+ @site_description = branding[:site_description] || ""
18
+ @logo = branding[:logo] || Constants::DEFAULT_LOGO_PATH
19
+ @logo_dark = branding[:logo_dark]
20
+ @favicon = branding[:favicon] || Constants::DEFAULT_FAVICON_PATH
21
+ @has_custom_logo = branding[:has_custom_logo] || false
22
+ @primary_color = branding[:primary_color]
23
+ end
24
+
25
+ def assign_search_options(branding)
26
+ @search_enabled = branding[:search_enabled].nil? || branding[:search_enabled]
27
+ @search_placeholder = branding[:search_placeholder] || "Search documentation..."
28
+ end
29
+
30
+ def assign_credits_and_social(branding)
31
+ @credits = branding[:credits] != false
32
+ @copyright = branding[:copyright]
33
+ @social = branding[:social] || []
34
+ @header_ctas = branding[:header_ctas] || []
35
+ @announcement = branding[:announcement]
36
+ end
37
+
38
+ def assign_analytics(branding)
39
+ @has_analytics = branding[:has_analytics] || false
40
+ @analytics_google = branding[:analytics_google]
41
+ @analytics_plausible = branding[:analytics_plausible]
42
+ @analytics_fathom = branding[:analytics_fathom]
43
+ @analytics_script = branding[:analytics_script]
44
+ end
45
+
46
+ def assign_tabs(branding, current_path)
47
+ tabs = branding[:tabs] || []
48
+ @tabs = tabs.map { |tab| tab.merge(active: tab_active?(tab[:href], current_path)) }
49
+ @has_tabs = branding[:has_tabs] || false
50
+ @current_path = current_path
51
+ end
52
+
53
+ def tab_active?(tab_href, current_path)
54
+ return false if tab_href.nil? || current_path.nil?
55
+ return false if tab_href.start_with?("http://", "https://")
56
+
57
+ normalized_tab = tab_href.chomp("/")
58
+ normalized_current = current_path.chomp("/")
59
+
60
+ return true if normalized_tab == normalized_current
61
+
62
+ current_path.start_with?("#{normalized_tab}/")
63
+ end
64
+ end
65
+ end