docyard 0.1.0 → 0.2.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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +3 -0
  3. data/CHANGELOG.md +23 -1
  4. data/README.md +16 -8
  5. data/lib/docyard/constants.rb +23 -0
  6. data/lib/docyard/errors.rb +54 -0
  7. data/lib/docyard/file_watcher.rb +2 -2
  8. data/lib/docyard/initializer.rb +9 -2
  9. data/lib/docyard/logging.rb +43 -0
  10. data/lib/docyard/rack_application.rb +29 -11
  11. data/lib/docyard/renderer.rb +5 -3
  12. data/lib/docyard/router.rb +13 -8
  13. data/lib/docyard/routing/resolution_result.rb +31 -0
  14. data/lib/docyard/sidebar/file_system_scanner.rb +77 -0
  15. data/lib/docyard/sidebar/renderer.rb +110 -0
  16. data/lib/docyard/sidebar/title_extractor.rb +25 -0
  17. data/lib/docyard/sidebar/tree_builder.rb +59 -0
  18. data/lib/docyard/sidebar_builder.rb +50 -0
  19. data/lib/docyard/templates/assets/css/code.css +214 -0
  20. data/lib/docyard/templates/assets/css/components.css +258 -0
  21. data/lib/docyard/templates/assets/css/layout.css +273 -0
  22. data/lib/docyard/templates/assets/css/main.css +10 -4
  23. data/lib/docyard/templates/assets/css/markdown.css +199 -0
  24. data/lib/docyard/templates/assets/css/reset.css +59 -0
  25. data/lib/docyard/templates/assets/css/typography.css +97 -0
  26. data/lib/docyard/templates/assets/css/variables.css +114 -0
  27. data/lib/docyard/templates/assets/js/theme.js +193 -1
  28. data/lib/docyard/templates/layouts/default.html.erb +41 -19
  29. data/lib/docyard/templates/markdown/core-concepts/file-structure.md.erb +61 -0
  30. data/lib/docyard/templates/markdown/core-concepts/markdown.md.erb +90 -0
  31. data/lib/docyard/templates/markdown/getting-started/installation.md.erb +43 -0
  32. data/lib/docyard/templates/markdown/getting-started/introduction.md.erb +30 -0
  33. data/lib/docyard/templates/markdown/getting-started/quick-start.md.erb +56 -0
  34. data/lib/docyard/templates/markdown/index.md.erb +78 -14
  35. data/lib/docyard/templates/partials/_icons.html.erb +11 -0
  36. data/lib/docyard/templates/partials/_nav_group.html.erb +7 -0
  37. data/lib/docyard/templates/partials/_nav_item.html.erb +3 -0
  38. data/lib/docyard/templates/partials/_nav_leaf.html.erb +1 -0
  39. data/lib/docyard/templates/partials/_nav_list.html.erb +3 -0
  40. data/lib/docyard/templates/partials/_nav_section.html.erb +6 -0
  41. data/lib/docyard/templates/partials/_sidebar.html.erb +6 -0
  42. data/lib/docyard/templates/partials/_sidebar_footer.html.erb +11 -0
  43. data/lib/docyard/utils/path_resolver.rb +30 -0
  44. data/lib/docyard/utils/text_formatter.rb +22 -0
  45. data/lib/docyard/version.rb +1 -1
  46. data/lib/docyard.rb +16 -4
  47. metadata +32 -3
  48. data/lib/docyard/templates/assets/css/syntax.css +0 -116
  49. data/lib/docyard/templates/markdown/getting-started.md.erb +0 -40
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9b69315ac7baa43631fa2019a0ab8fb16d2b738efc622ad58c9dac3a4922b94d
4
- data.tar.gz: f180d6145f4ae3ab99f41a91ea85c62d6824952782e33b7a91dfde612f68d9a3
3
+ metadata.gz: 0614d285d89d4f529607c5e4bdb50c823cce769a5628b90100234c9513f98690
4
+ data.tar.gz: 2f7c134bca20f17766e54fb4bc3b02b602e735cf4d8c7e11f83f9158773bec22
5
5
  SHA512:
6
- metadata.gz: 7e8a176789932b8b09d7f07dbb1eb2789230e7a48b5b6bee09e2ff2cf29f780456d58231adf54933c902172feb858020ca1d1ef148bba4aaf5ec1a8a3d5d3795
7
- data.tar.gz: 435ac4d482457cf029c1e98ca772d56291c7cbd810d6463a770d79b0e073925c0e790fe23bc21ad1f2386f715ba946d4520d5c25ffb6d8fc724731b3df61ba24
6
+ metadata.gz: 1259470e5245ff3ffc8796144a1e48fae46d851b44ca2c6cbeda0e8f83c782ebb815cfdfe0bdb3fa36fc25744a050859df44e5451376c8dadb4601ad9c3e2e98
7
+ data.tar.gz: 90281b4af9dd20425d26d6ea14214de5a79237ddeec9038546b9142819e363051850c64942a1b56459e5e8e1c509d08a36b323c2e01e3bacd4353fc39755915a
data/.rubocop.yml CHANGED
@@ -33,3 +33,6 @@ Layout/LineLength:
33
33
  Style/StringLiterals:
