docyard 0.8.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 (189) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +67 -1
  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/aliases.rb +12 -0
  13. data/lib/docyard/components/processors/abbreviation_processor.rb +72 -0
  14. data/lib/docyard/components/processors/accordion_processor.rb +81 -0
  15. data/lib/docyard/components/processors/badge_processor.rb +72 -0
  16. data/lib/docyard/components/processors/callout_processor.rb +9 -3
  17. data/lib/docyard/components/processors/cards_processor.rb +100 -0
  18. data/lib/docyard/components/processors/code_block_extended_fence_postprocessor.rb +24 -0
  19. data/lib/docyard/components/processors/code_block_extended_fence_preprocessor.rb +44 -0
  20. data/lib/docyard/components/processors/code_block_options_preprocessor.rb +34 -3
  21. data/lib/docyard/components/processors/code_block_processor.rb +11 -24
  22. data/lib/docyard/components/processors/code_group_processor.rb +182 -0
  23. data/lib/docyard/components/processors/code_snippet_import_preprocessor.rb +7 -1
  24. data/lib/docyard/components/processors/custom_anchor_processor.rb +42 -0
  25. data/lib/docyard/components/processors/file_tree_processor.rb +150 -0
  26. data/lib/docyard/components/processors/icon_processor.rb +8 -2
  27. data/lib/docyard/components/processors/image_caption_processor.rb +96 -0
  28. data/lib/docyard/components/processors/include_processor.rb +86 -0
  29. data/lib/docyard/components/processors/steps_processor.rb +89 -0
  30. data/lib/docyard/components/processors/tabs_processor.rb +9 -1
  31. data/lib/docyard/components/processors/tooltip_processor.rb +57 -0
  32. data/lib/docyard/components/processors/video_embed_processor.rb +207 -0
  33. data/lib/docyard/components/support/code_block/feature_extractor.rb +3 -1
  34. data/lib/docyard/components/support/code_block/icon_detector.rb +5 -12
  35. data/lib/docyard/components/support/code_block/line_number_resolver.rb +30 -0
  36. data/lib/docyard/components/support/code_detector.rb +2 -12
  37. data/lib/docyard/components/support/code_group/html_builder.rb +118 -0
  38. data/lib/docyard/components/support/markdown_code_block_helper.rb +56 -0
  39. data/lib/docyard/components/support/tabs/icon_detector.rb +6 -2
  40. data/lib/docyard/components/support/tabs/parser.rb +6 -23
  41. data/lib/docyard/config/analytics_resolver.rb +24 -0
  42. data/lib/docyard/config/branding_resolver.rb +84 -58
  43. data/lib/docyard/config/key_validator.rb +30 -0
  44. data/lib/docyard/config/logo_detector.rb +39 -0
  45. data/lib/docyard/config/schema.rb +39 -0
  46. data/lib/docyard/config/section.rb +21 -0
  47. data/lib/docyard/config/validation_helpers.rb +83 -0
  48. data/lib/docyard/config/validator.rb +45 -144
  49. data/lib/docyard/config/validators/navigation.rb +43 -0
  50. data/lib/docyard/config/validators/section.rb +114 -0
  51. data/lib/docyard/config.rb +45 -96
  52. data/lib/docyard/constants.rb +59 -0
  53. data/lib/docyard/{utils/errors.rb → errors.rb} +6 -0
  54. data/lib/docyard/initializer.rb +100 -49
  55. data/lib/docyard/navigation/page_navigation_builder.rb +65 -0
  56. data/lib/docyard/navigation/sidebar/auto_builder.rb +107 -0
  57. data/lib/docyard/navigation/sidebar/cache.rb +96 -0
  58. data/lib/docyard/navigation/sidebar/config_builder.rb +179 -0
  59. data/lib/docyard/navigation/sidebar/distributed_builder.rb +145 -0
  60. data/lib/docyard/navigation/sidebar/item.rb +6 -1
  61. data/lib/docyard/navigation/sidebar/local_config_loader.rb +69 -3
  62. data/lib/docyard/navigation/sidebar/renderer.rb +18 -3
  63. data/lib/docyard/navigation/sidebar_builder.rb +43 -81
  64. data/lib/docyard/rendering/branding_variables.rb +65 -0
  65. data/lib/docyard/rendering/icon_helpers.rb +14 -1
  66. data/lib/docyard/rendering/icons/devicons.rb +63 -0
  67. data/lib/docyard/rendering/icons.rb +26 -27
  68. data/lib/docyard/rendering/markdown.rb +20 -15
  69. data/lib/docyard/rendering/og_helpers.rb +36 -0
  70. data/lib/docyard/rendering/renderer.rb +87 -58
  71. data/lib/docyard/rendering/template_resolver.rb +14 -0
  72. data/lib/docyard/routing/fallback_resolver.rb +3 -3
  73. data/lib/docyard/search/build_indexer.rb +2 -2
  74. data/lib/docyard/search/dev_indexer.rb +36 -28
  75. data/lib/docyard/search/pagefind_support.rb +1 -1
  76. data/lib/docyard/server/asset_handler.rb +40 -15
  77. data/lib/docyard/server/dev_server.rb +90 -55
  78. data/lib/docyard/server/file_watcher.rb +68 -18
  79. data/lib/docyard/server/pagefind_handler.rb +1 -1
  80. data/lib/docyard/server/preview_server.rb +29 -33
  81. data/lib/docyard/server/rack_application.rb +38 -70
  82. data/lib/docyard/server/router.rb +11 -7
  83. data/lib/docyard/server/sse_server.rb +157 -0
  84. data/lib/docyard/server/static_file_app.rb +42 -0
  85. data/lib/docyard/templates/assets/css/components/abbreviation.css +86 -0
  86. data/lib/docyard/templates/assets/css/components/accordion.css +138 -0
  87. data/lib/docyard/templates/assets/css/components/badges.css +47 -0
  88. data/lib/docyard/templates/assets/css/components/banner.css +233 -0
  89. data/lib/docyard/templates/assets/css/components/breadcrumbs.css +2 -1
  90. data/lib/docyard/templates/assets/css/components/callout.css +26 -6
  91. data/lib/docyard/templates/assets/css/components/cards.css +100 -0
  92. data/lib/docyard/templates/assets/css/components/code-block.css +14 -2
  93. data/lib/docyard/templates/assets/css/components/code-group.css +294 -0
  94. data/lib/docyard/templates/assets/css/components/feedback.css +126 -0
  95. data/lib/docyard/templates/assets/css/components/figure.css +22 -0
  96. data/lib/docyard/templates/assets/css/components/file-tree.css +125 -0
  97. data/lib/docyard/templates/assets/css/components/heading-anchor.css +21 -13
  98. data/lib/docyard/templates/assets/css/components/icon.css +5 -0
  99. data/lib/docyard/templates/assets/css/components/lightbox.css +65 -0
  100. data/lib/docyard/templates/assets/css/components/nav-menu.css +20 -4
  101. data/lib/docyard/templates/assets/css/components/navigation.css +32 -3
  102. data/lib/docyard/templates/assets/css/components/page-actions.css +131 -0
  103. data/lib/docyard/templates/assets/css/components/prev-next.css +20 -22
  104. data/lib/docyard/templates/assets/css/components/search.css +6 -10
  105. data/lib/docyard/templates/assets/css/components/steps.css +122 -0
  106. data/lib/docyard/templates/assets/css/components/tab-bar.css +7 -4
  107. data/lib/docyard/templates/assets/css/components/table-of-contents.css +57 -11
  108. data/lib/docyard/templates/assets/css/components/tabs.css +13 -5
  109. data/lib/docyard/templates/assets/css/components/theme-toggle.css +3 -1
  110. data/lib/docyard/templates/assets/css/components/tooltip.css +113 -0
  111. data/lib/docyard/templates/assets/css/components/video.css +41 -0
  112. data/lib/docyard/templates/assets/css/landing.css +82 -13
  113. data/lib/docyard/templates/assets/css/layout.css +17 -0
  114. data/lib/docyard/templates/assets/css/markdown.css +25 -3
  115. data/lib/docyard/templates/assets/css/variables.css +13 -1
  116. data/lib/docyard/templates/assets/js/components/abbreviation.js +85 -0
  117. data/lib/docyard/templates/assets/js/components/banner.js +81 -0
  118. data/lib/docyard/templates/assets/js/components/code-group.js +286 -0
  119. data/lib/docyard/templates/assets/js/components/copy-page.js +115 -0
  120. data/lib/docyard/templates/assets/js/components/feedback.js +66 -0
  121. data/lib/docyard/templates/assets/js/components/file-tree.js +39 -0
  122. data/lib/docyard/templates/assets/js/components/lightbox.js +72 -0
  123. data/lib/docyard/templates/assets/js/components/navigation.js +3 -3
  124. data/lib/docyard/templates/assets/js/components/search.js +3 -3
  125. data/lib/docyard/templates/assets/js/components/table-of-contents.js +12 -6
  126. data/lib/docyard/templates/assets/js/components/tabs.js +45 -22
  127. data/lib/docyard/templates/assets/js/components/tooltip.js +118 -0
  128. data/lib/docyard/templates/assets/js/hot-reload.js +44 -0
  129. data/lib/docyard/templates/errors/404.html.erb +114 -5
  130. data/lib/docyard/templates/errors/500.html.erb +173 -10
  131. data/lib/docyard/templates/init/_sidebar.yml +36 -0
  132. data/lib/docyard/templates/init/docyard.yml +36 -0
  133. data/lib/docyard/templates/init/pages/components.md +146 -0
  134. data/lib/docyard/templates/init/pages/getting-started.md +94 -0
  135. data/lib/docyard/templates/init/pages/index.md +22 -0
  136. data/lib/docyard/templates/layouts/default.html.erb +11 -0
  137. data/lib/docyard/templates/layouts/splash.html.erb +15 -1
  138. data/lib/docyard/templates/partials/_accordion.html.erb +9 -0
  139. data/lib/docyard/templates/partials/_analytics.html.erb +24 -0
  140. data/lib/docyard/templates/partials/_banner.html.erb +27 -0
  141. data/lib/docyard/templates/partials/_card.html.erb +23 -0
  142. data/lib/docyard/templates/partials/_code_block.html.erb +1 -1
  143. data/lib/docyard/templates/partials/_feedback.html.erb +14 -0
  144. data/lib/docyard/templates/partials/_footer.html.erb +1 -1
  145. data/lib/docyard/templates/partials/_head.html.erb +79 -4
  146. data/lib/docyard/templates/partials/_icon_library.html.erb +8 -0
  147. data/lib/docyard/templates/partials/_nav_group.html.erb +6 -0
  148. data/lib/docyard/templates/partials/_nav_leaf.html.erb +3 -0
  149. data/lib/docyard/templates/partials/_page_actions.html.erb +21 -0
  150. data/lib/docyard/templates/partials/_scripts.html.erb +6 -3
  151. data/lib/docyard/templates/partials/_step.html.erb +14 -0
  152. data/lib/docyard/templates/partials/_tabs.html.erb +4 -1
  153. data/lib/docyard/utils/git_info.rb +157 -0
  154. data/lib/docyard/utils/hash_utils.rb +31 -0
  155. data/lib/docyard/utils/html_helpers.rb +8 -0
  156. data/lib/docyard/utils/logging.rb +44 -3
  157. data/lib/docyard/utils/path_resolver.rb +0 -10
  158. data/lib/docyard/utils/path_utils.rb +73 -0
  159. data/lib/docyard/version.rb +1 -1
  160. data/lib/docyard.rb +2 -2
  161. metadata +114 -47
  162. data/.github/ISSUE_TEMPLATE/bug_report.md +0 -31
  163. data/.github/ISSUE_TEMPLATE/feature_request.md +0 -19
  164. data/.github/pull_request_template.md +0 -14
  165. data/.github/workflows/ci.yml +0 -49
  166. data/.rubocop.yml +0 -42
  167. data/CODE_OF_CONDUCT.md +0 -132
  168. data/CONTRIBUTING.md +0 -55
  169. data/LICENSE.vscode-icons +0 -42
  170. data/Rakefile +0 -8
  171. data/lib/docyard/config/constants.rb +0 -31
  172. data/lib/docyard/navigation/sidebar/children_discoverer.rb +0 -51
  173. data/lib/docyard/navigation/sidebar/config_parser.rb +0 -208
  174. data/lib/docyard/navigation/sidebar/file_resolver.rb +0 -78
  175. data/lib/docyard/navigation/sidebar/file_system_scanner.rb +0 -78
  176. data/lib/docyard/navigation/sidebar/metadata_extractor.rb +0 -69
  177. data/lib/docyard/navigation/sidebar/metadata_reader.rb +0 -47
  178. data/lib/docyard/navigation/sidebar/path_prefixer.rb +0 -34
  179. data/lib/docyard/navigation/sidebar/sorter.rb +0 -21
  180. data/lib/docyard/navigation/sidebar/title_extractor.rb +0 -25
  181. data/lib/docyard/navigation/sidebar/tree_builder.rb +0 -139
  182. data/lib/docyard/rendering/icons/LICENSE.phosphor +0 -21
  183. data/lib/docyard/rendering/icons/file_types.rb +0 -79
  184. data/lib/docyard/rendering/icons/phosphor.rb +0 -90
  185. data/lib/docyard/rendering/language_mapping.rb +0 -52
  186. data/lib/docyard/templates/assets/js/reload.js +0 -98
  187. data/lib/docyard/templates/partials/_icon.html.erb +0 -1
  188. data/lib/docyard/templates/partials/_icon_file_extension.html.erb +0 -1
  189. data/sig/docyard.rbs +0 -4
