sakusei 0.4.0 → 0.5.8

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 51994014be2b8900d793d1954389a52a2726b54703cd60673886f7b85cfbeaa2
4
- data.tar.gz: d650c71e17c76b74f770dd32cc83b45848cacd0b1f7a8f49c3d4eb98185fee50
3
+ metadata.gz: 1845947d6ebe2f9b6c5732093eff5bbd90aa7bc2a60ad79a568033bd8c674e57
4
+ data.tar.gz: 595a6804e797c8b0c58804b7df122b5a738e5592b786f4a0c29454801c864ed9
5
5
  SHA512:
6
- metadata.gz: 0c8a398207d15178355e2da253dd4e3d20e9e524a044125214e7c074f264892a5ae9749b2773cf42c4de353532d731a8798d52eb922da38f6d0e5e5185940f8c
7
- data.tar.gz: 0b8970fdb542f9fe5f2b81bda13019be3893501735c62962f7a66e60c2acc8f38672ccf74fc4df08ece6b149796d84da52a4207b1ccde31793d079856c426ce7
6
+ metadata.gz: 0c2d9f30c83d0f1de7c73f8c23dcfe590208c057e95cfae0a261a64a36f7a386f3e0e34d9ba1849b46407ce4e19bd86e6dc3544db861fa8ab2f92cf0ac868d6d
7
+ data.tar.gz: 29428e2b7e9dbbe86753c58a537b6596f55a7ff7c075efe9bf40c1b0a2ada7d6f9921273400cc8226cf5acb12613a8caffed8ce9efc55576fe9a7d9b7790ca71
@@ -0,0 +1,8 @@
1
+ {
2
+ "permissions": {
3
+ "defaultMode": "auto",
4
+ "allow": [
5
+ "Bash(git *)"
6
+ ]
7
+ }
8
+ }
data/CLAUDE.md ADDED
@@ -0,0 +1,86 @@
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ ## Commands
6
+
7
+ Run all tests:
8
+
9
+ ```bash
10
+ bundle exec rake test
11
+ ```
12
+
13
+ Run a single test file:
14
+
15
+ ```bash
16
+ ruby -Ilib -Itest test/sakusei/test_builder_break_syntax.rb
17
+ ```
18
+
19
+ Build and install the gem locally:
20
+
21
+ ```bash
22
+ rake install
23
+ ```
24
+
25
+ Build a PDF from one of the examples:
26
+
27
+ ```bash
28
+ bundle exec sakusei build examples/getting-started.md
29
+ ```
30
+
31
+ Live-preview a markdown file in the browser:
32
+
33
+ ```bash
34
+ bundle exec sakusei-preview examples/getting-started.md
35
+ ```
36
+
37
+ ## Architecture
38
+
39
+ Sakusei ships two CLIs:
40
+
41
+ - `bin/sakusei` (Thor) — the build CLI that produces PDFs.
42
+ - `bin/sakusei-preview` — a separate live-render binary backed by `Sakusei::PreviewServer`. Watches the source file, its `@include` partials, the active style pack, and referenced images; re-renders to HTML on change via `npx md-to-pdf --as-html` and reloads the browser tab. Loads paged.js from a CDN so `@page`/`page-break-*` rules are visible as document pages. Use this for fast iteration; use `sakusei build` for the final PDF.
43
+
44
+ Both CLIs share the multi-stage build pipeline.
45
+
46
+ ### Build pipeline
47
+
48
+ `Builder#build` in `lib/sakusei/builder.rb` runs these steps in order:
49
+
50
+ 1. **StylePack.discover** — walks up the directory tree from the source file, looking for `.sakusei/style_packs/<name>/` at each level. Falls back to the built-in pack at `lib/templates/default_style_pack/`.
51
+ 2. **FileResolver** — resolves `<!-- @include ./file.md -->` directives and concatenates the content.
52
+ 3. **ErbProcessor** — evaluates ERB in the markdown. Available helpers: `today(format)`, `env(name, default)`, `sh(command)`, `include_file(path)`, `image_path(relative_path)`, `document_headings(path)`.
53
+ 4. **expand_break_syntax** — expands `::break::` shorthand to `<div class="page-break"></div>`.
54
+ 5. **VueProcessor** — finds `<vue-component name="Foo" prop="value" />` tags, renders them server-side via `lib/sakusei/vue_renderer.js` using Node.js + `@vue/server-renderer` in a single batched call.
55
+ 6. **HeadingWrapper** — wraps h2/h3 headings with their immediately following content block in keep-together divs to prevent orphaned headings.
56
+ 7. **MdToPdfConverter** / **HtmlConverter** — both extend `ConverterBase` (`lib/sakusei/converter_base.rb`) and assemble the `npx md-to-pdf` command with config, stylesheets, and header/footer from the style pack. `MdToPdfConverter` runs it in a temp dir and returns the PDF path; `HtmlConverter` adds `--as-html` and returns the HTML string (used by the live preview server).
57
+
58
+ `Builder#build_html` runs stages 1–6 and ends in `HtmlConverter`, sharing the pipeline with `Builder#build` so the preview matches the final PDF.
59
+
60
+ `MultiFileBuilder` handles glob patterns and multiple source files, delegating to `Builder` per file and concatenating results.
61
+
62
+ ### Style packs
63
+
64
+ A style pack is a directory containing:
65
+
66
+ - `config.js` — md-to-pdf configuration (Puppeteer/Chrome options)
67
+ - `style.css` — stylesheet applied after `lib/templates/base.css`
68
+ - `header.html`, `footer.html` — Puppeteer page chrome injected before the markdown content
69
+ - `components/*.vue` — Vue 3 SFCs, rendered server-side at build time (optional)
70
+ - `package.json` — if present, npm dependencies are auto-installed on first use
71
+
72
+ `lib/templates/base.css` is always applied first and provides keep-together rules for tables, code blocks, blockquotes, images, and common custom classes.
73
+
74
+ Style pack discovery walks up the directory tree from the source file; a named pack can live in any ancestor's `.sakusei/` directory. The default pack in `lib/templates/default_style_pack/` is the final fallback.
75
+
76
+ ### Vue component system
77
+
78
+ Components are referenced in markdown as `<vue-component name="MyComponent" prop="value" />`. `VueProcessor` finds all such tags in a first pass, replaces them with numbered placeholders, then sends a single JSON batch to `vue_renderer.js` via stdin. The JS renderer uses `@vue/server-renderer` to render each component and returns HTML + scoped CSS back as JSON. Scoped CSS is injected as a `<style>` block at the top of the document.
79
+
80
+ Component resolution order: local `./components/<Name>.vue` → style pack `components/<Name>.vue`.
81
+
82
+ Vue components support named slots via `<template #slotname>...</template>` inside the tag. Slot content is converted from Markdown to HTML before being passed to the renderer.
83
+
84
+ ### Tests
85
+
86
+ Tests use Minitest. Fixtures live in `test/fixtures/` (markdown samples and stub style packs). `test/test_vue_renderer.js` is a standalone Node.js test for the JS renderer. The base test class `Sakusei::TestCase` provides `fixtures_dir` and a `temp_dir` that auto-cleans on teardown.
data/Gemfile.lock ADDED
@@ -0,0 +1,82 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ sakusei (0.5.8)
5
+ erb (~> 4.0)
6
+ listen (~> 3.8)
7
+ thor (~> 1.2)
8
+ webrick (~> 1.8)
9
+
10
+ GEM
11
+ remote: https://rubygems.org/
12
+ specs:
13
+ addressable (2.8.9)
14
+ public_suffix (>= 2.0.2, < 8.0)
15
+ ast (2.4.3)
16
+ bigdecimal (4.0.1)
17
+ cgi (0.5.1)
18
+ erb (4.0.3)
19
+ cgi (>= 0.3.3)
20
+ ffi (1.17.4)
21
+ ffi (1.17.4-arm64-darwin)
22
+ json (2.19.2)
23
+ json-schema (6.2.0)
24
+ addressable (~> 2.8)
25
+ bigdecimal (>= 3.1, < 5)
26
+ language_server-protocol (3.17.0.5)
27
+ lint_roller (1.1.0)
28
+ listen (3.10.0)
29
+ logger
30
+ rb-fsevent (~> 0.10, >= 0.10.3)
31
+ rb-inotify (~> 0.9, >= 0.9.10)
32
+ logger (1.7.0)
33
+ mcp (0.9.0)
34
+ json-schema (>= 4.1)
35
+ minitest (5.27.0)
36
+ parallel (1.27.0)
37
+ parser (3.3.10.2)
38
+ ast (~> 2.4.1)
39
+ racc
40
+ prism (1.9.0)
41
+ public_suffix (7.0.5)
42
+ racc (1.8.1)
43
+ rainbow (3.1.1)
44
+ rake (13.3.1)
45
+ rb-fsevent (0.11.2)
46
+ rb-inotify (0.11.1)
47
+ ffi (~> 1.0)
48
+ regexp_parser (2.11.3)
49
+ rubocop (1.85.1)
50
+ json (~> 2.3)
51
+ language_server-protocol (~> 3.17.0.2)
52
+ lint_roller (~> 1.1.0)
53
+ mcp (~> 0.6)
54
+ parallel (~> 1.10)
55
+ parser (>= 3.3.0.2)
56
+ rainbow (>= 2.2.2, < 4.0)
57
+ regexp_parser (>= 2.9.3, < 3.0)
58
+ rubocop-ast (>= 1.49.0, < 2.0)
59
+ ruby-progressbar (~> 1.7)
60
+ unicode-display_width (>= 2.4.0, < 4.0)
61
+ rubocop-ast (1.49.1)
62
+ parser (>= 3.3.7.2)
63
+ prism (~> 1.7)
64
+ ruby-progressbar (1.13.0)
65
+ thor (1.5.0)
66
+ unicode-display_width (3.2.0)
67
+ unicode-emoji (~> 4.1)
68
+ unicode-emoji (4.2.0)
69
+ webrick (1.9.2)
70
+
71
+ PLATFORMS
72
+ arm64-darwin-24
73
+ ruby
74
+
75
+ DEPENDENCIES
76
+ minitest (~> 5.0)
77
+ rake (~> 13.0)
78
+ rubocop (~> 1.0)
79
+ sakusei!
80
+
81
+ BUNDLED WITH
82
+ 2.7.2
@@ -0,0 +1,40 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'optparse'
5
+ require 'sakusei'
6
+ require 'sakusei/preview_server'
7
+
8
+ options = { open: true, paged: true }
9
+ parser = OptionParser.new do |opts|
10
+ opts.banner = 'Usage: sakusei-preview FILE [options]'
11
+
12
+ opts.on('-p', '--port PORT', Integer, "Port to bind (default #{Sakusei::PreviewServer::DEFAULT_PORT})") { |v| options[:port] = v }
13
+ opts.on('-s', '--style PACK', 'Style pack name to use') { |v| options[:style] = v }
14
+ opts.on('-c', '--config FILE', 'Path to md-to-pdf config file') { |v| options[:config] = v }
15
+ opts.on('--stylesheet FILE', 'Path to CSS stylesheet') { |v| options[:stylesheet] = v }
16
+ opts.on('--no-open', 'Do not open the browser automatically') { options[:open] = false }
17
+ opts.on('--no-paged', 'Disable paged.js pagination (show as scrolling HTML)') { options[:paged] = false }
18
+ opts.on('-h', '--help', 'Show this help') { puts opts; exit 0 }
19
+ end
20
+
21
+ args = parser.parse(ARGV)
22
+ if args.empty?
23
+ warn parser
24
+ exit 1
25
+ end
26
+
27
+ source_file = args.first
28
+ unless File.exist?(source_file)
29
+ warn "File not found: #{source_file}"
30
+ exit 1
31
+ end
32
+
33
+ begin
34
+ Sakusei::PreviewServer.new(source_file, options).run
35
+ rescue Interrupt
36
+ exit 0
37
+ rescue Sakusei::Error => e
38
+ warn "[sakusei-preview] #{e.message}"
39
+ exit 1
40
+ end
@@ -7,6 +7,7 @@ require_relative 'image_path_resolver'
7
7
  require_relative 'vue_processor'
