red_quilt 0.7.0 → 0.7.2

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,316 @@
1
+ # RedQuilt CommonMark Conformance
2
+
3
+ ## 1. Scope of this document
4
+
5
+ This document describes how RedQuilt differs from the CommonMark / GFM spec.
6
+ For behavior that follows the spec, refer directly to the spec documents
7
+ (<https://spec.commonmark.org/0.31.2/>, <https://github.github.com/gfm/>); this
8
+ document does not repeat them.
9
+
10
+ #### What this document covers
11
+
12
+ - Places where the implementation **narrows** what the spec allows (interpreting
13
+ or tightening ambiguous areas).
14
+ - Features outside the spec (security, diagnostics, option flags).
15
+ - The extensions that are **enabled** (GFM, etc.) and their opt-in conditions.
16
+ - Unsupported features and known limitations.
17
+
18
+ #### What this document does not cover
19
+
20
+ - Descriptions of standard behavior that matches the spec.
21
+ - Design background or data structure choices.
22
+
23
+ ### 1.1 Target versions
24
+
25
+ - CommonMark: 0.31.2
26
+ - GitHub Flavored Markdown: 0.29-gfm
27
+
28
+ ### 1.2 Implementation assumptions
29
+
30
+ - Input is a UTF-8 string. Preprocessing such as `force_encoding(Encoding::UTF_8)`
31
+ is the caller's responsibility.
32
+ - The normalization required by spec §2.3 / §2.4 (NUL -> U+FFFD,
33
+ `\r\n` / `\r` -> `\n`) and limiting the blank-line definition to space/tab are
34
+ all implemented. These follow the spec, so this document does not list them
35
+ individually.
36
+
37
+ ### 1.3 Format of each item
38
+
39
+ ```
40
+ ### N.N <Title>
41
+
42
+ **Spec**: the relevant section and the spec rule (or ambiguity)
43
+ **RedQuilt behavior**: how the implementation behaves / where it narrows or extends
44
+ **Implementation**: file:line / main symbols
45
+ **Test**: spec file / example number
46
+ ```
47
+
48
+ ## 2. Points where the spec is tightened
49
+
50
+ Where the spec wording allows more than one interpretation, or where a "must" is
51
+ left ambiguous, the implementation chooses the stricter side.
52
+
53
+ ### 2.1 URI autolink rejects U+007F (DEL)
54
+
55
+ **Spec**: §6.5 — a URI autolink does not contain "ASCII control characters,
56
+ space, `<`, `>`". Whether the range of "ASCII control characters" is only
57
+ U+0000–U+001F or also includes U+007F is not stated.
58
+
59
+ **RedQuilt behavior**: also rejects U+007F.
60
+
61
+ **Implementation**: `lib/red_quilt/inline/lexer.rb` — `URI_AUTOLINK_RE`
62
+
63
+ **Test**: `spec/whitespace_strictness_spec.rb` — "URI autolink (CommonMark 6.5)"
64
+
65
+ ### 2.2 Raw HTML tag separators limited to space/tab/CR/LF
66
+
67
+ **Spec**: §6.6 — defines the separators between attributes and around `=` as
68
+ "whitespace". In the spec's terminology (§2.1), the "whitespace" set is broad and
69
+ includes space / tab / newline / line tabulation (U+000B) / form feed (U+000C) /
70
+ carriage return.
71
+
72
+ **RedQuilt behavior**: within the tag grammar, only `[ \t\r\n]` is allowed as a
73
+ separator. FF (U+000C) / VT (U+000B) are not included. The same constraint
74
+ applies to inline raw HTML and to HTML block types 1 / 6 / 7.
75
+
76
+ **Implementation**:
77
+ - Inline: `lib/red_quilt/inline/lexer.rb` — `HTML_OPEN_TAG_RE` /
78
+ `HTML_CLOSING_TAG_RE`
79
+ - Block: `lib/red_quilt/block_parser.rb` — `HTML_TYPE_7_OPEN_TAG_RE` /
80
+ `HTML_TYPE_7_CLOSING_TAG_RE` / `HTML_BLOCK_TYPE_6_RE` / type 1 regex
81
+
82
+ **Test**: `spec/whitespace_strictness_spec.rb` — "raw HTML tag whitespace
83
+ (CommonMark 6.6)"
84
+
85
+ ### 2.3 Inline link tail separators limited to space/tab/at most 1 LF
86
+
87
+ **Spec**: §6.3 — the link tail (inside `(dest "title")`) is separated by "spaces,
88
+ tabs, and up to one line ending". FF / VT are not mentioned.
89
+
90
+ **RedQuilt behavior**: within the link tail, only space / tab are separators, and
91
+ a line ending is counted separately, up to one. If FF / VT appears, it does not
92
+ form a link (it is treated as normal paragraph text).
93
+
94
+ **Implementation**: `lib/red_quilt/inline/link_scanner.rb` —
95
+ `link_tail_whitespace_byte?`, `skip_link_whitespace`, `inline_link`,
96
+ `parse_link_title`
97
+
98
+ **Test**: `spec/whitespace_strictness_spec.rb` — "inline link tail whitespace
99
+ (CommonMark 6.3)"
100
+
101
+ ### 2.4 Reference definition raw destination validated the same as inline links
102
+
103
+ **Spec**: §6.3 — the raw form of a link destination is "a nonempty sequence of
104
+ characters that does not start with `<`, does not include ASCII control
105
+ characters or space character, and includes parentheses only if (a) they are
106
+ backslash-escaped or (b) they are part of a balanced pair of unescaped
107
+ parentheses".
108
+
109
+ **RedQuilt behavior**: validates all of the above for the raw destination of a
110
+ reference definition too. Specifically, it rejects ASCII control
111
+ (U+0000–U+001F) / U+007F (DEL) / space, and tracks the depth of unescaped
112
+ parens, invalidating the definition if they are unbalanced.
113
+
114
+ **Past behavior**: it accepted destinations with a simple `/\A(\S+)(.*)\z/`, so
115
+ `[x]: foo(bar` or `[x]: foo\bbar` were also accepted as definitions.
116
+
117
+ **Implementation**: `lib/red_quilt/reference_definition.rb` —
118
+ `parse_raw_destination`, `RAW_DEST_FORBIDDEN_RE`
119
+
120
+ **Test**: `spec/link_validation_spec.rb` — "reference definition raw destination
121
+ validation"
122
+
123
+ ### 2.5 Apply the 999-character link label limit on all paths
124
+
125
+ **Spec**: §6.3 — "A link label can have at most 999 characters inside the square
126
+ brackets."
127
+
128
+ **RedQuilt behavior**: rejects more than 999 characters on both the reference
129
+ definition side and the reference link usage side (shortcut / collapsed / full,
130
+ all of them).
131
+
132
+ **Implementation**:
133
+ - Constant: `lib/red_quilt/reference_definition.rb` —
134
+ `LABEL_MAX_LENGTH = 999`, the `label_too_long?` helper
135
+ - Definition side: `match_label` (decides for both single-line and multi-line)
136
+ - Usage side: `lib/red_quilt/inline/builder.rb` — `try_reference_link`,
137
+ `lib/red_quilt/inline/link_scanner.rb` — `reference_label`
138
+
139
+ **Test**: `spec/link_validation_spec.rb` — "link label length limit (999
140
+ characters)"
141
+
142
+ ### 2.6 NCR digit limits and U+FFFD replacement of invalid codepoints
143
+
144
+ **Spec**: §6.4 — a decimal NCR is 1–7 digits, a hex NCR is 1–6 digits. If the
145
+ decode result is U+0000, a surrogate (U+D800–U+DFFF), or out of the Unicode range
146
+ (> U+10FFFF), it is replaced with U+FFFD.
147
+
148
+ **RedQuilt behavior**: implements all of the above.
149
+
150
+ **Past behavior**: it delegated to `CGI.unescapeHTML`, so an 8-digit decimal like
151
+ `&#00000065;` or a surrogate like `&#xD800;` would each decode to "A" or raise a
152
+ `RangeError`.
153
+
154
+ **Implementation**: `lib/red_quilt/inline/html_entities.rb` —
155
+ `Inline.decode_entity`, `Inline::ENTITY_RE`, `decode_numeric_codepoint`. The
156
+ `SURROGATE_RANGE` and `MAX_UNICODE_CODEPOINT` constants.
157
+
158
+ **Test**: `spec/numeric_character_reference_spec.rb`
159
+
160
+ ### 2.7 GFM table header / delimiter cell-count match requirement
161
+
162
+ **Spec (GFM §4.10)**: "The header row must match the delimiter row in the number
163
+ of cells. If not, a table will not be recognized."
164
+
165
+ **RedQuilt behavior**: if the cell count of the header and the delimiter do not
166
+ match, it is not recognized as a table and is treated as a paragraph.
167
+
168
+ **Implementation**: `lib/red_quilt/block_parser.rb` — `table_start?`
169
+
170
+ **Test**: `spec/red_quilt_spec.rb` — "table separator validation (GFM spec)"
171
+
172
+ ### 2.8 GFM extended autolink domain underscore constraint
173
+
174
+ **Spec (GFM §6.9)**: "If the domain name contains an underscore (`_`) in its last
175
+ two segments, it is invalid."
176
+
177
+ **RedQuilt behavior**: when extended autolinks are enabled, a URL / email whose
178
+ domain has `_` in its last two segments is not linkified.
179
+
180
+ **Implementation**: `lib/red_quilt/extended_autolink_pass.rb` — `valid_domain?` /
181
+ `extract_domain`
182
+
183
+ **Test**: `spec/extended_autolink_spec.rb` — "domain validation (GFM spec)"
184
+
185
+ ## 3. Features outside the spec
186
+
187
+ Features not defined in the spec that RedQuilt provides for safety and
188
+ convenience.
189
+
190
+ ### 3.1 Sanitizing unsafe URL schemes
191
+
192
+ **RedQuilt behavior**: if the scheme of a link / image destination is not in the
193
+ safe list below, it outputs `href` / `src` as an empty string. At the same time
194
+ it emits an `:unsafe_url` diagnostic as a warning. For CommonMark autolinks
195
+ (`<scheme:...>`), to stay spec-conformant, a denylist is used instead of a safe
196
+ list, and only schemes that could lead to script execution get an empty href.
197
+
198
+ **Safe schemes**: `http`, `https`, `mailto`, `ftp`, `tel`, `ssh`
199
+
200
+ **Schemes blocked in autolinks**: `javascript`, `vbscript`, `data`
201
+
202
+ **Implementation**: `lib/red_quilt/inline/builder.rb` — `SAFE_SCHEMES`,
203
+ `UNSAFE_AUTOLINK_SCHEMES`, `sanitize_destination`, `block_unsafe_autolink`
204
+
205
+ **Test**: `spec/red_quilt_spec.rb` — "sanitizes unsafe URL schemes"
206
+
207
+ ### 3.2 Diagnostics
208
+
209
+ **RedQuilt behavior**: suspicious syntax, missing references, and potential
210
+ security events detected during parse / render are accumulated in
211
+ `Document#diagnostics` as `RedQuilt::Diagnostic` objects. Processing is never
212
+ interrupted (a tree and HTML are always returned).
213
+
214
+ **Rules currently emitted**:
215
+
216
+ | Rule | Severity | Description |
217
+ |---|---|---|
218
+ | `:missing_reference` | warning | A full reference link `[text][ref]` has no definition. |
219
+ | `:duplicate_reference` | warning | There were multiple reference definitions with the same label (the first one is used). |
220
+ | `:duplicate_footnote` | warning | There were multiple footnote definitions with the same label (the first one is used; only when `footnotes: true`). |
221
+ | `:unsafe_url` | warning | An unsafe URL was replaced with an empty `href` / `src`. |
222
+ | `:empty_link` | warning | The link destination is empty (only when `lint: true`). |
223
+ | `:missing_alt` | info | An image's alt text is empty (only when `lint: true`). |
224
+ | `:heading_level_skip` | info | A heading level jumped by more than one (only when `lint: true`). |
225
+
226
+ **Implementation**: `lib/red_quilt/diagnostic.rb` (value object),
227
+ `lib/red_quilt/block_parser.rb` (duplicate reference),
228
+ `lib/red_quilt/footnote_definition.rb` (duplicate footnote),
229
+ `lib/red_quilt/inline/builder.rb` (missing / unsafe),
230
+ `lib/red_quilt/lint_pass.rb` (lint rules)
231
+
232
+ ### 3.3 `allow_html` / `disallow_raw_html` flags
233
+
234
+ **RedQuilt behavior**:
235
+
236
+ | Flag | Default | Effect |
237
+ |---|---|---|
238
+ | `allow_html` | `false` | When false, raw HTML is fully escaped (turned into `&lt;`). When true, HTML blocks and inline raw HTML are output as-is. |
239
+ | `disallow_raw_html` | `false` | The GFM "Disallowed Raw HTML" extension, enabled under `allow_html: true`. It rewrites `<` to `&lt;` for the specified tags. |
240
+
241
+ The disallowed tag set defined by GFM: `title`, `textarea`, `style`, `xmp`,
242
+ `iframe`, `noembed`, `noframes`, `script`, `plaintext`
243
+
244
+ **Implementation**: `lib/red_quilt/document.rb` — `allow_html?` /
245
+ `disallow_raw_html?`
246
+ **Implementation (filter)**: `lib/red_quilt/renderer/html.rb` —
247
+ `DISALLOWED_RAW_TAGS` / `DISALLOWED_RAW_TAG_RE` / `filter_disallowed_raw`
248
+
249
+ ## 4. Enabled extensions
250
+
251
+ ### 4.1 GFM Table
252
+
253
+ Always enabled. In addition to the spec, the column-count match requirement from
254
+ 2.7 is applied.
255
+
256
+ **Implementation**: `lib/red_quilt/block_parser.rb` — `table_start?` /
257
+ `parse_table`
258
+
259
+ ### 4.2 GFM Strikethrough
260
+
261
+ Always enabled. Only the double tilde `~~text~~` is supported (matching GFM
262
+ behavior). A single tilde `~text~` is treated as normal text.
263
+
264
+ **Implementation**: `lib/red_quilt/inline/lexer.rb` (handling of `~` in
265
+ `SPECIAL_BYTES` and `scan_delim_run`), `lib/red_quilt/inline/builder.rb`
266
+ (generating `STRIKETHROUGH` in `process_emphasis`)
267
+
268
+ ### 4.3 GFM Disallowed Raw HTML
269
+
270
+ Opt-in. It only works when `allow_html: true, disallow_raw_html: true` are used
271
+ together (under `allow_html: false` all HTML is escaped, so it has no effect).
272
+ See 3.3 for details.
273
+
274
+ ### 4.4 GFM Extended Autolink
275
+
276
+ Opt-in. Specifying `extended_autolinks: true` runs `ExtendedAutolinkPass` as a
277
+ pass that linkifies bare URLs / emails / `www.`-prefixed strings that are not
278
+ wrapped in `<...>`.
279
+
280
+ **Additional constraint**: implements the domain underscore check from 2.8.
281
+
282
+ **Implementation**: `lib/red_quilt/extended_autolink_pass.rb`
283
+
284
+ ### 4.5 GFM Footnotes
285
+
286
+ Opt-in. Specifying `footnotes: true` removes `[^label]: ...` definitions from the
287
+ body flow and converts `[^label]` references into sup links. Only the referenced
288
+ definitions are kept, ordered by first reference, and output as a
289
+ `FOOTNOTES_SECTION` at the end of the root. Unreferenced definitions are not
290
+ output.
291
+
292
+ **Implementation**: `lib/red_quilt/footnote_definition.rb`,
293
+ `lib/red_quilt/footnote_registry.rb`, `lib/red_quilt/footnote_pass.rb`
294
+
295
+ ## 5. Unsupported / known limitations
296
+
297
+ - GFM Task List Items (`- [ ]` / `- [x]`) are not supported. They are parsed as
298
+ normal list items.
299
+
300
+ ## 6. Correspondence with tests
301
+
302
+ This section collects the spec files that verify the difference items.
303
+
304
+ | Aspect | Spec file |
305
+ |---|---|
306
+ | Passing the official CommonMark examples | `spec/commonmark_compat_spec.rb` |
307
+ | Input normalization (line endings / NUL / blank line) | `spec/input_normalization_spec.rb` |
308
+ | Whitespace strictness (autolink / raw HTML / link tail) | `spec/whitespace_strictness_spec.rb` |
309
+ | Link / reference validation (label cap / raw dest) | `spec/link_validation_spec.rb` |
310
+ | NCR digit limits and invalid codepoints | `spec/numeric_character_reference_spec.rb` |
311
+ | GFM table column-count match | `spec/red_quilt_spec.rb` — "table separator validation" |
312
+ | GFM extended autolink domain validation | `spec/extended_autolink_spec.rb` |
313
+ | GFM footnotes | `spec/footnotes_spec.rb` |
314
+ | URL scheme sanitization | `spec/red_quilt_spec.rb` — "sanitizes unsafe URL schemes" |
315
+ | Diagnostics / lint diagnostics | `spec/diagnostic_spec.rb` |
316
+ | Disallowed Raw HTML | `spec/red_quilt_spec.rb` — disallow_raw_html cases |
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rbconfig"
4
+
5
+ module RedQuilt
6
+ # Opens a local HTML file in the OS default browser when `--open` is set.
7
+ # Best-effort: unsupported platforms or spawn failures are logged but
8
+ # never abort the CLI.
9
+ class BrowserLauncher
10
+ def initialize(err:)
11
+ @err = err
12
+ end
13
+
14
+ def launch(path)
15
+ command = platform_command
16
+ unless command
17
+ @err.puts "redquilt: --open is not supported on this platform; skipping."
18
+ return
19
+ end
20
+
21
+ pid = Process.spawn(*command, path, in: :close, out: File::NULL, err: File::NULL)
22
+ Process.detach(pid)
23
+ rescue StandardError => e
24
+ @err.puts "redquilt: failed to open browser: #{e.message}"
25
+ end
26
+
27
+ private
28
+
29
+ def platform_command
30
+ case RbConfig::CONFIG["host_os"]
31
+ when /darwin/ then ["open"]
32
+ when /linux|bsd/ then ["xdg-open"]
33
+ when /mswin|mingw|cygwin/ then ["cmd.exe", "/c", "start", ""]
34
+ end
35
+ end
36
+ end
37
+ end
data/lib/red_quilt/cli.rb CHANGED
@@ -1,7 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "optparse"
4
+ require "tmpdir"
4
5
  require "red_quilt"
