sakusei 0.4.0 → 0.5.9

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 51994014be2b8900d793d1954389a52a2726b54703cd60673886f7b85cfbeaa2
4
- data.tar.gz: d650c71e17c76b74f770dd32cc83b45848cacd0b1f7a8f49c3d4eb98185fee50
3
+ metadata.gz: f8f86cb0ba1fda1a5536579ffc06db171359b7d839537bef66a7622dabed68be
4
+ data.tar.gz: 1eeb757166832e05c87fa8ae7d8a654a4cf2c5abab097b82e4466245d99830d1
5
5
  SHA512:
6
- metadata.gz: 0c8a398207d15178355e2da253dd4e3d20e9e524a044125214e7c074f264892a5ae9749b2773cf42c4de353532d731a8798d52eb922da38f6d0e5e5185940f8c
7
- data.tar.gz: 0b8970fdb542f9fe5f2b81bda13019be3893501735c62962f7a66e60c2acc8f38672ccf74fc4df08ece6b149796d84da52a4207b1ccde31793d079856c426ce7
6
+ metadata.gz: e6d8145acd491414bdaea711ba249557aa887a049b0d7c59788825d1b8946cb93f33a4f5e87be5b3843f2ef8703577fe34dd7c0ec7681f9b55216c897e3aad17
7
+ data.tar.gz: 07ead103bda8c9d269501f6384f2d42d37efaf718f66237b006a0dc2430974535696d3eb1e1fb1b46b82683afa0eed437dd6a0a590692c01b6fd53f42f0daafa
@@ -0,0 +1,8 @@
1
+ {
2
+ "permissions": {
3
+ "defaultMode": "auto",
4
+ "allow": [
5
+ "Bash(git *)"
6
+ ]
7
+ }
8
+ }
data/CLAUDE.md ADDED
@@ -0,0 +1,86 @@
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ ## Commands
6
+
7
+ Run all tests:
8
+
9
+ ```bash
10
+ bundle exec rake test
11
+ ```
12
+
13
+ Run a single test file:
14
+
15
+ ```bash
16
+ ruby -Ilib -Itest test/sakusei/test_builder_break_syntax.rb
17
+ ```
18
+
19
+ Build and install the gem locally:
20
+
21
+ ```bash
22
+ rake install
23
+ ```
24
+
25
+ Build a PDF from one of the examples:
26
+
27
+ ```bash
28
+ bundle exec sakusei build examples/getting-started.md
29
+ ```
30
+
31
+ Live-preview a markdown file in the browser:
32
+
33
+ ```bash
34
+ bundle exec sakusei-preview examples/getting-started.md
35
+ ```
36
+
37
+ ## Architecture
38
+
39
+ Sakusei ships two CLIs:
40
+
41
+ - `bin/sakusei` (Thor) — the build CLI that produces PDFs.
42
+ - `bin/sakusei-preview` — a separate live-render binary backed by `Sakusei::PreviewServer`. Watches the source file, its `@include` partials, the active style pack, and referenced images; re-renders to HTML on change via `npx md-to-pdf --as-html` and reloads the browser tab. Loads paged.js from a CDN so `@page`/`page-break-*` rules are visible as document pages. Use this for fast iteration; use `sakusei build` for the final PDF.
43
+
44
+ Both CLIs share the multi-stage build pipeline.
45
+
46
+ ### Build pipeline
47
+
48
+ `Builder#build` in `lib/sakusei/builder.rb` runs these steps in order:
49
+
50
+ 1. **StylePack.discover** — walks up the directory tree from the source file, looking for `.sakusei/style_packs/<name>/` at each level. Falls back to the built-in pack at `lib/templates/default_style_pack/`.
51
+ 2. **FileResolver** — resolves `<!-- @include ./file.md -->` directives and concatenates the content.
52
+ 3. **ErbProcessor** — evaluates ERB in the markdown. Available helpers: `today(format)`, `env(name, default)`, `sh(command)`, `include_file(path)`, `image_path(relative_path)`, `document_headings(path)`.
53
+ 4. **expand_break_syntax** — expands `::break::` shorthand to `<div class="page-break"></div>`.
54
+ 5. **VueProcessor** — finds `<vue-component name="Foo" prop="value" />` tags, renders them server-side via `lib/sakusei/vue_renderer.js` using Node.js + `@vue/server-renderer` in a single batched call.
55
+ 6. **HeadingWrapper** — wraps h2/h3 headings with their immediately following content block in keep-together divs to prevent orphaned headings.
56
+ 7. **MdToPdfConverter** / **HtmlConverter** — both extend `ConverterBase` (`lib/sakusei/converter_base.rb`) and assemble the `npx md-to-pdf` command with config, stylesheets, and header/footer from the style pack. `MdToPdfConverter` runs it in a temp dir and returns the PDF path; `HtmlConverter` adds `--as-html` and returns the HTML string (used by the live preview server).
57
+
58
+ `Builder#build_html` runs stages 1–6 and ends in `HtmlConverter`, sharing the pipeline with `Builder#build` so the preview matches the final PDF.
59
+
60
+ `MultiFileBuilder` handles glob patterns and multiple source files, delegating to `Builder` per file and concatenating results.
61
+
62
+ ### Style packs
63
+
64
+ A style pack is a directory containing:
65
+
66
+ - `config.js` — md-to-pdf configuration (Puppeteer/Chrome options)
67
+ - `style.css` — stylesheet applied after `lib/templates/base.css`
68
+ - `header.html`, `footer.html` — Puppeteer page chrome injected before the markdown content
69
+ - `components/*.vue` — Vue 3 SFCs, rendered server-side at build time (optional)
70
+ - `package.json` — if present, npm dependencies are auto-installed on first use
71
+
72
+ `lib/templates/base.css` is always applied first and provides keep-together rules for tables, code blocks, blockquotes, images, and common custom classes.
73
+
74
+ Style pack discovery walks up the directory tree from the source file; a named pack can live in any ancestor's `.sakusei/` directory. The default pack in `lib/templates/default_style_pack/` is the final fallback.
75
+
76
+ ### Vue component system
77
+
78
+ Components are referenced in markdown as `<vue-component name="MyComponent" prop="value" />`. `VueProcessor` finds all such tags in a first pass, replaces them with numbered placeholders, then sends a single JSON batch to `vue_renderer.js` via stdin. The JS renderer uses `@vue/server-renderer` to render each component and returns HTML + scoped CSS back as JSON. Scoped CSS is injected as a `<style>` block at the top of the document.
79
+
80
+ Component resolution order: local `./components/<Name>.vue` → style pack `components/<Name>.vue`.
81
+
82
+ Vue components support named slots via `<template #slotname>...</template>` inside the tag. Slot content is converted from Markdown to HTML before being passed to the renderer.
83
+
84
+ ### Tests
85
+
86
+ Tests use Minitest. Fixtures live in `test/fixtures/` (markdown samples and stub style packs). `test/test_vue_renderer.js` is a standalone Node.js test for the JS renderer. The base test class `Sakusei::TestCase` provides `fixtures_dir` and a `temp_dir` that auto-cleans on teardown.
data/Gemfile.lock ADDED
@@ -0,0 +1,82 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ sakusei (0.5.9)
5
+ erb (~> 4.0)
6
+ listen (~> 3.8)
7
+ thor (~> 1.2)
8
+ webrick (~> 1.8)
9
+
10
+ GEM
11
+ remote: https://rubygems.org/
12
+ specs:
13
+ addressable (2.8.9)
14
+ public_suffix (>= 2.0.2, < 8.0)
15
+ ast (2.4.3)
16
+ bigdecimal (4.0.1)
17
+ cgi (0.5.1)
18
+ erb (4.0.3)
19
+ cgi (>= 0.3.3)
20
+ ffi (1.17.4)
21
+ ffi (1.17.4-arm64-darwin)
22
+ json (2.19.2)
23
+ json-schema (6.2.0)
24
+ addressable (~> 2.8)
25
+ bigdecimal (>= 3.1, < 5)
26
+ language_server-protocol (3.17.0.5)
27
+ lint_roller (1.1.0)
28
+ listen (3.10.0)
29
+ logger
30
+ rb-fsevent (~> 0.10, >= 0.10.3)
31
+ rb-inotify (~> 0.9, >= 0.9.10)
32
+ logger (1.7.0)
33
+ mcp (0.9.0)
34
+ json-schema (>= 4.1)
35
+ minitest (5.27.0)
36
+ parallel (1.27.0)
37
+ parser (3.3.10.2)
38
+ ast (~> 2.4.1)
39
+ racc
40
+ prism (1.9.0)
41
+ public_suffix (7.0.5)
42
+ racc (1.8.1)
43
+ rainbow (3.1.1)
44
+ rake (13.3.1)
45
+ rb-fsevent (0.11.2)
46
+ rb-inotify (0.11.1)
47
+ ffi (~> 1.0)
48
+ regexp_parser (2.11.3)
49
+ rubocop (1.85.1)
50
+ json (~> 2.3)
51
+ language_server-protocol (~> 3.17.0.2)
52
+ lint_roller (~> 1.1.0)
53
+ mcp (~> 0.6)
54
+ parallel (~> 1.10)
55
+ parser (>= 3.3.0.2)
56
+ rainbow (>= 2.2.2, < 4.0)
57
+ regexp_parser (>= 2.9.3, < 3.0)
58
+ rubocop-ast (>= 1.49.0, < 2.0)
59
+ ruby-progressbar (~> 1.7)
60
+ unicode-display_width (>= 2.4.0, < 4.0)
61
+ rubocop-ast (1.49.1)
62
+ parser (>= 3.3.7.2)
63
+ prism (~> 1.7)
64
+ ruby-progressbar (1.13.0)
65
+ thor (1.5.0)
66
+ unicode-display_width (3.2.0)
67
+ unicode-emoji (~> 4.1)
68
+ unicode-emoji (4.2.0)
69
+ webrick (1.9.2)
70
+
71
+ PLATFORMS
72
+ arm64-darwin-24
73
+ ruby
74
+
75
+ DEPENDENCIES
76
+ minitest (~> 5.0)
77
+ rake (~> 13.0)
78
+ rubocop (~> 1.0)
79
+ sakusei!
80
+
81
+ BUNDLED WITH
82
+ 2.7.2
@@ -0,0 +1,40 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'optparse'
5
+ require 'sakusei'
6
+ require 'sakusei/preview_server'
7
+
8
+ options = { open: true, paged: true }
9
+ parser = OptionParser.new do |opts|
10
+ opts.banner = 'Usage: sakusei-preview FILE [options]'
11
+
12
+ opts.on('-p', '--port PORT', Integer, "Port to bind (default #{Sakusei::PreviewServer::DEFAULT_PORT})") { |v| options[:port] = v }
13
+ opts.on('-s', '--style PACK', 'Style pack name to use') { |v| options[:style] = v }
14
+ opts.on('-c', '--config FILE', 'Path to md-to-pdf config file') { |v| options[:config] = v }
15
+ opts.on('--stylesheet FILE', 'Path to CSS stylesheet') { |v| options[:stylesheet] = v }
16
+ opts.on('--no-open', 'Do not open the browser automatically') { options[:open] = false }
17
+ opts.on('--no-paged', 'Disable paged.js pagination (show as scrolling HTML)') { options[:paged] = false }
18
+ opts.on('-h', '--help', 'Show this help') { puts opts; exit 0 }
19
+ end
20
+
21
+ args = parser.parse(ARGV)
22
+ if args.empty?
23
+ warn parser
24
+ exit 1
25
+ end
26
+
27
+ source_file = Sakusei.resolve_file_extension(args.first)
28
+ unless File.exist?(source_file)
29
+ warn "File not found: #{args.first}"
30
+ exit 1
31
+ end
32
+
33
+ begin
34
+ Sakusei::PreviewServer.new(source_file, options).run
35
+ rescue Interrupt
36
+ exit 0
37
+ rescue Sakusei::Error => e
38
+ warn "[sakusei-preview] #{e.message}"
39
+ exit 1
40
+ end
@@ -7,6 +7,7 @@ require_relative 'image_path_resolver'
7
7
  require_relative 'vue_processor'
