docyard 0.1.0 → 0.3.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 (91) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +3 -0
  3. data/CHANGELOG.md +41 -1
  4. data/LICENSE.vscode-icons +42 -0
  5. data/README.md +57 -8
  6. data/lib/docyard/asset_handler.rb +33 -0
  7. data/lib/docyard/components/base_processor.rb +24 -0
  8. data/lib/docyard/components/callout_processor.rb +121 -0
  9. data/lib/docyard/components/code_block_processor.rb +55 -0
  10. data/lib/docyard/components/code_detector.rb +59 -0
  11. data/lib/docyard/components/icon_detector.rb +57 -0
  12. data/lib/docyard/components/icon_processor.rb +51 -0
  13. data/lib/docyard/components/registry.rb +34 -0
  14. data/lib/docyard/components/tabs_parser.rb +60 -0
  15. data/lib/docyard/components/tabs_processor.rb +44 -0
  16. data/lib/docyard/config/validator.rb +171 -0
  17. data/lib/docyard/config.rb +133 -0
  18. data/lib/docyard/constants.rb +28 -0
  19. data/lib/docyard/errors.rb +54 -0
  20. data/lib/docyard/file_watcher.rb +2 -2
  21. data/lib/docyard/icons/LICENSE.phosphor +21 -0
  22. data/lib/docyard/icons/file_types.rb +92 -0
  23. data/lib/docyard/icons/phosphor.rb +63 -0
  24. data/lib/docyard/icons.rb +40 -0
  25. data/lib/docyard/initializer.rb +27 -2
  26. data/lib/docyard/language_mapping.rb +52 -0
  27. data/lib/docyard/logging.rb +43 -0
  28. data/lib/docyard/markdown.rb +14 -3
  29. data/lib/docyard/rack_application.rb +100 -13
  30. data/lib/docyard/renderer.rb +40 -5
  31. data/lib/docyard/router.rb +13 -8
  32. data/lib/docyard/routing/resolution_result.rb +31 -0
  33. data/lib/docyard/server.rb +5 -2
  34. data/lib/docyard/sidebar/file_system_scanner.rb +77 -0
  35. data/lib/docyard/sidebar/renderer.rb +110 -0
  36. data/lib/docyard/sidebar/title_extractor.rb +25 -0
  37. data/lib/docyard/sidebar/tree_builder.rb +59 -0
  38. data/lib/docyard/sidebar_builder.rb +58 -0
  39. data/lib/docyard/templates/assets/css/code.css +362 -0
  40. data/lib/docyard/templates/assets/css/components/callout.css +169 -0
  41. data/lib/docyard/templates/assets/css/components/code-block.css +196 -0
  42. data/lib/docyard/templates/assets/css/components/icon.css +16 -0
  43. data/lib/docyard/templates/assets/css/components/logo.css +44 -0
  44. data/lib/docyard/templates/assets/css/components/navigation.css +258 -0
  45. data/lib/docyard/templates/assets/css/components/tabs.css +298 -0
  46. data/lib/docyard/templates/assets/css/components/theme-toggle.css +61 -0
  47. data/lib/docyard/templates/assets/css/layout.css +283 -0
  48. data/lib/docyard/templates/assets/css/main.css +10 -4
  49. data/lib/docyard/templates/assets/css/markdown.css +200 -0
  50. data/lib/docyard/templates/assets/css/reset.css +63 -0
  51. data/lib/docyard/templates/assets/css/typography.css +97 -0
  52. data/lib/docyard/templates/assets/css/variables.css +205 -0
  53. data/lib/docyard/templates/assets/favicon.svg +16 -0
  54. data/lib/docyard/templates/assets/js/components/code-block.js +162 -0
  55. data/lib/docyard/templates/assets/js/components/tabs.js +338 -0
  56. data/lib/docyard/templates/assets/js/theme.js +209 -1
  57. data/lib/docyard/templates/assets/logo-dark.svg +4 -0
  58. data/lib/docyard/templates/assets/logo.svg +12 -0
  59. data/lib/docyard/templates/config/docyard.yml.erb +20 -0
  60. data/lib/docyard/templates/layouts/default.html.erb +69 -19
  61. data/lib/docyard/templates/markdown/components/callouts.md.erb +204 -0
  62. data/lib/docyard/templates/markdown/components/icons.md.erb +125 -0
  63. data/lib/docyard/templates/markdown/components/tabs.md.erb +686 -0
  64. data/lib/docyard/templates/markdown/configuration.md.erb +202 -0
  65. data/lib/docyard/templates/markdown/core-concepts/file-structure.md.erb +61 -0
  66. data/lib/docyard/templates/markdown/core-concepts/markdown.md.erb +90 -0
  67. data/lib/docyard/templates/markdown/getting-started/installation.md.erb +43 -0
  68. data/lib/docyard/templates/markdown/getting-started/introduction.md.erb +30 -0
  69. data/lib/docyard/templates/markdown/getting-started/quick-start.md.erb +56 -0
  70. data/lib/docyard/templates/markdown/index.md.erb +78 -14
  71. data/lib/docyard/templates/partials/_callout.html.erb +11 -0
  72. data/lib/docyard/templates/partials/_code_block.html.erb +6 -0
  73. data/lib/docyard/templates/partials/_icon.html.erb +1 -0
  74. data/lib/docyard/templates/partials/_icon_file_extension.html.erb +1 -0
  75. data/lib/docyard/templates/partials/_icons.html.erb +11 -0
  76. data/lib/docyard/templates/partials/_nav_group.html.erb +7 -0
  77. data/lib/docyard/templates/partials/_nav_item.html.erb +3 -0
  78. data/lib/docyard/templates/partials/_nav_leaf.html.erb +1 -0
  79. data/lib/docyard/templates/partials/_nav_list.html.erb +3 -0
  80. data/lib/docyard/templates/partials/_nav_section.html.erb +6 -0
  81. data/lib/docyard/templates/partials/_sidebar.html.erb +6 -0
  82. data/lib/docyard/templates/partials/_sidebar_footer.html.erb +11 -0
  83. data/lib/docyard/templates/partials/_tabs.html.erb +40 -0
  84. data/lib/docyard/templates/partials/_theme_toggle.html.erb +13 -0
  85. data/lib/docyard/utils/path_resolver.rb +30 -0
  86. data/lib/docyard/utils/text_formatter.rb +22 -0
  87. data/lib/docyard/version.rb +1 -1
  88. data/lib/docyard.rb +16 -4
  89. metadata +71 -3
  90. data/lib/docyard/templates/assets/css/syntax.css +0 -116
  91. data/lib/docyard/templates/markdown/getting-started.md.erb +0 -40
