docyard 0.3.0 → 0.4.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/CHANGELOG.md +24 -1
  3. data/README.md +55 -33
  4. data/lib/docyard/build/asset_bundler.rb +139 -0
  5. data/lib/docyard/build/file_copier.rb +105 -0
  6. data/lib/docyard/build/sitemap_generator.rb +57 -0
  7. data/lib/docyard/build/static_generator.rb +141 -0
  8. data/lib/docyard/builder.rb +104 -0
  9. data/lib/docyard/cli.rb +19 -0
  10. data/lib/docyard/components/table_wrapper_processor.rb +18 -0
  11. data/lib/docyard/config.rb +4 -2
  12. data/lib/docyard/icons/phosphor.rb +1 -0
  13. data/lib/docyard/initializer.rb +80 -14
  14. data/lib/docyard/markdown.rb +13 -0
  15. data/lib/docyard/preview_server.rb +72 -0
  16. data/lib/docyard/rack_application.rb +1 -1
  17. data/lib/docyard/renderer.rb +17 -3
  18. data/lib/docyard/sidebar/config_parser.rb +180 -0
  19. data/lib/docyard/sidebar/item.rb +58 -0
  20. data/lib/docyard/sidebar/renderer.rb +33 -6
  21. data/lib/docyard/sidebar_builder.rb +45 -1
  22. data/lib/docyard/templates/assets/css/components/callout.css +1 -1
  23. data/lib/docyard/templates/assets/css/components/code-block.css +2 -2
  24. data/lib/docyard/templates/assets/css/components/navigation.css +65 -7
  25. data/lib/docyard/templates/assets/css/components/tabs.css +3 -2
  26. data/lib/docyard/templates/assets/css/components/theme-toggle.css +8 -0
  27. data/lib/docyard/templates/assets/css/markdown.css +20 -11
  28. data/lib/docyard/templates/assets/js/components/navigation.js +221 -0
  29. data/lib/docyard/templates/assets/js/theme.js +2 -185
  30. data/lib/docyard/templates/config/docyard.yml.erb +32 -10
  31. data/lib/docyard/templates/layouts/default.html.erb +1 -1
  32. data/lib/docyard/templates/markdown/getting-started/installation.md.erb +46 -12
  33. data/lib/docyard/templates/markdown/guides/configuration.md.erb +202 -0
  34. data/lib/docyard/templates/markdown/guides/markdown-features.md.erb +247 -0
  35. data/lib/docyard/templates/markdown/index.md.erb +55 -59
  36. data/lib/docyard/templates/partials/_nav_group.html.erb +10 -4
  37. data/lib/docyard/templates/partials/_nav_leaf.html.erb +9 -1
  38. data/lib/docyard/version.rb +1 -1
  39. data/lib/docyard.rb +8 -0
  40. metadata +55 -10
  41. data/lib/docyard/templates/markdown/components/callouts.md.erb +0 -204
  42. data/lib/docyard/templates/markdown/components/icons.md.erb +0 -125
  43. data/lib/docyard/templates/markdown/components/tabs.md.erb +0 -686
  44. data/lib/docyard/templates/markdown/configuration.md.erb +0 -202
  45. data/lib/docyard/templates/markdown/core-concepts/file-structure.md.erb +0 -61
  46. data/lib/docyard/templates/markdown/core-concepts/markdown.md.erb +0 -90
  47. data/lib/docyard/templates/markdown/getting-started/introduction.md.erb +0 -30
  48. data/lib/docyard/templates/markdown/getting-started/quick-start.md.erb +0 -56
  49. data/lib/docyard/templates/partials/_icons.html.erb +0 -11
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "tty-progressbar"
5
+
6
+ module Docyard
7
+ class Builder
8
+ attr_reader :config, :clean, :verbose, :start_time
9
+
10
+ def initialize(clean: true, verbose: false)
11
+ @config = Config.new
12
+ @clean = clean
13
+ @verbose = verbose
14
+ @start_time = Time.now
15
+ end
16
+
17
+ def build
18
+ prepare_output_directory
19
+ log "Building static site..."
20
+
21
+ pages_built = generate_static_pages
22
+ bundles_created = bundle_assets
23
+ assets_copied = copy_static_files
24
+ generate_seo_files
25
+
26
+ display_summary(pages_built, bundles_created, assets_copied)
27
+ true
28
+ rescue StandardError => e
29
+ error "Build failed: #{e.message}"
30
+ error e.backtrace.first if verbose
31
+ false
32
+ end
33
+
34
+ private
35
+
36
+ def prepare_output_directory
37
+ output_dir = config.build.output_dir
38
+
39
+ if clean && Dir.exist?(output_dir)
40
+ log "[✓] Cleaning #{output_dir}/ directory"
41
+ FileUtils.rm_rf(output_dir)
42
+ end
43
+
44
+ FileUtils.mkdir_p(output_dir)
45
+ end
46
+
47
+ def generate_static_pages
48
+ require_relative "build/static_generator"
49
+ generator = Build::StaticGenerator.new(config, verbose: verbose)
50
+ generator.generate
51
+ end
52
+
53
+ def bundle_assets
54
+ require_relative "build/asset_bundler"
55
+ bundler = Build::AssetBundler.new(config, verbose: verbose)
56
+ bundler.bundle
57
+ end
58
+
59
+ def copy_static_files
60
+ require_relative "build/file_copier"
61
+ copier = Build::FileCopier.new(config, verbose: verbose)
62
+ copier.copy
63
+ end
64
+
65
+ def generate_seo_files
66
+ require_relative "build/sitemap_generator"
67
+ sitemap_gen = Build::SitemapGenerator.new(config)
68
+ sitemap_gen.generate
69
+
70
+ File.write(File.join(config.build.output_dir, "robots.txt"), robots_txt_content)
71
+ log "[✓] Generated robots.txt"
72
+ end
73
+
74
+ def robots_txt_content
75
+ base_url = config.build.base_url
76
+ base_url = "#{base_url}/" unless base_url.end_with?("/")
77
+
78
+ <<~ROBOTS
79
+ User-agent: *
80
+ Allow: /
81
+
82
+ Sitemap: #{base_url}sitemap.xml
83
+ ROBOTS
84
+ end
85
+
86
+ def display_summary(pages, bundles, assets)
87
+ elapsed = Time.now - start_time
88
+
89
+ puts "\n#{'=' * 50}"
90
+ puts "Build complete in #{format('%.2f', elapsed)}s"
91
+ puts "Output: #{config.build.output_dir}/"
92
+ puts "#{pages} pages, #{bundles} bundles, #{assets} static files"
93
+ puts "=" * 50
94
+ end
95
+
96
+ def log(message)
97
+ puts message
98
+ end
99
+
100
+ def error(message)
101
+ warn "[ERROR] #{message}"
102
+ end
103
+ end
104
+ end
data/lib/docyard/cli.rb CHANGED
@@ -19,6 +19,25 @@ module Docyard
19
19
  exit(1) unless initializer.run