34
34
  Enabled: true
35
35
  EnforcedStyle: double_quotes
36
+
37
+ Layout/MultilineMethodCallIndentation:
38
+ EnforcedStyle: indented
data/CHANGELOG.md CHANGED
@@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.0] - 2025-11-08
11
+
12
+ ### Added
13
+ - Automatic sidebar navigation with nested folders
14
+ - Collapsible sidebar sections with active page highlighting
15
+ - Sidebar scroll position persistence
16
+ - Modern responsive theme with mobile hamburger menu
17
+ - Structured logging system with configurable levels
18
+ - Custom error classes for better error handling
19
+ - Constants module for shared application values
20
+ - Utility modules for path resolution and text formatting
21
+ - Improved initial templates with nested docs structure
22
+
23
+ ### Changed
24
+ - Refactored sidebar rendering to use partial templates
25
+ - Modular CSS architecture (split into variables, reset, typography, components, layout)
26
+ - Enhanced router with routing resolution result pattern
27
+
28
+ ### Removed
29
+ - Legacy syntax.css in favor of modular code.css
30
+
10
31
  ## [0.1.0] - 2025-11-04
11
32
 
12
33
  ### Added
@@ -25,6 +46,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
25
46
  - Initial gem structure
26
47
  - Project scaffolding
27
48
 
28
- [Unreleased]: https://github.com/sanifhimani/docyard/compare/v0.1.0...HEAD
49
+ [Unreleased]: https://github.com/sanifhimani/docyard/compare/v0.2.0...HEAD
50
+ [0.2.0]: https://github.com/sanifhimani/docyard/compare/v0.1.0...v0.2.0
29
51
  [0.1.0]: https://github.com/sanifhimani/docyard/compare/v0.0.1...v0.1.0
30
52
  [0.0.1]: https://github.com/sanifhimani/docyard/releases/tag/v0.0.1
data/README.md CHANGED
@@ -5,10 +5,11 @@
5
5
 
6
6
  > Documentation generator for Ruby
7
7
 