@@ -2,12 +2,15 @@
2
2
 
3
3
  require "json"
4
4
  require "rack"
5
+ require_relative "sidebar_builder"
6
+ require_relative "constants"
5
7
 
6
8
  module Docyard
7
9
  class RackApplication
8
- def initialize(docs_path:, file_watcher:)
10
+ def initialize(docs_path:, file_watcher:, config: nil)
9
11
  @docs_path = docs_path
10
12
  @file_watcher = file_watcher
13
+ @config = config
11
14
  @router = Router.new(docs_path: docs_path)
12
15
  @renderer = Renderer.new
13
16
  @asset_handler = AssetHandler.new
@@ -19,13 +22,13 @@ module Docyard
19
22
 
20
23
  private
21
24
 
22
- attr_reader :docs_path, :file_watcher, :router, :renderer, :asset_handler
25
+ attr_reader :docs_path, :file_watcher, :config, :router, :renderer, :asset_handler
23
26
 
24
27
  def handle_request(env)
25
28
  path = env["PATH_INFO"]
26
29
 
27
- return handle_reload_check(env) if path == "/_docyard/reload"
28
- return asset_handler.serve(path) if path.start_with?("/assets/")
30
+ return handle_reload_check(env) if path == Constants::RELOAD_ENDPOINT
31
+ return asset_handler.serve(path) if path.start_with?(Constants::ASSETS_PREFIX)
29
32
 
30
33
  handle_documentation_request(path)
31
34
  rescue StandardError => e
@@ -33,31 +36,115 @@ module Docyard
33
36
  end
34
37
 
35
38
  def handle_documentation_request(path)
36
- file_path = router.resolve(path)
37
- status = file_path ? 200 : 404
38
- html = file_path ? renderer.render_file(file_path) : renderer.render_not_found
39
+ result = router.resolve(path)
39
40
 