20
20
  end
21
21
 
22
+ desc "build", "Build static site for production"
23
+ method_option :clean, type: :boolean, default: true, desc: "Clean output directory before building"
24
+ method_option :verbose, type: :boolean, default: false, aliases: "-v", desc: "Show verbose output"
25
+ def build
26
+ require_relative "builder"
27
+ builder = Docyard::Builder.new(
28
+ clean: options[:clean],
29
+ verbose: options[:verbose]
30
+ )
31
+ exit(1) unless builder.build
32
+ end
33
+
34
+ desc "preview", "Preview the built site locally"
35
+ method_option :port, type: :numeric, default: 4000, aliases: "-p", desc: "Port to run preview server on"
36
+ def preview
37
+ require_relative "preview_server"
38
+ Docyard::PreviewServer.new(port: options[:port]).start
39
+ end
40
+
22
41
  desc "serve", "Start the development server"
23
42
  method_option :port, type: :numeric, default: 4200, aliases: "-p", desc: "Port to run the server on"
24
43
  method_option :host, type: :string, default: "localhost", aliases: "-h", desc: "Host to bind the server to"
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docyard
4
+ module Components
5
+ class TableWrapperProcessor < BaseProcessor
6
+ self.priority = 100
7
+
8
+ def postprocess(html)
9
+ wrapped = html.gsub(/<table([^>]*)>/) do
10
+ attributes = Regexp.last_match(1)
11
+ "<div class=\"table-wrapper\"><table#{attributes}>"
12
+ end
13
+
14
+ wrapped.gsub("</table>", "</table></div>")
15
+ end
16
+ end
17
+ end
18
+ end
@@ -25,7 +25,9 @@ module Docyard
25
25
  "base_url" => "/",