8
8
  require_relative 'heading_wrapper'
9
9
  require_relative 'md_to_pdf_converter'
10
+ require_relative 'html_converter'
10
11
 
11
12
  module Sakusei
12
13
  class Builder
@@ -14,48 +15,64 @@ module Sakusei
14
15
  @source_file = File.expand_path(source_file)
15
16
  @options = options
16
17
  @source_dir = File.dirname(@source_file)
18
+ @file_resolver = nil
19
+ end
20
+
21
+ attr_reader :source_file, :source_dir
22
+
23
+ # Files that contributed to the most recent build (source + @include partials).
24
+ # Populated after #build, #build_html, or #build_processed_content runs.
25
+ def resolved_input_files
26
+ files = [@source_file]
27
+ files.concat(@file_resolver.resolved_files.to_a) if @file_resolver
28
+ files.uniq
17
29
  end
18
30
 
19
31
  def build
20
- # 1. Discover and load style pack
32
+ style_pack, processed_content = build_processed_content
33
+ $stderr.puts "[sakusei] converting to PDF..."
34
+ output_path = generate_output_path
35
+ convert_to_pdf(processed_content, output_path, style_pack)
36
+ $stderr.puts "[sakusei] written: #{output_path}"
37
+ output_path
38
+ end
39
+
40
+ # Run the full pipeline up to (but not including) PDF conversion, then
41
+ # render the processed markdown to a styled HTML string via md-to-pdf --as-html.
42
+ # Returns [html_string, style_pack].
43
+ def build_html
44
+ style_pack, processed_content = build_processed_content
45
+ html = HtmlConverter.new(processed_content, style_pack, @options.merge(source_dir: @source_dir)).convert
46
+ [html, style_pack]
47
+ end
48
+
49
+ private
50
+
51
+ def build_processed_content
21
52
  $stderr.puts "[sakusei] discovering style pack..."