40
- [status, { "Content-Type" => "text/html; charset=utf-8" }, [html]]
41
+ if result.found?
42
+ render_documentation_page(result.file_path, path)
43
+ else
44
+ render_not_found_page
45
+ end
46
+ end
47
+
48
+ def render_documentation_page(file_path, current_path)
49
+ html = renderer.render_file(
50
+ file_path,
51
+ sidebar_html: build_sidebar(current_path),
52
+ branding: branding_options
53
+ )
54
+
55
+ [Constants::STATUS_OK, { "Content-Type" => Constants::CONTENT_TYPE_HTML }, [html]]
56
+ end
57
+
58
+ def render_not_found_page
59
+ html = renderer.render_not_found
60
+ [Constants::STATUS_NOT_FOUND, { "Content-Type" => Constants::CONTENT_TYPE_HTML }, [html]]
61
+ end
62
+
63
+ def build_sidebar(current_path)
64
+ SidebarBuilder.new(
65
+ docs_path: docs_path,
66
+ current_path: current_path,
67
+ config: config
68
+ ).to_html
69
+ end
70
+
71
+ def branding_options
72
+ return default_branding unless config
73
+
74
+ default_branding.merge(config_branding_options)
75
+ end
76
+
77
+ def default_branding
78
+ {
79
+ site_title: Constants::DEFAULT_SITE_TITLE,
80
+ site_description: "",
81
+ logo: Constants::DEFAULT_LOGO_PATH,
82
+ logo_dark: Constants::DEFAULT_LOGO_DARK_PATH,
83
+ favicon: nil,
84
+ display_logo: true,
85
+ display_title: true
86
+ }
87
+ end
88
+
89
+ def config_branding_options
90
+ site = config.site
91
+ branding = config.branding
92
+
93
+ {
94
+ site_title: site.title || Constants::DEFAULT_SITE_TITLE,
95
+ site_description: site.description || "",
96
+ logo: resolve_logo(branding.logo, branding.logo_dark),
97
+ logo_dark: resolve_logo_dark(branding.logo, branding.logo_dark),
98
+ favicon: branding.favicon
99
+ }.merge(appearance_options(branding.appearance))
100
+ end
101
+
102
+ def appearance_options(appearance)
103
+ appearance ||= {}
104
+ {
105
+ display_logo: appearance["logo"] != false,
106
+ display_title: appearance["title"] != false
107
+ }
108
+ end
109
+
110
+ def resolve_logo(logo, logo_dark)
111
+ logo || logo_dark || Constants::DEFAULT_LOGO_PATH
112
+ end
113
+
114
+ def resolve_logo_dark(logo, logo_dark)
115
+ logo_dark || logo || Constants::DEFAULT_LOGO_DARK_PATH
41
116
  end
42
117
 
43
118
  def handle_reload_check(env)
44
- query = Rack::Utils.parse_query(env["QUERY_STRING"])
45
- since = query["since"] ? Time.at(query["since"].to_f) : Time.now
119
+ since = parse_since_timestamp(env)
46
120
  reload_needed = file_watcher.changed_since?(since)
47
121
 
48
122
  build_reload_response(reload_needed)
49
123
  rescue StandardError => e
50
- puts "[Docyard] Reload check error: #{e.message}"
124
+ log_reload_error(e)
51
125
  build_reload_response(false)
52
126
  end
53
127
 
128
+ def parse_since_timestamp(env)
129
+ query = Rack::Utils.parse_query(env["QUERY_STRING"])
130
+ query["since"] ? Time.at(query["since"].to_f) : Time.now
131
+ end
132
+
133
+ def log_reload_error(error)
134
+ Docyard.logger.error "Reload check error: #{error.message}"
135
+ Docyard.logger.debug error.backtrace.join("\n")
136
+ end
137
+
54
138
  def build_reload_response(reload_needed)
55
139
  response_body = { reload: reload_needed, timestamp: Time.now.to_f }.to_json
56
- [200, { "Content-Type" => "application/json" }, [response_body]]
140
+ [Constants::STATUS_OK, { "Content-Type" => Constants::CONTENT_TYPE_JSON }, [response_body]]
57
141
  end
58
142
 
59
143
  def handle_error(error)
