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.
@@ -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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sakusei
4
+ VERSION = '0.1.0'
5
+ 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