docyard 0.7.0 → 0.9.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 (155) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +5 -1
  3. data/CHANGELOG.md +43 -1
  4. data/lib/docyard/build/asset_bundler.rb +22 -7
  5. data/lib/docyard/build/file_copier.rb +49 -27
  6. data/lib/docyard/build/sitemap_generator.rb +6 -6
  7. data/lib/docyard/build/static_generator.rb +85 -12
  8. data/lib/docyard/builder.rb +6 -6
  9. data/lib/docyard/components/aliases.rb +12 -0
  10. data/lib/docyard/components/processors/abbreviation_processor.rb +72 -0
  11. data/lib/docyard/components/processors/accordion_processor.rb +81 -0
  12. data/lib/docyard/components/processors/badge_processor.rb +72 -0
  13. data/lib/docyard/components/processors/callout_processor.rb +8 -2
  14. data/lib/docyard/components/processors/cards_processor.rb +100 -0
  15. data/lib/docyard/components/processors/code_block_options_preprocessor.rb +23 -2
  16. data/lib/docyard/components/processors/code_block_processor.rb +6 -0
  17. data/lib/docyard/components/processors/code_group_processor.rb +198 -0
  18. data/lib/docyard/components/processors/code_snippet_import_preprocessor.rb +6 -1
  19. data/lib/docyard/components/processors/custom_anchor_processor.rb +42 -0
  20. data/lib/docyard/components/processors/file_tree_processor.rb +151 -0
  21. data/lib/docyard/components/processors/image_caption_processor.rb +96 -0
  22. data/lib/docyard/components/processors/include_processor.rb +86 -0
  23. data/lib/docyard/components/processors/steps_processor.rb +89 -0
  24. data/lib/docyard/components/processors/tabs_processor.rb +9 -1
  25. data/lib/docyard/components/processors/tooltip_processor.rb +57 -0
  26. data/lib/docyard/components/processors/video_embed_processor.rb +196 -0
  27. data/lib/docyard/components/support/code_group/html_builder.rb +122 -0
  28. data/lib/docyard/components/support/markdown_code_block_helper.rb +56 -0
  29. data/lib/docyard/config/branding_resolver.rb +121 -17
  30. data/lib/docyard/config/constants.rb +6 -4
  31. data/lib/docyard/config/logo_detector.rb +39 -0
  32. data/lib/docyard/config/validator.rb +122 -99
  33. data/lib/docyard/config.rb +40 -42
  34. data/lib/docyard/initializer.rb +15 -76
  35. data/lib/docyard/navigation/breadcrumb_builder.rb +133 -0
  36. data/lib/docyard/navigation/prev_next_builder.rb +4 -1
  37. data/lib/docyard/navigation/sidebar/children_discoverer.rb +51 -0
  38. data/lib/docyard/navigation/sidebar/config_parser.rb +136 -108
  39. data/lib/docyard/navigation/sidebar/file_resolver.rb +90 -0
  40. data/lib/docyard/navigation/sidebar/file_system_scanner.rb +2 -1
  41. data/lib/docyard/navigation/sidebar/item.rb +50 -7
  42. data/lib/docyard/navigation/sidebar/local_config_loader.rb +51 -0
  43. data/lib/docyard/navigation/sidebar/metadata_extractor.rb +71 -0
  44. data/lib/docyard/navigation/sidebar/metadata_reader.rb +51 -0
  45. data/lib/docyard/navigation/sidebar/path_prefixer.rb +34 -0
  46. data/lib/docyard/navigation/sidebar/renderer.rb +60 -38
  47. data/lib/docyard/navigation/sidebar/sorter.rb +21 -0
  48. data/lib/docyard/navigation/sidebar/tree_builder.rb +100 -26
  49. data/lib/docyard/navigation/sidebar/tree_filter.rb +55 -0
  50. data/lib/docyard/navigation/sidebar_builder.rb +105 -36
  51. data/lib/docyard/rendering/icon_helpers.rb +13 -0
  52. data/lib/docyard/rendering/icons/phosphor.rb +26 -1
  53. data/lib/docyard/rendering/markdown.rb +29 -1
  54. data/lib/docyard/rendering/renderer.rb +75 -34
  55. data/lib/docyard/rendering/template_resolver.rb +172 -0
  56. data/lib/docyard/routing/fallback_resolver.rb +92 -0
  57. data/lib/docyard/search/build_indexer.rb +1 -1
  58. data/lib/docyard/search/dev_indexer.rb +51 -6
  59. data/lib/docyard/search/pagefind_support.rb +2 -0
  60. data/lib/docyard/server/asset_handler.rb +25 -19
  61. data/lib/docyard/server/pagefind_handler.rb +63 -0
  62. data/lib/docyard/server/preview_server.rb +1 -1
  63. data/lib/docyard/server/rack_application.rb +81 -64
  64. data/lib/docyard/templates/assets/css/code.css +18 -51
  65. data/lib/docyard/templates/assets/css/components/abbreviation.css +86 -0
  66. data/lib/docyard/templates/assets/css/components/accordion.css +138 -0
  67. data/lib/docyard/templates/assets/css/components/badges.css +47 -0
  68. data/lib/docyard/templates/assets/css/components/banner.css +202 -0
  69. data/lib/docyard/templates/assets/css/components/breadcrumbs.css +143 -0
  70. data/lib/docyard/templates/assets/css/components/callout.css +67 -67
  71. data/lib/docyard/templates/assets/css/components/cards.css +100 -0
  72. data/lib/docyard/templates/assets/css/components/code-block.css +190 -282
  73. data/lib/docyard/templates/assets/css/components/code-group.css +281 -0
  74. data/lib/docyard/templates/assets/css/components/figure.css +22 -0
  75. data/lib/docyard/templates/assets/css/components/file-tree.css +124 -0
  76. data/lib/docyard/templates/assets/css/components/heading-anchor.css +36 -15
  77. data/lib/docyard/templates/assets/css/components/icon.css +0 -1
  78. data/lib/docyard/templates/assets/css/components/lightbox.css +65 -0
  79. data/lib/docyard/templates/assets/css/components/logo.css +0 -2
  80. data/lib/docyard/templates/assets/css/components/nav-menu.css +237 -0
  81. data/lib/docyard/templates/assets/css/components/navigation.css +193 -167
  82. data/lib/docyard/templates/assets/css/components/prev-next.css +68 -48
  83. data/lib/docyard/templates/assets/css/components/search.css +186 -174
  84. data/lib/docyard/templates/assets/css/components/steps.css +122 -0
  85. data/lib/docyard/templates/assets/css/components/tab-bar.css +163 -0
  86. data/lib/docyard/templates/assets/css/components/table-of-contents.css +127 -114
  87. data/lib/docyard/templates/assets/css/components/tabs.css +119 -160
  88. data/lib/docyard/templates/assets/css/components/theme-toggle.css +48 -44
  89. data/lib/docyard/templates/assets/css/components/tooltip.css +113 -0
  90. data/lib/docyard/templates/assets/css/components/video.css +41 -0
  91. data/lib/docyard/templates/assets/css/landing.css +815 -0
  92. data/lib/docyard/templates/assets/css/layout.css +489 -87
  93. data/lib/docyard/templates/assets/css/main.css +1 -3
  94. data/lib/docyard/templates/assets/css/markdown.css +113 -93
  95. data/lib/docyard/templates/assets/css/reset.css +0 -3
  96. data/lib/docyard/templates/assets/css/typography.css +43 -41
  97. data/lib/docyard/templates/assets/css/variables.css +268 -208
  98. data/lib/docyard/templates/assets/favicon.svg +7 -8
  99. data/lib/docyard/templates/assets/fonts/Inter-Variable.ttf +0 -0
  100. data/lib/docyard/templates/assets/js/components/abbreviation.js +85 -0
  101. data/lib/docyard/templates/assets/js/components/banner.js +81 -0
  102. data/lib/docyard/templates/assets/js/components/code-block.js +24 -42
  103. data/lib/docyard/templates/assets/js/components/code-group.js +283 -0
  104. data/lib/docyard/templates/assets/js/components/file-tree.js +39 -0
  105. data/lib/docyard/templates/assets/js/components/heading-anchor.js +26 -24
  106. data/lib/docyard/templates/assets/js/components/lightbox.js +72 -0
  107. data/lib/docyard/templates/assets/js/components/navigation.js +181 -70
  108. data/lib/docyard/templates/assets/js/components/search.js +0 -75
  109. data/lib/docyard/templates/assets/js/components/sidebar-toggle.js +29 -0
  110. data/lib/docyard/templates/assets/js/components/tab-navigation.js +145 -0
  111. data/lib/docyard/templates/assets/js/components/table-of-contents.js +153 -66
  112. data/lib/docyard/templates/assets/js/components/tabs.js +31 -69
  113. data/lib/docyard/templates/assets/js/components/tooltip.js +118 -0
  114. data/lib/docyard/templates/assets/js/theme.js +0 -3
  115. data/lib/docyard/templates/assets/logo-dark.svg +8 -2
  116. data/lib/docyard/templates/assets/logo.svg +7 -4
  117. data/lib/docyard/templates/config/docyard.yml.erb +37 -34
  118. data/lib/docyard/templates/errors/404.html.erb +1 -1
  119. data/lib/docyard/templates/errors/500.html.erb +1 -1
  120. data/lib/docyard/templates/layouts/default.html.erb +19 -67
  121. data/lib/docyard/templates/layouts/splash.html.erb +177 -0
  122. data/lib/docyard/templates/partials/_accordion.html.erb +9 -0
  123. data/lib/docyard/templates/partials/_banner.html.erb +27 -0
  124. data/lib/docyard/templates/partials/_breadcrumbs.html.erb +24 -0
  125. data/lib/docyard/templates/partials/_card.html.erb +23 -0
  126. data/lib/docyard/templates/partials/_code_block.html.erb +5 -3
  127. data/lib/docyard/templates/partials/_doc_footer.html.erb +25 -0
  128. data/lib/docyard/templates/partials/_features.html.erb +15 -0
  129. data/lib/docyard/templates/partials/_footer.html.erb +42 -0
  130. data/lib/docyard/templates/partials/_head.html.erb +22 -0
  131. data/lib/docyard/templates/partials/_header.html.erb +49 -0
  132. data/lib/docyard/templates/partials/_heading_anchor.html.erb +3 -1
  133. data/lib/docyard/templates/partials/_hero.html.erb +27 -0
  134. data/lib/docyard/templates/partials/_nav_group.html.erb +31 -11
  135. data/lib/docyard/templates/partials/_nav_leaf.html.erb +4 -1
  136. data/lib/docyard/templates/partials/_nav_menu.html.erb +42 -0
  137. data/lib/docyard/templates/partials/_nav_nested_section.html.erb +11 -0
  138. data/lib/docyard/templates/partials/_nav_section.html.erb +1 -1
  139. data/lib/docyard/templates/partials/_prev_next.html.erb +8 -2
  140. data/lib/docyard/templates/partials/_scripts.html.erb +7 -0
  141. data/lib/docyard/templates/partials/_search_modal.html.erb +2 -6
  142. data/lib/docyard/templates/partials/_search_trigger.html.erb +2 -6
  143. data/lib/docyard/templates/partials/_sidebar.html.erb +21 -4
  144. data/lib/docyard/templates/partials/_step.html.erb +14 -0
  145. data/lib/docyard/templates/partials/_tab_bar.html.erb +25 -0
  146. data/lib/docyard/templates/partials/_table_of_contents.html.erb +12 -12
  147. data/lib/docyard/templates/partials/_table_of_contents_toggle.html.erb +1 -3
  148. data/lib/docyard/templates/partials/_tabs.html.erb +2 -2
  149. data/lib/docyard/templates/partials/_theme_toggle.html.erb +2 -11
  150. data/lib/docyard/version.rb +1 -1
  151. metadata +70 -5
  152. data/lib/docyard/templates/markdown/getting-started/installation.md.erb +0 -77
  153. data/lib/docyard/templates/markdown/guides/configuration.md.erb +0 -202
  154. data/lib/docyard/templates/markdown/guides/markdown-features.md.erb +0 -247
  155. data/lib/docyard/templates/markdown/index.md.erb +0 -82
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docyard
4
+ class BreadcrumbBuilder
5
+ MAX_VISIBLE_ITEMS = 3
6
+
7
+ Item = Struct.new(:title, :href, :current, keyword_init: true)
8
+
9
+ attr_reader :sidebar_tree, :current_path
10
+
11
+ def initialize(sidebar_tree:, current_path:)
12
+ @sidebar_tree = sidebar_tree || []
13
+ @current_path = normalize_path(current_path)
14
+ end
15
+
16
+ def items
17
+ @items ||= build_items
18
+ end
19
+
20
+ def truncated?
21
+ full_path_items.length > MAX_VISIBLE_ITEMS
22
+ end
23
+
24
+ def should_show?
25
+ items.any? && !root_page?
26
+ end
27
+
28
+ private
29
+
30
+ def build_items
31
+ return [] if full_path_items.empty?
32
+
33
+ if truncated?
34
+ truncated_items
35
+ else
36
+ full_path_items
37
+ end
38
+ end
39
+
40
+ def truncated_items
41
+ path = full_path_items
42
+ [
43
+ Item.new(title: "...", href: nil, current: false),
44
+ path[-2],
45
+ path[-1]
46
+ ].compact
47
+ end
48
+
49
+ def full_path_items
50
+ @full_path_items ||= find_breadcrumb_path(sidebar_tree, [])
51
+ end
52
+
53
+ def find_breadcrumb_path(nodes, path)
54
+ nodes.each do |node|
55
+ result = process_node(node, path)
56
+ return result if result
57
+ end
58
+
59
+ []
60
+ end
61
+
62
+ def process_node(node, path)
63
+ node_path = normalize_path(node[:path])
64
+ node_title = truncate_title(node[:title] || "")
65
+
66
+ return build_current_item(path, node_title, node_path) if exact_match?(node_path)
67
+
68
+ search_in_ancestors(node, path, node_title, node_path) ||
69
+ search_in_children(node, path)
70
+ end
71
+
72
+ def build_current_item(path, title, href)
73
+ path + [Item.new(title: title, href: href, current: true)]
74
+ end
75
+
76
+ def search_in_ancestors(node, path, title, href)
77
+ return unless node[:children]&.any?
78
+
79
+ effective_href = href == "/" ? derive_section_path(node) : href
80
+ return unless path_is_ancestor?(effective_href)
81
+
82
+ result = find_breadcrumb_path(
83
+ node[:children],
84
+ path + [Item.new(title: title, href: effective_href, current: false)]
85
+ )
86
+ result.any? ? result : nil
87
+ end
88
+
89
+ def derive_section_path(node)
90
+ first_child = node[:children]&.first
91
+ return nil unless first_child
92
+
93
+ child_path = first_child[:path]
94
+ return nil if child_path.nil? || child_path.empty?
95
+
96
+ File.dirname(child_path)
97
+ end
98
+
99
+ def search_in_children(node, path)
100
+ return unless node[:children]&.any?
101
+
102
+ result = find_breadcrumb_path(node[:children], path)
103
+ result.any? ? result : nil
104
+ end
105
+
106
+ def exact_match?(node_path)
107
+ normalize_path(node_path) == current_path
108
+ end
109
+
110
+ def path_is_ancestor?(node_path)
111
+ return false if node_path.nil? || node_path.empty? || node_path == "/"
112
+
113
+ normalized = normalize_path(node_path)
114
+ current_path.start_with?("#{normalized}/") || current_path == normalized
115
+ end
116
+
117
+ def normalize_path(path)
118
+ return "/" if path.nil? || path.empty?
119
+
120
+ path.chomp("/")
121
+ end
122
+
123
+ def truncate_title(title)
124
+ return title if title.length <= 30
125
+
126
+ "#{title[0, 27]}..."
127
+ end
128
+
129
+ def root_page?
130
+ current_path == "/" || current_path.empty?
131
+ end
132
+ end
133
+ end
@@ -124,7 +124,10 @@ module Docyard
124
124
  end
