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,226 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Vue Server-Side Renderer for Sakusei (v2)
4
+ *
5
+ * Reads a JSON array of render jobs from stdin.
6
+ * Writes a JSON array of results to stdout.
7
+ *
8
+ * Each job: { id, componentFile, props, slotHtml }
9
+ * Each result: { id, html, css }
10
+ *
11
+ * Requires: npm install @vue/server-renderer @vue/compiler-sfc vue@3
12
+ */
13
+
14
+ 'use strict'
15
+ const fs = require('fs')
16
+ const path = require('path')
17
+ const vm = require('vm')
18
+ const Module = require('module')
19
+ const crypto = require('crypto')
20
+
21
+ // Add CWD node_modules to module resolution (user's project dependencies)
22
+ const cwdNodeModules = path.join(process.cwd(), 'node_modules')
23
+ if (fs.existsSync(cwdNodeModules)) module.paths.unshift(cwdNodeModules)
24
+
25
+ const { parse, compileScript, compileTemplate, compileStyleAsync } = require('@vue/compiler-sfc')
26
+ const { renderToString } = require('@vue/server-renderer')
27
+ const { createSSRApp } = require('vue')
28
+
29
+ // Compiled code cache: filePath → { code, css }
30
+ const compiledCache = new Map()
31
+
32
+ // Transform ESM import/export syntax to CommonJS
33
+ function esmToCjs(code) {
34
+ // Named imports with optional aliases: import { a as b, c } from 'pkg'
35
+ code = code.replace(
36
+ /^import\s*\{([^}]+)\}\s*from\s*["']([^"']+)["']\s*;?$/gm,
37
+ (_, names, pkg) => {
38
+ const renamed = names.replace(/(\w+)\s+as\s+(\w+)/g, '$1: $2')
39
+ return `const {${renamed}} = require('${pkg}');`
40
+ }
41
+ )
42
+ // Default imports: import X from 'pkg'
43
+ code = code.replace(
44
+ /^import\s+(\w+)\s+from\s*["']([^"']+)["']\s*;?$/gm,
45
+ (_, name, pkg) => `const ${name} = require('${pkg}');`
46
+ )
47
+ // export function/const → remove export keyword
48
+ code = code.replace(/^export\s+((?:async\s+)?function|const|let|var)\b/gm, '$1')
49
+ // Remove bare named exports: export { ssrRender }
50
+ code = code.replace(/^export\s*\{[^}]*\}\s*;?$/gm, '')
51
+ return code
52
+ }
53
+
54
+ // Execute compiled CJS module code and return its exports
55
+ function executeModule(code, filePath) {
56
+ const patchedRequire = (id) => {
57
+ if (id.endsWith('.vue')) {
58
+ // Handle both relative and absolute .vue imports
59
+ const abs = path.isAbsolute(id) ? id : path.resolve(path.dirname(filePath), id)
60
+ const compiled = compiledCache.get(abs)
61
+ if (!compiled) throw new Error(`Component not pre-compiled: ${abs}`)
62
+ return executeModule(compiled.code, abs)
63
+ }
64
+ // Use the renderer's own require so CWD node_modules are on the search path
65
+ return require(id)
66
+ }
67
+ const mod = { exports: {} }
68
+ const wrapper = `(function(require, module, exports, __filename, __dirname) {\n${code}\n})`
69
+ const fn = vm.runInThisContext(wrapper, { filename: filePath })
70
+ fn(patchedRequire, mod, mod.exports, filePath, path.dirname(filePath))
71
+ return mod.exports
72
+ }
73
+
74
+ // Compile a .vue file (base form, no slot injection) and store in compiledCache
75
+ async function compileVueFile(filePath) {
76
+ if (compiledCache.has(filePath)) return compiledCache.get(filePath)
77
+
78
+ const source = fs.readFileSync(filePath, 'utf-8')
79
+ const { descriptor, errors: parseErrors } = parse(source, { filename: filePath })
80
+ if (parseErrors.length > 0) {
81
+ throw new Error(`Parse errors in ${path.basename(filePath)}: ${parseErrors.map(e => e.message).join(', ')}`)
82
+ }
83
+
84
+ const id = crypto.createHash('md5').update(filePath).digest('hex').slice(0, 8)
85
+ const hasScoped = descriptor.styles.some(s => s.scoped)
86
+
87
+ // Compile script section
88
+ let componentCode = 'const __component__ = {};'
89
+ let bindings
90
+ if (descriptor.scriptSetup || descriptor.script) {
91
+ const scriptResult = compileScript(descriptor, { id, genDefaultAs: '__component__' })
92
+ componentCode = esmToCjs(scriptResult.content)
93
+ bindings = scriptResult.bindings
94
+ }
95
+
96
+ // Compile template in SSR mode
97
+ const templateResult = compileTemplate({
98
+ source: descriptor.template ? descriptor.template.content : '<div></div>',
99
+ filename: filePath,
100
+ id,
101
+ scoped: hasScoped,
102
+ ssr: true,
103
+ compilerOptions: { bindingMetadata: bindings }
104
+ })
105
+ if (templateResult.errors.length > 0) {
106
+ throw new Error(`Template errors in ${path.basename(filePath)}: ${templateResult.errors.join(', ')}`)
107
+ }
108
+ const renderCode = esmToCjs(templateResult.code)
109
+
110
+ // Compile styles
111
+ let css = ''
112
+ for (const style of descriptor.styles) {
113
+ const result = await compileStyleAsync({
114
+ source: style.content,
115
+ filename: filePath,
116
+ id,
117
+ scoped: style.scoped || false
118
+ })
119
+ if (!result.errors.length) css += result.code + '\n'
120
+ }
121
+
122
+ const fullCode = `${componentCode}\n${renderCode}\n__component__.ssrRender = ssrRender;\nmodule.exports = __component__;`
123
+ const compiled = { code: fullCode, css }
124
+ compiledCache.set(filePath, compiled)
125
+ return compiled
126
+ }
127
+
128
+ // Scan source for .vue imports and pre-compile them recursively
129
+ async function preCompileImports(filePath, visited = new Set()) {
130
+ if (visited.has(filePath) || !fs.existsSync(filePath)) return
131
+ visited.add(filePath)
132
+
133
+ const source = fs.readFileSync(filePath, 'utf-8')
134
+ // Match both relative (./foo.vue) and absolute (/path/to/foo.vue) imports
135
+ const importMatches = [...source.matchAll(/import\s+\w+\s+from\s*['"]([^'"]+\.vue)['"]/g)]
136
+
137
+ for (const match of importMatches) {
138
+ const importPath = match[1]
139
+ const importedPath = path.isAbsolute(importPath)
140
+ ? importPath
141
+ : path.resolve(path.dirname(filePath), importPath)
142
+ await preCompileImports(importedPath, visited)
143
+ await compileVueFile(importedPath)
144
+ }
145
+ }
146
+
147
+ // Compile and render a single job, injecting slotHtml at the template level
148
+ async function renderJob(job) {
149
+ const { id, componentFile, props, slotHtml } = job
150
+
151
+ if (!componentFile || !fs.existsSync(componentFile)) {
152
+ return { id, html: `<!-- Vue component not found: ${path.basename(componentFile || 'unknown')} -->`, css: '' }
153
+ }
154
+
155
+ try {
156
+ // Pre-compile all transitively imported .vue files first
157
+ await preCompileImports(componentFile)
158
+
159
+ // Compile base form (for CSS and script)
160
+ const base = await compileVueFile(componentFile)
161
+
162
+ let finalCode = base.code
163
+
164
+ // If slot content provided, recompile template with slot injected
165
+ if (slotHtml) {
166
+ const source = fs.readFileSync(componentFile, 'utf-8')
167
+ const { descriptor } = parse(source, { filename: componentFile })
168
+ const idHash = crypto.createHash('md5').update(componentFile).digest('hex').slice(0, 8)
169
+ const hasScoped = descriptor.styles.some(s => s.scoped)
170
+
171
+ let scriptCode = 'const __component__ = {};'
172
+ let bindings
173
+ if (descriptor.scriptSetup || descriptor.script) {
174
+ const scriptResult = compileScript(descriptor, { id: idHash, genDefaultAs: '__component__' })
175
+ scriptCode = esmToCjs(scriptResult.content)
176
+ bindings = scriptResult.bindings
177
+ }
178
+
179
+ const templateSource = (descriptor.template ? descriptor.template.content : '<div></div>')
180
+ .replace(/<slot\s*\/>/g, slotHtml)
181
+ .replace(/<slot>\s*<\/slot>/g, slotHtml)
182
+
183
+ const templateResult = compileTemplate({
184
+ source: templateSource,
185
+ filename: componentFile,
186
+ id: idHash,
187
+ scoped: hasScoped,
188
+ ssr: true,
189
+ compilerOptions: { bindingMetadata: bindings }
190
+ })
191
+ const renderCode = esmToCjs(templateResult.code)
192
+ finalCode = `${scriptCode}\n${renderCode}\n__component__.ssrRender = ssrRender;\nmodule.exports = __component__;`
193
+ }
194
+
195
+ const component = executeModule(finalCode, componentFile)
196
+ const app = createSSRApp(component, props)
197
+ const html = await renderToString(app)
198
+
199
+ return { id, html, css: base.css || '' }
200
+ } catch (err) {
201
+ process.stderr.write(`Vue render error (${path.basename(componentFile)}): ${err.message}\n`)
202
+ return { id, html: `<!-- Vue component error: ${err.message} -->`, css: '' }
203
+ }
204
+ }
205
+
206
+ async function main() {
207
+ let input = ''
208
+ process.stdin.setEncoding('utf-8')
209
+ for await (const chunk of process.stdin) input += chunk
210
+
211
+ let jobs
212
+ try {
213
+ jobs = JSON.parse(input)
214
+ } catch (e) {
215
+ process.stderr.write(`Failed to parse input JSON: ${e.message}\n`)
216
+ process.exit(1)
217
+ }
218
+
219
+ const results = await Promise.all(jobs.map(renderJob))
220
+ process.stdout.write(JSON.stringify(results) + '\n')
221
+ }
222
+
223
+ main().catch(e => {
224
+ process.stderr.write(`Fatal error: ${e.message}\n`)
225
+ process.exit(1)
226
+ })
data/lib/sakusei.rb ADDED
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'date'
4
+ require 'set'
5
+ require 'fileutils'
6
+
7
+ require_relative 'sakusei/version'
8
+ require_relative 'sakusei/cli'
9
+ require_relative 'sakusei/builder'
10
+ require_relative 'sakusei/style_pack'
11
+ require_relative 'sakusei/file_resolver'
12
+ require_relative 'sakusei/erb_processor'
13
+ require_relative 'sakusei/md_to_pdf_converter'
14
+ require_relative 'sakusei/pdf_concat'
15
+ require_relative 'sakusei/multi_file_builder'
16
+ require_relative 'sakusei/style_preview'
17
+ require_relative 'sakusei/vue_processor'
18
+
19
+ module Sakusei
20
+ class Error < StandardError; end
21
+
22
+ # Main entry point for building PDFs
23
+ def self.build(source_file, options = {})
24
+ Builder.new(source_file, options).build
25
+ end
26
+ end
@@ -0,0 +1,243 @@
1
+ /* Sakusei Base Styles - Applied before style pack CSS */
2
+
3
+ /* CSS Reset and Base Typography */
4
+ * {
5
+ box-sizing: border-box;
6
+ }
7
+
8
+ html {
9
+ font-size: 100%;
10
+ -webkit-print-color-adjust: exact;
11
+ print-color-adjust: exact;
12
+ }
13
+
14
+ body {
15
+ font-family:
16
+ -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu,
17
+ sans-serif;
18
+ font-size: 11pt;
19
+ line-height: 1.6;
20
+ color: #333;
21
+ margin: 0;
22
+ padding: 0;
23
+ }
24
+
25
+ /* Headings */
26
+ h1,
27
+ h2,
28
+ h3,
29
+ h4,
30
+ h5,
31
+ h6 {
32
+ font-weight: 600;
33
+ line-height: 1.3;
34
+ margin-top: 1.5em;
35
+ margin-bottom: 0.5em;
36
+ color: #222;
37
+ page-break-after: avoid;
38
+ }
39
+
40
+ h1 {
41
+ font-size: 2em;
42
+ }
43
+ h2 {
44
+ font-size: 1.5em;
45
+ }
46
+ h3 {
47
+ font-size: 1.25em;
48
+ }
49
+ h4 {
50
+ font-size: 1.1em;
51
+ }
52
+ h5 {
53
+ font-size: 1em;
54
+ }
55
+ h6 {
56
+ font-size: 0.9em;
57
+ }
58
+
59
+ /* Paragraphs */
60
+ p {
61
+ margin-top: 0;
62
+ margin-bottom: 1em;
63
+ orphans: 3;
64
+ widows: 3;
65
+ }
66
+
67
+ /* Links */
68
+ a {
69
+ color: #0366d6;
70
+ text-decoration: none;
71
+ }
72
+
73
+ a:hover {
74
+ text-decoration: underline;
75
+ }
76
+
77
+ /* Code */
78
+ code,
79
+ kbd,
80
+ pre,
81
+ samp {
82
+ font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
83
+ }
84
+
85
+ code {
86
+ background-color: rgba(175, 184, 193, 0.2);
87
+ padding: 0.2em 0.4em;
88
+ border-radius: 3px;
89
+ font-size: 0.9em;
90
+ }
91
+
92
+ pre {
93
+ background-color: #f6f8fa;
94
+ padding: 1em;
95
+ border-radius: 6px;
96
+ overflow-x: auto;
97
+ page-break-inside: avoid;
98
+ }
99
+
100
+ pre code {
101
+ background: none;
102
+ padding: 0;
103
+ font-size: 1em;
104
+ }
105
+
106
+ /* Blockquotes */
107
+ blockquote {
108
+ border-left: 4px solid #ddd;
109
+ padding-left: 1em;
110
+ margin-left: 0;
111
+ margin-right: 0;
112
+ color: #666;
113
+ page-break-inside: avoid;
114
+ }
115
+
116
+ /* Lists */
117
+ ul,
118
+ ol {
119
+ padding-left: 2em;
120
+ margin-top: 0;
121
+ margin-bottom: 1em;
122
+ }
123
+
124
+ li {
125
+ margin-bottom: 0.25em;
126
+ }
127
+
128
+ li > ul,
129
+ li > ol {
130
+ margin-top: 0.25em;
131
+ margin-bottom: 0.25em;
132
+ }
133
+
134
+ /* Tables */
135
+ table {
136
+ border-collapse: collapse;
137
+ width: 100%;
138
+ margin-bottom: 1em;
139
+ page-break-inside: avoid;
140
+ }
141
+
142
+ th,
143
+ td {
144
+ border: 1px solid #d0d7de;
145
+ padding: 0.5em;
146
+ text-align: left;
147
+ }
148
+
149
+ th {
150
+ background-color: #f6f8fa;
151
+ font-weight: 600;
152
+ }
153
+
154
+ tr {
155
+ page-break-inside: avoid;
156
+ }
157
+
158
+ /* Images */
159
+ img {
160
+ max-width: 100%;
161
+ height: auto;
162
+ page-break-inside: avoid;
163
+ }
164
+
165
+ /* Horizontal rules */
166
+ hr {
167
+ border: none;
168
+ border-top: 1px solid #ddd;
169
+ margin: 1.5em 0;
170
+ }
171
+
172
+ /* Page breaks */
173
+ .page-break,
174
+ .page-break-after {
175
+ page-break-after: always;
176
+ }
177
+
178
+ .page-break-before {
179
+ page-break-before: always;
180
+ }
181
+
182
+ /* Keep together - prevent page breaks inside these elements */
183
+ .keep-together,
184
+ figure,
185
+ figcaption,
186
+ dl,
187
+ dt,
188
+ dd,
189
+ details,
190
+ summary,
191
+ .admonition,
192
+ .callout,
193
+ .note,
194
+ .warning,
195
+ .tip,
196
+ .info,
197
+ .card,
198
+ .box,
199
+ .math,
200
+ .katex,
201
+ .katex-display {
202
+ page-break-inside: avoid;
203
+ }
204
+
205
+ /* Additional protection for table elements */
206
+ thead,
207
+ tbody,
208
+ tfoot {
209
+ page-break-inside: avoid;
210
+ }
211
+
212
+ /* Print-specific */
213
+ @media print {
214
+ body {
215
+ font-size: 10pt;
216
+ }
217
+
218
+ h1,
219
+ h2,
220
+ h3,
221
+ h4,
222
+ h5,
223
+ h6 {
224
+ page-break-after: avoid;
225
+ }
226
+
227
+ tr,
228
+ img,
229
+ pre,
230
+ blockquote {
231
+ page-break-inside: avoid;
232
+ }
233
+
234
+ a {
235
+ color: #333;
236
+ }
237
+
238
+ a[href]:after {
239
+ content: " (" attr(href) ")";
240
+ font-size: 0.8em;
241
+ color: #666;
242
+ }
243
+ }
@@ -0,0 +1,36 @@
1
+ module.exports = {
2
+ // PDF options
3
+ pdf_options: {
4
+ format: 'A4',
5
+ printBackground: true,
6
+ margin: {
7
+ top: '2cm',
8
+ right: '2cm',
9
+ bottom: '2cm',
10
+ left: '2cm'
11
+ },
12
+ displayHeaderFooter: true,
13
+ headerTemplate: `
14
+ <div style="font-size: 9px; width: 100%; text-align: center; color: #666;">
15
+ <span class="title"></span>
16
+ </div>
17
+ `,
18
+ footerTemplate: `
19
+ <div style="font-size: 9px; width: 100%; text-align: center; color: #666;">
20
+ Page <span class="pageNumber"></span> of <span class="totalPages"></span>
21
+ </div>
22
+ `
23
+ },
24
+
25
+ // Markdown-it options
26
+ md_options: {
27
+ html: true,
28
+ linkify: true,
29
+ typographer: true
30
+ },
31
+
32
+ // Launch options for Puppeteer
33
+ launch_options: {
34
+ headless: true
35
+ }
36
+ };
@@ -0,0 +1,5 @@
1
+ <!-- Custom footer template - used when configured in config.js -->
2
+ <!-- Available classes: date, title, url, pageNumber, totalPages -->
3
+ <div style="font-size: 9px; width: 100%; text-align: center; color: #666;">
4
+ Page <span class="pageNumber"></span> of <span class="totalPages"></span>
5
+ </div>
@@ -0,0 +1,5 @@
1
+ <!-- Custom header template - used when configured in config.js -->
2
+ <!-- Available classes: date, title, url, pageNumber, totalPages -->
3
+ <div style="font-size: 9px; width: 100%; text-align: center; color: #666;">
4
+ <span class="title"></span>
5
+ </div>
@@ -0,0 +1,22 @@
1
+ /* Sakusei Default Style Pack - Override styles applied on top of base */
2
+
3
+ /* Heading enhancements */
4
+ h1 {
5
+ border-bottom: 2px solid #eee;
6
+ padding-bottom: 0.3em;
7
+ }
8
+
9
+ h2 {
10
+ border-bottom: 1px solid #eee;
11
+ padding-bottom: 0.3em;
12
+ }
13
+
14
+ /* Code block styling */
15
+ code {
16
+ background-color: #f6f8fa;
17
+ }
18
+
19
+ /* Link hover enhancement */
20
+ a:hover {
21
+ text-decoration: underline;
22
+ }
data/sakusei.gemspec ADDED
@@ -0,0 +1,29 @@
1
+ require_relative 'lib/sakusei/version'
2
+
3
+ Gem::Specification.new do |spec|
4
+ spec.name = 'sakusei'
5
+ spec.version = Sakusei::VERSION
6
+ spec.authors = ['Keith Rowell']
7
+ spec.email = ['keith@example.com']
8
+
9
+ spec.summary = 'A PDF building system using Markdown, ERB, and CSS templating'
10
+ spec.description = 'Sakusei is a build system for creating PDF documents from Markdown sources with support for ERB templates, VueJS components, and hierarchical styling.'
11
+ spec.homepage = 'https://github.com/keithrowell/sakusei'
12
+ spec.license = 'MIT'
13
+
14
+ spec.required_ruby_version = '>= 3.0.0'
15
+
16
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
17
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{\A(?:test|spec|features)/}) }
18
+ end
19
+ spec.bindir = 'bin'
20
+ spec.executables = ['sakusei']
21
+ spec.require_paths = ['lib']
22
+
23
+ # Dependencies
24
+ spec.add_dependency 'thor', '~> 1.2'
25
+ spec.add_dependency 'erb', '~> 4.0'
26
+
27
+ spec.add_development_dependency 'minitest', '~> 5.0'
28
+ spec.add_development_dependency 'rubocop', '~> 1.0'
29
+ end