docyard 0.9.0 → 1.0.1

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 (165) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +57 -1
  3. data/README.md +8 -253
  4. data/exe/docyard +6 -0
  5. data/lib/docyard/build/asset_bundler.rb +24 -2
  6. data/lib/docyard/build/error_page_generator.rb +33 -0
  7. data/lib/docyard/build/file_copier.rb +12 -5
  8. data/lib/docyard/build/file_writer.rb +19 -0
  9. data/lib/docyard/build/llms_txt_generator.rb +103 -0
  10. data/lib/docyard/build/root_fallback_generator.rb +66 -0
  11. data/lib/docyard/build/sitemap_generator.rb +1 -1
  12. data/lib/docyard/build/static_generator.rb +119 -81
  13. data/lib/docyard/builder.rb +6 -2
  14. data/lib/docyard/cli.rb +14 -4
  15. data/lib/docyard/components/processors/callout_processor.rb +1 -1
  16. data/lib/docyard/components/processors/code_block_extended_fence_postprocessor.rb +24 -0
  17. data/lib/docyard/components/processors/code_block_extended_fence_preprocessor.rb +44 -0
  18. data/lib/docyard/components/processors/code_block_options_preprocessor.rb +11 -1
  19. data/lib/docyard/components/processors/code_block_processor.rb +5 -24
  20. data/lib/docyard/components/processors/code_group_processor.rb +6 -22
  21. data/lib/docyard/components/processors/code_snippet_import_preprocessor.rb +1 -0
  22. data/lib/docyard/components/processors/file_tree_processor.rb +1 -2
  23. data/lib/docyard/components/processors/icon_processor.rb +8 -2
  24. data/lib/docyard/components/processors/include_processor.rb +10 -10
  25. data/lib/docyard/components/processors/video_embed_processor.rb +14 -3
  26. data/lib/docyard/components/support/code_block/feature_extractor.rb +3 -1
  27. data/lib/docyard/components/support/code_block/icon_detector.rb +5 -12
  28. data/lib/docyard/components/support/code_block/line_number_resolver.rb +30 -0
  29. data/lib/docyard/components/support/code_detector.rb +2 -12
  30. data/lib/docyard/components/support/code_group/html_builder.rb +2 -6
  31. data/lib/docyard/components/support/tabs/icon_detector.rb +6 -2
  32. data/lib/docyard/components/support/tabs/parser.rb +6 -23
  33. data/lib/docyard/config/analytics_resolver.rb +24 -0
  34. data/lib/docyard/config/branding_resolver.rb +58 -27
  35. data/lib/docyard/config/key_validator.rb +30 -0
  36. data/lib/docyard/config/logo_detector.rb +8 -8
  37. data/lib/docyard/config/schema.rb +39 -0
  38. data/lib/docyard/config/section.rb +21 -0
  39. data/lib/docyard/config/validation_helpers.rb +83 -0
  40. data/lib/docyard/config/validator.rb +45 -144
  41. data/lib/docyard/config/validators/navigation.rb +43 -0
  42. data/lib/docyard/config/validators/section.rb +114 -0
  43. data/lib/docyard/config.rb +46 -102
  44. data/lib/docyard/constants.rb +59 -0
  45. data/lib/docyard/{utils/errors.rb → errors.rb} +6 -0
  46. data/lib/docyard/initializer.rb +100 -49
  47. data/lib/docyard/navigation/breadcrumb_builder.rb +45 -6
  48. data/lib/docyard/navigation/page_navigation_builder.rb +65 -0
  49. data/lib/docyard/navigation/sidebar/auto_builder.rb +107 -0
  50. data/lib/docyard/navigation/sidebar/cache.rb +96 -0
  51. data/lib/docyard/navigation/sidebar/config_builder.rb +179 -0
  52. data/lib/docyard/navigation/sidebar/distributed_builder.rb +145 -0
  53. data/lib/docyard/navigation/sidebar/local_config_loader.rb +69 -3
  54. data/lib/docyard/navigation/sidebar/renderer.rb +12 -1
  55. data/lib/docyard/navigation/sidebar_builder.rb +43 -81
  56. data/lib/docyard/rendering/branding_variables.rb +65 -0
  57. data/lib/docyard/rendering/icon_helpers.rb +14 -1
  58. data/lib/docyard/rendering/icons/devicons.rb +63 -0
  59. data/lib/docyard/rendering/icons.rb +26 -27
  60. data/lib/docyard/rendering/markdown.rb +5 -23
  61. data/lib/docyard/rendering/og_helpers.rb +36 -0
  62. data/lib/docyard/rendering/renderer.rb +96 -61
  63. data/lib/docyard/rendering/template_resolver.rb +14 -0
  64. data/lib/docyard/routing/fallback_resolver.rb +3 -3
  65. data/lib/docyard/search/build_indexer.rb +2 -2
  66. data/lib/docyard/search/dev_indexer.rb +36 -28
  67. data/lib/docyard/search/pagefind_support.rb +1 -1
  68. data/lib/docyard/server/asset_handler.rb +39 -15
  69. data/lib/docyard/server/dev_server.rb +90 -55
  70. data/lib/docyard/server/file_watcher.rb +68 -18
  71. data/lib/docyard/server/pagefind_handler.rb +1 -1
  72. data/lib/docyard/server/preview_server.rb +29 -33
  73. data/lib/docyard/server/rack_application.rb +39 -71
  74. data/lib/docyard/server/router.rb +11 -7
  75. data/lib/docyard/server/sse_server.rb +157 -0
  76. data/lib/docyard/server/static_file_app.rb +42 -0
  77. data/lib/docyard/templates/assets/css/components/banner.css +31 -0
  78. data/lib/docyard/templates/assets/css/components/breadcrumbs.css +2 -1
  79. data/lib/docyard/templates/assets/css/components/callout.css +26 -6
  80. data/lib/docyard/templates/assets/css/components/code-block.css +4 -2
  81. data/lib/docyard/templates/assets/css/components/code-group.css +20 -7
  82. data/lib/docyard/templates/assets/css/components/feedback.css +126 -0
  83. data/lib/docyard/templates/assets/css/components/file-tree.css +5 -4
  84. data/lib/docyard/templates/assets/css/components/heading-anchor.css +2 -2
  85. data/lib/docyard/templates/assets/css/components/icon.css +5 -0
  86. data/lib/docyard/templates/assets/css/components/nav-menu.css +20 -4
  87. data/lib/docyard/templates/assets/css/components/navigation.css +25 -3
  88. data/lib/docyard/templates/assets/css/components/page-actions.css +131 -0
  89. data/lib/docyard/templates/assets/css/components/prev-next.css +14 -7
  90. data/lib/docyard/templates/assets/css/components/search.css +6 -10
  91. data/lib/docyard/templates/assets/css/components/tab-bar.css +9 -6
  92. data/lib/docyard/templates/assets/css/components/table-of-contents.css +63 -17
  93. data/lib/docyard/templates/assets/css/components/tabs.css +12 -4
  94. data/lib/docyard/templates/assets/css/components/theme-toggle.css +3 -1
  95. data/lib/docyard/templates/assets/css/landing.css +82 -13
  96. data/lib/docyard/templates/assets/css/layout.css +32 -16
  97. data/lib/docyard/templates/assets/css/markdown.css +22 -2
  98. data/lib/docyard/templates/assets/css/variables.css +14 -1
  99. data/lib/docyard/templates/assets/js/components/code-group.js +4 -1
  100. data/lib/docyard/templates/assets/js/components/copy-page.js +115 -0
  101. data/lib/docyard/templates/assets/js/components/feedback.js +66 -0
  102. data/lib/docyard/templates/assets/js/components/file-tree.js +5 -5
  103. data/lib/docyard/templates/assets/js/components/navigation.js +3 -3
  104. data/lib/docyard/templates/assets/js/components/search.js +3 -3
  105. data/lib/docyard/templates/assets/js/components/table-of-contents.js +12 -6
  106. data/lib/docyard/templates/assets/js/components/tabs.js +45 -22
  107. data/lib/docyard/templates/assets/js/components/tooltip.js +4 -4
  108. data/lib/docyard/templates/assets/js/hot-reload.js +44 -0
  109. data/lib/docyard/templates/errors/404.html.erb +125 -5
  110. data/lib/docyard/templates/errors/500.html.erb +184 -10
  111. data/lib/docyard/templates/errors/redirect.html.erb +12 -0
  112. data/lib/docyard/templates/init/_sidebar.yml +36 -0
  113. data/lib/docyard/templates/init/docyard.yml +36 -0
  114. data/lib/docyard/templates/init/pages/components.md +146 -0
  115. data/lib/docyard/templates/init/pages/getting-started.md +94 -0
  116. data/lib/docyard/templates/init/pages/index.md +22 -0
  117. data/lib/docyard/templates/layouts/default.html.erb +10 -0
  118. data/lib/docyard/templates/layouts/splash.html.erb +14 -1
  119. data/lib/docyard/templates/partials/_analytics.html.erb +24 -0
  120. data/lib/docyard/templates/partials/_banner.html.erb +1 -1
  121. data/lib/docyard/templates/partials/_code_block.html.erb +1 -1
  122. data/lib/docyard/templates/partials/_feedback.html.erb +14 -0
  123. data/lib/docyard/templates/partials/_footer.html.erb +1 -1
  124. data/lib/docyard/templates/partials/_head.html.erb +80 -5
  125. data/lib/docyard/templates/partials/_icon_library.html.erb +8 -0
  126. data/lib/docyard/templates/partials/_page_actions.html.erb +21 -0
  127. data/lib/docyard/templates/partials/_scripts.html.erb +6 -3
  128. data/lib/docyard/templates/partials/_tabs.html.erb +4 -1
  129. data/lib/docyard/utils/git_info.rb +157 -0
  130. data/lib/docyard/utils/hash_utils.rb +31 -0
  131. data/lib/docyard/utils/html_helpers.rb +8 -0
  132. data/lib/docyard/utils/logging.rb +44 -3
  133. data/lib/docyard/utils/path_resolver.rb +0 -10
  134. data/lib/docyard/utils/path_utils.rb +73 -0
  135. data/lib/docyard/version.rb +1 -1
  136. data/lib/docyard.rb +2 -2
  137. metadata +81 -47
  138. data/.github/ISSUE_TEMPLATE/bug_report.md +0 -31
  139. data/.github/ISSUE_TEMPLATE/feature_request.md +0 -19
  140. data/.github/pull_request_template.md +0 -14
  141. data/.github/workflows/ci.yml +0 -49
  142. data/.rubocop.yml +0 -42
  143. data/CODE_OF_CONDUCT.md +0 -132
  144. data/CONTRIBUTING.md +0 -55
  145. data/LICENSE.vscode-icons +0 -42
  146. data/Rakefile +0 -8
  147. data/lib/docyard/config/constants.rb +0 -31
  148. data/lib/docyard/navigation/sidebar/children_discoverer.rb +0 -51
  149. data/lib/docyard/navigation/sidebar/config_parser.rb +0 -208
  150. data/lib/docyard/navigation/sidebar/file_resolver.rb +0 -90
  151. data/lib/docyard/navigation/sidebar/file_system_scanner.rb +0 -78
  152. data/lib/docyard/navigation/sidebar/metadata_extractor.rb +0 -71
  153. data/lib/docyard/navigation/sidebar/metadata_reader.rb +0 -51
  154. data/lib/docyard/navigation/sidebar/path_prefixer.rb +0 -34
  155. data/lib/docyard/navigation/sidebar/sorter.rb +0 -21
  156. data/lib/docyard/navigation/sidebar/title_extractor.rb +0 -25
  157. data/lib/docyard/navigation/sidebar/tree_builder.rb +0 -140
  158. data/lib/docyard/rendering/icons/LICENSE.phosphor +0 -21
  159. data/lib/docyard/rendering/icons/file_types.rb +0 -79
  160. data/lib/docyard/rendering/icons/phosphor.rb +0 -93
  161. data/lib/docyard/rendering/language_mapping.rb +0 -52
  162. data/lib/docyard/templates/assets/js/reload.js +0 -98
  163. data/lib/docyard/templates/partials/_icon.html.erb +0 -1
  164. data/lib/docyard/templates/partials/_icon_file_extension.html.erb +0 -1
  165. data/sig/docyard.rbs +0 -4
