docyard 0.0.1 → 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 (59) hide show
  1. checksums.yaml +4 -4
  2. data/.github/ISSUE_TEMPLATE/bug_report.md +31 -0
  3. data/.github/ISSUE_TEMPLATE/feature_request.md +19 -0
  4. data/.github/pull_request_template.md +14 -0
  5. data/.github/workflows/ci.yml +49 -0
  6. data/.rubocop.yml +38 -0
  7. data/CHANGELOG.md +52 -0
  8. data/CONTRIBUTING.md +55 -0
  9. data/README.md +174 -3
  10. data/lib/docyard/asset_handler.rb +64 -0
  11. data/lib/docyard/cli.rb +34 -0
  12. data/lib/docyard/constants.rb +23 -0
  13. data/lib/docyard/errors.rb +54 -0
  14. data/lib/docyard/file_watcher.rb +42 -0
  15. data/lib/docyard/initializer.rb +76 -0
  16. data/lib/docyard/logging.rb +43 -0
  17. data/lib/docyard/markdown.rb +61 -0
  18. data/lib/docyard/rack_application.rb +81 -0
  19. data/lib/docyard/renderer.rb +61 -0
  20. data/lib/docyard/router.rb +31 -0
  21. data/lib/docyard/routing/resolution_result.rb +31 -0
  22. data/lib/docyard/server.rb +91 -0
  23. data/lib/docyard/sidebar/file_system_scanner.rb +77 -0
  24. data/lib/docyard/sidebar/renderer.rb +110 -0
  25. data/lib/docyard/sidebar/title_extractor.rb +25 -0
  26. data/lib/docyard/sidebar/tree_builder.rb +59 -0
  27. data/lib/docyard/sidebar_builder.rb +50 -0
  28. data/lib/docyard/templates/assets/css/code.css +214 -0
  29. data/lib/docyard/templates/assets/css/components.css +258 -0
  30. data/lib/docyard/templates/assets/css/layout.css +273 -0
  31. data/lib/docyard/templates/assets/css/main.css +10 -0
  32. data/lib/docyard/templates/assets/css/markdown.css +199 -0
  33. data/lib/docyard/templates/assets/css/reset.css +59 -0
  34. data/lib/docyard/templates/assets/css/typography.css +97 -0
  35. data/lib/docyard/templates/assets/css/variables.css +114 -0
  36. data/lib/docyard/templates/assets/js/reload.js +98 -0
  37. data/lib/docyard/templates/assets/js/theme.js +193 -0
  38. data/lib/docyard/templates/errors/404.html.erb +16 -0
  39. data/lib/docyard/templates/errors/500.html.erb +25 -0
  40. data/lib/docyard/templates/layouts/default.html.erb +51 -0
  41. data/lib/docyard/templates/markdown/core-concepts/file-structure.md.erb +61 -0
  42. data/lib/docyard/templates/markdown/core-concepts/markdown.md.erb +90 -0
  43. data/lib/docyard/templates/markdown/getting-started/installation.md.erb +43 -0
  44. data/lib/docyard/templates/markdown/getting-started/introduction.md.erb +30 -0
  45. data/lib/docyard/templates/markdown/getting-started/quick-start.md.erb +56 -0
  46. data/lib/docyard/templates/markdown/index.md.erb +86 -0
  47. data/lib/docyard/templates/partials/_icons.html.erb +11 -0
  48. data/lib/docyard/templates/partials/_nav_group.html.erb +7 -0
  49. data/lib/docyard/templates/partials/_nav_item.html.erb +3 -0
  50. data/lib/docyard/templates/partials/_nav_leaf.html.erb +1 -0
  51. data/lib/docyard/templates/partials/_nav_list.html.erb +3 -0
  52. data/lib/docyard/templates/partials/_nav_section.html.erb +6 -0
  53. data/lib/docyard/templates/partials/_sidebar.html.erb +6 -0
  54. data/lib/docyard/templates/partials/_sidebar_footer.html.erb +11 -0
  55. data/lib/docyard/utils/path_resolver.rb +30 -0
  56. data/lib/docyard/utils/text_formatter.rb +22 -0
  57. data/lib/docyard/version.rb +1 -1
  58. data/lib/docyard.rb +20 -2
  59. metadata +169 -2
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Docyard
6
+ class Initializer
7
+ DOCS_DIR = "docs"
8
+ TEMPLATE_DIR = File.join(__dir__, "templates", "markdown")
9
+
10
+ TEMPLATES = {
11
+ "index.md" => "index.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"
17
+ }.freeze
18
+
19
+ def initialize(path = ".")
20
+ @path = path
21
+ @docs_path = File.join(@path, DOCS_DIR)
22
+ end
23
+
24
+ def run
25
+ if already_initialized?
26
+ print_already_exists_error
27
+ return
28
+ end
29
+
30
+ create_structure
31
+ print_success
32
+ true
33
+ end
34
+
35
+ private
36
+
37
+ def already_initialized?
38
+ File.exist?(@docs_path)
39
+ end
40
+
41
+ def create_structure
42
+ FileUtils.mkdir_p(@docs_path)
43
+
44
+ TEMPLATES.each do |output_name, template_name|
45
+ copy_template(template_name, output_name)
46
+ end
47
+ end
48
+
49
+ def copy_template(template_name, output_name)
50
+ template_path = File.join(TEMPLATE_DIR, template_name)
51
+ output_path = File.join(@docs_path, output_name)
52
+
53
+ output_dir = File.dirname(output_path)
54
+ FileUtils.mkdir_p(output_dir) unless File.directory?(output_dir)
55
+
56
+ content = File.read(template_path)
57
+ File.write(output_path, content)
58
+ end
59
+
60
+ def print_already_exists_error
61
+ puts "Error: #{DOCS_DIR}/ folder already exists"
62
+ puts " Remove it first or run docyard in a different directory"
63
+ end
64
+
65
+ def print_success
66
+ puts "Docyard initialized successfully!"
67
+ puts ""
68
+ puts "Created:"
69
+ TEMPLATES.each_key { |file| puts " #{DOCS_DIR}/#{file}" }
70
+ puts ""
71
+ puts "Next steps:"
72
+ puts " 1. Edit your markdown files in #{DOCS_DIR}/"
73
+ puts " 2. Run 'docyard serve' to preview your documentation locally"
74
+ end
75
+ end
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
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "kramdown"
4
+ require "kramdown-parser-gfm"
5
+ require "yaml"
6
+
7
+ module Docyard
8
+ class Markdown
9
+ FRONTMATTER_REGEX = /\A---\s*\n(.*?\n)---\s*\n/m
10
+
11
+ attr_reader :raw
12
+
13
+ def initialize(raw)
14
+ @raw = raw.freeze
15
+ end
16
+
17
+ def frontmatter
18
+ @frontmatter ||= parse_frontmatter
19
+ end
20
+
21
+ def content
22
+ @content ||= extract_content
23
+ end
24
+
25
+ def html
26
+ @html ||= render_html
27
+ end
28
+
29
+ def title
30
+ frontmatter["title"]
31
+ end
32
+
33
+ def description
34
+ frontmatter["description"]
35
+ end
36
+
37
+ private
38
+
39
+ def parse_frontmatter
40
+ match = raw.match(FRONTMATTER_REGEX)
41
+ return {} unless match
42
+
43
+ YAML.safe_load(match[1])
44
+ rescue Psych::SyntaxError
45
+ {}
46
+ end
47
+
48
+ def extract_content
49
+ raw.sub(FRONTMATTER_REGEX, "").strip
50
+ end
51
+
52
+ def render_html
53
+ Kramdown::Document.new(
54
+ content,
55
+ input: "GFM",
56
+ hard_wrap: false,
57
+ syntax_highlighter: "rouge"
58
+ ).to_html
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "rack"
5
+ require_relative "sidebar_builder"
6
+
7
+ module Docyard
8
+ class RackApplication
9
+ def initialize(docs_path:, file_watcher:)
10
+ @docs_path = docs_path
11
+ @file_watcher = file_watcher
12
+ @router = Router.new(docs_path: docs_path)
13
+ @renderer = Renderer.new
14
+ @asset_handler = AssetHandler.new
15
+ end
16
+
17
+ def call(env)
18
+ handle_request(env)
19
+ end
20
+
21
+ private
22
+
23
+ attr_reader :docs_path, :file_watcher, :router, :renderer, :asset_handler
24
+
25
+ def handle_request(env)
26
+ path = env["PATH_INFO"]
27
+
28
+ return handle_reload_check(env) if path == Constants::RELOAD_ENDPOINT
29
+ return asset_handler.serve(path) if path.start_with?(Constants::ASSETS_PREFIX)
30
+
31
+ handle_documentation_request(path)
32
+ rescue StandardError => e
33
+ handle_error(e)
34
+ end
35
+
36
+ def handle_documentation_request(path)
37
+ result = router.resolve(path)
38
+
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
47
+ end
48
+
49
+ def handle_reload_check(env)
50
+ since = parse_since_timestamp(env)
51
+ reload_needed = file_watcher.changed_since?(since)
52
+
53
+ build_reload_response(reload_needed)
54
+ rescue StandardError => e
55
+ log_reload_error(e)
56
+ build_reload_response(false)
57
+ end
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
+
69
+ def build_reload_response(reload_needed)
70
+ response_body = { reload: reload_needed, timestamp: Time.now.to_f }.to_json
71
+ [Constants::STATUS_OK, { "Content-Type" => Constants::CONTENT_TYPE_JSON }, [response_body]]
72
+ end
73
+
74
+ def handle_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)]]
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "erb"
4
+
5
+ module Docyard
6
+ class Renderer
7
+ LAYOUTS_PATH = File.join(__dir__, "templates", "layouts")
8
+ ERRORS_PATH = File.join(__dir__, "templates", "errors")
9
+
10
+ attr_reader :layout_path
11
+
12
+ def initialize(layout: "default")
13
+ @layout_path = File.join(LAYOUTS_PATH, "#{layout}.html.erb")
14
+ end
15
+
16
+ def render_file(file_path, sidebar_html: "")
17
+ markdown_content = File.read(file_path)
18
+ markdown = Markdown.new(markdown_content)
19
+
20
+ html_content = strip_md_from_links(markdown.html)
21
+
22
+ render(
23
+ content: html_content,
24
+ page_title: markdown.title || "Documentation",
25
+ sidebar_html: sidebar_html
26
+ )
27
+ end
28
+
29
+ def render(content:, page_title: "Documentation", sidebar_html: "")
30
+ template = File.read(layout_path)
31
+
32
+ @content = content
33
+ @page_title = page_title
34
+ @sidebar_html = sidebar_html
35
+
36
+ ERB.new(template).result(binding)
37
+ end
38
+
39
+ def render_not_found
40
+ render_error_template(404)
41
+ end
42
+
43
+ def render_server_error(error)
44
+ @error_message = error.message
45
+ @backtrace = error.backtrace.join("\n")
46
+ render_error_template(500)
47
+ end
48
+
49
+ def render_error_template(status)
50
+ error_template_path = File.join(ERRORS_PATH, "#{status}.html.erb")
51
+ template = File.read(error_template_path)
52
+ ERB.new(template).result(binding)
53
+ end
54
+
55
+ private
56
+
57
+ def strip_md_from_links(html)
58
+ html.gsub(/href="([^"]+)\.md"/, 'href="\1"')
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docyard
4
+ class Router
5
+ attr_reader :docs_path
6
+
7
+ def initialize(docs_path:)
8
+ @docs_path = docs_path
9
+ end
10
+
11
+ def resolve(request_path)
12
+ clean_path = sanitize_path(request_path)
13
+
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
+
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
+
20
+ Routing::ResolutionResult.not_found
21
+ end
22
+
23
+ private
24
+
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)
29
+ end
30
+ end
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,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "webrick"
4
+ require "stringio"
5
+ require_relative "file_watcher"
6
+ require_relative "rack_application"
7
+
8
+ module Docyard
9
+ class Server
10
+ DEFAULT_PORT = 4200
11
+ DEFAULT_HOST = "localhost"
12
+
13
+ attr_reader :port, :host, :docs_path
14
+
15
+ def initialize(port: DEFAULT_PORT, host: DEFAULT_HOST, docs_path: "docs")
16
+ @port = port
17
+ @host = host
18
+ @docs_path = docs_path
19
+ @file_watcher = FileWatcher.new(File.expand_path(docs_path))
20
+ @app = RackApplication.new(
21
+ docs_path: File.expand_path(docs_path),
22
+ file_watcher: @file_watcher
23
+ )
24
+ end
25
+
26
+ def start
27
+ validate_docs_directory!
28
+ print_server_info
29
+ @file_watcher.start
30
+
31
+ http_server.mount_proc("/") { |req, res| handle_request(req, res) }
32
+ trap("INT") { shutdown_server }
33
+
34
+ http_server.start
35
+ @file_watcher.stop
36
+ end
37
+
38
+ private
39
+
40
+ def validate_docs_directory!
41
+ return if File.directory?(docs_path)
42
+
43
+ abort "Error: #{docs_path}/ directory not found.\n" \
44
+ "Run `docyard init` first to create the docs structure."
45
+ end
46
+
47
+ def print_server_info
48
+ puts "Starting Docyard server..."
49
+ puts "=> Serving docs from: #{docs_path}/"
50
+ puts "=> Running at: http://#{host}:#{port}"
51
+ puts "=> Press Ctrl+C to stop\n"
52
+ end
53
+
54
+ def shutdown_server
55
+ puts "\nShutting down server..."
56
+ http_server.shutdown
57
+ end
58
+
59
+ def http_server
60
+ @http_server ||= WEBrick::HTTPServer.new(
61
+ Port: port,
62
+ BindAddress: host,
63
+ AccessLog: [],
64
+ Logger: WEBrick::Log.new(File::NULL)
65
+ )
66
+ end
67
+
68
+ def handle_request(req, res)
69
+ env = build_rack_env(req)
70
+ status, headers, body = @app.call(env)
71
+
72
+ res.status = status
73
+ headers.each { |key, value| res[key] = value }
74
+ body.each { |chunk| res.body << chunk }
75
+ end
76
+
77
+ def build_rack_env(req)
78
+ {
79
+ "REQUEST_METHOD" => req.request_method,
80
+ "PATH_INFO" => req.path,
81
+ "QUERY_STRING" => req.query_string || "",
82
+ "SERVER_NAME" => req.host,
83
+ "SERVER_PORT" => req.port.to_s,
84
+ "rack.version" => Rack::VERSION,
85
+ "rack.url_scheme" => "http",
86
+ "rack.input" => StringIO.new,
87
+ "rack.errors" => $stderr
88
+ }
89
+ end
90
+ end
91
+ 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