docyard 0.6.0 → 0.7.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 (92) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +15 -1
  3. data/lib/docyard/build/static_generator.rb +2 -43
  4. data/lib/docyard/builder.rb +14 -4
  5. data/lib/docyard/cli.rb +6 -3
  6. data/lib/docyard/components/aliases.rb +29 -0
  7. data/lib/docyard/components/processors/callout_processor.rb +124 -0
  8. data/lib/docyard/components/processors/code_block_diff_preprocessor.rb +106 -0
  9. data/lib/docyard/components/processors/code_block_focus_preprocessor.rb +79 -0
  10. data/lib/docyard/components/processors/code_block_options_preprocessor.rb +78 -0
  11. data/lib/docyard/components/processors/code_block_processor.rb +175 -0
  12. data/lib/docyard/components/processors/code_snippet_import_preprocessor.rb +127 -0
  13. data/lib/docyard/components/processors/heading_anchor_processor.rb +39 -0
  14. data/lib/docyard/components/processors/icon_processor.rb +53 -0
  15. data/lib/docyard/components/processors/table_of_contents_processor.rb +68 -0
  16. data/lib/docyard/components/processors/table_wrapper_processor.rb +22 -0
  17. data/lib/docyard/components/processors/tabs_processor.rb +48 -0
  18. data/lib/docyard/components/support/code_block/feature_extractor.rb +117 -0
  19. data/lib/docyard/components/support/code_block/icon_detector.rb +44 -0
  20. data/lib/docyard/components/support/code_block/line_parser.rb +84 -0
  21. data/lib/docyard/components/support/code_block/line_wrapper.rb +50 -0
  22. data/lib/docyard/components/support/code_block/patterns.rb +55 -0
  23. data/lib/docyard/components/support/code_detector.rb +61 -0
  24. data/lib/docyard/components/support/tabs/icon_detector.rb +62 -0
  25. data/lib/docyard/components/support/tabs/parser.rb +195 -0
  26. data/lib/docyard/components/support/tabs/range_finder.rb +46 -0
  27. data/lib/docyard/config/branding_resolver.rb +74 -0
  28. data/lib/docyard/{constants.rb → config/constants.rb} +1 -0
  29. data/lib/docyard/config.rb +10 -1
  30. data/lib/docyard/{prev_next_builder.rb → navigation/prev_next_builder.rb} +2 -2
  31. data/lib/docyard/{sidebar → navigation/sidebar}/renderer.rb +3 -14
  32. data/lib/docyard/{sidebar → navigation/sidebar}/tree_builder.rb +9 -2
  33. data/lib/docyard/{sidebar_builder.rb → navigation/sidebar_builder.rb} +3 -15
  34. data/lib/docyard/{icons → rendering/icons}/phosphor.rb +4 -1
  35. data/lib/docyard/{markdown.rb → rendering/markdown.rb} +14 -13
  36. data/lib/docyard/{renderer.rb → rendering/renderer.rb} +20 -17
  37. data/lib/docyard/search/build_indexer.rb +74 -0
  38. data/lib/docyard/search/dev_indexer.rb +110 -0
  39. data/lib/docyard/search/pagefind_support.rb +31 -0
  40. data/lib/docyard/{asset_handler.rb → server/asset_handler.rb} +1 -1
  41. data/lib/docyard/{server.rb → server/dev_server.rb} +32 -9
  42. data/lib/docyard/{preview_server.rb → server/preview_server.rb} +1 -1
  43. data/lib/docyard/{rack_application.rb → server/rack_application.rb} +52 -49
  44. data/lib/docyard/server/resolution_result.rb +29 -0
  45. data/lib/docyard/{router.rb → server/router.rb} +4 -4
  46. data/lib/docyard/templates/assets/css/components/search.css +549 -0
  47. data/lib/docyard/templates/assets/css/layout.css +15 -1
  48. data/lib/docyard/templates/assets/js/components/search.js +685 -0
  49. data/lib/docyard/templates/layouts/default.html.erb +14 -2
  50. data/lib/docyard/templates/partials/_code_block.html.erb +1 -1
  51. data/lib/docyard/templates/partials/_heading_anchor.html.erb +1 -1
  52. data/lib/docyard/templates/partials/_prev_next.html.erb +1 -1
  53. data/lib/docyard/templates/partials/_search_modal.html.erb +45 -0
  54. data/lib/docyard/templates/partials/_search_trigger.html.erb +22 -0
  55. data/lib/docyard/utils/html_helpers.rb +14 -0
  56. data/lib/docyard/utils/path_resolver.rb +2 -1
  57. data/lib/docyard/utils/url_helpers.rb +20 -0
  58. data/lib/docyard/version.rb +1 -1
  59. data/lib/docyard.rb +22 -15
  60. metadata +57 -46
  61. data/lib/docyard/components/callout_processor.rb +0 -121
  62. data/lib/docyard/components/code_block_diff_preprocessor.rb +0 -104
  63. data/lib/docyard/components/code_block_feature_extractor.rb +0 -113
  64. data/lib/docyard/components/code_block_focus_preprocessor.rb +0 -77
  65. data/lib/docyard/components/code_block_icon_detector.rb +0 -40
  66. data/lib/docyard/components/code_block_line_wrapper.rb +0 -46
  67. data/lib/docyard/components/code_block_options_preprocessor.rb +0 -76
  68. data/lib/docyard/components/code_block_patterns.rb +0 -51
  69. data/lib/docyard/components/code_block_processor.rb +0 -176
  70. data/lib/docyard/components/code_detector.rb +0 -59
  71. data/lib/docyard/components/code_line_parser.rb +0 -80
  72. data/lib/docyard/components/code_snippet_import_preprocessor.rb +0 -125
  73. data/lib/docyard/components/heading_anchor_processor.rb +0 -34
  74. data/lib/docyard/components/icon_detector.rb +0 -57
  75. data/lib/docyard/components/icon_processor.rb +0 -51
  76. data/lib/docyard/components/table_of_contents_processor.rb +0 -64
  77. data/lib/docyard/components/table_wrapper_processor.rb +0 -18
  78. data/lib/docyard/components/tabs_parser.rb +0 -191
  79. data/lib/docyard/components/tabs_processor.rb +0 -44
  80. data/lib/docyard/components/tabs_range_finder.rb +0 -42
  81. data/lib/docyard/routing/resolution_result.rb +0 -31
  82. /data/lib/docyard/{sidebar → navigation/sidebar}/config_parser.rb +0 -0
  83. /data/lib/docyard/{sidebar → navigation/sidebar}/file_system_scanner.rb +0 -0
  84. /data/lib/docyard/{sidebar → navigation/sidebar}/item.rb +0 -0
  85. /data/lib/docyard/{sidebar → navigation/sidebar}/title_extractor.rb +0 -0
  86. /data/lib/docyard/{icons → rendering/icons}/LICENSE.phosphor +0 -0
  87. /data/lib/docyard/{icons → rendering/icons}/file_types.rb +0 -0
  88. /data/lib/docyard/{icons.rb → rendering/icons.rb} +0 -0
  89. /data/lib/docyard/{language_mapping.rb → rendering/language_mapping.rb} +0 -0
  90. /data/lib/docyard/{file_watcher.rb → server/file_watcher.rb} +0 -0
  91. /data/lib/docyard/{errors.rb → utils/errors.rb} +0 -0
  92. /data/lib/docyard/{logging.rb → utils/logging.rb} +0 -0