@@ -1,82 +1,142 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "erb"
4
- require_relative "../config/constants"
4
+ require_relative "../constants"
5
+ require_relative "../utils/git_info"
5
6
  require_relative "icon_helpers"
7
+ require_relative "og_helpers"
8
+ require_relative "branding_variables"
6
9
 
7
10
  module Docyard
8
11
  class Renderer
9
12
  include Utils::UrlHelpers
13
+ include Utils::HtmlHelpers
10
14
  include IconHelpers
15
+ include OgHelpers
16
+ include BrandingVariables
11
17
 
12
18
  LAYOUTS_PATH = File.join(__dir__, "../templates", "layouts")
13
19
  ERRORS_PATH = File.join(__dir__, "../templates", "errors")
14
20
  PARTIALS_PATH = File.join(__dir__, "../templates", "partials")
15
21
  DEFAULT_LAYOUT = "default"
16
22
 
17
- attr_reader :base_url, :config
23
+ attr_reader :base_url, :config, :dev_mode, :sse_port
18
24
 
19
- def initialize(base_url: "/", config: nil)
25
+ def initialize(base_url: "/", config: nil, dev_mode: false, sse_port: nil)
20
26
  @base_url = normalize_base_url(base_url)
21
27
  @config = config
28
+ @dev_mode = dev_mode
29
+ @sse_port = sse_port
22
30
  end