22
53
  style_pack = discover_style_pack
23
54
  $stderr.puts "[sakusei] style pack: #{style_pack.name} (#{style_pack.path})"
24
55
 
25
- # 2. Resolve and concatenate file references
26
56
  $stderr.puts "[sakusei] resolving file includes..."
27
57
  resolved_content = resolve_files
28
58
 
29
- # 3. Process ERB templates
30
59
  $stderr.puts "[sakusei] processing ERB..."
31
60
  processed_content = process_erb(resolved_content)
32
61
 
33
- # 3.5. Expand shorthand syntax (e.g. ::break:: → page-break div)
34
62
  processed_content = expand_break_syntax(processed_content)
35
-
36
- # 4. Process Vue components (if available)
37
63
  processed_content = process_vue(processed_content, style_pack)
38
-
39
- # 4.5 Wrap h2/h3 headings with their following block to prevent orphaned headings
40
64
  processed_content = wrap_headings(processed_content)
41
65
 
42
- # 5. Convert to PDF
43
- $stderr.puts "[sakusei] converting to PDF..."
44
- output_path = generate_output_path
45
- convert_to_pdf(processed_content, output_path, style_pack)
46
-
47
- $stderr.puts "[sakusei] written: #{output_path}"
48
- output_path
66
+ [style_pack, processed_content]
49
67
  end