125
125
 
126
126
  def valid_navigation_item?(item)
127
- item[:type] == :file && item[:path] && !external_link?(item[:path])
127
+ return false unless item[:path]
128
+ return false if external_link?(item[:path])
129
+
130
+ item[:type] == :file || (item[:type] == :directory && item[:has_index])
128
131
  end
129
132
 
130
133
  def build_link(item)
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docyard
4
+ module Sidebar
5
+ class ChildrenDiscoverer
6
+ attr_reader :docs_path
7
+
8
+ def initialize(docs_path:)
9
+ @docs_path = docs_path
10
+ end
11
+
12
+ def discover(relative_path, depth:, &item_builder)
13
+ full_path = File.join(docs_path, relative_path)
14
+ return [] unless File.directory?(full_path)
15
+
16
+ local_config = load_local_sidebar_config(full_path)
17
+ return yield(local_config, relative_path, depth) if local_config
18
+
19
+ discover_from_filesystem(full_path, relative_path, depth, &item_builder)
20
+ end
21
+
22
+ private
23
+
24
+ def load_local_sidebar_config(dir_path)
25
+ LocalConfigLoader.new(dir_path).load
26
+ end
27
+
28
+ def discover_from_filesystem(full_path, relative_path, depth, &item_builder)
29
+ entries = filtered_entries(full_path)
30
+ entries.map { |entry| build_entry(entry, full_path, relative_path, depth, &item_builder) }.compact
31
+ end
32
+
33
+ def filtered_entries(full_path)
34
+ Dir.children(full_path)
35
+ .reject { |e| e.start_with?(".") || e.start_with?("_") || e == "index.md" }
36
+ .sort
37
+ end
38
+
39
+ def build_entry(entry, full_path, relative_path, depth)
40
+ entry_path = File.join(full_path, entry)
41
+
42
+ if File.directory?(entry_path)
43
+ yield(:directory, entry, relative_path, depth)
44
+ elsif entry.end_with?(".md")
45
+ slug = entry.delete_suffix(".md")
46
+ yield(:file, slug, relative_path, depth)
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -2,178 +2,206 @@
2
2
 
