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 +4 -4
- data/.claude/settings.json +8 -0
- data/CLAUDE.md +86 -0
- data/Gemfile.lock +82 -0
- data/bin/sakusei-preview +40 -0
- data/lib/sakusei/builder.rb +35 -18
- data/lib/sakusei/converter_base.rb +62 -0
- data/lib/sakusei/file_resolver.rb +2 -0
- data/lib/sakusei/html_converter.rb +34 -0
- data/lib/sakusei/md_to_pdf_converter.rb +4 -66
- data/lib/sakusei/page_chrome_translator.rb +268 -0
- data/lib/sakusei/preview_server.rb +444 -0
- data/lib/sakusei/version.rb +1 -1
- data/lib/sakusei.rb +2 -0
- data/sakusei.gemspec +3 -1
- metadata +39 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1845947d6ebe2f9b6c5732093eff5bbd90aa7bc2a60ad79a568033bd8c674e57
|
|
4
|
+
data.tar.gz: 595a6804e797c8b0c58804b7df122b5a738e5592b786f4a0c29454801c864ed9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0c2d9f30c83d0f1de7c73f8c23dcfe590208c057e95cfae0a261a64a36f7a386f3e0e34d9ba1849b46407ce4e19bd86e6dc3544db861fa8ab2f92cf0ac868d6d
|
|
7
|
+
data.tar.gz: 29428e2b7e9dbbe86753c58a537b6596f55a7ff7c075efe9bf40c1b0a2ada7d6f9921273400cc8226cf5acb12613a8caffed8ce9efc55576fe9a7d9b7790ca71
|
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
|
data/bin/sakusei-preview
ADDED
|
@@ -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
|
data/lib/sakusei/builder.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
|
@@ -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
|
-
|
|
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: 
|
|
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
|
data/lib/sakusei/version.rb
CHANGED
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 = [
|
|
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
|
+
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
|
|
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
|