23
31
 
24
32
  def render_file(file_path, sidebar_html: "", prev_next_html: "", breadcrumbs: nil, branding: {},
25
33
  template_options: {}, current_path: "/")
26
- markdown = Markdown.new(File.read(file_path), config: config, file_path: file_path)
34
+ raw_content = File.read(file_path)
35
+ markdown = Markdown.new(raw_content, config: config, file_path: file_path)
27
36
 
28
37
  render(
29
38
  content: strip_md_from_links(markdown.html),
30
39
  page_title: markdown.title || Constants::DEFAULT_SITE_TITLE,
40
+ page_description: markdown.description,
41
+ page_og_image: markdown.og_image,
31
42
  navigation: build_navigation(sidebar_html, prev_next_html, markdown.toc, breadcrumbs),
32
43
  branding: branding,
33
44
  template_options: template_options,
34
- current_path: current_path
45
+ current_path: current_path,
46
+ file_path: file_path,
47
+ raw_markdown: raw_content
35
48
  )
36
49
  end
37
50
 
51
+ def render_for_search(file_path)
52
+ markdown = Markdown.new(File.read(file_path), config: config, file_path: file_path)
53
+ title = markdown.title || Constants::DEFAULT_SITE_TITLE
54
+ content = strip_md_from_links(markdown.html)
55
+
56
+ <<~HTML
57
+ <!DOCTYPE html>
58
+ <html>
59
+ <head><title>#{escape_html(title)}</title></head>
60
+ <body><main data-pagefind-body>#{content}</main></body>
61
+ </html>
62
+ HTML
63
+ end
64
+
38
65
  def build_navigation(sidebar_html, prev_next_html, toc, breadcrumbs)