3
3
  require_relative "item"
4
4
  require_relative "title_extractor"
5
+ require_relative "metadata_extractor"
6
+ require_relative "children_discoverer"
7
+ require_relative "file_resolver"
5
8
 
6
9
  module Docyard
7
10
  module Sidebar
8
- class ConfigParser
9
- attr_reader :config_items, :docs_path, :current_path, :title_extractor
11
+ class ConfigParser # rubocop:disable Metrics/ClassLength
12
+ attr_reader :config_items, :docs_path, :current_path, :metadata_extractor,
13
+ :children_discoverer, :file_resolver
10
14
 
11
15
  def initialize(config_items, docs_path:, current_path: "/", title_extractor: TitleExtractor.new)
12
16
  @config_items = config_items || []
13
17
  @docs_path = docs_path
14
18
  @current_path = Utils::PathResolver.normalize(current_path)
15
- @title_extractor = title_extractor
19
+ @metadata_extractor = MetadataExtractor.new(docs_path: docs_path, title_extractor: title_extractor)
20
+ @children_discoverer = ChildrenDiscoverer.new(docs_path: docs_path)
21
+ @file_resolver = FileResolver.new(
22
+ docs_path: docs_path, current_path: @current_path, metadata_extractor: metadata_extractor
23
+ )
16
24
  end