8
8
  require_relative 'heading_wrapper'
9
9
  require_relative 'md_to_pdf_converter'
10
+ require_relative 'html_converter'
10
11
 
11
12
  module Sakusei
12
13
  class Builder
@@ -14,48 +15,64 @@ module Sakusei
14
15
  @source_file = File.expand_path(source_file)
15
16
  @options = options
16
17
  @source_dir = File.dirname(@source_file)
18
+ @file_resolver = nil
19
+ end
20
+
21
+ attr_reader :source_file, :source_dir
22
+
23
+ # Files that contributed to the most recent build (source + @include partials).
24
+ # Populated after #build, #build_html, or #build_processed_content runs.
25
+ def resolved_input_files
26
+ files = [@source_file]
27
+ files.concat(@file_resolver.resolved_files.to_a) if @file_resolver
28
+ files.uniq
17
29
  end
18
30
 
19
31
  def build
20
- # 1. Discover and load style pack
32
+ style_pack, processed_content = build_processed_content
33
+ $stderr.puts "[sakusei] converting to PDF..."
34
+ output_path = generate_output_path
35
+ convert_to_pdf(processed_content, output_path, style_pack)
36
+ $stderr.puts "[sakusei] written: #{output_path}"
37
+ output_path
38
+ end
39
+
40
+ # Run the full pipeline up to (but not including) PDF conversion, then
41
+ # render the processed markdown to a styled HTML string via md-to-pdf --as-html.
42
+ # Returns [html_string, style_pack].
43
+ def build_html
44
+ style_pack, processed_content = build_processed_content
45
+ html = HtmlConverter.new(processed_content, style_pack, @options.merge(source_dir: @source_dir)).convert
46
+ [html, style_pack]
47
+ end
48
+
49
+ private
50
+
51
+ def build_processed_content
21
52
  $stderr.puts "[sakusei] discovering style pack..."
