sakusei 0.1.0 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8d842e81bb78fb5e34d0750ee5f125b13b6a7b5dec236ce70547fcb44e6438ca
4
- data.tar.gz: 6872dbd65f839bd5e3fb786bcbf8712310c03aa99332f6ff740901fc26275800
3
+ metadata.gz: 3c5167b69a1150bb93726449a31f46408381ae14f26c0034e472a3ce6b22ee3f
4
+ data.tar.gz: b2187d77e9222b69d927da57ed085cb876e7e0aa7e2c2f556bf4bbfc9e8bbc1b
5
5
  SHA512:
6
- metadata.gz: 96c3e6db1059839c6bd8632c11789dfd6ec56af507b8f6ef754600dcee498be62fbad33b605cde5853fdb4734150a88ce53ba1bef863df09bf89e23c93c6e1c3
7
- data.tar.gz: a784a67e5c4cfbda815f3f6b96c0cc2de177125ce42bf7cc941a27a901487bb5f67a04240530e0f94ee1cf31500b402d650dc3298765ddae8e19b6a88a46152e
6
+ metadata.gz: b15a0b12a135ce5eec0d4ab4afa9db615abac32405656456b15ac66d612a13fb8bcfaea564824be18d74a201a947f8ba4d21e5f40a591cf6c90c59589d8443f2
7
+ data.tar.gz: c9c3dcfa53b2fd665a614414cae22502d830e618c3018b32b00fe314ea5b200d91569483e0c31e5c095862e0999c03ae67d4a8e148b56e2a151007bec8ec6c50
data/.gitignore CHANGED
@@ -2,3 +2,4 @@
2
2
  .DS_Store
3
3
  *.pdf
4
4
  node_modules/
5
+ .worktrees/
@@ -3,7 +3,9 @@
3
3
  require_relative 'style_pack'
4
4
  require_relative 'file_resolver'
5
5
  require_relative 'erb_processor'
6
+ require_relative 'image_path_resolver'
6
7
  require_relative 'vue_processor'
8
+ require_relative 'heading_wrapper'
7
9
  require_relative 'md_to_pdf_converter'
8
10
 
9
11
  module Sakusei
@@ -16,28 +18,37 @@ module Sakusei
16
18
 
17
19
  def build
18
20
  # 1. Discover and load style pack
21
+ $stderr.puts "[sakusei] discovering style pack..."
19
22
  style_pack = discover_style_pack
23
+ $stderr.puts "[sakusei] style pack: #{style_pack.name} (#{style_pack.path})"
20
24
 
21
25
  # 2. Resolve and concatenate file references
26
+ $stderr.puts "[sakusei] resolving file includes..."
22
27
  resolved_content = resolve_files
23
28
 
24
29
  # 3. Process ERB templates
30
+ $stderr.puts "[sakusei] processing ERB..."
25
31
  processed_content = process_erb(resolved_content)
26
32
 
27
33
  # 4. Process Vue components (if available)
28
- processed_content = process_vue(processed_content)
34
+ processed_content = process_vue(processed_content, style_pack)
35
+
36
+ # 4.5 Wrap h2/h3 headings with their following block to prevent orphaned headings
37
+ processed_content = wrap_headings(processed_content)
29
38
 
30
39
  # 5. Convert to PDF
40
+ $stderr.puts "[sakusei] converting to PDF..."
31
41
  output_path = generate_output_path
32
42
  convert_to_pdf(processed_content, output_path, style_pack)
33
43
 
44
+ $stderr.puts "[sakusei] written: #{output_path}"
34
45
  output_path
35
46
  end
36
47
 
37
48
  private
38
49
 
39
50
  def discover_style_pack
40
- StylePack.discover(@source_dir, @options[:style])
51
+ StylePack.discover(@options[:source_dir] || @source_dir, @options[:style])
41
52
  end
42
53
 
43
54
  def resolve_files
@@ -45,15 +56,23 @@ module Sakusei
45
56
  end
46
57
 
47
58
  def process_erb(content)
48
- ErbProcessor.new(content, @source_dir).process
59
+ ErbProcessor.new(content, @source_dir, source_file: @source_file).process
60
+ end
61
+
62
+ def resolve_image_paths(content)
63
+ ImagePathResolver.new(content, @source_dir).resolve
64
+ end
65
+
66
+ def process_vue(content, style_pack)
67
+ VueProcessor.new(content, @source_dir, style_pack: style_pack).process
49
68
  end
50
69
 
51
- def process_vue(content)
52
- VueProcessor.new(content, @source_dir).process
70
+ def wrap_headings(content)
71
+ HeadingWrapper.new(content).wrap
53
72
  end
54
73
 
55
74
  def convert_to_pdf(content, output_path, style_pack)
56
- MdToPdfConverter.new(content, output_path, style_pack, @options).convert
75
+ MdToPdfConverter.new(content, output_path, style_pack, @options.merge(source_dir: @source_dir)).convert
57
76
  end
58
77
 
59
78
  def generate_output_path
data/lib/sakusei/cli.rb CHANGED
@@ -65,7 +65,7 @@ module Sakusei
65
65
  def concat(*files)
66
66
  raise Error, 'No input files provided' if files.empty?
67
67
 
68
- PdfConcat.new(files, options[:output]).concat
68
+ PdfConcatenator.new(files, options[:output]).concat
69
69
  say "PDFs concatenated: #{options[:output]}", :green
70
70
  rescue Error => e
71
71
  say_error e.message
@@ -92,6 +92,115 @@ module Sakusei
92
92
  exit 1
93
93
  end
94
94
 