60
- [500, { "Content-Type" => "text/html; charset=utf-8" }, [renderer.render_server_error(error)]]
144
+ Docyard.logger.error "Request error: #{error.message}"
145
+ Docyard.logger.debug error.backtrace.join("\n")
146
+ [Constants::STATUS_INTERNAL_ERROR, { "Content-Type" => Constants::CONTENT_TYPE_HTML },
147
+ [renderer.render_server_error(error)]]
61
148
  end
62
149
  end
63
150
  end
@@ -1,11 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "erb"
4
+ require_relative "constants"
4
5
 
5
6
  module Docyard
6
7
  class Renderer
7
8
  LAYOUTS_PATH = File.join(__dir__, "templates", "layouts")
8
9
  ERRORS_PATH = File.join(__dir__, "templates", "errors")
10
+ PARTIALS_PATH = File.join(__dir__, "templates", "partials")
9
11
 
10
12
  attr_reader :layout_path
11
13
 
@@ -13,7 +15,7 @@ module Docyard
13
15
  @layout_path = File.join(LAYOUTS_PATH, "#{layout}.html.erb")
14
16
  end
15
17
 
16
- def render_file(file_path)
18
+ def render_file(file_path, sidebar_html: "", branding: {})
17
19
  markdown_content = File.read(file_path)
18
20
  markdown = Markdown.new(markdown_content)
19
21
 
@@ -21,15 +23,17 @@ module Docyard
21
23
 
22
24
  render(
23
25
  content: html_content,
24
- page_title: markdown.title || "Documentation"
26
+ page_title: markdown.title || Constants::DEFAULT_SITE_TITLE,
27
+ sidebar_html: sidebar_html,
28
+ branding: branding
25
29
  )
26
30
  end
27
31
 
28
- def render(content:, page_title: "Documentation")
32
+ def render(content:, page_title: Constants::DEFAULT_SITE_TITLE, sidebar_html: "", branding: {})
29
33
  template = File.read(layout_path)
30
34
 
31
- @content = content
32
- @page_title = page_title
35
+ assign_content_variables(content, page_title, sidebar_html)
36
+ assign_branding_variables(branding)
33
37
 
34
38
  ERB.new(template).result(binding)
35
39
  end
@@ -50,8 +54,39 @@ module Docyard
50
54
  ERB.new(template).result(binding)
51
55
  end
52
56
 
57
+ def render_partial(name, locals = {})
58
+ partial_path = File.join(PARTIALS_PATH, "#{name}.html.erb")
59
+ template = File.read(partial_path)
60
+
61
+ locals.each { |key, value| instance_variable_set("@#{key}", value) }
62
+
63
+ ERB.new(template).result(binding)
64
+ end
65
+
66
+ def asset_path(path)
67
+ return path if path.nil? || path.start_with?("http://", "https://")
68
+
69
+ "/#{path}"
70
+ end
71
+
53
72
  private
54
73
 
74
+ def assign_content_variables(content, page_title, sidebar_html)
75
+ @content = content
76
+ @page_title = page_title
77
+ @sidebar_html = sidebar_html
78
+ end
79
+
80
+ def assign_branding_variables(branding)
81
+ @site_title = branding[:site_title] || Constants::DEFAULT_SITE_TITLE
82
+ @site_description = branding[:site_description] || ""
83
+ @logo = branding[:logo] || Constants::DEFAULT_LOGO_PATH
84
+ @logo_dark = branding[:logo_dark]
85
+ @favicon = branding[:favicon] || Constants::DEFAULT_FAVICON_PATH
86
+ @display_logo = branding[:display_logo].nil? || branding[:display_logo]
87
+ @display_title = branding[:display_title].nil? || branding[:display_title]
88
+ end
89
+
55
90
  def strip_md_from_links(html)
56
91
  html.gsub(/href="([^"]+)\.md"/, 'href="\1"')
57
92
  end
@@ -9,18 +9,23 @@ module Docyard
9
9
  end
10
10
 
11
11
  def resolve(request_path)
12
- clean_path = request_path.delete_prefix("/")
13
- clean_path = "index" if clean_path.empty?
12
+ clean_path = sanitize_path(request_path)
14
13
 