17
25
 
18
26
  def parse
19
- parse_items(config_items)
27
+ parse_items(config_items, "", depth: 1)
20
28
  end
21
29
 
22
30
  private
23
31
 
24
- def parse_items(items, base_path = "")
25
- items.map do |item_config|
26
- parse_item(item_config, base_path)
27
- end.compact
32
+ def parse_items(items, base_path, depth:)
33
+ items.map { |item_config| parse_item(item_config, base_path, depth: depth) }.compact
28
34
  end
29
35
 
30
- def parse_item(item_config, base_path)
36
+ def parse_item(item_config, base_path, depth:)
31
37
  case item_config
32
- when String
33
- resolve_file_item(item_config, base_path)
34
- when Hash
35
- parse_hash_item(item_config, base_path)
38
+ when String then resolve_string_item(item_config, base_path, depth: depth)
39
+ when Hash then parse_hash_item(item_config, base_path, depth: depth)
36
40
  end
37
41
  end
38
42
 
39
- def parse_hash_item(item_config, base_path)
40
- return parse_link_item(item_config) if link_item?(item_config)
41
- return parse_nested_item(item_config, base_path) if nested_item?(item_config)
42
- return resolve_file_item(item_config.keys.first, base_path, {}) if nil_value_item?(item_config)
43
-
44
- slug = item_config.keys.first
45
- options = item_config.values.first || {}
46
- resolve_file_item(slug, base_path, options)
47
- end
48
-
49
- def link_item?(config)
50
- config.key?("link") || config.key?(:link)
43
+ def resolve_string_item(slug, base_path, depth:)
44
+ if File.directory?(File.join(docs_path, base_path, slug))
45
+ build_directory_item(slug, {}, [], base_path, depth: depth)
46
+ else
47
+ file_resolver.resolve(slug, base_path)
48
+ end
51
49
  end
