docyard 0.7.0 → 0.8.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 (112) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +5 -1
  3. data/CHANGELOG.md +20 -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/config/branding_resolver.rb +126 -17
  10. data/lib/docyard/config/constants.rb +6 -4
  11. data/lib/docyard/config/validator.rb +122 -99
  12. data/lib/docyard/config.rb +36 -43
  13. data/lib/docyard/initializer.rb +15 -76
  14. data/lib/docyard/navigation/breadcrumb_builder.rb +133 -0
  15. data/lib/docyard/navigation/prev_next_builder.rb +4 -1
  16. data/lib/docyard/navigation/sidebar/children_discoverer.rb +51 -0
  17. data/lib/docyard/navigation/sidebar/config_parser.rb +136 -108
  18. data/lib/docyard/navigation/sidebar/file_resolver.rb +78 -0
  19. data/lib/docyard/navigation/sidebar/file_system_scanner.rb +2 -1
  20. data/lib/docyard/navigation/sidebar/item.rb +45 -7
  21. data/lib/docyard/navigation/sidebar/local_config_loader.rb +51 -0
  22. data/lib/docyard/navigation/sidebar/metadata_extractor.rb +69 -0
  23. data/lib/docyard/navigation/sidebar/metadata_reader.rb +47 -0
  24. data/lib/docyard/navigation/sidebar/path_prefixer.rb +34 -0
  25. data/lib/docyard/navigation/sidebar/renderer.rb +55 -37
  26. data/lib/docyard/navigation/sidebar/sorter.rb +21 -0
  27. data/lib/docyard/navigation/sidebar/tree_builder.rb +99 -26
  28. data/lib/docyard/navigation/sidebar/tree_filter.rb +55 -0
  29. data/lib/docyard/navigation/sidebar_builder.rb +105 -36
  30. data/lib/docyard/rendering/icon_helpers.rb +13 -0
  31. data/lib/docyard/rendering/icons/phosphor.rb +23 -1
  32. data/lib/docyard/rendering/markdown.rb +5 -0
  33. data/lib/docyard/rendering/renderer.rb +74 -34
  34. data/lib/docyard/rendering/template_resolver.rb +172 -0
  35. data/lib/docyard/routing/fallback_resolver.rb +92 -0
  36. data/lib/docyard/search/build_indexer.rb +1 -1
  37. data/lib/docyard/search/dev_indexer.rb +51 -6
  38. data/lib/docyard/search/pagefind_support.rb +2 -0
  39. data/lib/docyard/server/asset_handler.rb +24 -19
  40. data/lib/docyard/server/pagefind_handler.rb +63 -0
  41. data/lib/docyard/server/preview_server.rb +1 -1
  42. data/lib/docyard/server/rack_application.rb +81 -64
  43. data/lib/docyard/templates/assets/css/code.css +18 -51
  44. data/lib/docyard/templates/assets/css/components/breadcrumbs.css +143 -0
  45. data/lib/docyard/templates/assets/css/components/callout.css +67 -67
  46. data/lib/docyard/templates/assets/css/components/code-block.css +180 -282
  47. data/lib/docyard/templates/assets/css/components/heading-anchor.css +28 -15
  48. data/lib/docyard/templates/assets/css/components/icon.css +0 -1
  49. data/lib/docyard/templates/assets/css/components/logo.css +0 -2
  50. data/lib/docyard/templates/assets/css/components/nav-menu.css +237 -0
  51. data/lib/docyard/templates/assets/css/components/navigation.css +186 -167
  52. data/lib/docyard/templates/assets/css/components/prev-next.css +76 -47
  53. data/lib/docyard/templates/assets/css/components/search.css +186 -174
  54. data/lib/docyard/templates/assets/css/components/tab-bar.css +163 -0
  55. data/lib/docyard/templates/assets/css/components/table-of-contents.css +127 -114
  56. data/lib/docyard/templates/assets/css/components/tabs.css +119 -160
  57. data/lib/docyard/templates/assets/css/components/theme-toggle.css +48 -44
  58. data/lib/docyard/templates/assets/css/landing.css +815 -0
  59. data/lib/docyard/templates/assets/css/layout.css +489 -87
  60. data/lib/docyard/templates/assets/css/main.css +1 -3
  61. data/lib/docyard/templates/assets/css/markdown.css +111 -93
  62. data/lib/docyard/templates/assets/css/reset.css +0 -3
  63. data/lib/docyard/templates/assets/css/typography.css +43 -41
  64. data/lib/docyard/templates/assets/css/variables.css +268 -208
  65. data/lib/docyard/templates/assets/favicon.svg +7 -8
  66. data/lib/docyard/templates/assets/fonts/Inter-Variable.ttf +0 -0
  67. data/lib/docyard/templates/assets/js/components/code-block.js +24 -42
  68. data/lib/docyard/templates/assets/js/components/heading-anchor.js +26 -24
  69. data/lib/docyard/templates/assets/js/components/navigation.js +181 -70
  70. data/lib/docyard/templates/assets/js/components/search.js +0 -75
  71. data/lib/docyard/templates/assets/js/components/sidebar-toggle.js +29 -0
  72. data/lib/docyard/templates/assets/js/components/tab-navigation.js +145 -0
  73. data/lib/docyard/templates/assets/js/components/table-of-contents.js +153 -66
  74. data/lib/docyard/templates/assets/js/components/tabs.js +31 -69
  75. data/lib/docyard/templates/assets/js/theme.js +0 -3
  76. data/lib/docyard/templates/assets/logo-dark.svg +8 -2
  77. data/lib/docyard/templates/assets/logo.svg +7 -4
  78. data/lib/docyard/templates/config/docyard.yml.erb +37 -34
  79. data/lib/docyard/templates/errors/404.html.erb +1 -1
  80. data/lib/docyard/templates/errors/500.html.erb +1 -1
  81. data/lib/docyard/templates/layouts/default.html.erb +18 -67
  82. data/lib/docyard/templates/layouts/splash.html.erb +176 -0
  83. data/lib/docyard/templates/partials/_breadcrumbs.html.erb +24 -0
  84. data/lib/docyard/templates/partials/_code_block.html.erb +5 -3
  85. data/lib/docyard/templates/partials/_doc_footer.html.erb +25 -0
  86. data/lib/docyard/templates/partials/_features.html.erb +15 -0
  87. data/lib/docyard/templates/partials/_footer.html.erb +42 -0
  88. data/lib/docyard/templates/partials/_head.html.erb +22 -0
  89. data/lib/docyard/templates/partials/_header.html.erb +49 -0
  90. data/lib/docyard/templates/partials/_heading_anchor.html.erb +3 -1
  91. data/lib/docyard/templates/partials/_hero.html.erb +27 -0
  92. data/lib/docyard/templates/partials/_nav_group.html.erb +25 -11
  93. data/lib/docyard/templates/partials/_nav_leaf.html.erb +1 -1
  94. data/lib/docyard/templates/partials/_nav_menu.html.erb +42 -0
  95. data/lib/docyard/templates/partials/_nav_nested_section.html.erb +11 -0
  96. data/lib/docyard/templates/partials/_nav_section.html.erb +1 -1
  97. data/lib/docyard/templates/partials/_prev_next.html.erb +8 -2
  98. data/lib/docyard/templates/partials/_scripts.html.erb +7 -0
  99. data/lib/docyard/templates/partials/_search_modal.html.erb +2 -6
  100. data/lib/docyard/templates/partials/_search_trigger.html.erb +2 -6
  101. data/lib/docyard/templates/partials/_sidebar.html.erb +21 -4
  102. data/lib/docyard/templates/partials/_tab_bar.html.erb +25 -0
  103. data/lib/docyard/templates/partials/_table_of_contents.html.erb +12 -12
  104. data/lib/docyard/templates/partials/_table_of_contents_toggle.html.erb +1 -3
  105. data/lib/docyard/templates/partials/_tabs.html.erb +2 -2
  106. data/lib/docyard/templates/partials/_theme_toggle.html.erb +2 -11
  107. data/lib/docyard/version.rb +1 -1
  108. metadata +33 -5
  109. data/lib/docyard/templates/markdown/getting-started/installation.md.erb +0 -77
  110. data/lib/docyard/templates/markdown/guides/configuration.md.erb +0 -202
  111. data/lib/docyard/templates/markdown/guides/markdown-features.md.erb +0 -247
  112. data/lib/docyard/templates/markdown/index.md.erb +0 -82
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Docyard
6
+ module Sidebar
7
+ class LocalConfigLoader
8
+ SIDEBAR_CONFIG_FILE = "_sidebar.yml"
9
+
10
+ attr_reader :docs_path
11
+
12
+ def initialize(docs_path)
13
+ @docs_path = docs_path
14
+ end
15
+
16
+ def load
17
+ return nil unless config_file_exists?
18
+
19
+ parse_config_file
20
+ end
21
+
22
+ def config_file_exists?
23
+ File.file?(config_file_path)
24
+ end
25
+
26
+ private
27
+
28
+ def config_file_path
29
+ File.join(docs_path, SIDEBAR_CONFIG_FILE)
30
+ end
31
+
32
+ def parse_config_file
33
+ content = YAML.load_file(config_file_path)
34
+ normalize_config(content)
35
+ rescue Psych::SyntaxError => e
36
+ warn "Warning: Invalid YAML in #{config_file_path}: #{e.message}"
37
+ nil
38
+ rescue StandardError => e
39
+ warn "Warning: Error reading #{config_file_path}: #{e.message}"
40
+ nil
41
+ end
42
+
43
+ def normalize_config(content)
44
+ return nil if content.nil?
45
+ return content if content.is_a?(Array)
46
+
47
+ content["items"] if content.is_a?(Hash)
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docyard
4
+ module Sidebar
5
+ class MetadataExtractor
6
+ attr_reader :docs_path, :title_extractor
7
+
8
+ def initialize(docs_path:, title_extractor:)
9
+ @docs_path = docs_path
10
+ @title_extractor = title_extractor
11
+ end
12
+
13
+ def extract_index_metadata(file_path)
14
+ return { sidebar_text: nil, icon: nil } unless File.file?(file_path)
15
+
16
+ markdown = Markdown.new(File.read(file_path))
17
+ {
18
+ sidebar_text: markdown.sidebar_text,
19
+ icon: markdown.sidebar_icon
20
+ }
21
+ rescue StandardError
22
+ { sidebar_text: nil, icon: nil }
23
+ end
24
+
25
+ def extract_frontmatter_metadata(file_path)
26
+ return { text: nil, icon: nil } unless File.exist?(file_path)
27
+
28
+ markdown = Markdown.new(File.read(file_path))
29
+ {
30
+ text: markdown.sidebar_text || markdown.title,
31
+ icon: markdown.sidebar_icon
32
+ }
33
+ end
34
+
35
+ def extract_file_title(file_path, slug)
36
+ File.exist?(file_path) ? title_extractor.extract(file_path) : Utils::TextFormatter.titleize(slug)
37
+ end
38
+
39
+ def extract_common_options(options)
40
+ collapsed_value = options["collapsed"]
41
+ collapsed_value = options[:collapsed] if collapsed_value.nil?
42
+ collapsible_value = options["collapsible"]
43
+ collapsible_value = options[:collapsible] if collapsible_value.nil?
44
+ collapsible_value = true if !collapsed_value.nil? && collapsible_value.nil?
45
+ {
46
+ text: options["text"] || options[:text],
47
+ icon: options["icon"] || options[:icon],
48
+ collapsed: collapsed_value,
49
+ section: section_from_collapsible(collapsible_value)
50
+ }
51
+ end
52
+
53
+ def section_from_collapsible(collapsible_value)
54
+ return nil if collapsible_value.nil?
55
+
56
+ collapsible_value != true
57
+ end
58
+
59
+ def resolve_item_text(slug, file_path, options, frontmatter_text)
60
+ text = options["text"] || options[:text] || frontmatter_text
61
+ text || extract_file_title(file_path, slug)
62
+ end
63
+
64
+ def resolve_item_icon(options, frontmatter_icon)
65
+ options["icon"] || options[:icon] || frontmatter_icon
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docyard
4
+ module Sidebar
5
+ class MetadataReader
6
+ def extract_file_metadata(file_path)
7
+ return empty_file_metadata unless File.file?(file_path)
8
+
9
+ content = File.read(file_path)
10
+ markdown = Markdown.new(content)
11
+ {
12
+ title: markdown.sidebar_text || markdown.title,
13
+ icon: markdown.sidebar_icon,
14
+ collapsed: markdown.sidebar_collapsed,
15
+ order: markdown.sidebar_order
16
+ }
17
+ rescue StandardError
18
+ empty_file_metadata
19
+ end
20
+
21
+ def extract_index_metadata(file_path)
22
+ return empty_index_metadata unless File.file?(file_path)
23
+
24
+ content = File.read(file_path)
25
+ markdown = Markdown.new(content)
26
+ {
27
+ sidebar_text: markdown.sidebar_text,
28
+ icon: markdown.sidebar_icon,
29
+ collapsed: markdown.sidebar_collapsed,
30
+ order: markdown.sidebar_order
31
+ }
32
+ rescue StandardError
33
+ empty_index_metadata
34
+ end
35
+
36
+ private
37
+
38
+ def empty_file_metadata
39
+ { title: nil, icon: nil, collapsed: nil, order: nil }
40
+ end
41
+
42
+ def empty_index_metadata
43
+ { sidebar_text: nil, icon: nil, collapsed: nil, order: nil }
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docyard
4
+ module Sidebar
5
+ class PathPrefixer
6
+ def initialize(tree, prefix)
7
+ @tree = tree
8
+ @prefix = prefix
9
+ end
10
+
11
+ def prefix
12
+ return @tree if @prefix.empty?
13
+
14
+ @tree.map { |item| prefix_item(item) }
15
+ end
16
+
17
+ private
18
+
19
+ def prefix_item(item)
20
+ prefixed = item.dup
21
+ prefixed[:path] = prefixed_path(prefixed[:path])
22
+ prefixed[:children] = self.class.new(prefixed[:children], @prefix).prefix if prefixed[:children]&.any?
23
+ prefixed
24
+ end
25
+
26
+ def prefixed_path(path)
27
+ return path if path.nil? || path.start_with?("http")
28
+
29
+ path_without_slash = path.sub(%r{^/}, "")
30
+ path_without_slash.empty? ? @prefix : "#{@prefix}/#{path_without_slash}"
31
+ end
32
+ end
33
+ end
34
+ end
@@ -1,28 +1,29 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "erb"
4
+ require_relative "../../rendering/icon_helpers"
4
5
 