95
+ desc 'components [STYLE]', 'List available Vue components'
96
+ option :directory, aliases: '-d', default: '.', desc: 'Directory to search for style packs'
97
+ def components(style = nil)
98
+ pack = StylePack.discover(options[:directory], style)
99
+
100
+ say "\nAvailable Vue components:\n\n"
101
+
102
+ pack_components = pack.list_components
103
+ if pack_components.any?
104
+ say " Style pack: #{pack.name}", :cyan
105
+ pack_components.each do |comp|
106
+ desc_str = comp[:description] || '(no description)'
107
+ say " • #{comp[:name].ljust(18)} #{desc_str}"
108
+ end
109
+ else
110
+ say " Style pack '#{pack.name}' has no Vue components.", :yellow
111
+ end
112
+
113
+ if style.nil?
114
+ local_components_dir = File.join(Dir.pwd, 'components')
115
+ if Dir.exist?(local_components_dir)
116
+ say ""
117
+ say " Local (./components/)", :cyan
118
+ Dir.glob(File.join(local_components_dir, '*.vue')).sort.each do |file|
119
+ name = File.basename(file, '.vue')
120
+ desc_str = StylePack.extract_docs_description(file) || '(no description)'
121
+ say " • #{name.ljust(18)} #{desc_str}"
122
+ end
123
+ end
124
+ end
125
+ rescue Error => e
126
+ say_error e.message
127
+ exit 1
128
+ end
129
+
130
+ desc 'component NAME', 'Show detailed information about a Vue component'
131
+ option :directory, aliases: '-d', default: '.', desc: 'Directory to search for style packs'
132
+ option :verbose, aliases: '-v', type: :boolean, default: false, desc: 'Show full component source code'
133
+ def component(name)
134
+ info = StylePack.find_component(options[:directory], name)
135
+
136
+ unless info
137
+ say_error "Component '#{name}' not found"
138
+ exit 1
139
+ end
140
+
141
+ say "\n"
142
+ say "═" * 60, :cyan
143
+ say " #{info[:name]}", :cyan
144
+ say "═" * 60, :cyan
145
+ say "\n"
146
+
147
+ # Description
148
+ if info[:description]
149
+ say "📄 Description:"
150
+ say " #{info[:description]}"
151
+ say "\n"
152
+ end
153
+
154
+ # Location
155
+ say "📁 Location:"
156
+ say " #{info[:path]}", :cyan
157
+ say " (in style pack: #{info[:pack_name]})"
158
+ say "\n"
159
+
160
+ # Props
161
+ if info[:props]&.any?
162
+ say "⚙️ Props:"
163
+ info[:props].each do |prop|
164
+ req_str = prop[:required] ? 'required' : 'optional'
165
+ default_str = prop[:default] ? " (default: #{prop[:default]})" : ""
166
+ type_str = prop[:type] ? " [#{prop[:type]}]" : ""
167
+ say " • #{prop[:name]}#{type_str} - #{req_str}#{default_str}"
168
+ end
169
+ say "\n"
170
+ end
171
+
172
+ # Usage
173
+ say "📝 Usage:"
174
+ say " #{info[:usage]}", :green
175
+ say "\n"
176
+
177
+ # Full documentation if available
178
+ if info[:full_description] && info[:full_description].lines.count > 1
179
+ say "📖 Documentation:"
180
+ info[:full_description].lines.each do |line|
181
+ say " #{line.rstrip}"
182
+ end
183
+ say "\n"
184
+ end
185
+
186
+ # Full source option
187
+ if options[:verbose]
188
+ say "🔍 Template:"
189
+ say info[:template] || "(no template)"
190
+ say "\n"
191
+ say "🔍 Script:"
192
+ say info[:script] || "(no script)"
193
+ say "\n"
194
+ say "🔍 Style:"
195
+ say info[:style] || "(no style)"
196
+ else
197
+ say "💡 Use --verbose to see the full component source code"
198
+ end
199
+ rescue Error => e
200
+ say_error e.message
201
+ exit 1
202
+ end
203
+
95
204
  desc 'version', 'Show version'
96
205
  def version
97
206
  say "Sakusei #{Sakusei::VERSION}"
@@ -102,8 +211,8 @@ module Sakusei
102
211
 
103
212
  # Override dispatch to treat file paths as build commands
104
213
  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)
214
+ # If first arg is an existing file or glob pattern (and not a known subcommand), treat it as a build command
215
+ if given_args.any? && !all_commands.key?(given_args.first) && file_arg?(given_args.first)
107
216
  given_args.unshift('build')
108
217
  end
109
218
  super
@@ -1,34 +1,42 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'erb'
4
+ require 'json'
4
5
 
5
6
  module Sakusei
6
7
  # Processes ERB templates in markdown content
7
8
  class ErbProcessor
8
- def initialize(content, base_dir)
9
+ def initialize(content, base_dir, source_file: nil)
9
10
  @content = content
10
11
  @base_dir = base_dir
12
+ @source_file = source_file
11
13
  end
12
14
 
13
15
  def process
14
16
  # Create a context object with helper methods
15
- context = ErbContext.new(@base_dir)
17
+ context = ErbContext.new(@base_dir, source_file: @source_file)
16
18
 
17
- # Process the ERB
19
+ # Process the ERB — setting filename makes require_relative resolve
20
+ # relative to the source document, not the working directory.
18
21
  erb = ERB.new(@content, trim_mode: '-')
19
- erb.result(context.binding)
22
+ erb.filename = @source_file if @source_file
23
+ erb.result(context.template_binding)
20
24
  rescue StandardError => e
21
25
  raise Error, "ERB processing error: #{e.message}"
22
26
  end
23
27
 
24
28
  # Context object that provides helper methods for ERB templates
25
29
  class ErbContext
26
- def initialize(base_dir)
30
+ def initialize(base_dir, source_file: nil)
27
31
  @base_dir = base_dir
32
+ @source_file = source_file
28
33
  end
29
34
 
30
- def binding
31
- ::Kernel.binding
35
+ # Returns the binding of this ErbContext instance so that ERB local
36
+ # variables (e.g. <% x = 1 %>) persist across the full template evaluation
37
+ # and helper methods (today, include_file, etc.) are callable as self.
38
+ def template_binding
39
+ binding
32
40
  end
33
41
 
34
42
  # Helper method to include file content directly