6
+ require "red_quilt/browser_launcher"
5
7
 
6
8
  module RedQuilt
7
9
  # Entry point for the `redquilt` executable. Defined as a module-level
@@ -34,6 +36,9 @@ module RedQuilt
34
36
  lang: "en",
35
37
  css: nil,
36
38
  theme: :default,
39
+ output: nil,
40
+ open: false,
41
+ mermaid: false,
37
42
  }.freeze
38
43
 
39
44
  THEMES = %i[none default].freeze
@@ -44,6 +49,13 @@ module RedQuilt
44
49
  options = parse_options(argv, stderr: stderr)
45
50
  return options if options.is_a?(Integer)
46
51
 
52
+ if options[:open] && options[:format] != :html
53
+ stderr.puts "redquilt: --open requires --format html"
54
+ return 1
55
+ end
56
+ options[:standalone] = true if options[:open]
57
+
58
+ source_path = argv.first
47
59
  source = read_source(argv, stdin: stdin, stderr: stderr)
48
60
  return 1 unless source
49
61
 
@@ -54,15 +66,7 @@ module RedQuilt
54
66
  lint: options[:lint])
55
67
 
56
68
  unless options[:diagnostics_only]
57
- case options[:format]
58
- when :html
59
- stdout.write(render_html(doc, options))
60
- when :ast
61
- require "pp"
62
- PP.pp(doc.to_ast, stdout)
63
- when :json
64
- stdout.puts doc.to_json
65
- end
69
+ emit_output(doc, options, source_path: source_path, stdout: stdout, stderr: stderr)
66
70
  end
