docyard 0.9.0 → 1.0.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 (159) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +43 -0
  3. data/README.md +8 -253
  4. data/exe/docyard +6 -0
  5. data/lib/docyard/build/asset_bundler.rb +2 -2
  6. data/lib/docyard/build/file_copier.rb +12 -5
  7. data/lib/docyard/build/llms_txt_generator.rb +103 -0
  8. data/lib/docyard/build/sitemap_generator.rb +1 -1
  9. data/lib/docyard/build/static_generator.rb +115 -79
  10. data/lib/docyard/builder.rb +6 -2
  11. data/lib/docyard/cli.rb +14 -4
  12. data/lib/docyard/components/processors/callout_processor.rb +1 -1
  13. data/lib/docyard/components/processors/code_block_extended_fence_postprocessor.rb +24 -0
  14. data/lib/docyard/components/processors/code_block_extended_fence_preprocessor.rb +44 -0
  15. data/lib/docyard/components/processors/code_block_options_preprocessor.rb +11 -1
  16. data/lib/docyard/components/processors/code_block_processor.rb +5 -24
  17. data/lib/docyard/components/processors/code_group_processor.rb +6 -22
  18. data/lib/docyard/components/processors/code_snippet_import_preprocessor.rb +1 -0
  19. data/lib/docyard/components/processors/file_tree_processor.rb +1 -2
  20. data/lib/docyard/components/processors/icon_processor.rb +8 -2
  21. data/lib/docyard/components/processors/include_processor.rb +10 -10
  22. data/lib/docyard/components/processors/video_embed_processor.rb +14 -3
  23. data/lib/docyard/components/support/code_block/feature_extractor.rb +3 -1
  24. data/lib/docyard/components/support/code_block/icon_detector.rb +5 -12
  25. data/lib/docyard/components/support/code_block/line_number_resolver.rb +30 -0
  26. data/lib/docyard/components/support/code_detector.rb +2 -12
  27. data/lib/docyard/components/support/code_group/html_builder.rb +2 -6
  28. data/lib/docyard/components/support/tabs/icon_detector.rb +6 -2
  29. data/lib/docyard/components/support/tabs/parser.rb +6 -23
  30. data/lib/docyard/config/analytics_resolver.rb +24 -0
  31. data/lib/docyard/config/branding_resolver.rb +58 -27
  32. data/lib/docyard/config/key_validator.rb +30 -0
  33. data/lib/docyard/config/logo_detector.rb +8 -8
  34. data/lib/docyard/config/schema.rb +39 -0
  35. data/lib/docyard/config/section.rb +21 -0
  36. data/lib/docyard/config/validation_helpers.rb +83 -0
  37. data/lib/docyard/config/validator.rb +45 -144
  38. data/lib/docyard/config/validators/navigation.rb +43 -0
  39. data/lib/docyard/config/validators/section.rb +114 -0
  40. data/lib/docyard/config.rb +46 -102
  41. data/lib/docyard/constants.rb +59 -0
  42. data/lib/docyard/{utils/errors.rb → errors.rb} +6 -0
  43. data/lib/docyard/initializer.rb +100 -49
  44. data/lib/docyard/navigation/page_navigation_builder.rb +65 -0
  45. data/lib/docyard/navigation/sidebar/auto_builder.rb +107 -0
  46. data/lib/docyard/navigation/sidebar/cache.rb +96 -0
  47. data/lib/docyard/navigation/sidebar/config_builder.rb +179 -0
  48. data/lib/docyard/navigation/sidebar/distributed_builder.rb +145 -0
  49. data/lib/docyard/navigation/sidebar/local_config_loader.rb +69 -3
  50. data/lib/docyard/navigation/sidebar/renderer.rb +12 -1
  51. data/lib/docyard/navigation/sidebar_builder.rb +43 -81
  52. data/lib/docyard/rendering/branding_variables.rb +65 -0
  53. data/lib/docyard/rendering/icon_helpers.rb +14 -1
  54. data/lib/docyard/rendering/icons/devicons.rb +63 -0
  55. data/lib/docyard/rendering/icons.rb +26 -27
  56. data/lib/docyard/rendering/markdown.rb +5 -23
  57. data/lib/docyard/rendering/og_helpers.rb +36 -0
  58. data/lib/docyard/rendering/renderer.rb +87 -59
  59. data/lib/docyard/rendering/template_resolver.rb +14 -0
  60. data/lib/docyard/routing/fallback_resolver.rb +3 -3
  61. data/lib/docyard/search/build_indexer.rb +2 -2
  62. data/lib/docyard/search/dev_indexer.rb +36 -28
  63. data/lib/docyard/search/pagefind_support.rb +1 -1
  64. data/lib/docyard/server/asset_handler.rb +39 -15
  65. data/lib/docyard/server/dev_server.rb +90 -55
  66. data/lib/docyard/server/file_watcher.rb +68 -18
  67. data/lib/docyard/server/pagefind_handler.rb +1 -1
  68. data/lib/docyard/server/preview_server.rb +29 -33
  69. data/lib/docyard/server/rack_application.rb +38 -70
  70. data/lib/docyard/server/router.rb +11 -7
  71. data/lib/docyard/server/sse_server.rb +157 -0
  72. data/lib/docyard/server/static_file_app.rb +42 -0
  73. data/lib/docyard/templates/assets/css/components/banner.css +31 -0
  74. data/lib/docyard/templates/assets/css/components/breadcrumbs.css +2 -1
  75. data/lib/docyard/templates/assets/css/components/callout.css +26 -6
  76. data/lib/docyard/templates/assets/css/components/code-block.css +4 -2
  77. data/lib/docyard/templates/assets/css/components/code-group.css +20 -7
  78. data/lib/docyard/templates/assets/css/components/feedback.css +126 -0
  79. data/lib/docyard/templates/assets/css/components/file-tree.css +5 -4
  80. data/lib/docyard/templates/assets/css/components/icon.css +5 -0
  81. data/lib/docyard/templates/assets/css/components/nav-menu.css +20 -4
  82. data/lib/docyard/templates/assets/css/components/navigation.css +25 -3
  83. data/lib/docyard/templates/assets/css/components/page-actions.css +131 -0
  84. data/lib/docyard/templates/assets/css/components/prev-next.css +14 -7
  85. data/lib/docyard/templates/assets/css/components/search.css +6 -10
  86. data/lib/docyard/templates/assets/css/components/tab-bar.css +7 -4
  87. data/lib/docyard/templates/assets/css/components/table-of-contents.css +57 -11
  88. data/lib/docyard/templates/assets/css/components/tabs.css +12 -4
  89. data/lib/docyard/templates/assets/css/components/theme-toggle.css +3 -1
  90. data/lib/docyard/templates/assets/css/landing.css +82 -13
  91. data/lib/docyard/templates/assets/css/layout.css +17 -0
  92. data/lib/docyard/templates/assets/css/markdown.css +22 -2
  93. data/lib/docyard/templates/assets/css/variables.css +13 -1
  94. data/lib/docyard/templates/assets/js/components/code-group.js +4 -1
  95. data/lib/docyard/templates/assets/js/components/copy-page.js +115 -0
  96. data/lib/docyard/templates/assets/js/components/feedback.js +66 -0
  97. data/lib/docyard/templates/assets/js/components/file-tree.js +5 -5
  98. data/lib/docyard/templates/assets/js/components/navigation.js +3 -3
  99. data/lib/docyard/templates/assets/js/components/search.js +3 -3
  100. data/lib/docyard/templates/assets/js/components/table-of-contents.js +12 -6
  101. data/lib/docyard/templates/assets/js/components/tabs.js +45 -22
  102. data/lib/docyard/templates/assets/js/components/tooltip.js +4 -4
  103. data/lib/docyard/templates/assets/js/hot-reload.js +44 -0
  104. data/lib/docyard/templates/errors/404.html.erb +114 -5
  105. data/lib/docyard/templates/errors/500.html.erb +173 -10
  106. data/lib/docyard/templates/init/_sidebar.yml +36 -0
  107. data/lib/docyard/templates/init/docyard.yml +36 -0
  108. data/lib/docyard/templates/init/pages/components.md +146 -0
  109. data/lib/docyard/templates/init/pages/getting-started.md +94 -0
  110. data/lib/docyard/templates/init/pages/index.md +22 -0
  111. data/lib/docyard/templates/layouts/default.html.erb +10 -0
  112. data/lib/docyard/templates/layouts/splash.html.erb +14 -1
  113. data/lib/docyard/templates/partials/_analytics.html.erb +24 -0
  114. data/lib/docyard/templates/partials/_banner.html.erb +1 -1
  115. data/lib/docyard/templates/partials/_code_block.html.erb +1 -1
  116. data/lib/docyard/templates/partials/_feedback.html.erb +14 -0
  117. data/lib/docyard/templates/partials/_footer.html.erb +1 -1
  118. data/lib/docyard/templates/partials/_head.html.erb +79 -4
  119. data/lib/docyard/templates/partials/_icon_library.html.erb +8 -0
  120. data/lib/docyard/templates/partials/_page_actions.html.erb +21 -0
  121. data/lib/docyard/templates/partials/_scripts.html.erb +6 -3
  122. data/lib/docyard/templates/partials/_tabs.html.erb +4 -1
  123. data/lib/docyard/utils/git_info.rb +157 -0
  124. data/lib/docyard/utils/hash_utils.rb +31 -0
  125. data/lib/docyard/utils/html_helpers.rb +8 -0
  126. data/lib/docyard/utils/logging.rb +44 -3
  127. data/lib/docyard/utils/path_resolver.rb +0 -10
  128. data/lib/docyard/utils/path_utils.rb +73 -0
  129. data/lib/docyard/version.rb +1 -1
  130. data/lib/docyard.rb +2 -2
  131. metadata +77 -47
  132. data/.github/ISSUE_TEMPLATE/bug_report.md +0 -31
  133. data/.github/ISSUE_TEMPLATE/feature_request.md +0 -19
  134. data/.github/pull_request_template.md +0 -14
  135. data/.github/workflows/ci.yml +0 -49
  136. data/.rubocop.yml +0 -42
  137. data/CODE_OF_CONDUCT.md +0 -132
  138. data/CONTRIBUTING.md +0 -55
  139. data/LICENSE.vscode-icons +0 -42
  140. data/Rakefile +0 -8
  141. data/lib/docyard/config/constants.rb +0 -31
  142. data/lib/docyard/navigation/sidebar/children_discoverer.rb +0 -51
  143. data/lib/docyard/navigation/sidebar/config_parser.rb +0 -208
  144. data/lib/docyard/navigation/sidebar/file_resolver.rb +0 -90
  145. data/lib/docyard/navigation/sidebar/file_system_scanner.rb +0 -78
  146. data/lib/docyard/navigation/sidebar/metadata_extractor.rb +0 -71
  147. data/lib/docyard/navigation/sidebar/metadata_reader.rb +0 -51
  148. data/lib/docyard/navigation/sidebar/path_prefixer.rb +0 -34
  149. data/lib/docyard/navigation/sidebar/sorter.rb +0 -21
  150. data/lib/docyard/navigation/sidebar/title_extractor.rb +0 -25
  151. data/lib/docyard/navigation/sidebar/tree_builder.rb +0 -140
  152. data/lib/docyard/rendering/icons/LICENSE.phosphor +0 -21
  153. data/lib/docyard/rendering/icons/file_types.rb +0 -79
  154. data/lib/docyard/rendering/icons/phosphor.rb +0 -93
  155. data/lib/docyard/rendering/language_mapping.rb +0 -52
  156. data/lib/docyard/templates/assets/js/reload.js +0 -98
  157. data/lib/docyard/templates/partials/_icon.html.erb +0 -1
  158. data/lib/docyard/templates/partials/_icon_file_extension.html.erb +0 -1
  159. data/sig/docyard.rbs +0 -4
