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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +17 -1
- data/README.md +56 -11
- data/lib/docyard/build/asset_bundler.rb +17 -1
- data/lib/docyard/build/social_cards/card_renderer.rb +132 -0
- data/lib/docyard/build/social_cards/doc_card.rb +97 -0
- data/lib/docyard/build/social_cards/homepage_card.rb +98 -0
- data/lib/docyard/build/social_cards_generator.rb +188 -0
- data/lib/docyard/build/step_runner.rb +2 -0
- data/lib/docyard/builder.rb +10 -0
- data/lib/docyard/cli.rb +27 -0
- data/lib/docyard/config/branding_resolver.rb +1 -1
- data/lib/docyard/config/schema/definition.rb +2 -1
- data/lib/docyard/config/schema/sections.rb +0 -1
- data/lib/docyard/config/schema/simple_sections.rb +7 -0
- data/lib/docyard/config.rb +3 -1
- data/lib/docyard/customizer.rb +196 -0
- data/lib/docyard/rendering/markdown.rb +4 -0
- data/lib/docyard/rendering/og_helpers.rb +19 -1
- data/lib/docyard/rendering/renderer.rb +10 -1
- data/lib/docyard/search/build_indexer.rb +8 -3
- data/lib/docyard/search/dev_indexer.rb +3 -2
- data/lib/docyard/search/pagefind_binary.rb +185 -0
- data/lib/docyard/search/pagefind_support.rb +9 -7
- data/lib/docyard/server/asset_handler.rb +28 -2
- data/lib/docyard/server/dev_server.rb +1 -0
- data/lib/docyard/server/file_watcher.rb +10 -5
- data/lib/docyard/server/rack_application.rb +1 -1
- data/lib/docyard/templates/assets/css/variables.css +0 -6
- data/lib/docyard/templates/assets/js/components/abbreviation.js +20 -11
- data/lib/docyard/templates/assets/js/components/code-block.js +8 -3
- data/lib/docyard/templates/assets/js/components/code-group.js +20 -3
- data/lib/docyard/templates/assets/js/components/file-tree.js +9 -3
- data/lib/docyard/templates/assets/js/components/heading-anchor.js +71 -72
- data/lib/docyard/templates/assets/js/components/lightbox.js +10 -3
- data/lib/docyard/templates/assets/js/components/tabs.js +8 -3
- data/lib/docyard/templates/assets/js/components/tooltip.js +32 -23
- data/lib/docyard/templates/assets/js/hot-reload.js +42 -2
- data/lib/docyard/templates/partials/_head.html.erb +4 -0
- data/lib/docyard/templates/partials/_scripts.html.erb +3 -0
- data/lib/docyard/version.rb +1 -1
- data/lib/docyard.rb +2 -0
- 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(', ')})"
|
data/lib/docyard/builder.rb
CHANGED
|
@@ -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
|
data/lib/docyard/config.rb
CHANGED
|
@@ -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
|
|
@@ -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 =
|
|
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(
|
|
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
|
|
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(
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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 = ["
|
|
22
|
+
args = ["--site", site_dir, "--output-subdir", "_docyard/pagefind"]
|
|
21
23
|
|
|
22
24
|
exclusions = config.search.exclude || []
|
|
23
25
|
exclusions.each do |pattern|
|