67
71
 
68
72
  if options[:diagnostics] || options[:diagnostics_only]
@@ -72,6 +76,35 @@ module RedQuilt
72
76
  doc.diagnostics.any? { |d| d.severity == :error } ? 1 : 0
73
77
  end
74
78
 
79
+ def self.emit_output(doc, options, source_path:, stdout:, stderr:)
80
+ destination = output_destination(options, source_path)
81
+
82
+ case options[:format]
83
+ when :html
84
+ html = render_html(doc, options)
85
+ if destination
86
+ File.write(destination, html)
87
+ else
88
+ stdout.write(html)
89
+ end
90
+ when :ast
91
+ require "pp"
92
+ PP.pp(doc.to_ast, stdout)
93
+ when :json
94
+ stdout.puts doc.to_json
95
+ end
96
+
97
+ BrowserLauncher.new(err: stderr).launch(destination) if options[:open] && destination
98
+ end
99
+
100
+ def self.output_destination(options, source_path)
101
+ return options[:output] if options[:output]
102
+ return nil unless options[:open]
103
+
104
+ base = source_path ? File.basename(source_path, ".*") : "stdin"
105
+ File.join(Dir.tmpdir, "redquilt-#{base}.html")
106
+ end
107
+
75
108
  def self.parse_options(argv, stderr:)
