sakusei 0.3.0 → 0.5.8
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/.claude/settings.json +8 -0
- data/.gitignore +1 -0
- data/CLAUDE.md +86 -0
- data/Gemfile.lock +82 -0
- data/README.md +147 -18
- data/bin/sakusei-preview +40 -0
- data/examples/getting-started-screenshot.png +0 -0
- data/examples/getting-started.md +31 -0
- data/examples/getting-started.pdf +0 -0
- data/examples/meridian-proposal-screenshot.png +0 -0
- data/examples/meridian-proposal.md +61 -0
- data/examples/meridian-proposal.pdf +0 -0
- data/lib/sakusei/builder.rb +40 -16
- data/lib/sakusei/converter_base.rb +62 -0
- data/lib/sakusei/file_resolver.rb +2 -0
- data/lib/sakusei/html_converter.rb +34 -0
- data/lib/sakusei/md_to_pdf_converter.rb +4 -66
- data/lib/sakusei/page_chrome_translator.rb +268 -0
- data/lib/sakusei/preview_server.rb +444 -0
- data/lib/sakusei/version.rb +1 -1
- data/lib/sakusei.rb +2 -0
- data/lib/templates/default_style_pack/config.js +1 -1
- data/lib/templates/default_style_pack/footer.html +1 -1
- data/sakusei.gemspec +3 -1
- metadata +45 -2
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'webrick'
|
|
4
|
+
require 'listen'
|
|
5
|
+
require_relative 'builder'
|
|
6
|
+
|
|
7
|
+
module Sakusei
|
|
8
|
+
# Live-reload preview server. Watches a markdown source and its dependencies
|
|
9
|
+
# (style pack, Vue components, @include partials, images) and re-renders to
|
|
10
|
+
# styled HTML on change. Browser long-polls /__events to know when to update;
|
|
11
|
+
# updates apply incrementally via paged.js's Previewer API (no full reload).
|
|
12
|
+
class PreviewServer
|
|
13
|
+
DEFAULT_PORT = 4567
|
|
14
|
+
DEBOUNCE_SECONDS = 0.05
|
|
15
|
+
POLL_TIMEOUT_SECONDS = 30
|
|
16
|
+
|
|
17
|
+
PAGED_JS_CDN = 'https://unpkg.com/pagedjs/dist/paged.js'
|
|
18
|
+
|
|
19
|
+
PREVIEW_CSS = <<~CSS
|
|
20
|
+
html, body {
|
|
21
|
+
background: #2a2a2a;
|
|
22
|
+
margin: 0;
|
|
23
|
+
padding: 0;
|
|
24
|
+
}
|
|
25
|
+
.pagedjs_pages {
|
|
26
|
+
padding: 24px 0;
|
|
27
|
+
}
|
|
28
|
+
.pagedjs_page {
|
|
29
|
+
background: white;
|
|
30
|
+
margin: 24px auto !important;
|
|
31
|
+
box-shadow: 0 8px 32px rgba(0,0,0,0.55);
|
|
32
|
+
}
|
|
33
|
+
#__sakusei_render {
|
|
34
|
+
min-height: 100vh;
|
|
35
|
+
}
|
|
36
|
+
#__sakusei_render.no-paged {
|
|
37
|
+
max-width: 850px;
|
|
38
|
+
margin: 24px auto;
|
|
39
|
+
padding: 48px 64px;
|
|
40
|
+
background: white;
|
|
41
|
+
box-shadow: 0 8px 32px rgba(0,0,0,0.55);
|
|
42
|
+
}
|
|
43
|
+
CSS
|
|
44
|
+
|
|
45
|
+
PREVIEW_JS = <<~'JS'
|
|
46
|
+
(function() {
|
|
47
|
+
var version = window.__SAKUSEI_VERSION__ || 0;
|
|
48
|
+
var renderTarget = null;
|
|
49
|
+
var ready = false;
|
|
50
|
+
|
|
51
|
+
function fullReload() { window.location.reload(); }
|
|
52
|
+
|
|
53
|
+
function render(html) {
|
|
54
|
+
renderTarget.innerHTML = '';
|
|
55
|
+
renderTarget.classList.remove('no-paged');
|
|
56
|
+
if (typeof Paged === 'undefined' || !Paged.Previewer) {
|
|
57
|
+
renderTarget.classList.add('no-paged');
|
|
58
|
+
renderTarget.innerHTML = html;
|
|
59
|
+
return Promise.resolve();
|
|
60
|
+
}
|
|
61
|
+
var previewer = new Paged.Previewer();
|
|
62
|
+
return previewer.preview(html, undefined, renderTarget).catch(function(e) {
|
|
63
|
+
console.error('[sakusei-preview] paged.js failed:', e);
|
|
64
|
+
renderTarget.classList.add('no-paged');
|
|
65
|
+
renderTarget.innerHTML = html;
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function init() {
|
|
70
|
+
var sourceHTML = document.body.innerHTML;
|
|
71
|
+
document.body.innerHTML = '';
|
|
72
|
+
renderTarget = document.createElement('div');
|
|
73
|
+
renderTarget.id = '__sakusei_render';
|
|
74
|
+
document.body.appendChild(renderTarget);
|
|
75
|
+
render(sourceHTML).then(function() {
|
|
76
|
+
ready = true;
|
|
77
|
+
poll();
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function refresh(newVersion) {
|
|
82
|
+
fetch('/__content').then(function(r) {
|
|
83
|
+
if (!r.ok) throw new Error('content fetch failed');
|
|
84
|
+
return r.text();
|
|
85
|
+
}).then(function(html) {
|
|
86
|
+
var scrollY = window.scrollY;
|
|
87
|
+
version = newVersion;
|
|
88
|
+
return render(html).then(function() {
|
|
89
|
+
window.scrollTo(0, scrollY);
|
|
90
|
+
poll();
|
|
91
|
+
});
|
|
92
|
+
}).catch(function(e) {
|
|
93
|
+
console.warn('[sakusei-preview] partial update failed, reloading:', e);
|
|
94
|
+
fullReload();
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function poll() {
|
|
99
|
+
if (!ready) return;
|
|
100
|
+
fetch('/__events?since=' + version).then(function(r) {
|
|
101
|
+
if (r.status === 200) {
|
|
102
|
+
return r.text().then(function(t) {
|
|
103
|
+
var v = parseInt(t, 10);
|
|
104
|
+
if (v > version) {
|
|
105
|
+
refresh(v);
|
|
106
|
+
} else {
|
|
107
|
+
poll();
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
setTimeout(poll, 50);
|
|
112
|
+
}).catch(function() {
|
|
113
|
+
setTimeout(poll, 1000);
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (document.readyState === 'loading') {
|
|
118
|
+
document.addEventListener('DOMContentLoaded', init);
|
|
119
|
+
} else {
|
|
120
|
+
init();
|
|
121
|
+
}
|
|
122
|
+
})();
|
|
123
|
+
JS
|
|
124
|
+
|
|
125
|
+
def initialize(source_file, options = {})
|
|
126
|
+
@source_file = File.expand_path(source_file)
|
|
127
|
+
raise Error, "File not found: #{source_file}" unless File.exist?(@source_file)
|
|
128
|
+
|
|
129
|
+
@options = options
|
|
130
|
+
@port = options[:port] || DEFAULT_PORT
|
|
131
|
+
@open_browser = options.fetch(:open, true)
|
|
132
|
+
@use_paged_js = options.fetch(:paged, true)
|
|
133
|
+
|
|
134
|
+
@source_dir = File.dirname(@source_file)
|
|
135
|
+
@lock = Mutex.new
|
|
136
|
+
@cv = ConditionVariable.new
|
|
137
|
+
@version = 0
|
|
138
|
+
@cached_html = nil
|
|
139
|
+
@cached_body = nil
|
|
140
|
+
@cached_error = nil
|
|
141
|
+
@style_pack_path = nil
|
|
142
|
+
|
|
143
|
+
@rebuild_lock = Mutex.new
|
|
144
|
+
@debounce_timer = nil
|
|
145
|
+
@shutting_down = false
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def run
|
|
149
|
+
build_now(initial: true)
|
|
150
|
+
start_listeners
|
|
151
|
+
start_server
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
private
|
|
155
|
+
|
|
156
|
+
def build_now(initial: false, change_detected_at: nil)
|
|
157
|
+
build_started_at = Time.now
|
|
158
|
+
builder = Builder.new(@source_file, @options)
|
|
159
|
+
html, style_pack = builder.build_html
|
|
160
|
+
build_done_at = Time.now
|
|
161
|
+
|
|
162
|
+
chrome = style_pack ? PageChromeTranslator.new(style_pack).build : { css: '', html: '' }
|
|
163
|
+
|
|
164
|
+
@style_pack_path = style_pack&.path
|
|
165
|
+
body_inner = extract_body_inner(html)
|
|
166
|
+
@cached_body = "#{chrome[:html]}#{body_inner}"
|
|
167
|
+
@cached_html = inject_preview_chrome(html, chrome)
|
|
168
|
+
@cached_error = nil
|
|
169
|
+
bump_version
|
|
170
|
+
|
|
171
|
+
log_timing(initial: initial, change_detected_at: change_detected_at,
|
|
172
|
+
build_started_at: build_started_at, build_done_at: build_done_at)
|
|
173
|
+
rescue => e
|
|
174
|
+
@cached_error = e
|
|
175
|
+
@cached_html = render_error_page(e)
|
|
176
|
+
@cached_body = error_body(e)
|
|
177
|
+
bump_version
|
|
178
|
+
$stderr.puts "[sakusei-preview] build error: #{e.message}"
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def log_timing(initial:, change_detected_at:, build_started_at:, build_done_at:)
|
|
182
|
+
build_ms = ms(build_started_at, build_done_at)
|
|
183
|
+
msg = "[sakusei-preview] #{initial ? 'initial render' : 're-rendered'} (v#{@version}) — build #{build_ms}ms"
|
|
184
|
+
if change_detected_at
|
|
185
|
+
detected_ms = ms(change_detected_at, build_started_at)
|
|
186
|
+
total_ms = ms(change_detected_at, build_done_at)
|
|
187
|
+
msg += " · detected #{detected_ms}ms · total #{total_ms}ms"
|
|
188
|
+
end
|
|
189
|
+
$stderr.puts msg
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def ms(t0, t1)
|
|
193
|
+
((t1 - t0) * 1000).round
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def bump_version
|
|
197
|
+
@lock.synchronize do
|
|
198
|
+
@version += 1
|
|
199
|
+
@cv.broadcast
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def start_listeners
|
|
204
|
+
dirs = [@source_dir]
|
|
205
|
+
dirs << @style_pack_path if @style_pack_path && !path_within?(@style_pack_path, @source_dir)
|
|
206
|
+
|
|
207
|
+
@listener = Listen.to(*dirs, ignore: [/node_modules/, /\.git/, /\.DS_Store/, /\.pdf\z/]) do |modified, added, removed|
|
|
208
|
+
changed = (modified + added + removed).reject { |p| p.end_with?('.pdf') }
|
|
209
|
+
next if changed.empty?
|
|
210
|
+
schedule_rebuild(Time.now)
|
|
211
|
+
end
|
|
212
|
+
@listener.start
|
|
213
|
+
$stderr.puts "[sakusei-preview] watching: #{dirs.join(', ')}"
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def schedule_rebuild(detected_at)
|
|
217
|
+
@rebuild_lock.synchronize do
|
|
218
|
+
@debounce_timer&.kill
|
|
219
|
+
@debounce_timer = Thread.new do
|
|
220
|
+
sleep DEBOUNCE_SECONDS
|
|
221
|
+
build_now(change_detected_at: detected_at)
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def start_server
|
|
227
|
+
server = WEBrick::HTTPServer.new(
|
|
228
|
+
Port: @port,
|
|
229
|
+
BindAddress: '127.0.0.1',
|
|
230
|
+
Logger: WEBrick::Log.new(File::NULL),
|
|
231
|
+
AccessLog: []
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
server.mount_proc('/__events') { |req, res| handle_events(req, res) }
|
|
235
|
+
server.mount_proc('/__content') { |_req, res| serve_text(res, 'text/html; charset=utf-8', @cached_body || '') }
|
|
236
|
+
server.mount_proc('/__assets/preview.js') { |_req, res| serve_text(res, 'application/javascript', PREVIEW_JS) }
|
|
237
|
+
server.mount_proc('/__pack') { |req, res| handle_pack_asset(req, res) }
|
|
238
|
+
server.mount_proc('/') { |req, res| handle_root_or_static(req, res) }
|
|
239
|
+
|
|
240
|
+
shutdown_queue = Queue.new
|
|
241
|
+
# Trap handlers run in a constrained context (no Mutex#synchronize),
|
|
242
|
+
# so just enqueue intent and let the supervisor thread do the work.
|
|
243
|
+
%w[INT TERM].each do |sig|
|
|
244
|
+
trap(sig) { shutdown_queue.push(:stop) rescue nil }
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
Thread.new do
|
|
248
|
+
shutdown_queue.pop
|
|
249
|
+
$stderr.puts "\n[sakusei-preview] shutting down..."
|
|
250
|
+
@shutting_down = true
|
|
251
|
+
# Wake any pending long-poll connections so they return immediately
|
|
252
|
+
# instead of holding the server open for up to POLL_TIMEOUT_SECONDS.
|
|
253
|
+
@lock.synchronize { @cv.broadcast }
|
|
254
|
+
@debounce_timer&.kill
|
|
255
|
+
begin
|
|
256
|
+
@listener&.stop
|
|
257
|
+
rescue StandardError
|
|
258
|
+
# Listen may raise on stop if its threads are already torn down.
|
|
259
|
+
end
|
|
260
|
+
server.shutdown
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
url = "http://127.0.0.1:#{@port}/"
|
|
264
|
+
$stderr.puts "[sakusei-preview] serving #{url}"
|
|
265
|
+
open_in_browser(url) if @open_browser
|
|
266
|
+
|
|
267
|
+
server.start
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def handle_events(req, res)
|
|
271
|
+
since = req.query['since'].to_i
|
|
272
|
+
@lock.synchronize do
|
|
273
|
+
return shutdown_response(res) if @shutting_down
|
|
274
|
+
if @version > since
|
|
275
|
+
respond_version(res)
|
|
276
|
+
return
|
|
277
|
+
end
|
|
278
|
+
@cv.wait(@lock, POLL_TIMEOUT_SECONDS)
|
|
279
|
+
if @shutting_down
|
|
280
|
+
shutdown_response(res)
|
|
281
|
+
elsif @version > since
|
|
282
|
+
respond_version(res)
|
|
283
|
+
else
|
|
284
|
+
res.status = 204
|
|
285
|
+
res.body = ''
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def shutdown_response(res)
|
|
291
|
+
res.status = 503
|
|
292
|
+
res['Content-Type'] = 'text/plain'
|
|
293
|
+
res.body = 'shutting down'
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def respond_version(res)
|
|
297
|
+
res.status = 200
|
|
298
|
+
res['Content-Type'] = 'text/plain'
|
|
299
|
+
res['Cache-Control'] = 'no-store'
|
|
300
|
+
res.body = @version.to_s
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def handle_pack_asset(req, res)
|
|
304
|
+
unless @style_pack_path
|
|
305
|
+
res.status = 404
|
|
306
|
+
res.body = 'no style pack'
|
|
307
|
+
return
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
rel = req.path.sub(%r{\A/__pack/?}, '')
|
|
311
|
+
if rel.empty?
|
|
312
|
+
res.status = 404
|
|
313
|
+
res.body = 'not found'
|
|
314
|
+
return
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
candidate = File.expand_path(rel, @style_pack_path)
|
|
318
|
+
unless candidate.start_with?(File.expand_path(@style_pack_path) + File::SEPARATOR)
|
|
319
|
+
res.status = 403
|
|
320
|
+
res.body = 'forbidden'
|
|
321
|
+
return
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
if File.file?(candidate)
|
|
325
|
+
res.status = 200
|
|
326
|
+
res['Content-Type'] = WEBrick::HTTPUtils.mime_type(candidate, WEBrick::HTTPUtils::DefaultMimeTypes)
|
|
327
|
+
res['Cache-Control'] = 'no-store'
|
|
328
|
+
res.body = File.binread(candidate)
|
|
329
|
+
else
|
|
330
|
+
res.status = 404
|
|
331
|
+
res.body = 'not found'
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
def handle_root_or_static(req, res)
|
|
336
|
+
path = req.path
|
|
337
|
+
if path == '/' || path == ''
|
|
338
|
+
serve_text(res, 'text/html; charset=utf-8', @cached_html || '<p>building...</p>')
|
|
339
|
+
return
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
rel = path.sub(%r{\A/}, '')
|
|
343
|
+
candidate = File.expand_path(rel, @source_dir)
|
|
344
|
+
unless candidate.start_with?(File.expand_path(@source_dir) + File::SEPARATOR)
|
|
345
|
+
res.status = 403
|
|
346
|
+
res.body = 'forbidden'
|
|
347
|
+
return
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
if File.file?(candidate)
|
|
351
|
+
res['Content-Type'] = WEBrick::HTTPUtils.mime_type(candidate, WEBrick::HTTPUtils::DefaultMimeTypes)
|
|
352
|
+
res['Cache-Control'] = 'no-store'
|
|
353
|
+
res.body = File.binread(candidate)
|
|
354
|
+
else
|
|
355
|
+
res.status = 404
|
|
356
|
+
res.body = 'not found'
|
|
357
|
+
end
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
def serve_text(res, content_type, body)
|
|
361
|
+
res.status = 200
|
|
362
|
+
res['Content-Type'] = content_type
|
|
363
|
+
res['Cache-Control'] = 'no-store'
|
|
364
|
+
res.body = body
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
# Extract the inner HTML of <body>...</body>. Falls back to the original
|
|
368
|
+
# html if the regex doesn't match (e.g. md-to-pdf changes its output shape).
|
|
369
|
+
def extract_body_inner(html)
|
|
370
|
+
m = html.match(/<body[^>]*>(.*)<\/body>/m)
|
|
371
|
+
m ? m[1] : html
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
def inject_preview_chrome(html, chrome = { css: '', html: '' })
|
|
375
|
+
version_script = "<script>window.__SAKUSEI_VERSION__=#{@version + 1};</script>"
|
|
376
|
+
paged = @use_paged_js ? %(<script src="#{PAGED_JS_CDN}"></script>) : ''
|
|
377
|
+
preview_js = '<script src="/__assets/preview.js"></script>'
|
|
378
|
+
style_block = "<style>#{PREVIEW_CSS}\n#{chrome[:css]}</style>"
|
|
379
|
+
|
|
380
|
+
head_injection = "#{style_block}#{paged}"
|
|
381
|
+
body_open_injection = chrome[:html].to_s
|
|
382
|
+
body_close_injection = "#{version_script}#{preview_js}"
|
|
383
|
+
|
|
384
|
+
result = if html.include?('</head>')
|
|
385
|
+
html.sub('</head>', "#{head_injection}</head>")
|
|
386
|
+
else
|
|
387
|
+
head_injection + html
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
if (m = result.match(/<body[^>]*>/))
|
|
391
|
+
result = result.sub(m[0], "#{m[0]}#{body_open_injection}")
|
|
392
|
+
else
|
|
393
|
+
result = body_open_injection + result
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
if result.include?('</body>')
|
|
397
|
+
result.sub('</body>', "#{body_close_injection}</body>")
|
|
398
|
+
else
|
|
399
|
+
result + body_close_injection
|
|
400
|
+
end
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
def error_body(err)
|
|
404
|
+
msg = WEBrick::HTMLUtils.escape(err.message)
|
|
405
|
+
backtrace = WEBrick::HTMLUtils.escape((err.backtrace || []).first(20).join("\n"))
|
|
406
|
+
<<~HTML
|
|
407
|
+
<h1 style="color:#ff6b6b;">Build failed</h1>
|
|
408
|
+
<pre style="background:#000;color:#e0e0e0;padding:1rem;overflow:auto;">#{msg}</pre>
|
|
409
|
+
<h2 style="color:#ccc;">Backtrace</h2>
|
|
410
|
+
<pre style="background:#000;color:#e0e0e0;padding:1rem;overflow:auto;">#{backtrace}</pre>
|
|
411
|
+
HTML
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
def render_error_page(err)
|
|
415
|
+
version_script = "<script>window.__SAKUSEI_VERSION__=#{@version + 1};</script>"
|
|
416
|
+
<<~HTML
|
|
417
|
+
<!DOCTYPE html>
|
|
418
|
+
<html><head><meta charset="utf-8"><title>sakusei-preview error</title>
|
|
419
|
+
<style>
|
|
420
|
+
body { font-family: ui-monospace, Menlo, monospace; padding: 2rem; background:#1e1e1e; color:#e0e0e0; }
|
|
421
|
+
#{PREVIEW_CSS}
|
|
422
|
+
</style>
|
|
423
|
+
</head><body>
|
|
424
|
+
#{error_body(err)}
|
|
425
|
+
#{version_script}
|
|
426
|
+
<script src="/__assets/preview.js"></script>
|
|
427
|
+
</body></html>
|
|
428
|
+
HTML
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
def path_within?(child, parent)
|
|
432
|
+
File.expand_path(child).start_with?(File.expand_path(parent) + File::SEPARATOR)
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
def open_in_browser(url)
|
|
436
|
+
cmd = case RbConfig::CONFIG['host_os']
|
|
437
|
+
when /darwin/i then ['open', url]
|
|
438
|
+
when /linux/i then ['xdg-open', url]
|
|
439
|
+
when /mswin|mingw|cygwin/i then ['start', url]
|
|
440
|
+
end
|
|
441
|
+
system(*cmd) if cmd
|
|
442
|
+
end
|
|
443
|
+
end
|
|
444
|
+
end
|
data/lib/sakusei/version.rb
CHANGED
data/lib/sakusei.rb
CHANGED
|
@@ -15,6 +15,8 @@ require_relative 'sakusei/pdf_concat'
|
|
|
15
15
|
require_relative 'sakusei/multi_file_builder'
|
|
16
16
|
require_relative 'sakusei/style_preview'
|
|
17
17
|
require_relative 'sakusei/vue_processor'
|
|
18
|
+
require_relative 'sakusei/html_converter'
|
|
19
|
+
require_relative 'sakusei/page_chrome_translator'
|
|
18
20
|
|
|
19
21
|
module Sakusei
|
|
20
22
|
class Error < StandardError; end
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<!-- Custom footer template - used when configured in config.js -->
|
|
2
2
|
<!-- Available classes: date, title, url, pageNumber, totalPages -->
|
|
3
3
|
<div style="font-size: 9px; width: 100%; text-align: center; color: #666;">
|
|
4
|
-
|
|
4
|
+
<span class="pageNumber"></span>
|
|
5
5
|
</div>
|
data/sakusei.gemspec
CHANGED
|
@@ -17,12 +17,14 @@ Gem::Specification.new do |spec|
|
|
|
17
17
|
`git ls-files -z`.split("\x0").reject { |f| f.match(%r{\A(?:test|spec|features)/}) }
|
|
18
18
|
end
|
|
19
19
|
spec.bindir = 'bin'
|
|
20
|
-
spec.executables = [
|
|
20
|
+
spec.executables = %w[sakusei sakusei-preview]
|
|
21
21
|
spec.require_paths = ['lib']
|
|
22
22
|
|
|
23
23
|
# Dependencies
|
|
24
24
|
spec.add_dependency 'thor', '~> 1.2'
|
|
25
25
|
spec.add_dependency 'erb', '~> 4.0'
|
|
26
|
+
spec.add_dependency 'listen', '~> 3.8'
|
|
27
|
+
spec.add_dependency 'webrick', '~> 1.8'
|
|
26
28
|
|
|
27
29
|
spec.add_development_dependency 'minitest', '~> 5.0'
|
|
28
30
|
spec.add_development_dependency 'rubocop', '~> 1.0'
|
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.5.8
|
|
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-04
|
|
11
|
+
date: 2026-05-04 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: thor
|
|
@@ -38,6 +38,34 @@ dependencies:
|
|
|
38
38
|
- - "~>"
|
|
39
39
|
- !ruby/object:Gem::Version
|
|
40
40
|
version: '4.0'
|
|
41
|
+
- !ruby/object:Gem::Dependency
|
|
42
|
+
name: listen
|
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - "~>"
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: '3.8'
|
|
48
|
+
type: :runtime
|
|
49
|
+
prerelease: false
|
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - "~>"
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '3.8'
|
|
55
|
+
- !ruby/object:Gem::Dependency
|
|
56
|
+
name: webrick
|
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
|
58
|
+
requirements:
|
|
59
|
+
- - "~>"
|
|
60
|
+
- !ruby/object:Gem::Version
|
|
61
|
+
version: '1.8'
|
|
62
|
+
type: :runtime
|
|
63
|
+
prerelease: false
|
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
65
|
+
requirements:
|
|
66
|
+
- - "~>"
|
|
67
|
+
- !ruby/object:Gem::Version
|
|
68
|
+
version: '1.8'
|
|
41
69
|
- !ruby/object:Gem::Dependency
|
|
42
70
|
name: minitest
|
|
43
71
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -72,24 +100,39 @@ email:
|
|
|
72
100
|
- keith@example.com
|
|
73
101
|
executables:
|
|
74
102
|
- sakusei
|
|
103
|
+
- sakusei-preview
|
|
75
104
|
extensions: []
|
|
76
105
|
extra_rdoc_files: []
|
|
77
106
|
files:
|
|
107
|
+
- ".claude/settings.json"
|
|
78
108
|
- ".gitignore"
|
|
109
|
+
- CLAUDE.md
|
|
79
110
|
- Gemfile
|
|
111
|
+
- Gemfile.lock
|
|
80
112
|
- README.md
|
|
81
113
|
- Rakefile
|
|
82
114
|
- bin/sakusei
|
|
115
|
+
- bin/sakusei-preview
|
|
116
|
+
- examples/getting-started-screenshot.png
|
|
117
|
+
- examples/getting-started.md
|
|
118
|
+
- examples/getting-started.pdf
|
|
119
|
+
- examples/meridian-proposal-screenshot.png
|
|
120
|
+
- examples/meridian-proposal.md
|
|
121
|
+
- examples/meridian-proposal.pdf
|
|
83
122
|
- lib/sakusei.rb
|
|
84
123
|
- lib/sakusei/builder.rb
|
|
85
124
|
- lib/sakusei/cli.rb
|
|
125
|
+
- lib/sakusei/converter_base.rb
|
|
86
126
|
- lib/sakusei/erb_processor.rb
|
|
87
127
|
- lib/sakusei/file_resolver.rb
|
|
88
128
|
- lib/sakusei/heading_wrapper.rb
|
|
129
|
+
- lib/sakusei/html_converter.rb
|
|
89
130
|
- lib/sakusei/image_path_resolver.rb
|
|
90
131
|
- lib/sakusei/md_to_pdf_converter.rb
|
|
91
132
|
- lib/sakusei/multi_file_builder.rb
|
|
133
|
+
- lib/sakusei/page_chrome_translator.rb
|
|
92
134
|
- lib/sakusei/pdf_concat.rb
|
|
135
|
+
- lib/sakusei/preview_server.rb
|
|
93
136
|
- lib/sakusei/style_pack.rb
|
|
94
137
|
- lib/sakusei/style_preview.rb
|
|
95
138
|
- lib/sakusei/version.rb
|