@@ -5,7 +5,9 @@ require "erb"
5
5
  module Docyard
6
6
  module Sidebar
7
7
  class Renderer
8
- PARTIALS_PATH = File.join(__dir__, "../templates/partials")
8
+ include Utils::UrlHelpers
9
+
10
+ PARTIALS_PATH = File.join(__dir__, "../../templates/partials")
9
11
 
10
12
  attr_reader :site_title, :base_url
11
13
 
@@ -39,19 +41,6 @@ module Docyard
39
41
  Icons.render(name.to_s.tr("_", "-"), weight) || ""
40
42
  end
41
43
 
42
- def link_path(path)
43
- return path if path.nil? || path.start_with?("http://", "https://")
44
-
45
- "#{base_url.chomp('/')}#{path}"
46
- end
47
-
48
- def normalize_base_url(url)
49
- return "/" if url.nil? || url.empty?
50
-
51
- url = "/#{url}" unless url.start_with?("/")
52
- url.end_with?("/") ? url : "#{url}/"
53
- end
54
-
55
44
  def render_tree_with_sections(items)
56
45
  filtered_items = items.reject { |item| item[:title]&.downcase == site_title.downcase }
57
46
  grouped_items = group_by_section(filtered_items)
@@ -29,6 +29,7 @@ module Docyard
29
29
 
30
30
  def transform_directory(item, relative_base)
31
31
  dir_path = File.join(relative_base, item[:name])
32
+ children = transform_items(item[:children], dir_path)
32
33
 
33
34
  {
34
35
  title: Utils::TextFormatter.titleize(item[:name]),
@@ -36,11 +37,17 @@ module Docyard
36
37
  active: false,
37
38
  type: :directory,
38
39
  collapsible: true,
39
- collapsed: false,
40
- children: transform_items(item[:children], dir_path)
40
+ collapsed: !active_child?(children),
41
+ children: children
41
42
  }