@@ -51,6 +59,68 @@ module Sakusei
51
59
  def sh(command)
52
60
  `#{command}`.chomp
53
61
  end
62
+
63
+ # Resolves a relative image path (relative to the source document) to a
64
+ # file:// URI that Puppeteer can load during PDF generation.
65
+ #
66
+ # Usage in markdown:
67
+ # <%= image_path('images/photo.jpg') %>
68
+ def image_path(relative_path)
69
+ full_path = File.expand_path(relative_path, @base_dir)
70
+ "file://#{full_path}"
71
+ end
72
+
73
+ # Extracts document headings as a JSON array for use with the Contents component.
74
+ # Only includes headings that appear after the Contents component tag in the file.
75
+ # Normalises heading depth relative to h2: h2 → level 1, h3 → level 2, etc.
76
+ #
77
+ # Usage:
78
+ # <%= document_headings %> # reads current source file
79
+ # <%= document_headings('./other.md') %> # reads a specific file
80
+ def document_headings(path = nil)
81
+ target = path ? File.expand_path(path, @base_dir) : @source_file
82
+ return '[]' unless target && File.exist?(target)
83
+
84
+ items = []
85
+ past_contents = false
86
+
87
+ File.foreach(target) do |line|
88
+ line = line.chomp
89
+ past_contents = true if !past_contents && line.match?(/vue-component\s+name=["']Contents["']/)
90
+ next unless past_contents
91
+
92
+ m = line.match(/^(#+)\s+(.+)/)
93
+ next unless m
94
+
95
+ raw_level = m[1].length
96
+ next if raw_level < 2 # skip h1 document title
97
+
98
+ title = m[2].strip
99
+ items << { title: title, level: raw_level - 1, slug: slugify(title) }
100
+ end
101
+
102
+ items.to_json
103
+ end
104
+
105
+ private
106
+
107
+ # Slugify matching marked's Slugger.serialize (used by md-to-pdf v3).
108
+ # Mirrors the JS logic exactly so that href="#slug" in Contents links
109
+ # match the id="slug" attributes that marked puts on headings.
110
+ #
111
+ # marked source:
112
+ # .toLowerCase().trim()
113
+ # .replace(/<[!\/a-z].*?>/ig, '') # strip html tags
114
+ # .replace(/[\u2000-\u206F\u2E00-\u2E7F\'...]/g, '') # remove punctuation
115
+ # .replace(/\s/g, '-') # each space → hyphen (NOT \s+)
116
+ def slugify(title)
117
+ title
118
+ .downcase
119
+ .strip
120
+ .gsub(/<[!\/a-z].*?>/i, '')
121
+ .gsub(/[\u2000-\u206F\u2E00-\u2E7F\\'!"#$%&()*+,.\/:;<=>?@\[\]^`{|}~]/, '')
122
+ .gsub(/\s/, '-')
123
+ end
54
124
  end
55
125
  end
56
126
  end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sakusei
4
+ # Wraps each h2/h3 heading and its immediately following content block in a
5
+ # <div style="page-break-inside: avoid"> so that Chromium/Puppeteer keeps
6
+ # the heading glued to the content that follows it.
7
+ #
8
+ # Operates on the markdown string after ERB and Vue processing so that Vue
9
+ # component output (already rendered to HTML) is treated as a normal block.
10
+ # Raw HTML in the markdown (html: true) passes through md-to-pdf untouched.
11
+ class HeadingWrapper
12
+ HEADING_PATTERN = /\A[ \t]*(##|###) /
13
+
14
+ def initialize(content)
15
+ @content = content
16
+ end
17
+
18
+ def wrap
19
+ # Split on two-or-more blank lines to get top-level blocks.
20
+ # Preserve the separator length so rejoining is faithful.
21
+ blocks = @content.split(/(\n{2,})/)
22
+ # split with a capture group gives us [block, sep, block, sep, ...]
23
+ result = []
24
+ i = 0
25
+ while i < blocks.length
26
+ block = blocks[i]
27
+ sep = blocks[i + 1] || "\n\n"
28
+
29
+ if heading_block?(block)
30
+ # Look ahead past the separator to the next content block
31
+ next_block = blocks[i + 2]
32
+ next_sep = blocks[i + 3] || "\n\n"
33
+
34
+ if next_block && !heading_block?(next_block)
35
+ result << "<div style=\"page-break-inside: avoid\">\n\n#{block}#{sep}#{next_block}\n\n</div>"
36
+ result << next_sep
37
+ i += 4
38
+ next
39
+ end
40
+ end
41
+
42
+ result << block
43
+ result << sep
44
+ i += 2
45
+ end
46
+
47
+ result.join
48
+ end
49
+
50
+ private
51
+
52
+ def heading_block?(block)
53
+ return false unless block
54
+ block.match?(HEADING_PATTERN)
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'base64'
4
+
5
+ module Sakusei
6
+ # Embeds local images as base64 data URIs so that Puppeteer can render them
7
+ # regardless of HTTP server basedir or working directory. Runs after ERB
8
+ # processing so documents can use standard relative paths (e.g. images/foo.jpg)
9
+ # and remain previewable in any standard markdown viewer.
10
+ #
11
+ # Handles:
12
+ # - Standard markdown images: ![alt](images/foo.jpg)
13
+ # - Vue component image props: "image":"images/foo.jpg"
14
+ #
15
+ # Leaves http://, https://, data:, and missing files untouched.
16
+ class ImagePathResolver
17
+ SKIP_PATTERN = /\A(https?:|data:|\/\/)/i
18
+
19
+ MIME_TYPES = {
20
+ '.jpg' => 'image/jpeg',
21
+ '.jpeg' => 'image/jpeg',
22
+ '.png' => 'image/png',
23
+ '.gif' => 'image/gif',
24
+ '.svg' => 'image/svg+xml',
25
+ '.webp' => 'image/webp'
26
+ }.freeze
27
+
28
+ def initialize(content, base_dir)
29
+ @content = content
30
+ @base_dir = base_dir
31
+ end
32
+
33
+ def resolve
34
+ content = resolve_markdown_images(@content)
35
+ content = resolve_component_image_props(content)
36
+ content
37
+ end
38
+
39
+ private
40
+
41
+ def to_data_uri(path)
42
+ return path if path.match?(SKIP_PATTERN)
43
+
44
+ full_path = path.start_with?('/') ? path : File.expand_path(path, @base_dir)
45
+ return path unless File.exist?(full_path)
46
+
47
+ ext = File.extname(full_path).downcase
48
+ mime = MIME_TYPES[ext] || 'application/octet-stream'
49
+ data = Base64.strict_encode64(File.binread(full_path))
50
+ "data:#{mime};base64,#{data}"
51
+ end
52
+
53
+ def resolve_markdown_images(content)
54
+ content.gsub(/!\[([^\]]*)\]\(([^)]+)\)/) do
55
+ alt = Regexp.last_match(1)
56
+ path = Regexp.last_match(2).strip
57
+ "![#{alt}](#{to_data_uri(path)})"
58
+ end
59
+ end
60
+
61
+ def resolve_component_image_props(content)
62
+ content.gsub(/"image"\s*:\s*"([^"]+)"/) do
63
+ path = Regexp.last_match(1)
64
+ "\"image\":\"#{to_data_uri(path)}\""
65
+ end
66
+ end
67
+ end
68
+ end
@@ -1,36 +1,34 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'tempfile'
3
+ require 'fileutils'
4
+ require 'tmpdir'
4
5
 
5
6
  module Sakusei
6
7
  # Converts markdown content to PDF using md-to-pdf
7
8
  class MdToPdfConverter
8
9
  def initialize(content, output_path, style_pack, options = {})
9
- @content = content
10
+ @content = content
10
11
  @output_path = output_path
11
12
  @style_pack = style_pack
12
- @options = options
13
+ @options = options
14
+ @source_dir = options[:source_dir]
13
15
  end
14
16
 
15
17
  def convert
16
- # Create temp directory for working files
17
18
  Dir.mktmpdir('sakusei') do |temp_dir|
18
- # Write content to temp markdown file
19
+ # Write processed content to temp markdown file
19
20
  temp_md = File.join(temp_dir, 'input.md')
20
- File.write(temp_md, @content)
21
+ File.write(temp_md, page_chrome_prefix + @content)
21
22
 
22
- # Build md-to-pdf command
23
- cmd = build_command(temp_md, temp_dir)
23
+ # Copy any local images into the temp dir, mirroring the relative structure,
24
+ # so md-to-pdf's HTTP server can serve them by their relative paths.
25
+ copy_images(temp_dir)
24
26
 
25
- # Execute command
27
+ cmd = build_command(temp_md, temp_dir)
26
28
  result = system(cmd)
27
29
  raise Error, 'PDF conversion failed' unless result
28
30
 
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)
31
+ FileUtils.mv(File.join(temp_dir, 'input.pdf'), @output_path)
34
32
  end
35
33
 
36
34
  @output_path
@@ -38,31 +36,61 @@ module Sakusei
38
36
 
39
37
  private
40
38
 
39
+ # Scans content for relative image paths (markdown and HTML img src),
40
+ # and copies each referenced file into temp_dir at the same relative path.
41
+ def copy_images(temp_dir)
42
+ return unless @source_dir
43
+
44
+ image_paths.each do |rel_path|
45
+ src = File.expand_path(rel_path, @source_dir)
46
+ next unless File.exist?(src)
47
+
48
+ dest = File.join(temp_dir, rel_path)
49
+ FileUtils.mkdir_p(File.dirname(dest))
50
+ FileUtils.cp(src, dest)
51
+ end
52
+ end
53
+
54
+ # Extracts relative image paths from both markdown syntax and HTML img tags.
55
+ def image_paths
56
+ paths = []
57
+
58
+ # Markdown: ![alt](path)
59
+ @content.scan(/!\[[^\]]*\]\(([^)]+)\)/) { |m| paths << m[0].strip }
60
+
61
+ # HTML: src="path" (covers Vue-rendered img tags)
62
+ @content.scan(/src="([^"]+)"/) { |m| paths << m[0].strip }
63
+
64
+ # Filter to relative paths only (skip http, https, data URIs, absolute paths)
65
+ paths.reject { |p| p.match?(/\A(https?:|data:|\/\/)/) || p.start_with?('/') }
66
+ .uniq
67
+ end
68
+
69
+ def page_chrome_prefix
70
+ return '' unless @style_pack
71
+ %i[header footer].map do |part|
72
+ path = @style_pack.public_send(part)
73
+ path ? File.read(path) + "\n" : ''
74
+ end.join
75
+ end
76
+
41
77
  def build_command(temp_path, temp_dir)