26
26
  "clean" => true
27
27
  },
28
- "sidebar" => nil
28
+ "sidebar" => {
29
+ "items" => []
30
+ }
29
31
  }.freeze
30
32
 
31
33
  attr_reader :data, :file_path
@@ -58,7 +60,7 @@ module Docyard
58
60
  end
59
61
 
60
62
  def sidebar
61
- data["sidebar"]
63
+ @sidebar ||= ConfigSection.new(data["sidebar"])
62
64
  end
63
65
 
64
66
  private
@@ -27,6 +27,7 @@ module Docyard
27
27
  "sun" => '<path d="M120,40V16a8,8,0,0,1,16,0V40a8,8,0,0,1-16,0Zm72,88a64,64,0,1,1-64-64A64.07,64.07,0,0,1,192,128Zm-16,0a48,48,0,1,0-48,48A48.05,48.05,0,0,0,176,128ZM58.34,69.66A8,8,0,0,0,69.66,58.34l-16-16A8,8,0,0,0,42.34,53.66Zm0,116.68-16,16a8,8,0,0,0,11.32,11.32l16-16a8,8,0,0,0-11.32-11.32ZM192,72a8,8,0,0,0,5.66-2.34l16-16a8,8,0,0,0-11.32-11.32l-16,16A8,8,0,0,0,192,72Zm5.66,114.34a8,8,0,0,0-11.32,11.32l16,16a8,8,0,0,0,11.32-11.32ZM48,128a8,8,0,0,0-8-8H16a8,8,0,0,0,0,16H40A8,8,0,0,0,48,128Zm80,80a8,8,0,0,0-8,8v24a8,8,0,0,0,16,0V216A8,8,0,0,0,128,208Zm112-88H216a8,8,0,0,0,0,16h24a8,8,0,0,0,0-16Z"/>',
28
28
  "link-external" => '<path d="M224,104a8,8,0,0,1-16,0V59.32l-66.33,66.34a8,8,0,0,1-11.32-11.32L196.68,48H152a8,8,0,0,1,0-16h64a8,8,0,0,1,8,8Zm-40,24a8,8,0,0,0-8,8v72H48V80h72a8,8,0,0,0,0-16H48A16,16,0,0,0,32,80V208a16,16,0,0,0,16,16H176a16,16,0,0,0,16-16V136A8,8,0,0,0,184,128Z"/>',
29
29
  "copy" => '<path d="M216,32H88a8,8,0,0,0-8,8V80H40a8,8,0,0,0-8,8V216a8,8,0,0,0,8,8H168a8,8,0,0,0,8-8V176h40a8,8,0,0,0,8-8V40A8,8,0,0,0,216,32ZM160,208H48V96H160Zm48-48H176V88a8,8,0,0,0-8-8H96V48H208Z"/>',