22
53
  style_pack = discover_style_pack
23
54
  $stderr.puts "[sakusei] style pack: #{style_pack.name} (#{style_pack.path})"
24
55
 
25
- # 2. Resolve and concatenate file references
26
56
  $stderr.puts "[sakusei] resolving file includes..."
27
57
  resolved_content = resolve_files
28
58
 
29
- # 3. Process ERB templates
30
59
  $stderr.puts "[sakusei] processing ERB..."
31
60
  processed_content = process_erb(resolved_content)
32
61
 
33
- # 3.5. Expand shorthand syntax (e.g. ::break:: → page-break div)
34
62
  processed_content = expand_break_syntax(processed_content)
35
-
36
- # 4. Process Vue components (if available)
37
63
  processed_content = process_vue(processed_content, style_pack)
38
-
39
- # 4.5 Wrap h2/h3 headings with their following block to prevent orphaned headings
40
64
  processed_content = wrap_headings(processed_content)
41
65
 
42
- # 5. Convert to PDF
43
- $stderr.puts "[sakusei] converting to PDF..."
44
- output_path = generate_output_path
45
- convert_to_pdf(processed_content, output_path, style_pack)
46
-
47
- $stderr.puts "[sakusei] written: #{output_path}"
48
- output_path
66
+ [style_pack, processed_content]
49
67
  end