76
109
  options = DEFAULTS.dup
77
110
  parser = OptionParser.new do |opts|
@@ -115,6 +148,17 @@ module RedQuilt
115
148
  "Embedded stylesheet: default (the default) or none (bare HTML)") do |t|
116
149
  options[:theme] = t
117
150
  end
151
+ opts.on("-o", "--output FILE", "Write HTML to FILE instead of stdout") do |f|
152
+ options[:output] = f
153
+ end
154
+ opts.on("--open",
155
+ "Write HTML to a file and open it in the default browser (forces --standalone)") do
156
+ options[:open] = true
157
+ end
158
+ opts.on("--mermaid",
159
+ "Render `mermaid` code blocks as diagrams (loads mermaid.js from a CDN in standalone output)") do
160
+ options[:mermaid] = true
161
+ end
118
162
  opts.on("--diagnostics", "Also print diagnostics to stderr") do
119
163
  options[:diagnostics] = true
120
164
  end
@@ -167,6 +211,7 @@ module RedQuilt
167
211
  lang: options[:lang],
168
212
  css: options[:css],
169
213
  theme: options[:theme],
214
+ mermaid: options[:mermaid],
170
215
  )
171
216
  end
172
217
 
@@ -47,11 +47,15 @@ module RedQuilt
47
47
  # (an external stylesheet link) is independent and may be combined.