30
+ "caret-right" => '<path d="M181.66,133.66l-80,80a8,8,0,0,1-11.32-11.32L164.69,128,90.34,53.66a8,8,0,0,1,11.32-11.32l80,80A8,8,0,0,1,181.66,133.66Z"/>',
30
31
  "github" => '<path d="M208.31,75.68A59.78,59.78,0,0,0,202.93,28,8,8,0,0,0,196,24a59.75,59.75,0,0,0-48,24H124A59.75,59.75,0,0,0,76,24a8,8,0,0,0-6.93,4,59.78,59.78,0,0,0-5.38,47.68A58.14,58.14,0,0,0,56,104v8a56.06,56.06,0,0,0,48.44,55.47A39.8,39.8,0,0,0,96,192v8H72a24,24,0,0,1-24-24A40,40,0,0,0,8,136a8,8,0,0,0,0,16,24,24,0,0,1,24,24,40,40,0,0,0,40,40H96v16a8,8,0,0,0,16,0V192a24,24,0,0,1,48,0v40a8,8,0,0,0,16,0V192a39.8,39.8,0,0,0-8.44-24.53A56.06,56.06,0,0,0,216,112v-8A58.14,58.14,0,0,0,208.31,75.68ZM200,112a40,40,0,0,1-40,40H112a40,40,0,0,1-40-40v-8a41.74,41.74,0,0,1,6.9-22.48,8,8,0,0,0,1.1-7.69,43.81,43.81,0,0,1,.79-33.58,43.88,43.88,0,0,1,32.32,20.06,8,8,0,0,0,6.71,3.69h32.35a8,8,0,0,0,6.74-3.69,43.87,43.87,0,0,1,32.32-20.06,43.81,43.81,0,0,1,.77,33.58,8.09,8.09,0,0,0,1,7.65,41.76,41.76,0,0,1,7,22.52Z"/>',
31
32
  "question" => '<path d="M140,180a12,12,0,1,1-12-12A12,12,0,0,1,140,180ZM128,72c-22.06,0-40,16.15-40,36v4a8,8,0,0,0,16,0v-4c0-11,10.77-20,24-20s24,9,24,20-10.77,20-24,20a8,8,0,0,0-8,8v8a8,8,0,0,0,16,0v-.72c18.24-3.35,32-17.9,32-35.28C168,88.15,150.06,72,128,72Zm104,56A104,104,0,1,1,128,24,104.11,104.11,0,0,1,232,128Zm-16,0a88,88,0,1,0-88,88A88.1,88.1,0,0,0,216,128Z"/>',
32
33
  "lightbulb" => '<path d="M176,232a8,8,0,0,1-8,8H88a8,8,0,0,1,0-16h80A8,8,0,0,1,176,232Zm40-128a87.55,87.55,0,0,1-33.64,69.21A16.24,16.24,0,0,0,176,186v6a16,16,0,0,1-16,16H96a16,16,0,0,1-16-16v-6a16,16,0,0,0-6.23-12.66A87.59,87.59,0,0,1,40,104.49C39.74,56.83,78.26,17.14,125.88,16A88,88,0,0,1,216,104Zm-16,0a72,72,0,0,0-73.74-72c-39,.92-70.47,33.39-70.26,72.39a71.65,71.65,0,0,0,27.64,56.3A32,32,0,0,1,96,186v6h64v-6a32.15,32.15,0,0,1,12.47-25.35A71.65,71.65,0,0,0,200,104Zm-16.11-9.34a57.6,57.6,0,0,0-46.56-46.55,8,8,0,0,0-2.66,15.78c16.57,2.79,30.63,16.85,33.44,33.45A8,8,0,0,0,176,104a9,9,0,0,0,1.35-.11A8,8,0,0,0,183.89,94.66Z"/>',
@@ -10,14 +10,9 @@ module Docyard
10
10
 
11
11
  TEMPLATES = {
12
12
  "index.md" => "index.md.erb",
13
- "getting-started/introduction.md" => "getting-started/introduction.md.erb",
14
13
  "getting-started/installation.md" => "getting-started/installation.md.erb",
15
- "getting-started/quick-start.md" => "getting-started/quick-start.md.erb",
16
- "core-concepts/file-structure.md" => "core-concepts/file-structure.md.erb",
17
- "core-concepts/markdown.md" => "core-concepts/markdown.md.erb",
18
- "components/callouts.md" => "components/callouts.md.erb",
19
- "components/icons.md" => "components/icons.md.erb",
20
- "components/tabs.md" => "components/tabs.md.erb"
14
+ "guides/markdown-features.md" => "guides/markdown-features.md.erb",
15
+ "guides/configuration.md" => "guides/configuration.md.erb"
21
16
  }.freeze
22
17
 
23
18
  def initialize(path = ".")
@@ -79,16 +74,87 @@ module Docyard
79
74
  end