50
68
 
51
- private
52
-
53
69
  def discover_style_pack
54
70
  StylePack.discover(@options[:source_dir] || @source_dir, @options[:style])
55
71
  end
56
72
 
57
73
  def resolve_files
58
- FileResolver.new(@source_file).resolve
74
+ @file_resolver = FileResolver.new(@source_file)
75
+ @file_resolver.resolve
59
76
  end
60
77
 
61
78
  def process_erb(content)
data/lib/sakusei/cli.rb CHANGED
@@ -242,26 +242,14 @@ module Sakusei
242
242
  return false if arg.nil? || arg.empty?
243
243
  return false if File.extname(arg).length > 0 # Already has an extension
244
244
 
245
- %w[.md .text .markdown].any? { |ext| File.exist?(arg + ext) }
245
+ Sakusei::MARKDOWN_EXTENSIONS.any? { |ext| File.exist?(arg + ext) }
246
246
  end
247
247
 
248
248
  private
249
249
 
250
250
  # Resolve file by trying markdown extensions if no extension provided
251
251
  def resolve_file_extension(file)
252
- return file if File.exist?(file)
253
- return file if File.directory?(file)
254
- return file if file.include?('*') # Glob pattern
255
- return file if File.extname(file).length > 0 # Already has extension
256
-
257
- # Try markdown extensions
258
- %w[.md .text .markdown].each do |ext|
259
- path_with_ext = file + ext
260
- return path_with_ext if File.exist?(path_with_ext)
261
- end
262
-
263
- # Return original if no extension found
264
- file
252
+ Sakusei.resolve_file_extension(file)
265
253
  end