15
- clean_path = clean_path.delete_suffix(".md")
14
+ file_path = File.join(docs_path, "#{clean_path}#{Constants::MARKDOWN_EXTENSION}")
15
+ return Routing::ResolutionResult.found(file_path) if File.file?(file_path)
16
16
 
17
- file_path = File.join(docs_path, "#{clean_path}.md")
18
- return file_path if File.file?(file_path)
17
+ index_path = File.join(docs_path, clean_path, "#{Constants::INDEX_FILE}#{Constants::MARKDOWN_EXTENSION}")
18
+ return Routing::ResolutionResult.found(index_path) if File.file?(index_path)
19
19
 
20
- index_path = File.join(docs_path, clean_path, "index.md")
21
- return index_path if File.file?(index_path)
20
+ Routing::ResolutionResult.not_found
21
+ end
22
+
23
+ private
22
24
 
23
- nil
25
+ def sanitize_path(request_path)
26
+ clean = request_path.delete_prefix("/")
27
+ clean = Constants::INDEX_FILE if clean.empty?
28
+ clean.delete_suffix(Constants::MARKDOWN_EXTENSION)
24
29
  end
25
30
  end
26
31
  end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docyard
4
+ module Routing
5
+ class ResolutionResult
6
+ attr_reader :file_path, :status
7
+
8
+ def self.found(file_path)
9
+ new(file_path: file_path, status: :found)
10
+ end
11
+
12
+ def self.not_found
13
+ new(file_path: nil, status: :not_found)
14
+ end
15
+
16
+ def initialize(file_path:, status:)
17
+ @file_path = file_path
18
+ @status = status
19
+ freeze
20
+ end
21
+
22
+ def found?
23
+ status == :found
24
+ end
25
+
26
+ def not_found?
27
+ status == :not_found
28
+ end
29
+ end
30
+ end
31
+ end
@@ -4,22 +4,25 @@ require "webrick"
4
4
  require "stringio"
5
5
  require_relative "file_watcher"
6
6
  require_relative "rack_application"
7
+ require_relative "config"
7
8
 
8
9
  module Docyard
9
10
  class Server
10
11
  DEFAULT_PORT = 4200
11
12
  DEFAULT_HOST = "localhost"
12
13
 
13
- attr_reader :port, :host, :docs_path
14
+ attr_reader :port, :host, :docs_path, :config
14
15
 
15
16
  def initialize(port: DEFAULT_PORT, host: DEFAULT_HOST, docs_path: "docs")
16
17
  @port = port
17
18
  @host = host
18
19
  @docs_path = docs_path
20
+ @config = Config.load
19
21
  @file_watcher = FileWatcher.new(File.expand_path(docs_path))
20
22
  @app = RackApplication.new(
21
23
  docs_path: File.expand_path(docs_path),
22
- file_watcher: @file_watcher
24
+ file_watcher: @file_watcher,
25
+ config: @config
23
26
  )
24
27
  end