@@ -1,53 +1,84 @@
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)
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
@@ -58,7 +89,7 @@ module Docyard
58
89
 
59
90
  def render_server_error(error)
60
91
  @error_message = error.message
61
- @backtrace = error.backtrace.join("\n")
92
+ @backtrace = error.backtrace&.join("\n") || "No backtrace available"
62
93
  render_error_template(500)
63
94
  end
64
95
 
@@ -68,15 +99,37 @@ module Docyard
68
99
  ERB.new(template).result(binding)
69
100
  end
70
101
 
102
+ VALID_IVAR_PATTERN = /\A[a-z_][a-z0-9_]*\z/i
103
+
71
104
  def render_partial(name, locals = {})
72
105
  partial_path = File.join(PARTIALS_PATH, "#{name}.html.erb")
73
106
  template = File.read(partial_path)
74
107
 
75
- locals.each { |key, value| instance_variable_set("@#{key}", value) }
108
+ locals.each do |key, value|
109
+ validate_variable_name!(key)
110
+ instance_variable_set("@#{key}", value)
111
+ end
76
112
 
77
113
  ERB.new(template).result(binding)
78
114
  end
79
115
 
116
+ def render_custom_visual(file_path)
117
+ return "" if file_path.nil? || file_path.empty?
118
+
119
+ source_dir = config&.source || "docs"
120
+ full_path = File.join(source_dir, file_path)
121
+
122
+ return "" unless File.exist?(full_path)
123
+
124
+ File.read(full_path)
125
+ end
126
+
127
+ def validate_variable_name!(name)
128
+ return if name.to_s.match?(VALID_IVAR_PATTERN)
129
+
130
+ raise ArgumentError, "Invalid variable name: #{name}"
131
+ end
132
+
80
133
  def asset_path(path)
