Module: RedQuilt::CLI

Defined in:
lib/red_quilt/cli.rb

Overview

Entry point for the ‘redquilt` executable. Defined as a module-level function so tests can drive it without shelling out.

CLI.run takes an argv-style array and an optional set of IO objects (stdin / stdout / stderr) for testability. It returns an Integer exit code: 0 on success, 1 on usage errors.

Constant Summary collapse

USAGE =
<<~USAGE
  Usage: redquilt [options] [file]

  Reads Markdown from FILE (or stdin if FILE is omitted) and writes the
  result to stdout.

  Options:
USAGE
DEFAULTS =
{
  format: :html,
  allow_html: false,
  disallow_raw_html: false,
  extended_autolinks: false,
  lint: false,
  diagnostics: false,
  diagnostics_only: false,
  standalone: true,
  auto_title: false,
  title: nil,
  lang: "en",
  css: nil,
  theme: :default,
  output: nil,
  open: false,
}.freeze
THEMES =
%i[none default].freeze
FORMATS =
%i[html ast json].freeze

Class Method Summary collapse

Class Method Details

.emit_output(doc, options, source_path:, stdout:, stderr:) ⇒ Object



78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
# File 'lib/red_quilt/cli.rb', line 78

def self.emit_output(doc, options, source_path:, stdout:, stderr:)
  destination = output_destination(options, source_path)

  case options[:format]
  when :html
    html = render_html(doc, options)
    if destination
      File.write(destination, html)
    else
      stdout.write(html)
    end
  when :ast
    require "pp"
    PP.pp(doc.to_ast, stdout)
  when :json
    stdout.puts doc.to_json
  end

  BrowserLauncher.new(err: stderr).launch(destination) if options[:open] && destination
end

.output_destination(options, source_path) ⇒ Object



99
100
101
102
103
104
105
# File 'lib/red_quilt/cli.rb', line 99

def self.output_destination(options, source_path)
  return options[:output] if options[:output]
  return nil unless options[:open]

  base = source_path ? File.basename(source_path, ".*") : "stdin"
  File.join(Dir.tmpdir, "redquilt-#{base}.html")
end

.parse_options(argv, stderr:) ⇒ Object



107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
# File 'lib/red_quilt/cli.rb', line 107

def self.parse_options(argv, stderr:)
  options = DEFAULTS.dup
  parser = OptionParser.new do |opts|
    opts.banner = USAGE
    opts.on("--format FORMAT", FORMATS, "Output format: html (default), ast, json") do |f|
      options[:format] = f
    end
    opts.on("--allow-html", "Pass raw HTML through to the output") do
      options[:allow_html] = true
    end
    opts.on("--disallow-raw-html",
            "Filter dangerous tags (script, iframe, ...) even with --allow-html (GFM)") do
      options[:disallow_raw_html] = true
    end
    opts.on("--extended-autolinks",
            "Linkify bare URLs and email addresses (GFM)") do
      options[:extended_autolinks] = true
    end
    opts.on("--lint",
            "Emit lint-style diagnostics (empty_link, missing_alt, heading_level_skip)") do
      options[:lint] = true
    end
    opts.on("--[no-]standalone",
            "Wrap (or not) the rendered HTML in a full document (default: on)") do |v|
      options[:standalone] = v
    end
    opts.on("--auto-title",
            "Use the first heading's text as <title> (standalone only)") do
      options[:auto_title] = true
    end
    opts.on("--title TITLE", "Explicit <title> text (standalone only)") do |t|
      options[:title] = t
    end
    opts.on("--lang LANG", "html lang attribute (standalone only; default \"en\")") do |l|
      options[:lang] = l
    end
    opts.on("--css URL", "Add a stylesheet link (standalone only)") do |u|
      options[:css] = u
    end
    opts.on("--theme THEME", THEMES,
            "Embedded stylesheet: default (the default) or none (bare HTML)") do |t|
      options[:theme] = t
    end
    opts.on("-o", "--output FILE", "Write HTML to FILE instead of stdout") do |f|
      options[:output] = f
    end
    opts.on("--open",
            "Write HTML to a file and open it in the default browser (forces --standalone)") do
      options[:open] = true
    end
    opts.on("--diagnostics", "Also print diagnostics to stderr") do
      options[:diagnostics] = true
    end
    opts.on("--diagnostics-only", "Print diagnostics only (suppress normal output)") do
      options[:diagnostics_only] = true
    end
    opts.on("-h", "--help", "Show this help") do
      stderr.puts opts
      return 0
    end
    opts.on("-v", "--version", "Show version") do
      stderr.puts "redquilt #{RedQuilt::VERSION}"
      return 0
    end
  end

  begin
    parser.parse!(argv)
  rescue OptionParser::ParseError => e
    stderr.puts "redquilt: #{e.message}"
    stderr.puts parser
    return 1
  end

  options
end

.read_source(argv, stdin:, stderr:) ⇒ Object



184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
# File 'lib/red_quilt/cli.rb', line 184

def self.read_source(argv, stdin:, stderr:)
  if argv.empty?
    stdin.read
  elsif argv.size == 1
    path = argv.first
    unless File.file?(path)
      stderr.puts "redquilt: no such file: #{path}"
      return nil
    end
    File.read(path)
  else
    stderr.puts "redquilt: too many arguments: #{argv.inspect}"
    nil
  end
end

.render_html(doc, options) ⇒ Object



200
201
202
203
204
205
206
207
208
209
210
# File 'lib/red_quilt/cli.rb', line 200

def self.render_html(doc, options)
  title = options[:title]
  title = doc.first_heading_text.to_s if title.nil? && options[:auto_title]
  doc.to_html(
    standalone: options[:standalone],
    title: title,
    lang: options[:lang],
    css: options[:css],
    theme: options[:theme],
  )
end

.run(argv, stdin: $stdin, stdout: $stdout, stderr: $stderr) ⇒ Object



47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
# File 'lib/red_quilt/cli.rb', line 47

def self.run(argv, stdin: $stdin, stdout: $stdout, stderr: $stderr)
  options = parse_options(argv, stderr: stderr)
  return options if options.is_a?(Integer)

  if options[:open] && options[:format] != :html
    stderr.puts "redquilt: --open requires --format html"
    return 1
  end
  options[:standalone] = true if options[:open]

  source_path = argv.first
  source = read_source(argv, stdin: stdin, stderr: stderr)
  return 1 unless source

  doc = RedQuilt.parse(source,
                       allow_html: options[:allow_html],
                       disallow_raw_html: options[:disallow_raw_html],
                       extended_autolinks: options[:extended_autolinks],
                       lint: options[:lint])

  unless options[:diagnostics_only]
    emit_output(doc, options, source_path: source_path, stdout: stdout, stderr: stderr)
  end

  if options[:diagnostics] || options[:diagnostics_only]
    write_diagnostics(doc.diagnostics, stderr)
  end

  doc.diagnostics.any? { |d| d.severity == :error } ? 1 : 0
end

.write_diagnostics(diagnostics, stderr) ⇒ Object



212
213
214
215
216
217
218
219
220
# File 'lib/red_quilt/cli.rb', line 212

def self.write_diagnostics(diagnostics, stderr)
  if diagnostics.empty?
    stderr.puts "redquilt: no diagnostics"
    return
  end
  diagnostics.each do |d|
    stderr.puts "[#{d.severity}] #{d.rule}: #{d.message}"
  end
end