80
75
 
81
76
  def print_success
82
- puts "Docyard initialized successfully!"
77
+ print_banner
78
+ print_created_files
79
+ print_next_steps
80
+ end
81
+
82
+ def print_banner
83
+ puts ""
84
+ puts "┌─────────────────────────────────────────────────────────────┐"
85
+ puts "│ ✓ Docyard initialized successfully │"
86
+ puts "└─────────────────────────────────────────────────────────────┘"
87
+ puts ""
88
+ end
89
+
90
+ def print_created_files
91
+ puts "Created files:"
83
92
  puts ""
84
- puts "Created:"
85
- TEMPLATES.each_key { |file| puts " #{DOCS_DIR}/#{file}" }
86
- puts " docyard.yml (configuration - optional)"
93
+ print_file_tree
87
94
  puts ""
95
+ end
96
+
97
+ def print_next_steps
88
98
  puts "Next steps:"
89
- puts " 1. Edit your markdown files in #{DOCS_DIR}/"
90
- puts " 2. Customize docyard.yml (optional)"
91
- puts " 3. Run 'docyard serve' to preview your documentation locally"
99
+ puts ""
100
+ puts " Start development server:"
101
+ puts " docyard serve"
102
+ puts " → http://localhost:4200"
103
+ puts ""
104
+ puts " Build for production:"
105
+ puts " docyard build"
106
+ puts ""
107
+ puts " Preview production build:"
108
+ puts " docyard preview"
109
+ puts ""
110
+ end
111
+
112
+ def print_file_tree
113
+ puts " ├── docs/"
114
+
115
+ grouped_files = TEMPLATES.keys.group_by { |file| File.dirname(file) }
116
+ sorted_dirs = grouped_files.keys.sort
117
+
118
+ sorted_dirs.each_with_index do |dir, dir_idx|
119
+ print_directory_group(dir, grouped_files[dir], dir_idx == sorted_dirs.length - 1)
120
+ end
121
+
122
+ puts " └── docyard.yml"
123
+ end
124
+
125
+ def print_directory_group(dir, files, is_last_dir)
126
+ sorted_files = files.sort
127
+
128
+ if dir == "."
129
+ print_root_files(sorted_files, is_last_dir)
130
+ else
131
+ print_subdirectory(dir, sorted_files, is_last_dir)
132
+ end
133
+ end
134
+
135
+ def print_root_files(files, is_last_dir)
136
+ files.each_with_index do |file, idx|
137
+ is_last = idx == files.length - 1 && is_last_dir
138
+ prefix = is_last ? " │ └──" : " │ ├──"
139
+ puts "#{prefix} #{file}"
140
+ end
141
+ end
142
+
143
+ def print_subdirectory(dir, files, is_last_dir)
144
+ dir_prefix = is_last_dir ? " │ └──" : " │ ├──"
145
+ puts "#{dir_prefix} #{dir}/"
146
+
147
+ files.each_with_index do |file, idx|
148
+ print_subdirectory_file(file, idx, files.length, is_last_dir)
149
+ end
150
+ end
151
+
152
+ def print_subdirectory_file(file, idx, total, is_last_dir)
153
+ is_last_file = idx == total - 1
154
+ file_prefix = is_last_dir ? " │ " : " │ │ "
155
+ file_prefix += is_last_file ? "└──" : "├──"
156
+ basename = File.basename(file)
157
+ puts "#{file_prefix} #{basename}"
92
158
  end
93
159
  end
94
160
  end
@@ -9,6 +9,7 @@ require_relative "components/callout_processor"
9
9
  require_relative "components/tabs_processor"
10
10
  require_relative "components/icon_processor"
11
11
  require_relative "components/code_block_processor"
12
+ require_relative "components/table_wrapper_processor"
12
13
 
13
14
  module Docyard
14
15
  class Markdown
@@ -40,6 +41,18 @@ module Docyard
40
41
  frontmatter["description"]
41
42
  end
42
43
 