52
50
 
53
- def nested_item?(config)
54
- config.size == 1 && config.values.first.is_a?(Hash)
55
- end
51
+ def parse_hash_item(item_config, base_path, depth:)
52
+ return file_resolver.build_link_item(item_config) if external_link?(item_config)
56
53
 
57
- def nil_value_item?(config)
58
- config.size == 1 && config.values.first.nil?
59
- end
54
+ slug = item_config.keys.first
55
+ options = item_config.values.first
60
56
 
61
- def parse_link_item(config)
62
- link = config["link"] || config[:link]
63
- text = config["text"] || config[:text]
64
- icon = config["icon"] || config[:icon]
65
- target = config["target"] || config[:target] || "_blank"
57
+ return file_resolver.resolve(slug, base_path, {}) if options.nil?
58
+ return parse_nested_item(slug, options, base_path, depth: depth) if options.is_a?(Hash)
66
59
 
67
- Item.new(
68
- text: text,
69
- link: link,
70
- path: link,
71
- icon: icon,
72
- target: target,
73
- type: :external
74
- )
60
+ file_resolver.resolve(slug, base_path, options)
75
61
  end
76
62
 
77
- def parse_nested_item(item_config, base_path)
78
- slug = item_config.keys.first.to_s
79
- options = item_config.values.first || {}
80
- nested_items = extract_nested_items(options)
63
+ def external_link?(config)
64
+ config.key?("link") || config.key?(:link)
65
+ end
81
66
 