81
134
  return path if path.nil? || path.start_with?("http://", "https://")
82
135
 
@@ -85,60 +138,14 @@ module Docyard
85
138
 
86
139
  private
87
140
 
88
- def assign_content_variables(content, page_title, navigation)
141
+ def assign_content_variables(content, page_title, navigation, raw_markdown)
89
142
  @content = content
90
143
  @page_title = page_title
91
144
  @sidebar_html = navigation[:sidebar_html] || ""
92
145
  @prev_next_html = navigation[:prev_next_html] || ""
93
146
  @toc = navigation[:toc] || []
94
147
  @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
- end
124
-
125
- def assign_tabs(branding, current_path)
126
- tabs = branding[:tabs] || []
127
- @tabs = tabs.map { |tab| tab.merge(active: tab_active?(tab[:href], current_path)) }
128
- @has_tabs = branding[:has_tabs] || false
129
- @current_path = current_path
130
- end
131
-
132
- def tab_active?(tab_href, current_path)
133
- return false if tab_href.nil? || current_path.nil?
134
- return false if tab_href.start_with?("http://", "https://")
135
-
136
- normalized_tab = tab_href.chomp("/")
137
- normalized_current = current_path.chomp("/")
138
-
139
- return true if normalized_tab == normalized_current
140
-
141
- current_path.start_with?("#{normalized_tab}/")
148
+ @raw_markdown = raw_markdown
142
149
  end