44
+ def sidebar_icon
45
+ frontmatter.dig("sidebar", "icon")
46
+ end
47
+
48
+ def sidebar_text
49
+ frontmatter.dig("sidebar", "text")
50
+ end
51
+
52
+ def sidebar_collapsed
53
+ frontmatter.dig("sidebar", "collapsed")
54
+ end
55
+
43
56
  private
44
57
 
45
58
  def parse_frontmatter
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "webrick"
4
+ require_relative "config"
5
+
6
+ module Docyard
7
+ class PreviewServer
8
+ DEFAULT_PORT = 4000
9
+
10
+ attr_reader :port, :output_dir
11
+
12
+ def initialize(port: DEFAULT_PORT)
13
+ @port = port
14
+ @config = Config.load
15
+ @output_dir = File.expand_path(@config.build.output_dir)
16
+ end
17
+
18
+ def start
19
+ validate_output_directory!
20
+ print_server_info
21
+
22
+ server = create_server
23
+ trap("INT") { shutdown_server(server) }
24
+
25
+ server.start
26
+ end
27
+
28
+ private
29
+
30
+ def validate_output_directory!
31
+ return if File.directory?(output_dir)
32
+
33
+ abort "Error: #{output_dir}/ directory not found.\n" \
34
+ "Run `docyard build` first to build the site."
35
+ end
36
+
37
+ def print_server_info
38
+ puts "Preview server starting..."
39
+ puts "=> Serving from: #{output_dir}/"
40
+ puts "=> Running at: http://localhost:#{port}"
41
+ puts "=> Press Ctrl+C to stop\n"
42
+ end
43
+
44
+ def create_server
45
+ WEBrick::HTTPServer.new(
46
+ Port: port,
47
+ DocumentRoot: output_dir,
48
+ AccessLog: [],
49
+ Logger: WEBrick::Log.new(File::NULL),
50
+ MimeTypes: mime_types
51
+ )
52
+ end
53
+
54
+ def mime_types
55
+ WEBrick::HTTPUtils::DefaultMimeTypes.merge(
56
+ {
57
+ "css" => "text/css",
58
+ "js" => "application/javascript",
59
+ "json" => "application/json",
60
+ "svg" => "image/svg+xml",
61
+ "woff" => "font/woff",
62
+ "woff2" => "font/woff2"
63
+ }
64
+ )
65
+ end
66
+
67
+ def shutdown_server(server)
68
+ puts "\nShutting down preview server..."
69
+ server.shutdown
70
+ end
71
+ end
72
+ end
@@ -12,7 +12,7 @@ module Docyard
12
12
  @file_watcher = file_watcher
13
13
  @config = config
14
14
  @router = Router.new(docs_path: docs_path)
15
- @renderer = Renderer.new
15
+ @renderer = Renderer.new(base_url: config&.build&.base_url || "/")
16
16
  @asset_handler = AssetHandler.new
17
17
  end
18
18
 
@@ -9,10 +9,11 @@ module Docyard
9
9
  ERRORS_PATH = File.join(__dir__, "templates", "errors")
10
10
  PARTIALS_PATH = File.join(__dir__, "templates", "partials")
11
11
 
12
- attr_reader :layout_path
12
+ attr_reader :layout_path, :base_url
13
13
 
14
- def initialize(layout: "default")
14
+ def initialize(layout: "default", base_url: "/")
15
15
  @layout_path = File.join(LAYOUTS_PATH, "#{layout}.html.erb")
16
+ @base_url = normalize_base_url(base_url)
16
17
  end
17
18
 
18
19
  def render_file(file_path, sidebar_html: "", branding: {})
@@ -66,11 +67,24 @@ module Docyard
66
67
  def asset_path(path)
67
68
  return path if path.nil? || path.start_with?("http://", "https://")
68
69
 
69
- "/#{path}"
70
+ "#{base_url}#{path}"
71
+ end
72
+
73
+ def link_path(path)
74
+ return path if path.nil? || path.start_with?("http://", "https://")
75
+
76
+ "#{base_url.chomp('/')}#{path}"
70
77
  end
71
78
 
72
79
  private
73
80
 
