sakusei 0.4.0 → 0.5.9

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.
@@ -0,0 +1,444 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'webrick'
4
+ require 'listen'
5
+ require_relative 'builder'
6
+
7
+ module Sakusei
8
+ # Live-reload preview server. Watches a markdown source and its dependencies
9
+ # (style pack, Vue components, @include partials, images) and re-renders to
10
+ # styled HTML on change. Browser long-polls /__events to know when to update;
11
+ # updates apply incrementally via paged.js's Previewer API (no full reload).
12
+ class PreviewServer
13
+ DEFAULT_PORT = 4567
14
+ DEBOUNCE_SECONDS = 0.05
15
+ POLL_TIMEOUT_SECONDS = 30
16
+
17
+ PAGED_JS_CDN = 'https://unpkg.com/pagedjs/dist/paged.js'
18
+
19
+ PREVIEW_CSS = <<~CSS
20
+ html, body {
21
+ background: #2a2a2a;
22
+ margin: 0;
23
+ padding: 0;
24
+ }
25
+ .pagedjs_pages {
26
+ padding: 24px 0;
27
+ }
28
+ .pagedjs_page {
29
+ background: white;
30
+ margin: 24px auto !important;
31
+ box-shadow: 0 8px 32px rgba(0,0,0,0.55);
32
+ }
33
+ #__sakusei_render {
34
+ min-height: 100vh;
35
+ }
36
+ #__sakusei_render.no-paged {
37
+ max-width: 850px;
38
+ margin: 24px auto;
39
+ padding: 48px 64px;
40
+ background: white;
41
+ box-shadow: 0 8px 32px rgba(0,0,0,0.55);
42
+ }
43
+ CSS
44
+
45
+ PREVIEW_JS = <<~'JS'
46
+ (function() {
47
+ var version = window.__SAKUSEI_VERSION__ || 0;
48
+ var renderTarget = null;
49
+ var ready = false;
50
+
51
+ function fullReload() { window.location.reload(); }
52
+
53
+ function render(html) {
54
+ renderTarget.innerHTML = '';
55
+ renderTarget.classList.remove('no-paged');
56
+ if (typeof Paged === 'undefined' || !Paged.Previewer) {
57
+ renderTarget.classList.add('no-paged');
58
+ renderTarget.innerHTML = html;
59
+ return Promise.resolve();
60
+ }
61
+ var previewer = new Paged.Previewer();
62
+ return previewer.preview(html, undefined, renderTarget).catch(function(e) {
63
+ console.error('[sakusei-preview] paged.js failed:', e);
64
+ renderTarget.classList.add('no-paged');
65
+ renderTarget.innerHTML = html;
66
+ });
67
+ }
68
+
69
+ function init() {
70
+ var sourceHTML = document.body.innerHTML;
71
+ document.body.innerHTML = '';
72
+ renderTarget = document.createElement('div');
73
+ renderTarget.id = '__sakusei_render';
74
+ document.body.appendChild(renderTarget);
75
+ render(sourceHTML).then(function() {
76
+ ready = true;
77
+ poll();
78
+ });
79
+ }
80
+
81
+ function refresh(newVersion) {
82
+ fetch('/__content').then(function(r) {
83
+ if (!r.ok) throw new Error('content fetch failed');
84
+ return r.text();
85
+ }).then(function(html) {
86
+ var scrollY = window.scrollY;
87
+ version = newVersion;
88
+ return render(html).then(function() {
89
+ window.scrollTo(0, scrollY);
90
+ poll();
91
+ });
92
+ }).catch(function(e) {
93
+ console.warn('[sakusei-preview] partial update failed, reloading:', e);
94
+ fullReload();
95
+ });
96
+ }
97
+
98
+ function poll() {
99
+ if (!ready) return;
100
+ fetch('/__events?since=' + version).then(function(r) {
101
+ if (r.status === 200) {
102
+ return r.text().then(function(t) {
103
+ var v = parseInt(t, 10);
104
+ if (v > version) {
105
+ refresh(v);
106
+ } else {
107
+ poll();
108
+ }
109
+ });
110
+ }
111
+ setTimeout(poll, 50);
112
+ }).catch(function() {
113
+ setTimeout(poll, 1000);
114
+ });
115
+ }
116
+
117
+ if (document.readyState === 'loading') {
118
+ document.addEventListener('DOMContentLoaded', init);
119
+ } else {
120
+ init();
121
+ }
122
+ })();
123
+ JS
124
+
125
+ def initialize(source_file, options = {})
126
+ @source_file = File.expand_path(source_file)
127
+ raise Error, "File not found: #{source_file}" unless File.exist?(@source_file)
128
+
129
+ @options = options
130
+ @port = options[:port] || DEFAULT_PORT
131
+ @open_browser = options.fetch(:open, true)
132
+ @use_paged_js = options.fetch(:paged, true)
133
+
134
+ @source_dir = File.dirname(@source_file)
135
+ @lock = Mutex.new
136
+ @cv = ConditionVariable.new
137
+ @version = 0
138
+ @cached_html = nil
139
+ @cached_body = nil
140
+ @cached_error = nil
141
+ @style_pack_path = nil
142
+
143
+ @rebuild_lock = Mutex.new
144
+ @debounce_timer = nil
145
+ @shutting_down = false
146
+ end
147
+
148
+ def run
149
+ build_now(initial: true)
150
+ start_listeners
151
+ start_server
152
+ end
153
+
154
+ private
155
+
156
+ def build_now(initial: false, change_detected_at: nil)
157
+ build_started_at = Time.now
158
+ builder = Builder.new(@source_file, @options)
159
+ html, style_pack = builder.build_html
160
+ build_done_at = Time.now
161
+
162
+ chrome = style_pack ? PageChromeTranslator.new(style_pack).build : { css: '', html: '' }
163
+
164
+ @style_pack_path = style_pack&.path
165
+ body_inner = extract_body_inner(html)
166
+ @cached_body = "#{chrome[:html]}#{body_inner}"
167
+ @cached_html = inject_preview_chrome(html, chrome)
168
+ @cached_error = nil
169
+ bump_version
170
+
171
+ log_timing(initial: initial, change_detected_at: change_detected_at,
172
+ build_started_at: build_started_at, build_done_at: build_done_at)
173
+ rescue => e
174
+ @cached_error = e
175
+ @cached_html = render_error_page(e)
176
+ @cached_body = error_body(e)
177
+ bump_version
178
+ $stderr.puts "[sakusei-preview] build error: #{e.message}"
179
+ end
180
+
181
+ def log_timing(initial:, change_detected_at:, build_started_at:, build_done_at:)
182
+ build_ms = ms(build_started_at, build_done_at)
183
+ msg = "[sakusei-preview] #{initial ? 'initial render' : 're-rendered'} (v#{@version}) — build #{build_ms}ms"
184
+ if change_detected_at
185
+ detected_ms = ms(change_detected_at, build_started_at)
186
+ total_ms = ms(change_detected_at, build_done_at)
187
+ msg += " · detected #{detected_ms}ms · total #{total_ms}ms"
188
+ end
189
+ $stderr.puts msg
190
+ end
191
+
192
+ def ms(t0, t1)
193
+ ((t1 - t0) * 1000).round
194
+ end
195
+
196
+ def bump_version
197
+ @lock.synchronize do
198
+ @version += 1
199
+ @cv.broadcast
200
+ end
201
+ end
202
+
203
+ def start_listeners
204
+ dirs = [@source_dir]
205
+ dirs << @style_pack_path if @style_pack_path && !path_within?(@style_pack_path, @source_dir)
206
+
207
+ @listener = Listen.to(*dirs, ignore: [/node_modules/, /\.git/, /\.DS_Store/, /\.pdf\z/]) do |modified, added, removed|
208
+ changed = (modified + added + removed).reject { |p| p.end_with?('.pdf') }
209
+ next if changed.empty?
210
+ schedule_rebuild(Time.now)
211
+ end
212
+ @listener.start
213
+ $stderr.puts "[sakusei-preview] watching: #{dirs.join(', ')}"
214
+ end
215
+
216
+ def schedule_rebuild(detected_at)
217
+ @rebuild_lock.synchronize do
218
+ @debounce_timer&.kill
219
+ @debounce_timer = Thread.new do
220
+ sleep DEBOUNCE_SECONDS
221
+ build_now(change_detected_at: detected_at)
222
+ end
223
+ end
224
+ end
225
+
226
+ def start_server
227
+ server = WEBrick::HTTPServer.new(
228
+ Port: @port,
229
+ BindAddress: '127.0.0.1',
230
+ Logger: WEBrick::Log.new(File::NULL),
231
+ AccessLog: []
232
+ )
233
+
234
+ server.mount_proc('/__events') { |req, res| handle_events(req, res) }
235
+ server.mount_proc('/__content') { |_req, res| serve_text(res, 'text/html; charset=utf-8', @cached_body || '') }
236
+ server.mount_proc('/__assets/preview.js') { |_req, res| serve_text(res, 'application/javascript', PREVIEW_JS) }
237
+ server.mount_proc('/__pack') { |req, res| handle_pack_asset(req, res) }
238
+ server.mount_proc('/') { |req, res| handle_root_or_static(req, res) }
239
+
240
+ shutdown_queue = Queue.new
241
+ # Trap handlers run in a constrained context (no Mutex#synchronize),
242
+ # so just enqueue intent and let the supervisor thread do the work.
243
+ %w[INT TERM].each do |sig|
244
+ trap(sig) { shutdown_queue.push(:stop) rescue nil }
245
+ end
246
+
247
+ Thread.new do
248
+ shutdown_queue.pop
249
+ $stderr.puts "\n[sakusei-preview] shutting down..."
250
+ @shutting_down = true
251
+ # Wake any pending long-poll connections so they return immediately
252
+ # instead of holding the server open for up to POLL_TIMEOUT_SECONDS.
253
+ @lock.synchronize { @cv.broadcast }
254
+ @debounce_timer&.kill
255
+ begin
256
+ @listener&.stop
257
+ rescue StandardError
258
+ # Listen may raise on stop if its threads are already torn down.
259
+ end
260
+ server.shutdown
261
+ end
262
+
263
+ url = "http://127.0.0.1:#{@port}/"
264
+ $stderr.puts "[sakusei-preview] serving #{url}"
265
+ open_in_browser(url) if @open_browser
266
+
267
+ server.start
268
+ end
269
+
270
+ def handle_events(req, res)
271
+ since = req.query['since'].to_i
272
+ @lock.synchronize do
273
+ return shutdown_response(res) if @shutting_down
274
+ if @version > since
275
+ respond_version(res)
276
+ return
277
+ end
278
+ @cv.wait(@lock, POLL_TIMEOUT_SECONDS)
279
+ if @shutting_down
280
+ shutdown_response(res)
281
+ elsif @version > since
282
+ respond_version(res)
283
+ else
284
+ res.status = 204
285
+ res.body = ''
286
+ end
287
+ end
288
+ end
289
+
290
+ def shutdown_response(res)
291
+ res.status = 503
292
+ res['Content-Type'] = 'text/plain'
293
+ res.body = 'shutting down'
294
+ end
295
+
296
+ def respond_version(res)
297
+ res.status = 200
298
+ res['Content-Type'] = 'text/plain'
299
+ res['Cache-Control'] = 'no-store'
300
+ res.body = @version.to_s
301
+ end
302
+
303
+ def handle_pack_asset(req, res)
304
+ unless @style_pack_path
305
+ res.status = 404
306
+ res.body = 'no style pack'
307
+ return
308
+ end
309
+
310
+ rel = req.path.sub(%r{\A/__pack/?}, '')
311
+ if rel.empty?
312
+ res.status = 404
313
+ res.body = 'not found'
314
+ return
315
+ end
316
+
317
+ candidate = File.expand_path(rel, @style_pack_path)
318
+ unless candidate.start_with?(File.expand_path(@style_pack_path) + File::SEPARATOR)
319
+ res.status = 403
320
+ res.body = 'forbidden'
321
+ return
322
+ end
323
+
324
+ if File.file?(candidate)
325
+ res.status = 200
326
+ res['Content-Type'] = WEBrick::HTTPUtils.mime_type(candidate, WEBrick::HTTPUtils::DefaultMimeTypes)
327
+ res['Cache-Control'] = 'no-store'
328
+ res.body = File.binread(candidate)
329
+ else
330
+ res.status = 404
331
+ res.body = 'not found'
332
+ end
333
+ end
334
+
335
+ def handle_root_or_static(req, res)
336
+ path = req.path
337
+ if path == '/' || path == ''
338
+ serve_text(res, 'text/html; charset=utf-8', @cached_html || '<p>building...</p>')
339
+ return
340
+ end
341
+
342
+ rel = path.sub(%r{\A/}, '')
343
+ candidate = File.expand_path(rel, @source_dir)
344
+ unless candidate.start_with?(File.expand_path(@source_dir) + File::SEPARATOR)
345
+ res.status = 403
346
+ res.body = 'forbidden'
347
+ return
348
+ end
349
+
350
+ if File.file?(candidate)
351
+ res['Content-Type'] = WEBrick::HTTPUtils.mime_type(candidate, WEBrick::HTTPUtils::DefaultMimeTypes)
352
+ res['Cache-Control'] = 'no-store'
353
+ res.body = File.binread(candidate)
354
+ else
355
+ res.status = 404
356
+ res.body = 'not found'
357
+ end
358
+ end
359
+
360
+ def serve_text(res, content_type, body)
361
+ res.status = 200
362
+ res['Content-Type'] = content_type
363
+ res['Cache-Control'] = 'no-store'
364
+ res.body = body
365
+ end
366
+
367
+ # Extract the inner HTML of <body>...</body>. Falls back to the original
368
+ # html if the regex doesn't match (e.g. md-to-pdf changes its output shape).
369
+ def extract_body_inner(html)
370
+ m = html.match(/<body[^>]*>(.*)<\/body>/m)
371
+ m ? m[1] : html
372
+ end
373
+
374
+ def inject_preview_chrome(html, chrome = { css: '', html: '' })
375
+ version_script = "<script>window.__SAKUSEI_VERSION__=#{@version + 1};</script>"
376
+ paged = @use_paged_js ? %(<script src="#{PAGED_JS_CDN}"></script>) : ''
377
+ preview_js = '<script src="/__assets/preview.js"></script>'
378
+ style_block = "<style>#{PREVIEW_CSS}\n#{chrome[:css]}</style>"
379
+
380
+ head_injection = "#{style_block}#{paged}"
381
+ body_open_injection = chrome[:html].to_s
382
+ body_close_injection = "#{version_script}#{preview_js}"
383
+
384
+ result = if html.include?('</head>')
385
+ html.sub('</head>', "#{head_injection}</head>")
386
+ else
387
+ head_injection + html
388
+ end
389
+
390
+ if (m = result.match(/<body[^>]*>/))
391
+ result = result.sub(m[0], "#{m[0]}#{body_open_injection}")
392
+ else
393
+ result = body_open_injection + result
394
+ end
395
+
396
+ if result.include?('</body>')
397
+ result.sub('</body>', "#{body_close_injection}</body>")
398
+ else
399
+ result + body_close_injection
400
+ end
401
+ end
402
+
403
+ def error_body(err)
404
+ msg = WEBrick::HTMLUtils.escape(err.message)
405
+ backtrace = WEBrick::HTMLUtils.escape((err.backtrace || []).first(20).join("\n"))
406
+ <<~HTML
407
+ <h1 style="color:#ff6b6b;">Build failed</h1>
408
+ <pre style="background:#000;color:#e0e0e0;padding:1rem;overflow:auto;">#{msg}</pre>
409
+ <h2 style="color:#ccc;">Backtrace</h2>
410
+ <pre style="background:#000;color:#e0e0e0;padding:1rem;overflow:auto;">#{backtrace}</pre>
411
+ HTML
412
+ end
413
+
414
+ def render_error_page(err)
415
+ version_script = "<script>window.__SAKUSEI_VERSION__=#{@version + 1};</script>"
416
+ <<~HTML
417
+ <!DOCTYPE html>
418
+ <html><head><meta charset="utf-8"><title>sakusei-preview error</title>
419
+ <style>
420
+ body { font-family: ui-monospace, Menlo, monospace; padding: 2rem; background:#1e1e1e; color:#e0e0e0; }
421
+ #{PREVIEW_CSS}
422
+ </style>
423
+ </head><body>
424
+ #{error_body(err)}
425
+ #{version_script}
426
+ <script src="/__assets/preview.js"></script>
427
+ </body></html>
428
+ HTML
429
+ end
430
+
431
+ def path_within?(child, parent)
432
+ File.expand_path(child).start_with?(File.expand_path(parent) + File::SEPARATOR)
433
+ end
434
+
435
+ def open_in_browser(url)
436
+ cmd = case RbConfig::CONFIG['host_os']
437
+ when /darwin/i then ['open', url]
438
+ when /linux/i then ['xdg-open', url]
439
+ when /mswin|mingw|cygwin/i then ['start', url]
440
+ end
441
+ system(*cmd) if cmd
442
+ end
443
+ end
444
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Sakusei
4
- VERSION = '0.4.0'
4
+ VERSION = '0.5.9'
5
5
  end
data/lib/sakusei.rb CHANGED
@@ -15,10 +15,34 @@ require_relative 'sakusei/pdf_concat'
15
15
  require_relative 'sakusei/multi_file_builder'
16
16
  require_relative 'sakusei/style_preview'
17
17
  require_relative 'sakusei/vue_processor'
18
+ require_relative 'sakusei/html_converter'
19
+ require_relative 'sakusei/page_chrome_translator'
18
20
 
19
21
  module Sakusei
20
22
  class Error < StandardError; end
21
23
 
24
+ # Markdown extensions to auto-discover when a CLI is given a file argument
25
+ # without an extension (e.g. `sakusei-preview ai_workflow_assessment`).
26
+ MARKDOWN_EXTENSIONS = %w[.md .text .markdown].freeze
27
+
28
+ # Resolve a file argument by trying known markdown extensions if no extension
29
+ # was given. Returns the input unchanged if the file already exists, is a
30
+ # directory, looks like a glob, or already has an extension.
31
+ def self.resolve_file_extension(file)
32
+ return file if file.nil? || file.empty?
33
+ return file if File.exist?(file)
34
+ return file if File.directory?(file)
35
+ return file if file.include?('*')
36
+ return file if File.extname(file).length.positive?
37
+
38
+ MARKDOWN_EXTENSIONS.each do |ext|
39
+ candidate = file + ext
40
+ return candidate if File.exist?(candidate)
41
+ end
42
+
43
+ file
44
+ end
45
+
22
46
  # Main entry point for building PDFs
23
47
  def self.build(source_file, options = {})
24
48
  Builder.new(source_file, options).build
data/sakusei.gemspec CHANGED
@@ -17,12 +17,14 @@ Gem::Specification.new do |spec|
17
17
  `git ls-files -z`.split("\x0").reject { |f| f.match(%r{\A(?:test|spec|features)/}) }
18
18
  end
19
19
  spec.bindir = 'bin'
20
- spec.executables = ['sakusei']
20
+ spec.executables = %w[sakusei sakusei-preview]
21
21
  spec.require_paths = ['lib']
22
22
 
23
23
  # Dependencies
24
24
  spec.add_dependency 'thor', '~> 1.2'
25
25
  spec.add_dependency 'erb', '~> 4.0'
26
+ spec.add_dependency 'listen', '~> 3.8'
27
+ spec.add_dependency 'webrick', '~> 1.8'
26
28
 
27
29
  spec.add_development_dependency 'minitest', '~> 5.0'
28
30
  spec.add_development_dependency 'rubocop', '~> 1.0'
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sakusei
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.5.9
5
5
  platform: ruby
6
6
  authors:
7
7
  - Keith Rowell
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-04-20 00:00:00.000000000 Z
11
+ date: 2026-05-04 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: thor
@@ -38,6 +38,34 @@ dependencies:
38
38
  - - "~>"
39
39
  - !ruby/object:Gem::Version
40
40
  version: '4.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: listen
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '3.8'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '3.8'
55
+ - !ruby/object:Gem::Dependency
56
+ name: webrick
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '1.8'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '1.8'
41
69
  - !ruby/object:Gem::Dependency
42
70
  name: minitest
43
71
  requirement: !ruby/object:Gem::Requirement
@@ -72,14 +100,19 @@ email:
72
100
  - keith@example.com
73
101
  executables:
74
102
  - sakusei
103
+ - sakusei-preview
75
104
  extensions: []
76
105
  extra_rdoc_files: []
77
106
  files:
107
+ - ".claude/settings.json"
78
108
  - ".gitignore"
109
+ - CLAUDE.md
79
110
  - Gemfile
111
+ - Gemfile.lock
80
112
  - README.md
81
113
  - Rakefile
82
114
  - bin/sakusei
115
+ - bin/sakusei-preview
83
116
  - examples/getting-started-screenshot.png
84
117
  - examples/getting-started.md
85
118
  - examples/getting-started.pdf
@@ -89,13 +122,17 @@ files:
89
122
  - lib/sakusei.rb
90
123
  - lib/sakusei/builder.rb
91
124
  - lib/sakusei/cli.rb
125
+ - lib/sakusei/converter_base.rb
92
126
  - lib/sakusei/erb_processor.rb
93
127
  - lib/sakusei/file_resolver.rb
94
128
  - lib/sakusei/heading_wrapper.rb
129
+ - lib/sakusei/html_converter.rb
95
130
  - lib/sakusei/image_path_resolver.rb
96
131
  - lib/sakusei/md_to_pdf_converter.rb
97
132
  - lib/sakusei/multi_file_builder.rb
133
+ - lib/sakusei/page_chrome_translator.rb
98
134
  - lib/sakusei/pdf_concat.rb
135
+ - lib/sakusei/preview_server.rb
99
136
  - lib/sakusei/style_pack.rb
100
137
  - lib/sakusei/style_preview.rb
101
138
  - lib/sakusei/version.rb