48
48
  # heading_ids: when true, every heading gets a slugified `id` (Unicode
49
49
  # preserving, deduplicated within the document) for anchor links.
50
- def to_html(standalone: false, title: nil, lang: "en", css: nil, theme: :none, heading_ids: false)
51
- body = Renderer::HTML.new(self, heading_ids: heading_ids).render
50
+ # mermaid: when true, fenced code blocks tagged `mermaid` render as
51
+ # `<pre class="mermaid">` containers instead of `<pre><code>`. In
52
+ # standalone mode the mermaid.js runtime is also loaded from a CDN so
53
+ # the diagrams render in the browser without further setup.
54
+ def to_html(standalone: false, title: nil, lang: "en", css: nil, theme: :none, heading_ids: false, mermaid: false)
55
+ body = Renderer::HTML.new(self, heading_ids: heading_ids, mermaid: mermaid).render
52
56
  return body unless standalone
53
57
 
54
- wrap_standalone_html(body, title: title.to_s, lang: lang.to_s, css: css, theme: Theme.css(theme))
58
+ wrap_standalone_html(body, title: title.to_s, lang: lang.to_s, css: css, theme: Theme.css(theme), mermaid: mermaid)
55
59
  end
56
60
 
57
61
  def to_ast
@@ -87,7 +91,68 @@ module RedQuilt
87
91
 