81
+ def normalize_base_url(url)
82
+ return "/" if url.nil? || url.empty?
83
+
84
+ url = "/#{url}" unless url.start_with?("/")
85
+ url.end_with?("/") ? url : "#{url}/"
86
+ end
87
+
74
88
  def assign_content_variables(content, page_title, sidebar_html)
75
89
  @content = content
76
90
  @page_title = page_title
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "item"
4
+ require_relative "title_extractor"
5
+
6
+ module Docyard
7
+ module Sidebar
8
+ class ConfigParser
9
+ attr_reader :config_items, :docs_path, :current_path, :title_extractor
10
+
11
+ def initialize(config_items, docs_path:, current_path: "/", title_extractor: TitleExtractor.new)
12
+ @config_items = config_items || []
13
+ @docs_path = docs_path
14
+ @current_path = Utils::PathResolver.normalize(current_path)
15
+ @title_extractor = title_extractor
16
+ end
17
+
18
+ def parse
19
+ parse_items(config_items)
20
+ end
21
+
22
+ private
23
+
24
+ def parse_items(items, base_path = "")
25
+ items.map do |item_config|
26
+ parse_item(item_config, base_path)
27
+ end.compact
28
+ end
29
+
30
+ def parse_item(item_config, base_path)
31
+ case item_config
32
+ when String
33
+ resolve_file_item(item_config, base_path)
34
+ when Hash
35
+ parse_hash_item(item_config, base_path)
36
+ end
37
+ end
38
+
39
+ def parse_hash_item(item_config, base_path)
40
+ return parse_link_item(item_config) if link_item?(item_config)
41
+ return parse_nested_item(item_config, base_path) if nested_item?(item_config)
42
+ return resolve_file_item(item_config.keys.first, base_path, {}) if nil_value_item?(item_config)
43
+
44
+ slug = item_config.keys.first
45
+ options = item_config.values.first || {}
46
+ resolve_file_item(slug, base_path, options)
47
+ end
48
+
49
+ def link_item?(config)
50
+ config.key?("link") || config.key?(:link)
51
+ end
52
+
53
+ def nested_item?(config)
54
+ config.size == 1 && config.values.first.is_a?(Hash)
55
+ end
56
+
57
+ def nil_value_item?(config)
58
+ config.size == 1 && config.values.first.nil?
59
+ end
60
+
61
+ def parse_link_item(config)
62
+ link = config["link"] || config[:link]
63
+ text = config["text"] || config[:text]
64
+ icon = config["icon"] || config[:icon]
65
+ target = config["target"] || config[:target] || "_blank"
66
+
67
+ Item.new(
68
+ text: text,
69
+ link: link,
70
+ path: link,
71
+ icon: icon,
72
+ target: target,
73
+ type: :external
74
+ )
75
+ end
76
+
77
+ def parse_nested_item(item_config, base_path)
78
+ slug = item_config.keys.first.to_s
79
+ options = item_config.values.first || {}
80
+ nested_items = extract_nested_items(options)
81
+
82
+ dir_path = File.join(docs_path, base_path, slug)
83
+
84
+ if File.directory?(dir_path)
85
+ build_directory_item(slug, options, nested_items, base_path)
86
+ elsif nested_items.any?
87
+ build_file_with_children_item(slug, options, nested_items, base_path)
88
+ else
89
+ resolve_file_item(slug, base_path, options)
90
+ end
91
+ end
92
+
93
+ def extract_nested_items(options)
94
+ options["items"] || options[:items] || []
95
+ end
96
+
97
+ def extract_common_options(options)
98
+ {
99
+ text: options["text"] || options[:text],
100
+ icon: options["icon"] || options[:icon],
101
+ collapsed: options["collapsed"] || options[:collapsed] || false
102
+ }
103
+ end
104
+
105
+ def build_directory_item(slug, options, nested_items, base_path)
106
+ common_opts = extract_common_options(options)
107
+ new_base_path = File.join(base_path, slug)
108
+ parsed_items = parse_items(nested_items, new_base_path)
109
+
110
+ Item.new(
111
+ slug: slug,
112
+ text: common_opts[:text] || Utils::TextFormatter.titleize(slug),
113
+ icon: common_opts[:icon],
114
+ collapsed: common_opts[:collapsed],
115
+ items: parsed_items,
116
+ type: :directory
117
+ )
118
+ end
119
+
120
+ def build_file_with_children_item(slug, options, nested_items, base_path)
121
+ common_opts = extract_common_options(options)
122
+ file_path = File.join(docs_path, base_path, "#{slug}.md")
123
+ url_path = Utils::PathResolver.to_url(File.join(base_path, slug))
124
+ resolved_text = common_opts[:text] || extract_file_title(file_path, slug)
125
+
126
+ Item.new(
127
+ slug: slug,
128
+ text: resolved_text,
129
+ path: url_path,
130
+ icon: common_opts[:icon],
131
+ collapsed: common_opts[:collapsed],
132
+ items: parse_items(nested_items, base_path),
133
+ active: current_path == url_path,
134
+ type: :file
135
+ )
136
+ end
137
+
138
+ def extract_file_title(file_path, slug)
139
+ File.exist?(file_path) ? title_extractor.extract(file_path) : Utils::TextFormatter.titleize(slug)
140
+ end
141
+
142
+ def resolve_file_item(slug, base_path, options = {})
143
+ slug_str = slug.to_s
144
+ options ||= {}
145
+
146
+ file_path = File.join(docs_path, base_path, "#{slug_str}.md")
147
+ url_path = Utils::PathResolver.to_url(File.join(base_path, slug_str))
148
+
149
+ frontmatter = extract_frontmatter_metadata(file_path)
150
+ text = resolve_item_text(slug_str, file_path, options, frontmatter[:text])
151
+ icon = resolve_item_icon(options, frontmatter[:icon])
152
+ final_path = options["link"] || options[:link] || url_path
153
+
154
+ Item.new(
155
+ slug: slug_str, text: text, path: final_path, icon: icon,
156
+ active: current_path == final_path, type: :file
157
+ )
158
+ end
159
+
160
+ def extract_frontmatter_metadata(file_path)
161
+ return { text: nil, icon: nil } unless File.exist?(file_path)
162
+
163
+ markdown = Markdown.new(File.read(file_path))
164
+ {
165
+ text: markdown.sidebar_text || markdown.title,
166
+ icon: markdown.sidebar_icon
167
+ }
168
+ end
169
+
170
+ def resolve_item_text(slug, file_path, options, frontmatter_text)
171
+ text = options["text"] || options[:text] || frontmatter_text
172
+ text || extract_file_title(file_path, slug)
173
+ end
174
+
175
+ def resolve_item_icon(options, frontmatter_icon)
176
+ options["icon"] || options[:icon] || frontmatter_icon
177
+ end
178
+ end
179
+ end
180
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docyard
4
+ module Sidebar
5
+ class Item
6
+ attr_reader :slug, :text, :icon, :link, :target, :collapsed, :items, :path, :active, :type
7
+
8
+ def initialize(**options)
9
+ @slug = options[:slug]
10
+ @text = options[:text]
11
+ @icon = options[:icon]
12
+ @link = options[:link]
13
+ @target = options[:target] || "_self"
14
+ @collapsed = options[:collapsed] || false
15
+ @items = options[:items] || []
16
+ @path = options[:path] || options[:link]
17
+ @active = options[:active] || false
18
+ @type = options[:type] || :file
19
+ end
20
+
21
+ def external?
22
+ return false if path.nil?
23
+
24
+ path.start_with?("http://", "https://")
25
+ end
26
+
27
+ def children?
28
+ items.any?
29
+ end
30
+
31
+ def title
32
+ text
33
+ end
34
+
35
+ def children
36
+ items
37
+ end
38
+
39
+ def collapsible?
40
+ children?
41
+ end
42
+
43
+ def to_h
44
+ {
45
+ title: title,
46
+ path: path,
47
+ icon: icon,
48
+ active: active,
49
+ type: type,
50
+ collapsed: collapsed,
51
+ collapsible: collapsible?,
52
+ target: target,
53
+ children: children.map(&:to_h)
54
+ }
55
+ end
56
+ end
57
+ end
58
+ end