maquina_stream 0.1.0

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.
Files changed (79) hide show
  1. checksums.yaml +7 -0
  2. data/.rdoc_options +30 -0
  3. data/CHANGELOG.md +38 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +163 -0
  6. data/app/assets/stylesheets/maquina_stream/components/attachment.css +35 -0
  7. data/app/assets/stylesheets/maquina_stream/components/code_block.css +30 -0
  8. data/app/assets/stylesheets/maquina_stream/components/shimmer.css +31 -0
  9. data/app/assets/stylesheets/maquina_stream/components/snippet.css +22 -0
  10. data/app/assets/stylesheets/maquina_stream/components/source_citation.css +14 -0
  11. data/app/assets/stylesheets/maquina_stream/components/suggestion.css +32 -0
  12. data/app/assets/stylesheets/maquina_stream/reveal.css +48 -0
  13. data/app/assets/stylesheets/maquina_stream/themes/dark.css +235 -0
  14. data/app/assets/stylesheets/maquina_stream/themes/light.css +117 -0
  15. data/app/controllers/maquina_stream/application_controller.rb +21 -0
  16. data/app/controllers/maquina_stream/blocks_controller.rb +41 -0
  17. data/app/controllers/maquina_stream/manifests_controller.rb +15 -0
  18. data/app/helpers/maquina_stream/components_helper.rb +80 -0
  19. data/app/javascript/maquina_stream/controllers/application_controller.js +169 -0
  20. data/app/javascript/maquina_stream/controllers/ms_autoscroll_controller.js +110 -0
  21. data/app/javascript/maquina_stream/controllers/ms_code_controller.js +96 -0
  22. data/app/javascript/maquina_stream/controllers/ms_deferred_controller.js +223 -0
  23. data/app/javascript/maquina_stream/controllers/ms_diagram_controller.js +40 -0
  24. data/app/javascript/maquina_stream/controllers/ms_link_safety_controller.js +196 -0
  25. data/app/javascript/maquina_stream/controllers/ms_math_controller.js +32 -0
  26. data/app/javascript/maquina_stream/controllers/ms_repair_controller.js +167 -0
  27. data/app/javascript/maquina_stream/controllers/ms_reveal_controller.js +320 -0
  28. data/app/javascript/maquina_stream/controllers/ms_table_controller.js +183 -0
  29. data/app/javascript/maquina_stream/index.js +59 -0
  30. data/app/views/maquina_stream/components/_attachment.html.erb +139 -0
  31. data/app/views/maquina_stream/components/_code_block.html.erb +74 -0
  32. data/app/views/maquina_stream/components/_shimmer.html.erb +36 -0
  33. data/app/views/maquina_stream/components/_snippet.html.erb +50 -0
  34. data/app/views/maquina_stream/components/_source_citation.html.erb +42 -0
  35. data/app/views/maquina_stream/components/_suggestion.html.erb +73 -0
  36. data/config/importmap.rb +10 -0
  37. data/config/locales/en.yml +79 -0
  38. data/config/locales/es.yml +82 -0
  39. data/config/routes.rb +11 -0
  40. data/docs/configuration.md +219 -0
  41. data/docs/deferred-renderers.md +184 -0
  42. data/docs/getting-started.md +356 -0
  43. data/docs/javascript.md +298 -0
  44. data/docs/registries.md +283 -0
  45. data/docs/repair.md +162 -0
  46. data/docs/security.md +247 -0
  47. data/docs/streaming.md +308 -0
  48. data/lib/generators/maquina_stream/install/USAGE +26 -0
  49. data/lib/generators/maquina_stream/install/install_generator.rb +199 -0
  50. data/lib/generators/maquina_stream/install/templates/initializer.rb.tt +121 -0
  51. data/lib/generators/maquina_stream/streamable/USAGE +28 -0
  52. data/lib/generators/maquina_stream/streamable/streamable_generator.rb +187 -0
  53. data/lib/generators/maquina_stream/streamable/templates/migration.rb.tt +21 -0
  54. data/lib/generators/maquina_stream/streamable/templates/model.rb.tt +4 -0
  55. data/lib/maquina_stream/block.rb +99 -0
  56. data/lib/maquina_stream/broadcaster.rb +233 -0
  57. data/lib/maquina_stream/component_cache.rb +0 -0
  58. data/lib/maquina_stream/components/contract.rb +184 -0
  59. data/lib/maquina_stream/components.rb +135 -0
  60. data/lib/maquina_stream/configuration.rb +240 -0
  61. data/lib/maquina_stream/document.rb +296 -0
  62. data/lib/maquina_stream/engine.rb +46 -0
  63. data/lib/maquina_stream/errors.rb +17 -0
  64. data/lib/maquina_stream/export.rb +66 -0
  65. data/lib/maquina_stream/frame.rb +73 -0
  66. data/lib/maquina_stream/manifest.rb +137 -0
  67. data/lib/maquina_stream/registries.rb +116 -0
  68. data/lib/maquina_stream/renderer/fence.rb +115 -0
  69. data/lib/maquina_stream/renderer/post_pass.rb +363 -0
  70. data/lib/maquina_stream/renderer/tag_blocks.rb +276 -0
  71. data/lib/maquina_stream/renderer/view_context.rb +72 -0
  72. data/lib/maquina_stream/renderer.rb +128 -0
  73. data/lib/maquina_stream/sanitizer.rb +392 -0
  74. data/lib/maquina_stream/streamable.rb +281 -0
  75. data/lib/maquina_stream/text_direction.rb +56 -0
  76. data/lib/maquina_stream/themes.rb +84 -0
  77. data/lib/maquina_stream/version.rb +5 -0
  78. data/lib/maquina_stream.rb +175 -0
  79. metadata +204 -0