39
66
  { sidebar_html: sidebar_html, prev_next_html: prev_next_html, toc: toc, breadcrumbs: breadcrumbs }
40
67
  end
41
68
 
42
- def render(content:, page_title: Constants::DEFAULT_SITE_TITLE, navigation: {}, branding: {},
43
- template_options: {}, current_path: "/")
69
+ def render(content:, page_title: Constants::DEFAULT_SITE_TITLE, page_description: nil, page_og_image: nil,
70
+ navigation: {}, branding: {}, template_options: {}, current_path: "/", file_path: nil,
71
+ raw_markdown: nil)
44
72
  layout = template_options[:template] || DEFAULT_LAYOUT
45
73
  layout_path = File.join(LAYOUTS_PATH, "#{layout}.html.erb")
46
74
  template = File.read(layout_path)
47
75
 
48
- assign_content_variables(content, page_title, navigation)
76
+ assign_content_variables(content, page_title, navigation, raw_markdown)
49
77
  assign_branding_variables(branding, current_path)
78
+ assign_og_variables(branding, page_description, page_og_image, current_path)
50
79
  assign_template_variables(template_options)
80
+ assign_git_info(branding, file_path)
81
+ assign_feedback_variables
51
82
 
52
83
  ERB.new(template).result(binding)
53
84
  end
