docyard 1.1.0 → 1.2.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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +17 -1
  3. data/README.md +56 -11
  4. data/lib/docyard/build/asset_bundler.rb +17 -1
  5. data/lib/docyard/build/social_cards/card_renderer.rb +132 -0
  6. data/lib/docyard/build/social_cards/doc_card.rb +97 -0
  7. data/lib/docyard/build/social_cards/homepage_card.rb +98 -0
  8. data/lib/docyard/build/social_cards_generator.rb +188 -0
  9. data/lib/docyard/build/step_runner.rb +2 -0
  10. data/lib/docyard/builder.rb +10 -0
  11. data/lib/docyard/cli.rb +27 -0
  12. data/lib/docyard/config/branding_resolver.rb +1 -1
  13. data/lib/docyard/config/schema/definition.rb +2 -1
  14. data/lib/docyard/config/schema/sections.rb +0 -1
  15. data/lib/docyard/config/schema/simple_sections.rb +7 -0
  16. data/lib/docyard/config.rb +3 -1
  17. data/lib/docyard/customizer.rb +196 -0
  18. data/lib/docyard/rendering/markdown.rb +4 -0
  19. data/lib/docyard/rendering/og_helpers.rb +19 -1
  20. data/lib/docyard/rendering/renderer.rb +10 -1
  21. data/lib/docyard/search/build_indexer.rb +8 -3
  22. data/lib/docyard/search/dev_indexer.rb +3 -2
  23. data/lib/docyard/search/pagefind_binary.rb +185 -0
  24. data/lib/docyard/search/pagefind_support.rb +9 -7
  25. data/lib/docyard/server/asset_handler.rb +28 -2
  26. data/lib/docyard/server/dev_server.rb +1 -0
  27. data/lib/docyard/server/file_watcher.rb +10 -5
  28. data/lib/docyard/server/rack_application.rb +1 -1
  29. data/lib/docyard/templates/assets/css/variables.css +0 -6
  30. data/lib/docyard/templates/assets/js/components/abbreviation.js +20 -11
  31. data/lib/docyard/templates/assets/js/components/code-block.js +8 -3
  32. data/lib/docyard/templates/assets/js/components/code-group.js +20 -3
  33. data/lib/docyard/templates/assets/js/components/file-tree.js +9 -3
  34. data/lib/docyard/templates/assets/js/components/heading-anchor.js +71 -72
  35. data/lib/docyard/templates/assets/js/components/lightbox.js +10 -3
  36. data/lib/docyard/templates/assets/js/components/tabs.js +8 -3
  37. data/lib/docyard/templates/assets/js/components/tooltip.js +32 -23
  38. data/lib/docyard/templates/assets/js/hot-reload.js +42 -2
  39. data/lib/docyard/templates/partials/_head.html.erb +4 -0
  40. data/lib/docyard/templates/partials/_scripts.html.erb +3 -0
  41. data/lib/docyard/version.rb +1 -1
  42. data/lib/docyard.rb +2 -0
  43. metadata +7 -1
@@ -5,6 +5,7 @@ module Docyard
5
5
  class StepRunner