@@ -3,13 +3,17 @@
3
3
  require "fileutils"
4
4
  require "tmpdir"
5
5
  require "open3"
6
+ require "parallel"
6
7
  require "tty-progressbar"
8
+ require_relative "../utils/path_utils"
7
9
 
8
10
  module Docyard
9
11
  module Search
10
12
  class DevIndexer
11
13
  include PagefindSupport
12
14
 
15
+ PARALLEL_THRESHOLD = 10
16
+
13
17
  attr_reader :docs_path, :config, :temp_dir, :pagefind_path
14
18
 
15
19
  def initialize(docs_path:, config:)
@@ -26,12 +30,12 @@ module Docyard
26
30
  @temp_dir = Dir.mktmpdir("docyard-search-")
27
31
  generate_html_files
28
32
  page_count = run_pagefind
29
- @pagefind_path = File.join(temp_dir, "pagefind")
33
+ @pagefind_path = File.join(temp_dir, "_docyard", "pagefind")
30
34
 
31
35
  log_success(page_count)
32
36
  pagefind_path
33
37
  rescue StandardError => e
34
- warn "[!] Search index generation failed: #{e.message}"
38
+ Docyard.logger.warn("Search index generation failed: #{e.message}")
35
39
  cleanup
36
40
  nil
37
41
  end