54
85
 
55
- def render_not_found
86
+ def render_not_found(branding: nil)
87
+ @primary_color = branding&.dig(:primary_color)
56
88
  render_error_template(404)
57
89
  end
58
90
 
59
- def render_server_error(error)
91
+ def render_server_error(error, branding: nil)
60
92
  @error_message = error.message
61
- @backtrace = error.backtrace.join("\n")
93
+ @backtrace = error.backtrace&.join("\n") || "No backtrace available"
94
+ @primary_color = branding&.dig(:primary_color)
62
95
  render_error_template(500)
63
96
  end
64
97
 
98
+ def render_redirect(target_url)
99
+ @target_url = target_url
100
+ render_error_template(:redirect)
101
+ end
102
+
65
103
  def render_error_template(status)
66
104
  error_template_path = File.join(ERRORS_PATH, "#{status}.html.erb")
67
105
  template = File.read(error_template_path)
68
106
  ERB.new(template).result(binding)
69
107
  end
70
108
 
109
+ VALID_IVAR_PATTERN = /\A[a-z_][a-z0-9_]*\z/i
110
+
71
111
  def render_partial(name, locals = {})
72
112
  partial_path = File.join(PARTIALS_PATH, "#{name}.html.erb")
73
113
  template = File.read(partial_path)
74
114
 
75
- locals.each { |key, value| instance_variable_set("@#{key}", value) }
115
+ locals.each do |key, value|
116
+ validate_variable_name!(key)
117
+ instance_variable_set("@#{key}", value)
118
+ end
76
119
 
77
120
  ERB.new(template).result(binding)
78
121
  end
79
122
 
123
+ def render_custom_visual(file_path)
124
+ return "" if file_path.nil? || file_path.empty?
125
+
126
+ source_dir = config&.source || "docs"
127
+ full_path = File.join(source_dir, file_path)
128
+
129
+ return "" unless File.exist?(full_path)
130
+
131
+ File.read(full_path)
132
+ end
133
+
134
+ def validate_variable_name!(name)
135
+ return if name.to_s.match?(VALID_IVAR_PATTERN)
136
+
137
+ raise ArgumentError, "Invalid variable name: #{name}"
138
+ end
139
+
80
140
  def asset_path(path)
81
141
  return path if path.nil? || path.start_with?("http://", "https://")
82
142
 
@@ -85,61 +145,14 @@ module Docyard
85
145
 
86
146
  private
87
147
 
88
- def assign_content_variables(content, page_title, navigation)
148
+ def assign_content_variables(content, page_title, navigation, raw_markdown)
89
149
  @content = content
90
150
  @page_title = page_title
91
151
  @sidebar_html = navigation[:sidebar_html] || ""
92
152
  @prev_next_html = navigation[:prev_next_html] || ""
93
153
  @toc = navigation[:toc] || []
94
154
  @breadcrumbs = navigation[:breadcrumbs]
95
- end
96
-
97
- def assign_branding_variables(branding, current_path = "/")
98
- assign_site_branding(branding)
99
- assign_search_options(branding)
100
- assign_credits_and_social(branding)
101
- assign_tabs(branding, current_path)
102
- end
103
-
104
- def assign_site_branding(branding)
105
- @site_title = branding[:site_title] || Constants::DEFAULT_SITE_TITLE
106
- @site_description = branding[:site_description] || ""
107
- @logo = branding[:logo] || Constants::DEFAULT_LOGO_PATH
108
- @logo_dark = branding[:logo_dark]
109
- @favicon = branding[:favicon] || Constants::DEFAULT_FAVICON_PATH
110
- @has_custom_logo = branding[:has_custom_logo] || false
111
- end
112
-
113
- def assign_search_options(branding)
114
- @search_enabled = branding[:search_enabled].nil? || branding[:search_enabled]
115
- @search_placeholder = branding[:search_placeholder] || "Search documentation..."
116
- end
117
-
118
- def assign_credits_and_social(branding)
119
- @credits = branding[:credits] != false
120
- @copyright = branding[:copyright]
121
- @social = branding[:social] || []
122
- @header_ctas = branding[:header_ctas] || []
123
- @announcement = branding[:announcement]
124
- end
125
-
126
- def assign_tabs(branding, current_path)
127
- tabs = branding[:tabs] || []
128
- @tabs = tabs.map { |tab| tab.merge(active: tab_active?(tab[:href], current_path)) }
129
- @has_tabs = branding[:has_tabs] || false
130
- @current_path = current_path
131
- end
132
-
133
- def tab_active?(tab_href, current_path)
134
- return false if tab_href.nil? || current_path.nil?
135
- return false if tab_href.start_with?("http://", "https://")
136
-
137
- normalized_tab = tab_href.chomp("/")
138
- normalized_current = current_path.chomp("/")
139
-
140
- return true if normalized_tab == normalized_current
141
-
142
- current_path.start_with?("#{normalized_tab}/")
155
+ @raw_markdown = raw_markdown
143
156
  end
