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: nil,
  css: nil,
  theme: :default,
  output: nil,
  open: false,
  mermaid: false,
  frontmatter: 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



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

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



102
103
104
105
106
107
108
# File 'lib/red_quilt/cli.rb', line 102

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



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
183
184
185
186
187
188
189
190
191
192
193
# File 'lib/red_quilt/cli.rb', line 110

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("--mermaid",
            "Render `mermaid` code blocks as diagrams (loads mermaid.js from a CDN in standalone output)") do
      options[:mermaid] = true
    end
    opts.on("--frontmatter",
            "Parse leading YAML frontmatter (---) as metadata; fills <title>/lang in standalone output") do
      options[:frontmatter] = 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



195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
# File 'lib/red_quilt/cli.rb', line 195

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



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

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],
    mermaid: options[:mermaid],
  )
end

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



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
77
78
79
# File 'lib/red_quilt/cli.rb', line 49

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],
                       frontmatter: options[:frontmatter])

  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



224
225
226
227
228
229
230
231
232
# File 'lib/red_quilt/cli.rb', line 224

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