@@ -0,0 +1,276 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MaquinaStream
4
+ class Renderer
5
+ # Gives a **registered** app-meaning tag its own HTML block, by putting
6
+ # blank lines around its opening and closing tags before commonmarker ever
7
+ # sees the buffer.
8
+ #
9
+ # ```ruby
10
+ # MaquinaStream::Renderer::TagBlocks.call(markdown, names: MaquinaStream.tags.keys)
11
+ # ```
12
+ #
13
+ # ## The bug this exists for
14
+ #
15
+ # CommonMark ends an HTML block at the first blank line (spec §4.6,
16
+ # condition 7). A tag a model wrote across several paragraphs therefore has
17
+ # its closing tag emitted *inside a paragraph*:
18
+ #
19
+ # ```
20
+ # <thinking>\nFirst para.\n\nSecond para.\n</thinking>\n\nAfter the tag.
21
+ # # <thinking>
22
+ # # First para.
23
+ # # <p>Second para.<br />\n</thinking></p>
24
+ # # <p>After the tag.</p>
25
+ # ```
26
+ #
27
+ # The HTML5 parser sees a stray end tag, ignores it, and never closes
28
+ # `<thinking>` — so the rest of the message is parsed *inside* it and the
29
+ # host's partial is handed content the model wrote after the tag closed.
30
+ # That is a content leak, not a cosmetic defect: the partial is where the
31
+ # host says "this is the model's private reasoning".
32
+ #
33
+ # With a blank line after the opener and before the closer, each tag is its
34
+ # own HTML block, the element closes where the model closed it, and the
35
+ # paragraphs between them are ordinary markdown.
36
+ #
37
+ # ## Why it normalises markdown rather than HTML
38
+ #
39
+ # Rebalancing end tags in the commonmarker output would mean hand-editing an
40
+ # HTML string that is model output — the one thing this engine never does.
41
+ # This pass inserts newlines into the markdown and changes nothing else;
42
+ # the trust boundary stays exactly where it was, with the sanitizer running
43
+ # last over parsed HTML.
44
+ #
45
+ # ## What it will not touch
46
+ #
47
+ # It is a privilege boundary, and it is written to be boring:
48
+ #
49
+ # * **Only registered names.** `names:` is what the host passed to
50
+ # `MaquinaStream.register_tag`. An unregistered `<script>`, `<iframe>` or
51
+ # `<img>` is not matched, not moved and not re-parsed. With an empty
52
+ # registry the input is returned byte for byte.
53
+ # * **Code wins.** Matching runs over MaquinaRemend::Scanner#masked_text,
54
+ # which blanks fenced code blocks and balanced inline code spans while
55
+ # preserving offsets, so a `<thinking>` inside a fence or a backtick span
56
+ # is text and stays text. Indented code is covered by the indent rule
57
+ # below rather than by the scanner, which does not track it, and the raw
58
+ # HTML regions the scanner does not model either — comments, `<script>`,
59
+ # `<pre>` and friends — are masked here by RAW_REGIONS.
60
+ # * **Complete tags only.** A match is a whole open or close tag on one
61
+ # line, with CommonMark's attribute grammar — quoted values may contain
62
+ # `>`, and a tag broken across a newline is not a tag. `<thinkingXYZ>`
63
+ # does not match `thinking`; `<thinking/>` opens nothing.
64
+ # * **Block position only.** The opening tag must *begin its line* under
65
+ # four columns of indent, which is the shape that starts an HTML block and
66
+ # therefore the shape that leaks. Anything deeper may be indented code or
67
+ # list content and is left alone; an inline `<citation>…</citation>` in
68
+ # the middle of a sentence is left alone because it already works, and is
69
+ # what the registry was built for.
70
+ # * **Pairs only.** An opener with no closer, or a closer with no opener,
71
+ # inserts nothing. Nesting is matched innermost-first, as HTML does it.
72
+ #
73
+ # Insertion is idempotent: a buffer that already has the blank lines comes
74
+ # back byte-identical, which is what makes the pass safe to run on every
75
+ # frame of a stream.
76
+ class TagBlocks
77
+ # An HTML block opener may be indented up to three columns; the fourth
78
+ # makes it indented code.
79
+ MAX_INDENT = 3
80
+
81
+ # An indent that still starts an HTML block, and one that is deep enough
82
+ # to be indented code or list content instead.
83
+ BLOCK_INDENT = /\A {0,#{MAX_INDENT}}\z/
84
+ CODE_INDENT = /\A[ \t]{#{MAX_INDENT + 1},}\z/
85
+
86
+ # CommonMark's attribute grammar (spec §6.6), restricted to spaces and
87
+ # tabs. A tag whose attributes wrap onto a second line is not a complete
88
+ # tag for HTML-block purposes, so it must not be one for us either.
89
+ ATTRIBUTE = /[ \t]+[a-zA-Z_:][a-zA-Z0-9_.:-]*(?:[ \t]*=[ \t]*(?:[^ \t"'=<>`]+|'[^']*'|"[^"]*"))?/
90
+
91
+ # The raw regions CommonMark reads to a closing marker rather than to a
92
+ # blank line: HTML block types 1 through 5 — `<script>`, `<pre>`,
93
+ # `<style>` and `<textarea>`, comments, processing instructions,
94
+ # declarations and CDATA.
95
+ #
96
+ # MaquinaRemend::Scanner masks fences and inline code, which is the
97
+ # markdown half of "this is text, not markup"; it does not model these,
98
+ # because no repair it makes has ever needed to. They matter here for one
99
+ # reason: a blank line inserted inside one of them **ends it early**, and
100
+ # text the model had buried in a comment or a `<script>` — text the
101
+ # sanitizer would have dropped whole — comes back out as live markdown.
102
+ # Masking them means a registered name written inside one is never a tag.
103
+ #
104
+ # An unterminated region masks to the end of the buffer, which is the
105
+ # conservative answer while a message is still streaming.
106
+ RAW_REGIONS = [
107
+ /<!--.*?(?:-->|\z)/m,
108
+ /<!\[CDATA\[.*?(?:\]\]>|\z)/m,
109
+ /<\?.*?(?:\?>|\z)/m,
110
+ /<![A-Za-z].*?(?:>|\z)/m,
111
+ %r{<(script|pre|style|textarea)\b.*?(?:</\1\s*>|\z)}mi
112
+ ].freeze
113
+
114
+ class << self
115
+ # Normalises `markdown` for the given tag `names` and returns it.
116
+ # Returns the argument unchanged when nothing is registered, when no
117
+ # registered tag appears in block position, or when the blank lines are
118
+ # already there.
119
+ def call(markdown, names: MaquinaStream.tags.keys)
120
+ new(names: names).call(markdown)
121
+ end
122
+ end
123
+
124
+ # The registered tag names this instance will normalise, downcased.
125
+ attr_reader :names
126
+
127
+ def initialize(names:)
128
+ @names = Array(names).map { |name| name.to_s.downcase }.reject(&:empty?).uniq
129
+ end
130
+
131
+ def call(markdown)
132
+ return markdown if markdown.nil? || markdown.empty? || names.empty?
133
+
134
+ insertions = plan(markdown.to_s)
135
+ return markdown if insertions.empty?
136
+
137
+ splice(markdown.to_s, insertions)
138
+ end
139
+
140
+ private
141
+ # Longest first, so `<answer>` cannot be matched inside `<answerable>`
142
+ # by a shorter alternative winning the alternation.
143
+ def pattern
144
+ @pattern ||= begin
145
+ alternation = names.sort_by { |name| -name.length }.map { |name| Regexp.escape(name) }.join("|")
146
+ /<(\/?)(#{alternation})(?![a-zA-Z0-9_:-])(#{ATTRIBUTE}*)[ \t]*(\/?)>/i
147
+ end
148
+ end
149
+
150
+ # `[position, text]` pairs, one per newline this pass wants to insert.
151
+ def plan(text)
152
+ pairs(text).flat_map { |open, close| insertions_for(text, open, close) }.compact
153
+ end
154
+
155
+ # Matched open/close ranges, innermost first, over the masked buffer so
156
+ # that code context is honoured. Mirrors the stack in
157
+ # MaquinaRemend::Handlers::AppTags rather than inventing a second one.
158
+ def pairs(text)
159
+ masked = mask_raw_regions(MaquinaRemend::Scanner.new(text).masked_text)
160
+ stack = []
161
+
162
+ masked.to_enum(:scan, pattern).each_with_object([]) do |_, found|
163
+ match = Regexp.last_match
164
+ name = match[2].downcase
165
+ range = match.begin(0)...match.end(0)
166
+
167
+ if match[1].empty?
168
+ # A self-closing `<citation id="1"/>` opens nothing.
169
+ stack << [name, range] if match[4].empty?
170
+ elsif (index = stack.rindex { |open_name, _| open_name == name })
171
+ found << [stack[index][1], range]
172
+ stack.slice!(index..)
173
+ end
174
+ end
175
+ end
176
+
177
+ # Blanks every RAW_REGIONS span, keeping the buffer's length so that
178
+ # match offsets are still offsets into the original text. Same trick,
179
+ # and the same masking character, as MaquinaRemend::Scanner.
180
+ def mask_raw_regions(masked)
181
+ RAW_REGIONS.reduce(masked) do |text, pattern|
182
+ text.gsub(pattern) { MaquinaRemend::Scanner::MASK * ::Regexp.last_match(0).length }
183
+ end
184
+ end
185
+
186
+ # Four questions per pair, each answering with one newline or with
187
+ # nothing: is there a blank line above the opener, below the opener,
188
+ # above the closer, below the closer. A pair that is not in block
189
+ # position, or whose closer sits in what could be indented code, is
190
+ # skipped entirely.
191
+ def insertions_for(text, open, close)
192
+ return [] unless block_positioned?(text, open)
193
+ return [] unless closer_placeable?(text, close)
194
+
195
+ [
196
+ blank_line_before(text, open),
197
+ blank_line_after(text, open),
198
+ blank_line_before(text, close),
199
+ blank_line_after(text, close)
200
+ ]
201
+ end
202
+
203
+ # A registered tag that **begins a line** under four columns of indent is
204
+ # the host's block-level component, and is the only shape this pass
205
+ # touches. Anything else is either already correct — an inline
206
+ # `<citation>…</citation>` in the middle of a sentence renders fine and
207
+ # is left exactly as the model wrote it — or ambiguous, because four
208
+ # columns could be indented code or list continuation, and ambiguous
209
+ # means untouched.
210
+ def block_positioned?(text, range)
211
+ text[line_start(text, range.first)...range.first].match?(BLOCK_INDENT)
212
+ end
213
+
214
+ # The closer may carry prose in front of it — that is the common case,
215
+ # `Second para.</thinking>` — but four columns of leading whitespace
216
+ # could be indented code, and a repair there would corrupt it.
217
+ def closer_placeable?(text, range)
218
+ indent = text[line_start(text, range.first)...range.first]
219
+
220
+ !indent.match?(CODE_INDENT)
221
+ end
222
+
223
+ # A blank line above the tag. Prose in front of it on the same line is
224
+ # pushed down instead — `Second para.</thinking>` becomes two blocks —
225
+ # and a tag that already has a blank line above it is left alone, which
226
+ # is what makes the pass idempotent.
227
+ def blank_line_before(text, range)
228
+ start = line_start(text, range.first)
229
+ head = text[start...range.first]
230
+
231
+ return [range.first, "\n\n"] unless head.match?(/\A[ \t]*\z/)
232
+ return nil if start.zero? || text[0...start].match?(/(?:\A|\n)[ \t]*\n\z/)
233
+
234
+ [start, "\n"]
235
+ end
236
+
237
+ # A blank line below the tag, by the same rules. It matters on the
238
+ # closer as much as on the opener: without it the text after
239
+ # `</thinking>` continues the closer's HTML block and is emitted as raw
240
+ # HTML instead of being rendered as the markdown the model wrote.
241
+ def blank_line_after(text, range)
242
+ tail = rest_of_line(text, range.last)
243
+
244
+ return [range.last, "\n\n"] unless tail.match?(/\A[ \t]*\z/)
245
+
246
+ following = text[(range.last + tail.length + 1)..]
247
+ return nil if following.nil? || following.match?(/\A[ \t]*(?:\n|\z)/)
248
+
249
+ [range.last, "\n"]
250
+ end
251
+
252
+ # `rindex` reads a negative start as an offset from the end of the
253
+ # string, so offset 0 has to answer for itself rather than search.
254
+ def line_start(text, offset)
255
+ return 0 if offset.zero?
256
+
257
+ (text.rindex("\n", offset - 1) || -1) + 1
258
+ end
259
+
260
+ def rest_of_line(text, offset)
261
+ text[offset...(text.index("\n", offset) || text.length)].to_s
262
+ end
263
+
264
+ # Two pairs can ask for the same newline — a closer and the next
265
+ # opener, say. Each position is written once, with the longest text
266
+ # asked for there, so the pass stays idempotent.
267
+ def splice(text, insertions)
268
+ merged = insertions.group_by(&:first).transform_values { |group| group.map(&:last).max_by(&:length) }
269
+
270
+ merged.keys.sort.reverse_each.with_object(+text) do |position, buffer|
271
+ buffer.insert(position, merged[position])
272
+ end
273
+ end
274
+ end
275
+ end
276
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MaquinaStream
4
+ class Renderer
5
+ # Renders engine partials without a request.
6
+ #
7
+ # The renderer is a pure function, but components are ERB — so it builds its
8
+ # own view context from the engine's view paths rather than borrowing a
9
+ # controller's. "No request context" is the constraint; ActionView is not
10
+ # request context.
11
+ class ViewContext
12
+ class MissingActionView < Error; end
13
+
14
+ def self.build
15
+ @build ||= new
16
+ end
17
+
18
+ # Templates are looked up once per process, so a component change in
19
+ # development needs a reload — the same trade every engine partial makes.
20
+ def self.reset!
21
+ @build = nil
22
+ end
23
+
24
+ def render(partial, locals = {})
25
+ view.render(partial: partial, locals: locals)
26
+ end
27
+
28
+ private
29
+ def view
30
+ @view ||= begin
31
+ require_action_view!
32
+
33
+ lookup = ActionView::LookupContext.new(view_paths)
34
+ view = ActionView::Base.with_empty_template_cache.new(lookup, {}, nil)
35
+ # Components render through the seam, and the seam is a helper.
36
+ view.extend(components_helper)
37
+ view
38
+ end
39
+ end
40
+
41
+ def view_paths
42
+ if defined?(ActionController::Base)
43
+ ActionController::Base.view_paths
44
+ else
45
+ [File.expand_path("../../../app/views", __dir__)]
46
+ end
47
+ end
48
+
49
+ # The helper lives in app/helpers and is normally autoloaded by the
50
+ # engine. Requiring it directly is what lets the renderer work in a
51
+ # plain Ruby process with ActionView and no Rails application - which is
52
+ # the whole claim the pure-function constraint makes.
53
+ def components_helper
54
+ unless defined?(MaquinaStream::ComponentsHelper)
55
+ require File.expand_path("../../../app/helpers/maquina_stream/components_helper", __dir__)
56
+ end
57
+
58
+ MaquinaStream::ComponentsHelper
59
+ end
60
+
61
+ def require_action_view!
62
+ return if defined?(ActionView::Base)
63
+
64
+ raise MissingActionView, <<~MESSAGE
65
+ MaquinaStream::Renderer needs ActionView to render component partials.
66
+ It does not need a request, a controller or a running server — but it
67
+ is an engine, and its components are ERB.
68
+ MESSAGE
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "commonmarker"
5
+ require "nokogiri"
6
+ require "maquina_remend"
7
+
8
+ require_relative "renderer/view_context"
9
+ require_relative "renderer/fence"
10
+ require_relative "renderer/post_pass"
11
+ require_relative "renderer/tag_blocks"
12
+
13
+ module MaquinaStream
14
+ # Markdown in, sanitized HTML out.
15
+ #
16
+ # ```ruby
17
+ # MaquinaStream::Renderer.call(markdown, mode: :streaming) # => SafeBuffer
18
+ # ```
19
+ #
20
+ # Pure: no request, no controller, no stubbing. The same function serves the
21
+ # live stream, a page reload, a replay and an export, and `mode` changes
22
+ # nothing but whether the reveal attributes are emitted. If this ever starts
23
+ # needing request context, that is a design error rather than a plumbing one.
24
+ #
25
+ # ## The pipeline
26
+ #
27
+ # 1. `MaquinaRemend.call` repairs the unterminated markdown a half-written
28
+ # buffer always ends in — an open bold run, an unclosed fence.
29
+ # 2. Renderer::TagBlocks puts blank lines around the opening and closing tags
30
+ # of a *registered* tag, so a block-level `<thinking>` is its own HTML
31
+ # block rather than a closing tag stranded inside a paragraph.
32
+ # 3. Commonmarker parses it, with source positions.
33
+ # 4. Renderer::PostPass rewrites elements: fences, custom tags, registered
34
+ # element overrides, text direction, block indices.
35
+ # 5. Sanitizer runs, unconditionally, last.
36
+ #
37
+ # Renderer returns one HTML string for the whole buffer. It does **not**
38
+ # split it into blocks or stamp ids and digests on them — that is Document,
39
+ # and without it a page cannot be repaired at all. Host code almost always
40
+ # wants MaquinaStream.render or Document rather than this.
41
+ class Renderer
42
+ # The two render modes. Both produce the same document byte for byte; the
43
+ # mode is carried so the post-pass knows whether the message is still being
44
+ # written.
45
+ MODES = %i[streaming static].freeze
46
+
47
+ # unsafe: true lets registered custom tags through the parser. It is not a
48
+ # relaxation: the sanitizer is the gate, it runs unconditionally, and it runs
49
+ # last. Model output is assumed hostile at every step before it.
50
+ COMMONMARKER_OPTIONS = {
51
+ parse: {sourcepos_chars: true},
52
+ render: {sourcepos: true, unsafe: true, github_pre_lang: false},
53
+ extension: {
54
+ table: true,
55
+ strikethrough: true,
56
+ autolink: true,
57
+ tasklist: true,
58
+ footnotes: true
59
+ }
60
+ }.freeze
61
+
62
+ # commonmarker highlights with syntect by default, which would ship a second
63
+ # highlighter's inline styles into a pipeline that already owns highlighting
64
+ # (Rouge, at fence close only) and forbids inline colour. Confirmed against
65
+ # commonmarker 2.10.0 in test/sourcepos_test.rb.
66
+ COMMONMARKER_PLUGINS = {syntax_highlighter: nil}.freeze
67
+
68
+ # The mode this renderer was built with, one of MODES.
69
+ attr_reader :mode
70
+
71
+ # The Configuration this renderer reads.
72
+ attr_reader :config
73
+
74
+ # Renders `markdown` in one call. The usual entry point.
75
+ #
76
+ # `mode:` is `:streaming` or `:static`; anything else raises ArgumentError.
77
+ # `config:` defaults to the global MaquinaStream.config, and is a keyword
78
+ # rather than a lookup so the renderer stays callable from a plain Ruby
79
+ # process with no Rails around it.
80
+ def self.call(markdown, mode: :streaming, config: MaquinaStream.config)
81
+ new(mode: mode, config: config).call(markdown)
82
+ end
83
+
84
+ # The buffer as commonmarker will see it: repaired, then normalised so a
85
+ # registered block-level tag stands on its own. Document parses this rather
86
+ # than the raw buffer, so its source positions are the ones the rendered
87
+ # document was built from — two parses of two different strings do not line
88
+ # up, and the block ids are derived from the alignment.
89
+ #
90
+ # Byte-identical to `MaquinaRemend.call(markdown)` when no tag is
91
+ # registered.
92
+ def self.prepare(markdown)
93
+ TagBlocks.call(MaquinaRemend.call(markdown))
94
+ end
95
+
96
+ # Builds a reusable renderer. Raises ArgumentError unless `mode:` is one of
97
+ # MODES.
98
+ def initialize(mode: :streaming, config: MaquinaStream.config)
99
+ raise ArgumentError, "mode must be one of #{MODES.join(", ")}" unless MODES.include?(mode)
100
+
101
+ @mode = mode
102
+ @config = config
103
+ end
104
+
105
+ # Renders one markdown string to sanitized HTML.
106
+ #
107
+ # Returns an `html_safe` String — a plain String when ActiveSupport is not
108
+ # loaded — and an empty one for nil or whitespace-only input.
109
+ def call(markdown)
110
+ return safe("") if markdown.nil? || markdown.strip.empty?
111
+
112
+ prepared = self.class.prepare(markdown)
113
+ parsed = Commonmarker.to_html(prepared, options: COMMONMARKER_OPTIONS, plugins: COMMONMARKER_PLUGINS)
114
+ fragment = Nokogiri::HTML5.fragment(parsed)
115
+
116
+ PostPass.new(fragment, markdown: prepared, mode: mode, config: config).call
117
+
118
+ safe(Sanitizer.call(fragment.to_html, config: config))
119
+ end
120
+
121
+ private
122
+ # ActiveSupport is present inside the engine, but the renderer is expected
123
+ # to run in a plain process too, so the wrapper degrades to a String.
124
+ def safe(html)
125
+ html.respond_to?(:html_safe) ? html.html_safe : html
126
+ end
127
+ end
128
+ end