50
68
 
51
- private
52
-
53
69
  def discover_style_pack
54
70
  StylePack.discover(@options[:source_dir] || @source_dir, @options[:style])
55
71
  end
56
72
 
57
73
  def resolve_files
58
- FileResolver.new(@source_file).resolve
74
+ @file_resolver = FileResolver.new(@source_file)
75
+ @file_resolver.resolve
59
76
  end
60
77
 
61
78
  def process_erb(content)
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+
5
+ module Sakusei
6
+ class ConverterBase
7
+ def initialize(content, style_pack, options = {})
8
+ @content = content
9
+ @style_pack = style_pack
10
+ @options = options
11
+ @source_dir = options[:source_dir]
12
+ end
13
+
14
+ protected
15
+
16
+ def copy_images(temp_dir)
17
+ return unless @source_dir
18
+
19
+ image_paths.each do |rel_path|
20
+ src = File.expand_path(rel_path, @source_dir)
21
+ next unless File.exist?(src)
22
+
23
+ dest = File.join(temp_dir, rel_path)
24
+ FileUtils.mkdir_p(File.dirname(dest))
25
+ FileUtils.cp(src, dest)
26
+ end
27
+ end
28
+
29
+ def image_paths
30
+ paths = []
31
+ @content.scan(/!\[[^\]]*\]\(([^)]+)\)/) { |m| paths << m[0].strip }
32
+ @content.scan(/src="([^"]+)"/) { |m| paths << m[0].strip }
33
+ paths.reject { |p| p.match?(/\A(https?:|data:|\/\/)/) || p.start_with?('/') }
34
+ .uniq
35
+ end
36
+
37
+ def page_chrome_prefix
38
+ return '' unless @style_pack
39
+ %i[header footer].map do |part|
40
+ path = @style_pack.public_send(part)
41
+ path ? File.read(path) + "\n" : ''
42
+ end.join
43
+ end
44
+
45
+ def build_command(temp_md, temp_dir, extra_flags = [])
46
+ cmd = ['npx', 'md-to-pdf']
47
+
48
+ config = @options[:config] || @style_pack&.config
49
+ cmd << '--config-file' << config if config
50
+
51
+ stylesheets = [StylePack.base_stylesheet]
52
+ pack_stylesheet = @options[:stylesheet] || @style_pack&.stylesheet
53
+ stylesheets << pack_stylesheet if pack_stylesheet
54
+ stylesheets.each { |s| cmd << '--stylesheet' << s }
55
+
56
+ cmd << '--basedir' << temp_dir
57
+ extra_flags.each { |f| cmd << f }
58
+ cmd << temp_md
59
+ cmd
60
+ end
61
+ end
62
+ end
@@ -5,6 +5,8 @@ module Sakusei
5
5
  class FileResolver
6
6
  INCLUDE_PATTERN = /<!--\s*@include\s+(\S+)\s*-->/
7
7
 
8
+ attr_reader :resolved_files
9
+
8
10
  def initialize(source_file)
9
11
  @source_file = source_file