67
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
68
+ def parse_nested_item(slug, options, base_path, depth:)
69
+ slug = slug.to_s
70
+ nested_items = options["items"] || options[:items] || []
82
71
  dir_path = File.join(docs_path, base_path, slug)
72
+ is_virtual_group = (options["section"] == false || options[:section] == false) && nested_items.any?
83
73
 
84
- if File.directory?(dir_path)
85
- build_directory_item(slug, options, nested_items, base_path)
74
+ if is_virtual_group
75
+ build_virtual_group_item(slug, options, nested_items, base_path, depth: depth)
76
+ elsif File.directory?(dir_path)
77
+ build_directory_item(slug, options, nested_items, base_path, depth: depth)
86
78
  elsif nested_items.any?
87
- build_file_with_children_item(slug, options, nested_items, base_path)
79
+ build_file_with_children_item(slug, options, nested_items, base_path, depth: depth)
88
80
  else
89
- resolve_file_item(slug, base_path, options)
81
+ file_resolver.resolve(slug, base_path, options)
90
82
  end
91
83
  end
84
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
92
85
 
93
- def extract_nested_items(options)
94
- options["items"] || options[:items] || []
95
- end
96
-
97
- def extract_common_options(options)
98
- {
99
- text: options["text"] || options[:text],
100
- icon: options["icon"] || options[:icon],
101
- collapsed: options["collapsed"] || options[:collapsed] || false
102
- }
103
- end
104
-
105
- def build_directory_item(slug, options, nested_items, base_path)
106
- common_opts = extract_common_options(options)
107
- new_base_path = File.join(base_path, slug)
108
- parsed_items = parse_items(nested_items, new_base_path)
86
+ def build_virtual_group_item(slug, options, nested_items, base_path, depth:)
87
+ common_opts = metadata_extractor.extract_common_options(options)
88
+ parsed_items = parse_items(nested_items, base_path, depth: depth + 1)
89
+ is_collapsed = common_opts[:collapsed] != false && !active_child?(parsed_items)
109
90
 
110
91
  Item.new(
111
92
  slug: slug,
112
93
  text: common_opts[:text] || Utils::TextFormatter.titleize(slug),
94
+ path: nil,
113
95
  icon: common_opts[:icon],
114
- collapsed: common_opts[:collapsed],
96
+ collapsed: is_collapsed,
97
+ active: false,
115
98
  items: parsed_items,
116
- type: :directory
99
+ type: :directory,
100
+ section: false
117
101
  )
118
102
  end
119
103
 
120
- def build_file_with_children_item(slug, options, nested_items, base_path)
121
- common_opts = extract_common_options(options)
122
- file_path = File.join(docs_path, base_path, "#{slug}.md")
123
- url_path = Utils::PathResolver.to_url(File.join(base_path, slug))
124
- resolved_text = common_opts[:text] || extract_file_title(file_path, slug)
104
+ def build_directory_item(slug, options, nested_items, base_path, depth:)
105
+ context = build_directory_context(slug, options, nested_items, base_path, depth)
106
+ context[:parsed_items] = prepend_intro_if_needed(context, depth)
107
+ create_directory_item(slug, context, depth)
108
+ end
109
+
110
+ def build_directory_context(slug, options, nested_items, base_path, depth)
111
+ new_base_path = File.join(base_path, slug)
112
+ {
113
+ common_opts: metadata_extractor.extract_common_options(options),
114
+ parsed_items: resolve_directory_children(nested_items, new_base_path, depth),
115
+ **build_index_info(new_base_path)
116
+ }
117
+ end
118
+
119
+ def build_index_info(base_path)
120
+ index_file_path = File.join(docs_path, base_path, "index.md")
121
+ has_index = File.file?(index_file_path)
122
+ url_path = has_index ? Utils::PathResolver.to_url(base_path) : nil
123
+
124
+ { index_file_path: index_file_path, has_index: has_index,
125
+ url_path: url_path, is_active: has_index && current_path == url_path }
126
+ end
127
+
128
+ def resolve_directory_children(nested_items, base_path, depth)
129
+ return parse_items(nested_items, base_path, depth: depth + 1) if nested_items.any?
130
+
131
+ auto_discover_children(base_path, depth: depth + 1)
132
+ end
133
+
134
+ def prepend_intro_if_needed(context, depth)
135
+ is_section = section_for_depth?(context[:common_opts][:section], depth)
136
+ return context[:parsed_items] unless is_section && context[:has_index]
137
+
138
+ [build_introduction_item(context[:index_file_path], context[:url_path])] + context[:parsed_items]
139
+ end
125
140
 