144
157
 
145
158
  def assign_template_variables(template_options)
@@ -160,5 +173,27 @@ module Docyard
160
173
  def strip_md_from_links(html)
161
174
  html.gsub(/href="([^"]+)\.md"/, 'href="\1"')
162
175
  end
176
+
177
+ def assign_git_info(branding, file_path)
178
+ @show_edit_link = branding[:show_edit_link] && file_path
179
+ @show_last_updated = branding[:show_last_updated] && file_path
180
+ return unless @show_edit_link || @show_last_updated
181
+
182
+ git_info = Utils::GitInfo.new(
183
+ repo_url: branding[:repo_url],
184
+ branch: branding[:repo_branch],
185
+ edit_path: branding[:repo_edit_path]
186
+ )
187
+
188
+ @edit_url = git_info.edit_url(file_path) if @show_edit_link
189
+ @last_updated = git_info.last_updated(file_path) if @show_last_updated
190
+ end
191
+
192
+ def assign_feedback_variables
193
+ return unless config
194
+
195
+ @feedback_enabled = config.feedback.enabled
196
+ @feedback_question = config.feedback.question
197
+ end
163
198
  end
164
199
  end
@@ -117,6 +117,7 @@ module Docyard
117
117
  tagline: hero["tagline"],
118
118
  gradient: hero.fetch("gradient", true),
119
119
  image: symbolize_image(hero["image"]),
120
+ custom_visual: symbolize_custom_visual(hero["custom_visual"]),
120
121
  actions: symbolize_actions(hero["actions"])
121
122
  }.compact
122
123
  end
@@ -138,6 +139,19 @@ module Docyard
138
139
  end
139
140
  end
140
141
 
142
+ def symbolize_custom_visual(custom_visual)
143
+ return nil if custom_visual.nil?
144
+
145
+ if custom_visual.is_a?(String)
146
+ { html: custom_visual, placement: "side" }
147
+ elsif custom_visual.is_a?(Hash)
148
+ {
149
+ html: custom_visual["html"],
150
+ placement: custom_visual["placement"] || "side"
151
+ }
152
+ end
153
+ end
154
+
141
155
  def symbolize_actions(actions)
142
156
  return nil unless actions.is_a?(Array)
143
157
 
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../utils/path_utils"
4
+
3
5
  module Docyard
4
6
  module Routing
5
7
  class FallbackResolver
@@ -29,9 +31,7 @@ module Docyard
29
31
  end
30
32
 
31
33
  def sanitize_path(request_path)
32
- clean = request_path.to_s.delete_prefix("/").delete_suffix("/")
33
- clean = "index" if clean.empty?
34
- clean.delete_suffix(".md")
34
+ Utils::PathUtils.sanitize_url_path(request_path)
35
35
  end
36
36
 
37
37
  def find_first_item_in_section(request_path)
@@ -63,11 +63,11 @@ module Docyard
63
63
  end
64
64
 
65
65
  def log(message)
66
- puts message
66
+ Docyard.logger.info(message)
67
67
  end
68
68
 
69
69
  def log_warning(message)
70
- warn message
70
+ Docyard.logger.warn(message)
71
71
  end
72
72
  end
73
73
  end
@@ -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