42
43
  end
43
44
 
45
+ def active_child?(children)
46
+ children.any? do |child|
47
+ child[:active] || active_child?(child[:children] || [])
48
+ end
49
+ end
50
+
44
51
  def transform_file(item, relative_base)
45
52
  file_path = File.join(relative_base, "#{item[:name]}#{Constants::MARKDOWN_EXTENSION}")
46
53
  full_file_path = File.join(docs_path, file_path)
@@ -50,11 +50,7 @@ module Docyard
50
50
  def config_sidebar_items
51
51
  return [] unless config
52
52
 
53
- if config.is_a?(Hash)
54
- config.dig("sidebar", "items") || config.dig(:sidebar, :items) || []
55
- else
56
- config.sidebar&.items || []
57
- end
53
+ config.sidebar&.items || []
58
54
  end
59
55
 
60
56
  def config_parser
@@ -84,19 +80,11 @@ module Docyard
84
80
  end
85
81
 
86
82
  def extract_base_url
87
- if config.is_a?(Hash)
88
- config.dig(:build, :base_url) || "/"
89
- else
90
- config&.build&.base_url || "/"
91
- end
83
+ config&.build&.base_url || "/"
92
84
  end
93
85
 
94
86
  def extract_site_title
95
- if config.is_a?(Hash)
96
- config[:site_title] || "Documentation"
97
- else
98
- config&.site&.title || "Documentation"
99
- end
87
+ config&.site&.title || "Documentation"
100
88
  end
101
89
  end
102
90
  end
@@ -36,7 +36,10 @@ module Docyard
36
36
  "siren" => '<path d="M120,16V8a8,8,0,0,1,16,0v8a8,8,0,0,1-16,0Zm80,32a8,8,0,0,0,5.66-2.34l8-8a8,8,0,0,0-11.32-11.32l-8,8A8,8,0,0,0,200,48ZM50.34,45.66A8,8,0,0,0,61.66,34.34l-8-8A8,8,0,0,0,42.34,37.66Zm87,26.45a8,8,0,1,0-2.64,15.78C153.67,91.08,168,108.32,168,128a8,8,0,0,0,16,0C184,100.6,163.93,76.57,137.32,72.11ZM232,176v24a16,16,0,0,1-16,16H40a16,16,0,0,1-16-16V176a16,16,0,0,1,16-16V128a88,88,0,0,1,88.67-88c48.15.36,87.33,40.29,87.33,89v31A16,16,0,0,1,232,176ZM56,160H200V129c0-40-32.05-72.71-71.45-73H128a72,72,0,0,0-72,72Zm160,40V176H40v24H216Z"/>',
37
37
  "file" => '<path d="M213.66,82.34l-56-56A8,8,0,0,0,152,24H56A16,16,0,0,0,40,40V216a16,16,0,0,0,16,16H200a16,16,0,0,0,16-16V88A8,8,0,0,0,213.66,82.34ZM160,51.31,188.69,80H160ZM200,216H56V40h88V88a8,8,0,0,0,8,8h48V216Z"/>',
38
38
  "terminal-window" => '<path d="M128,128a8,8,0,0,1-3,6.25l-40,32a8,8,0,1,1-10-12.5L107.19,128,75,102.25a8,8,0,1,1,10-12.5l40,32A8,8,0,0,1,128,128Zm48,24H136a8,8,0,0,0,0,16h40a8,8,0,0,0,0-16Zm56-96V200a16,16,0,0,1-16,16H40a16,16,0,0,1-16-16V56A16,16,0,0,1,40,40H216A16,16,0,0,1,232,56ZM216,200V56H40V200H216Z"/>',