141
+ def create_directory_item(slug, context, depth)
142
+ is_section = section_for_depth?(context[:common_opts][:section], depth)
126
143
  Item.new(
127
144
  slug: slug,
128
- text: resolved_text,
129
- path: url_path,
130
- icon: common_opts[:icon],
131
- collapsed: common_opts[:collapsed],
132
- items: parse_items(nested_items, base_path),
133
- active: current_path == url_path,
134
- type: :file
145
+ text: context[:common_opts][:text] || Utils::TextFormatter.titleize(slug),
146
+ path: is_section ? nil : context[:url_path],
147
+ icon: context[:common_opts][:icon],
148
+ collapsed: is_section ? false : directory_collapsed?(context),
149
+ active: is_section ? false : context[:is_active],
150
+ has_index: is_section ? false : context[:has_index],
151
+ items: context[:parsed_items],
152
+ type: :directory,
153
+ section: is_section
135
154
  )
136
155
  end
137
156
 
138
- def extract_file_title(file_path, slug)
139
- File.exist?(file_path) ? title_extractor.extract(file_path) : Utils::TextFormatter.titleize(slug)
140
- end
157
+ def section_for_depth?(explicit_section, depth)
158
+ return explicit_section unless explicit_section.nil?
141
159
 
142
- def resolve_file_item(slug, base_path, options = {})
143
- slug_str = slug.to_s
144
- options ||= {}
160
+ depth == 1
161
+ end
145
162
 
146
- file_path = File.join(docs_path, base_path, "#{slug_str}.md")
147
- url_path = Utils::PathResolver.to_url(File.join(base_path, slug_str))
163
+ def directory_collapsed?(context)
164
+ return false if context[:is_active] || active_child?(context[:parsed_items])
148
165
 
149
- frontmatter = extract_frontmatter_metadata(file_path)
150
- text = resolve_item_text(slug_str, file_path, options, frontmatter[:text])
151
- icon = resolve_item_icon(options, frontmatter[:icon])
152
- final_path = options["link"] || options[:link] || url_path
166
+ context[:common_opts][:collapsed] != false
167
+ end
153
168
 
169
+ def build_introduction_item(index_file_path, url_path)
170
+ metadata = metadata_extractor.extract_index_metadata(index_file_path)
154
171
  Item.new(
155
- slug: slug_str, text: text, path: final_path, icon: icon,
156
- active: current_path == final_path, type: :file
172
+ slug: "index", text: metadata[:sidebar_text] || "Overview",
173
+ path: url_path, icon: metadata[:icon], active: current_path == url_path, type: :file
157
174
  )
158
175
  end
159
176
 
160
- def extract_frontmatter_metadata(file_path)
161
- return { text: nil, icon: nil } unless File.exist?(file_path)
177
+ def build_file_with_children_item(slug, options, nested_items, base_path, depth:)
178
+ file_resolver.build_file_with_children(
179
+ slug: slug, options: options, base_path: base_path,
180
+ parsed_items: parse_items(nested_items, base_path, depth: depth + 1),
181
+ depth: depth
182
+ )
183
+ end
162
184
 
163
- markdown = Markdown.new(File.read(file_path))
164
- {
165
- text: markdown.sidebar_text || markdown.title,
166
- icon: markdown.sidebar_icon
167
- }
185
+ def auto_discover_children(relative_path, depth:)
186
+ children_discoverer.discover(relative_path, depth: depth) do |config_or_type, *args|
187
+ if config_or_type.is_a?(Array)
188
+ parse_items(config_or_type, args[0], depth: args[1])
189
+ else
190
+ build_discovered_item(config_or_type, args[0], args[1], depth)
191
+ end
192
+ end
168
193
  end
169
194
 