42
78
  cmd = ['npx', 'md-to-pdf']
43
79
 
44
- # Config file
45
80
  config = @options[:config] || @style_pack.config
46
81
  cmd << '--config-file' << config if config
47
82
 
48
- # Stylesheets - base CSS first, then style pack CSS
49
- # This allows style packs to override base styles
50
83
  stylesheets = [StylePack.base_stylesheet]
51
-
52
- # Add style pack stylesheet if available
53
84
  pack_stylesheet = @options[:stylesheet] || @style_pack.stylesheet
54
85
  stylesheets << pack_stylesheet if pack_stylesheet
86
+ stylesheets.each { |s| cmd << '--stylesheet' << s }
55
87
 
56
- stylesheets.each do |stylesheet|
57
- cmd << '--stylesheet' << stylesheet
58
- end
59
-
60
- # Basedir for resolving relative paths
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.
61
91
  cmd << '--basedir' << temp_dir
62
92
 
63
- # Input file
64
93
  cmd << temp_path
65
-
66
94
  cmd.join(' ')
67
95
  end
68
96
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'tempfile'
4
+
3
5
  module Sakusei
4
6
  # Builds multiple markdown files into a single PDF with consistent page numbering
5
7
  class MultiFileBuilder
@@ -18,8 +20,14 @@ module Sakusei
18
20
  # Process the combined content through the normal pipeline