5
6
  module Docyard
6
7
  module Sidebar
7
8
  class Renderer
8
9
  include Utils::UrlHelpers
10
+ include IconHelpers
9
11
 
10
12
  PARTIALS_PATH = File.join(__dir__, "../../templates/partials")
11
13
 
12
- attr_reader :site_title, :base_url
14
+ attr_reader :site_title, :base_url, :header_ctas
13
15
 
14
- def initialize(site_title: "Documentation", base_url: "/")
16
+ def initialize(site_title: "Documentation", base_url: "/", header_ctas: [])
15
17
  @site_title = site_title
16
18
  @base_url = normalize_base_url(base_url)
19
+ @header_ctas = header_ctas
17
20
  end
18
21
 
19
22
  def render(tree)
20
23
  return "" if tree.empty?
21
24
 
22
25
  nav_content = render_tree_with_sections(tree)
23
- footer_html = render_partial(:sidebar_footer)
24
-
25
- render_partial(:sidebar, nav_content: nav_content, footer_html: footer_html)
26
+ render_partial(:sidebar, nav_content: nav_content, header_ctas: header_ctas)
26
27
  end
27
28
 
28
29
  private
@@ -37,50 +38,52 @@ module Docyard
37
38
  ERB.new(template).result(erb_binding)
38
39
  end
