pocketbook 0.1.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 +7 -0
- data/LICENSE +21 -0
- data/README.md +308 -0
- data/bin/pocketbook +5 -0
- data/bin/test-render +49 -0
- data/lib/pocketbook/book.rb +28 -0
- data/lib/pocketbook/book_renderer/chapter.rb +85 -0
- data/lib/pocketbook/book_renderer/front_matter.rb +27 -0
- data/lib/pocketbook/book_renderer/metadata.rb +70 -0
- data/lib/pocketbook/book_renderer/pdf.rb +42 -0
- data/lib/pocketbook/book_renderer/toc.rb +55 -0
- data/lib/pocketbook/book_renderer.rb +140 -0
- data/lib/pocketbook/book_template.rb +40 -0
- data/lib/pocketbook/cli/options_parser.rb +344 -0
- data/lib/pocketbook/cli/runner.rb +505 -0
- data/lib/pocketbook/cli/watch_command.rb +275 -0
- data/lib/pocketbook/cli.rb +12 -0
- data/lib/pocketbook/core_stylesheet.rb +20 -0
- data/lib/pocketbook/pdf_document.rb +96 -0
- data/lib/pocketbook/render_request.rb +67 -0
- data/lib/pocketbook/styles/core/01_tokens.css +11 -0
- data/lib/pocketbook/styles/core/02_pages.css +72 -0
- data/lib/pocketbook/styles/core/03_layout.css +162 -0
- data/lib/pocketbook/styles/core/04_toc.css +62 -0
- data/lib/pocketbook/styles/core/05_content.css +49 -0
- data/lib/pocketbook/styles/core/06_running.css +48 -0
- data/lib/pocketbook/styles/core/07_print.css +12 -0
- data/lib/pocketbook/theme/manifest.rb +244 -0
- data/lib/pocketbook/theme.rb +268 -0
- data/lib/pocketbook/version.rb +3 -0
- data/lib/pocketbook.rb +29 -0
- data/themes/basic/styles/plain.css +63 -0
- data/themes/basic/template.html.erb +30 -0
- data/themes/basic/theme.yml +8 -0
- data/themes/classic/styles/base.css +250 -0
- data/themes/classic/styles/dark.css +19 -0
- data/themes/classic/styles/light.css +12 -0
- data/themes/classic/styles/sepia.css +17 -0
- data/themes/classic/template.html.erb +72 -0
- data/themes/classic/theme.yml +17 -0
- metadata +136 -0
|
@@ -0,0 +1,505 @@
|
|
|
1
|
+
require "optparse"
|
|
2
|
+
require "fileutils"
|
|
3
|
+
require "rbconfig"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
require "yaml"
|
|
7
|
+
require "tmpdir"
|
|
8
|
+
require_relative "../book"
|
|
9
|
+
require_relative "../theme"
|
|
10
|
+
require_relative "options_parser"
|
|
11
|
+
require_relative "watch_command"
|
|
12
|
+
|
|
13
|
+
module Pocketbook
|
|
14
|
+
module CLI
|
|
15
|
+
class Runner
|
|
16
|
+
GitHubThemeSource = Struct.new(:owner, :repo, :ref, :theme_directory, keyword_init: true) do
|
|
17
|
+
def with_ref(new_ref)
|
|
18
|
+
self.class.new(owner: owner, repo: repo, ref: new_ref, theme_directory: theme_directory)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def raw_base
|
|
22
|
+
"https://raw.githubusercontent.com/#{owner}/#{repo}/#{ref}"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def raw_manifest_url
|
|
26
|
+
raw_file_url("theme.yml")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def raw_file_url(relative_path)
|
|
30
|
+
joined = [theme_directory, relative_path].reject { |part| part.nil? || part.empty? || part == "." }.join("/")
|
|
31
|
+
"#{raw_base}/#{joined}"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def default_theme_name
|
|
35
|
+
File.basename(theme_directory)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def initialize(stdout: $stdout, stderr: $stderr, options_parser: nil, watcher_class: Watcher)
|
|
40
|
+
@stdout = stdout
|
|
41
|
+
@stderr = stderr
|
|
42
|
+
@options_parser = options_parser || OptionsParser.new(stdout: @stdout)
|
|
43
|
+
@watcher_class = watcher_class
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def run(argv = ARGV)
|
|
47
|
+
command, request, parse_status = @options_parser.parse(argv)
|
|
48
|
+
return if parse_status == :exit
|
|
49
|
+
|
|
50
|
+
case command
|
|
51
|
+
when :build
|
|
52
|
+
run_build_command(request)
|
|
53
|
+
when :watch
|
|
54
|
+
build_watcher(request).run
|
|
55
|
+
when :theme_new
|
|
56
|
+
scaffold_theme(request)
|
|
57
|
+
when :theme_validate
|
|
58
|
+
validate_theme(request)
|
|
59
|
+
when :theme_inspect
|
|
60
|
+
inspect_theme(request)
|
|
61
|
+
when :theme_get
|
|
62
|
+
get_theme(request)
|
|
63
|
+
else
|
|
64
|
+
raise OptionParser::InvalidArgument, "Unknown command '#{command}'"
|
|
65
|
+
end
|
|
66
|
+
rescue OptionParser::ParseError, ArgumentError => e
|
|
67
|
+
@stderr.puts("Error: #{e.message}")
|
|
68
|
+
@stderr.puts("Run with --help for usage.")
|
|
69
|
+
exit(1)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def run_build_command(request)
|
|
75
|
+
started_at = monotonic_time
|
|
76
|
+
theme = build_theme(request)
|
|
77
|
+
output_path = build_book(request, theme: theme).render_to(request.output_path)
|
|
78
|
+
elapsed = monotonic_time - started_at
|
|
79
|
+
|
|
80
|
+
print_build_success(output_path, elapsed)
|
|
81
|
+
maybe_open_output(output_path) if request.open_output
|
|
82
|
+
print_next_step_hint(request)
|
|
83
|
+
print_build_diagnostics(request, theme, output_path, elapsed) if request.diagnostics
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def build_book(request, theme: nil)
|
|
87
|
+
Book.new(
|
|
88
|
+
inputs: request.inputs,
|
|
89
|
+
theme: theme || build_theme(request),
|
|
90
|
+
metadata: request.metadata
|
|
91
|
+
)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def build_theme(request)
|
|
95
|
+
Theme.new(
|
|
96
|
+
theme_path: request.theme_path,
|
|
97
|
+
style: request.style,
|
|
98
|
+
template_override: request.template_override
|
|
99
|
+
)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def build_watcher(request)
|
|
103
|
+
@watcher_class.new(request: request, stdout: @stdout, stderr: @stderr)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def scaffold_theme(request)
|
|
107
|
+
target_root = File.expand_path(request.target_root)
|
|
108
|
+
raise ArgumentError, "Theme target root is required" if blank?(target_root)
|
|
109
|
+
|
|
110
|
+
theme_root = File.join(target_root, request.name)
|
|
111
|
+
if File.exist?(theme_root) && !request.force
|
|
112
|
+
raise ArgumentError, "Theme directory already exists: #{theme_root} (use --force to overwrite scaffold files)"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
FileUtils.mkdir_p(theme_root)
|
|
116
|
+
|
|
117
|
+
css_path = File.join(theme_root, "theme.css")
|
|
118
|
+
write_if_allowed(
|
|
119
|
+
css_path,
|
|
120
|
+
default_theme_css(request.name),
|
|
121
|
+
force: request.force
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
if request.with_template
|
|
125
|
+
template_path = File.join(theme_root, "template.html.erb")
|
|
126
|
+
write_if_allowed(
|
|
127
|
+
template_path,
|
|
128
|
+
default_theme_template,
|
|
129
|
+
force: request.force
|
|
130
|
+
)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
@stdout.puts("Created theme scaffold at #{theme_root}")
|
|
134
|
+
@stdout.puts("- #{css_path}")
|
|
135
|
+
@stdout.puts("- #{File.join(theme_root, 'template.html.erb')}") if request.with_template
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def validate_theme(request)
|
|
139
|
+
theme = Theme.new(theme_path: request.theme_path, style: request.style)
|
|
140
|
+
@stdout.puts("Theme is valid: #{theme.name}")
|
|
141
|
+
@stdout.puts("- root: #{theme.root_path}")
|
|
142
|
+
@stdout.puts("- template: #{theme.template_path}")
|
|
143
|
+
if theme.style_name
|
|
144
|
+
@stdout.puts("- style: #{theme.style_name}")
|
|
145
|
+
theme.style_paths.each { |path| @stdout.puts(" - #{path}") }
|
|
146
|
+
else
|
|
147
|
+
@stdout.puts("- style: (none)")
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def inspect_theme(request)
|
|
152
|
+
theme = Theme.new(theme_path: request.theme_path, style: request.style)
|
|
153
|
+
@stdout.puts("Theme: #{theme.name}")
|
|
154
|
+
@stdout.puts("Version: #{theme.version}")
|
|
155
|
+
@stdout.puts("Root: #{theme.root_path}")
|
|
156
|
+
@stdout.puts("Template: #{theme.template_path}")
|
|
157
|
+
@stdout.puts("Selected style: #{theme.style_name || '(none)'}")
|
|
158
|
+
|
|
159
|
+
if theme.available_styles.empty?
|
|
160
|
+
@stdout.puts("Available styles: (none)")
|
|
161
|
+
else
|
|
162
|
+
@stdout.puts("Available styles: #{theme.available_styles.join(', ')}")
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
@stdout.puts("Style files:")
|
|
166
|
+
if theme.style_paths.empty?
|
|
167
|
+
@stdout.puts(" (none)")
|
|
168
|
+
else
|
|
169
|
+
theme.style_paths.each { |path| @stdout.puts(" - #{path}") }
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def get_theme(request)
|
|
174
|
+
source = parse_github_theme_source(request.url)
|
|
175
|
+
source = source.with_ref(request.ref) if request.ref
|
|
176
|
+
|
|
177
|
+
manifest_text = http_get(source.raw_manifest_url)
|
|
178
|
+
manifest_values = parse_manifest_values(manifest_text, source.raw_manifest_url)
|
|
179
|
+
required_files = required_theme_files(manifest_values)
|
|
180
|
+
|
|
181
|
+
theme_name = resolve_downloaded_theme_name(request.name, manifest_values, source)
|
|
182
|
+
destination_root = File.expand_path(request.into)
|
|
183
|
+
destination_theme_root = File.join(destination_root, theme_name)
|
|
184
|
+
|
|
185
|
+
if request.dry_run
|
|
186
|
+
print_theme_get_plan(source: source, destination_theme_root: destination_theme_root, files: required_files)
|
|
187
|
+
return
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
if File.exist?(destination_theme_root)
|
|
191
|
+
unless request.force
|
|
192
|
+
raise ArgumentError, "Destination theme already exists: #{destination_theme_root} (use --force to overwrite)"
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
FileUtils.rm_rf(destination_theme_root)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
downloaded_theme_root = download_theme_to_temp(source: source, manifest_text: manifest_text, files: required_files, theme_name: theme_name)
|
|
199
|
+
|
|
200
|
+
begin
|
|
201
|
+
Theme.new(theme_path: downloaded_theme_root)
|
|
202
|
+
|
|
203
|
+
FileUtils.mkdir_p(destination_root)
|
|
204
|
+
FileUtils.mv(downloaded_theme_root, destination_theme_root)
|
|
205
|
+
|
|
206
|
+
@stdout.puts("Downloaded theme '#{theme_name}' to #{destination_theme_root}")
|
|
207
|
+
@stdout.puts("Use it with: pocketbook build book.md --theme #{theme_name}")
|
|
208
|
+
ensure
|
|
209
|
+
FileUtils.rm_rf(downloaded_theme_root) if File.exist?(downloaded_theme_root)
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def download_theme_to_temp(source:, manifest_text:, files:, theme_name:)
|
|
214
|
+
temp_root = Dir.mktmpdir("pocketbook-theme-get")
|
|
215
|
+
theme_root = File.join(temp_root, theme_name)
|
|
216
|
+
FileUtils.mkdir_p(theme_root)
|
|
217
|
+
|
|
218
|
+
File.write(File.join(theme_root, "theme.yml"), manifest_text)
|
|
219
|
+
|
|
220
|
+
files.each do |relative_path|
|
|
221
|
+
next if relative_path == "theme.yml"
|
|
222
|
+
|
|
223
|
+
body = http_get(source.raw_file_url(relative_path))
|
|
224
|
+
destination = File.join(theme_root, relative_path)
|
|
225
|
+
ensure_safe_destination!(theme_root, destination)
|
|
226
|
+
FileUtils.mkdir_p(File.dirname(destination))
|
|
227
|
+
File.write(destination, body)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
theme_root
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def parse_github_theme_source(url)
|
|
234
|
+
uri = URI.parse(url)
|
|
235
|
+
unless uri.is_a?(URI::HTTPS)
|
|
236
|
+
raise ArgumentError, "Theme URL must use https"
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
case uri.host
|
|
240
|
+
when "github.com"
|
|
241
|
+
parse_github_blob_url(uri)
|
|
242
|
+
when "raw.githubusercontent.com"
|
|
243
|
+
parse_github_raw_url(uri)
|
|
244
|
+
else
|
|
245
|
+
raise ArgumentError, "Unsupported theme URL host: #{uri.host}"
|
|
246
|
+
end
|
|
247
|
+
rescue URI::InvalidURIError => e
|
|
248
|
+
raise ArgumentError, "Invalid theme URL: #{e.message}"
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def parse_github_blob_url(uri)
|
|
252
|
+
segments = uri.path.split("/").reject(&:empty?)
|
|
253
|
+
unless segments.length >= 6 && segments[2] == "blob"
|
|
254
|
+
raise ArgumentError, "GitHub URL must look like https://github.com/<org>/<repo>/blob/<ref>/.../theme.yml"
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
owner = segments[0]
|
|
258
|
+
repo = segments[1]
|
|
259
|
+
ref = segments[3]
|
|
260
|
+
manifest_path = segments[4..].join("/")
|
|
261
|
+
|
|
262
|
+
build_github_source(owner: owner, repo: repo, ref: ref, manifest_path: manifest_path)
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def parse_github_raw_url(uri)
|
|
266
|
+
segments = uri.path.split("/").reject(&:empty?)
|
|
267
|
+
unless segments.length >= 5
|
|
268
|
+
raise ArgumentError, "Raw GitHub URL must look like https://raw.githubusercontent.com/<org>/<repo>/<ref>/.../theme.yml"
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
owner = segments[0]
|
|
272
|
+
repo = segments[1]
|
|
273
|
+
ref = segments[2]
|
|
274
|
+
manifest_path = segments[3..].join("/")
|
|
275
|
+
|
|
276
|
+
build_github_source(owner: owner, repo: repo, ref: ref, manifest_path: manifest_path)
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def build_github_source(owner:, repo:, ref:, manifest_path:)
|
|
280
|
+
unless File.basename(manifest_path) == "theme.yml"
|
|
281
|
+
raise ArgumentError, "Theme URL must point to a theme.yml file"
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
theme_directory = File.dirname(manifest_path)
|
|
285
|
+
theme_directory = "." if theme_directory.nil? || theme_directory.empty?
|
|
286
|
+
|
|
287
|
+
GitHubThemeSource.new(owner: owner, repo: repo, ref: ref, theme_directory: theme_directory)
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def required_theme_files(manifest_values)
|
|
291
|
+
files = ["theme.yml"]
|
|
292
|
+
|
|
293
|
+
template_path = manifest_values["template"]
|
|
294
|
+
files << template_path if template_path.is_a?(String) && !template_path.strip.empty?
|
|
295
|
+
|
|
296
|
+
styles = manifest_values["styles"]
|
|
297
|
+
style_entries = collect_style_entries(styles)
|
|
298
|
+
files.concat(style_entries)
|
|
299
|
+
|
|
300
|
+
normalized = files.map { |path| normalize_relative_path(path) }.uniq.sort
|
|
301
|
+
normalized
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def collect_style_entries(styles)
|
|
305
|
+
return [] if styles.nil?
|
|
306
|
+
|
|
307
|
+
case styles
|
|
308
|
+
when String
|
|
309
|
+
[styles]
|
|
310
|
+
when Array
|
|
311
|
+
styles
|
|
312
|
+
when Hash
|
|
313
|
+
styles.values.flat_map do |entry|
|
|
314
|
+
case entry
|
|
315
|
+
when String
|
|
316
|
+
[entry]
|
|
317
|
+
when Array
|
|
318
|
+
entry
|
|
319
|
+
else
|
|
320
|
+
raise ArgumentError, "Manifest style entries must be strings or arrays"
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
else
|
|
324
|
+
raise ArgumentError, "Manifest styles must be a string, array, or object"
|
|
325
|
+
end
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def normalize_relative_path(path)
|
|
329
|
+
normalized = path.to_s.strip
|
|
330
|
+
raise ArgumentError, "Manifest contains an empty file path" if normalized.empty?
|
|
331
|
+
raise ArgumentError, "Manifest file path must be relative: #{normalized}" if normalized.start_with?("/")
|
|
332
|
+
|
|
333
|
+
segments = normalized.split("/")
|
|
334
|
+
if segments.any? { |segment| segment.nil? || segment.empty? || segment == "." || segment == ".." }
|
|
335
|
+
raise ArgumentError, "Manifest file path is not safe: #{normalized}"
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
normalized
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def ensure_safe_destination!(theme_root, destination)
|
|
342
|
+
expanded_root = File.expand_path(theme_root)
|
|
343
|
+
expanded_destination = File.expand_path(destination)
|
|
344
|
+
|
|
345
|
+
return if expanded_destination.start_with?("#{expanded_root}/")
|
|
346
|
+
|
|
347
|
+
raise ArgumentError, "Resolved download path escapes theme root: #{destination}"
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
def parse_manifest_values(text, source_url)
|
|
351
|
+
values = YAML.safe_load(text, aliases: true)
|
|
352
|
+
raise ArgumentError, "Manifest at #{source_url} must be a YAML object" unless values.is_a?(Hash)
|
|
353
|
+
|
|
354
|
+
values.each_with_object({}) do |(key, value), output|
|
|
355
|
+
output[key.to_s] = value
|
|
356
|
+
end
|
|
357
|
+
rescue Psych::SyntaxError => e
|
|
358
|
+
raise ArgumentError, "Manifest YAML is invalid at #{source_url}: #{e.message}"
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
def resolve_downloaded_theme_name(requested_name, manifest_values, source)
|
|
362
|
+
name = requested_name
|
|
363
|
+
name = manifest_values["name"] if blank?(name)
|
|
364
|
+
name = source.default_theme_name if blank?(name)
|
|
365
|
+
|
|
366
|
+
normalized = name.to_s.strip
|
|
367
|
+
if normalized.empty? || normalized.include?("/") || normalized.include?("\\")
|
|
368
|
+
raise ArgumentError, "Invalid destination theme name: #{name.inspect}"
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
normalized
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
def print_theme_get_plan(source:, destination_theme_root:, files:)
|
|
375
|
+
@stdout.puts("Theme download plan")
|
|
376
|
+
@stdout.puts("- source: #{source.raw_manifest_url}")
|
|
377
|
+
@stdout.puts("- destination: #{destination_theme_root}")
|
|
378
|
+
@stdout.puts("- files:")
|
|
379
|
+
files.each { |path| @stdout.puts(" - #{path}") }
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
def http_get(url, redirect_limit: 4)
|
|
383
|
+
raise ArgumentError, "Too many redirects while fetching #{url}" if redirect_limit < 0
|
|
384
|
+
|
|
385
|
+
uri = URI.parse(url)
|
|
386
|
+
request = Net::HTTP::Get.new(uri)
|
|
387
|
+
|
|
388
|
+
response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
|
|
389
|
+
http.request(request)
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
case response
|
|
393
|
+
when Net::HTTPSuccess
|
|
394
|
+
response.body
|
|
395
|
+
when Net::HTTPRedirection
|
|
396
|
+
location = response["location"]
|
|
397
|
+
raise ArgumentError, "Redirect without location while fetching #{url}" if blank?(location)
|
|
398
|
+
|
|
399
|
+
redirected_url = URI.join(url, location).to_s
|
|
400
|
+
http_get(redirected_url, redirect_limit: redirect_limit - 1)
|
|
401
|
+
else
|
|
402
|
+
raise ArgumentError, "Failed to fetch #{url} (#{response.code} #{response.message})"
|
|
403
|
+
end
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
def write_if_allowed(path, content, force:)
|
|
407
|
+
if File.exist?(path) && !force
|
|
408
|
+
raise ArgumentError, "Scaffold file already exists: #{path} (use --force to overwrite)"
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
File.write(path, content)
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
def default_theme_css(theme_name)
|
|
415
|
+
<<~CSS
|
|
416
|
+
/* Pocketbook theme: #{theme_name} */
|
|
417
|
+
|
|
418
|
+
:root {
|
|
419
|
+
--theme-accent: #1f4f8b;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
body {
|
|
423
|
+
color: #111;
|
|
424
|
+
}
|
|
425
|
+
CSS
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
def default_theme_template
|
|
429
|
+
File.read(File.join(Pocketbook.bundled_theme_path("basic"), "template.html.erb"))
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
def print_build_success(output_path, elapsed)
|
|
433
|
+
absolute_output_path = File.expand_path(output_path)
|
|
434
|
+
@stdout.puts("✅ Built #{output_path} in #{format('%.2f', elapsed)}s")
|
|
435
|
+
@stdout.puts("📄 #{absolute_output_path}")
|
|
436
|
+
@stdout.puts("👀 Open: #{open_command(absolute_output_path)}")
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
def print_next_step_hint(request)
|
|
440
|
+
return unless request.style.nil?
|
|
441
|
+
|
|
442
|
+
@stdout.puts("🎨 Try: pocketbook build #{request.inputs.join(' ')} --theme classic --style dark")
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
def maybe_open_output(output_path)
|
|
446
|
+
absolute_output_path = File.expand_path(output_path)
|
|
447
|
+
success = open_with_default_viewer(absolute_output_path)
|
|
448
|
+
|
|
449
|
+
if success
|
|
450
|
+
@stdout.puts("🚀 Opened PDF in default viewer")
|
|
451
|
+
else
|
|
452
|
+
@stderr.puts("Warning: could not open PDF automatically. Try: #{open_command(absolute_output_path)}")
|
|
453
|
+
end
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
def open_with_default_viewer(path)
|
|
457
|
+
host_os = RbConfig::CONFIG["host_os"]
|
|
458
|
+
|
|
459
|
+
if host_os =~ /darwin/
|
|
460
|
+
system("open", path)
|
|
461
|
+
elsif host_os =~ /mswin|mingw|cygwin/
|
|
462
|
+
system("cmd", "/c", "start", "", path)
|
|
463
|
+
else
|
|
464
|
+
system("xdg-open", path)
|
|
465
|
+
end
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
def print_build_diagnostics(request, theme, output_path, elapsed)
|
|
469
|
+
@stdout.puts("\nDiagnostics")
|
|
470
|
+
@stdout.puts("- inputs:")
|
|
471
|
+
request.inputs.each { |path| @stdout.puts(" - #{File.expand_path(path)}") }
|
|
472
|
+
@stdout.puts("- theme root: #{theme.root_path}")
|
|
473
|
+
@stdout.puts("- template: #{theme.template_path}")
|
|
474
|
+
@stdout.puts("- selected style: #{theme.style_name || '(none)'}")
|
|
475
|
+
@stdout.puts("- style files:")
|
|
476
|
+
if theme.style_paths.empty?
|
|
477
|
+
@stdout.puts(" (none)")
|
|
478
|
+
else
|
|
479
|
+
theme.style_paths.each { |path| @stdout.puts(" - #{path}") }
|
|
480
|
+
end
|
|
481
|
+
@stdout.puts("- output: #{File.expand_path(output_path)}")
|
|
482
|
+
@stdout.puts("- elapsed: #{format('%.2f', elapsed)}s")
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
def open_command(path)
|
|
486
|
+
host_os = RbConfig::CONFIG["host_os"]
|
|
487
|
+
if host_os =~ /darwin/
|
|
488
|
+
"open \"#{path}\""
|
|
489
|
+
elsif host_os =~ /mswin|mingw|cygwin/
|
|
490
|
+
"cmd /c start \"\" \"#{path}\""
|
|
491
|
+
else
|
|
492
|
+
"xdg-open \"#{path}\""
|
|
493
|
+
end
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
def monotonic_time
|
|
497
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
def blank?(value)
|
|
501
|
+
value.nil? || value.to_s.strip.empty?
|
|
502
|
+
end
|
|
503
|
+
end
|
|
504
|
+
end
|
|
505
|
+
end
|