@@ -46,7 +50,7 @@ module Docyard
46
50
 
47
51
  def pagefind_available?
48
52
  result = super
49
- warn "[!] Search disabled: Pagefind not found (npm install -g pagefind)" unless result
53
+ Docyard.logger.warn("Search disabled: Pagefind not found (npm install -g pagefind)") unless result
50
54
  result
51
55
  end
52
56
 
@@ -54,20 +58,43 @@ module Docyard
54
58
  markdown_files = Dir.glob(File.join(docs_path, "**", "*.md"))
55
59
  markdown_files = filter_excluded_files(markdown_files)
56
60
  markdown_files = filter_non_indexable_files(markdown_files)
57
- renderer = Renderer.new(base_url: "/", config: config)
58
61
 
59
62
  progress = TTY::ProgressBar.new(
60
63
  "Indexing search [:bar] :current/:total (:percent)",
61
64
  total: markdown_files.size,
62
65
  width: 50
63
66
  )
67
+ mutex = Mutex.new
68
+
69
+ Logging.start_buffering
70
+ if markdown_files.size >= PARALLEL_THRESHOLD
71
+ generate_files_in_parallel(markdown_files, progress, mutex)
72
+ else
73
+ generate_files_sequentially(markdown_files, progress)
74
+ end
75
+ Logging.flush_warnings
76
+ end
64
77
 