39
40
 
40
- def icon(name, weight = "regular")
41
- Icons.render(name.to_s.tr("_", "-"), weight) || ""
42
- end
43
-
44
41
  def render_tree_with_sections(items)
45
42
  filtered_items = items.reject { |item| item[:title]&.downcase == site_title.downcase }
46
- grouped_items = group_by_section(filtered_items)
47
-
48
- grouped_items.map do |section_name, section_items|
49
- render_section(section_name, section_items)
43
+ grouped = group_items_by_section(filtered_items)
44
+
45
+ grouped.map do |group|
46
+ if group[:section]
47
+ render_section(group[:item])
48
+ else
49
+ render_item_group(group[:items])
50
+ end
50
51
  end.join
51
52
  end
52
53
 
53
- def render_section(section_name, section_items)
54
- section_content = render_tree(section_items)
55
- render_partial(:nav_section, section_name: section_name, section_content: section_content)
56
- end
57
-
58
- def group_by_section(items)
59
- sections = {}
60
- root_items = []
54
+ def group_items_by_section(items)
55
+ groups = []
56
+ current_non_section_items = []
61
57
 
62
58
  items.each do |item|
63
- process_section_item(item, sections, root_items)
59
+ if item[:section]
60
+ if current_non_section_items.any?
61
+ groups << { section: false, items: current_non_section_items }
62
+ current_non_section_items = []
63
+ end
64
+ groups << { section: true, item: item }
65
+ else
66
+ current_non_section_items << item
67
+ end
64
68
  end