88
92
  private
89
93
 
90
- def wrap_standalone_html(body, title:, lang:, css:, theme:)
94
+ # Self-contained assets embedded in standalone output when mermaid
95
+ # support is enabled. Loads the mermaid.js runtime from a CDN as an ES
96
+ # module, renders every `<pre class="mermaid">` container, then makes
97
+ # each diagram interactive with svg-pan-zoom (also from a CDN): mouse
98
+ # wheel zooms, drag pans, and a small control panel offers +/-/reset.
99
+ MERMAID_SCRIPT = <<~HTML
100
+ <style>
101
+ .rq-mermaid-pz {
102
+ /* Break out of the body's max-width column so the viewport isn't a
103
+ narrow peephole: span most of the viewport width, centered. */
104
+ width: 80vw;
105
+ margin-left: calc(50% - 40vw);
106
+ height: 80vh;
107
+ border: 1px solid #d0d7de;
108
+ border-radius: 6px;
109
+ overflow: hidden;
110
+ }
111
+ .rq-mermaid-pz svg {
112
+ width: 100%;
113
+ height: 100%;
114
+ max-width: none;
115
+ display: block;
116
+ cursor: grab;
117
+ }
118
+ @media (prefers-color-scheme: dark) {
119
+ .rq-mermaid-pz { border-color: #30363d; }
120
+ }
121
+ </style>
122
+ <script type="module">
123
+ import mermaid from "https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.esm.min.mjs";
124
+ import svgPanZoom from "https://cdn.jsdelivr.net/npm/svg-pan-zoom@3.6.1/+esm";
125
+ mermaid.initialize({ startOnLoad: false });
126
+ await mermaid.run();
127
+
128
+ for (const pre of document.querySelectorAll("pre.mermaid")) {
129
+ const svg = pre.querySelector("svg");
130
+ if (!svg) continue;
131
+ // Drop mermaid's inline max-width and let the SVG fill a sized box so
132
+ // svg-pan-zoom has room to zoom/pan. The whole viewBox scales as one,
133
+ // so every element stays aligned.
134
+ svg.removeAttribute("style");
135
+ svg.setAttribute("width", "100%");
136
+ svg.setAttribute("height", "100%");
137
+ const box = document.createElement("div");
138
+ box.className = "rq-mermaid-pz";
139
+ pre.replaceWith(box);
140
+ box.appendChild(svg);
141
+ svgPanZoom(svg, {
142
+ zoomEnabled: true,
143
+ controlIconsEnabled: true,
144
+ fit: true,
145
+ center: true,
146
+ zoomScaleSensitivity: 0.3,
147
+ minZoom: 0.2,
148
+ maxZoom: 20,
149
+ });
150
+ }
151
+ </script>
152
+ HTML
153
+ private_constant :MERMAID_SCRIPT
154
+
155
+ def wrap_standalone_html(body, title:, lang:, css:, theme:, mermaid: false)
91
156
  out = +"<!DOCTYPE html>\n"
92
157
  out << %(<html lang="#{html_escape_attr(lang)}">\n)
93
158
  out << "<head>\n"
@@ -97,6 +162,7 @@ module RedQuilt
97
162
  out << "<style>\n#{theme}</style>\n" if theme
98
163
  out << "</head>\n<body>\n"
99
164
  out << body
165
+ out << MERMAID_SCRIPT if mermaid
100
166
  out << "</body>\n</html>\n"
101
167
  out
102
168
  end
@@ -3,11 +3,12 @@
3
3
  module RedQuilt
4
4
  module Renderer
5
5
  class HTML
6
- def initialize(document, heading_ids: false)
6
+ def initialize(document, heading_ids: false, mermaid: false)
7
7
  @document = document
8
8
  @arena = document.arena
9
9
  @out = +""
10
10
  @slugger = Slug::Counter.new if heading_ids
11
+ @mermaid = mermaid
11
12
  end
12
13
 
13
14
  def render
@@ -73,12 +74,21 @@ module RedQuilt
73
74
  render_list_item(node_id)
74
75
  @out << "</li>\n"
75
76
  when NodeType::CODE_BLOCK
76
- @out << "<pre><code"
77
77
  info_word = @arena.str2(node_id).to_s.split.first.to_s
78
- @out << %( class="language-#{escape_html(info_word)}") unless info_word.empty?
79
- @out << ">"
80
- @out << escape_html(@arena.text(node_id).to_s)
81
- @out << "</code></pre>\n"
78
+ if @mermaid && info_word == "mermaid"
79
+ # Emit a container mermaid.js recognizes via class="mermaid".
80
+ # The diagram source is still HTML-escaped; the browser decodes
81
+ # the entities back into textContent, which is what mermaid reads.
82
+ @out << %(<pre class="mermaid">)
83
+ @out << escape_html(@arena.text(node_id).to_s)
84
+ @out << "</pre>\n"
85
+ else
86
+ @out << "<pre><code"
87
+ @out << %( class="language-#{escape_html(info_word)}") unless info_word.empty?
88
+ @out << ">"
89
+ @out << escape_html(@arena.text(node_id).to_s)
90
+ @out << "</code></pre>\n"
91
+ end
82
92
  when NodeType::HTML_BLOCK
83
93
  render_raw_html(@arena.text(node_id).to_s, block: true)
84
94
  when NodeType::TABLE
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RedQuilt
4
- VERSION = "0.7.0"
4
+ VERSION = "0.7.2"
5
5
  end
data/lib/red_quilt.rb CHANGED
@@ -57,13 +57,13 @@ module RedQuilt
57
57
  document
58
58
  end
59
59
 
60
- def render_html(source, allow_html: false, disallow_raw_html: false, extended_autolinks: false, footnotes: false, lint: false, heading_ids: false)
60
+ def render_html(source, allow_html: false, disallow_raw_html: false, extended_autolinks: false, footnotes: false, lint: false, heading_ids: false, mermaid: false)
61
61
  parse(source,
62
62
  allow_html: allow_html,
63
63
  disallow_raw_html: disallow_raw_html,
64
64
  extended_autolinks: extended_autolinks,
65
65
  footnotes: footnotes,
66
- lint: lint).to_html(heading_ids: heading_ids)
66
+ lint: lint).to_html(heading_ids: heading_ids, mermaid: mermaid)
67
67
  end
