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,79 @@
1
+ # English is the secondary translation. It must stay complete: every key in
2
+ # es.yml exists here with the same shape, and
3
+ # test/maquina_stream/locales_test.rb fails the build when one is missing —
4
+ # a silent fallback to Spanish inside an English UI is a bug, not a default.
5
+ en:
6
+ maquina_stream:
7
+ number:
8
+ decimal_separator: "."
9
+ streaming:
10
+ controls_disabled: "Available once the response finishes"
11
+ deferred:
12
+ error: "This block could not be rendered"
13
+ shimmer:
14
+ label: "Loading…"
15
+ code:
16
+ copy: "Copy"
17
+ copy_code: "Copy the code"
18
+ copied: "Code copied"
19
+ copy_failed: "Couldn't copy"
20
+ download: "Download"
21
+ download_code: "Download the code"
22
+ language: "Language: %{name}"
23
+ snippet:
24
+ copy: "Copy"
25
+ copy_command: "Copy the command"
26
+ copied: "Command copied"
27
+ copy_failed: "Couldn't copy"
28
+ table:
29
+ copy: "Copy"
30
+ copy_markdown: "Copy as Markdown"
31
+ copy_csv: "Copy as CSV"
32
+ copy_tsv: "Copy as TSV"
33
+ copied: "Table copied"
34
+ copy_failed: "Couldn't copy"
35
+ download: "Download"
36
+ download_csv: "Download as CSV"
37
+ download_markdown: "Download as Markdown"
38
+ fullscreen: "Full screen"
39
+ exit_fullscreen: "Exit full screen"
40
+ image:
41
+ download: "Download"
42
+ download_image: "Download the image %{name}"
43
+ failed: "The image could not be loaded"
44
+ link_safety:
45
+ title: "You are leaving this site"
46
+ body: "This link goes to %{host}, an external site. Do you want to continue?"
47
+ host: "Destination"
48
+ confirm: "Continue"
49
+ cancel: "Cancel"
50
+ always_allow: "Always allow this site"
51
+ external_link: "External link to %{host}"
52
+ close: "Close"
53
+ autoscroll:
54
+ scroll_to_bottom: "Go to the end"
55
+ following: "Following the response"
56
+ source_citation:
57
+ unknown: "Source"
58
+ link_title: "Open the source %{name}"
59
+ attachment:
60
+ unnamed: "Attachment"
61
+ download: "Download"
62
+ download_file: "Download %{name}"
63
+ remove: "Remove"
64
+ remove_file: "Remove %{name}"
65
+ remove_confirm: "Remove this attachment?"
66
+ size: "%{value} %{unit}"
67
+ units:
68
+ bytes: "B"
69
+ kb: "kB"
70
+ mb: "MB"
71
+ gb: "GB"
72
+ tb: "TB"
73
+ suggestion:
74
+ list_label: "Suggestions"
75
+ chip_label: "Use the suggestion: %{text}"
76
+ export:
77
+ cancelled: "> _Response cancelled before it finished._"
78
+ errored: "> _The response ended with an error._"
79
+ timed_out: "> _The response stopped after a timeout._"
@@ -0,0 +1,82 @@
1
+ # Español es el idioma por defecto del motor. Cada etiqueta de control y cada
2
+ # nombre accesible vive aquí; ningún texto visible se escribe en línea en una
3
+ # plantilla o en un controlador Stimulus.
4
+ #
5
+ # test/maquina_stream/locales_test.rb exige que es.yml y en.yml tengan
6
+ # exactamente el mismo conjunto de claves: una traducción que falta rompe la
7
+ # compilación en vez de degradarse en silencio.
8
+ es:
9
+ maquina_stream:
10
+ number:
11
+ decimal_separator: ","
12
+ streaming:
13
+ controls_disabled: "Disponible cuando termine la respuesta"
14
+ deferred:
15
+ error: "No se pudo representar este bloque"
16
+ shimmer:
17
+ label: "Cargando…"
18
+ code:
19
+ copy: "Copiar"
20
+ copy_code: "Copiar el código"
21
+ copied: "Código copiado"
22
+ copy_failed: "No se pudo copiar"
23
+ download: "Descargar"
24
+ download_code: "Descargar el código"
25
+ language: "Lenguaje: %{name}"
26
+ snippet:
27
+ copy: "Copiar"
28
+ copy_command: "Copiar el comando"
29
+ copied: "Comando copiado"
30
+ copy_failed: "No se pudo copiar"
31
+ table:
32
+ copy: "Copiar"
33
+ copy_markdown: "Copiar como Markdown"
34
+ copy_csv: "Copiar como CSV"
35
+ copy_tsv: "Copiar como TSV"
36
+ copied: "Tabla copiada"
37
+ copy_failed: "No se pudo copiar"
38
+ download: "Descargar"
39
+ download_csv: "Descargar como CSV"
40
+ download_markdown: "Descargar como Markdown"
41
+ fullscreen: "Pantalla completa"
42
+ exit_fullscreen: "Salir de pantalla completa"
43
+ image:
44
+ download: "Descargar"
45
+ download_image: "Descargar la imagen %{name}"
46
+ failed: "La imagen no se pudo cargar"
47
+ link_safety:
48
+ title: "Vas a salir del sitio"
49
+ body: "Este enlace lleva a %{host}, un sitio externo. ¿Quieres continuar?"
50
+ host: "Destino"
51
+ confirm: "Continuar"
52
+ cancel: "Cancelar"
53
+ always_allow: "Permitir siempre este sitio"
54
+ external_link: "Enlace externo a %{host}"
55
+ close: "Cerrar"
56
+ autoscroll:
57
+ scroll_to_bottom: "Ir al final"
58
+ following: "Siguiendo la respuesta"
59
+ source_citation:
60
+ unknown: "Fuente"
61
+ link_title: "Abrir la fuente %{name}"
62
+ attachment:
63
+ unnamed: "Archivo adjunto"
64
+ download: "Descargar"
65
+ download_file: "Descargar %{name}"
66
+ remove: "Eliminar"
67
+ remove_file: "Eliminar %{name}"
68
+ remove_confirm: "¿Eliminar este adjunto?"
69
+ size: "%{value} %{unit}"
70
+ units:
71
+ bytes: "B"
72
+ kb: "kB"
73
+ mb: "MB"
74
+ gb: "GB"
75
+ tb: "TB"
76
+ suggestion:
77
+ list_label: "Sugerencias"
78
+ chip_label: "Usar la sugerencia: %{text}"
79
+ export:
80
+ cancelled: "> _Respuesta cancelada antes de terminar._"
81
+ errored: "> _La respuesta terminó con un error._"
82
+ timed_out: "> _La respuesta se interrumpió por tiempo de espera._"
data/config/routes.rb ADDED
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ MaquinaStream::Engine.routes.draw do
4
+ # Mounted at /maquina_stream by the host:
5
+ # GET /maquina_stream/:sid/manifest => { seq:, blocks: [[id, digest], …] }
6
+ # GET /maquina_stream/:sid/blocks?ids[]= => Turbo Stream, morph per block
7
+ scope ":sid" do
8
+ resource :manifest, only: :show
9
+ resources :blocks, only: :index
10
+ end
11
+ end
@@ -0,0 +1,219 @@
1
+ # Configuration
2
+
3
+ Configure once, from an initializer. Configuration is global and read on every
4
+ render, so changing it mid-stream changes what later frames of an open message
5
+ look like.
6
+
7
+ ```ruby
8
+ # config/initializers/maquina_stream.rb
9
+ MaquinaStream.configure do |c|
10
+ c.find_stream = ->(sid) { Message.find_by(id: sid) }
11
+ c.authorize = ->(record, request) { record.conversation.readable_by?(request) }
12
+ end
13
+ ```
14
+
15
+ Everything else has a working default. `MaquinaStream.config` returns the same
16
+ object outside a `configure` block.
17
+
18
+ ## Host seams
19
+
20
+ | Option | Default | What it does |
21
+ |---|---|---|
22
+ | `find_stream` | `nil` | Callable turning a stream id into a record. Unset, every repair request raises `ConfigurationError`. |
23
+ | `authorize` | `nil` | Callable deciding whether a request may see a record. **Unset, every repair request is refused.** |
24
+ | `transport` | `:turbo_streams` | Which transport `Broadcaster`'s default emitter uses. `:turbo_streams` is the only value the engine ships; the seam exists so SSE is possible without the broadcaster knowing about it. |
25
+
26
+ `find_stream` raises when unset and `authorize` denies when unset, and the
27
+ difference is deliberate: a silent `nil` from the finder would look like a
28
+ missing record rather than a missing seam, while denial is always the safe
29
+ answer to "may this request see this?".
30
+
31
+ See [repair.md](repair.md) for both in context.
32
+
33
+ ## Streaming cadence
34
+
35
+ | Option | Default | What it does |
36
+ |---|---|---|
37
+ | `frame_budget_ms` | `100` | How long appends coalesce before one frame goes out. |
38
+ | `seal_lag` | `2` | How many blocks must open after a block before it may freeze. |
39
+ | `keyframe_interval_ms` | `4000` | How often the client reconciles its DOM against the manifest. |
40
+ | `manifest_window` | `50` | How many recent sealed blocks a manifest carries in full. |
41
+
42
+ ### Choosing `frame_budget_ms`
43
+
44
+ This is the one number worth thinking about, because the right value depends on
45
+ how *your* model delivers text. Coalescing only saves bytes when appends arrive
46
+ faster than the budget: below that, raising it merges frames and cuts bandwidth;
47
+ above it, raising it does nothing but add latency.
48
+
49
+ Measured on a 20KB message, as a multiple of the size of the rendered document.
50
+ Find the row that matches your model and pick a column you can live with:
51
+
52
+ | How your text arrives | 60ms | 100ms | 150ms | 250ms |
53
+ |---|---|---|---|---|
54
+ | token by token — 4 chars every 25ms | 3.03x | 2.34x | 1.65x | 1.08x |
55
+ | batched — 40 chars every 200ms | 1.07x | 1.07x | 1.07x | 0.89x |
56
+ | step by step — 2000 chars every second | 1.17x | 1.17x | 1.17x | 1.17x |
57
+
58
+ **If your provider streams token by token, raise this.** At the default you pay
59
+ 2.34x; at 250ms you pay 1.08x, and the word-level reveal animation covers the
60
+ coarser cadence so the reader does not see the difference.
61
+
62
+ **If your text arrives in batches or whole steps, leave it alone.** Raising it
63
+ buys nothing at those cadences, and at 250ms it starts silently merging two
64
+ steps into one frame — which costs the per-step feedback that is the reason to
65
+ stream a step at all.
66
+
67
+ ### `seal_lag`
68
+
69
+ A block freezes only once `seal_lag` later blocks exist. Markdown reinterprets
70
+ backwards — a paragraph becomes a heading when its underline arrives — so a
71
+ block that is still near the tail is still moving. Lower it and you freeze
72
+ blocks that were about to change; raise it and more blocks stay in the patch set
73
+ of every frame.
74
+
75
+ The seal pointer additionally stops at a block holding an unresolved link
76
+ reference, because `[docs]` cannot be rendered until `[docs]:` arrives.
77
+
78
+ With the default, a four-block message has two sealed blocks and two open ones:
79
+
80
+ ```ruby
81
+ doc = MaquinaStream::Document.new("# Uno\n\nDos\n\nTres\n\nCuatro\n",
82
+ config: MaquinaStream.config, sid: "42", mode: :streaming)
83
+
84
+ doc.blocks.map { |b| [b.id, b.sealed?] }
85
+ # => [["ms-42-b0", true], ["ms-42-b1", true], ["ms-42-b2", false], ["ms-42-b3", false]]
86
+ ```
87
+
88
+ ### `keyframe_interval_ms` and `manifest_window`
89
+
90
+ The keyframe is the periodic "am I still right?" check. Lower it and drift is
91
+ corrected sooner, at the cost of one small request per interval per open
92
+ message. The request is small because the manifest is windowed: the last
93
+ `manifest_window` sealed blocks in full, plus one rollup digest covering
94
+ everything older, so the payload is bounded by the window rather than by the
95
+ message.
96
+
97
+ ## Presentation
98
+
99
+ | Option | Default | What it does |
100
+ |---|---|---|
101
+ | `locale` | `:es` | Fallback locale for the engine's own labels when `I18n.locale` is unset. Spanish and English both ship complete. |
102
+ | `components` | `:maquina` | `:maquina` renders through `maquina_components` when that gem is installed and defines a component; `:plain` forces the engine's own Tailwind fallback even when the gem is present. |
103
+ | `themes` | `{light: "github.light", dark: "github.dark"}` | Rouge theme names for the two generated highlighting stylesheets. |
104
+ | `controls` | every control on | Which interactive affordances render. |
105
+
106
+ ### Themes
107
+
108
+ Highlighting emits CSS classes and never inline colour, so switching to dark
109
+ mode is a stylesheet concern and needs no re-render — which matters, because a
110
+ re-render mid-stream would mean re-broadcasting sealed blocks to change a
111
+ colour.
112
+
113
+ Two stylesheets ship generated. Load both; they are scoped so only one applies:
114
+
115
+ ```erb
116
+ <%= stylesheet_link_tag "maquina_stream/themes/light" %>
117
+ <%= stylesheet_link_tag "maquina_stream/themes/dark" %>
118
+ ```
119
+
120
+ ```css
121
+ /* every rule is scoped to a code block and to one scheme */
122
+ :root:not([data-theme="dark"]) [data-ms-code] … { }
123
+ [data-theme="dark"] [data-ms-code] … { }
124
+ ```
125
+
126
+ Changing `config.themes` means regenerating them:
127
+
128
+ ```sh
129
+ bundle exec rake maquina_stream:themes
130
+ ```
131
+
132
+ Names are Rouge's own — the registry has `github.light` and `github.dark`, not
133
+ `github_dark`. An unknown name raises `MaquinaStream::Themes::UnknownTheme`
134
+ rather than falling back silently.
135
+
136
+ ### Controls
137
+
138
+ Every control, and its group:
139
+
140
+ | Group | Controls |
141
+ |---|---|
142
+ | `code` | `copy`, `download` |
143
+ | `table` | `copy`, `download`, `fullscreen` |
144
+ | `image` | `download` |
145
+ | `attachment` | `download`, `remove` |
146
+ | `suggestion` | `enabled` |
147
+ | `link_safety` | a single boolean, not a group |
148
+
149
+ An assigned hash merges onto the defaults one level deep, so you name only what
150
+ you are changing:
151
+
152
+ ```ruby
153
+ c.controls = {code: {download: false}} # copy stays on
154
+ c.controls = false # everything off
155
+ c.controls = true # everything back on
156
+ ```
157
+
158
+ ```ruby
159
+ config.control?(:code, :copy) # => true
160
+ config.control?(:code, :download) # => false, after the first line above
161
+ config.control?(:code) # => true, while any code control remains
162
+ ```
163
+
164
+ Turning a group off removes the buttons the renderer emits. A table with no
165
+ controls left loses its control bar and its `data-controller` too — a controller
166
+ with nothing to drive is cost on every frame:
167
+
168
+ ```html
169
+ <!-- controls = false -->
170
+ <div data-ms-table><table data-ms-element="table">…</table></div>
171
+ ```
172
+
173
+ Controls you render yourself should carry `data-ms-control`, or they stay
174
+ clickable while the message is still streaming. See [javascript.md](javascript.md).
175
+
176
+ ## URL hardening
177
+
178
+ Read by the sanitizer, which is the last pass before any HTML leaves the server.
179
+ Every one of these loosens or tightens what a **model** may put in an `href` or
180
+ a `src`, and model output is prompt-injectable.
181
+
182
+ | Option | Default | What it does |
183
+ |---|---|---|
184
+ | `default_origin` | `nil` | Base for resolving relative URLs. `nil` leaves a relative URL relative. |
185
+ | `allowed_protocols` | `%w[http https mailto]` | The only schemes that survive. |
186
+ | `allowed_link_prefixes` | `["*"]` | `"*"` allows any destination. A list of prefixes strips the `href` off every link not starting with one; the text stays. |
187
+ | `allowed_image_prefixes` | `["*"]` | Same, for images. An image whose `src` does not survive is removed entirely. |
188
+ | `allow_data_images` | `true` | Whether `data:` image URLs survive. Only base64 rasters ever do. |
189
+
190
+ ```ruby
191
+ c.allowed_link_prefixes = ["https://example.com/"]
192
+ ```
193
+
194
+ ```html
195
+ <!-- in --> <a href="https://evil.test/x">a</a> <a href="https://example.com/ok">b</a>
196
+ <!-- out --> <a>a</a> <a href="https://example.com/ok" rel="noopener noreferrer">b</a>
197
+ ```
198
+
199
+ ```ruby
200
+ c.default_origin = "https://app.example.com"
201
+ ```
202
+
203
+ ```html
204
+ <!-- in --> <a href="/docs">d</a>
205
+ <!-- out --> <a href="https://app.example.com/docs" rel="noopener noreferrer">d</a>
206
+ ```
207
+
208
+ `data:image/svg+xml` is refused whatever `allow_data_images` is set to: it is a
209
+ scriptable document wearing an image's MIME type. Full detail in
210
+ [security.md](security.md).
211
+
212
+ ## Resetting
213
+
214
+ ```ruby
215
+ MaquinaStream.reset_configuration!
216
+ ```
217
+
218
+ For tests. Called in production it loses your `find_stream` and `authorize`
219
+ seams, and every repair request after it is refused.
@@ -0,0 +1,184 @@
1
+ # Client-deferred renderers
2
+
3
+ Some things cannot be rendered on the server without shipping a rendering engine
4
+ with them — a Mermaid diagram, a KaTeX formula. Those render in the browser,
5
+ from **one JSON payload for one leaf node**.
6
+
7
+ This is not an exception to "only rendered HTML reaches the browser". The client
8
+ never parses markdown; it receives a payload for a single, already-identified
9
+ block and nothing else.
10
+
11
+ ## What the engine ships
12
+
13
+ Two renderers, and no fence name for either. Which fence means "diagram" is your
14
+ decision:
15
+
16
+ ```ruby
17
+ # config/initializers/maquina_stream.rb
18
+ MaquinaStream.register_fence "mermaid",
19
+ strategy: :client,
20
+ controller: "ms-diagram",
21
+ payload: ->(source, info) { {source: source, info: info} }
22
+
23
+ MaquinaStream.register_fence "math",
24
+ strategy: :client,
25
+ controller: "ms-math",
26
+ payload: ->(source, _info) { {source: source, display: true} }
27
+ ```
28
+
29
+ Pin the libraries yourself. The engine never pins a third-party library — one
30
+ that pinned Mermaid would win or lose a version fight with your app for no
31
+ reason — and `preload: false` is what keeps the lazy import lazy:
32
+
33
+ ```ruby
34
+ # config/importmap.rb
35
+ pin "mermaid", to: "https://cdn.jsdelivr.net/npm/mermaid@11.4.1/+esm", preload: false
36
+ pin "katex", to: "https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.mjs", preload: false
37
+ ```
38
+
39
+ NoBuild: there is no lockfile, so the version lives in the importmap and nowhere
40
+ else.
41
+
42
+ ## What it renders
43
+
44
+ While the fence is open there is no payload and no controller — just a skeleton:
45
+
46
+ ````markdown
47
+ ```mermaid
48
+ graph TD; A-->B;
49
+ ````
50
+
51
+ ```html
52
+ <div data-component="shimmer" role="status" aria-busy="true" aria-live="polite">
53
+ <span class="sr-only">mermaid</span>
54
+
55
+ </div>
56
+ ```
57
+
58
+ Once it closes:
59
+
60
+ ````markdown
61
+ ```mermaid
62
+ graph TD; A-->B;
63
+ ```
64
+ ````
65
+
66
+ ```html
67
+ <div data-controller="ms-diagram"
68
+ data-ms-diagram-payload-value='{"source":"graph TD; A--\u003eB;\n","info":"mermaid"}'>
69
+ <div data-ms-diagram-target="output" data-turbo-permanent>…shimmer…</div>
70
+ </div>
71
+ ```
72
+
73
+ **Split ownership.** The payload attribute is server state and belongs to morph.
74
+ The output element is client state, is `data-turbo-permanent`, and belongs to
75
+ the controller. A repair morph that leaves the payload byte-identical fires no
76
+ value-changed callback, so it triggers no re-render and no flicker.
77
+
78
+ **No payload until the fence closes.** Handing the client half a diagram to draw
79
+ produces an error state for text that was merely still arriving.
80
+
81
+ ## Writing your own
82
+
83
+ Nothing about a renderer lives in the engine. The registry carries the
84
+ controller name and the payload shape; the controller is yours.
85
+
86
+ ```ruby
87
+ MaquinaStream.register_fence "timeline",
88
+ strategy: :client,
89
+ controller: "ms-timeline",
90
+ payload: ->(source, info) { {source: source, info: info, format: "timeline"} }
91
+ ```
92
+
93
+ ```js
94
+ import MsDeferredController from "maquina_stream/controllers/ms_deferred_controller"
95
+
96
+ class MsTimelineController extends MsDeferredController {
97
+ static library = null
98
+
99
+ async library() {
100
+ if (!this.constructor.library) {
101
+ const timeline = await import("timeline")
102
+ this.constructor.library = timeline.default ?? timeline
103
+ }
104
+
105
+ return this.constructor.library
106
+ }
107
+
108
+ async draw(payload) {
109
+ const timeline = await this.library()
110
+ return timeline.renderToString(String(payload.source ?? ""))
111
+ }
112
+ }
113
+
114
+ application.register("ms-timeline", MsTimelineController)
115
+ ```
116
+
117
+ Two methods: `library()` imports lazily and memoizes on the class, `draw(payload)`
118
+ returns a markup string. Everything else is decided for you.
119
+
120
+ `payload:` is a callable receiving `(source, info)` and returning a Hash; it
121
+ defaults to `{source:, info:}`. It is serialized as JSON into a data attribute,
122
+ so it must be JSON-representable.
123
+
124
+ `ms-timeline` appears nowhere in the engine. The dummy app registers exactly
125
+ this.
126
+
127
+ ## What `ms-deferred` decides for every renderer
128
+
129
+ - **When to render.** On intersection, not on connect: a conversation scrolled
130
+ back through hundreds of messages must not render hundreds of diagrams nobody
131
+ is looking at. Set `data-ms-<name>-eager-value="true"` to override.
132
+ - **Lazy import.** The library is imported the first time something is actually
133
+ about to draw. A page with no deferred content loads no renderer library.
134
+ - **Re-rendering.** Only when the payload really changed. A morph that leaves it
135
+ byte-identical does nothing.
136
+ - **Sanitization of the output.** An allowlist, applied to whatever the library
137
+ returns — SVG and MathML elements, the geometry and presentation attributes
138
+ they need, and nothing that takes a URL except an `href` starting `https:`,
139
+ `http:`, `mailto:` or `#`. The payload is model output and the library is
140
+ third-party; neither is a reason to skip the check. This runs *in addition to*
141
+ the server's sanitizer. See [security.md](security.md).
142
+ - **Failure.** A broken payload never breaks the message.
143
+
144
+ ## The export fallback
145
+
146
+ **Show the source the model wrote.** That is the answer for every deferred
147
+ renderer, implemented once, and used for the error state, for
148
+ `MaquinaStream::Export.markdown`, and for any environment where the renderer
149
+ cannot run:
150
+
151
+ ```html
152
+ <div data-ms-deferred-error role="note">
153
+ <p>This block could not be rendered.</p>
154
+ <pre>graph TD; A-->B;</pre>
155
+ </div>
156
+ ```
157
+
158
+ Alt text would be a summary nobody wrote, and a server-rendered substitute would
159
+ mean shipping the rendering engine to the server — which is the reason these are
160
+ deferred at all.
161
+
162
+ The label is read from `data-ms-deferred-error-label` on the block itself, and
163
+ falls back to the English string above when there is none — JavaScript cannot
164
+ read `I18n`, and hardcoding the engine's default locale would show Spanish to a
165
+ host that never asked for it. A renderer of your own can put a translated label
166
+ on the element it renders.
167
+
168
+ ## Security posture
169
+
170
+ | Renderer | Setting | What it buys |
171
+ |---|---|---|
172
+ | `ms-diagram` | `securityLevel: "strict"` | disables click handlers and inline HTML in diagram source, which is model output |
173
+ | `ms-math` | `trust: false` | refuses `\htmlClass`, `\includegraphics` and `\href`, all of which take attacker-controlled strings into the DOM |
174
+ | both | output allowlist | the library is third-party; its output is scrubbed before it reaches the DOM |
175
+
176
+ `ms-math` also runs with `throwOnError: false`, so a malformed formula degrades
177
+ to the fallback rather than taking the message down.
178
+
179
+ ## Events
180
+
181
+ | Event | Detail |
182
+ |---|---|
183
+ | `ms:rendered` | `{controller}` |
184
+ | `ms:render-failed` | `{controller, error}` |