25
28
 
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docyard
4
+ module Sidebar
5
+ class FileSystemScanner
6
+ attr_reader :docs_path
7
+
8
+ def initialize(docs_path)
9
+ @docs_path = docs_path
10
+ end
11
+
12
+ def scan
13
+ return [] unless File.directory?(docs_path)
14
+
15
+ scan_directory(docs_path, "")
16
+ end
17
+
18
+ private
19
+
20
+ def scan_directory(base_path, relative_path)
21
+ full_path = File.join(base_path, relative_path)
22
+ return [] unless File.directory?(full_path)
23
+
24
+ entries = sorted_entries(full_path, relative_path)
25
+ entries.map { |entry| build_item(entry, base_path, relative_path, full_path) }.compact
26
+ end
27
+
28
+ def sorted_entries(full_path, relative_path)
29
+ Dir.children(full_path)
30
+ .reject { |entry| hidden_or_ignored?(entry, relative_path) }
31
+ .sort_by { |entry| sort_key(entry) }
32
+ end
33
+
34
+ def build_item(entry, base_path, relative_path, full_path)
35
+ entry_full_path = File.join(full_path, entry)
36
+ entry_relative_path = build_relative_path(relative_path, entry)
37
+
38
+ if File.directory?(entry_full_path)
39
+ build_directory_item(entry, entry_relative_path, base_path)
40
+ elsif entry.end_with?(".md")
41
+ build_file_item(entry, entry_relative_path)
42
+ end
43
+ end
44
+
45
+ def build_relative_path(relative_path, entry)
46
+ relative_path.empty? ? entry : File.join(relative_path, entry)
47
+ end
48
+
49
+ def build_directory_item(entry, entry_relative_path, base_path)
50
+ {
51
+ type: :directory,
52
+ name: entry,
53
+ path: entry_relative_path,
54
+ children: scan_directory(base_path, entry_relative_path)
55
+ }
56
+ end
57
+
58
+ def build_file_item(entry, entry_relative_path)
59
+ {
60
+ type: :file,
61
+ name: entry.delete_suffix(".md"),
62
+ path: entry_relative_path
63
+ }
64
+ end
65
+
66
+ def hidden_or_ignored?(entry, relative_path)
67
+ entry.start_with?(".") ||
68
+ entry.start_with?("_") ||
69
+ (entry == "index.md" && relative_path.empty?)
70
+ end
71
+
72
+ def sort_key(entry)
73
+ entry.downcase
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "erb"
4
+
5
+ module Docyard
6
+ module Sidebar
7
+ class Renderer
8
+ PARTIALS_PATH = File.join(__dir__, "../templates/partials")
9
+
10
+ attr_reader :site_title
11
+
12
+ def initialize(site_title: "Documentation")
13
+ @site_title = site_title
14
+ end
15
+
16
+ def render(tree)
17
+ return "" if tree.empty?
18
+
19
+ nav_content = render_tree_with_sections(tree)
20
+ footer_html = render_partial(:sidebar_footer)
21
+
22
+ render_partial(:sidebar, nav_content: nav_content, footer_html: footer_html)
23
+ end
24
+
25
+ private
26
+
27
+ def render_partial(name, locals = {})
28
+ template_path = File.join(PARTIALS_PATH, "_#{name}.html.erb")
29
+ template = File.read(template_path)
30
+
31
+ locals.each { |key, value| instance_variable_set("@#{key}", value) }
32
+
33
+ erb_binding = binding
34
+ ERB.new(template).result(erb_binding)
35
+ end
36
+
37
+ def icon(name)
38
+ render_partial(:icons, icon_name: name)
39
+ end
40
+
41
+ def render_tree_with_sections(items)
42
+ filtered_items = items.reject { |item| item[:title]&.downcase == site_title.downcase }
43
+ grouped_items = group_by_section(filtered_items)
44
+
45
+ grouped_items.map do |section_name, section_items|
46
+ render_section(section_name, section_items)
47
+ end.join
48
+ end
49
+
50
+ def render_section(section_name, section_items)
51
+ section_content = render_tree(section_items)
52
+ render_partial(:nav_section, section_name: section_name, section_content: section_content)
53
+ end
54
+
55
+ def group_by_section(items)
56
+ sections = {}
57
+ root_items = []
58
+
59
+ items.each do |item|
60
+ process_section_item(item, sections, root_items)
61
+ end
62
+
63
+ build_section_result(sections, root_items)
64
+ end
65
+
66
+ def process_section_item(item, sections, root_items)
67
+ return if item[:title]&.downcase == site_title.downcase
68
+
69
+ if item[:type] == :directory && !item[:children].empty?
70
+ section_name = item[:title].upcase
71
+ sections[section_name] = item[:children]
72
+ else
73
+ root_items << item
74
+ end
75
+ end
76
+
77
+ def build_section_result(sections, root_items)
78
+ result = {}
79
+ result[nil] = root_items unless root_items.empty?
80
+ result.merge!(sections)
81
+ end
82
+
83
+ def render_tree(items)
84
+ return "" if items.empty?
85
+
86
+ list_items = items.map { |item| render_item(item) }.join
87
+ render_partial(:nav_list, list_items: list_items)
88
+ end
89
+
90
+ def render_item(item)
91
+ item_content = if item[:children].empty?
92
+ render_leaf_item(item)
93
+ else
94
+ render_group_item(item)
95
+ end
96
+
97
+ render_partial(:nav_item, item_content: item_content)
98
+ end
99
+
100
+ def render_leaf_item(item)
101
+ render_partial(:nav_leaf, path: item[:path], title: item[:title], active: item[:active])
102
+ end
103
+
104
+ def render_group_item(item)
105
+ children_html = render_tree(item[:children])
106
+ render_partial(:nav_group, title: item[:title], children_html: children_html)
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docyard
4
+ module Sidebar
5
+ class TitleExtractor
6
+ def extract(file_path)
7
+ return titleize_filename(file_path) unless File.file?(file_path)
8
+
9
+ content = File.read(file_path)
10
+ markdown = Markdown.new(content)
11
+ markdown.title || titleize_filename(file_path)
12
+ rescue StandardError => e
13
+ Docyard.logger.warn "Failed to extract title from #{file_path}: #{e.message}"
14
+ titleize_filename(file_path)
15
+ end
16
+
17
+ private
18
+
19
+ def titleize_filename(file_path)
20
+ filename = File.basename(file_path, Constants::MARKDOWN_EXTENSION)
21
+ Utils::TextFormatter.titleize(filename)
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docyard
4
+ module Sidebar
5
+ class TreeBuilder
6
+ attr_reader :docs_path, :current_path, :title_extractor
7
+
8
+ def initialize(docs_path:, current_path:, title_extractor: TitleExtractor.new)
9
+ @docs_path = docs_path
10
+ @current_path = Utils::PathResolver.normalize(current_path)
11
+ @title_extractor = title_extractor
12
+ end
13
+
14
+ def build(file_items)
15
+ transform_items(file_items, "")
16
+ end
17
+
18
+ private
19
+
20
+ def transform_items(items, relative_base)
21
+ items.map do |item|
22
+ if item[:type] == :directory
23
+ transform_directory(item, relative_base)
24
+ else
25
+ transform_file(item, relative_base)
26
+ end
27
+ end
28
+ end
29
+
30
+ def transform_directory(item, relative_base)
31
+ dir_path = File.join(relative_base, item[:name])
32
+
33
+ {
34
+ title: Utils::TextFormatter.titleize(item[:name]),
35
+ path: nil,
36
+ active: false,
37
+ type: :directory,
38
+ collapsible: true,
39
+ collapsed: false,
40
+ children: transform_items(item[:children], dir_path)
41
+ }
42
+ end
43
+
44
+ def transform_file(item, relative_base)
45
+ file_path = File.join(relative_base, "#{item[:name]}#{Constants::MARKDOWN_EXTENSION}")
46
+ full_file_path = File.join(docs_path, file_path)
47
+ url_path = Utils::PathResolver.to_url(file_path.delete_suffix(Constants::MARKDOWN_EXTENSION))
48
+
49
+ {
50
+ title: title_extractor.extract(full_file_path),
51
+ path: url_path,
52
+ active: current_path == url_path,
53
+ type: :file,
54
+ children: []
55
+ }
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "sidebar/file_system_scanner"
4
+ require_relative "sidebar/title_extractor"
5
+ require_relative "sidebar/tree_builder"
6
+ require_relative "sidebar/renderer"
7
+
8
+ module Docyard
9
+ class SidebarBuilder
10
+ attr_reader :docs_path, :current_path, :config
11
+
12
+ def initialize(docs_path:, current_path: "/", config: nil)
13
+ @docs_path = docs_path
14
+ @current_path = current_path
15
+ @config = config
16
+ end
17
+
18
+ def tree
19
+ @tree ||= build_tree
20
+ end
21
+
22
+ def to_html
23
+ renderer.render(tree)
24
+ end
25
+
26
+ private
27
+
28
+ def build_tree
29
+ file_items = scanner.scan
30
+ tree_builder.build(file_items)
31
+ end
32
+
33
+ def scanner
34
+ @scanner ||= Sidebar::FileSystemScanner.new(docs_path)
35
+ end
36
+
37
+ def tree_builder
38
+ @tree_builder ||= Sidebar::TreeBuilder.new(
39
+ docs_path: docs_path,
40
+ current_path: current_path
41
+ )
42
+ end
43
+
44
+ def renderer
45
+ @renderer ||= Sidebar::Renderer.new(
46
+ site_title: extract_site_title
47
+ )
48
+ end
49
+
50
+ def extract_site_title
51
+ if config.is_a?(Hash)
52
+ config[:site_title] || "Documentation"
53
+ else
54
+ config&.site&.title || "Documentation"
55
+ end
56
+ end
57
+ end
58
+ end