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 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
@@ -0,0 +1,4 @@
1
+ *.gem
2
+ .DS_Store
3
+ *.pdf
4
+ node_modules/
data/Gemfile ADDED
@@ -0,0 +1,6 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in sakusei.gemspec
4
+ gemspec
5
+
6
+ gem 'rake', '~> 13.0'
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,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'sakusei'
5
+
6
+ Sakusei::CLI.start(ARGV)
@@ -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
@@ -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