6
6
  STEP_SHORT_LABELS = {
7
7
  "Generating pages" => "Pages",
8
+ "Social cards" => "Cards",
8
9
  "Bundling assets" => "Assets",
9
10
  "Copying files" => "Files",
10
11
  "Generating SEO" => "SEO",
@@ -59,6 +60,7 @@ module Docyard
59
60
  def format_result(label, result)
60
61
  case label
61
62
  when "Generating pages" then "done (#{result} pages)"
63
+ when "Social cards" then "done (#{result} cards)"
62
64
  when "Bundling assets" then format_assets_result(result)
63
65
  when "Copying files" then "done (#{result} files)"
64
66
  when "Generating SEO" then "done (#{result.join(', ')})"
@@ -52,6 +52,7 @@ module Docyard
52
52
  @step_runner.run("Generating pages") { generate_static_pages }
53
53
  @step_runner.run("Bundling assets") { bundle_assets }
54
54
  @step_runner.run("Copying files") { copy_static_files }
55
+ @step_runner.run("Social cards") { generate_social_cards } if social_cards_enabled?
55
56
  @step_runner.run("Generating SEO") { generate_seo_files }
56
57
  @step_runner.run("Indexing search") { generate_search_index }
57
58
  end
@@ -130,6 +131,15 @@ module Docyard
130
131
  Search::BuildIndexer.new(config, verbose: verbose).index
131
132
  end
132
133
 
134
+ def generate_social_cards
135
+ require_relative "build/social_cards_generator"
136
+ Build::SocialCardsGenerator.new(config, verbose: verbose).generate
137
+ end
138
+
139
+ def social_cards_enabled?
140
+ config.social_cards&.enabled == true
141
+ end
142
+
133
143
  def robots_txt_content
134
144
  base = config.build.base
135
145
  base = "#{base}/" unless base.end_with?("/")
data/lib/docyard/cli.rb CHANGED
@@ -80,6 +80,23 @@ module Docyard
80
80
  exit(doctor.run)
81
81
  end
82
82
 
83
+ desc "customize", "Generate theme customization files"
84
+ method_option :minimal, type: :boolean, default: false, aliases: "-m",
85
+ desc: "Generate minimal files without comments"
86
+ def customize
87
+ apply_global_options
88
+ require_relative "customizer"
89
+ Docyard::Customizer.new(minimal: options[:minimal]).generate
90
+ rescue ConfigError => e
91
+ print_config_error(e)
92
+ rescue Errno::EACCES => e
93
+ print_file_error("Permission denied", e.message)
94
+ rescue Errno::ENOSPC
95
+ print_file_error("Disk full", "No space left on device")
96
+ rescue SystemCallError => e
97
+ print_file_error("File operation failed", e.message)
98
+ end
99
+
83
100
  private
84
101
 
85
102
  def apply_global_options
@@ -94,5 +111,15 @@ module Docyard
94
111
  puts
95
112
  exit(1)
96
113
  end
114
+
115
+ def print_file_error(title, message)
116
+ puts
117
+ puts " #{UI.bold('Docyard')} v#{VERSION}"
118
+ puts
119
+ puts " #{UI.red("#{title}:")}"
120
+ puts " #{message}"
121
+ puts
122
+ exit(1)
123
+ end
97
124
  end
98
125
  end
@@ -38,7 +38,7 @@ module Docyard
38
38
  site_options, logo_options, search_options, credits_options, social_options,
39
39
  navigation_options, tabs_options, announcement_options, repo_options, analytics_options,
40
40
  color_options
41
- ].reduce({}, :merge)
41
+ ].reduce({}, :merge).merge(social_cards_enabled: config.social_cards&.enabled == true)
42
42
  end
43
43
 
44
44
  def site_options
@@ -22,7 +22,8 @@ module Docyard
22
22
  announcement: ANNOUNCEMENT_SCHEMA,
23
23
  repo: REPO_SCHEMA,
24
24
  analytics: ANALYTICS_SCHEMA,
25
- feedback: FEEDBACK_SCHEMA
25
+ feedback: FEEDBACK_SCHEMA,
26
+ social_cards: SOCIAL_CARDS_SCHEMA
26
27
  }.freeze
27
28
  end
28
29
  end
@@ -15,7 +15,6 @@ module Docyard
15
15
  slack: { type: :url },
16
16
  linkedin: { type: :url },
17
17
  youtube: { type: :url },
18
- bluesky: { type: :url },
19
18
  custom: {
20
19
  type: :array,
21
20
  items: {
@@ -73,6 +73,13 @@ module Docyard
73
73
  question: { type: :string }
74
74
  }
75
75
  }.freeze
76
+
77
+ SOCIAL_CARDS_SCHEMA = {
78
+ type: :hash,
79
+ keys: {
80
+ enabled: { type: :boolean }
81
+ }
82
+ }.freeze
76
83
  end
77
84
  end
78
85
  end
@@ -26,7 +26,8 @@ module Docyard
26
26
  "repo" => { "url" => nil, "branch" => "main", "edit_path" => nil, "edit_link" => true,
27
27
  "last_updated" => true },
28
28
  "analytics" => { "google" => nil, "plausible" => nil, "fathom" => nil, "script" => nil },