170
- def resolve_item_text(slug, file_path, options, frontmatter_text)
171
- text = options["text"] || options[:text] || frontmatter_text
172
- text || extract_file_title(file_path, slug)
195
+ def build_discovered_item(type, slug, base_path, depth)
196
+ if type == :directory
197
+ build_directory_item(slug, {}, [], base_path, depth: depth)
198
+ else
199
+ file_resolver.resolve(slug, base_path)
200
+ end
173
201
  end
174
202
 
175
- def resolve_item_icon(options, frontmatter_icon)
176
- options["icon"] || options[:icon] || frontmatter_icon
203
+ def active_child?(items)
204
+ items.any? { |item| item.active || active_child?(item.items) }
177
205
  end
178
206
  end
179
207
  end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "item"
4
+
5
+ module Docyard
6
+ module Sidebar
7
+ class FileResolver
8
+ attr_reader :docs_path, :current_path, :metadata_extractor
9
+
10
+ def initialize(docs_path:, current_path:, metadata_extractor:)
11
+ @docs_path = docs_path
12
+ @current_path = current_path
13
+ @metadata_extractor = metadata_extractor
14
+ end
15
+
16
+ def resolve(slug, base_path, options = {})
17
+ context = build_context(slug.to_s, base_path, options || {})
18
+ Item.new(**context)
19
+ end
20
+
21
+ def build_link_item(config)
22
+ Item.new(
23
+ text: config["text"] || config[:text],
24
+ link: config["link"] || config[:link],
25
+ path: config["link"] || config[:link],
26
+ icon: config["icon"] || config[:icon],
27
+ target: config["target"] || config[:target] || "_blank",
28
+ type: :external,
29
+ section: false
30
+ )
31
+ end
32
+
33
+ def build_file_with_children(slug:, options:, base_path:, parsed_items:, depth: 1)
34
+ common_opts = metadata_extractor.extract_common_options(options)
35
+ file_path = File.join(docs_path, base_path, "#{slug}.md")
36
+ url_path = Utils::PathResolver.to_url(File.join(base_path, slug))
37
+ is_section = section_for_depth?(common_opts[:section], depth)
38
+
39
+ Item.new(
40
+ slug: slug,
41
+ text: common_opts[:text] || metadata_extractor.extract_file_title(file_path, slug),
42
+ path: is_section ? nil : url_path,
43
+ icon: common_opts[:icon],
44
+ collapsed: is_section ? false : common_opts[:collapsed],
45
+ items: parsed_items,
46
+ active: is_section ? false : current_path == url_path,
47
+ type: is_section ? :section : :file,
48
+ section: is_section
49
+ )
50
+ end
51
+
52
+ private
53
+
54
+ def section_for_depth?(explicit_section, depth)
55
+ return explicit_section unless explicit_section.nil?
56
+
57
+ depth == 1
58
+ end
59
+
60
+ def build_context(slug, base_path, options)
61
+ paths = resolve_paths(slug, base_path, options)
62
+ frontmatter = metadata_extractor.extract_frontmatter_metadata(paths[:file])
63
+
64
+ build_context_hash(slug, paths, options, frontmatter)
65
+ end
66
+
67
+ def resolve_paths(slug, base_path, options)
68
+ file_path = File.join(docs_path, base_path, "#{slug}.md")
69
+ url_path = Utils::PathResolver.to_url(File.join(base_path, slug))
70
+ final_path = options["link"] || options[:link] || url_path
71
+
72
+ { file: file_path, final: final_path }
73
+ end
74
+
75
+ def build_context_hash(slug, paths, options, frontmatter)
76
+ {
77
+ slug: slug,
78
+ text: metadata_extractor.resolve_item_text(slug, paths[:file], options, frontmatter[:text]),
79
+ path: paths[:final],
80
+ icon: metadata_extractor.resolve_item_icon(options, frontmatter[:icon]),
81
+ badge: frontmatter[:badge],
82
+ badge_type: frontmatter[:badge_type],
83
+ active: current_path == paths[:final],
84
+ type: :file,
85
+ section: false
86
+ }
87
+ end
88
+ end
89
+ end
90
+ end
@@ -66,7 +66,8 @@ module Docyard
66
66
  def hidden_or_ignored?(entry, relative_path)
67
67
  entry.start_with?(".") ||
68
68
  entry.start_with?("_") ||
69
- (entry == "index.md" && relative_path.empty?)
69
+ (entry == "index.md" && relative_path.empty?) ||
70
+ (entry == "public" && relative_path.empty?)
70
71
  end
71
72
 
72
73
  def sort_key(entry)