sakusei 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.gitignore +4 -0
- data/Gemfile +6 -0
- data/README.md +191 -0
- data/Rakefile +13 -0
- data/bin/sakusei +6 -0
- data/lib/sakusei/builder.rb +66 -0
- data/lib/sakusei/cli.rb +167 -0
- data/lib/sakusei/erb_processor.rb +56 -0
- data/lib/sakusei/file_resolver.rb +56 -0
- data/lib/sakusei/md_to_pdf_converter.rb +69 -0
- data/lib/sakusei/multi_file_builder.rb +77 -0
- data/lib/sakusei/pdf_concat.rb +69 -0
- data/lib/sakusei/style_pack.rb +146 -0
- data/lib/sakusei/style_preview.rb +148 -0
- data/lib/sakusei/version.rb +5 -0
- data/lib/sakusei/vue_processor.rb +141 -0
- data/lib/sakusei/vue_renderer.js +226 -0
- data/lib/sakusei.rb +26 -0
- data/lib/templates/base.css +243 -0
- data/lib/templates/default_style_pack/config.js +36 -0
- data/lib/templates/default_style_pack/footer.html +5 -0
- data/lib/templates/default_style_pack/header.html +5 -0
- data/lib/templates/default_style_pack/style.css +22 -0
- data/sakusei.gemspec +29 -0
- metadata +125 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sakusei
|
|
4
|
+
# Builds multiple markdown files into a single PDF with consistent page numbering
|
|
5
|
+
class MultiFileBuilder
|
|
6
|
+
def initialize(files, options = {})
|
|
7
|
+
@files = expand_files(files)
|
|
8
|
+
@options = options
|
|
9
|
+
@base_dir = options[:base_dir] || Dir.pwd
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def build
|
|
13
|
+
raise Error, 'No files to build' if @files.empty?
|
|
14
|
+
|
|
15
|
+
# Concatenate all markdown files
|
|
16
|
+
combined_content = concatenate_files
|
|
17
|
+
|
|
18
|
+
# Process the combined content through the normal pipeline
|
|
19
|
+
temp_file = create_temp_file(combined_content)
|
|
20
|
+
|
|
21
|
+
# Use standard Builder with the combined file
|
|
22
|
+
Builder.new(temp_file, @options.merge(base_dir: @base_dir)).build
|
|
23
|
+
ensure
|
|
24
|
+
File.delete(temp_file) if temp_file && File.exist?(temp_file)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def expand_files(files)
|
|
30
|
+
expanded = []
|
|
31
|
+
|
|
32
|
+
Array(files).each do |pattern|
|
|
33
|
+
if pattern.include?('*')
|
|
34
|
+
# Handle glob patterns
|
|
35
|
+
matches = Dir.glob(File.expand_path(pattern, @base_dir))
|
|
36
|
+
expanded.concat(matches)
|
|
37
|
+
elsif File.directory?(pattern)
|
|
38
|
+
# If directory, get all .md files
|
|
39
|
+
expanded.concat(Dir.glob(File.join(pattern, '**', '*.md')))
|
|
40
|
+
elsif File.exist?(pattern)
|
|
41
|
+
expanded << File.expand_path(pattern, @base_dir)
|
|
42
|
+
else
|
|
43
|
+
raise Error, "File not found: #{pattern}"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Remove duplicates while preserving order
|
|
48
|
+
expanded.uniq
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def concatenate_files
|
|
52
|
+
parts = []
|
|
53
|
+
|
|
54
|
+
@files.each do |file|
|
|
55
|
+
content = File.read(file)
|
|
56
|
+
|
|
57
|
+
# Resolve includes within each file
|
|
58
|
+
resolver = FileResolver.new(file)
|
|
59
|
+
resolved = resolver.resolve
|
|
60
|
+
|
|
61
|
+
parts << resolved
|
|
62
|
+
|
|
63
|
+
# Add page break between files (optional)
|
|
64
|
+
parts << "\n\n<div class=\"page-break\"></div>\n\n" if @options[:page_breaks]
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
parts.join("\n")
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def create_temp_file(content)
|
|
71
|
+
temp = Tempfile.new(['sakusei_multifile', '.md'])
|
|
72
|
+
temp.write(content)
|
|
73
|
+
temp.close
|
|
74
|
+
temp.path
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sakusei
|
|
4
|
+
# Concatenates multiple PDF files
|
|
5
|
+
class PdfConcatenator
|
|
6
|
+
# macOS built-in PDF join tool (last resort fallback)
|
|
7
|
+
MACOS_JOIN_TOOL = '/System/Library/Automator/Combine PDF Pages.action/Contents/MacOS/join'
|
|
8
|
+
|
|
9
|
+
def initialize(files, output_path)
|
|
10
|
+
@files = files.map { |f| File.expand_path(f) }
|
|
11
|
+
@output_path = File.expand_path(output_path)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def concat
|
|
15
|
+
validate_files
|
|
16
|
+
|
|
17
|
+
# Try tools in order of preference
|
|
18
|
+
if pdfunite_available?
|
|
19
|
+
concat_with_pdfunite
|
|
20
|
+
elsif pdftk_available?
|
|
21
|
+
concat_with_pdftk
|
|
22
|
+
elsif macos_join_available?
|
|
23
|
+
concat_with_macos_join
|
|
24
|
+
else
|
|
25
|
+
raise Error, 'No PDF concatenation tool found. Please install pdfunite (poppler-utils) or pdftk'
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def validate_files
|
|
32
|
+
@files.each do |file|
|
|
33
|
+
raise Error, "File not found: #{file}" unless File.exist?(file)
|
|
34
|
+
raise Error, "Not a PDF file: #{file}" unless File.extname(file).downcase == '.pdf'
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def pdfunite_available?
|
|
39
|
+
system('which pdfunite > /dev/null 2>&1')
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def pdftk_available?
|
|
43
|
+
system('which pdftk > /dev/null 2>&1')
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def macos_join_available?
|
|
47
|
+
File.executable?(MACOS_JOIN_TOOL)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def concat_with_pdfunite
|
|
51
|
+
cmd = ['pdfunite', *@files, @output_path].join(' ')
|
|
52
|
+
result = system(cmd)
|
|
53
|
+
raise Error, 'PDF concatenation failed with pdfunite' unless result
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def concat_with_pdftk
|
|
57
|
+
cmd = ['pdftk', *@files, 'cat', 'output', @output_path].join(' ')
|
|
58
|
+
result = system(cmd)
|
|
59
|
+
raise Error, 'PDF concatenation failed with pdftk' unless result
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Fallback to macOS built-in tool (last resort)
|
|
63
|
+
def concat_with_macos_join
|
|
64
|
+
cmd = [MACOS_JOIN_TOOL, '-o', @output_path, *@files].join(' ')
|
|
65
|
+
result = system(cmd)
|
|
66
|
+
raise Error, 'PDF concatenation failed with macOS join tool' unless result
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'set'
|
|
4
|
+
|
|
5
|
+
module Sakusei
|
|
6
|
+
class StylePack
|
|
7
|
+
STYLE_PACKS_DIR = 'style_packs'
|
|
8
|
+
SAKUSEI_DIR = '.sakusei'
|
|
9
|
+
|
|
10
|
+
attr_reader :name, :path, :config, :stylesheet, :header, :footer
|
|
11
|
+
|
|
12
|
+
# Path to the base CSS that is always applied before style pack CSS
|
|
13
|
+
def self.base_stylesheet
|
|
14
|
+
File.expand_path('../templates/base.css', __dir__)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def initialize(path, name = nil)
|
|
18
|
+
@path = path
|
|
19
|
+
@name = name || File.basename(path)
|
|
20
|
+
load_files
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Discover style pack by walking up the directory tree
|
|
24
|
+
def self.discover(start_dir, requested_name = nil)
|
|
25
|
+
sakusei_path = find_sakusei_dir(start_dir)
|
|
26
|
+
|
|
27
|
+
if sakusei_path
|
|
28
|
+
packs_dir = File.join(sakusei_path, STYLE_PACKS_DIR)
|
|
29
|
+
return load_from_path(packs_dir, requested_name) if Dir.exist?(packs_dir)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Fall back to default style pack
|
|
33
|
+
default_path = File.expand_path('../templates/default_style_pack', __dir__)
|
|
34
|
+
new(default_path, 'default')
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Initialize a new style pack
|
|
38
|
+
def self.init(directory, name)
|
|
39
|
+
sakusei_path = File.join(directory, SAKUSEI_DIR)
|
|
40
|
+
pack_path = File.join(sakusei_path, STYLE_PACKS_DIR, name)
|
|
41
|
+
|
|
42
|
+
FileUtils.mkdir_p(pack_path)
|
|
43
|
+
|
|
44
|
+
# Copy default templates
|
|
45
|
+
default_path = File.expand_path('../templates/default_style_pack', __dir__)
|
|
46
|
+
FileUtils.cp_r(Dir.glob("#{default_path}/*"), pack_path)
|
|
47
|
+
|
|
48
|
+
pack_path
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# List all available style packs
|
|
52
|
+
def self.list_available(start_dir = '.')
|
|
53
|
+
packs = []
|
|
54
|
+
|
|
55
|
+
# Find all .sakusei directories walking up from start_dir
|
|
56
|
+
current = File.expand_path(start_dir)
|
|
57
|
+
visited_dirs = Set.new
|
|
58
|
+
|
|
59
|
+
loop do
|
|
60
|
+
sakusei_path = File.join(current, SAKUSEI_DIR)
|
|
61
|
+
if Dir.exist?(sakusei_path) && !visited_dirs.include?(sakusei_path)
|
|
62
|
+
visited_dirs.add(sakusei_path)
|
|
63
|
+
packs_dir = File.join(sakusei_path, STYLE_PACKS_DIR)
|
|
64
|
+
if Dir.exist?(packs_dir)
|
|
65
|
+
Dir.glob(File.join(packs_dir, '*')).select { |f| File.directory?(f) }.each do |pack_path|
|
|
66
|
+
packs << { name: File.basename(pack_path), path: pack_path }
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
parent = File.dirname(current)
|
|
72
|
+
break if parent == current
|
|
73
|
+
|
|
74
|
+
current = parent
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Add default style pack
|
|
78
|
+
default_path = File.expand_path('../templates/default_style_pack', __dir__)
|
|
79
|
+
packs << { name: 'default', path: default_path }
|
|
80
|
+
|
|
81
|
+
# Remove duplicates by name (closer packs take precedence)
|
|
82
|
+
seen_names = Set.new
|
|
83
|
+
packs.reverse.select { |p| seen_names.add?(p[:name]) }.reverse
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
def self.find_sakusei_dir(start_dir)
|
|
89
|
+
current = File.expand_path(start_dir)
|
|
90
|
+
|
|
91
|
+
loop do
|
|
92
|
+
sakusei_path = File.join(current, SAKUSEI_DIR)
|
|
93
|
+
return sakusei_path if Dir.exist?(sakusei_path)
|
|
94
|
+
|
|
95
|
+
parent = File.dirname(current)
|
|
96
|
+
break if parent == current # Reached root
|
|
97
|
+
|
|
98
|
+
current = parent
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
nil
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def self.load_from_path(packs_dir, requested_name)
|
|
105
|
+
available_packs = Dir.glob(File.join(packs_dir, '*')).select { |f| File.directory?(f) }
|
|
106
|
+
|
|
107
|
+
raise Error, "No style packs found in #{packs_dir}" if available_packs.empty?
|
|
108
|
+
|
|
109
|
+
if requested_name
|
|
110
|
+
pack_path = available_packs.find { |p| File.basename(p) == requested_name }
|
|
111
|
+
raise Error, "Style pack '#{requested_name}' not found" unless pack_path
|
|
112
|
+
elsif available_packs.length == 1
|
|
113
|
+
pack_path = available_packs.first
|
|
114
|
+
else
|
|
115
|
+
# Interactive selection would happen here
|
|
116
|
+
# For now, use the first one
|
|
117
|
+
pack_path = available_packs.first
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
new(pack_path)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def load_files
|
|
124
|
+
@config = find_file('config.js')
|
|
125
|
+
@stylesheet = find_file('style.css')
|
|
126
|
+
@header = find_file('header.html')
|
|
127
|
+
@footer = find_file('footer.html')
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def find_file(name)
|
|
131
|
+
file_path = File.join(@path, name)
|
|
132
|
+
File.exist?(file_path) ? file_path : nil
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
class StylePackInitializer
|
|
137
|
+
def initialize(directory, name)
|
|
138
|
+
@directory = directory
|
|
139
|
+
@name = name
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def run
|
|
143
|
+
StylePack.init(@directory, @name)
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sakusei
|
|
4
|
+
# Generates a preview PDF showing all style elements
|
|
5
|
+
class StylePreview
|
|
6
|
+
PREVIEW_CONTENT = <<~MARKDOWN
|
|
7
|
+
# Style Pack Preview
|
|
8
|
+
|
|
9
|
+
This document demonstrates all the styling elements available in your style pack.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Typography
|
|
14
|
+
|
|
15
|
+
### Headings
|
|
16
|
+
|
|
17
|
+
# Heading 1
|
|
18
|
+
## Heading 2
|
|
19
|
+
### Heading 3
|
|
20
|
+
#### Heading 4
|
|
21
|
+
##### Heading 5
|
|
22
|
+
###### Heading 6
|
|
23
|
+
|
|
24
|
+
### Paragraphs
|
|
25
|
+
|
|
26
|
+
This is a paragraph with **bold text**, *italic text*, and `inline code`. Here's a [link to example.com](https://example.com).
|
|
27
|
+
|
|
28
|
+
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## Lists
|
|
33
|
+
|
|
34
|
+
### Unordered List
|
|
35
|
+
|
|
36
|
+
- First item
|
|
37
|
+
- Second item
|
|
38
|
+
- Nested item A
|
|
39
|
+
- Nested item B
|
|
40
|
+
- Third item
|
|
41
|
+
|
|
42
|
+
### Ordered List
|
|
43
|
+
|
|
44
|
+
1. First step
|
|
45
|
+
2. Second step
|
|
46
|
+
3. Third step
|
|
47
|
+
|
|
48
|
+
### Task List
|
|
49
|
+
|
|
50
|
+
- [x] Completed task
|
|
51
|
+
- [ ] Incomplete task
|
|
52
|
+
- [ ] Another incomplete task
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
## Code
|
|
57
|
+
|
|
58
|
+
### Inline Code
|
|
59
|
+
|
|
60
|
+
Use `puts "Hello World"` to print to stdout.
|
|
61
|
+
|
|
62
|
+
### Code Block
|
|
63
|
+
|
|
64
|
+
```ruby
|
|
65
|
+
def greet(name)
|
|
66
|
+
puts "Hello, \#{name}!"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
greet("Sakusei")
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
## Blockquotes
|
|
75
|
+
|
|
76
|
+
> This is a blockquote.
|
|
77
|
+
> It can span multiple lines.
|
|
78
|
+
>
|
|
79
|
+
> — Author Name
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
## Tables
|
|
84
|
+
|
|
85
|
+
| Feature | Status | Priority |
|
|
86
|
+
|---------|--------|----------|
|
|
87
|
+
| Markdown | ✅ Supported | High |
|
|
88
|
+
| ERB | ✅ Supported | High |
|
|
89
|
+
| VueJS | 🚧 Planned | Low |
|
|
90
|
+
|
|
91
|
+
---
|
|
92
|
+
|
|
93
|
+
## Horizontal Rules
|
|
94
|
+
|
|
95
|
+
Above is a horizontal rule.
|
|
96
|
+
|
|
97
|
+
---
|
|
98
|
+
|
|
99
|
+
## Special Elements
|
|
100
|
+
|
|
101
|
+
This page demonstrates all the styling. Check the next page for more.
|
|
102
|
+
|
|
103
|
+
<div class="page-break"></div>
|
|
104
|
+
|
|
105
|
+
## Second Page
|
|
106
|
+
|
|
107
|
+
This content appears on a second page to demonstrate page breaks and consistent styling across pages.
|
|
108
|
+
|
|
109
|
+
### Sample Formula
|
|
110
|
+
|
|
111
|
+
Inline math: $E = mc^2$
|
|
112
|
+
|
|
113
|
+
---
|
|
114
|
+
|
|
115
|
+
*Generated by Sakusei Style Preview*
|
|
116
|
+
MARKDOWN
|
|
117
|
+
|
|
118
|
+
def initialize(style_pack_name = nil, options = {})
|
|
119
|
+
@style_pack_name = style_pack_name
|
|
120
|
+
@options = options
|
|
121
|
+
@output_path = options[:output] || 'style-preview.pdf'
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def generate
|
|
125
|
+
# Create temp file with preview content
|
|
126
|
+
temp_file = Tempfile.new(['sakusei_preview', '.md'])
|
|
127
|
+
temp_file.write(PREVIEW_CONTENT)
|
|
128
|
+
temp_file.flush
|
|
129
|
+
temp_path = temp_file.path
|
|
130
|
+
temp_file.close
|
|
131
|
+
|
|
132
|
+
# Build using the style pack
|
|
133
|
+
builder = Builder.new(temp_path, @options.merge(style: @style_pack_name))
|
|
134
|
+
result = builder.build
|
|
135
|
+
|
|
136
|
+
# Clean up temp file
|
|
137
|
+
FileUtils.rm_f(temp_path)
|
|
138
|
+
|
|
139
|
+
# Rename to desired output if different
|
|
140
|
+
if result != File.expand_path(@output_path)
|
|
141
|
+
FileUtils.mv(result, @output_path)
|
|
142
|
+
@output_path
|
|
143
|
+
else
|
|
144
|
+
result
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'shellwords'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'open3'
|
|
6
|
+
|
|
7
|
+
module Sakusei
|
|
8
|
+
# Processes Vue components at build time using a single Node.js process per build.
|
|
9
|
+
# Requires Node.js with @vue/server-renderer, @vue/compiler-sfc, and vue@3 installed.
|
|
10
|
+
class VueProcessor
|
|
11
|
+
VUE_COMPONENT_PATTERN = /<vue-component\s+([^>]+)(?:\s*\/>|>(.*?)<\/vue-component>)/m
|
|
12
|
+
|
|
13
|
+
INSTALL_INSTRUCTIONS = <<~MSG
|
|
14
|
+
Vue components detected but dependencies not found.
|
|
15
|
+
|
|
16
|
+
To use Vue components, install the required npm packages:
|
|
17
|
+
|
|
18
|
+
npm install @vue/server-renderer @vue/compiler-sfc vue@3
|
|
19
|
+
|
|
20
|
+
Or initialize a new package.json first:
|
|
21
|
+
|
|
22
|
+
npm init -y
|
|
23
|
+
npm install @vue/server-renderer @vue/compiler-sfc vue@3
|
|
24
|
+
MSG
|
|
25
|
+
|
|
26
|
+
def initialize(content, base_dir)
|
|
27
|
+
@content = content
|
|
28
|
+
@base_dir = base_dir
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def process
|
|
32
|
+
return @content unless vue_components_present?
|
|
33
|
+
raise Error, INSTALL_INSTRUCTIONS unless vue_renderer_available?
|
|
34
|
+
|
|
35
|
+
jobs = []
|
|
36
|
+
content_with_placeholders = first_pass(@content, jobs)
|
|
37
|
+
return content_with_placeholders if jobs.empty?
|
|
38
|
+
|
|
39
|
+
results = render_batch(jobs)
|
|
40
|
+
result_map = results.each_with_object({}) { |r, h| h[r['id']] = r }
|
|
41
|
+
|
|
42
|
+
all_css = []
|
|
43
|
+
output = content_with_placeholders.gsub(/<!-- sakusei-vue-(\d+) -->/) do
|
|
44
|
+
id = Regexp.last_match(1).to_i
|
|
45
|
+
result = result_map[id]
|
|
46
|
+
all_css << result['css'] if result&.fetch('css', '')&.length&.positive?
|
|
47
|
+
result ? result['html'] : '<!-- Vue component render error -->'
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
if all_css.any?
|
|
51
|
+
"<style>\n#{all_css.join("\n\n")}\n</style>\n\n#{output}"
|
|
52
|
+
else
|
|
53
|
+
output
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def self.available?
|
|
58
|
+
system('which node > /dev/null 2>&1') && vue_renderer_installed?
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def vue_components_present?
|
|
64
|
+
@content.match?(VUE_COMPONENT_PATTERN)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def vue_renderer_available?
|
|
68
|
+
self.class.available?
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def self.vue_renderer_installed?
|
|
72
|
+
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
|
+
system(check_cmd)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# First pass: replace each <vue-component> tag with a placeholder and populate jobs array.
|
|
77
|
+
def first_pass(content, jobs)
|
|
78
|
+
content.gsub(VUE_COMPONENT_PATTERN) do |_match|
|
|
79
|
+
attrs = parse_attributes(Regexp.last_match(1))
|
|
80
|
+
slot_content = Regexp.last_match(2)
|
|
81
|
+
component_name = attrs.delete('name')
|
|
82
|
+
component_file = find_component_file(component_name)
|
|
83
|
+
|
|
84
|
+
id = jobs.length
|
|
85
|
+
jobs << {
|
|
86
|
+
'id' => id,
|
|
87
|
+
'componentFile' => component_file || '',
|
|
88
|
+
'props' => attrs,
|
|
89
|
+
'slotHtml' => slot_content ? markdown_to_html(slot_content.strip) : ''
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
"<!-- sakusei-vue-#{id} -->"
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Send all jobs to Node.js in one call via stdin/stdout.
|
|
97
|
+
def render_batch(jobs)
|
|
98
|
+
stdout, stderr, status = Open3.capture3('node', vue_renderer_script, stdin_data: jobs.to_json)
|
|
99
|
+
raise Error, "Vue renderer failed: #{stderr.strip}" unless status.success?
|
|
100
|
+
|
|
101
|
+
JSON.parse(stdout)
|
|
102
|
+
rescue JSON::ParserError => e
|
|
103
|
+
raise Error, "Vue renderer returned invalid JSON: #{e.message}"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def find_component_file(name)
|
|
107
|
+
possible_paths = [
|
|
108
|
+
File.join(@base_dir, 'components', "#{name}.vue"),
|
|
109
|
+
File.join(@base_dir, "#{name}.vue"),
|
|
110
|
+
File.join(@base_dir, 'vue_components', "#{name}.vue")
|
|
111
|
+
]
|
|
112
|
+
possible_paths.find { |p| File.exist?(p) }
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def vue_renderer_script
|
|
116
|
+
File.expand_path('../vue_renderer.js', __FILE__)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def parse_attributes(attrs_string)
|
|
120
|
+
attrs = {}
|
|
121
|
+
return attrs if attrs_string.nil? || attrs_string.empty?
|
|
122
|
+
|
|
123
|
+
attrs_string.scan(/(\w+)=("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')/) do |key, quoted_value|
|
|
124
|
+
value = quoted_value[1..-2]
|
|
125
|
+
value = value.gsub('\\"', '"').gsub("\\'", "'")
|
|
126
|
+
attrs[key] = value
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
attrs
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def markdown_to_html(markdown)
|
|
133
|
+
return '' if markdown.nil? || markdown.empty?
|
|
134
|
+
|
|
135
|
+
cmd = "echo #{Shellwords.escape(markdown)} | npx marked --stdin 2>/dev/null"
|
|
136
|
+
html = `#{cmd}`
|
|
137
|
+
|
|
138
|
+
($?.success? && !html.empty?) ? html.strip : markdown
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|