266
254
 
267
255
  def open_pdf(path)
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+
5
+ module Sakusei
6
+ class ConverterBase
7
+ def initialize(content, style_pack, options = {})
8
+ @content = content
9
+ @style_pack = style_pack
10
+ @options = options
11
+ @source_dir = options[:source_dir]
12
+ end
13
+
14
+ protected
15
+
16
+ def copy_images(temp_dir)
17
+ return unless @source_dir
18
+
19
+ image_paths.each do |rel_path|
20
+ src = File.expand_path(rel_path, @source_dir)
21
+ next unless File.exist?(src)
22
+
23
+ dest = File.join(temp_dir, rel_path)
24
+ FileUtils.mkdir_p(File.dirname(dest))
25
+ FileUtils.cp(src, dest)
26
+ end
27
+ end
28
+
29
+ def image_paths
30
+ paths = []
31
+ @content.scan(/!\[[^\]]*\]\(([^)]+)\)/) { |m| paths << m[0].strip }
32
+ @content.scan(/src="([^"]+)"/) { |m| paths << m[0].strip }
33
+ paths.reject { |p| p.match?(/\A(https?:|data:|\/\/)/) || p.start_with?('/') }
34
+ .uniq
35
+ end
36
+
37
+ def page_chrome_prefix
38
+ return '' unless @style_pack
39
+ %i[header footer].map do |part|
40
+ path = @style_pack.public_send(part)
41
+ path ? File.read(path) + "\n" : ''
42
+ end.join
43
+ end
44
+
45
+ def build_command(temp_md, temp_dir, extra_flags = [])
46
+ cmd = ['npx', 'md-to-pdf']
47
+
48
+ config = @options[:config] || @style_pack&.config
49
+ cmd << '--config-file' << config if config
50
+
51
+ stylesheets = [StylePack.base_stylesheet]
52
+ pack_stylesheet = @options[:stylesheet] || @style_pack&.stylesheet
53
+ stylesheets << pack_stylesheet if pack_stylesheet
54
+ stylesheets.each { |s| cmd << '--stylesheet' << s }
55
+
56
+ cmd << '--basedir' << temp_dir
57
+ extra_flags.each { |f| cmd << f }
58
+ cmd << temp_md
59
+ cmd
60
+ end
61
+ end
62
+ end
@@ -5,6 +5,8 @@ module Sakusei
5
5
  class FileResolver