29
- "feedback" => { "enabled" => false, "question" => "Was this page helpful?" }
29
+ "feedback" => { "enabled" => false, "question" => "Was this page helpful?" },
30
+ "social_cards" => { "enabled" => false }
30
31
  }.freeze
31
32
 
32
33
  attr_reader :data, :file_path
@@ -63,6 +64,7 @@ module Docyard
63
64
  def repo = @repo ||= Section.new(data["repo"])
64
65
  def analytics = @analytics ||= Section.new(data["analytics"])
65
66
  def feedback = @feedback ||= Section.new(data["feedback"])
67
+ def social_cards = @social_cards ||= Section.new(data["social_cards"])
66
68
 
67
69
  def announcement
68
70
  @announcement ||= data["announcement"] ? Section.new(data["announcement"]) : nil
@@ -0,0 +1,196 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docyard
4
+ class Customizer
5
+ CUSTOM_DIR = "_custom"
6
+ STYLES_FILE = "styles.css"
7
+ SCRIPTS_FILE = "scripts.js"
8
+ VARIABLES_PATH = File.join(__dir__, "templates", "assets", "css", "variables.css")
9
+
10
+ CATEGORY_PATTERNS = {
11
+ /^--sidebar-width/ => "Layout",
12
+ /^--sidebar/ => "Sidebar",
13
+ /^--code|^--diff/ => "Code",
14
+ /^--callout/ => "Callouts",
15
+ /^--table|^--hr/ => "Tables",
16
+ /^--search|^--overlay/ => "Search",
17
+ /^--font|^--text|^--leading/ => "Typography",
18
+ /^--spacing/ => "Spacing",
19
+ /^--toc|^--header|^--content|^--layout|^--tab-bar|^--secondary-header/ => "Layout",
20
+ /^--radius/ => "Radius",
21
+ /^--shadow/ => "Shadows",
22
+ /^--transition/ => "Transitions",
23
+ /^--z-/ => "Z-Index",
24
+ /^--ring/ => "Other"
25
+ }.freeze
26
+
27
+ CATEGORIES = %w[
28
+ Colors Sidebar Code Callouts Tables Search
29
+ Typography Spacing Layout Radius Shadows Transitions Z-Index Other
30
+ ].freeze
31
+
32
+ attr_reader :config, :minimal
33
+
34
+ def initialize(minimal: false)
35
+ @config = Config.load
36
+ @minimal = minimal
37
+ end
38
+
39
+ def generate
40
+ validate_source_directory
41
+ create_custom_directory
42
+ write_styles_file
43
+ write_scripts_file
44
+ print_success
45
+ end
46
+
47
+ private
48
+
49
+ def validate_source_directory
50
+ return if File.directory?(config.source)
51
+
52
+ raise ConfigError, "Source directory '#{config.source}' does not exist"
53
+ end
54
+
55
+ def custom_dir_path
56
+ @custom_dir_path ||= File.join(config.source, CUSTOM_DIR)
57
+ end
58
+
59
+ def styles_path
60
+ @styles_path ||= File.join(custom_dir_path, STYLES_FILE)
61
+ end
62
+
63
+ def scripts_path
64
+ @scripts_path ||= File.join(custom_dir_path, SCRIPTS_FILE)
65
+ end
66
+
67
+ def create_custom_directory
68
+ FileUtils.mkdir_p(custom_dir_path)
69
+ end
70
+
71
+ def write_styles_file
72
+ File.write(styles_path, minimal ? minimal_styles : annotated_styles)
73
+ end
74
+
75
+ def write_scripts_file
76
+ File.write(scripts_path, minimal ? minimal_scripts : annotated_scripts)
77
+ end
78
+
79
+ def annotated_styles
80
+ build_annotated_css(parse_variables)
81
+ end
82
+
83
+ def minimal_styles
84
+ build_minimal_css(parse_variables)
85
+ end
86
+
87
+ def parse_variables
88
+ content = File.read(VARIABLES_PATH)
89
+ { light: extract_variables(content, ":root"), dark: extract_variables(content, ".dark") }
90
+ end
91
+
92
+ def extract_variables(content, selector)
93
+ match = content.match(/#{Regexp.escape(selector)}\s*\{([^}]+)\}/m)
94
+ return [] unless match
95
+
96
+ match[1].scan(/(--[\w-]+):\s*([^;]+);/).map { |name, value| { name: name, value: value.strip } }
97
+ end
98
+
99
+ def build_annotated_css(variables)
100
+ light = format_annotated(variables[:light])
101
+ dark = format_annotated(variables[:dark])
102
+ "#{css_header}:root {\n#{light}\n}\n\n.dark {\n#{dark}\n}\n"
103
+ end
104
+
105
+ def build_minimal_css(variables)
106
+ ":root {\n#{format_minimal(variables[:light])}\n}\n\n.dark {\n#{format_minimal(variables[:dark])}\n}\n"
107
+ end
108
+
109
+ def css_header
110
+ <<~HEADER
111
+ /* =============================================================================
112
+ DOCYARD THEME CUSTOMIZATION
113
+
114
+ Uncomment and modify variables to customize your site.
115
+ Delete any variables you don't need to change.
116
+
117
+ Generated with: docyard customize
118
+ ============================================================================= */
119
+
120
+ HEADER
121
+ end
122
+
123
+ def format_annotated(vars)
124
+ group_variables(vars).flat_map do |category, category_vars|
125
+ [" /* #{category} */"] + category_vars.map { |v| " /* #{v[:name]}: #{v[:value]}; */" } + [""]
126
+ end.join("\n").rstrip
127
+ end
128
+
129
+ def format_minimal(vars)
130
+ vars.map { |var| " /* #{var[:name]}: #{var[:value]}; */" }.join("\n")
131
+ end
132
+
133
+ def group_variables(vars)
134
+ groups = CATEGORIES.to_h { |cat| [cat, []] }
135
+ vars.each { |var| groups[categorize_variable(var[:name])] << var }
136
+ groups.reject { |_, v| v.empty? }
137
+ end
138
+
139
+ def categorize_variable(name)
140
+ CATEGORY_PATTERNS.each { |pattern, category| return category if name.match?(pattern) }
141
+ "Colors"
142
+ end
143
+
144
+ def annotated_scripts
145
+ "#{js_header}document.addEventListener('DOMContentLoaded', function() {\n // Your custom JavaScript here\n});\n"
146
+ end
147
+
148
+ def js_header
149
+ <<~HEADER
150
+ /* =============================================================================
151
+ DOCYARD CUSTOM SCRIPTS
152
+
153
+ This file is loaded on every page after the default scripts.
154
+
155
+ Generated with: docyard customize
156
+ ============================================================================= */
157
+
158
+ HEADER
159
+ end
160
+
161
+ def minimal_scripts
162
+ "document.addEventListener('DOMContentLoaded', function() {\n // Your custom JavaScript here\n});\n"
163
+ end
164
+
165
+ def print_success
166
+ puts
167
+ print_header
168
+ print_created_files
169
+ print_next_steps
170
+ end
171
+
172
+ def print_header
173
+ puts " #{UI.bold('Docyard')} v#{VERSION}"
174
+ puts
175
+ end
176
+
177
+ def print_created_files
178
+ puts " #{UI.success('Created:')}"
179
+ puts UI.dim(" #{relative_path(custom_dir_path)}/")
180
+ puts UI.dim(" #{STYLES_FILE}")
181
+ puts UI.dim(" #{SCRIPTS_FILE}")
182
+ puts
183
+ end
184
+
185
+ def print_next_steps
186
+ puts " #{UI.bold('Next steps:')}"
187
+ puts " Edit #{UI.cyan(relative_path(styles_path))} to customize theme"
188
+ puts " Changes apply on next serve or build"
189
+ puts
190
+ end
191
+
192
+ def relative_path(path)
193
+ path.sub("#{Dir.pwd}/", "")
194
+ end
195
+ end
196
+ end
@@ -69,6 +69,10 @@ module Docyard
69
69
  frontmatter["og_image"]
70
70
  end
71
71
 
72
+ def social_cards
73
+ frontmatter["social_cards"]
74
+ end
75
+
72
76
  def toc
73
77
  @context[:toc] || []
74
78
  end
@@ -2,6 +2,8 @@
2
2
 
3
3
  module Docyard
4
4
  module OgHelpers
5
+ SOCIAL_CARDS_OUTPUT_DIR = "_docyard/og"
6
+
5
7
  def assign_og_variables(branding, page_description, page_og_image, current_path)
6
8
  site_url = branding[:site_url]
7
9
  @og_enabled = !site_url.nil? && !site_url.empty?
@@ -9,12 +11,28 @@ module Docyard
9
11
 
10
12
  @og_url = build_canonical_url(site_url, current_path)
11
13
  @og_description = page_description || @site_description
12
- @og_image = build_og_image_url(site_url, page_og_image || branding[:og_image])
14
+ @og_image = resolve_og_image(site_url, page_og_image, branding, current_path)
13
15
  @og_twitter = branding[:twitter]
14
16
  end
15
17
 
16
18
  private
17
19
 
20
+ def resolve_og_image(site_url, page_og_image, branding, current_path)
21
+ explicit_image = page_og_image || branding[:og_image]
22
+ return build_og_image_url(site_url, explicit_image) if explicit_image
23
+
24
+ return nil unless branding[:social_cards_enabled]
25
+
26
+ generated_card_path = social_card_path_for(current_path)
27
+ build_og_image_url(site_url, generated_card_path)
28
+ end
29
+
30
+ def social_card_path_for(current_path)
31
+ path = current_path.delete_prefix("/")
32
+ path = "index" if path.empty?
33
+ "/#{SOCIAL_CARDS_OUTPUT_DIR}/#{path}.png"
34
+ end
35
+
18
36
  def build_canonical_url(site_url, current_path)
19
37
  base = site_url.chomp("/")
20
38
  path = current_path.start_with?("/") ? current_path : "/#{current_path}"
@@ -8,7 +8,7 @@ require_relative "og_helpers"
8
8
  require_relative "branding_variables"
9
9
 
10
10
  module Docyard
11
- class Renderer
11
+ class Renderer # rubocop:disable Metrics/ClassLength
12
12
  include Utils::UrlHelpers
13
13
  include Utils::HtmlHelpers
14
14
  include IconHelpers
@@ -153,6 +153,15 @@ module Docyard
153
153
  @toc = navigation[:toc] || []
154
154
  @breadcrumbs = navigation[:breadcrumbs]
155
155
  @raw_markdown = raw_markdown
156
+ assign_custom_file_variables
157
+ end
158
+
159
+ def assign_custom_file_variables
160
+ source_dir = config&.source || "docs"
161
+ custom_css_path = File.join(source_dir, "_custom", "styles.css")
162
+ custom_js_path = File.join(source_dir, "_custom", "scripts.js")
163
+ @custom_css_exists = File.exist?(custom_css_path)
164
+ @custom_js_exists = File.exist?(custom_js_path)
156
165
  end
157
166
 
158
167
  def assign_template_variables(template_options)
@@ -7,8 +7,6 @@ module Docyard
7
7
  class BuildIndexer
8
8
  include PagefindSupport
9
9
 
10
- PAGEFIND_COMMAND = "npx"
11
-
12
10
  attr_reader :config, :output_dir, :verbose
13
11
 
14
12
  def initialize(config, verbose: false)
@@ -26,10 +24,17 @@ module Docyard
26
24
 
27
25
  private
28
26
 
27
+ def pagefind_available?
28
+ result = super
29
+ Docyard.logger.warn("Search disabled: Pagefind binary not available") unless result
30
+ result
31
+ end
32
+
29
33
  def run_pagefind
34
+ command = pagefind_command
30
35
  args = build_pagefind_args(output_dir)
31
36
 
32
- stdout, stderr, status = Open3.capture3(PAGEFIND_COMMAND, *args)
37
+ stdout, stderr, status = Open3.capture3(*command, *args)
33
38
 
34
39
  if status.success?
35
40
  count = extract_page_count(stdout)
@@ -50,7 +50,7 @@ module Docyard
50
50
 
51
51
  def pagefind_available?
52
52
  result = super
53
- Docyard.logger.warn("Search disabled: Pagefind not found (npm install -g pagefind)") unless result
53
+ Docyard.logger.warn("Search disabled: Pagefind binary not available") unless result
54
54
  result
55
55
  end
56
56
 
@@ -132,8 +132,9 @@ module Docyard
132
132
  end
133
133
 
134
134
  def run_pagefind
135
+ command = pagefind_command
135
136
  args = build_pagefind_args(temp_dir)
136
- stdout, stderr, status = Open3.capture3("npx", *args)
137
+ stdout, stderr, status = Open3.capture3(*command, *args)
137
138
 
138
139
  raise "Pagefind failed: #{stderr}" unless status.success?
139
140
 
@@ -0,0 +1,185 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "fileutils"
5
+ require "net/http"
6
+ require "open3"
7
+ require "rubygems/package"
8
+ require "zlib"
9
+
10
+ module Docyard
11
+ module Search
12
+ class PagefindBinary
13
+ VERSION = "1.3.0"
14
+ CACHE_DIR = File.join(Dir.home, ".docyard", "bin")
15
+ DOWNLOAD_BASE = "https://github.com/CloudCannon/pagefind/releases/download"
16
+
17
+ DOWNLOAD_TIMEOUT = 30
18
+ MAX_REDIRECTS = 5
19
+
20
+ PLATFORMS = {
21
+ %w[darwin arm64] => "aarch64-apple-darwin",
22
+ %w[darwin x86_64] => "x86_64-apple-darwin",
23
+ %w[linux x86_64] => "x86_64-unknown-linux-musl",
24
+ %w[linux aarch64] => "aarch64-unknown-linux-musl",
25
+ %w[mingw x64] => "x86_64-pc-windows-msvc"
26
+ }.freeze
27
+
28
+ class << self
29
+ def executable
30
+ @executable ||= resolve_executable
31
+ end
32
+
33
+ def reset!
34
+ @executable = nil
35
+ end
36
+
37
+ private
38
+
39
+ def resolve_executable
40
+ cached_path || download_binary || npx_fallback
41
+ end
42
+
43
+ def cached_path
44
+ path = binary_path
45
+ return path if path && File.executable?(path)
46
+
47
+ nil
48
+ end
49
+
50
+ def binary_path
51
+ platform = detect_platform
52
+ return nil unless platform
53
+
54
+ dir = File.join(CACHE_DIR, "pagefind-#{VERSION}-#{platform}")
55
+ ext = platform.include?("windows") ? ".exe" : ""
56
+ File.join(dir, "pagefind#{ext}")
57
+ end
58
+
59
+ def detect_platform
60
+ os = normalize_os(RbConfig::CONFIG["host_os"])
61
+ cpu = normalize_cpu(RbConfig::CONFIG["host_cpu"])
62
+ PLATFORMS[[os, cpu]]
63
+ end
64
+
65
+ def normalize_os(host_os)
66
+ case host_os
67
+ when /darwin/i then "darwin"
68
+ when /linux/i then "linux"
69
+ when /mingw|mswin|cygwin/i then "mingw"
70
+ end
71
+ end
72
+
73
+ def normalize_cpu(host_cpu)
74
+ case host_cpu
75
+ when /\Aarm64\z/i then "arm64"
76
+ when /\Aaarch64\z/i then "aarch64"
77
+ when /\Ax86_64\z/i, /\Aamd64\z/i then "x86_64"
78
+ when /\Ax64\z/i then "x64"
79
+ end
80
+ end
81
+
82
+ def download_binary
83
+ platform = detect_platform
84
+ return nil unless platform
85
+
86
+ perform_download(platform)
87
+ rescue StandardError => e
88
+ Docyard.logger.debug("Failed to download Pagefind: #{e.message}")
89
+ nil
90
+ end
91
+
92
+ def perform_download(platform)
93
+ tar_url = build_tar_url(platform)
94
+ sha_url = "#{tar_url}.sha256"
95
+
96
+ expected_checksum = fetch_checksum(sha_url)
97
+ return nil unless expected_checksum
98
+
99
+ tar_data = download_file(tar_url)
100
+ return nil unless tar_data
101
+
102
+ return nil unless checksum_valid?(tar_data, expected_checksum)
103
+
104
+ extract_binary(tar_data, platform)
105
+ end
106
+
107
+ def build_tar_url(platform)
108
+ tar_filename = "pagefind-v#{VERSION}-#{platform}.tar.gz"
109
+ "#{DOWNLOAD_BASE}/v#{VERSION}/#{tar_filename}"
110
+ end
111
+
112
+ def checksum_valid?(tar_data, expected_checksum)
113
+ actual_checksum = Digest::SHA256.hexdigest(tar_data)
114
+ return true if actual_checksum == expected_checksum
115
+
116
+ Docyard.logger.warn("Pagefind checksum mismatch: expected #{expected_checksum}, got #{actual_checksum}")
117
+ false
118
+ end
119
+
120
+ def fetch_checksum(url)
121
+ response = download_file(url)
122
+ return nil unless response
123
+
124
+ response.split.first
125
+ end
126
+
127
+ def download_file(url, redirect_count = 0)
128
+ return nil if redirect_count >= MAX_REDIRECTS
129
+
130
+ uri = URI(url)
131
+ response = http_get(uri)
132
+
133
+ case response
134
+ when Net::HTTPSuccess then response.body
135
+ when Net::HTTPRedirection then download_file(response["location"], redirect_count + 1)
136
+ end
137
+ end
138
+
139
+ def http_get(uri)
140
+ Net::HTTP.start(
141
+ uri.host, uri.port,
142
+ use_ssl: uri.scheme == "https",
143
+ open_timeout: DOWNLOAD_TIMEOUT,
144
+ read_timeout: DOWNLOAD_TIMEOUT
145
+ ) { |http| http.request(Net::HTTP::Get.new(uri)) }
146
+ end
147
+
148
+ def extract_binary(tar_data, platform)
149
+ target_dir = File.join(CACHE_DIR, "pagefind-#{VERSION}-#{platform}")
150
+ FileUtils.mkdir_p(target_dir)
151
+
152
+ binary_name = platform.include?("windows") ? "pagefind.exe" : "pagefind"
153
+ target_path = File.join(target_dir, binary_name)
154
+
155
+ extract_from_tar(tar_data, binary_name, target_path)
156
+ end
157
+
158
+ def extract_from_tar(tar_data, binary_name, target_path)
159
+ io = StringIO.new(tar_data)
160
+ Zlib::GzipReader.wrap(io) do |gz|
161
+ Gem::Package::TarReader.new(gz) do |tar|
162
+ tar.each do |entry|
163
+ next unless entry.file? && File.basename(entry.full_name) == binary_name
164
+
165
+ File.binwrite(target_path, entry.read)
166
+ File.chmod(0o755, target_path)
167
+ return target_path
168
+ end
169
+ end
170
+ end
171
+ nil
172
+ end
173
+
174
+ def npx_fallback
175
+ _, _, status = Open3.capture3("npx", "pagefind", "--version")
176
+ return "npx" if status.success?
177
+
178
+ nil
179
+ rescue Errno::ENOENT
180
+ nil
181
+ end
182
+ end
183
+ end
184
+ end
185
+ end
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "open3"
4
-
5
3
  module Docyard
6
4
  module Search
7
5
  module PagefindSupport
@@ -10,14 +8,18 @@ module Docyard
10
8
  end
11
9
 
12
10
  def pagefind_available?
13
- _stdout, _stderr, status = Open3.capture3("npx", "pagefind", "--version")
14
- status.success?
15
- rescue Errno::ENOENT
16
- false
11
+ !PagefindBinary.executable.nil?
12
+ end
13
+
14
+ def pagefind_command
15
+ executable = PagefindBinary.executable
16
+ return nil unless executable
17
+
18
+ executable == "npx" ? %w[npx pagefind] : [executable]
17
19
  end
18
20
 
19
21
  def build_pagefind_args(site_dir)
20
- args = ["pagefind", "--site", site_dir, "--output-subdir", "_docyard/pagefind"]
22
+ args = ["--site", site_dir, "--output-subdir", "_docyard/pagefind"]
21
23
 
22
24
  exclusions = config.search.exclude || []
23
25
  exclusions.each do |pattern|