39
- "list-dashes" => '<path d="M88,64a8,8,0,0,1,8-8H216a8,8,0,0,1,0,16H96A8,8,0,0,1,88,64Zm128,56H96a8,8,0,0,0,0,16H216a8,8,0,0,0,0-16Zm0,64H96a8,8,0,0,0,0,16H216a8,8,0,0,0,0-16ZM56,56H40a8,8,0,0,0,0,16H56a8,8,0,0,0,0-16Zm0,64H40a8,8,0,0,0,0,16H56a8,8,0,0,0,0-16Zm0,64H40a8,8,0,0,0,0,16H56a8,8,0,0,0,0-16Z"/>'
39
+ "list-dashes" => '<path d="M88,64a8,8,0,0,1,8-8H216a8,8,0,0,1,0,16H96A8,8,0,0,1,88,64Zm128,56H96a8,8,0,0,0,0,16H216a8,8,0,0,0,0-16Zm0,64H96a8,8,0,0,0,0,16H216a8,8,0,0,0,0-16ZM56,56H40a8,8,0,0,0,0,16H56a8,8,0,0,0,0-16Zm0,64H40a8,8,0,0,0,0,16H56a8,8,0,0,0,0-16Zm0,64H40a8,8,0,0,0,0,16H56a8,8,0,0,0,0-16Z"/>',
40
+ "magnifying-glass" => '<path d="M229.66,218.34l-50.07-50.06a88.11,88.11,0,1,0-11.31,11.31l50.06,50.07a8,8,0,0,0,11.32-11.32ZM40,112a72,72,0,1,1,72,72A72.08,72.08,0,0,1,40,112Z"/>',
41
+ "command" => '<path d="M180,144H160V112h20a36,36,0,1,0-36-36V96H112V76a36,36,0,1,0-36,36H96v32H76a36,36,0,1,0,36,36V160h32v20a36,36,0,1,0,36-36ZM160,76a20,20,0,1,1,20,20H160ZM56,76a20,20,0,0,1,40,0V96H76A20,20,0,0,1,56,76ZM96,180a20,20,0,1,1-20-20H96Zm16-68h32v32H112Zm68,88a20,20,0,0,1-20-20V160h20a20,20,0,0,1,0,40Z"/>',
42
+ "hash" => '<path d="M224,88H175.4l8.47-46.57a8,8,0,0,0-15.74-2.86l-9,49.43H111.4l8.47-46.57a8,8,0,0,0-15.74-2.86L95.14,88H48a8,8,0,0,0,0,16H92.23L83.5,152H32a8,8,0,0,0,0,16H80.6l-8.47,46.57a8,8,0,0,0,6.44,9.3A7.79,7.79,0,0,0,80,224a8,8,0,0,0,7.86-6.57l9-49.43H144.6l-8.47,46.57a8,8,0,0,0,6.44,9.3,7.79,7.79,0,0,0,1.43.13,8,8,0,0,0,7.86-6.57l9-49.43H208a8,8,0,0,0,0-16H163.77l8.73-48H224a8,8,0,0,0,0-16Zm-68.5,64H107.77l8.73-48h47.73Z"/>'
40
43
  },