65
69
 
66
- build_section_result(sections, root_items)
70
+ groups << { section: false, items: current_non_section_items } if current_non_section_items.any?
71
+ groups
67
72
  end
68
73
 
69
- def process_section_item(item, sections, root_items)
70
- return if item[:title]&.downcase == site_title.downcase
71
-
72
- if item[:type] == :directory && !item[:children].empty?
73
- section_name = item[:title].upcase
74
- sections[section_name] = item[:children]
75
- else
76
- root_items << item
77
- end
74
+ def render_section(item)
75
+ section_content = render_tree(item[:children])
76
+ render_partial(:nav_section,
77
+ section_name: item[:title],
78
+ section_icon: item[:icon],
79
+ section_content: section_content)
78
80
  end
79
81
 
80
- def build_section_result(sections, root_items)
81
- result = {}
82
- result[nil] = root_items unless root_items.empty?
83
- result.merge!(sections)
82
+ def render_item_group(items)
83
+ render_partial(:nav_section,
84
+ section_name: nil,
85
+ section_icon: nil,
86
+ section_content: render_tree(items))
84
87
  end
85
88
 
86
89
  def render_tree(items)
@@ -93,6 +96,8 @@ module Docyard
93
96
  def render_item(item)
94
97
  item_content = if item[:children].empty?
