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,140 @@
1
+ require "erb"
2
+ require "fileutils"
3
+ require "tmpdir"
4
+ require_relative "book_template"
5
+ require_relative "core_stylesheet"
6
+ require_relative "book_renderer/front_matter"
7
+ require_relative "book_renderer/chapter"
8
+ require_relative "book_renderer/metadata"
9
+ require_relative "book_renderer/toc"
10
+ require_relative "book_renderer/pdf"
11
+
12
+ module Pocketbook
13
+ class BookRenderer
14
+ DEFAULT_METADATA = Metadata::DEFAULT_VALUES
15
+
16
+ def initialize(inputs:, theme:, output_path: nil, metadata: {})
17
+ @inputs = inputs
18
+ @theme = theme
19
+ @output_path = output_path
20
+ @cli_metadata = symbolize_keys(metadata)
21
+ end
22
+
23
+ def render
24
+ raise ArgumentError, "Output PDF path is required" if blank?(@output_path)
25
+
26
+ validate_paths!
27
+
28
+ chapters, front_matter = chapter_compiler.compile(inputs: @inputs)
29
+ metadata = resolved_metadata(front_matter)
30
+
31
+ toc_targets = toc_builder.targets(chapters: chapters)
32
+ html_without_toc_pages = build_document_html(chapters, metadata, toc_page_numbers: {})
33
+ toc_page_numbers = resolve_toc_page_numbers(html_without_toc_pages, toc_targets)
34
+ html = build_document_html(chapters, metadata, toc_page_numbers: toc_page_numbers)
35
+
36
+ FileUtils.mkdir_p(File.dirname(@output_path))
37
+ generate_pdf_to_path(html, @output_path)
38
+
39
+ @output_path
40
+ end
41
+
42
+ def render_html
43
+ validate_paths!
44
+
45
+ chapters, front_matter = chapter_compiler.compile(inputs: @inputs)
46
+ metadata = resolved_metadata(front_matter)
47
+
48
+ build_document_html(chapters, metadata, toc_page_numbers: {})
49
+ end
50
+
51
+ private
52
+
53
+ def validate_paths!
54
+ raise ArgumentError, "At least one markdown input file is required" if @inputs.empty?
55
+
56
+ @inputs.each do |path|
57
+ raise ArgumentError, "Input markdown file not found: #{path}" unless File.exist?(path)
58
+ end
59
+ end
60
+
61
+ def build_document_html(chapters, metadata, toc_page_numbers:)
62
+ template = BookTemplate.new(template_source: @theme.template_source)
63
+ template.render(
64
+ body_html: build_body_html(chapters),
65
+ toc_html: build_toc_html(chapters, toc_page_numbers: toc_page_numbers),
66
+ styles_css: @theme.styles_css,
67
+ core_css: core_css,
68
+ metadata: metadata,
69
+ escape_html: method(:h)
70
+ )
71
+ end
72
+
73
+ def build_body_html(chapters)
74
+ chapters.map { |chapter| chapter.article_html(escape_html: method(:h)) }.join("\n")
75
+ end
76
+
77
+ def build_toc_html(chapters, toc_page_numbers:)
78
+ toc_builder.build(chapters: chapters, escape_html: method(:h), page_numbers: toc_page_numbers)
79
+ end
80
+
81
+ def generate_pdf_to_path(html, output_path)
82
+ pdf_writer.write(html: html, output_path: output_path)
83
+ end
84
+
85
+ def resolve_toc_page_numbers(html, toc_targets)
86
+ return {} if toc_targets.empty?
87
+
88
+ Dir.mktmpdir("pocketbook-toc") do |dir|
89
+ temp_pdf_path = File.join(dir, "toc-pass.pdf")
90
+ generate_pdf_to_path(html, temp_pdf_path)
91
+ pdf_writer.toc_page_numbers(pdf_path: temp_pdf_path, toc_targets: toc_targets)
92
+ end
93
+ rescue StandardError
94
+ {}
95
+ end
96
+
97
+ def resolved_metadata(front_matter)
98
+ metadata_resolver.resolve(
99
+ theme_defaults: @theme.defaults_metadata,
100
+ cli_metadata: @cli_metadata,
101
+ front_matter: front_matter,
102
+ first_input_path: @inputs.first
103
+ )
104
+ end
105
+
106
+ def h(value)
107
+ ERB::Util.html_escape(value.to_s)
108
+ end
109
+
110
+ def core_css
111
+ CoreStylesheet.load
112
+ end
113
+
114
+ def chapter_compiler
115
+ @chapter_compiler ||= Chapter.new
116
+ end
117
+
118
+ def metadata_resolver
119
+ @metadata_resolver ||= Metadata.new
120
+ end
121
+
122
+ def toc_builder
123
+ @toc_builder ||= Toc.new
124
+ end
125
+
126
+ def pdf_writer
127
+ @pdf_writer ||= Pdf.new
128
+ end
129
+
130
+ def blank?(value)
131
+ value.nil? || value.to_s.strip.empty?
132
+ end
133
+
134
+ def symbolize_keys(hash)
135
+ hash.each_with_object({}) do |(key, value), output|
136
+ output[key.to_sym] = value
137
+ end
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,40 @@
1
+ require "erb"
2
+
3
+ module Pocketbook
4
+ class BookTemplate
5
+ Context = Struct.new(
6
+ :body_html,
7
+ :toc_html,
8
+ :styles_css,
9
+ :core_css,
10
+ :metadata,
11
+ :escape_html,
12
+ keyword_init: true
13
+ ) do
14
+ def h(value)
15
+ escape_html.call(value)
16
+ end
17
+
18
+ def get_binding
19
+ binding
20
+ end
21
+ end
22
+
23
+ def initialize(template_source:)
24
+ @template = ERB.new(template_source, trim_mode: "-")
25
+ end
26
+
27
+ def render(body_html:, toc_html:, styles_css:, core_css:, metadata:, escape_html:)
28
+ context = Context.new(
29
+ body_html: body_html,
30
+ toc_html: toc_html,
31
+ styles_css: styles_css,
32
+ core_css: core_css,
33
+ metadata: metadata,
34
+ escape_html: escape_html
35
+ )
36
+
37
+ @template.result(context.get_binding)
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,344 @@
1
+ require "optparse"
2
+ require_relative "../render_request"
3
+
4
+ module Pocketbook
5
+ module CLI
6
+ class OptionsParser
7
+ DEFAULT_DEBOUNCE_MS = 350
8
+ DEFAULT_THEME_NAME = "classic"
9
+ DEFAULT_THEME_PATH = Pocketbook.bundled_theme_path(DEFAULT_THEME_NAME)
10
+ DEFAULT_THEME_DIRECTORY = Pocketbook.user_themes_path
11
+
12
+ class ParsedOptions
13
+ attr_reader :inputs, :theme_path, :style, :template_override, :output_path, :metadata, :debounce_ms, :diagnostics, :open_output
14
+
15
+ def initialize
16
+ @inputs = []
17
+ @theme_path = DEFAULT_THEME_PATH
18
+ @style = nil
19
+ @template_override = nil
20
+ @output_path = "output/book.pdf"
21
+ @metadata = {}
22
+ @debounce_ms = DEFAULT_DEBOUNCE_MS
23
+ @diagnostics = false
24
+ @open_output = false
25
+ end
26
+
27
+ def add_inputs(path_value)
28
+ path_value.to_s.split(",").each do |value|
29
+ normalized = value.strip
30
+ next if normalized.empty?
31
+
32
+ @inputs << normalized
33
+ end
34
+ end
35
+
36
+ def theme_path=(value)
37
+ @theme_path = value
38
+ end
39
+
40
+ def style=(value)
41
+ @style = value
42
+ end
43
+
44
+ def template_override=(value)
45
+ @template_override = value
46
+ end
47
+
48
+ def output_path=(value)
49
+ @output_path = value
50
+ end
51
+
52
+ def metadata_value(key, value)
53
+ @metadata[key] = value
54
+ end
55
+
56
+ def debounce_ms=(value)
57
+ raise OptionParser::InvalidArgument, "--debounce-ms must be zero or positive" if value.negative?
58
+
59
+ @debounce_ms = value
60
+ end
61
+
62
+ def diagnostics!
63
+ @diagnostics = true
64
+ end
65
+
66
+ def open_output!
67
+ @open_output = true
68
+ end
69
+
70
+ def validate!
71
+ raise OptionParser::MissingArgument, "at least one markdown input file is required" if @inputs.empty?
72
+ end
73
+
74
+ def to_request
75
+ Pocketbook::RenderRequest.new(
76
+ inputs: @inputs,
77
+ theme_path: @theme_path,
78
+ style: @style,
79
+ template_override: @template_override,
80
+ output_path: @output_path,
81
+ metadata: @metadata,
82
+ debounce_ms: @debounce_ms,
83
+ diagnostics: @diagnostics,
84
+ open_output: @open_output
85
+ )
86
+ end
87
+ end
88
+
89
+ ThemeNewRequest = Struct.new(:name, :target_root, :with_template, :force, keyword_init: true)
90
+ ThemeResolveRequest = Struct.new(:theme_path, :style, keyword_init: true)
91
+ ThemeGetRequest = Struct.new(:url, :name, :into, :force, :dry_run, :ref, keyword_init: true)
92
+
93
+ def initialize(stdout: $stdout)
94
+ @stdout = stdout
95
+ end
96
+
97
+ def parse(argv)
98
+ args = argv.dup
99
+ command = resolve_command(args)
100
+
101
+ case command
102
+ when :build
103
+ request, parse_status = parse_render_options(args, watch: false)
104
+ [:build, request, parse_status]
105
+ when :watch
106
+ request, parse_status = parse_render_options(args, watch: true)
107
+ [:watch, request, parse_status]
108
+ when :theme
109
+ parse_theme_options(args)
110
+ else
111
+ raise OptionParser::InvalidArgument, "Unknown command '#{command}'"
112
+ end
113
+ end
114
+
115
+ private
116
+
117
+ def resolve_command(args)
118
+ first = args.first
119
+ return :build if first.nil? || first.start_with?("-")
120
+
121
+ case first
122
+ when "build"
123
+ args.shift
124
+ :build
125
+ when "watch"
126
+ args.shift
127
+ :watch
128
+ when "theme"
129
+ args.shift
130
+ :theme
131
+ else
132
+ :build
133
+ end
134
+ end
135
+
136
+ def parse_render_options(args, watch: false)
137
+ options = ParsedOptions.new
138
+
139
+ parser = OptionParser.new do |opts|
140
+ opts.banner = if watch
141
+ "Usage: pocketbook watch FILE.md [MORE.md ...] [options]"
142
+ else
143
+ "Usage: pocketbook build FILE.md [MORE.md ...] [options]"
144
+ end
145
+
146
+ unless watch
147
+ opts.separator ""
148
+ opts.separator "Commands:"
149
+ opts.separator " build Build a PDF (default command)"
150
+ opts.separator " watch Rebuild PDF on file changes"
151
+ opts.separator " theme new Scaffold a new theme"
152
+ opts.separator " theme validate Validate a theme reference"
153
+ opts.separator " theme inspect Show resolved theme assets"
154
+ opts.separator " theme get Download theme from GitHub manifest URL"
155
+ end
156
+
157
+ opts.on("-t", "--theme NAME_OR_PATH", "Theme name, directory, or theme.yml (default: bundled #{DEFAULT_THEME_NAME} theme)") { |path| options.theme_path = path }
158
+ opts.on("-s", "--style NAME", "Style variant inside the selected theme") { |value| options.style = value }
159
+ opts.on("--template PATH", "Template ERB override file") { |path| options.template_override = path }
160
+ opts.on("-o", "--output PATH", "Output PDF path (default: output/book.pdf)") { |path| options.output_path = path }
161
+ opts.on("--diagnostics", "Print resolved configuration diagnostics after build") { options.diagnostics! }
162
+ opts.on("--open", "Open the generated PDF with the OS default viewer") { options.open_output! }
163
+
164
+ opts.on("--title TEXT", "Book title") { |value| options.metadata_value(:title, value) }
165
+ opts.on("--subtitle TEXT", "Book subtitle") { |value| options.metadata_value(:subtitle, value) }
166
+ opts.on("--author TEXT", "Book author") { |value| options.metadata_value(:author, value) }
167
+ opts.on("--publisher TEXT", "Publisher label") { |value| options.metadata_value(:publisher, value) }
168
+ opts.on("--backcover TEXT", "Backcover copy") { |value| options.metadata_value(:backcover_text, value) }
169
+ opts.on("--size TEXT", "Book size (CSS format, ex: 6in 9in or 6inx9in)") { |value| options.metadata_value(:size, value) }
170
+
171
+ if watch
172
+ opts.on("--debounce-ms INTEGER", Integer, "Debounce window in milliseconds before rebuild (default: #{DEFAULT_DEBOUNCE_MS})") do |value|
173
+ options.debounce_ms = value
174
+ end
175
+ end
176
+
177
+ opts.on("-v", "--version", "Print version") do
178
+ @stdout.puts("Pocketbook #{Pocketbook::VERSION}")
179
+ return [nil, :exit]
180
+ end
181
+
182
+ opts.on("-h", "--help", "Print this help") do
183
+ @stdout.puts(opts)
184
+ return [nil, :exit]
185
+ end
186
+ end
187
+
188
+ parser.parse!(args)
189
+ args.each { |path| options.add_inputs(path) }
190
+
191
+ options.validate!
192
+ [options.to_request, :ok]
193
+ end
194
+
195
+ def parse_theme_options(args)
196
+ command = args.shift
197
+ case command
198
+ when "new"
199
+ parse_theme_new(args)
200
+ when "validate"
201
+ parse_theme_validate(args)
202
+ when "inspect"
203
+ parse_theme_inspect(args)
204
+ when "get"
205
+ parse_theme_get(args)
206
+ when nil, "-h", "--help", "help"
207
+ print_theme_help
208
+ [nil, nil, :exit]
209
+ else
210
+ raise OptionParser::InvalidArgument, "Unknown theme command '#{command}'"
211
+ end
212
+ end
213
+
214
+ def parse_theme_new(args)
215
+ options = {
216
+ target_root: DEFAULT_THEME_DIRECTORY,
217
+ with_template: false,
218
+ force: false
219
+ }
220
+
221
+ parser = OptionParser.new do |opts|
222
+ opts.banner = "Usage: pocketbook theme new NAME [options]"
223
+ opts.separator ""
224
+ opts.separator "Options:"
225
+ opts.on("--path DIR", "Theme directory root (default: #{DEFAULT_THEME_DIRECTORY})") { |value| options[:target_root] = value }
226
+ opts.on("--with-template", "Create template.html.erb scaffold") { options[:with_template] = true }
227
+ opts.on("--force", "Overwrite scaffold files when they already exist") { options[:force] = true }
228
+ opts.on("-h", "--help", "Print this help") do
229
+ @stdout.puts(opts)
230
+ return [nil, nil, :exit]
231
+ end
232
+ end
233
+
234
+ parser.parse!(args)
235
+
236
+ name = args.shift
237
+ raise OptionParser::MissingArgument, "theme name is required" if blank?(name)
238
+ raise OptionParser::InvalidArgument, "Unexpected arguments: #{args.join(' ')}" unless args.empty?
239
+
240
+ request = ThemeNewRequest.new(
241
+ name: name.to_s.strip,
242
+ target_root: options[:target_root].to_s.strip,
243
+ with_template: options[:with_template],
244
+ force: options[:force]
245
+ )
246
+ [:theme_new, request, :ok]
247
+ end
248
+
249
+ def parse_theme_validate(args)
250
+ parse_theme_resolve(args, :theme_validate, "Usage: pocketbook theme validate [THEME_NAME_OR_PATH] [--style NAME]")
251
+ end
252
+
253
+ def parse_theme_inspect(args)
254
+ parse_theme_resolve(args, :theme_inspect, "Usage: pocketbook theme inspect [THEME_NAME_OR_PATH] [--style NAME]")
255
+ end
256
+
257
+ def parse_theme_get(args)
258
+ options = {
259
+ name: nil,
260
+ into: DEFAULT_THEME_DIRECTORY,
261
+ force: false,
262
+ dry_run: false,
263
+ ref: nil
264
+ }
265
+
266
+ parser = OptionParser.new do |opts|
267
+ opts.banner = "Usage: pocketbook theme get GITHUB_THEME_YML_URL [options]"
268
+ opts.on("--name NAME", "Destination theme name override") { |value| options[:name] = value }
269
+ opts.on("--into DIR", "Destination theme directory root (default: #{DEFAULT_THEME_DIRECTORY})") { |value| options[:into] = value }
270
+ opts.on("--ref REF", "Git reference override (branch/tag/sha)") { |value| options[:ref] = value }
271
+ opts.on("--force", "Overwrite existing destination theme directory") { options[:force] = true }
272
+ opts.on("--dry-run", "Print planned downloads without writing files") { options[:dry_run] = true }
273
+ opts.on("-h", "--help", "Print this help") do
274
+ @stdout.puts(opts)
275
+ return [nil, nil, :exit]
276
+ end
277
+ end
278
+
279
+ parser.parse!(args)
280
+ url = args.shift
281
+ raise OptionParser::MissingArgument, "theme manifest URL is required" if blank?(url)
282
+ raise OptionParser::InvalidArgument, "Unexpected arguments: #{args.join(' ')}" unless args.empty?
283
+
284
+ request = ThemeGetRequest.new(
285
+ url: url.to_s.strip,
286
+ name: normalize_optional_string(options[:name]),
287
+ into: options[:into].to_s.strip,
288
+ force: options[:force],
289
+ dry_run: options[:dry_run],
290
+ ref: normalize_optional_string(options[:ref])
291
+ )
292
+
293
+ [:theme_get, request, :ok]
294
+ end
295
+
296
+ def parse_theme_resolve(args, command, usage)
297
+ options = {
298
+ style: nil
299
+ }
300
+
301
+ parser = OptionParser.new do |opts|
302
+ opts.banner = usage
303
+ opts.on("-s", "--style NAME", "Style variant to resolve") { |value| options[:style] = value }
304
+ opts.on("-h", "--help", "Print this help") do
305
+ @stdout.puts(opts)
306
+ return [nil, nil, :exit]
307
+ end
308
+ end
309
+
310
+ parser.parse!(args)
311
+ theme_path = args.shift || DEFAULT_THEME_NAME
312
+ raise OptionParser::InvalidArgument, "Unexpected arguments: #{args.join(' ')}" unless args.empty?
313
+
314
+ request = ThemeResolveRequest.new(theme_path: theme_path, style: options[:style])
315
+ [command, request, :ok]
316
+ end
317
+
318
+ def print_theme_help
319
+ @stdout.puts <<~TEXT
320
+ Usage: pocketbook theme <command> [options]
321
+
322
+ Commands:
323
+ new NAME Scaffold a new theme directory
324
+ validate [THEME] Validate that a theme resolves correctly
325
+ inspect [THEME] Print resolved theme details
326
+ get URL Download a theme from a GitHub theme.yml URL
327
+ TEXT
328
+ end
329
+
330
+ def normalize_optional_string(value)
331
+ return nil if value.nil?
332
+
333
+ normalized = value.to_s.strip
334
+ return nil if normalized.empty?
335
+
336
+ normalized
337
+ end
338
+
339
+ def blank?(value)
340
+ value.nil? || value.to_s.strip.empty?
341
+ end
342
+ end
343
+ end
344
+ end