8
- **Early development** - Core features work, but missing sidebar, search, and build command. See [roadmap](#roadmap).
8
+ **Early development** - Core features work, but missing search and build command. See [roadmap](#roadmap).
9
9
 
10
10
  ## Features
11
11
 
12
+ - **Sidebar navigation** - Automatic sidebar with nested folders and collapsible sections
12
13
  - **Hot reload** - Changes appear instantly while you write
13
14
  - **GitHub Flavored Markdown** - Tables, task lists, strikethrough
14
15
  - **Syntax highlighting** - 100+ languages via Rouge
@@ -56,8 +57,14 @@ docyard init
56
57
  This creates:
57
58
  ```
58
59
  docs/
59
- index.md # Home page
60
- getting-started.md # Sample page
60
+ index.md # Home page
61
+ getting-started/
62
+ introduction.md # Getting started guide
63
+ installation.md # Installation instructions
64
+ quick-start.md # Quick start guide
65
+ core-concepts/
66
+ file-structure.md # File structure guide
67
+ markdown.md # Markdown guide
61
68
  ```
62
69
 
63
70
  ### Start Development Server
@@ -163,11 +170,12 @@ bundle exec rubocop
163
170
 
164
171
  ## Roadmap
165
172
 
166
- - Sidebar navigation
167
- - Search
168
- - Dark mode
169
- - Build command
170
- - Config file
173
+ **Next up:**
174
+ - Dark mode with theme toggle
175
+ - Markdown components (callouts, code groups, collapsible sections)
176
+ - Icon system
177
+ - Client-side search
178
+ - Static site generation (`docyard build`)
171
179
 
172
180
  ## Contributing
173
181
 
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docyard
4
+ module Constants
5
+ CONTENT_TYPE_HTML = "text/html; charset=utf-8"
6
+ CONTENT_TYPE_JSON = "application/json; charset=utf-8"
7
+ CONTENT_TYPE_CSS = "text/css; charset=utf-8"
8
+ CONTENT_TYPE_JS = "application/javascript; charset=utf-8"
9
+
10
+ RELOAD_ENDPOINT = "/_docyard/reload"
11
+ ASSETS_PREFIX = "/assets/"
12
+
13
+ INDEX_FILE = "index"
14
+ INDEX_TITLE = "Home"
15
+
16
+ MARKDOWN_EXTENSION = ".md"
17
+ HTML_EXTENSION = ".html"
18
+
19
+ STATUS_OK = 200
20
+ STATUS_NOT_FOUND = 404
21
+ STATUS_INTERNAL_ERROR = 500
22
+ end
23
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docyard
4
+ class Error < StandardError; end
5
+
6
+ class FileNotFoundError < Error
7
+ attr_reader :path
8
+
9
+ def initialize(path)
10
+ @path = path
11
+ super("File not found: #{path}")
12
+ end
13
+ end
14
+
15
+ class InvalidPathError < Error; end
16
+
17
+ class MarkdownParseError < Error
18
+ attr_reader :file_path, :original_error
19
+
20
+ def initialize(file_path, original_error)
21
+ @file_path = file_path
22
+ @original_error = original_error
23
+ super("Failed to parse markdown file #{file_path}: #{original_error.message}")
24
+ end
25
+ end
26
+
27
+ class TemplateRenderError < Error
28
+ attr_reader :template_path, :original_error
29
+
30
+ def initialize(template_path, original_error)
31
+ @template_path = template_path
32
+ @original_error = original_error
33
+ super("Failed to render template #{template_path}: #{original_error.message}")
34
+ end
35
+ end
36
+
37
+ class ReloadCheckError < Error
38
+ attr_reader :original_error
39
+
40
+ def initialize(original_error)
41
+ @original_error = original_error
42
+ super("Reload check failed: #{original_error.message}")
43
+ end
44
+ end
45
+
46
+ class AssetNotFoundError < Error
47
+ attr_reader :asset_path
48
+
49
+ def initialize(asset_path)
50
+ @asset_path = asset_path
51
+ super("Asset not found: #{asset_path}")
52
+ end
53
+ end
54
+ end
@@ -23,7 +23,7 @@ module Docyard
23
23
  def stop
24
24
  @listener&.stop
25
25
  rescue StandardError => e
26
- puts "[Docyard] Error stopping file watcher: #{e.class} - #{e.message}"
26
+ Docyard.logger.error "Error stopping file watcher: #{e.class} - #{e.message}"
27
27
  end
28
28
 
29
29
  def changed_since?(timestamp)
@@ -36,7 +36,7 @@ module Docyard
36
36
  return if modified.empty? && added.empty? && removed.empty?
37
37
 
38
38
  @last_modified_time = Time.now
39
- puts "[Docyard] Files changed, triggering reload..."
39
+ Docyard.logger.info "Files changed, triggering reload..."
40
40
  end
41
41
  end
42
42
  end
@@ -9,7 +9,11 @@ module Docyard
9
9
 
10
10
  TEMPLATES = {
11
11
  "index.md" => "index.md.erb",
12
- "getting-started.md" => "getting-started.md.erb"
12
+ "getting-started/introduction.md" => "getting-started/introduction.md.erb",
13
+ "getting-started/installation.md" => "getting-started/installation.md.erb",
14
+ "getting-started/quick-start.md" => "getting-started/quick-start.md.erb",
15
+ "core-concepts/file-structure.md" => "core-concepts/file-structure.md.erb",
16
+ "core-concepts/markdown.md" => "core-concepts/markdown.md.erb"
13
17
  }.freeze
14
18
 
15
19
  def initialize(path = ".")
@@ -46,6 +50,9 @@ module Docyard
46
50
  template_path = File.join(TEMPLATE_DIR, template_name)
47
51
  output_path = File.join(@docs_path, output_name)
48
52
 
53
+ output_dir = File.dirname(output_path)
54
+ FileUtils.mkdir_p(output_dir) unless File.directory?(output_dir)
55
+
49
56
  content = File.read(template_path)
50
57
  File.write(output_path, content)
51
58
  end
@@ -63,7 +70,7 @@ module Docyard
63
70
  puts ""
64
71
  puts "Next steps:"
65
72
  puts " 1. Edit your markdown files in #{DOCS_DIR}/"
66
- puts " 2. Run 'docyard serve' to preview (not implemented yet)"
73
+ puts " 2. Run 'docyard serve' to preview your documentation locally"
67
74
  end
68
75
  end
69
76
  end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+
5
+ module Docyard
6
+ module Logging
7
+ class << self
8
+ attr_writer :logger
9
+
10
+ def logger
11
+ @logger ||= default_logger
12
+ end
13
+
14
+ def level=(level)
15
+ logger.level = Logger.const_get(level.to_s.upcase)
16
+ end
17
+
18
+ private
19
+
20
+ def default_logger
21
+ logger = Logger.new($stdout)
22
+ logger.level = Logger::INFO
23
+ logger.formatter = log_formatter
24
+ logger
25
+ end
26
+
27
+ def log_formatter
28
+ proc do |severity, datetime, _progname, msg|
29
+ timestamp = datetime.strftime("%Y-%m-%d %H:%M:%S")
30
+ "[#{timestamp}] [Docyard] [#{severity}] #{msg}\n"
31
+ end
32
+ end
33
+ end
34
+ end
35
+
36
+ def self.logger
37
+ Logging.logger
38
+ end
39
+
40
+ def self.log_level=(level)
41
+ Logging.level = level
42
+ end
43
+ end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "json"
4
4
  require "rack"
5
+ require_relative "sidebar_builder"
5
6
 
6
7
  module Docyard
7
8
  class RackApplication
@@ -24,8 +25,8 @@ module Docyard
24
25
  def handle_request(env)
25
26
  path = env["PATH_INFO"]
26
27
 
27
- return handle_reload_check(env) if path == "/_docyard/reload"
28
- return asset_handler.serve(path) if path.start_with?("/assets/")
28
+ return handle_reload_check(env) if path == Constants::RELOAD_ENDPOINT
29
+ return asset_handler.serve(path) if path.start_with?(Constants::ASSETS_PREFIX)
29
30
 
30
31
  handle_documentation_request(path)
31
32
  rescue StandardError => e
@@ -33,31 +34,48 @@ module Docyard
33
34
  end
34
35
 
35
36
  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
37
+ result = router.resolve(path)
39
38
 
40
- [status, { "Content-Type" => "text/html; charset=utf-8" }, [html]]
39
+ if result.found?
40
+ sidebar = SidebarBuilder.new(docs_path: docs_path, current_path: path)
41
+ html = renderer.render_file(result.file_path, sidebar_html: sidebar.to_html)
42
+ [Constants::STATUS_OK, { "Content-Type" => Constants::CONTENT_TYPE_HTML }, [html]]
43
+ else
44
+ html = renderer.render_not_found
45
+ [Constants::STATUS_NOT_FOUND, { "Content-Type" => Constants::CONTENT_TYPE_HTML }, [html]]
46
+ end
41
47
  end
42
48
 
43
49
  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
50
+ since = parse_since_timestamp(env)
46
51
  reload_needed = file_watcher.changed_since?(since)
47
52
 
48
53
  build_reload_response(reload_needed)
49
54
  rescue StandardError => e
50
- puts "[Docyard] Reload check error: #{e.message}"
55
+ log_reload_error(e)
51
56
  build_reload_response(false)
52
57
  end
53
58
 
59
+ def parse_since_timestamp(env)
60
+ query = Rack::Utils.parse_query(env["QUERY_STRING"])
61
+ query["since"] ? Time.at(query["since"].to_f) : Time.now
62
+ end
63
+
64
+ def log_reload_error(error)
65
+ Docyard.logger.error "Reload check error: #{error.message}"
66
+ Docyard.logger.debug error.backtrace.join("\n")
67
+ end
68
+
54
69
  def build_reload_response(reload_needed)
55
70
  response_body = { reload: reload_needed, timestamp: Time.now.to_f }.to_json
56
- [200, { "Content-Type" => "application/json" }, [response_body]]
71
+ [Constants::STATUS_OK, { "Content-Type" => Constants::CONTENT_TYPE_JSON }, [response_body]]
57
72
  end
58
73
 
59
74
  def handle_error(error)
60
- [500, { "Content-Type" => "text/html; charset=utf-8" }, [renderer.render_server_error(error)]]
75
+ Docyard.logger.error "Request error: #{error.message}"
76
+ Docyard.logger.debug error.backtrace.join("\n")
77
+ [Constants::STATUS_INTERNAL_ERROR, { "Content-Type" => Constants::CONTENT_TYPE_HTML },
78
+ [renderer.render_server_error(error)]]
61
79
  end
62
80
  end
63
81
  end
@@ -13,7 +13,7 @@ module Docyard
13
13
  @layout_path = File.join(LAYOUTS_PATH, "#{layout}.html.erb")
14
14
  end
15
15
 
16
- def render_file(file_path)
16
+ def render_file(file_path, sidebar_html: "")
17
17
  markdown_content = File.read(file_path)
18
18
  markdown = Markdown.new(markdown_content)
19
19
 
@@ -21,15 +21,17 @@ module Docyard
21
21
 
22
22
  render(
23
23
  content: html_content,
24
- page_title: markdown.title || "Documentation"
24
+ page_title: markdown.title || "Documentation",
25
+ sidebar_html: sidebar_html
25
26
  )
26
27
  end
27
28
 
28
- def render(content:, page_title: "Documentation")
29
+ def render(content:, page_title: "Documentation", sidebar_html: "")
29
30
  template = File.read(layout_path)
30
31
 
31
32
  @content = content
32
33
  @page_title = page_title
34
+ @sidebar_html = sidebar_html
33
35
 
34
36
  ERB.new(template).result(binding)
35
37
  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
@@ -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