143
150
 
144
151
  def assign_template_variables(template_options)
@@ -159,5 +166,27 @@ module Docyard
159
166
  def strip_md_from_links(html)
160
167
  html.gsub(/href="([^"]+)\.md"/, 'href="\1"')
161
168
  end
169
+
170
+ def assign_git_info(branding, file_path)
171
+ @show_edit_link = branding[:show_edit_link] && file_path
172
+ @show_last_updated = branding[:show_last_updated] && file_path
173
+ return unless @show_edit_link || @show_last_updated
174
+
175
+ git_info = Utils::GitInfo.new(
176
+ repo_url: branding[:repo_url],
177
+ branch: branding[:repo_branch],
178
+ edit_path: branding[:repo_edit_path]
179
+ )
180
+
181
+ @edit_url = git_info.edit_url(file_path) if @show_edit_link
182
+ @last_updated = git_info.last_updated(file_path) if @show_last_updated
183
+ end
184
+
185
+ def assign_feedback_variables
186
+ return unless config
187
+
188
+ @feedback_enabled = config.feedback.enabled
189
+ @feedback_question = config.feedback.question
190
+ end
162
191
  end
163
192
  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",
@@ -12,6 +19,7 @@ module Docyard
12
19
  ".jpeg" => "image/jpeg",