41
44
  "bold" => {
42
45
  "heart" => '<path d="M178,36c-20.09,0-37.92,7.93-50,21.56C115.92,43.93,98.09,36,78,36a66.08,66.08,0,0,0-66,66c0,72.34,105.81,130.14,110.31,132.57a12,12,0,0,0,11.38,0C138.19,232.14,244,174.34,244,102A66.08,66.08,0,0,0,178,36Zm-5.49,142.36A328.69,328.69,0,0,1,128,210.16a328.69,328.69,0,0,1-44.51-31.8C61.82,159.77,36,131.42,36,102A42,42,0,0,1,78,60c17.8,0,32.7,9.4,38.89,24.54a12,12,0,0,0,22.22,0C145.3,69.4,160.2,60,178,60a42,42,0,0,1,42,42C220,131.42,194.18,159.77,172.51,178.36Z"/>'
@@ -3,19 +3,20 @@
3
3
  require "kramdown"
4
4
  require "kramdown-parser-gfm"
5
5
  require "yaml"
6
- require_relative "components/registry"
7
- require_relative "components/base_processor"
8
- require_relative "components/callout_processor"
9
- require_relative "components/tabs_processor"
10
- require_relative "components/icon_processor"
11
- require_relative "components/code_block_processor"
12
- require_relative "components/code_snippet_import_preprocessor"
13
- require_relative "components/code_block_options_preprocessor"
14
- require_relative "components/code_block_diff_preprocessor"
15
- require_relative "components/code_block_focus_preprocessor"
16
- require_relative "components/table_wrapper_processor"
17
- require_relative "components/heading_anchor_processor"
18
- require_relative "components/table_of_contents_processor"
6
+ require_relative "../components/registry"
7
+ require_relative "../components/base_processor"
8
+ require_relative "../components/processors/callout_processor"
9
+ require_relative "../components/processors/tabs_processor"
10
+ require_relative "../components/processors/icon_processor"
11
+ require_relative "../components/processors/code_block_processor"
12
+ require_relative "../components/processors/code_snippet_import_preprocessor"
13
+ require_relative "../components/processors/code_block_options_preprocessor"
14
+ require_relative "../components/processors/code_block_diff_preprocessor"
15
+ require_relative "../components/processors/code_block_focus_preprocessor"
16
+ require_relative "../components/processors/table_wrapper_processor"
17
+ require_relative "../components/processors/heading_anchor_processor"
18
+ require_relative "../components/processors/table_of_contents_processor"
19
+ require_relative "../components/aliases"
19
20
 
20
21
  module Docyard
21
22
  class Markdown
@@ -1,13 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "erb"
4
- require_relative "constants"
4
+ require_relative "../config/constants"
5
5
 
6
6
  module Docyard
7
7
  class Renderer
8
- LAYOUTS_PATH = File.join(__dir__, "templates", "layouts")
9
- ERRORS_PATH = File.join(__dir__, "templates", "errors")
10
- PARTIALS_PATH = File.join(__dir__, "templates", "partials")
8
+ include Utils::UrlHelpers
9
+
10
+ LAYOUTS_PATH = File.join(__dir__, "../templates", "layouts")
11
+ ERRORS_PATH = File.join(__dir__, "../templates", "errors")
12
+ PARTIALS_PATH = File.join(__dir__, "../templates", "partials")
11
13
 
12
14
  attr_reader :layout_path, :base_url, :config
13
15
 
@@ -80,21 +82,8 @@ module Docyard
80
82
  "#{base_url}#{path}"
81
83
  end
82
84
 
83
- def link_path(path)
84
- return path if path.nil? || path.start_with?("http://", "https://")
85
-
86
- "#{base_url.chomp('/')}#{path}"
87
- end
88
-
89
85
  private
90
86
 
91
- def normalize_base_url(url)
92
- return "/" if url.nil? || url.empty?
93
-
94
- url = "/#{url}" unless url.start_with?("/")
95
- url.end_with?("/") ? url : "#{url}/"
96
- end
97
-
98
87
  def assign_content_variables(content, page_title, sidebar_html, prev_next_html, toc)
99
88
  @content = content
100
89
  @page_title = page_title
@@ -104,15 +93,29 @@ module Docyard
104
93
  end
105
94
 
106
95
  def assign_branding_variables(branding)
96
+ assign_site_branding(branding)
97
+ assign_display_options(branding)
98
+ assign_search_options(branding)
99
+ end
100
+
101
+ def assign_site_branding(branding)
107
102
  @site_title = branding[:site_title] || Constants::DEFAULT_SITE_TITLE
108
103
  @site_description = branding[:site_description] || ""
109
104
  @logo = branding[:logo] || Constants::DEFAULT_LOGO_PATH
110
105
  @logo_dark = branding[:logo_dark]
111
106
  @favicon = branding[:favicon] || Constants::DEFAULT_FAVICON_PATH
107
+ end
108
+
109
+ def assign_display_options(branding)
112
110
  @display_logo = branding[:display_logo].nil? || branding[:display_logo]
113
111
  @display_title = branding[:display_title].nil? || branding[:display_title]
114
112
  end
115
113
 
114
+ def assign_search_options(branding)
115
+ @search_enabled = branding[:search_enabled].nil? || branding[:search_enabled]
116
+ @search_placeholder = branding[:search_placeholder] || "Search documentation..."
117
+ end
118
+
116
119
  def strip_md_from_links(html)
117
120
  html.gsub(/href="([^"]+)\.md"/, 'href="\1"')
118
121
  end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module Docyard