19
21
  temp_file = create_temp_file(combined_content)
20
22
 
21
- # Use standard Builder with the combined file
22
- Builder.new(temp_file, @options.merge(base_dir: @base_dir)).build
23
+ # Derive output path from the first source file if not explicitly specified
24
+ output = @options[:output] || begin
25
+ first = @files.first
26
+ File.join(File.dirname(File.expand_path(first)), "#{File.basename(first, '.*')}.pdf")
27
+ end
28
+
29
+ # Use standard Builder with the combined file, pointing it at the real source dir
30
+ Builder.new(temp_file, @options.merge(source_dir: @base_dir, output: output)).build
23
31
  ensure
24
32
  File.delete(temp_file) if temp_file && File.exist?(temp_file)
25
33
  end
@@ -48,6 +48,159 @@ module Sakusei
48
48
  pack_path
49
49
  end
50
50
 
51
+ def components_dir
52
+ dir = File.join(@path, 'components')
53
+ Dir.exist?(dir) ? dir : nil
54
+ end
55
+
56
+ def list_components
57
+ return [] unless components_dir
58
+ Dir.glob(File.join(components_dir, '*.vue')).sort.map do |file|
59
+ {
60
+ name: File.basename(file, '.vue'),
61
+ description: self.class.extract_docs_description(file),
62
+ path: file
63
+ }
64
+ end
65
+ end
66
+
67
+ def self.extract_docs_description(file)
68
+ content = File.read(file)
69
+ match = content.match(/<docs>\s*\n\s*(.+)/)
70
+ match ? match[1].strip : nil
71
+ end
72
+
73
+ # Find a component by name across style packs and local directories
74
+ def self.find_component(start_dir, component_name)
75
+ # Search in style packs
76
+ sakusei_path = find_sakusei_dir(start_dir)
77
+ if sakusei_path
78
+ packs_dir = File.join(sakusei_path, STYLE_PACKS_DIR)
79
+ if Dir.exist?(packs_dir)
80
+ Dir.glob(File.join(packs_dir, '*')).select { |f| File.directory?(f) }.each do |pack_path|
81
+ component_file = File.join(pack_path, 'components', "#{component_name}.vue")
82
+ if File.exist?(component_file)
83
+ pack = new(pack_path)
84
+ return pack.extract_component_info(component_file)
85
+ end
86
+ end
87
+ end
88
+ end
89
+
90
+ # Search in local ./components directory
91
+ local_component = File.join(Dir.pwd, 'components', "#{component_name}.vue")
92
+ if File.exist?(local_component)
93
+ return extract_component_info(local_component, 'local')
94
+ end
95
+
96
+ # Search in default style pack
97
+ default_path = File.expand_path('../templates/default_style_pack', __dir__)
98
+ default_component = File.join(default_path, 'components', "#{component_name}.vue")
99
+ if File.exist?(default_component)
100
+ pack = new(default_path, 'default')
101
+ return pack.extract_component_info(default_component)
102
+ end
103
+
104
+ nil
105
+ end
106
+
107
+ # Extract full component information from a Vue file
108
+ def self.extract_component_info(file, pack_name = nil)
109
+ pack_name ||= File.basename(File.dirname(File.dirname(file)))
110
+ content = File.read(file)
111
+
112
+ # Extract docs section
113
+ docs_match = content.match(/<docs>(.+?)<\/docs>/m)
114
+ docs = docs_match ? docs_match[1].strip : nil
115
+
116
+ # Extract template section
117
+ template_match = content.match(/<template>(.+?)<\/template>/m)
118
+ template = template_match ? template_match[1].strip : nil
119
+
120
+ # Extract script section
121
+ script_match = content.match(/<script(?:\s+setup)?>(.+?)<\/script>/m)
122
+ script = script_match ? script_match[1].strip : nil
123
+
124
+ # Extract style section
125
+ style_match = content.match(/<style(?:\s+scoped)?>(.+?)<\/style>/m)
126
+ style = style_match ? style_match[1].strip : nil
127
+
128
+ # Parse props from script
129
+ props = parse_props(script) if script
130
+
131
+ # Generate usage example
132
+ usage = generate_usage(File.basename(file, '.vue'), props, template)
133
+
134
+ {
135
+ name: File.basename(file, '.vue'),
136
+ description: docs ? docs.lines.first&.strip : nil,
137
+ full_description: docs,
138
+ path: file,
139
+ pack_name: pack_name,
140
+ template: template,
141
+ script: script,
142
+ style: style,
143
+ props: props,
144
+ usage: usage
145
+ }
146
+ end
147
+
148
+ # Instance method wrapper for extract_component_info
149
+ def extract_component_info(file)
150
+ self.class.extract_component_info(file, @name)
151
+ end
152
+
153
+ private_class_method def self.parse_props(script)
154
+ props = []
155
+
156
+ # Match defineProps with object syntax
157
+ props_match = script.match(/defineProps\(\{(.+?)\}\)/m)
158
+ if props_match
159
+ props_block = props_match[1]
160
+ # Parse each property
161
+ props_block.scan(/(\w+):\s*\{([^}]+)\}/).each do |name, config|
162
+ prop = { name: name, required: false }
163
+ if config.include?('required: true')
164
+ prop[:required] = true
165
+ elsif config.include?('default:')
166
+ default_match = config.match(/default:\s*([^,\n]+)/)
167
+ prop[:default] = default_match[1].strip if default_match
168
+ end
169
+ if config.include?('type:')
170
+ type_match = config.match(/type:\s*(\w+)/)
171
+ prop[:type] = type_match[1] if type_match
172
+ end
173
+ props << prop
174
+ end
175
+ end
176
+
177
+ # Match defineProps with array syntax: defineProps(['name', 'other'])
178
+ array_match = script.match(/defineProps\(\[\s*([^\]]+)\s*\]\)/)
179
+ if array_match
180
+ array_match[1].scan(/['"]([^'"]+)['"]/).each do |name|
181
+ props << { name: name[0], required: true }
182
+ end
183
+ end
184
+
185
+ props
186
+ end
187
+
188
+ private_class_method def self.generate_usage(name, props, template)
189
+ return "<#{name} />" unless props&.any?
190
+
191
+ attrs = props.map do |prop|
192
+ if prop[:required]
193
+ "#{prop[:name]}=\"...\""
194
+ elsif prop[:default]
195
+ "#{prop[:name]}=\"#{prop[:default].gsub(/['"]/, '')}\""
196
+ else
197
+ "#{prop[:name]}=\"...\""
198
+ end
199
+ end
200
+
201
+ "<#{name} #{attrs.join(' ')} />"
202
+ end
203
+
51
204
  # List all available style packs
