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,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}` |
|