6
+ module Search
7
+ class BuildIndexer
8
+ include PagefindSupport
9
+
10
+ PAGEFIND_COMMAND = "npx"
11
+
12
+ attr_reader :config, :output_dir, :verbose
13
+
14
+ def initialize(config, verbose: false)
15
+ @config = config
16
+ @output_dir = config.build.output_dir
17
+ @verbose = verbose
18
+ end
19
+
20
+ def index
21
+ return 0 unless search_enabled?
22
+
23
+ log "Generating search index..."
24
+
25
+ unless pagefind_available?
26
+ warn_pagefind_missing
27
+ return 0
28
+ end
29
+
30
+ run_pagefind
31
+ end
32
+
33
+ private
34
+
35
+ def warn_pagefind_missing
36
+ log_warning "[!] Search index skipped: Pagefind not found"
37
+ log_warning " Install with: npm install -g pagefind"
38
+ log_warning " Or run: npx pagefind --site #{output_dir}"
39
+ end
40
+
41
+ def run_pagefind
42
+ args = build_pagefind_args(output_dir)
43
+ log "Running: npx #{args.join(' ')}" if verbose
44
+
45
+ stdout, stderr, status = Open3.capture3(PAGEFIND_COMMAND, *args)
46
+
47
+ if status.success?
48
+ page_count = extract_page_count(stdout)
49
+ log "[+] Generated search index (#{page_count} pages indexed)"
50
+ page_count
51
+ else
52
+ log_warning "[!] Search indexing failed: #{stderr}"
53
+ 0
54
+ end
55
+ end
56
+
57
+ def extract_page_count(output)
58
+ if output =~ /Indexed (\d+) page/i
59
+ Regexp.last_match(1).to_i
60
+ else
61
+ 0
62
+ end
63
+ end
64
+
65
+ def log(message)
66
+ puts message
67
+ end
68
+
69
+ def log_warning(message)
70
+ warn message
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "tmpdir"
5
+ require "open3"
6
+ require "tty-progressbar"
7
+
8
+ module Docyard
9
+ module Search
10
+ class DevIndexer
11
+ include PagefindSupport
12
+
13
+ attr_reader :docs_path, :config, :temp_dir, :pagefind_path
14
+
15
+ def initialize(docs_path:, config:)
16
+ @docs_path = docs_path
17
+ @config = config
18
+ @temp_dir = nil
19
+ @pagefind_path = nil
20
+ end
21
+
22
+ def generate
23
+ return unless search_enabled?
24
+ return unless pagefind_available?
25
+
26
+ @temp_dir = Dir.mktmpdir("docyard-search-")
27
+ generate_html_files
28
+ run_pagefind
29
+ @pagefind_path = File.join(temp_dir, "pagefind")
30
+
31
+ log_success
32
+ pagefind_path
33
+ rescue StandardError => e
34
+ warn "[!] Search index generation failed: #{e.message}"
35
+ cleanup
36
+ nil
37
+ end
38
+
39
+ def cleanup
40
+ return unless temp_dir && Dir.exist?(temp_dir)
41
+
42
+ FileUtils.rm_rf(temp_dir)
43
+ end
44
+
45
+ private
46
+
47
+ def pagefind_available?
48
+ result = super
49
+ warn "[!] Search disabled: Pagefind not found (npm install -g pagefind)" unless result
50
+ result
51
+ end
52
+
53
+ def generate_html_files
54
+ markdown_files = Dir.glob(File.join(docs_path, "**", "*.md"))
55
+ renderer = Renderer.new(base_url: "/", config: config)
56
+
57
+ progress = TTY::ProgressBar.new(
58
+ "Indexing search [:bar] :current/:total (:percent)",
59
+ total: markdown_files.size,
60
+ width: 50
61
+ )
62
+
63
+ markdown_files.each do |file_path|
64
+ generate_html_file(file_path, renderer)
65
+ progress.advance
66
+ end
67
+ end
68
+
69
+ def generate_html_file(markdown_file, renderer)
70
+ relative_path = markdown_file.delete_prefix("#{docs_path}/")
71
+ output_path = determine_output_path(relative_path)
72
+
73
+ html = renderer.render_file(markdown_file, branding: branding_options)
74
+
75
+ FileUtils.mkdir_p(File.dirname(output_path))
76
+ File.write(output_path, html)
77
+ end
78
+
79
+ def determine_output_path(relative_path)
80
+ base_name = File.basename(relative_path, ".md")
81
+ dir_name = File.dirname(relative_path)
82
+
83
+ if base_name == "index"
84
+ File.join(temp_dir, dir_name, "index.html")
85
+ else
86
+ File.join(temp_dir, dir_name, base_name, "index.html")
87
+ end
88
+ end
89
+
90
+ def branding_options
91
+ BrandingResolver.new(config).resolve
92
+ end
93
+
94
+ def run_pagefind
95
+ args = build_pagefind_args(temp_dir)
96
+ stdout, stderr, status = Open3.capture3("npx", *args)
97
+
98
+ raise "Pagefind failed: #{stderr}" unless status.success?
99
+
100
+ stdout
101
+ end
102
+
103
+ def log_success
104
+ page_count = Dir.glob(File.join(temp_dir, "**", "*.html")).size
105
+ puts "=> Search index generated (#{page_count} pages)"
106
+ puts "=> Temp directory: #{temp_dir}" if ENV["DOCYARD_DEBUG"]
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module Docyard
6
+ module Search
7
+ module PagefindSupport
8
+ def search_enabled?
9
+ config.search.enabled != false
10
+ end
11
+
12
+ def pagefind_available?
13
+ _stdout, _stderr, status = Open3.capture3("npx", "pagefind", "--version")
14
+ status.success?
15
+ rescue Errno::ENOENT
16
+ false
17
+ end
18
+
19
+ def build_pagefind_args(site_dir)
20
+ args = ["pagefind", "--site", site_dir]
21
+
22
+ exclusions = config.search.exclude || []
23
+ exclusions.each do |pattern|
24
+ args += ["--exclude-selectors", pattern]
25
+ end
26
+
27
+ args
28
+ end
29
+ end
30
+ end
31
+ end
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Docyard
4
4
  class AssetHandler