52
205
  def self.list_available(start_dir = '.')
53
206
  packs = []
@@ -80,7 +233,7 @@ module Sakusei
80
233
 
81
234
  # Remove duplicates by name (closer packs take precedence)
82
235
  seen_names = Set.new
83
- packs.reverse.select { |p| seen_names.add?(p[:name]) }.reverse
236
+ packs.select { |p| seen_names.add?(p[:name]) }
84
237
  end
85
238
 
86
239
  private
@@ -140,7 +293,11 @@ module Sakusei
140
293
  end
141
294
 
142
295
  def run
143
- StylePack.init(@directory, @name)
296
+ pack_path = StylePack.init(@directory, @name)
297
+ $stderr.puts "Installing style pack dependencies for '#{@name}'..."
298
+ result = system('npm', 'install', '--prefix', pack_path)
299
+ raise Sakusei::Error, "npm install failed for style pack '#{@name}'. Check #{pack_path}." unless result
300
+ pack_path
144
301
  end
145
302
  end
146
303
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Sakusei
4
- VERSION = '0.1.0'
4
+ VERSION = '0.2.0'
5
5
  end
@@ -8,7 +8,8 @@ module Sakusei
8
8
  # Processes Vue components at build time using a single Node.js process per build.
9
9
  # Requires Node.js with @vue/server-renderer, @vue/compiler-sfc, and vue@3 installed.
10
10
  class VueProcessor