78
+ def generate_files_in_parallel(markdown_files, progress, mutex)
79
+ Parallel.each(markdown_files, in_threads: Parallel.processor_count) do |file_path|
80
+ renderer = thread_local_renderer
81
+ generate_html_file(file_path, renderer)
82
+ mutex.synchronize { progress.advance }
83
+ end
84
+ end
85
+
86
+ def generate_files_sequentially(markdown_files, progress)
87
+ renderer = Renderer.new(base_url: "/", config: config)
65
88
  markdown_files.each do |file_path|
66
89
  generate_html_file(file_path, renderer)
67
90
  progress.advance
68
91
  end
69
92
  end
70
93
 
94
+ def thread_local_renderer
95
+ Thread.current[:docyard_search_renderer] ||= Renderer.new(base_url: "/", config: config)
96
+ end
97
+
71
98
  def filter_excluded_files(files)
72
99
  exclude_patterns = config.search.exclude || []
73
100
  return files if exclude_patterns.empty?
@@ -96,40 +123,21 @@ module Docyard
96
123
  end
97
124
 
98
125
  def file_to_url_path(file_path)
99
- relative_path = file_path.delete_prefix("#{docs_path}/")
100
- base_name = File.basename(relative_path, ".md")
101
- dir_name = File.dirname(relative_path)
102
-
103
- if base_name == "index"
104
- dir_name == "." ? "/" : "/#{dir_name}"
105
- else
106
- dir_name == "." ? "/#{base_name}" : "/#{dir_name}/#{base_name}"
107
- end
126
+ Utils::PathUtils.markdown_file_to_url(file_path, docs_path)
108
127
  end
109
128
 
110
129
  def generate_html_file(markdown_file, renderer)
111
130
  relative_path = markdown_file.delete_prefix("#{docs_path}/")
112
131
  output_path = determine_output_path(relative_path)
113
132
 
114
- html = renderer.render_file(markdown_file, branding: branding_options)
133
+ html = renderer.render_for_search(markdown_file)
115
134
 
116
135
  FileUtils.mkdir_p(File.dirname(output_path))
117
136
  File.write(output_path, html)
118
137
  end
119
138
 
120
139
  def determine_output_path(relative_path)
121
- base_name = File.basename(relative_path, ".md")
122
- dir_name = File.dirname(relative_path)
123
-
124
- if base_name == "index"
125
- File.join(temp_dir, dir_name, "index.html")
126
- else
127
- File.join(temp_dir, dir_name, base_name, "index.html")
128
- end
129
- end
130
-
131
- def branding_options
132
- BrandingResolver.new(config).resolve
140
+ Utils::PathUtils.markdown_to_html_output(relative_path, temp_dir)
133
141
  end
134
142
 
135
143
  def run_pagefind
@@ -147,8 +155,8 @@ module Docyard
147
155
  end
148
156
 
149
157
  def log_success(page_count)
150
- puts "=> Search index generated (#{page_count} pages indexed)"
151
- puts "=> Temp directory: #{temp_dir}" if ENV["DOCYARD_DEBUG"]
158
+ Docyard.logger.info("* Search index generated (#{page_count} pages indexed)")
159
+ Docyard.logger.debug("* Temp directory: #{temp_dir}")
152
160
  end
153
161
  end
154
162
  end
@@ -17,7 +17,7 @@ module Docyard
17
17
  end
18
18
 
19
19
  def build_pagefind_args(site_dir)
20
- args = ["pagefind", "--site", site_dir]
20
+ args = ["pagefind", "--site", site_dir, "--output-subdir", "_docyard/pagefind"]
21
21
 