95
98
  render_leaf_item(item)
99
+ elsif item[:section]
100
+ render_nested_section(item)
96
101
  else
97
102
  render_group_item(item)
98
103
  end
@@ -111,14 +116,27 @@ module Docyard
111
116
  )
112
117
  end
113
118
 
119
+ def render_nested_section(item)
120
+ children_html = render_tree(item[:children])
121
+ render_partial(
122
+ :nav_nested_section,
123
+ title: item[:title],
124
+ icon: item[:icon],
125
+ children_html: children_html
126
+ )
127
+ end
128
+
114
129
  def render_group_item(item)
115
130
  children_html = render_tree(item[:children])
116
131
  render_partial(
117
132
  :nav_group,
118
133
  title: item[:title],
134
+ path: item[:path],
135
+ active: item[:active],
119
136
  children_html: children_html,
120
137
  icon: item[:icon],
121
- collapsed: item[:collapsed]
138
+ collapsed: item[:collapsed],
139
+ has_index: item[:has_index]
122
140
  )
123
141
  end
124
142
  end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docyard
4
+ module Sidebar
5
+ module Sorter
6
+ module_function
7
+
8
+ def sort_by_order(items)
9
+ items.sort_by do |item|
10
+ order = item[:order]
11
+ title = item[:title]&.downcase || ""
12
+ if order.nil?
13
+ [1, title]
14
+ else
15
+ [0, order, title]
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -1,65 +1,138 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "sorter"
4
+ require_relative "local_config_loader"
5
+ require_relative "config_parser"
6
+ require_relative "metadata_reader"
7
+
3
8
  module Docyard
