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 +4 -4
- data/.gitignore +1 -0
- data/lib/sakusei/builder.rb +25 -6
- data/lib/sakusei/cli.rb +112 -3
- data/lib/sakusei/erb_processor.rb +77 -7
- data/lib/sakusei/heading_wrapper.rb +57 -0
- data/lib/sakusei/image_path_resolver.rb +68 -0
- data/lib/sakusei/md_to_pdf_converter.rb +54 -26
- data/lib/sakusei/multi_file_builder.rb +10 -2
- data/lib/sakusei/style_pack.rb +159 -2
- data/lib/sakusei/version.rb +1 -1
- data/lib/sakusei/vue_processor.rb +93 -6
- data/lib/sakusei/vue_renderer.js +38 -10
- data/lib/templates/default_style_pack/.gitignore +1 -0
- data/lib/templates/default_style_pack/components/HelloWorld.vue +30 -0
- data/lib/templates/default_style_pack/package.json +10 -0
- metadata +7 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3c5167b69a1150bb93726449a31f46408381ae14f26c0034e472a3ce6b22ee3f
|
|
4
|
+
data.tar.gz: b2187d77e9222b69d927da57ed085cb876e7e0aa7e2c2f556bf4bbfc9e8bbc1b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b15a0b12a135ce5eec0d4ab4afa9db615abac32405656456b15ac66d612a13fb8bcfaea564824be18d74a201a947f8ba4d21e5f40a591cf6c90c59589d8443f2
|
|
7
|
+
data.tar.gz: c9c3dcfa53b2fd665a614414cae22502d830e618c3018b32b00fe314ea5b200d91569483e0c31e5c095862e0999c03ae67d4a8e148b56e2a151007bec8ec6c50
|
data/.gitignore
CHANGED
data/lib/sakusei/builder.rb
CHANGED
|
@@ -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
|
|
52
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
31
|
-
|
|
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: 
|
|
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
|
+
"})"
|
|
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 '
|
|
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
|
|
10
|
+
@content = content
|
|
10
11
|
@output_path = output_path
|
|
11
12
|
@style_pack = style_pack
|
|
12
|
-
@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
|
-
#
|
|
23
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: 
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
#
|
|
22
|
-
|
|
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
|
data/lib/sakusei/style_pack.rb
CHANGED
|
@@ -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.
|
|
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
|
data/lib/sakusei/version.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
data/lib/sakusei/vue_renderer.js
CHANGED
|
@@ -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
|
-
//
|
|
165
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
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>
|
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.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-
|
|
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
|