5
- ASSETS_PATH = File.join(__dir__, "templates", "assets")
5
+ ASSETS_PATH = File.join(__dir__, "../templates", "assets")
6
6
  USER_ASSETS_PATH = "docs/assets"
7
7
 
8
8
  CONTENT_TYPES = {
@@ -4,30 +4,30 @@ require "webrick"
4
4
  require "stringio"
5
5
  require_relative "file_watcher"
6
6
  require_relative "rack_application"
7
- require_relative "config"
7
+ require_relative "../config"
8
8
 
9
9
  module Docyard
10
10
  class Server
11
11
  DEFAULT_PORT = 4200
12
12
  DEFAULT_HOST = "localhost"
13
13
 
14
- attr_reader :port, :host, :docs_path, :config
14
+ attr_reader :port, :host, :docs_path, :config, :search_enabled
15
15
 
16
- def initialize(port: DEFAULT_PORT, host: DEFAULT_HOST, docs_path: "docs")
16
+ def initialize(port: DEFAULT_PORT, host: DEFAULT_HOST, docs_path: "docs", search: false)
17
17
  @port = port
18
18
  @host = host
19
19
  @docs_path = docs_path
20
+ @search_enabled = search
20
21
  @config = Config.load
21
22
  @file_watcher = FileWatcher.new(File.expand_path(docs_path))
22
- @app = RackApplication.new(
23
- docs_path: File.expand_path(docs_path),
24
- file_watcher: @file_watcher,
25
- config: @config
26
- )
23
+ @search_indexer = nil
24
+ @app = nil
27
25
  end
28
26
 
29
27
  def start
30
28
  validate_docs_directory!
29
+ generate_search_index if @search_enabled
30
+ initialize_app
31
31
  print_server_info
32
32
  @file_watcher.start
33
33
 
@@ -35,11 +35,33 @@ module Docyard
35
35
  trap("INT") { shutdown_server }
36
36
 
37
37
  http_server.start
38
- @file_watcher.stop
38
+ cleanup
39
39
  end
40
40
 
41
41
  private
42
42
 
43
+ def generate_search_index
44
+ @search_indexer = Search::DevIndexer.new(
45
+ docs_path: File.expand_path(docs_path),
46
+ config: @config
47
+ )
48
+ @search_indexer.generate
49
+ end
50
+
51
+ def initialize_app
52
+ @app = RackApplication.new(
53
+ docs_path: File.expand_path(docs_path),
54
+ file_watcher: @file_watcher,
55
+ config: @config,
56
+ pagefind_path: @search_indexer&.pagefind_path
57
+ )
58
+ end
59
+
60
+ def cleanup
61
+ @file_watcher.stop
62
+ @search_indexer&.cleanup
63
+ end
64
+
43
65
  def validate_docs_directory!
44
66
  return if File.directory?(docs_path)
45
67
 
@@ -51,6 +73,7 @@ module Docyard
51
73
  puts "Starting Docyard server..."
52
74
  puts "=> Serving docs from: #{docs_path}/"
53
75
  puts "=> Running at: http://#{host}:#{port}"
76
+ puts "=> Search: #{@search_enabled ? 'enabled' : 'disabled (use --search to enable)'}"
54
77
  puts "=> Press Ctrl+C to stop\n"
55
78
  end
56
79
 
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "webrick"
4
- require_relative "config"
4
+ require_relative "../config"
5
5
 
6
6
  module Docyard
7
7
  class PreviewServer
@@ -2,16 +2,24 @@
2
2
 
3
3
  require "json"
4
4
  require "rack"
5
- require_relative "sidebar_builder"
6
- require_relative "prev_next_builder"
7
- require_relative "constants"
5
+ require_relative "../navigation/sidebar_builder"
6
+ require_relative "../navigation/prev_next_builder"
7
+ require_relative "../config/branding_resolver"
8
+ require_relative "../config/constants"
8
9
 
9
10
  module Docyard
10
11
  class RackApplication
11
- def initialize(docs_path:, file_watcher:, config: nil)
12
+ PAGEFIND_CONTENT_TYPES = {
13
+ ".js" => "application/javascript; charset=utf-8",
14
+ ".css" => "text/css; charset=utf-8",
15
+ ".json" => "application/json; charset=utf-8"
16
+ }.freeze
17
+
18
+ def initialize(docs_path:, file_watcher:, config: nil, pagefind_path: nil)
12
19
  @docs_path = docs_path
13
20
  @file_watcher = file_watcher
14
21
  @config = config
22
+ @pagefind_path = pagefind_path
15
23
  @router = Router.new(docs_path: docs_path)
16
24
  @renderer = Renderer.new(base_url: config&.build&.base_url || "/", config: config)
17
25
  @asset_handler = AssetHandler.new
@@ -23,13 +31,14 @@ module Docyard
23
31
 
24
32
  private
25
33
 
26
- attr_reader :docs_path, :file_watcher, :config, :router, :renderer, :asset_handler
34
+ attr_reader :docs_path, :file_watcher, :config, :pagefind_path, :router, :renderer, :asset_handler
27
35
 
28
36
  def handle_request(env)
29
37
  path = env["PATH_INFO"]
30
38
 
31
39
  return handle_reload_check(env) if path == Constants::RELOAD_ENDPOINT
32
40
  return asset_handler.serve(path) if path.start_with?(Constants::ASSETS_PREFIX)
41
+ return serve_pagefind(path) if path.start_with?(Constants::PAGEFIND_PREFIX)
33
42
 
34
43
  handle_documentation_request(path)
35
44
  rescue StandardError => e
@@ -91,50 +100,7 @@ module Docyard
91
100
  end
92
101
 
93
102
  def branding_options
94
- return default_branding unless config
95
-
96
- default_branding.merge(config_branding_options)
97
- end
98
-
99
- def default_branding
100
- {
101
- site_title: Constants::DEFAULT_SITE_TITLE,
102
- site_description: "",
103
- logo: Constants::DEFAULT_LOGO_PATH,
104
- logo_dark: Constants::DEFAULT_LOGO_DARK_PATH,
105
- favicon: nil,
106
- display_logo: true,
107
- display_title: true
108
- }
109
- end
110
-
111
- def config_branding_options
112
- site = config.site
113
- branding = config.branding
114
-
115
- {
116
- site_title: site.title || Constants::DEFAULT_SITE_TITLE,
117
- site_description: site.description || "",
118
- logo: resolve_logo(branding.logo, branding.logo_dark),
119
- logo_dark: resolve_logo_dark(branding.logo, branding.logo_dark),
120
- favicon: branding.favicon
121
- }.merge(appearance_options(branding.appearance))
122
- end
123
-
124
- def appearance_options(appearance)
125
- appearance ||= {}
126
- {
127
- display_logo: appearance["logo"] != false,
128
- display_title: appearance["title"] != false
129
- }
130
- end
131
-
132
- def resolve_logo(logo, logo_dark)
133
- logo || logo_dark || Constants::DEFAULT_LOGO_PATH
134
- end
135
-
136
- def resolve_logo_dark(logo, logo_dark)
137
- logo_dark || logo || Constants::DEFAULT_LOGO_DARK_PATH
103
+ BrandingResolver.new(config).resolve
138
104
  end
139
105
 
140
106
  def handle_reload_check(env)
@@ -168,5 +134,42 @@ module Docyard
168
134
  [Constants::STATUS_INTERNAL_ERROR, { "Content-Type" => Constants::CONTENT_TYPE_HTML },
169
135
  [renderer.render_server_error(error)]]
170
136
  end
137
+
138
+ def serve_pagefind(path)
139
+ relative_path = path.delete_prefix(Constants::PAGEFIND_PREFIX)
140
+ return pagefind_not_found if relative_path.include?("..")
141
+
142
+ file_path = resolve_pagefind_file(relative_path)
143
+ return pagefind_not_found unless file_path && File.file?(file_path)
144
+
145
+ content = File.binread(file_path)
146
+ content_type = pagefind_content_type(file_path)
147
+
148
+ headers = {
149
+ "Content-Type" => content_type,
150
+ "Cache-Control" => "no-cache, no-store, must-revalidate",
151
+ "Pragma" => "no-cache",
152
+ "Expires" => "0"
153
+ }
154
+
155
+ [Constants::STATUS_OK, headers, [content]]
156
+ end
157
+
158
+ def resolve_pagefind_file(relative_path)
159
+ return File.join(pagefind_path, relative_path) if pagefind_path && Dir.exist?(pagefind_path)
160
+
161
+ output_dir = config&.build&.output_dir || "dist"
162
+ File.join(output_dir, "pagefind", relative_path)
163
+ end
164
+
165
+ def pagefind_content_type(file_path)
166
+ extension = File.extname(file_path)
167
+ PAGEFIND_CONTENT_TYPES.fetch(extension, "application/octet-stream")
168
+ end
169
+
170
+ def pagefind_not_found
171
+ message = "Pagefind not found. Run 'docyard build' first."
172
+ [Constants::STATUS_NOT_FOUND, { "Content-Type" => "text/plain" }, [message]]
173
+ end
171
174
  end
172
175
  end