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.
Files changed (41) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +308 -0
  4. data/bin/pocketbook +5 -0
  5. data/bin/test-render +49 -0
  6. data/lib/pocketbook/book.rb +28 -0
  7. data/lib/pocketbook/book_renderer/chapter.rb +85 -0
  8. data/lib/pocketbook/book_renderer/front_matter.rb +27 -0
  9. data/lib/pocketbook/book_renderer/metadata.rb +70 -0
  10. data/lib/pocketbook/book_renderer/pdf.rb +42 -0
  11. data/lib/pocketbook/book_renderer/toc.rb +55 -0
  12. data/lib/pocketbook/book_renderer.rb +140 -0
  13. data/lib/pocketbook/book_template.rb +40 -0
  14. data/lib/pocketbook/cli/options_parser.rb +344 -0
  15. data/lib/pocketbook/cli/runner.rb +505 -0
  16. data/lib/pocketbook/cli/watch_command.rb +275 -0
  17. data/lib/pocketbook/cli.rb +12 -0
  18. data/lib/pocketbook/core_stylesheet.rb +20 -0
  19. data/lib/pocketbook/pdf_document.rb +96 -0
  20. data/lib/pocketbook/render_request.rb +67 -0
  21. data/lib/pocketbook/styles/core/01_tokens.css +11 -0
  22. data/lib/pocketbook/styles/core/02_pages.css +72 -0
  23. data/lib/pocketbook/styles/core/03_layout.css +162 -0
  24. data/lib/pocketbook/styles/core/04_toc.css +62 -0
  25. data/lib/pocketbook/styles/core/05_content.css +49 -0
  26. data/lib/pocketbook/styles/core/06_running.css +48 -0
  27. data/lib/pocketbook/styles/core/07_print.css +12 -0
  28. data/lib/pocketbook/theme/manifest.rb +244 -0
  29. data/lib/pocketbook/theme.rb +268 -0
  30. data/lib/pocketbook/version.rb +3 -0
  31. data/lib/pocketbook.rb +29 -0
  32. data/themes/basic/styles/plain.css +63 -0
  33. data/themes/basic/template.html.erb +30 -0
  34. data/themes/basic/theme.yml +8 -0
  35. data/themes/classic/styles/base.css +250 -0
  36. data/themes/classic/styles/dark.css +19 -0
  37. data/themes/classic/styles/light.css +12 -0
  38. data/themes/classic/styles/sepia.css +17 -0
  39. data/themes/classic/template.html.erb +72 -0
  40. data/themes/classic/theme.yml +17 -0
  41. 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