22
22
  exclusions = config.search.exclude || []
23
23
  exclusions.each do |pattern|
@@ -1,8 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "digest"
4
+ require_relative "../utils/path_utils"
5
+
3
6
  module Docyard
4
7
  class AssetHandler
5
8
  TEMPLATES_ASSETS_PATH = File.join(__dir__, "../templates", "assets")
9
+ CACHE_MAX_AGE = 3600
10
+ DEFAULT_PUBLIC_DIR = "docs/public"
11
+
12
+ attr_reader :public_dir
6
13
 
7
14
  CONTENT_TYPES = {
8
15
  ".css" => "text/css; charset=utf-8",
@@ -23,47 +30,52 @@ module Docyard
23
30
  ".webm" => "video/webm"
24
31
  }.freeze
25
32
 
26
- def serve_docyard_assets(request_path)
27
- asset_path = request_path.delete_prefix("/_docyard/")
33
+ def initialize(public_dir: DEFAULT_PUBLIC_DIR)
34
+ @public_dir = public_dir
35
+ end
28
36
 
29
- return forbidden_response if directory_traversal?(asset_path)
37
+ def serve_docyard_assets(request_path)
38
+ asset_path = Utils::PathUtils.decode_path(request_path.delete_prefix("/_docyard/"))
30
39
 
31
40
  return serve_components_css if asset_path == "css/components.css"
32
41
  return serve_components_js if asset_path == "js/components.js"
33
42
 
34
- file_path = File.join(TEMPLATES_ASSETS_PATH, asset_path)
43
+ file_path = safe_asset_path(asset_path, TEMPLATES_ASSETS_PATH)
44
+ return forbidden_response unless file_path
35
45
  return not_found_response unless File.file?(file_path)
36
46
 
37
47
  serve_file(file_path)
38
48
  end
39
49
 
40
50
  def serve_public_file(request_path)
41
- asset_path = request_path.delete_prefix("/")
51
+ asset_path = Utils::PathUtils.decode_path(request_path.delete_prefix("/"))
42
52
 
43
- return nil if directory_traversal?(asset_path)
44
-
45
- file_path = File.join(Constants::PUBLIC_DIR, asset_path)
46
- return nil unless File.file?(file_path)
53
+ file_path = safe_asset_path(asset_path, public_dir)
54
+ return nil unless file_path && File.file?(file_path)
47
55
 
48
56
  serve_file(file_path)
49
57
  end
50
58
 
51
59
  private
52
60
 
53
- def directory_traversal?(path)
54
- path.include?("..")
61
+ def safe_asset_path(relative_path, base_dir)
62
+ Utils::PathUtils.resolve_safe_path(relative_path, base_dir)
55
63
  end
56
64
 
57
65
  def serve_file(file_path)
58
66
  content = File.read(file_path)
59
- content_type = detect_content_type(file_path)
67
+ headers = build_cache_headers(content, File.mtime(file_path))
68
+ headers["Content-Type"] = detect_content_type(file_path)
60
69
 
61
- [200, { "Content-Type" => content_type }, [content]]
70
+ [200, headers, [content]]
62
71
  end
63
72
 
64
73
  def serve_components_css
65
74
  content = concatenate_component_css
66
- [200, { "Content-Type" => "text/css; charset=utf-8" }, [content]]
75
+ headers = build_cache_headers(content)
76
+ headers["Content-Type"] = "text/css; charset=utf-8"
77
+
78
+ [200, headers, [content]]
67
79
  end
68
80
 
69
81
  def concatenate_component_css
@@ -76,7 +88,10 @@ module Docyard
76
88
 
77
89
  def serve_components_js
78
90
  content = concatenate_component_js
79
- [200, { "Content-Type" => "application/javascript; charset=utf-8" }, [content]]
91
+ headers = build_cache_headers(content)
92
+ headers["Content-Type"] = "application/javascript; charset=utf-8"
93
+
94
+ [200, headers, [content]]
80
95
  end
81
96
 
82
97
  def concatenate_component_js
@@ -92,6 +107,15 @@ module Docyard
92
107
  CONTENT_TYPES.fetch(extension, "application/octet-stream")
93
108
  end
94
109
 
110
+ def build_cache_headers(content, last_modified = nil)
111
+ headers = {
112
+ "Cache-Control" => "public, max-age=#{CACHE_MAX_AGE}",
113
+ "ETag" => %("#{Digest::MD5.hexdigest(content)}")
114
+ }
115
+ headers["Last-Modified"] = last_modified.httpdate if last_modified
116
+ headers
117
+ end
118
+
95
119
  def forbidden_response
