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.
- checksums.yaml +7 -0
- data/.rdoc_options +30 -0
- data/CHANGELOG.md +38 -0
- data/LICENSE.txt +21 -0
- data/README.md +163 -0
- data/app/assets/stylesheets/maquina_stream/components/attachment.css +35 -0
- data/app/assets/stylesheets/maquina_stream/components/code_block.css +30 -0
- data/app/assets/stylesheets/maquina_stream/components/shimmer.css +31 -0
- data/app/assets/stylesheets/maquina_stream/components/snippet.css +22 -0
- data/app/assets/stylesheets/maquina_stream/components/source_citation.css +14 -0
- data/app/assets/stylesheets/maquina_stream/components/suggestion.css +32 -0
- data/app/assets/stylesheets/maquina_stream/reveal.css +48 -0
- data/app/assets/stylesheets/maquina_stream/themes/dark.css +235 -0
- data/app/assets/stylesheets/maquina_stream/themes/light.css +117 -0
- data/app/controllers/maquina_stream/application_controller.rb +21 -0
- data/app/controllers/maquina_stream/blocks_controller.rb +41 -0
- data/app/controllers/maquina_stream/manifests_controller.rb +15 -0
- data/app/helpers/maquina_stream/components_helper.rb +80 -0
- data/app/javascript/maquina_stream/controllers/application_controller.js +169 -0
- data/app/javascript/maquina_stream/controllers/ms_autoscroll_controller.js +110 -0
- data/app/javascript/maquina_stream/controllers/ms_code_controller.js +96 -0
- data/app/javascript/maquina_stream/controllers/ms_deferred_controller.js +223 -0
- data/app/javascript/maquina_stream/controllers/ms_diagram_controller.js +40 -0
- data/app/javascript/maquina_stream/controllers/ms_link_safety_controller.js +196 -0
- data/app/javascript/maquina_stream/controllers/ms_math_controller.js +32 -0
- data/app/javascript/maquina_stream/controllers/ms_repair_controller.js +167 -0
- data/app/javascript/maquina_stream/controllers/ms_reveal_controller.js +320 -0
- data/app/javascript/maquina_stream/controllers/ms_table_controller.js +183 -0
- data/app/javascript/maquina_stream/index.js +59 -0
- data/app/views/maquina_stream/components/_attachment.html.erb +139 -0
- data/app/views/maquina_stream/components/_code_block.html.erb +74 -0
- data/app/views/maquina_stream/components/_shimmer.html.erb +36 -0
- data/app/views/maquina_stream/components/_snippet.html.erb +50 -0
- data/app/views/maquina_stream/components/_source_citation.html.erb +42 -0
- data/app/views/maquina_stream/components/_suggestion.html.erb +73 -0
- data/config/importmap.rb +10 -0
- data/config/locales/en.yml +79 -0
- data/config/locales/es.yml +82 -0
- data/config/routes.rb +11 -0
- data/docs/configuration.md +219 -0
- data/docs/deferred-renderers.md +184 -0
- data/docs/getting-started.md +356 -0
- data/docs/javascript.md +298 -0
- data/docs/registries.md +283 -0
- data/docs/repair.md +162 -0
- data/docs/security.md +247 -0
- data/docs/streaming.md +308 -0
- data/lib/generators/maquina_stream/install/USAGE +26 -0
- data/lib/generators/maquina_stream/install/install_generator.rb +199 -0
- data/lib/generators/maquina_stream/install/templates/initializer.rb.tt +121 -0
- data/lib/generators/maquina_stream/streamable/USAGE +28 -0
- data/lib/generators/maquina_stream/streamable/streamable_generator.rb +187 -0
- data/lib/generators/maquina_stream/streamable/templates/migration.rb.tt +21 -0
- data/lib/generators/maquina_stream/streamable/templates/model.rb.tt +4 -0
- data/lib/maquina_stream/block.rb +99 -0
- data/lib/maquina_stream/broadcaster.rb +233 -0
- data/lib/maquina_stream/component_cache.rb +0 -0
- data/lib/maquina_stream/components/contract.rb +184 -0
- data/lib/maquina_stream/components.rb +135 -0
- data/lib/maquina_stream/configuration.rb +240 -0
- data/lib/maquina_stream/document.rb +296 -0
- data/lib/maquina_stream/engine.rb +46 -0
- data/lib/maquina_stream/errors.rb +17 -0
- data/lib/maquina_stream/export.rb +66 -0
- data/lib/maquina_stream/frame.rb +73 -0
- data/lib/maquina_stream/manifest.rb +137 -0
- data/lib/maquina_stream/registries.rb +116 -0
- data/lib/maquina_stream/renderer/fence.rb +115 -0
- data/lib/maquina_stream/renderer/post_pass.rb +363 -0
- data/lib/maquina_stream/renderer/tag_blocks.rb +276 -0
- data/lib/maquina_stream/renderer/view_context.rb +72 -0
- data/lib/maquina_stream/renderer.rb +128 -0
- data/lib/maquina_stream/sanitizer.rb +392 -0
- data/lib/maquina_stream/streamable.rb +281 -0
- data/lib/maquina_stream/text_direction.rb +56 -0
- data/lib/maquina_stream/themes.rb +84 -0
- data/lib/maquina_stream/version.rb +5 -0
- data/lib/maquina_stream.rb +175 -0
- 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
|