68
68
 
69
69
  private
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: red_quilt
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.0
4
+ version: 0.7.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - takahashim
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-05-29 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies: []
12
12
  description: A modern Markdown document processor in pure Ruby, with an arena-style
13
13
  AST and full CommonMark spec test suite compliance.
@@ -26,13 +26,17 @@ files:
26
26
  - Rakefile
27
27
  - docs/api.md
28
28
  - docs/architecture.ja.md
29
+ - docs/architecture.md
29
30
  - docs/arena-usage.ja.md
31
+ - docs/arena-usage.md
30
32
  - docs/commonmark-conformance.ja.md
33
+ - docs/commonmark-conformance.md
31
34
  - exe/redquilt
32
35
  - lib/red_quilt.rb
33
36
  - lib/red_quilt/arena.rb
34
37
  - lib/red_quilt/block_parser.rb
35
38
  - lib/red_quilt/blockquote.rb
39
+ - lib/red_quilt/browser_launcher.rb
36
40
  - lib/red_quilt/cli.rb
37
41
  - lib/red_quilt/diagnostic.rb
38
42
  - lib/red_quilt/document.rb
@@ -90,7 +94,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
90
94
  - !ruby/object:Gem::Version
91
95
  version: '0'
92
96
  requirements: []
93
- rubygems_version: 3.6.2
97
+ rubygems_version: 3.6.9
94
98
  specification_version: 4
95
99
  summary: CommonMark-based Markdown processor written in pure Ruby
96
100
  test_files: []