96
120
  [403, { "Content-Type" => "text/plain" }, ["403 Forbidden"]]
97
121
  end
@@ -1,13 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "webrick"
4
- require "stringio"
5
- require_relative "file_watcher"
3
+ require "puma"
4
+ require "puma/configuration"
5
+ require "puma/launcher"
6
+ require "puma/log_writer"
6
7
  require_relative "rack_application"
8
+ require_relative "sse_server"
9
+ require_relative "file_watcher"
7
10
  require_relative "../config"
11
+ require_relative "../navigation/sidebar/cache"
8
12
 
9
13
  module Docyard
10
- class Server
14
+ class DevServer
11
15
  DEFAULT_PORT = 4200
12
16
  DEFAULT_HOST = "localhost"
13
17
 
@@ -19,27 +23,34 @@ module Docyard
19
23
  @docs_path = docs_path
20
24
  @search_enabled = search
21
25
  @config = Config.load
22
- @file_watcher = FileWatcher.new(File.expand_path(docs_path))
23
26
  @search_indexer = nil
24
- @app = nil
27
+ @sse_server = nil
28
+ @file_watcher = nil
29
+ @launcher = nil
30
+ @sidebar_cache = nil
25
31
  end
26
32
 
27
33
  def start
28
34
  validate_docs_directory!
35
+ build_sidebar_cache
29
36
  generate_search_index if @search_enabled
30
- initialize_app
37
+ setup_hot_reload
31
38
  print_server_info
32
- @file_watcher.start
33
-
34
- http_server.mount_proc("/") { |req, res| handle_request(req, res) }
35
- trap("INT") { shutdown_server }
36
-
37
- http_server.start
39
+ run_server
40
+ ensure
38
41
  cleanup
39
42
  end
40
43
 
41
44
  private
42
45
 
46
+ def build_sidebar_cache
47
+ @sidebar_cache = Sidebar::Cache.new(
48
+ docs_path: File.expand_path(docs_path),
49
+ config: @config
50
+ )
51
+ @sidebar_cache.build
52
+ end
53
+
43
54
  def generate_search_index