4
9
  module Sidebar
5
10
  class TreeBuilder
6
- attr_reader :docs_path, :current_path, :title_extractor
11
+ attr_reader :docs_path, :current_path, :title_extractor, :metadata_reader
7
12
 
8
13
  def initialize(docs_path:, current_path:, title_extractor: TitleExtractor.new)
9
14
  @docs_path = docs_path
10
15
  @current_path = Utils::PathResolver.normalize(current_path)
11
16
  @title_extractor = title_extractor
17
+ @metadata_reader = MetadataReader.new
12
18
  end
13
19
 
14
20
  def build(file_items)
15
- transform_items(file_items, "")
21
+ transform_items(file_items, "", depth: 1)
16
22
  end
17
23
 
18
24
  private
19
25
 
20
- def transform_items(items, relative_base)
21
- items.map do |item|
26
+ def transform_items(items, relative_base, depth:)
27
+ transformed = items.map do |item|
22
28
  if item[:type] == :directory
23
- transform_directory(item, relative_base)
29
+ transform_directory(item, relative_base, depth: depth)
24
30
  else
25
31
  transform_file(item, relative_base)
26
32
  end
27
33
  end
34
+ Sorter.sort_by_order(transformed)
28
35
  end
29
36
 
30
- def transform_directory(item, relative_base)
37
+ def transform_directory(item, relative_base, depth:)
31
38
  dir_path = File.join(relative_base, item[:name])
32
- children = transform_items(item[:children], dir_path)
39
+ dir_context = build_directory_context(dir_path)
40
+ children = build_directory_children(item, dir_path, depth)
33
41
 
34
- {
35
- title: Utils::TextFormatter.titleize(item[:name]),
36
- path: nil,
37
- active: false,
38
- type: :directory,
39
- collapsible: true,
40
- collapsed: !active_child?(children),
41
- children: children
42
- }
42
+ if depth == 1
43
+ build_section(item, children, dir_context)
44
+ else
45
+ build_collapsible_group(item, children, dir_context)
46
+ end
43
47
  end
44
48
 
45
- def active_child?(children)
46
- children.any? do |child|
47
- child[:active] || active_child?(child[:children] || [])
49
+ def build_directory_children(item, dir_path, depth)
50
+ full_dir_path = File.join(docs_path, dir_path)
51
+ local_config = LocalConfigLoader.new(full_dir_path).load
52
+
53
+ if local_config
54
+ build_children_from_config(local_config, dir_path)
55
+ else
56
+ transform_items(item[:children], dir_path, depth: depth + 1)
57
+ end
58
+ end
59
+
60
+ def build_children_from_config(config_items, base_path)
61
+ full_base_path = File.join(docs_path, base_path)
62
+ parser = ConfigParser.new(config_items, docs_path: full_base_path, current_path: current_path)
63
+ parser.parse.map(&:to_h)
64
+ end
65
+
66
+ def build_directory_context(dir_path)
67
+ index_file_path = File.join(docs_path, dir_path, "index.md")
68
+ has_index = File.file?(index_file_path)
69
+ { index_file_path: index_file_path, has_index: has_index,
70
+ url_path: has_index ? Utils::PathResolver.to_url(dir_path) : nil }
71
+ end
72
+
73
+ def build_section(item, children, context)
74
+ filtered_children = filter_index_from_children(children, context[:url_path])
75
+ metadata = context[:has_index] ? metadata_reader.extract_index_metadata(context[:index_file_path]) : {}
76
+
77
+ if context[:has_index]
78
+ overview = build_overview_item(metadata, context[:url_path])
79
+ filtered_children = [overview] + filtered_children
48
80
  end