13
20
  ".gif" => "image/gif",
14
21
  ".webp" => "image/webp",
22
+ ".avif" => "image/avif",
15
23
  ".svg" => "image/svg+xml",
16
24
  ".woff" => "font/woff",
17
25
  ".woff2" => "font/woff2",
@@ -22,47 +30,52 @@ module Docyard
22
30
  ".webm" => "video/webm"
23
31
  }.freeze
24
32
 
25
- def serve_docyard_assets(request_path)
26
- asset_path = request_path.delete_prefix("/_docyard/")
33
+ def initialize(public_dir: DEFAULT_PUBLIC_DIR)
34
+ @public_dir = public_dir
35
+ end
27
36
 
28
- 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/"))
29
39
 
30
40
  return serve_components_css if asset_path == "css/components.css"
31
41
  return serve_components_js if asset_path == "js/components.js"
32
42
 
33
- 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
34
45
  return not_found_response unless File.file?(file_path)
35
46
 
36
47
  serve_file(file_path)
37
48
  end
38
49
 
39
50
  def serve_public_file(request_path)
40
- asset_path = request_path.delete_prefix("/")
51
+ asset_path = Utils::PathUtils.decode_path(request_path.delete_prefix("/"))
41
52
 
42
- return nil if directory_traversal?(asset_path)
43
-
44
- file_path = File.join(Constants::PUBLIC_DIR, asset_path)
45
- 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)
46
55
 
47
56
  serve_file(file_path)
48
57
  end
49
58
 
50
59
  private
51
60
 
52
- def directory_traversal?(path)
53
- path.include?("..")
61
+ def safe_asset_path(relative_path, base_dir)
62
+ Utils::PathUtils.resolve_safe_path(relative_path, base_dir)
54
63
  end
55
64
 
56
65
  def serve_file(file_path)
57
66
  content = File.read(file_path)
58
- 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)
59
69
 
60
- [200, { "Content-Type" => content_type }, [content]]
70
+ [200, headers, [content]]
61
71
  end
62
72
 
63
73
  def serve_components_css
64
74
  content = concatenate_component_css
65
- [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]]
66
79
  end
67
80
 
68
81
  def concatenate_component_css
@@ -75,7 +88,10 @@ module Docyard
75
88
 
76
89
  def serve_components_js
77
90
  content = concatenate_component_js
78
- [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]]
79
95
  end
80
96
 
81
97
  def concatenate_component_js
@@ -91,6 +107,15 @@ module Docyard
91
107
  CONTENT_TYPES.fetch(extension, "application/octet-stream")
92
108
  end
93
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
+
94
119
  def forbidden_response
95
120
  [403, { "Content-Type" => "text/plain" }, ["403 Forbidden"]]
96
121
  end