6
6
  INCLUDE_PATTERN = /<!--\s*@include\s+(\S+)\s*-->/
7
7
 
8
+ attr_reader :resolved_files
9
+
8
10
  def initialize(source_file)
9
11
  @source_file = source_file
10
12
  @source_dir = File.dirname(source_file)
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+ require 'tmpdir'
5
+ require_relative 'converter_base'
6
+
7
+ module Sakusei
8
+ # Converts markdown content to a styled HTML string by invoking
9
+ # `npx md-to-pdf --as-html`. Used by the live preview server.
10
+ class HtmlConverter < ConverterBase
11
+ def convert
12
+ Dir.mktmpdir('sakusei-html') do |temp_dir|
13
+ temp_md = File.join(temp_dir, 'input.md')
14
+ # Header/footer files are injected as @page margin chrome by PreviewServer.
15
+ # Don't prepend them to the body the way MdToPdfConverter does for PDF output.
16
+ File.write(temp_md, @content)
17
+
18
+ copy_images(temp_dir)
19
+
20
+ cmd = build_command(temp_md, temp_dir, ['--as-html'])
21
+ stdout, stderr, status = Open3.capture3(*cmd)
22
+
23
+ unless status.success?
24
+ raise Error, "HTML conversion failed: #{stderr.strip}\n#{stdout.strip}"
25
+ end
26
+
27
+ html_path = File.join(temp_dir, 'input.html')
28
+ raise Error, "md-to-pdf did not produce HTML at #{html_path}" unless File.exist?(html_path)
29
+
30
+ File.read(html_path)
31
+ end
32
+ end
33
+ end
34
+ end
@@ -2,16 +2,14 @@
2
2
 
3
3
  require 'fileutils'
4
4
  require 'tmpdir'
5
+ require_relative 'converter_base'
5
6
 
6
7
  module Sakusei
7
8
  # Converts markdown content to PDF using md-to-pdf
8
- class MdToPdfConverter
9
+ class MdToPdfConverter < ConverterBase
9
10
  def initialize(content, output_path, style_pack, options = {})
10
- @content = content
11
+ super(content, style_pack, options)
11
12
  @output_path = output_path
12
- @style_pack = style_pack
13
- @options = options
14
- @source_dir = options[:source_dir]
15
13
  end
16
14
 
17
15
  def convert
@@ -24,7 +22,7 @@ module Sakusei
24
22
  # so md-to-pdf's HTTP server can serve them by their relative paths.
25
23
  copy_images(temp_dir)
26
24
 
27
- cmd = build_command(temp_md, temp_dir)
25
+ cmd = build_command(temp_md, temp_dir).join(' ')
28
26
  result = system(cmd)
29
27
  raise Error, 'PDF conversion failed' unless result
30
28
 
@@ -33,65 +31,5 @@ module Sakusei
33
31
 
34
32
  @output_path
35
33
  end
36
-
37
- private
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: ![alt](path)
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
-
77
- def build_command(temp_path, temp_dir)
78
- cmd = ['npx', 'md-to-pdf']
79
-
80
- config = @options[:config] || @style_pack.config
81
- cmd << '--config-file' << config if config
82
-
83
- stylesheets = [StylePack.base_stylesheet]
84
- pack_stylesheet = @options[:stylesheet] || @style_pack.stylesheet
85
- stylesheets << pack_stylesheet if pack_stylesheet
86
- stylesheets.each { |s| cmd << '--stylesheet' << s }
87
-
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.
91
- cmd << '--basedir' << temp_dir
92
-
93
- cmd << temp_path
94
- cmd.join(' ')
95
- end
96
34
  end
97
35
  end