81
+
82
+ build_section_hash(item, filtered_children, metadata)
83
+ end
84
+
85
+ def build_section_hash(item, children, metadata)
86
+ { title: Utils::TextFormatter.titleize(item[:name]), path: nil, icon: metadata[:icon],
87
+ active: false, type: :directory, section: true,
88
+ collapsed: false, has_index: false, order: metadata[:order], children: children }
89
+ end
90
+
91
+ def build_collapsible_group(item, children, context)
92
+ filtered_children = filter_index_from_children(children, context[:url_path])
93
+ metadata = context[:has_index] ? metadata_reader.extract_index_metadata(context[:index_file_path]) : {}
94
+ is_active = context[:has_index] && current_path == context[:url_path]
95
+
96
+ build_collapsible_hash(item, filtered_children, context, metadata, is_active)
97
+ end
98
+
99
+ def build_collapsible_hash(item, children, context, metadata, is_active)
100
+ { title: Utils::TextFormatter.titleize(item[:name]), path: context[:url_path],
101
+ icon: metadata[:icon], active: is_active, type: :directory, section: false,
102
+ collapsed: collapsible_collapsed?(children, is_active), has_index: context[:has_index],
103
+ order: metadata[:order], children: children }
104
+ end
105
+
106
+ def collapsible_collapsed?(children, is_active)
107
+ return false if is_active || active_child?(children)
108
+
109
+ true
110
+ end
111
+
112
+ def build_overview_item(metadata, url_path)
113
+ { title: metadata[:sidebar_text] || "Overview", path: url_path,
114
+ icon: metadata[:icon], active: current_path == url_path, type: :file, children: [] }
115
+ end
116
+
117
+ def filter_index_from_children(children, index_url_path)
118
+ return children unless index_url_path
119
+
120
+ children.reject { |child| child[:path] == index_url_path }
121
+ end
122
+
123
+ def active_child?(children)
124
+ children.any? { |child| child[:active] || active_child?(child[:children] || []) }
49
125
  end
50
126
 
51
127
  def transform_file(item, relative_base)
52
128
  file_path = File.join(relative_base, "#{item[:name]}#{Constants::MARKDOWN_EXTENSION}")
53
129
  full_file_path = File.join(docs_path, file_path)
54
130
  url_path = Utils::PathResolver.to_url(file_path.delete_suffix(Constants::MARKDOWN_EXTENSION))
131
+ metadata = metadata_reader.extract_file_metadata(full_file_path)
55
132
 
56
- {
57
- title: title_extractor.extract(full_file_path),
58
- path: url_path,
59
- active: current_path == url_path,
60
- type: :file,
61
- children: []
62
- }
133
+ { title: metadata[:title] || title_extractor.extract(full_file_path),
134
+ path: url_path, icon: metadata[:icon], active: current_path == url_path,
135
+ type: :file, order: metadata[:order], children: [] }
63
136
  end
64
137
  end
65
138
  end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docyard
4
+ module Sidebar
5
+ class TreeFilter
6
+ def initialize(tree, tab_path)
7
+ @tree = tree
8
+ @tab_path = tab_path
9
+ end
10
+
11
+ def filter
12
+ @tree.filter_map { |item| filter_item(item) }
13
+ end
14
+
15
+ private
16
+
17
+ def filter_item(item)
18
+ children = item[:children] || []
19
+
20
+ if children.any?
21
+ filter_parent_item(item, children)
22
+ else
23
+ filter_leaf_item(item)
24
+ end
25
+ end
26
+
27
+ def filter_parent_item(item, children)
28
+ filtered_children = self.class.new(children, @tab_path).filter
29
+ has_matching_content = filtered_children.any? { |c| !external_item?(c) }
30
+
31
+ return nil if !has_matching_content && !item_matches_path?(item[:path])
32
+
33
+ item.merge(children: filtered_children)
34
+ end
35
+
36
+ def filter_leaf_item(item)
37
+ return item if external_item?(item)
38
+ return nil unless item_matches_path?(item[:path])
39
+
40
+ item
41
+ end
42
+
43
+ def external_item?(item)
44
+ item[:type] == :external || item[:path]&.start_with?("http")
45
+ end
46
+
47
+ def item_matches_path?(item_path)
48
+ return false if item_path.nil?
49
+
50
+ normalized_path = item_path.chomp("/")
51
+ normalized_path == @tab_path || normalized_path.start_with?("#{@tab_path}/")
52
+ end
53
+ end
54
+ end
55
+ end