sakusei 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.gitignore +4 -0
- data/Gemfile +6 -0
- data/README.md +191 -0
- data/Rakefile +13 -0
- data/bin/sakusei +6 -0
- data/lib/sakusei/builder.rb +66 -0
- data/lib/sakusei/cli.rb +167 -0
- data/lib/sakusei/erb_processor.rb +56 -0
- data/lib/sakusei/file_resolver.rb +56 -0
- data/lib/sakusei/md_to_pdf_converter.rb +69 -0
- data/lib/sakusei/multi_file_builder.rb +77 -0
- data/lib/sakusei/pdf_concat.rb +69 -0
- data/lib/sakusei/style_pack.rb +146 -0
- data/lib/sakusei/style_preview.rb +148 -0
- data/lib/sakusei/version.rb +5 -0
- data/lib/sakusei/vue_processor.rb +141 -0
- data/lib/sakusei/vue_renderer.js +226 -0
- data/lib/sakusei.rb +26 -0
- data/lib/templates/base.css +243 -0
- data/lib/templates/default_style_pack/config.js +36 -0
- data/lib/templates/default_style_pack/footer.html +5 -0
- data/lib/templates/default_style_pack/header.html +5 -0
- data/lib/templates/default_style_pack/style.css +22 -0
- data/sakusei.gemspec +29 -0
- metadata +125 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 8d842e81bb78fb5e34d0750ee5f125b13b6a7b5dec236ce70547fcb44e6438ca
|
|
4
|
+
data.tar.gz: 6872dbd65f839bd5e3fb786bcbf8712310c03aa99332f6ff740901fc26275800
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 96c3e6db1059839c6bd8632c11789dfd6ec56af507b8f6ef754600dcee498be62fbad33b605cde5853fdb4734150a88ce53ba1bef863df09bf89e23c93c6e1c3
|
|
7
|
+
data.tar.gz: a784a67e5c4cfbda815f3f6b96c0cc2de177125ce42bf7cc941a27a901487bb5f67a04240530e0f94ee1cf31500b402d650dc3298765ddae8e19b6a88a46152e
|
data/.gitignore
ADDED
data/Gemfile
ADDED
data/README.md
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
# Sakusei
|
|
2
|
+
|
|
3
|
+
**Sakusei** (作成) — from the Japanese words meaning "creation," "making," or "craft."
|
|
4
|
+
|
|
5
|
+
Like a master artisan refining their craft, Sakusei transforms raw Markdown into beautifully crafted PDF documents. Every document is an act of creation — structured, styled, and brought to life with precision.
|
|
6
|
+
|
|
7
|
+
The name embodies the philosophy behind this tool: documents aren't just generated, they're _crafted_.
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
## Overview
|
|
11
|
+
|
|
12
|
+
Sakusei is a build system for creating PDF documents from Markdown source files. It supports:
|
|
13
|
+
|
|
14
|
+
- **Markdown to PDF conversion** via `md-to-pdf`
|
|
15
|
+
- **ERB template evaluation** for dynamic content
|
|
16
|
+
- **Hierarchical style packs** for consistent document styling
|
|
17
|
+
- **File inclusion** for multi-file documents
|
|
18
|
+
- **PDF concatenation** for combining multiple documents
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
## Installation
|
|
22
|
+
|
|
23
|
+
### macOS (Homebrew)
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
# Add the tap and install
|
|
27
|
+
brew tap keithrowell/sakusei https://github.com/keithrowell/sakusei/homebrew-tap
|
|
28
|
+
brew install sakusei
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
### Ruby Gem
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
gem install sakusei
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### Build from Source
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
git clone https://github.com/keithrowell/sakusei
|
|
41
|
+
cd sakusei
|
|
42
|
+
bundle install
|
|
43
|
+
rake install
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
**Prerequisites:**
|
|
47
|
+
- Ruby 3.0+
|
|
48
|
+
- Node.js (for md-to-pdf)
|
|
49
|
+
- pdfunite or pdftk (for PDF concatenation)
|
|
50
|
+
|
|
51
|
+
## Quick Start
|
|
52
|
+
|
|
53
|
+
### Build a PDF from Markdown
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
sakusei build document.md
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Or simply (build is the default command):
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
sakusei document.md
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Extension is optional - `.md`, `.text`, or `.markdown` will be tried:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
sakusei document # Looks for document.md, document.text, or document.markdown
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Auto-open after building:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
sakusei document.md --open
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### Initialize a Style Pack
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
sakusei init my_company
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### Concatenate PDFs
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
sakusei concat part1.pdf part2.pdf -o combined.pdf
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Style Packs
|
|
90
|
+
|
|
91
|
+
Style packs are stored in `.sakusei/style_packs/` directories. Sakusei searches for style packs by walking up the directory tree from your source file.
|
|
92
|
+
|
|
93
|
+
```
|
|
94
|
+
.sakusei/
|
|
95
|
+
└── style_packs/
|
|
96
|
+
└── my_company/
|
|
97
|
+
├── config.js # md-to-pdf configuration
|
|
98
|
+
├── style.css # Stylesheet
|
|
99
|
+
├── header.html # Header template
|
|
100
|
+
└── footer.html # Footer template
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## File Inclusion
|
|
104
|
+
|
|
105
|
+
Include other markdown files in your document:
|
|
106
|
+
|
|
107
|
+
```markdown
|
|
108
|
+
# My Document
|
|
109
|
+
|
|
110
|
+
<!-- @include ./introduction.md -->
|
|
111
|
+
|
|
112
|
+
<!-- @include ./chapter1.md -->
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## ERB Templates
|
|
116
|
+
|
|
117
|
+
Use ERB for dynamic content:
|
|
118
|
+
|
|
119
|
+
```markdown
|
|
120
|
+
# Report Generated <%= today %>
|
|
121
|
+
|
|
122
|
+
Environment: <%= env('RAILS_ENV', 'development') %>
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## Page Breaks
|
|
126
|
+
|
|
127
|
+
### Manual Page Breaks
|
|
128
|
+
|
|
129
|
+
Insert page breaks in your markdown using HTML:
|
|
130
|
+
|
|
131
|
+
```markdown
|
|
132
|
+
# Chapter 1
|
|
133
|
+
|
|
134
|
+
Content here...
|
|
135
|
+
|
|
136
|
+
<div class="page-break"></div>
|
|
137
|
+
|
|
138
|
+
# Chapter 2
|
|
139
|
+
|
|
140
|
+
More content...
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Available classes:
|
|
144
|
+
- `.page-break` or `.page-break-after` - Break after this element
|
|
145
|
+
- `.page-break-before` - Break before this element
|
|
146
|
+
|
|
147
|
+
### Automatic Keep-Together
|
|
148
|
+
|
|
149
|
+
The base stylesheet automatically prevents page breaks inside these elements:
|
|
150
|
+
- Tables (including rows)
|
|
151
|
+
- Code blocks (`<pre>`)
|
|
152
|
+
- Blockquotes
|
|
153
|
+
- Images
|
|
154
|
+
- Figures and captions
|
|
155
|
+
- Definition lists (`<dl>`, `<dt>`, `<dd>`)
|
|
156
|
+
- Details/summary sections
|
|
157
|
+
- Math blocks (KaTeX)
|
|
158
|
+
- Custom elements: `.admonition`, `.callout`, `.card`, `.box`
|
|
159
|
+
|
|
160
|
+
To force keep-together on any element, add the `.keep-together` class:
|
|
161
|
+
|
|
162
|
+
```markdown
|
|
163
|
+
<div class="keep-together">
|
|
164
|
+
|
|
165
|
+
This content will not be split across pages.
|
|
166
|
+
|
|
167
|
+
| Table | Data |
|
|
168
|
+
|-------|------|
|
|
169
|
+
| A | 1 |
|
|
170
|
+
| B | 2 |
|
|
171
|
+
|
|
172
|
+
</div>
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
## Build Scripts
|
|
176
|
+
|
|
177
|
+
Create a `.sakusei_build` file for complex builds:
|
|
178
|
+
|
|
179
|
+
```yaml
|
|
180
|
+
steps:
|
|
181
|
+
- command: build
|
|
182
|
+
files:
|
|
183
|
+
- cover.md
|
|
184
|
+
- content/*.md
|
|
185
|
+
output: document.pdf
|
|
186
|
+
style: my_company
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
## License
|
|
190
|
+
|
|
191
|
+
MIT
|
data/Rakefile
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'bundler/gem_tasks'
|
|
4
|
+
require 'rake/testtask'
|
|
5
|
+
|
|
6
|
+
Rake::TestTask.new(:test) do |t|
|
|
7
|
+
t.libs << 'test'
|
|
8
|
+
t.libs << 'lib'
|
|
9
|
+
t.test_files = FileList['test/**/test_*.rb', 'test/**/*_test.rb']
|
|
10
|
+
t.verbose = true
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
task default: :test
|
data/bin/sakusei
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'style_pack'
|
|
4
|
+
require_relative 'file_resolver'
|
|
5
|
+
require_relative 'erb_processor'
|
|
6
|
+
require_relative 'vue_processor'
|
|
7
|
+
require_relative 'md_to_pdf_converter'
|
|
8
|
+
|
|
9
|
+
module Sakusei
|
|
10
|
+
class Builder
|
|
11
|
+
def initialize(source_file, options = {})
|
|
12
|
+
@source_file = File.expand_path(source_file)
|
|
13
|
+
@options = options
|
|
14
|
+
@source_dir = File.dirname(@source_file)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def build
|
|
18
|
+
# 1. Discover and load style pack
|
|
19
|
+
style_pack = discover_style_pack
|
|
20
|
+
|
|
21
|
+
# 2. Resolve and concatenate file references
|
|
22
|
+
resolved_content = resolve_files
|
|
23
|
+
|
|
24
|
+
# 3. Process ERB templates
|
|
25
|
+
processed_content = process_erb(resolved_content)
|
|
26
|
+
|
|
27
|
+
# 4. Process Vue components (if available)
|
|
28
|
+
processed_content = process_vue(processed_content)
|
|
29
|
+
|
|
30
|
+
# 5. Convert to PDF
|
|
31
|
+
output_path = generate_output_path
|
|
32
|
+
convert_to_pdf(processed_content, output_path, style_pack)
|
|
33
|
+
|
|
34
|
+
output_path
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def discover_style_pack
|
|
40
|
+
StylePack.discover(@source_dir, @options[:style])
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def resolve_files
|
|
44
|
+
FileResolver.new(@source_file).resolve
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def process_erb(content)
|
|
48
|
+
ErbProcessor.new(content, @source_dir).process
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def process_vue(content)
|
|
52
|
+
VueProcessor.new(content, @source_dir).process
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def convert_to_pdf(content, output_path, style_pack)
|
|
56
|
+
MdToPdfConverter.new(content, output_path, style_pack, @options).convert
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def generate_output_path
|
|
60
|
+
return File.expand_path(@options[:output]) if @options[:output]
|
|
61
|
+
|
|
62
|
+
base_name = File.basename(@source_file, '.*')
|
|
63
|
+
File.join(@source_dir, "#{base_name}.pdf")
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
data/lib/sakusei/cli.rb
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'thor'
|
|
4
|
+
|
|
5
|
+
module Sakusei
|
|
6
|
+
class CLI < Thor
|
|
7
|
+
desc 'preview [STYLE]', 'Generate a preview PDF showing all style elements'
|
|
8
|
+
option :output, aliases: '-o', default: 'style-preview.pdf', desc: 'Output PDF file path'
|
|
9
|
+
option :config, aliases: '-c', desc: 'Path to md-to-pdf config file'
|
|
10
|
+
option :stylesheet, aliases: '-css', desc: 'Path to CSS stylesheet'
|
|
11
|
+
def preview(style = nil)
|
|
12
|
+
preview = StylePreview.new(style, options)
|
|
13
|
+
output_path = preview.generate
|
|
14
|
+
say "Style preview generated: #{output_path}", :green
|
|
15
|
+
rescue Error => e
|
|
16
|
+
say_error e.message
|
|
17
|
+
exit 1
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
desc 'build FILES', 'Build PDF from markdown FILE(s). Accepts multiple files, globs, or directories.'
|
|
21
|
+
option :output, aliases: '-o', desc: 'Output PDF file path'
|
|
22
|
+
option :style, aliases: '-s', desc: 'Style pack name to use'
|
|
23
|
+
option :config, aliases: '-c', desc: 'Path to md-to-pdf config file'
|
|
24
|
+
option :stylesheet, aliases: '-css', desc: 'Path to CSS stylesheet'
|
|
25
|
+
option :page_breaks, aliases: '-p', type: :boolean, default: false, desc: 'Add page breaks between files'
|
|
26
|
+
option :open, type: :boolean, default: false, desc: 'Open the PDF after building'
|
|
27
|
+
def build(*files)
|
|
28
|
+
raise Error, 'No input files provided' if files.empty?
|
|
29
|
+
|
|
30
|
+
# Resolve file extensions (.md, .text, .markdown) if not provided
|
|
31
|
+
resolved_files = files.map { |f| resolve_file_extension(f) }
|
|
32
|
+
|
|
33
|
+
# Check if we have multiple files, globs, or directories
|
|
34
|
+
if resolved_files.length > 1 || resolved_files.any? { |f| f.include?('*') || File.directory?(f) }
|
|
35
|
+
# Multi-file build
|
|
36
|
+
builder = MultiFileBuilder.new(resolved_files, options)
|
|
37
|
+
else
|
|
38
|
+
# Single file build
|
|
39
|
+
raise Error, "File not found: #{resolved_files.first}" unless File.exist?(resolved_files.first)
|
|
40
|
+
builder = Builder.new(resolved_files.first, options)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
output_path = builder.build
|
|
44
|
+
say "PDF created: #{output_path}", :green
|
|
45
|
+
|
|
46
|
+
# Open the PDF if requested
|
|
47
|
+
open_pdf(output_path) if options[:open]
|
|
48
|
+
rescue Error => e
|
|
49
|
+
say_error e.message
|
|
50
|
+
exit 1
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
desc 'init [NAME]', 'Initialize a new style pack'
|
|
54
|
+
option :directory, aliases: '-d', default: '.', desc: 'Directory to create style pack in'
|
|
55
|
+
def init(name = 'default')
|
|
56
|
+
StylePackInitializer.new(options[:directory], name).run
|
|
57
|
+
say "Style pack '#{name}' created in #{options[:directory]}/.sakusei/style_packs/#{name}", :green
|
|
58
|
+
rescue Error => e
|
|
59
|
+
say_error e.message
|
|
60
|
+
exit 1
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
desc 'concat FILES', 'Concatenate multiple PDF files'
|
|
64
|
+
option :output, aliases: '-o', required: true, desc: 'Output PDF file path'
|
|
65
|
+
def concat(*files)
|
|
66
|
+
raise Error, 'No input files provided' if files.empty?
|
|
67
|
+
|
|
68
|
+
PdfConcat.new(files, options[:output]).concat
|
|
69
|
+
say "PDFs concatenated: #{options[:output]}", :green
|
|
70
|
+
rescue Error => e
|
|
71
|
+
say_error e.message
|
|
72
|
+
exit 1
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
desc 'styles', 'List available style packs'
|
|
76
|
+
option :directory, aliases: '-d', default: '.', desc: 'Directory to search for style packs'
|
|
77
|
+
def styles
|
|
78
|
+
style_packs = StylePack.list_available(options[:directory])
|
|
79
|
+
|
|
80
|
+
if style_packs.empty?
|
|
81
|
+
say 'No style packs found.', :yellow
|
|
82
|
+
say "Run 'sakusei init <name>' to create a new style pack."
|
|
83
|
+
else
|
|
84
|
+
say 'Available style packs:', :green
|
|
85
|
+
style_packs.each do |pack|
|
|
86
|
+
say " • #{pack[:name]}"
|
|
87
|
+
say " Path: #{pack[:path]}", :cyan
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
rescue Error => e
|
|
91
|
+
say_error e.message
|
|
92
|
+
exit 1
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
desc 'version', 'Show version'
|
|
96
|
+
def version
|
|
97
|
+
say "Sakusei #{Sakusei::VERSION}"
|
|
98
|
+
end
|
|
99
|
+
map %w[--version -v] => :version
|
|
100
|
+
|
|
101
|
+
default_task :help
|
|
102
|
+
|
|
103
|
+
# Override dispatch to treat file paths as build commands
|
|
104
|
+
def self.dispatch(meth, given_args, given_opts, config)
|
|
105
|
+
# If first arg is an existing file or glob pattern, treat it as a build command
|
|
106
|
+
if given_args.any? && file_arg?(given_args.first)
|
|
107
|
+
given_args.unshift('build')
|
|
108
|
+
end
|
|
109
|
+
super
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def self.file_arg?(arg)
|
|
113
|
+
return false if arg.nil?
|
|
114
|
+
return false if arg.start_with?('-') # Skip options
|
|
115
|
+
|
|
116
|
+
# Check if it's a file (with or without extension), glob pattern, or directory
|
|
117
|
+
File.exist?(arg) || file_with_extension?(arg) || arg.include?('*') || File.directory?(arg)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Check if file exists with any of the supported markdown extensions
|
|
121
|
+
def self.file_with_extension?(arg)
|
|
122
|
+
return false if arg.nil? || arg.empty?
|
|
123
|
+
return false if File.extname(arg).length > 0 # Already has an extension
|
|
124
|
+
|
|
125
|
+
%w[.md .text .markdown].any? { |ext| File.exist?(arg + ext) }
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
private
|
|
129
|
+
|
|
130
|
+
# Resolve file by trying markdown extensions if no extension provided
|
|
131
|
+
def resolve_file_extension(file)
|
|
132
|
+
return file if File.exist?(file)
|
|
133
|
+
return file if File.directory?(file)
|
|
134
|
+
return file if file.include?('*') # Glob pattern
|
|
135
|
+
return file if File.extname(file).length > 0 # Already has extension
|
|
136
|
+
|
|
137
|
+
# Try markdown extensions
|
|
138
|
+
%w[.md .text .markdown].each do |ext|
|
|
139
|
+
path_with_ext = file + ext
|
|
140
|
+
return path_with_ext if File.exist?(path_with_ext)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Return original if no extension found
|
|
144
|
+
file
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def open_pdf(path)
|
|
148
|
+
return unless File.exist?(path)
|
|
149
|
+
|
|
150
|
+
cmd = case RbConfig::CONFIG['host_os']
|
|
151
|
+
when /darwin/i # macOS
|
|
152
|
+
['open', path]
|
|
153
|
+
when /linux/i
|
|
154
|
+
['xdg-open', path]
|
|
155
|
+
when /mswin|mingw|cygwin/i # Windows
|
|
156
|
+
['start', path]
|
|
157
|
+
else
|
|
158
|
+
say "PDF created at: #{path}", :yellow
|
|
159
|
+
return
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
system(*cmd)
|
|
163
|
+
rescue => e
|
|
164
|
+
say_error "Failed to open PDF: #{e.message}"
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'erb'
|
|
4
|
+
|
|
5
|
+
module Sakusei
|
|
6
|
+
# Processes ERB templates in markdown content
|
|
7
|
+
class ErbProcessor
|
|
8
|
+
def initialize(content, base_dir)
|
|
9
|
+
@content = content
|
|
10
|
+
@base_dir = base_dir
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def process
|
|
14
|
+
# Create a context object with helper methods
|
|
15
|
+
context = ErbContext.new(@base_dir)
|
|
16
|
+
|
|
17
|
+
# Process the ERB
|
|
18
|
+
erb = ERB.new(@content, trim_mode: '-')
|
|
19
|
+
erb.result(context.binding)
|
|
20
|
+
rescue StandardError => e
|
|
21
|
+
raise Error, "ERB processing error: #{e.message}"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Context object that provides helper methods for ERB templates
|
|
25
|
+
class ErbContext
|
|
26
|
+
def initialize(base_dir)
|
|
27
|
+
@base_dir = base_dir
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def binding
|
|
31
|
+
::Kernel.binding
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Helper method to include file content directly
|
|
35
|
+
def include_file(path)
|
|
36
|
+
full_path = File.expand_path(path, @base_dir)
|
|
37
|
+
File.exist?(full_path) ? File.read(full_path) : "<!-- File not found: #{path} -->"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Helper for current date
|
|
41
|
+
def today(format = '%Y-%m-%d')
|
|
42
|
+
Date.today.strftime(format)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Helper for reading environment variables
|
|
46
|
+
def env(name, default = nil)
|
|
47
|
+
ENV.fetch(name, default)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Helper for executing shell commands
|
|
51
|
+
def sh(command)
|
|
52
|
+
`#{command}`.chomp
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sakusei
|
|
4
|
+
# Resolves file references in markdown and concatenates them
|
|
5
|
+
class FileResolver
|
|
6
|
+
INCLUDE_PATTERN = /<!--\s*@include\s+(\S+)\s*-->/
|
|
7
|
+
|
|
8
|
+
def initialize(source_file)
|
|
9
|
+
@source_file = source_file
|
|
10
|
+
@source_dir = File.dirname(source_file)
|
|
11
|
+
@resolved_files = Set.new
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def resolve
|
|
15
|
+
content = File.read(@source_file)
|
|
16
|
+
resolve_includes(content, @source_file)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def resolve_includes(content, parent_file)
|
|
22
|
+
content.gsub(INCLUDE_PATTERN) do |match|
|
|
23
|
+
file_ref = Regexp.last_match(1)
|
|
24
|
+
resolved_path = resolve_path(file_ref, parent_file)
|
|
25
|
+
|
|
26
|
+
next match unless resolved_path
|
|
27
|
+
next '' if @resolved_files.include?(resolved_path)
|
|
28
|
+
|
|
29
|
+
@resolved_files.add(resolved_path)
|
|
30
|
+
|
|
31
|
+
file_content = File.read(resolved_path)
|
|
32
|
+
# Recursively resolve includes in the included file
|
|
33
|
+
resolve_includes(file_content, resolved_path)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def resolve_path(file_ref, parent_file)
|
|
38
|
+
# Handle absolute paths
|
|
39
|
+
if file_ref.start_with?('/')
|
|
40
|
+
return File.exist?(file_ref) ? file_ref : nil
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Handle relative paths from the parent file's directory
|
|
44
|
+
parent_dir = File.dirname(parent_file)
|
|
45
|
+
full_path = File.expand_path(file_ref, parent_dir)
|
|
46
|
+
|
|
47
|
+
return full_path if File.exist?(full_path)
|
|
48
|
+
|
|
49
|
+
# Try with .md extension
|
|
50
|
+
full_path_with_ext = "#{full_path}.md"
|
|
51
|
+
return full_path_with_ext if File.exist?(full_path_with_ext)
|
|
52
|
+
|
|
53
|
+
nil
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'tempfile'
|
|
4
|
+
|
|
5
|
+
module Sakusei
|
|
6
|
+
# Converts markdown content to PDF using md-to-pdf
|
|
7
|
+
class MdToPdfConverter
|
|
8
|
+
def initialize(content, output_path, style_pack, options = {})
|
|
9
|
+
@content = content
|
|
10
|
+
@output_path = output_path
|
|
11
|
+
@style_pack = style_pack
|
|
12
|
+
@options = options
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def convert
|
|
16
|
+
# Create temp directory for working files
|
|
17
|
+
Dir.mktmpdir('sakusei') do |temp_dir|
|
|
18
|
+
# Write content to temp markdown file
|
|
19
|
+
temp_md = File.join(temp_dir, 'input.md')
|
|
20
|
+
File.write(temp_md, @content)
|
|
21
|
+
|
|
22
|
+
# Build md-to-pdf command
|
|
23
|
+
cmd = build_command(temp_md, temp_dir)
|
|
24
|
+
|
|
25
|
+
# Execute command
|
|
26
|
+
result = system(cmd)
|
|
27
|
+
raise Error, 'PDF conversion failed' unless result
|
|
28
|
+
|
|
29
|
+
# md-to-pdf outputs to input.pdf in the same directory
|
|
30
|
+
temp_pdf = File.join(temp_dir, 'input.pdf')
|
|
31
|
+
|
|
32
|
+
# Move to final destination
|
|
33
|
+
FileUtils.mv(temp_pdf, @output_path)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
@output_path
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def build_command(temp_path, temp_dir)
|
|
42
|
+
cmd = ['npx', 'md-to-pdf']
|
|
43
|
+
|
|
44
|
+
# Config file
|
|
45
|
+
config = @options[:config] || @style_pack.config
|
|
46
|
+
cmd << '--config-file' << config if config
|
|
47
|
+
|
|
48
|
+
# Stylesheets - base CSS first, then style pack CSS
|
|
49
|
+
# This allows style packs to override base styles
|
|
50
|
+
stylesheets = [StylePack.base_stylesheet]
|
|
51
|
+
|
|
52
|
+
# Add style pack stylesheet if available
|
|
53
|
+
pack_stylesheet = @options[:stylesheet] || @style_pack.stylesheet
|
|
54
|
+
stylesheets << pack_stylesheet if pack_stylesheet
|
|
55
|
+
|
|
56
|
+
stylesheets.each do |stylesheet|
|
|
57
|
+
cmd << '--stylesheet' << stylesheet
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Basedir for resolving relative paths
|
|
61
|
+
cmd << '--basedir' << temp_dir
|
|
62
|
+
|
|
63
|
+
# Input file
|
|
64
|
+
cmd << temp_path
|
|
65
|
+
|
|
66
|
+
cmd.join(' ')
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|