10
12
  @source_dir = File.dirname(source_file)
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+ require 'tmpdir'
5
+ require_relative 'converter_base'
6
+
7
+ module Sakusei
8
+ # Converts markdown content to a styled HTML string by invoking
9
+ # `npx md-to-pdf --as-html`. Used by the live preview server.
10
+ class HtmlConverter < ConverterBase
11
+ def convert
12
+ Dir.mktmpdir('sakusei-html') do |temp_dir|
13
+ temp_md = File.join(temp_dir, 'input.md')
14
+ # Header/footer files are injected as @page margin chrome by PreviewServer.
15
+ # Don't prepend them to the body the way MdToPdfConverter does for PDF output.
16
+ File.write(temp_md, @content)
17
+
18
+ copy_images(temp_dir)
19
+
20
+ cmd = build_command(temp_md, temp_dir, ['--as-html'])
21
+ stdout, stderr, status = Open3.capture3(*cmd)
22
+
23
+ unless status.success?
24
+ raise Error, "HTML conversion failed: #{stderr.strip}\n#{stdout.strip}"
25
+ end
26
+
27
+ html_path = File.join(temp_dir, 'input.html')
28
+ raise Error, "md-to-pdf did not produce HTML at #{html_path}" unless File.exist?(html_path)
29
+
30
+ File.read(html_path)
31
+ end
32
+ end
33
+ end
34
+ end
@@ -2,16 +2,14 @@
2
2
 
3
3
  require 'fileutils'
4
4
  require 'tmpdir'
5
+ require_relative 'converter_base'
5
6
 
6
7
  module Sakusei
7
8
  # Converts markdown content to PDF using md-to-pdf
8
- class MdToPdfConverter
9
+ class MdToPdfConverter < ConverterBase
9
10
  def initialize(content, output_path, style_pack, options = {})
10
- @content = content
11
+ super(content, style_pack, options)
11
12
  @output_path = output_path
12
- @style_pack = style_pack
13
- @options = options
14
- @source_dir = options[:source_dir]
15
13
  end
16
14
 
17
15
  def convert
@@ -24,7 +22,7 @@ module Sakusei
24
22
  # so md-to-pdf's HTTP server can serve them by their relative paths.
25
23
  copy_images(temp_dir)
26
24
 
27
- cmd = build_command(temp_md, temp_dir)
25
+ cmd = build_command(temp_md, temp_dir).join(' ')
28
26
  result = system(cmd)
29
27
  raise Error, 'PDF conversion failed' unless result
30
28
 
@@ -33,65 +31,5 @@ module Sakusei
33
31
 
34
32
  @output_path
35
33
  end
36
-
37
- private
38
-
39
- # Scans content for relative image paths (markdown and HTML img src),
40
- # and copies each referenced file into temp_dir at the same relative path.
41
- def copy_images(temp_dir)
42
- return unless @source_dir
43
-
44
- image_paths.each do |rel_path|
45
- src = File.expand_path(rel_path, @source_dir)
46
- next unless File.exist?(src)
47
-
48
- dest = File.join(temp_dir, rel_path)
49
- FileUtils.mkdir_p(File.dirname(dest))
50
- FileUtils.cp(src, dest)
51
- end
52
- end
53
-
54
- # Extracts relative image paths from both markdown syntax and HTML img tags.
55
- def image_paths
56
- paths = []
57
-
58
- # Markdown: ![alt](path)
59
- @content.scan(/!\[[^\]]*\]\(([^)]+)\)/) { |m| paths << m[0].strip }
60
-
61
- # HTML: src="path" (covers Vue-rendered img tags)
62
- @content.scan(/src="([^"]+)"/) { |m| paths << m[0].strip }
63
-
64
- # Filter to relative paths only (skip http, https, data URIs, absolute paths)
65
- paths.reject { |p| p.match?(/\A(https?:|data:|\/\/)/) || p.start_with?('/') }
66
- .uniq
67
- end
68
-
69
- def page_chrome_prefix
70
- return '' unless @style_pack
71
- %i[header footer].map do |part|
72
- path = @style_pack.public_send(part)
73
- path ? File.read(path) + "\n" : ''
74
- end.join
75
- end
76
-
77
- def build_command(temp_path, temp_dir)
78
- cmd = ['npx', 'md-to-pdf']
79
-
80
- config = @options[:config] || @style_pack.config
81
- cmd << '--config-file' << config if config
82
-
83
- stylesheets = [StylePack.base_stylesheet]
84
- pack_stylesheet = @options[:stylesheet] || @style_pack.stylesheet
85
- stylesheets << pack_stylesheet if pack_stylesheet
86
- stylesheets.each { |s| cmd << '--stylesheet' << s }
87
-
88
- # Set basedir to temp_dir so relativePath resolves to /input.md,
89
- # making image src="images/foo.jpg" resolve to http://localhost:PORT/images/foo.jpg
90
- # which is served from temp_dir where we've copied the assets.
91
- cmd << '--basedir' << temp_dir
92
-
93
- cmd << temp_path
94
- cmd.join(' ')
95
- end
96
34
  end