44
55
  @search_indexer = Search::DevIndexer.new(
45
56
  docs_path: File.expand_path(docs_path),
@@ -48,17 +59,45 @@ module Docyard
48
59
  @search_indexer.generate
49
60
  end
50
61
 
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
62
+ def setup_hot_reload
63
+ @sse_server = SSEServer.new(port: sse_port)
64
+ @sse_server.start
65
+
66
+ @file_watcher = FileWatcher.new(
67
+ docs_path: docs_path,
68
+ on_change: ->(change_type) { handle_file_change(change_type) }
57
69
  )
70
+ @file_watcher.start
71
+ end
72
+
73
+ def sse_port
74
+ port + 1
75
+ end
76
+
77
+ def handle_file_change(change_type)
78
+ invalidate_sidebar_cache if change_type == :full
79
+ log_file_change(change_type)
80
+ @sse_server.broadcast("reload", { type: change_type.to_s })
81
+ end
82
+
83
+ def invalidate_sidebar_cache
84
+ @sidebar_cache&.invalidate
85
+ @sidebar_cache&.build
86
+ end
87
+
88
+ def log_file_change(change_type)
89
+ message = case change_type
90
+ when :content then "Content changed, reloading..."
91
+ when :config then "Config changed, full reload..."
92
+ when :asset then "Asset changed, reloading..."
93
+ else "File changed, reloading..."
94
+ end
95
+ Docyard.logger.info("* #{message}")
58
96
  end
59
97
 
60
98
  def cleanup
61
- @file_watcher.stop
99
+ @file_watcher&.stop
100
+ @sse_server&.stop
62
101
  @search_indexer&.cleanup
63
102
  end
64
103
 
@@ -70,48 +109,44 @@ module Docyard
70
109
  end
71
110
 
72
111
  def print_server_info
73
- puts "Starting Docyard server..."
74
- puts "=> Serving docs from: #{docs_path}/"
75
- puts "=> Running at: http://#{host}:#{port}"
76
- puts "=> Search: #{@search_enabled ? 'enabled' : 'disabled (use --search to enable)'}"
77
- puts "=> Press Ctrl+C to stop\n"
112
+ Docyard.logger.info("Starting Docyard server...")
113
+ Docyard.logger.info("* Version: #{Docyard::VERSION}")
114
+ Docyard.logger.info("* Running at: http://#{host}:#{port}")
115
+ Docyard.logger.info("* Hot reload: ws://127.0.0.1:#{sse_port}")
116
+ Docyard.logger.info("* Search: #{@search_enabled ? 'enabled' : 'disabled (use --search to enable)'}")
117
+ Docyard.logger.info("Use Ctrl+C to stop\n")
78
118
  end
79
119
 
80
- def shutdown_server
81
- puts "\nShutting down server..."
82
- http_server.shutdown
83
- end
120
+ def run_server
121
+ app = build_rack_app
122
+ puma_config = build_puma_config(app)
123
+ log_writer = Puma::LogWriter.strings
84
124
 
85
- def http_server
86
- @http_server ||= WEBrick::HTTPServer.new(
87
- Port: port,
88
- BindAddress: host,
89
- AccessLog: [],
90
- Logger: WEBrick::Log.new(File::NULL)
91
- )
125
+ @launcher = Puma::Launcher.new(puma_config, log_writer: log_writer)
126
+ @launcher.run
92
127
  end
93
128
 
94
- def handle_request(req, res)
95
- env = build_rack_env(req)
96
- status, headers, body = @app.call(env)
97
-
98
- res.status = status
99
- headers.each { |key, value| res[key] = value }
100
- body.each { |chunk| res.body << chunk }
129
+ def build_rack_app
130
+ RackApplication.new(
131
+ docs_path: File.expand_path(docs_path),
132
+ config: @config,
133
+ pagefind_path: @search_indexer&.pagefind_path,
134
+ sse_port: sse_port,
135
+ sidebar_cache: @sidebar_cache
136
+ )
101
137
  end
102
138
 
103
- def build_rack_env(req)
104
- {
105
- "REQUEST_METHOD" => req.request_method,
106
- "PATH_INFO" => req.path,
107
- "QUERY_STRING" => req.query_string || "",
108
- "SERVER_NAME" => req.host,
109
- "SERVER_PORT" => req.port.to_s,
110
- "rack.version" => Rack::VERSION,
111
- "rack.url_scheme" => "http",
112
- "rack.input" => StringIO.new,
113
- "rack.errors" => $stderr
114
- }
139
+ def build_puma_config(app)
140
+ server_host = host
141
+ server_port = port
142
+
143
+ Puma::Configuration.new do |config|
144
+ config.bind "tcp://#{server_host}:#{server_port}"
145
+ config.app app
146
+ config.workers 0
147
+ config.threads 4, 8
148
+ config.quiet
149
+ end
115
150
  end
116
151
  end
117
152
  end
@@ -4,39 +4,89 @@ require "listen"
4
4
 
5
5
  module Docyard
6
6
  class FileWatcher
7
- attr_reader :last_modified_time
7
+ DEBOUNCE_DELAY = 0.1
8
+ ROOT_CONFIG_FILE = "docyard.yml"
9
+ CONFIG_FILES = %w[docyard.yml _sidebar.yml].freeze
10
+ CONTENT_EXTENSIONS = %w[.md .markdown].freeze
11
+ ASSET_EXTENSIONS = %w[.css .js .html .erb].freeze
8
12
 
9
- def initialize(docs_path)
10
- @docs_path = docs_path
11
- @last_modified_time = Time.now
12
- @listener = nil
13
+ def initialize(docs_path:, on_change:)
14
+ @docs_path = File.expand_path(docs_path)
15
+ @project_root = File.dirname(@docs_path)
16
+ @on_change = on_change
17
+ @docs_listener = nil
18
+ @config_listener = nil
19
+ @pending_changes = { content: false, config: false, asset: false }
20
+ @debounce_timer = nil
21
+ @mutex = Mutex.new
13
22
  end
14
23
 
15
24
  def start
16
- @listener = Listen.to(@docs_path, only: /\.md$/) do |modified, added, removed|
17
- handle_changes(modified, added, removed)
25
+ @docs_listener = Listen.to(@docs_path, latency: DEBOUNCE_DELAY) do |modified, added, removed|
26
+ handle_changes(modified + added + removed)
18
27
  end
28
+ @docs_listener.start
19
29
 
20
- @listener.start
30
+ @config_listener = Listen.to(@project_root, only: /\Adocyard\.yml\z/) do |modified, added, removed|
31
+ handle_changes(modified + added + removed)
32
+ end
33
+ @config_listener.start
21
34
  end
22
35
 
23
36
  def stop
24
- @listener&.stop
25
- rescue StandardError => e
26
- Docyard.logger.error "Error stopping file watcher: #{e.class} - #{e.message}"
37
+ @docs_listener&.stop
38
+ @config_listener&.stop
39
+ @debounce_timer&.kill
27
40
  end
28
41
 
29
- def changed_since?(timestamp)
30
- @last_modified_time > timestamp
42
+ private
43
+
44
+ def handle_changes(paths)
45
+ return if paths.empty?
46
+
47
+ @mutex.synchronize do
48
+ paths.each { |path| categorize_change(path) }
49
+ schedule_notification
50
+ end
31
51
  end
32
52
 
33
- private
53
+ def categorize_change(path)
54
+ filename = File.basename(path)
55
+
56
+ if CONFIG_FILES.include?(filename)
57
+ @pending_changes[:config] = true
58
+ elsif CONTENT_EXTENSIONS.include?(File.extname(path))
59
+ @pending_changes[:content] = true
60
+ elsif ASSET_EXTENSIONS.include?(File.extname(path))
61
+ @pending_changes[:asset] = true
62
+ end
63
+ end
64
+
65
+ def schedule_notification
66
+ @debounce_timer&.kill
67
+ @debounce_timer = Thread.new do
68
+ sleep DEBOUNCE_DELAY
69
+ send_notification
70
+ end
71
+ end
72
+
73
+ def send_notification
74
+ changes = nil
75
+ @mutex.synchronize do
76
+ changes = @pending_changes.dup
77
+ @pending_changes = { content: false, config: false, asset: false }
78
+ end
79
+
80
+ change_type = determine_change_type(changes)
81
+ @on_change.call(change_type) if change_type
82
+ end
34
83
 
35
- def handle_changes(modified, added, removed)
36
- return if modified.empty? && added.empty? && removed.empty?
84
+ def determine_change_type(changes)
85
+ return :full if changes[:config]
86
+ return :content if changes[:content]
87
+ return :asset if changes[:asset]
37
88
 
38
- @last_modified_time = Time.now
39
- Docyard.logger.info "Files changed, triggering reload..."
89
+ nil
40
90
  end
41
91
  end
42
92
  end
@@ -31,7 +31,7 @@ module Docyard
31
31
  return File.join(pagefind_path, relative_path) if pagefind_path && Dir.exist?(pagefind_path)
32
32
 
33
33
  output_dir = config&.build&.output_dir || "dist"
34
- File.join(output_dir, "pagefind", relative_path)
34
+ File.join(output_dir, "_docyard", "pagefind", relative_path)
35
35
  end
36
36
 
37
37
  def serve_file(file_path)
@@ -1,7 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "webrick"
3
+ require "puma"
4
+ require "puma/configuration"
5
+ require "puma/launcher"
6
+ require "puma/log_writer"
4
7
  require_relative "../config"
8
+ require_relative "static_file_app"
5
9
 
6
10
  module Docyard
7
11
  class PreviewServer
@@ -13,16 +17,13 @@ module Docyard
13
17
  @port = port
14
18
  @config = Config.load
15
19
  @output_dir = File.expand_path(@config.build.output)
20
+ @launcher = nil
16
21
  end
17
22
 
18
23
  def start
19
24
  validate_output_directory!
20
25
  print_server_info
21
-
22
- server = create_server
23
- trap("INT") { shutdown_server(server) }
24
-
25
- server.start
26
+ run_server
26
27
  end
27
28
 
28
29
  private
@@ -35,38 +36,33 @@ module Docyard
35
36
  end
36
37
 
37
38
  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"
39
+ Docyard.logger.info("Starting preview server...")
40
+ Docyard.logger.info("* Version: #{Docyard::VERSION}")
41
+ Docyard.logger.info("* Running at: http://localhost:#{port}")
42
+ Docyard.logger.info("Use Ctrl+C to stop\n")
42
43
  end
43
44
 
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
45
+ def run_server
46
+ app = StaticFileApp.new(output_dir)
47
+ puma_config = build_puma_config(app)
48
+ log_writer = Puma::LogWriter.strings
53
49
 
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
- )
50
+ @launcher = Puma::Launcher.new(puma_config, log_writer: log_writer)
51
+ @launcher.run
52
+ rescue Interrupt
53
+ Docyard.logger.info("\nShutting down preview server...")
65
54
  end
66
55
 
67
- def shutdown_server(server)
68
- puts "\nShutting down preview server..."
69
- server.shutdown
56
+ def build_puma_config(app)
57
+ server_port = port
58
+
59
+ Puma::Configuration.new do |config|
60
+ config.bind "tcp://localhost:#{server_port}"
61
+ config.app app
62
+ config.workers 0
63
+ config.threads 1, 4
64
+ config.quiet
65
+ end
70
66
  end
71
67
  end
72
68
  end