11
- VUE_COMPONENT_PATTERN = /<vue-component\s+([^>]+)(?:\s*\/>|>(.*?)<\/vue-component>)/m
11
+ # Attribute values may contain > (e.g. HTML in slot-like props), so we match quoted strings properly
12
+ VUE_COMPONENT_PATTERN = /<vue-component((?:\s+[\w-]+=(?:"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'))*)\s*(?:\/>|>(.*?)<\/vue-component>)/m
12
13
 
13
14
  INSTALL_INSTRUCTIONS = <<~MSG
14
15
  Vue components detected but dependencies not found.
@@ -23,20 +24,27 @@ module Sakusei
23
24
  npm install @vue/server-renderer @vue/compiler-sfc vue@3
24
25
  MSG
25
26
 
26
- def initialize(content, base_dir)
27
+ def initialize(content, base_dir, style_pack: nil)
27
28
  @content = content
28
29
  @base_dir = base_dir
30
+ @style_pack = style_pack
29
31
  end
30
32
 
31
33
  def process
32
34
  return @content unless vue_components_present?
35
+
36
+ ensure_style_pack_deps_installed
33
37
  raise Error, INSTALL_INSTRUCTIONS unless vue_renderer_available?
34
38
 
35
39
  jobs = []
36
40
  content_with_placeholders = first_pass(@content, jobs)
37
41
  return content_with_placeholders if jobs.empty?
38
42
 
43
+ content_with_placeholders = wrap_headings_with_placeholders(content_with_placeholders)
44
+
45
+ $stderr.puts "[sakusei] rendering #{jobs.length} Vue component(s)..."
39
46
  results = render_batch(jobs)
47
+ $stderr.puts "[sakusei] Vue components rendered"
40
48
  result_map = results.each_with_object({}) { |r, h| h[r['id']] = r }
41
49
 
42
50
  all_css = []
@@ -65,9 +73,20 @@ module Sakusei
65
73
  end
66
74
 
67
75
  def vue_renderer_available?
76
+ return true if style_pack_has_vue_renderer?
68
77
  self.class.available?
69
78
  end
70
79
 
80
+ def style_pack_has_vue_renderer?
81
+ return false unless @style_pack
82
+ nm = File.join(@style_pack.path, 'node_modules')
83
+ return false unless Dir.exist?(nm)
84
+ env = { 'NODE_PATH' => nm }
85
+ system(env, 'node', '-e',
86
+ "try{require('@vue/server-renderer');process.exit(0)}catch(e){process.exit(1)}",
87
+ %i[out err] => File::NULL)
88
+ end
89
+
71
90
  def self.vue_renderer_installed?
72
91
  check_cmd = "cd '#{Dir.pwd}' && node -e \"try { require('@vue/server-renderer'); require('@vue/compiler-sfc'); process.exit(0); } catch(e) { process.exit(1); }\" 2>/dev/null"
73
92
  system(check_cmd)
@@ -81,12 +100,15 @@ module Sakusei
81
100
  component_name = attrs.delete('name')
82
101
  component_file = find_component_file(component_name)
83
102
 
103
+ named = parse_named_slots(slot_content)
84
104
  id = jobs.length
85
105
  jobs << {
86
106
  'id' => id,
87
107
  'componentFile' => component_file || '',
88
108
  'props' => attrs,
89
- 'slotHtml' => slot_content ? markdown_to_html(slot_content.strip) : ''
109
+ 'slotHtml' => named.any? ? '' : (slot_content ? markdown_to_html(slot_content.strip) : ''),
110
+ 'namedSlots' => named,
111
+ 'nodeModulesDir' => node_modules_dir_for(component_file)
90
112
  }
91
113
 
92
114
  "<!-- sakusei-vue-#{id} -->"
@@ -95,7 +117,8 @@ module Sakusei
95
117
 
96
118
  # Send all jobs to Node.js in one call via stdin/stdout.
97
119
  def render_batch(jobs)
98
- stdout, stderr, status = Open3.capture3('node', vue_renderer_script, stdin_data: jobs.to_json)
120
+ env = renderer_env
121
+ stdout, stderr, status = Open3.capture3(env, 'node', vue_renderer_script, stdin_data: jobs.to_json)
99
122
  raise Error, "Vue renderer failed: #{stderr.strip}" unless status.success?
100
123
 
101
124
  JSON.parse(stdout)
@@ -103,13 +126,41 @@ module Sakusei
103
126
  raise Error, "Vue renderer returned invalid JSON: #{e.message}"
104
127
  end
105
128
 
129
+ def renderer_env
130
+ env = {}
131
+ if @style_pack
132
+ nm = File.join(@style_pack.path, 'node_modules')
133
+ env['NODE_PATH'] = nm if Dir.exist?(nm)
134
+ end
135
+ env
136
+ end
137
+
138
+ def node_modules_dir_for(component_file)
139
+ return nil if component_file.nil? || component_file.empty?
140
+
141
+ if @style_pack&.components_dir && component_file.start_with?(@style_pack.components_dir)
142
+ File.join(@style_pack.path, 'node_modules')
143
+ else
144
+ local_nm = File.join(@base_dir, 'node_modules')
145
+ Dir.exist?(local_nm) ? local_nm : nil
146
+ end
147
+ end
148
+
106
149
  def find_component_file(name)
107
- possible_paths = [
150
+ local_paths = [
108
151
  File.join(@base_dir, 'components', "#{name}.vue"),
109
152
  File.join(@base_dir, "#{name}.vue"),
110
153
  File.join(@base_dir, 'vue_components', "#{name}.vue")
111
154
  ]
112
- possible_paths.find { |p| File.exist?(p) }
155
+ local = local_paths.find { |p| File.exist?(p) }
156
+ return local if local
157
+
158
+ if @style_pack&.components_dir
159
+ pack_file = File.join(@style_pack.components_dir, "#{name}.vue")
160
+ return pack_file if File.exist?(pack_file)
161
+ end
162
+
163
+ nil
113
164
  end
114
165
 
115
166
  def vue_renderer_script
@@ -129,6 +180,42 @@ module Sakusei
129
180
  attrs
130
181
  end
131
182
 
183
+ def style_pack_needs_install?(style_pack)
184
+ return false unless style_pack&.components_dir
185
+ return false unless File.exist?(File.join(style_pack.path, 'package.json'))
186
+ !Dir.exist?(File.join(style_pack.path, 'node_modules'))
187
+ end
188
+
189
+ def ensure_style_pack_deps_installed
190
+ return unless style_pack_needs_install?(@style_pack)
191
+ $stderr.puts "Installing style pack dependencies for '#{@style_pack.name}'..."
192
+ result = system('npm', 'install', '--prefix', @style_pack.path)
193
+ raise Error, "npm install failed for style pack '#{@style_pack.name}'. Check #{@style_pack.path}." unless result
194
+ end
195
+
196
+ # Wrap a markdown heading immediately before a placeholder in a keep-together div.
197
+ # Converts the heading to HTML so marked doesn't process it inside the div block.
198
+ def wrap_headings_with_placeholders(content)
199
+ content.gsub(/^([#]{1,6}) ([^\n]+)\n+(<!-- sakusei-vue-\d+ -->)/) do
200
+ level = Regexp.last_match(1).length
201
+ text = Regexp.last_match(2).strip
202
+ placeholder = Regexp.last_match(3)
203
+ "<div class=\"kdc-section\">\n<h#{level}>#{text}</h#{level}>\n\n#{placeholder}\n\n</div>"
204
+ end
205
+ end
206
+
207
+ # Extract <template #slotname>...</template> sections and convert their markdown to HTML.
208
+ # Returns a hash of { slot_name => html }. Empty hash if no named slots found.
209
+ def parse_named_slots(content)
210
+ return {} unless content
211
+
212
+ slots = {}
213
+ content.scan(/<template\s+#(\w+)>(.*?)<\/template>/m) do |name, slot_md|
214
+ slots[name] = markdown_to_html(slot_md.strip)
215
+ end
216
+ slots
217
+ end
218
+
132
219
  def markdown_to_html(markdown)
133
220
  return '' if markdown.nil? || markdown.empty?
134
221
 
@@ -52,14 +52,18 @@ function esmToCjs(code) {
52
52
  }
53
53
 
54
54
  // Execute compiled CJS module code and return its exports
55
- function executeModule(code, filePath) {
55
+ function executeModule(code, filePath, nodeModulesDir) {
56
56
  const patchedRequire = (id) => {
57
57
  if (id.endsWith('.vue')) {
58
58
  // Handle both relative and absolute .vue imports
59
59
  const abs = path.isAbsolute(id) ? id : path.resolve(path.dirname(filePath), id)
60
60
  const compiled = compiledCache.get(abs)
61
61
  if (!compiled) throw new Error(`Component not pre-compiled: ${abs}`)
62
- return executeModule(compiled.code, abs)
62
+ return executeModule(compiled.code, abs, nodeModulesDir)
63
+ }
64
+ if (nodeModulesDir) {
65
+ const customRequire = Module.createRequire(path.join(nodeModulesDir, '_'))
66
+ return customRequire(id)
63
67
  }
64
68
  // Use the renderer's own require so CWD node_modules are on the search path
65
69
  return require(id)
@@ -146,7 +150,7 @@ async function preCompileImports(filePath, visited = new Set()) {
146
150
 
147
151
  // Compile and render a single job, injecting slotHtml at the template level
148
152
  async function renderJob(job) {
149
- const { id, componentFile, props, slotHtml } = job
153
+ const { id, componentFile, props, slotHtml, namedSlots, nodeModulesDir } = job
150
154
 
151
155
  if (!componentFile || !fs.existsSync(componentFile)) {
152
156
  return { id, html: `<!-- Vue component not found: ${path.basename(componentFile || 'unknown')} -->`, css: '' }
@@ -161,8 +165,9 @@ async function renderJob(job) {
161
165
 
162
166
  let finalCode = base.code
163
167
 
164
- // If slot content provided, recompile template with slot injected
165
- if (slotHtml) {
168
+ // Recompile template with slot content injected (default and/or named slots)
169
+ const hasNamedSlots = namedSlots && Object.keys(namedSlots).length > 0
170
+ if (slotHtml || hasNamedSlots) {
166
171
  const source = fs.readFileSync(componentFile, 'utf-8')
167
172
  const { descriptor } = parse(source, { filename: componentFile })
168
173
  const idHash = crypto.createHash('md5').update(componentFile).digest('hex').slice(0, 8)
@@ -176,9 +181,23 @@ async function renderJob(job) {
176
181
  bindings = scriptResult.bindings
177
182
  }
178
183
 
179
- const templateSource = (descriptor.template ? descriptor.template.content : '<div></div>')
180
- .replace(/<slot\s*\/>/g, slotHtml)
181
- .replace(/<slot>\s*<\/slot>/g, slotHtml)
184
+ let templateSource = descriptor.template ? descriptor.template.content : '<div></div>'
185
+
186
+ // Inject named slots: replace <slot name="X" /> and <slot name="X"></slot>
187
+ if (hasNamedSlots) {
188
+ for (const [name, html] of Object.entries(namedSlots)) {
189
+ templateSource = templateSource
190
+ .replace(new RegExp(`<slot\\s+name=["']${name}["']\\s*/>`, 'g'), html)
191
+ .replace(new RegExp(`<slot\\s+name=["']${name}["']>\\s*</slot>`, 'g'), html)
192
+ }
193
+ }
194
+
195
+ // Inject default slot
196
+ if (slotHtml) {
197
+ templateSource = templateSource
198
+ .replace(/<slot\s*\/>/g, slotHtml)
199
+ .replace(/<slot>\s*<\/slot>/g, slotHtml)
200
+ }
182
201
 
183
202
  const templateResult = compileTemplate({
184
203
  source: templateSource,
@@ -192,8 +211,17 @@ async function renderJob(job) {
192
211
  finalCode = `${scriptCode}\n${renderCode}\n__component__.ssrRender = ssrRender;\nmodule.exports = __component__;`
193
212
  }
194
213
 
195
- const component = executeModule(finalCode, componentFile)
196
- const app = createSSRApp(component, props)
214
+ const component = executeModule(finalCode, componentFile, nodeModulesDir)
215
+ // Auto-parse JSON-encoded prop values (arrays and objects passed as strings)
216
+ const parsedProps = {}
217
+ for (const [k, v] of Object.entries(props)) {
218
+ if (typeof v === 'string' && (v.startsWith('[') || v.startsWith('{'))) {
219
+ try { parsedProps[k] = JSON.parse(v) } catch { parsedProps[k] = v }
220
+ } else {
221
+ parsedProps[k] = v
222
+ }
223
+ }
224
+ const app = createSSRApp(component, parsedProps)
197
225
  const html = await renderToString(app)
198
226
 
199
227
  return { id, html, css: base.css || '' }
@@ -0,0 +1 @@
1
+ node_modules/
@@ -0,0 +1,30 @@
1
+ <docs>
2
+ A simple greeting component. Replace or delete this example.
3
+
4
+ Props:
5
+ - name (optional): Name to greet. Defaults to "World".
6
+ </docs>
7
+
8
+ <template>
9
+ <div class="hello-world">
10
+ <p>Hello, {{ name }}!</p>
11
+ </div>
12
+ </template>
13
+
14
+ <script setup>
15
+ const props = defineProps({
16
+ name: {
17
+ type: String,
18
+ default: 'World'
19
+ }
20
+ })
21
+ </script>
22
+
23
+ <style scoped>
24
+ .hello-world {
25
+ padding: 0.5em;
26
+ border: 1px solid #ccc;
27
+ border-radius: 4px;
28
+ display: inline-block;
29
+ }
30
+ </style>
@@ -0,0 +1,10 @@
1
+ {
2
+ "name": "sakusei-style-pack",
3
+ "version": "1.0.0",
4
+ "private": true,
5
+ "dependencies": {
6
+ "@vue/compiler-sfc": "^3.5.0",
7
+ "@vue/server-renderer": "^3.5.0",
8
+ "vue": "^3.5.0"
9
+ }
10
+ }
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.1.0
4
+ version: 0.2.0
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-03-27 00:00:00.000000000 Z
11
+ date: 2026-04-14 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: thor
@@ -85,6 +85,8 @@ files:
85
85
  - lib/sakusei/cli.rb
86
86
  - lib/sakusei/erb_processor.rb
87
87
  - lib/sakusei/file_resolver.rb
88
+ - lib/sakusei/heading_wrapper.rb
89
+ - lib/sakusei/image_path_resolver.rb
88
90
  - lib/sakusei/md_to_pdf_converter.rb
89
91
  - lib/sakusei/multi_file_builder.rb
90
92
  - lib/sakusei/pdf_concat.rb
@@ -94,9 +96,12 @@ files:
94
96
  - lib/sakusei/vue_processor.rb
95
97
  - lib/sakusei/vue_renderer.js
96
98
  - lib/templates/base.css
99
+ - lib/templates/default_style_pack/.gitignore
100
+ - lib/templates/default_style_pack/components/HelloWorld.vue
97
101
  - lib/templates/default_style_pack/config.js
98
102
  - lib/templates/default_style_pack/footer.html
99
103
  - lib/templates/default_style_pack/header.html
104
+ - lib/templates/default_style_pack/package.json
100
105
  - lib/templates/default_style_pack/style.css
101
106
  - sakusei.gemspec
102
107
  homepage: https://github.com/keithrowell/sakusei