97
35
  end
@@ -0,0 +1,268 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'date'
4
+ require 'json'
5
+ require 'open3'
6
+
7
+ module Sakusei
8
+ # Builds the live-preview's page chrome from the style pack's PDF
9
+ # configuration, matching what Puppeteer renders for the PDF as closely as
10
+ # possible.
11
+ #
12
+ # Source of truth is `pdf_options.headerTemplate` / `footerTemplate` /
13
+ # `format` / `margin` inside `config.js`. We evaluate `config.js` with Node
14
+ # to expand JS template literals (e.g. `${wordmarkDataUri}`), then translate
15
+ # the resulting HTML strings into running elements + @page CSS.
16
+ #
17
+ # Strategy: Puppeteer's headerTemplate / footerTemplate are arbitrary HTML
18
+ # (with images, flex layouts, multi-column structures). CSS @page margin
19
+ # boxes only accept a content string, not arbitrary HTML — so we use
20
+ # CSS Paged Media's running-element mechanism instead:
21
+ #
22
+ # .sakusei-running-header { position: running(sk_header); }
23
+ # @page { @top-center { content: element(sk_header); } }
24
+ #
25
+ # Placeholder rewrites (matching Puppeteer's chrome semantics):
26
+ # - <span class="pageNumber"></span> → <span class="sk-pn"></span>, with
27
+ # .sk-pn::after { content: counter(page); }
28
+ # - <span class="totalPages"></span> → <span class="sk-tp"></span>, similar
29
+ # - <span class="title"></span> → "" (Puppeteer leaves it blank by default)
30
+ # - <span class="date"></span> → today's date (static at build time)
31
+ # - <span class="url"></span> → ""
32
+ # - <img src="rel/path"> → <img src="/__pack/rel/path"> so the browser can
33
+ # fetch images from the style-pack directory. Data URIs and absolute
34
+ # URLs pass through unchanged.
35
+ #
36
+ # Returns { css:, html: } — caller injects css into <head>, prepends html
37
+ # into <body>.
38
+ class PageChromeTranslator
39
+ HEADER_RUNNING_NAME = 'sk_header'
40
+ FOOTER_RUNNING_NAME = 'sk_footer'
41
+ HEADER_CLASS = 'sakusei-running-header'
42
+ FOOTER_CLASS = 'sakusei-running-footer'
43
+ PAGE_NUMBER_CLASS = 'sk-pn'
44
+ TOTAL_PAGES_CLASS = 'sk-tp'
45
+
46
+ NODE_EXTRACT_SCRIPT = <<~JS.freeze
47
+ try {
48
+ const c = require(process.argv[1]);
49
+ const o = (c && c.pdf_options) || {};
50
+ const enabled = o.displayHeaderFooter !== false;
51
+ process.stdout.write(JSON.stringify({
52
+ format: o.format || null,
53
+ margin: o.margin || null,
54
+ headerTemplate: enabled ? (o.headerTemplate || null) : null,
55
+ footerTemplate: enabled ? (o.footerTemplate || null) : null
56
+ }));
57
+ } catch (e) {
58
+ process.stderr.write(String(e && e.stack || e));
59
+ process.exit(2);
60
+ }
61
+ JS
62
+
63
+ def initialize(style_pack)
64
+ @style_pack = style_pack
65
+ end
66
+
67
+ def build
68
+ return empty_result unless @style_pack
69
+ opts = read_pdf_options
70
+
71
+ # Extract chrome strip backgrounds from the templates BEFORE we strip them.
72
+ # Puppeteer's PDF chrome uses a `position: fixed; top: -10cm; bottom: -10cm`
73
+ # div that breaks out to fill the full chrome strip. paged.js's running
74
+ # elements live inside the @top-center / @bottom-center margin boxes which
75
+ # are constrained to the page's content width, so the same trick covers the
76
+ # whole page (wrong) instead of just the strip. We replicate the strip via
77
+ # .pagedjs_page::before / ::after pseudo-elements with the extracted color.
78
+ header_bg = extract_chrome_bg(opts[:header_template])
79
+ footer_bg = extract_chrome_bg(opts[:footer_template])
80
+
81
+ header_html = transform_template(opts[:header_template])
82
+ footer_html = transform_template(opts[:footer_template])
83
+
84
+ html = +''
85
+ html << wrap_running(:header, header_html) if header_html && !header_html.empty?
86
+ html << wrap_running(:footer, footer_html) if footer_html && !footer_html.empty?
87
+
88
+ { css: build_css(opts, header_html, footer_html, header_bg, footer_bg), html: html }
89
+ end
90
+
91
+ private
92
+
93
+ def empty_result
94
+ { css: '', html: '' }
95
+ end
96
+
97
+ def wrap_running(kind, inner)
98
+ class_name = kind == :header ? HEADER_CLASS : FOOTER_CLASS
99
+ tag = kind == :header ? 'header' : 'footer'
100
+ %(<#{tag} class="#{class_name}">#{inner}</#{tag}>)
101
+ end
102
+
103
+ def build_css(opts, header_html, footer_html, header_bg, footer_bg)
104
+ pieces = []
105
+
106
+ page_decls = []
107
+ page_decls << "size: #{opts[:size]};" if opts[:size]
108
+ page_decls << "margin: #{opts[:margin]};" if opts[:margin]
109
+ page_decls << "@top-center { content: element(#{HEADER_RUNNING_NAME}); }" if header_html && !header_html.empty?
110
+ page_decls << "@bottom-center { content: element(#{FOOTER_RUNNING_NAME}); }" if footer_html && !footer_html.empty?
111
+ pieces << "@page { #{page_decls.join(' ')} }" unless page_decls.empty?
112
+
113
+ pieces << ".#{HEADER_CLASS} { position: running(#{HEADER_RUNNING_NAME}); }" if header_html && !header_html.empty?
114
+ pieces << ".#{FOOTER_CLASS} { position: running(#{FOOTER_RUNNING_NAME}); }" if footer_html && !footer_html.empty?
115
+
116
+ pieces << ".#{PAGE_NUMBER_CLASS}::after { content: counter(page); }"
117
+ pieces << ".#{TOTAL_PAGES_CLASS}::after { content: counter(pages); }"
118
+
119
+ sides = opts[:margin_sides] || {}
120
+
121
+ # Full-page-width chrome strips, sized by the page's top/bottom margins.
122
+ if header_bg && sides['top']
123
+ pieces << <<~CSS
124
+ .pagedjs_page { position: relative; }
125
+ .pagedjs_page::before {
126
+ content: "";
127
+ position: absolute;
128
+ top: 0; left: 0; right: 0;
129
+ height: #{sides['top']};
130
+ background: #{header_bg};
131
+ z-index: 0;
132
+ -webkit-print-color-adjust: exact;
133
+ print-color-adjust: exact;
134
+ }
135
+ CSS
136
+ end
137
+
138
+ if footer_bg && sides['bottom']
139
+ pieces << <<~CSS
140
+ .pagedjs_page::after {
141
+ content: "";
142
+ position: absolute;
143
+ bottom: 0; left: 0; right: 0;
144
+ height: #{sides['bottom']};
145
+ background: #{footer_bg};
146
+ z-index: 0;
147
+ -webkit-print-color-adjust: exact;
148
+ print-color-adjust: exact;
149
+ }
150
+ CSS
151
+ end
152
+
153
+ # Make the running content extend to the full page width by overflowing
154
+ # the @top-center / @bottom-center margin box on the left and right by
155
+ # the page's side margins. Restores the Puppeteer chrome's edge-to-edge
156
+ # padding semantics (e.g. `padding: 0.4cm 26px 0.6cm 26px` inside the
157
+ # template now positions content from page edges, not from inner content
158
+ # area). Also lifts the content above the chrome strip pseudo-elements.
159
+ if sides['left'] || sides['right']
160
+ left = sides['left'] || '0'
161
+ right = sides['right'] || '0'
162
+ pieces << <<~CSS
163
+ .pagedjs_margin-top-center > .pagedjs_margin-content > *,
164
+ .pagedjs_margin-bottom-center > .pagedjs_margin-content > * {
165
+ margin-left: -#{left};
166
+ margin-right: -#{right};
167
+ position: relative;
168
+ z-index: 1;
169
+ }
170
+ CSS
171
+ end
172
+
173
+ pieces.join("\n")
174
+ end
175
+
176
+ # Spawns Node to require() config.js and emit pdf_options as JSON.
177
+ # Returns { size:, margin:, header_template:, footer_template: }, all
178
+ # nil-safe. On any failure, returns {} and logs to stderr.
179
+ def read_pdf_options
180
+ return {} unless @style_pack.config && File.exist?(@style_pack.config)
181
+
182
+ stdout, stderr, status = Open3.capture3(
183
+ 'node', '-e', NODE_EXTRACT_SCRIPT, @style_pack.config
184
+ )
185
+ unless status.success?
186
+ $stderr.puts "[sakusei-preview] could not read pdf_options from config.js: #{stderr.strip}"
187
+ return {}
188
+ end
189
+
190
+ raw = JSON.parse(stdout)
191
+ sides = raw['margin'].is_a?(Hash) ? raw['margin'] : nil
192
+ {
193
+ size: raw['format'],
194
+ margin: format_margin(sides),
195
+ margin_sides: sides,
196
+ header_template: raw['headerTemplate'],
197
+ footer_template: raw['footerTemplate']
198
+ }
199
+ rescue StandardError => e
200
+ $stderr.puts "[sakusei-preview] failed to extract chrome from config.js: #{e.message}"
201
+ {}
202
+ end
203
+
204
+ def format_margin(margin)
205
+ return nil unless margin.is_a?(Hash)
206
+ sides = %w[top right bottom left].map { |s| margin[s] || '0' }
207
+ sides.join(' ')
208
+ end
209
+
210
+ # Heuristic: Puppeteer chrome templates often use a position-fixed
211
+ # background div to color the chrome strip. Find the first such background
212
+ # color so we can replicate the strip via .pagedjs_page::before / ::after.
213
+ # Looks for either `.X-bg { ... background: <color>; ... }` in a <style>
214
+ # block, or `<div ... style="...background: <color>...">` with a *-bg class.
215
+ def extract_chrome_bg(template)
216
+ return nil unless template
217
+ if template =~ /<style\b[^>]*>[\s\S]*?\.\w+-bg\s*\{[^}]*?background:\s*([^;}]+?)\s*[;}]/m
218
+ return Regexp.last_match(1).strip
219
+ end
220
+ if template =~ /<div\b[^>]*class=["'][^"']*\b\w+-bg\b[^"']*["'][^>]*style=["'][^"']*background:\s*([^;"]+)/m
221
+ return Regexp.last_match(1).strip
222
+ end
223
+ nil
224
+ end
225
+
226
+ # Transform a Puppeteer chrome template (HTML string) into preview HTML.
227
+ def transform_template(raw)
228
+ return nil if raw.nil?
229
+ s = raw.to_s.dup
230
+ return nil if s.strip.empty?
231
+
232
+ strip_chrome_bg_markup!(s)
233
+ s.gsub!(/<!--.*?-->/m, '')
234
+ s.gsub!(/<span\s+class=["']pageNumber["']\s*>\s*<\/span>/i, %(<span class="#{PAGE_NUMBER_CLASS}"></span>))
235
+ s.gsub!(/<span\s+class=["']totalPages["']\s*>\s*<\/span>/i, %(<span class="#{TOTAL_PAGES_CLASS}"></span>))
236
+ s.gsub!(/<span\s+class=["']title["']\s*>\s*<\/span>/i, '')
237
+ s.gsub!(/<span\s+class=["']date["']\s*>\s*<\/span>/i, Date.today.strftime('%Y-%m-%d'))
238
+ s.gsub!(/<span\s+class=["']url["']\s*>\s*<\/span>/i, '')
239
+ rewrite_image_paths!(s)
240
+ s.strip!
241
+ s
242
+ end
243
+
244
+ # Remove Puppeteer-specific markup that we replicate via paged.js CSS
245
+ # (the <style> block defining .X-bg and the empty <div class="X-bg">).
246
+ # Leaving them in would cover the whole page in paged.js because
247
+ # `position: fixed` resolves against .pagedjs_page, not the chrome strip.
248
+ def strip_chrome_bg_markup!(html)
249
+ html.sub!(/<style\b[^>]*>[\s\S]*?\.\w+-bg\s*\{[\s\S]*?\}[\s\S]*?<\/style>/m, '')
250
+ html.gsub!(/<div\b[^>]*class=["'][^"']*\b\w+-bg\b[^"']*["'][^>]*>\s*<\/div>/m, '')
251
+ end
252
+
253
+ # Rewrite <img src="rel/path"> to <img src="/__pack/rel/path"> for any
254
+ # path that looks relative (no scheme, no leading slash). Data URIs and
255
+ # absolute URLs pass through.
256
+ def rewrite_image_paths!(html)
257
+ html.gsub!(/(<img\b[^>]*\bsrc=["'])([^"']+)(["'])/i) do
258
+ prefix = Regexp.last_match(1)
259
+ src = Regexp.last_match(2)
260
+ suffix = Regexp.last_match(3)
261
+ new_src = if src.match?(%r{\A(https?:|data:|/)}) then src
262
+ else "/__pack/#{src}"
263
+ end
264
+ "#{prefix}#{new_src}#{suffix}"
265
+ end
266
+ end
267
+ end
268
+ end
@@ -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.8'
5
5
  end
data/lib/sakusei.rb CHANGED
@@ -15,6 +15,8 @@ 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
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.8
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