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,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MaquinaStream
4
+ # One registered element override: the element name, and the options it was
5
+ # registered with. See Registries#register_element.
6
+ Element = Struct.new(:name, :options, keyword_init: false)
7
+
8
+ # One registered custom tag: the tag name, and the options it was registered
9
+ # with. See Registries#register_tag.
10
+ Tag = Struct.new(:name, :options, keyword_init: false)
11
+
12
+ # One registered fence: the info string it matches, and the options it was
13
+ # registered with. See Registries#register_fence.
14
+ Fence = Struct.new(:info, :options, keyword_init: false)
15
+
16
+ # The three registries, extended into MaquinaStream itself — so every method
17
+ # here is called as `MaquinaStream.register_element`, not on a registry
18
+ # object. Registrations are global and are read on every render; make them
19
+ # once, from an initializer.
20
+ #
21
+ # See docs/registries.md for the long form.
22
+ module Registries
23
+ # Registered element overrides, keyed by element name. Read by the render
24
+ # pipeline; a host rarely reads it directly.
25
+ def elements = @elements ||= {}
26
+
27
+ # Registered custom tags, keyed by tag name.
28
+ def tags = @tags ||= {}
29
+
30
+ # Registered fences, keyed by info string.
31
+ def fences = @fences ||= {}
32
+
33
+ # Renders one markdown element through a partial of the host's instead of
34
+ # the engine's default markup.
35
+ #
36
+ # ```ruby
37
+ # MaquinaStream.register_element :h2, partial: "my/headings/h2"
38
+ # ```
39
+ #
40
+ # `name` is the HTML element the renderer produced (`:h2`, `:blockquote`,
41
+ # `:table`). The partial receives the element's content and renders in
42
+ # place of it. Registering the same name twice replaces the first
43
+ # registration; the last one in wins.
44
+ #
45
+ # The output still goes through Sanitizer, which runs unconditionally and
46
+ # last. A partial cannot introduce an element or attribute the allowlist
47
+ # does not name.
48
+ def register_element(name, **options)
49
+ elements[name.to_sym] = Element.new(name.to_sym, options)
50
+ end
51
+
52
+ # Admits one HTML-ish tag that markdown does not define, and renders it
53
+ # through a partial.
54
+ #
55
+ # ```ruby
56
+ # MaquinaStream.register_tag :source,
57
+ # attributes: %w[id],
58
+ # partial: "my/tags/source",
59
+ # literal_content: false
60
+ # ```
61
+ #
62
+ # | Option | Meaning |
63
+ # |---|---|
64
+ # | `attributes:` | The attribute names the tag may carry. Anything else on it is dropped — the tag is model output, and model output is prompt-injectable. |
65
+ # | `partial:` | The partial that renders it. |
66
+ # | `literal_content:` | `true` keeps the tag's body as text; `false` renders it as markdown. |
67
+ #
68
+ # A tag nobody registered is not markup: it is text, and it is escaped.
69
+ def register_tag(name, **options)
70
+ tags[name.to_sym] = Tag.new(name.to_sym, options)
71
+ end
72
+
73
+ # Decides what happens to a fenced code block with this info string.
74
+ #
75
+ # ```ruby
76
+ # MaquinaStream.register_fence "ruby", strategy: :server
77
+ # MaquinaStream.register_fence "unknown", strategy: :passthrough
78
+ # MaquinaStream.register_fence "mermaid",
79
+ # strategy: :client,
80
+ # controller: "ms-diagram",
81
+ # payload: ->(source, info) { { source: source, info: info } }
82
+ # ```
83
+ #
84
+ # ## The three strategies
85
+ #
86
+ # | Strategy | What it does | When to use it |
87
+ # |---|---|---|
88
+ # | `:server` | Rouge highlights the source once the fence closes, into classes — never inline colour, so a theme switch needs no re-render. The default for every unregistered language. | Anything Rouge has a lexer for. |
89
+ # | `:client` | Nothing is rendered server-side. The block carries a `payload:` as a data attribute and a Stimulus `controller:` draws it in the browser. | Diagrams, math — anything whose renderer is a JavaScript library the server has no business running. |
90
+ # | `:passthrough` | The source is emitted as escaped text and nothing else happens to it. | A language Rouge would mangle, or one whose highlighting is not worth the CPU. |
91
+ #
92
+ # **An open fence is never highlighted and never emits a payload.**
93
+ # Highlighting would be thrown away on the next frame, and a payload would
94
+ # hand the client half a diagram to draw. A `:client` fence renders a
95
+ # skeleton until it closes.
96
+ #
97
+ # `payload:` is a callable receiving `(source, info)` and returning a Hash;
98
+ # it defaults to `{source:, info:}`. It is serialized as JSON into a data
99
+ # attribute, so it must be JSON-representable. **The client sanitizes what
100
+ # its renderer produces** even though the server already sanitized the
101
+ # document — the payload came from the model.
102
+ #
103
+ # See Renderer::Fence and docs/deferred-renderers.md.
104
+ def register_fence(info, **options)
105
+ fences[info.to_s] = Fence.new(info.to_s, options)
106
+ end
107
+
108
+ # Empties all three registries. For tests; a host that calls this loses
109
+ # every registration made in its initializer.
110
+ def reset_registries!
111
+ @elements = {}
112
+ @tags = {}
113
+ @fences = {}
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rouge"
4
+
5
+ module MaquinaStream
6
+ class Renderer
7
+ # One fenced code block, and the decision about what to do with it.
8
+ #
9
+ # The strategy comes from the fence registry; the fence's own state — open or
10
+ # closed — decides how much of that strategy actually runs. An open fence is
11
+ # never highlighted and never emits a client payload: highlighting would be
12
+ # thrown away on the next frame, and a payload would hand the client half a
13
+ # diagram to draw.
14
+ #
15
+ # Which strategy a fence gets is the host's, through
16
+ # MaquinaStream.register_fence. This class is what reads that registration
17
+ # and applies it.
18
+ class Fence
19
+ # What an unregistered language gets: Rouge highlighting once it closes.
20
+ DEFAULT_STRATEGY = :server
21
+
22
+ # The three strategies, documented on Registries#register_fence. A
23
+ # registration naming anything else falls back to DEFAULT_STRATEGY rather
24
+ # than raising — a typo in an initializer should not take a whole
25
+ # message down.
26
+ STRATEGIES = %i[server client passthrough].freeze
27
+
28
+ # The fence's whole info string, `"ruby"` or `"mermaid graph"`.
29
+ attr_reader :info
30
+
31
+ # The fence's body, as written.
32
+ attr_reader :source
33
+
34
+ # Whether the fence is still unterminated.
35
+ attr_reader :open
36
+
37
+ # The Configuration this fence reads.
38
+ attr_reader :config
39
+
40
+ # Builds a fence. The render pipeline does this, once per fenced block
41
+ # per frame.
42
+ def initialize(info:, source:, open:, config:)
43
+ @info = info.to_s
44
+ @source = source
45
+ @open = open
46
+ @config = config
47
+ end
48
+
49
+ # Whether the fence is still unterminated. An open fence is never
50
+ # highlighted and never emits a payload.
51
+ def open? = @open
52
+
53
+ # Whether the fence has closed, and may therefore be highlighted or
54
+ # emit a payload.
55
+ def closed? = !open?
56
+
57
+ # The first word of the info string — what the registry is keyed on.
58
+ def language
59
+ info.split(/\s+/).first.to_s
60
+ end
61
+
62
+ # This language's registration, or nil when nobody registered it.
63
+ def registration
64
+ MaquinaStream.fences[language]
65
+ end
66
+
67
+ # The strategy in force for this fence: one of STRATEGIES, and
68
+ # DEFAULT_STRATEGY for anything unregistered or misregistered.
69
+ def strategy
70
+ candidate = registration&.options&.fetch(:strategy, nil) || DEFAULT_STRATEGY
71
+ STRATEGIES.include?(candidate) ? candidate : DEFAULT_STRATEGY
72
+ end
73
+
74
+ # The Stimulus identifier a `:client` fence hands its payload to, from
75
+ # the registration's `controller:`. Nil for any other strategy.
76
+ def controller
77
+ registration&.options&.fetch(:controller, nil)
78
+ end
79
+
80
+ # The Hash a `:client` fence hands its controller, or nil.
81
+ #
82
+ # Built by the registration's `payload:` callable, and `{source:, info:}`
83
+ # when it has none. It is serialized to JSON into a data attribute, so
84
+ # the callable must return something JSON-representable.
85
+ #
86
+ # The payload exists only once the fence has closed. Until then there is
87
+ # nothing here for the client to render, by design.
88
+ def payload
89
+ return nil unless strategy == :client && closed?
90
+
91
+ builder = registration&.options&.fetch(:payload, nil)
92
+ return {source: source, info: info} unless builder.respond_to?(:call)
93
+
94
+ builder.call(source, info)
95
+ end
96
+
97
+ # Highlighted HTML, or nil when this fence should not be highlighted at
98
+ # all: an open fence, a passthrough language, or a language Rouge does not
99
+ # know.
100
+ def highlighted
101
+ return nil unless strategy == :server && closed?
102
+
103
+ lexer = Rouge::Lexer.find(language)
104
+ return nil unless lexer
105
+
106
+ formatter.format(lexer.lex(source))
107
+ end
108
+
109
+ private
110
+ def formatter
111
+ @formatter ||= Rouge::Formatters::HTML.new
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,363 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MaquinaStream
4
+ class Renderer
5
+ # One walk over the parsed document. Everything that needs the tree happens
6
+ # here: fence strategies, table wrappers, element hooks, registered custom
7
+ # tags, and the reveal attributes that separate streaming from static.
8
+ #
9
+ # It is one pass on purpose. Each extra traversal is paid on every frame of
10
+ # every message.
11
+ class PostPass
12
+ ELEMENT_HOOKS = %w[h1 h2 h3 h4 h5 h6 p ul ol li table blockquote pre hr img a].freeze
13
+ ELEMENT_HOOK_SET = ELEMENT_HOOKS.to_set.freeze
14
+
15
+ # Elements that hold text of their own, and are therefore the unit the
16
+ # bidi algorithm should decide a direction for. Containers are left out:
17
+ # a <ul> takes its direction from each <li>, not the other way round.
18
+ DIRECTIONAL = %w[p h1 h2 h3 h4 h5 h6 li blockquote td th dt dd figcaption summary].to_set.freeze
19
+
20
+ attr_reader :fragment, :markdown, :mode, :config
21
+
22
+ def initialize(fragment, markdown:, mode:, config:)
23
+ @fragment = fragment
24
+ @markdown = markdown
25
+ @mode = mode
26
+ @config = config
27
+ end
28
+
29
+ def call
30
+ collect
31
+
32
+ rewrite_fences
33
+ wrap_tables
34
+ render_registered_tags
35
+ apply_overrides
36
+ annotate_blocks
37
+ fragment
38
+ end
39
+
40
+ private
41
+ attr_reader :tables, :tagged, :overrides
42
+
43
+ def streaming? = mode == :streaming
44
+
45
+ # The single walk this class has always claimed to be.
46
+ #
47
+ # Every CSS query over a 2,000-node document costs about 3.5ms, and this
48
+ # runs on every frame of every message: five queries were most of the
49
+ # frame. Attribute work happens inline because it cannot restructure the
50
+ # tree; anything that replaces a node is collected and applied after the
51
+ # walk, because replacing a node mid-traversal makes the walk skip
52
+ # siblings.
53
+ def collect
54
+ @code_blocks = []
55
+ @tables = []
56
+ @tagged = Hash.new { |hash, key| hash[key] = [] }
57
+ @overrides = []
58
+ registered = MaquinaStream.tags.keys.to_set
59
+
60
+ fragment.traverse do |node|
61
+ next unless node.element?
62
+
63
+ node.remove_attribute("data-sourcepos") if node.attribute("data-sourcepos")
64
+
65
+ name = node.name
66
+
67
+ if ELEMENT_HOOK_SET.include?(name)
68
+ node["data-ms-element"] = name
69
+ @overrides << node if MaquinaStream.elements.key?(name.to_sym)
70
+ end
71
+
72
+ annotate_direction(node) if DIRECTIONAL.include?(name)
73
+
74
+ case name
75
+ when "pre" then @code_blocks << node if node.at_css("> code")
76
+ when "table" then @tables << node
77
+ end
78
+
79
+ @tagged[name.to_sym] << node if registered.include?(name.to_sym)
80
+ end
81
+ end
82
+
83
+ # The open fence, when there is one, is the last one in the document:
84
+ # the buffer can only be cut in one place.
85
+ def open_fence_index
86
+ return @open_fence_index if defined?(@open_fence_index)
87
+
88
+ open = MaquinaRemend.context(markdown).in_code_fence?
89
+ @open_fence_index = open ? code_blocks.length - 1 : nil
90
+ end
91
+
92
+ attr_reader :code_blocks
93
+
94
+ def rewrite_fences
95
+ code_blocks.each_with_index do |pre, index|
96
+ code = pre.at_css("code")
97
+ fence = Fence.new(
98
+ info: language_of(code),
99
+ source: code.text,
100
+ open: index == open_fence_index,
101
+ config: config
102
+ )
103
+
104
+ replacement = render_fence(fence)
105
+ guard_bidi(replacement ? pre.replace(replacement) : pre, fence.source)
106
+ end
107
+ end
108
+
109
+ # Right-to-left is marked; left-to-right is the default and is left
110
+ # unsaid, so a document in Spanish or English pays nothing for this.
111
+ #
112
+ # Nested blocks are handled by the walk itself: a <li> inside an RTL
113
+ # <blockquote> is visited too, and answers for its own text. That is the
114
+ # point of deciding per block — a quotation in Hebrew inside an English
115
+ # answer reads correctly without the host configuring anything.
116
+ def annotate_direction(node)
117
+ node["dir"] = "rtl" if TextDirection.of(node.text) == :rtl
118
+ end
119
+
120
+ # Code is the one place where the bidi algorithm is a hazard rather than
121
+ # a service. An override character inside a comment reorders how the
122
+ # code READS without changing what it MEANS, which is the whole of a
123
+ # Trojan Source attack — and every character here was written by a model
124
+ # repeating text from somewhere else.
125
+ #
126
+ # The characters are not removed: the copy button hands back what the
127
+ # model actually wrote, and silently altering it would be worse. The
128
+ # block is pinned to one direction instead, so an override cannot escape
129
+ # the element it sits in.
130
+ def guard_bidi(node, source)
131
+ return unless TextDirection.controls?(source)
132
+
133
+ # `replace` answers with a node set; a passthrough fence answers with
134
+ # the one node it left alone. Not `Array()`: Nokogiri nodes are
135
+ # enumerable over their ATTRIBUTES, so it silently yields nothing.
136
+ nodes = node.is_a?(Nokogiri::XML::NodeSet) ? node : [node]
137
+ nodes.each { |inserted| inserted["dir"] = "ltr" if inserted.element? }
138
+ end
139
+
140
+ def render_fence(fence)
141
+ case fence.strategy
142
+ when :client then render_client_fence(fence)
143
+ when :passthrough then nil
144
+ else render_server_fence(fence)
145
+ end
146
+ end
147
+
148
+ # Cached on the locals, which is what the partial is a pure function of.
149
+ # A fence that closed twenty frames ago renders identically on every
150
+ # frame after it, and rendering it again is most of a frame's cost.
151
+ def render_server_fence(fence)
152
+ partial = Components.partial_for(:code_block, config: config)
153
+
154
+ # An open fence gets no controls. They would be inert anyway — copying
155
+ # half a code block is worse than not offering to — and the open block
156
+ # is re-sent on every frame, so its chrome is paid for over and over.
157
+ controls = fence.open? ? {} : config.controls[:code]
158
+
159
+ # The partial is generic — the engine's DOM contract and its labels
160
+ # are added here, at the call site, by Components::Contract. Labels
161
+ # are locale-dependent, so the locale is part of the key.
162
+ ComponentCache.fetch(partial, locale, fence.language, fence.open?, controls, fence.source) do
163
+ view.render(partial, Components::Contract.apply(
164
+ :code_block,
165
+ {
166
+ lang: fence.language,
167
+ source: fence.source,
168
+ highlighted: html_safe(fence.highlighted),
169
+ controls: controls
170
+ },
171
+ config: config
172
+ ))
173
+ end
174
+ end
175
+
176
+ # Rouge emits markup and escapes the source itself, so it is passed to
177
+ # the partial as markup rather than as text. It is not an exception to
178
+ # "never assign model output as raw HTML": the sanitizer still runs over
179
+ # the whole document afterwards, and it is the gate.
180
+ def html_safe(html)
181
+ html.respond_to?(:html_safe) ? html.html_safe : html
182
+ end
183
+
184
+ # Skeleton until the fence closes, then payload and controller. The
185
+ # skeleton is the shimmer component; there is no ad-hoc placeholder
186
+ # markup anywhere in the engine.
187
+ def render_client_fence(fence)
188
+ return render_shimmer(fence) if fence.open?
189
+
190
+ node = Nokogiri::XML::Node.new("div", fragment.document)
191
+ node["data-controller"] = fence.controller if fence.controller
192
+ node["data-#{fence.controller}-payload-value"] = payload_json(fence) if fence.controller
193
+
194
+ # What the reader sees when the renderer throws. It is rendered here
195
+ # because JavaScript cannot read I18n: a controller that hardcodes the
196
+ # sentence shows one language to every host, whatever locale it asked
197
+ # for. The controller keeps a fallback for a block a host mounted
198
+ # itself, and this is what makes that fallback the exception.
199
+ node["data-ms-deferred-error-label"] =
200
+ I18n.t("maquina_stream.deferred.error", locale: locale, default: "This block could not be rendered")
201
+
202
+ # Split ownership, per the DOM contract: the payload attribute is
203
+ # server state and belongs to morph, the output element is client
204
+ # state and belongs to the controller. The skeleton sits inside the
205
+ # output element so the controller replaces it when it renders.
206
+ #
207
+ # The stable id (ms-<sid>-b<n>-out) needs the message id, which a pure
208
+ # renderer does not have. Phase 3 stamps it along with the block ids,
209
+ # and data-turbo-permanent only takes effect once it is there.
210
+ output = Nokogiri::XML::Node.new("div", fragment.document)
211
+ output["data-#{fence.controller}-target"] = "output" if fence.controller
212
+ output["data-turbo-permanent"] = ""
213
+ output.inner_html = render_shimmer(fence)
214
+
215
+ node.add_child(output)
216
+ node.to_html
217
+ end
218
+
219
+ # The one skeleton, resolved through the seam like every other
220
+ # component. Nothing here names a partial path.
221
+ def render_shimmer(fence)
222
+ partial = Components.partial_for(:shimmer, config: config)
223
+
224
+ ComponentCache.fetch(partial, locale, fence.language) do
225
+ view.render(partial, label: fence.language)
226
+ end
227
+ end
228
+
229
+ # The HTML5 serializer escapes only &, " and NBSP inside an attribute
230
+ # value, so a fence containing "</div><script>" would sit in the payload
231
+ # with its angle brackets intact. It does not break out of the attribute,
232
+ # but it does mean the raw string is in the document; escaping at the
233
+ # JSON level keeps the payload inert whatever reads it next.
234
+ def payload_json(fence)
235
+ JSON.generate(fence.payload).gsub("<", "\\u003c").gsub(">", "\\u003e")
236
+ end
237
+
238
+ def language_of(code)
239
+ code["class"].to_s[/language-(\S+)/, 1].to_s
240
+ end
241
+
242
+ def wrap_tables
243
+ controls = config.controls[:table] || {}
244
+ interactive = controls.values.any?
245
+
246
+ tables.each do |table|
247
+ wrapper = Nokogiri::XML::Node.new("div", fragment.document)
248
+ wrapper["data-ms-table"] = ""
249
+ wrapper["data-controller"] = "ms-table" if interactive
250
+ table.replace(wrapper)
251
+ wrapper.add_child(table_controls(controls)) if interactive
252
+ wrapper.add_child(table)
253
+ table["data-ms-table-target"] = "table" if interactive
254
+ end
255
+ end
256
+
257
+ # The ms-table controller documents the markup it expects and emits none
258
+ # of it itself. This is engine chrome rather than a component: there is
259
+ # no vendored table component, and inventing one to hold
260
+ # three buttons would be worse than drawing them here.
261
+ def table_controls(controls)
262
+ bar = Nokogiri::XML::Node.new("div", fragment.document)
263
+ bar["data-ms-table-part"] = "controls"
264
+
265
+ if controls[:copy]
266
+ table_button(bar, "copy", "markdown", :copy_markdown)
267
+ table_button(bar, "copy", "csv", :copy_csv)
268
+ end
269
+ table_button(bar, "download", "csv", :download_csv) if controls[:download]
270
+ table_button(bar, "toggleFullscreen", nil, :fullscreen) if controls[:fullscreen]
271
+
272
+ bar
273
+ end
274
+
275
+ def table_button(bar, action, format, key)
276
+ node = Nokogiri::XML::Node.new("button", fragment.document)
277
+ node["type"] = "button"
278
+ # Marks it for the stream guard, which disables every control while
279
+ # the message is still being written.
280
+ node["data-ms-control"] = ""
281
+ node["data-action"] = "ms-table##{action}"
282
+ node["data-ms-table-format-param"] = format if format
283
+ label = I18n.t("maquina_stream.table.#{key}", locale: locale, default: key.to_s.tr("_", " "))
284
+ node["aria-label"] = label
285
+ node.content = label
286
+ bar.add_child(node)
287
+ end
288
+
289
+ # The host owns the locale, as in any Rails app: labels follow
290
+ # I18n.locale. `config.locale` is the engine's own default, used when
291
+ # the host has expressed no preference — see docs/javascript.md.
292
+ def locale
293
+ I18n.locale || config.locale
294
+ end
295
+
296
+ # A registered tag is rendered through its partial with only the
297
+ # attributes its registration allows. An unregistered tag is left for the
298
+ # sanitizer, which drops it.
299
+ def render_registered_tags
300
+ MaquinaStream.tags.each do |name, tag|
301
+ tagged[name].each do |node|
302
+ allowed = Array(tag.options[:attributes]).to_h { |key| [key.to_sym, node[key]] }
303
+ content = tag.options[:literal_content] ? node.text : node.inner_html
304
+
305
+ node.replace(view.render(tag.options[:partial], **allowed, content: content))
306
+ end
307
+ end
308
+ end
309
+
310
+ # One traversal, not one CSS query per element type. Eighteen queries
311
+ # over a 2,000-node document cost 31ms of a 68ms frame; walking it once
312
+ # costs a fraction of that, and this runs on every frame of every
313
+ # message.
314
+ #
315
+ # Overrides are collected first and applied afterwards: replacing a node
316
+ # while traversing the tree it is being read from is how a walk starts
317
+ # skipping siblings.
318
+ def apply_overrides
319
+ overrides.each { |node| apply_element_override(node.name, node) }
320
+ end
321
+
322
+ def apply_element_override(name, node)
323
+ registration = MaquinaStream.elements[name.to_sym]
324
+ return unless registration&.options&.key?(:partial)
325
+
326
+ node.replace(view.render(registration.options[:partial], content: node.inner_html, node: node))
327
+ end
328
+
329
+ # Top-level children are blocks. Numbering them here - rather than
330
+ # letting Phase 3 match by position - means the splitter survives the
331
+ # sanitizer dropping an element, which position matching would not.
332
+ # The index is derived from order, never from content.
333
+ def annotate_blocks
334
+ fragment.children.select(&:element?).each_with_index do |node, index|
335
+ node["data-ms-block-index"] = index.to_s
336
+ end
337
+ end
338
+
339
+ # There is deliberately no per-block chrome here any more.
340
+ #
341
+ # The caret and the reveal marker used to be stamped on each block, and
342
+ # it made a block's bytes disagree with its digest: the digest covers
343
+ # what a block SAYS, so repair could not correct chrome. Two tabs that
344
+ # lost different frames ended up visibly different — one with a caret,
345
+ # one without — and nothing could reconcile them, because their digests
346
+ # agreed.
347
+ #
348
+ # Chrome is derived instead, from the message element the host renders:
349
+ #
350
+ # ```css
351
+ # [data-ms-streaming] > [data-ms-block]:last-child { /* caret */ }
352
+ # [data-ms-streaming] > [data-ms-block] { /* reveal */ }
353
+ # ```
354
+ #
355
+ # A block is then exactly its content, identical content is identical
356
+ # bytes, and streaming and static output are the same document.
357
+
358
+ def view
359
+ @view ||= ViewContext.build
360
+ end
361
+ end
362
+ end
363
+ end