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,356 @@
|
|
|
1
|
+
# Getting started
|
|
2
|
+
|
|
3
|
+
From `bundle add maquina_stream` to a message streaming into a browser: two
|
|
4
|
+
commands, two seams you fill in yourself, one view.
|
|
5
|
+
|
|
6
|
+
## 1. Install
|
|
7
|
+
|
|
8
|
+
```ruby
|
|
9
|
+
# Gemfile
|
|
10
|
+
gem "maquina_stream"
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Rails 8, Ruby 3.3+. The engine pulls in `maquina_remend`, `commonmarker`,
|
|
14
|
+
`nokogiri` and `rouge`. `maquina_components` is optional; without it the engine
|
|
15
|
+
renders its own Tailwind fallbacks.
|
|
16
|
+
|
|
17
|
+
It ships no migrations and no models. **The host owns persistence.**
|
|
18
|
+
|
|
19
|
+
**Turbo is required.** Repair applies Turbo Stream morphs, and with
|
|
20
|
+
`window.Turbo` undefined every repair fails inside a catch — the message stops
|
|
21
|
+
being correct and nothing in the browser says so. The install generator pins it
|
|
22
|
+
if your Gemfile has `turbo-rails` and refuses loudly if it does not.
|
|
23
|
+
|
|
24
|
+
## 2. Two commands
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
bin/rails generate maquina_stream:install
|
|
28
|
+
bin/rails generate maquina_stream:streamable Message
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
create config/initializers/maquina_stream.rb
|
|
33
|
+
route mount MaquinaStream::Engine => "/maquina_stream"
|
|
34
|
+
append config/importmap.rb
|
|
35
|
+
append app/javascript/controllers/index.js
|
|
36
|
+
insert app/views/layouts/application.html.erb
|
|
37
|
+
|
|
38
|
+
create db/migrate/20260101000000_create_messages.rb
|
|
39
|
+
create app/models/message.rb
|
|
40
|
+
gsub config/initializers/maquina_stream.rb
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Both are idempotent and neither overwrites a file. Re-run them as often as you
|
|
44
|
+
like; anything already in place is reported and left alone. A step whose
|
|
45
|
+
ingredient is missing — no importmap, no Stimulus entrypoint, no layout — prints
|
|
46
|
+
the exact content to paste rather than failing or silently doing nothing.
|
|
47
|
+
|
|
48
|
+
`streamable` is per model, because a host may have several: an assistant
|
|
49
|
+
message and a tool call are two streams, not one.
|
|
50
|
+
|
|
51
|
+
```sh
|
|
52
|
+
bin/rails db:migrate
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
The migration carries the three columns the contract needs — the buffer, plus
|
|
56
|
+
`stream_sequence` and `stream_status` — and generates them from
|
|
57
|
+
`MaquinaStream::Streamable` itself, so it cannot drift from the contract
|
|
58
|
+
`maquina_stream_contract_gaps` checks. A model whose table already exists gets
|
|
59
|
+
`add_column` for only the columns it lacks.
|
|
60
|
+
|
|
61
|
+
| Flag | What it does |
|
|
62
|
+
|---|---|
|
|
63
|
+
| `--buffer=body` | Names a different column as the markdown buffer |
|
|
64
|
+
| `--stream-for="[:conversation, record.conversation_id, :messages]"` | Sets the Turbo broadcast target |
|
|
65
|
+
| `--deferred-renderers` (install) | Also pins `mermaid` and `katex`, with `preload: false` |
|
|
66
|
+
|
|
67
|
+
Assert the contract in your own suite, so a missing column fails at test time
|
|
68
|
+
rather than mid-stream:
|
|
69
|
+
|
|
70
|
+
```ruby
|
|
71
|
+
test "Message satisfies the maquina_stream contract" do
|
|
72
|
+
assert_empty Message.maquina_stream_contract_gaps
|
|
73
|
+
end
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## 3. The two seams
|
|
77
|
+
|
|
78
|
+
The generators write everything the engine can know. These two it cannot.
|
|
79
|
+
|
|
80
|
+
### `authorize`, in the initializer
|
|
81
|
+
|
|
82
|
+
```ruby
|
|
83
|
+
# config/initializers/maquina_stream.rb
|
|
84
|
+
c.authorize = ->(record, request) { false } # ← the generated stub
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
**As generated it denies every repair request.** That is the safe failure and a
|
|
88
|
+
broken feature both: the browser can never repair a message until you replace
|
|
89
|
+
it. Guessing a host's authorization is how an engine leaks other people's
|
|
90
|
+
messages, so it guesses nothing.
|
|
91
|
+
|
|
92
|
+
```ruby
|
|
93
|
+
c.authorize = ->(record, request) do
|
|
94
|
+
record.conversation.member?(request.session[:user_id])
|
|
95
|
+
end
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
`find_stream` is the other seam, and `maquina_stream:streamable` fills it in:
|
|
99
|
+
|
|
100
|
+
```ruby
|
|
101
|
+
c.find_stream = ->(sid) { Message.find_by(id: sid) }
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Unset, it raises rather than returning `nil` — a silent `nil` would look like a
|
|
105
|
+
missing record rather than a missing seam. Every other option in the generated
|
|
106
|
+
initializer is commented, with the default it already has.
|
|
107
|
+
[configuration.md](configuration.md) explains each one.
|
|
108
|
+
|
|
109
|
+
### `stream_for:`, in the model
|
|
110
|
+
|
|
111
|
+
```ruby
|
|
112
|
+
class Message < ApplicationRecord
|
|
113
|
+
include MaquinaStream::Streamable
|
|
114
|
+
|
|
115
|
+
maquina_stream buffer: :content,
|
|
116
|
+
stream_for: ->(record) { record }
|
|
117
|
+
end
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
`stream_for:` returns the Turbo broadcast target. The generated one gives every
|
|
121
|
+
message a stream of its own; a conversation-wide target is usually what you
|
|
122
|
+
want:
|
|
123
|
+
|
|
124
|
+
```ruby
|
|
125
|
+
stream_for: ->(record) { [:conversation, record.conversation_id, :messages] }
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
The engine never guesses one, because who may subscribe to a stream is your
|
|
129
|
+
question, not the engine's.
|
|
130
|
+
|
|
131
|
+
## 4. The view
|
|
132
|
+
|
|
133
|
+
The engine appends block HTML into `#ms-msg-<sid>` and never creates that
|
|
134
|
+
element. The wrapper, the controllers on it and its repair URLs are yours:
|
|
135
|
+
|
|
136
|
+
```erb
|
|
137
|
+
<%# app/views/messages/_message.html.erb %>
|
|
138
|
+
<article id="live-msg-<%= message.maquina_stream_id %>">
|
|
139
|
+
<div id="ms-msg-<%= message.maquina_stream_id %>"
|
|
140
|
+
data-controller="ms-repair ms-reveal"
|
|
141
|
+
data-ms-repair-manifest-url-value="<%= maquina_stream.manifest_path(sid: message.maquina_stream_id) %>"
|
|
142
|
+
data-ms-repair-blocks-url-value="<%= maquina_stream.blocks_path(sid: message.maquina_stream_id) %>"
|
|
143
|
+
data-ms-repair-interval-value="4000"
|
|
144
|
+
<%= "data-ms-streaming" if message.maquina_stream_open? %>><%= MaquinaStream.render(message) %></div>
|
|
145
|
+
</article>
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
`maquina_stream.` is the mounted engine's route proxy, so those two paths follow
|
|
149
|
+
wherever the install generator mounted it.
|
|
150
|
+
|
|
151
|
+
`data-ms-streaming` is the one attribute you have to keep correct. It is stamped
|
|
152
|
+
from `maquina_stream_open?`, and everything derived from "this message is still
|
|
153
|
+
being written" reads it: the caret CSS, the reveal animation, and the guard that
|
|
154
|
+
keeps copy and download buttons inert while a code block is half-arrived.
|
|
155
|
+
Taking it off is what a seal looks like in the DOM.
|
|
156
|
+
|
|
157
|
+
Subscribe the page to the same target you gave `stream_for:`:
|
|
158
|
+
|
|
159
|
+
```erb
|
|
160
|
+
<%= turbo_stream_from :conversation, @conversation.id, :messages %>
|
|
161
|
+
<div id="messages">
|
|
162
|
+
<%= render @messages %>
|
|
163
|
+
</div>
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
## 5. Stream one message end to end
|
|
167
|
+
|
|
168
|
+
Create the record, broadcast an empty shell so the browser has somewhere to put
|
|
169
|
+
blocks, then feed the broadcaster:
|
|
170
|
+
|
|
171
|
+
```ruby
|
|
172
|
+
class Message < ApplicationRecord
|
|
173
|
+
# …
|
|
174
|
+
|
|
175
|
+
def broadcast_shell
|
|
176
|
+
if maquina_stream_open?
|
|
177
|
+
Turbo::StreamsChannel.broadcast_append_to(
|
|
178
|
+
maquina_stream_target,
|
|
179
|
+
target: "messages",
|
|
180
|
+
partial: "messages/message",
|
|
181
|
+
locals: {message: self}
|
|
182
|
+
)
|
|
183
|
+
else
|
|
184
|
+
Turbo::StreamsChannel.broadcast_replace_to(
|
|
185
|
+
maquina_stream_target,
|
|
186
|
+
target: "live-msg-#{maquina_stream_id}",
|
|
187
|
+
partial: "messages/message",
|
|
188
|
+
locals: {message: self},
|
|
189
|
+
attributes: {"method" => "morph"}
|
|
190
|
+
)
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def stream_from(chat, prompt, broadcaster: MaquinaStream::Broadcaster.new(self))
|
|
195
|
+
chat.ask(prompt) do |chunk|
|
|
196
|
+
text = chunk.content.to_s
|
|
197
|
+
broadcaster.append(text) unless text.empty?
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
broadcaster.seal!
|
|
201
|
+
rescue
|
|
202
|
+
broadcaster.seal!(status: :errored)
|
|
203
|
+
raise
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
```ruby
|
|
209
|
+
message = Message.create!(conversation: conversation)
|
|
210
|
+
message.broadcast_shell
|
|
211
|
+
message.stream_from(chat, params[:prompt])
|
|
212
|
+
message.broadcast_shell # again, now sealed: takes data-ms-streaming off
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
The shell goes out twice — once empty when the record opens, once more when it
|
|
216
|
+
seals. The second one morphs, so the blocks the deltas already delivered are
|
|
217
|
+
reconciled rather than deleted and recreated.
|
|
218
|
+
|
|
219
|
+
**Always seal.** A stream that ends without a seal leaves every client waiting
|
|
220
|
+
for a frame that never arrives; that is why the `rescue` above seals as
|
|
221
|
+
`errored` before re-raising.
|
|
222
|
+
|
|
223
|
+
## 6. Watch it work
|
|
224
|
+
|
|
225
|
+
Two live pages in `test/dummy` talk to a real model rather than a fixture:
|
|
226
|
+
|
|
227
|
+
| Page | What it is |
|
|
228
|
+
|---|---|
|
|
229
|
+
| `/harness/chat` | Bare `ruby_llm`. `chat.ask` yields chunks, one record, one seal. |
|
|
230
|
+
| `/harness/agent` | [Nexo](https://maquina.app/documentation/nexo/). One `Streamable` record per step of an agent run. |
|
|
231
|
+
|
|
232
|
+
Both read `test/dummy/config/llm.yml`, which is git-ignored. Copy the example
|
|
233
|
+
and point it at any OpenAI-compatible endpoint — ollama, vLLM, LM Studio,
|
|
234
|
+
OpenRouter and OpenAI itself all speak it:
|
|
235
|
+
|
|
236
|
+
```sh
|
|
237
|
+
cp test/dummy/config/llm.yml.example test/dummy/config/llm.yml
|
|
238
|
+
cd test/dummy && bundle exec puma -p 3001 config.ru
|
|
239
|
+
# http://localhost:3001/harness
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
There is no ENV fallback and no default host: a missing or half-written file
|
|
243
|
+
makes the pages say what to create rather than fail against somebody else's
|
|
244
|
+
endpoint.
|
|
245
|
+
|
|
246
|
+
`/harness` itself is the fixture page — every control, the link dialog, the
|
|
247
|
+
autoscroll pane, the deferred renderers — and `/history` shows a page of sealed
|
|
248
|
+
messages served from the render cache.
|
|
249
|
+
|
|
250
|
+
## What the generators do for you
|
|
251
|
+
|
|
252
|
+
Every step, for a host that would rather do it by hand — or that has to,
|
|
253
|
+
because it uses a bundler instead of importmaps.
|
|
254
|
+
|
|
255
|
+
### `maquina_stream:install`
|
|
256
|
+
|
|
257
|
+
**The initializer**, `config/initializers/maquina_stream.rb`, with every option
|
|
258
|
+
commented at its default and the two seams stubbed:
|
|
259
|
+
|
|
260
|
+
```ruby
|
|
261
|
+
MaquinaStream.configure do |c|
|
|
262
|
+
c.find_stream = ->(sid) { Message.find_by(id: sid) }
|
|
263
|
+
c.authorize = ->(record, request) { record.conversation.readable_by?(request) }
|
|
264
|
+
end
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
**The mount**, in `config/routes.rb`. The engine contributes two `GET` routes —
|
|
268
|
+
the repair path — and nothing else:
|
|
269
|
+
|
|
270
|
+
```ruby
|
|
271
|
+
mount MaquinaStream::Engine => "/maquina_stream"
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
**The Turbo pin**, in `config/importmap.rb`. The engine appends its own pins to
|
|
275
|
+
your importmap through its engine initializer, and pins nothing third-party —
|
|
276
|
+
not even Stimulus, because an engine that pinned it would win or lose a version
|
|
277
|
+
fight with your app for no reason:
|
|
278
|
+
|
|
279
|
+
```ruby
|
|
280
|
+
pin "@hotwired/turbo-rails", to: "turbo.min.js"
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
**The Stimulus registration**, appended to `app/javascript/controllers/index.js`
|
|
284
|
+
or, failing that, to whichever entrypoint calls `Application.start()`:
|
|
285
|
+
|
|
286
|
+
```js
|
|
287
|
+
import { registerMaquinaStreamControllers } from "maquina_stream"
|
|
288
|
+
registerMaquinaStreamControllers(application)
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
The engine registers its own identifiers rather than relying on your eager-load
|
|
292
|
+
glob, because those identifiers are part of the DOM contract. See
|
|
293
|
+
[javascript.md](javascript.md).
|
|
294
|
+
|
|
295
|
+
**The stylesheets**, injected into the layout's `<head>`:
|
|
296
|
+
|
|
297
|
+
```erb
|
|
298
|
+
<%= stylesheet_link_tag "maquina_stream/reveal" %>
|
|
299
|
+
<%= stylesheet_link_tag "maquina_stream/themes/light" %>
|
|
300
|
+
<%= stylesheet_link_tag "maquina_stream/themes/dark" %>
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
**The deferred-renderer pins**, with `--deferred-renderers`:
|
|
304
|
+
|
|
305
|
+
```ruby
|
|
306
|
+
pin "mermaid", to: "https://cdn.jsdelivr.net/npm/mermaid@11.4.1/+esm", preload: false
|
|
307
|
+
pin "katex", to: "https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.mjs", preload: false
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
`preload: false` is load-bearing. importmap-rails preloads by default, which
|
|
311
|
+
emits a `<link rel="modulepreload">` and fetches both libraries on every page —
|
|
312
|
+
exactly the cost the lazy import inside the deferred controller exists to
|
|
313
|
+
avoid. Pin an exact version: NoBuild means no lockfile, so the version lives
|
|
314
|
+
there and nowhere else.
|
|
315
|
+
|
|
316
|
+
### `maquina_stream:streamable`
|
|
317
|
+
|
|
318
|
+
**The migration.** The `maquina_stream` macro generates its contract methods
|
|
319
|
+
from three columns: the one you name as the buffer, plus `stream_sequence` and
|
|
320
|
+
`stream_status`.
|
|
321
|
+
|
|
322
|
+
```ruby
|
|
323
|
+
create_table :messages do |t|
|
|
324
|
+
t.text :content, null: false, default: ""
|
|
325
|
+
t.integer :stream_sequence, null: false, default: 0
|
|
326
|
+
t.string :stream_status, null: false, default: "open"
|
|
327
|
+
t.timestamps
|
|
328
|
+
end
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
`stream_status` holds one of `open`, `complete`, `cancelled`, `errored` or
|
|
332
|
+
`timed_out`.
|
|
333
|
+
|
|
334
|
+
**The model**, created if it is missing and injected into if it is not:
|
|
335
|
+
|
|
336
|
+
```ruby
|
|
337
|
+
class Message < ApplicationRecord
|
|
338
|
+
include MaquinaStream::Streamable
|
|
339
|
+
|
|
340
|
+
maquina_stream buffer: :content,
|
|
341
|
+
stream_for: ->(record) { record }
|
|
342
|
+
end
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
**The `find_stream` seam**, but only when it is still the stub the install
|
|
346
|
+
generator wrote. One you wrote yourself is never touched.
|
|
347
|
+
|
|
348
|
+
The full method table, and what to do when your columns are named differently,
|
|
349
|
+
is in [streaming.md](streaming.md).
|
|
350
|
+
|
|
351
|
+
## Where to go next
|
|
352
|
+
|
|
353
|
+
- [streaming.md](streaming.md) — the contract in full, sealing, agent runs
|
|
354
|
+
- [configuration.md](configuration.md) — every option and how to choose
|
|
355
|
+
- [repair.md](repair.md) — what the two routes do and what you must implement
|
|
356
|
+
- [registries.md](registries.md) — teach the renderer about your own fences and tags
|
data/docs/javascript.md
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
# JavaScript
|
|
2
|
+
|
|
3
|
+
Nine Stimulus controllers, shipped as source and pinned into your importmap.
|
|
4
|
+
There is no build step, no `package.json` and no npm dependency, and nothing
|
|
5
|
+
here imports a third-party library at load time.
|
|
6
|
+
|
|
7
|
+
## Registering
|
|
8
|
+
|
|
9
|
+
```js
|
|
10
|
+
// app/javascript/application.js
|
|
11
|
+
import "@hotwired/turbo-rails"
|
|
12
|
+
import { Application } from "@hotwired/stimulus"
|
|
13
|
+
import { registerMaquinaStreamControllers } from "maquina_stream"
|
|
14
|
+
|
|
15
|
+
const application = Application.start()
|
|
16
|
+
registerMaquinaStreamControllers(application)
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
The engine registers its own identifiers rather than relying on your eager-load
|
|
20
|
+
glob, because those identifiers are part of the DOM contract and must not depend
|
|
21
|
+
on where you keep your files.
|
|
22
|
+
|
|
23
|
+
`registerMaquinaStreamControllers` also wires one `turbo:before-cache` listener
|
|
24
|
+
that calls `teardown()` on any controller that defines it, so a controller that
|
|
25
|
+
mutated the DOM rolls that back before Turbo snapshots the page.
|
|
26
|
+
|
|
27
|
+
## Importmap
|
|
28
|
+
|
|
29
|
+
The engine appends its own pins to your importmap automatically. It pins only
|
|
30
|
+
its own source: `@hotwired/stimulus` stays your pin, because an engine that
|
|
31
|
+
pinned it would win or lose a version fight with your app for no reason.
|
|
32
|
+
|
|
33
|
+
Libraries the deferred renderers need are yours too:
|
|
34
|
+
|
|
35
|
+
```ruby
|
|
36
|
+
# config/importmap.rb
|
|
37
|
+
pin "mermaid", to: "https://cdn.jsdelivr.net/npm/mermaid@11.4.1/+esm", preload: false
|
|
38
|
+
pin "katex", to: "https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.mjs", preload: false
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
`preload: false` is load-bearing. importmap-rails preloads by default, which
|
|
42
|
+
emits a `<link rel="modulepreload">` and fetches both libraries on every page —
|
|
43
|
+
exactly the cost the lazy import inside the deferred controller exists to avoid.
|
|
44
|
+
|
|
45
|
+
## The controllers
|
|
46
|
+
|
|
47
|
+
| Identifier | Job | Mounted by |
|
|
48
|
+
|---|---|---|
|
|
49
|
+
| `ms-repair` | manifest diff, block fetch, silent morph, keyframe timer | you, on the message element |
|
|
50
|
+
| `ms-reveal` | animates the text that just arrived | you, on the message element |
|
|
51
|
+
| `ms-autoscroll` | stick to the bottom, release when the reader scrolls up | you, on whatever scrolls |
|
|
52
|
+
| `ms-link-safety` | confirmation dialog before following a link out | you, around the message |
|
|
53
|
+
| `ms-code` | copy and download a code block | the renderer |
|
|
54
|
+
| `ms-table` | copy and download a table, toggle fullscreen | the renderer |
|
|
55
|
+
| `ms-deferred` | base class: lazy import, render on payload change, sanitize output | — |
|
|
56
|
+
| `ms-diagram`, `ms-math` | extend `ms-deferred` | the renderer, per the fence registry |
|
|
57
|
+
|
|
58
|
+
The renderer emits everything the controllers need **inside** a message. Four
|
|
59
|
+
are yours, because they are page-level rather than message-level.
|
|
60
|
+
|
|
61
|
+
**Mounting a controller without its actions is silent.** Nothing errors; the
|
|
62
|
+
controller simply never hears anything. Check the `data-action` when a control
|
|
63
|
+
seems dead.
|
|
64
|
+
|
|
65
|
+
## The message element
|
|
66
|
+
|
|
67
|
+
```erb
|
|
68
|
+
<div id="ms-msg-<%= message.maquina_stream_id %>"
|
|
69
|
+
data-controller="ms-repair ms-reveal"
|
|
70
|
+
data-ms-repair-manifest-url-value="<%= maquina_stream.manifest_path(sid: message.maquina_stream_id) %>"
|
|
71
|
+
data-ms-repair-blocks-url-value="<%= maquina_stream.blocks_path(sid: message.maquina_stream_id) %>"
|
|
72
|
+
data-ms-repair-interval-value="4000"
|
|
73
|
+
<%= "data-ms-streaming" if message.maquina_stream_open? %>><%= MaquinaStream.render(message) %></div>
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
`data-ms-streaming` is the one attribute you have to keep correct, and
|
|
77
|
+
everything about "this message is still being written" derives from it. Blocks
|
|
78
|
+
carry no streaming state of their own: a block is exactly its content plus its
|
|
79
|
+
identity, so two tabs holding the same content hold the same DOM, and a block's
|
|
80
|
+
bytes never disagree with the digest repair compares them by.
|
|
81
|
+
|
|
82
|
+
The caret is CSS, reading the same attribute:
|
|
83
|
+
|
|
84
|
+
```css
|
|
85
|
+
[data-ms-streaming] > [data-ms-block]:last-child::after { /* caret */ }
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Controllers find the message element by `#ms-msg-<sid>`; if you wrap rendered
|
|
89
|
+
output some other way, mark your wrapper `data-ms-message`.
|
|
90
|
+
|
|
91
|
+
### `ms-repair`
|
|
92
|
+
|
|
93
|
+
| Value | Default | Meaning |
|
|
94
|
+
|---|---|---|
|
|
95
|
+
| `data-ms-repair-manifest-url-value` | — | required |
|
|
96
|
+
| `data-ms-repair-blocks-url-value` | — | required |
|
|
97
|
+
| `data-ms-repair-interval-value` | `4000` | keyframe period in ms; `0` disables the timer |
|
|
98
|
+
| `data-ms-repair-seq-value` | `0` | last sequence seen |
|
|
99
|
+
| `data-ms-repair-rollup-value` | `""` | last rollup digest seen |
|
|
100
|
+
|
|
101
|
+
It fetches on a final seal, on a sequence gap, on reconnect or tab visibility,
|
|
102
|
+
and on the keyframe timer. See [repair.md](repair.md).
|
|
103
|
+
|
|
104
|
+
### `ms-reveal`
|
|
105
|
+
|
|
106
|
+
Mounts on the message element, has no targets and no actions, and needs one
|
|
107
|
+
stylesheet:
|
|
108
|
+
|
|
109
|
+
```erb
|
|
110
|
+
<%= stylesheet_link_tag "maquina_stream/reveal" %>
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
When a block's text grows, the tail that just arrived is wrapped in **one**
|
|
114
|
+
`<span data-ms-revealing>`, animated in, and unwrapped again on `animationend` —
|
|
115
|
+
so a block is plain text between frames and the DOM gains at most one extra live
|
|
116
|
+
node per block, not one per word.
|
|
117
|
+
|
|
118
|
+
| Value | Default | Meaning |
|
|
119
|
+
|---|---|---|
|
|
120
|
+
| `data-ms-reveal-duration-value` | `320` | milliseconds; writes `--ms-reveal-duration` |
|
|
121
|
+
| `data-ms-reveal-disabled-value` | `false` | markup without the animation |
|
|
122
|
+
|
|
123
|
+
Nothing animates when the message element has no `data-ms-streaming`, while the
|
|
124
|
+
tab is hidden, while suppressed, or under `prefers-reduced-motion: reduce` — the
|
|
125
|
+
last is answered in the controller as well as in CSS, because with
|
|
126
|
+
`animation: none` no `animationend` fires and a span wrapped anyway would never
|
|
127
|
+
be unwrapped.
|
|
128
|
+
|
|
129
|
+
Suppression is an event, not a method call. `ms-repair` dispatches `ms:suppress`
|
|
130
|
+
on the message element before a repair morph and `ms:resume` after it;
|
|
131
|
+
`ms-reveal` listens for both in `connect`. Suppression unwraps whatever is
|
|
132
|
+
mid-flight so the morph never sees reveal chrome, and resume re-baselines every
|
|
133
|
+
block to the text currently on screen — which is what keeps a repair from
|
|
134
|
+
re-revealing what the reader has already read.
|
|
135
|
+
|
|
136
|
+
### `ms-autoscroll`
|
|
137
|
+
|
|
138
|
+
Wrap whatever actually scrolls, and bind the three actions:
|
|
139
|
+
|
|
140
|
+
```erb
|
|
141
|
+
<div data-controller="ms-autoscroll"
|
|
142
|
+
data-action="scroll->ms-autoscroll#track
|
|
143
|
+
wheel->ms-autoscroll#release
|
|
144
|
+
touchmove->ms-autoscroll#release"
|
|
145
|
+
style="overflow-y: auto">
|
|
146
|
+
<%= render @messages %>
|
|
147
|
+
</div>
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
For a page that scrolls as a whole:
|
|
151
|
+
|
|
152
|
+
```erb
|
|
153
|
+
<div data-controller="ms-autoscroll"
|
|
154
|
+
data-ms-autoscroll-scroller-value="window"
|
|
155
|
+
data-action="scroll@window->ms-autoscroll#track
|
|
156
|
+
wheel@window->ms-autoscroll#release
|
|
157
|
+
touchmove@window->ms-autoscroll#release">
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
| Value | Default | Meaning |
|
|
161
|
+
|---|---|---|
|
|
162
|
+
| `scroller` | `"self"` | `"self"` or `"window"` |
|
|
163
|
+
| `threshold` | `32` | how close to the bottom still counts as at the bottom, in px |
|
|
164
|
+
| `pinned` | `true` | current state, serialized so it survives a morph |
|
|
165
|
+
|
|
166
|
+
The rule the whole controller exists to keep: **it never scrolls unless it is
|
|
167
|
+
pinned, and only the reader can pin it.** Pinning is derived from position and
|
|
168
|
+
never remembered, so scrolling back to the bottom re-pins by the same rule that
|
|
169
|
+
unpinned you.
|
|
170
|
+
|
|
171
|
+
### `ms-link-safety`
|
|
172
|
+
|
|
173
|
+
Mount it on the container, not on each anchor: the anchors are model output,
|
|
174
|
+
their count is unbounded, and one delegated listener survives a repair morph
|
|
175
|
+
that replaces every one of them.
|
|
176
|
+
|
|
177
|
+
```erb
|
|
178
|
+
<div data-controller="ms-link-safety" data-action="click->ms-link-safety#intercept">
|
|
179
|
+
<%= MaquinaStream.render(message) %>
|
|
180
|
+
|
|
181
|
+
<dialog data-ms-link-safety-target="dialog" aria-labelledby="link-safety-title">
|
|
182
|
+
<h3 id="link-safety-title"><%= t("maquina_stream.link_safety.title") %></h3>
|
|
183
|
+
<p data-ms-link-safety-target="url"></p>
|
|
184
|
+
<label>
|
|
185
|
+
<input type="checkbox" data-ms-link-safety-target="remember">
|
|
186
|
+
<%= t("maquina_stream.link_safety.always_allow") %>
|
|
187
|
+
</label>
|
|
188
|
+
<button type="button" data-action="ms-link-safety#confirm"><%= t("maquina_stream.link_safety.confirm") %></button>
|
|
189
|
+
<button type="button" data-action="ms-link-safety#cancel"><%= t("maquina_stream.link_safety.cancel") %></button>
|
|
190
|
+
</dialog>
|
|
191
|
+
</div>
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
| Value | Default | Meaning |
|
|
195
|
+
|---|---|---|
|
|
196
|
+
| `origin` | `""` | same-origin links are followed with no prompt |
|
|
197
|
+
| `bypass` | `false` | turns the guard off, for a host with its own interstitial |
|
|
198
|
+
| `rememberKey` | `"ms-link-safety.trusted"` | session-storage key for hosts the reader chose to trust |
|
|
199
|
+
|
|
200
|
+
Trust is kept in session storage, not local: trust granted mid-conversation
|
|
201
|
+
should not outlive the tab. The controller refuses anything outside `http:`,
|
|
202
|
+
`https:` and `mailto:` whatever the document says.
|
|
203
|
+
|
|
204
|
+
The allowlist is a function, not an attribute — an allowlist an injected
|
|
205
|
+
fragment can rewrite is not an allowlist:
|
|
206
|
+
|
|
207
|
+
```js
|
|
208
|
+
import { linkSafety } from "maquina_stream"
|
|
209
|
+
linkSafety.allow = (url) => url.hostname.endsWith("example.com")
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
`url` is a parsed `URL`. Returning true follows the link with no dialog.
|
|
213
|
+
|
|
214
|
+
### `ms-code`
|
|
215
|
+
|
|
216
|
+
Mounted by the renderer on a code block. Copy and download read the
|
|
217
|
+
`<pre hidden data-ms-code-source>` carrier, never the highlighted markup, so a
|
|
218
|
+
copy returns raw source rather than Rouge's spans.
|
|
219
|
+
|
|
220
|
+
| Value | Default | Meaning |
|
|
221
|
+
|---|---|---|
|
|
222
|
+
| `sourceSelector` | `"[data-ms-code-source]"` | late-bound, so a host with a different carrier need not fork the controller |
|
|
223
|
+
| `filename` | `""` | download name; otherwise derived from the language |
|
|
224
|
+
|
|
225
|
+
An unknown language downloads as `.txt` rather than guessing an extension.
|
|
226
|
+
|
|
227
|
+
### `ms-table`
|
|
228
|
+
|
|
229
|
+
Mounted by the renderer on the table wrapper. It reconstructs the table from the
|
|
230
|
+
DOM cell by cell using `textContent` — nothing here reads or produces HTML.
|
|
231
|
+
|
|
232
|
+
The engine renders its own control bar unless you turn the `table` controls off.
|
|
233
|
+
If you render your own:
|
|
234
|
+
|
|
235
|
+
```html
|
|
236
|
+
<div data-ms-table data-controller="ms-table">
|
|
237
|
+
<button data-ms-control data-action="ms-table#copy" data-ms-table-format-param="markdown">…</button>
|
|
238
|
+
<button data-ms-control data-action="ms-table#download" data-ms-table-format-param="csv">…</button>
|
|
239
|
+
<button data-ms-control data-action="ms-table#toggleFullscreen">…</button>
|
|
240
|
+
<table>…</table>
|
|
241
|
+
</div>
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
Formats are `markdown`, `csv` and `tsv`.
|
|
245
|
+
|
|
246
|
+
## Controls are inert while streaming
|
|
247
|
+
|
|
248
|
+
Two things make that true, and both are needed:
|
|
249
|
+
|
|
250
|
+
1. the message element carries `data-ms-streaming` while the message is open;
|
|
251
|
+
2. every control carries `data-ms-control`.
|
|
252
|
+
|
|
253
|
+
The base controller disables marked controls whenever the attribute is present
|
|
254
|
+
and re-enables them the moment the seal removes it, so the UI never offers half
|
|
255
|
+
a code block to copy. **Controls you render yourself must carry
|
|
256
|
+
`data-ms-control` too**, or they stay clickable mid-stream.
|
|
257
|
+
|
|
258
|
+
Turning controls off in configuration removes the buttons the renderer emits.
|
|
259
|
+
See [configuration.md](configuration.md).
|
|
260
|
+
|
|
261
|
+
## Events
|
|
262
|
+
|
|
263
|
+
Every controller dispatches through Stimulus with the `ms` prefix, so listen for
|
|
264
|
+
`ms:<name>` on or above the element.
|
|
265
|
+
|
|
266
|
+
| Event | Detail | From |
|
|
267
|
+
|---|---|---|
|
|
268
|
+
| `ms:copied` | `{length, fallback?}` | any copy control |
|
|
269
|
+
| `ms:copy-failed` | `{text}` | any copy control |
|
|
270
|
+
| `ms:downloaded` | `{filename}` | any download control |
|
|
271
|
+
| `ms:refused` | `{reason: "streaming"}` | a control clicked while the message is open |
|
|
272
|
+
| `ms:repaired` | `{reason, blocks}` | `ms-repair` |
|
|
273
|
+
| `ms:repair-failed` | `{reason, error}` | `ms-repair` |
|
|
274
|
+
| `ms:rendered` | `{controller}` | a deferred renderer |
|
|
275
|
+
| `ms:render-failed` | `{controller, error}` | a deferred renderer |
|
|
276
|
+
| `ms:fullscreen` | `{fullscreen}` | `ms-table` |
|
|
277
|
+
| `ms:autoscroll` | `{pinned}` | `ms-autoscroll` |
|
|
278
|
+
| `ms:link-prompted` | `{href}` | `ms-link-safety` |
|
|
279
|
+
| `ms:link-followed` | `{href}` | `ms-link-safety` |
|
|
280
|
+
| `ms:link-cancelled` | `{href}` | `ms-link-safety` |
|
|
281
|
+
| `ms:link-refused` | `{href}` | `ms-link-safety` |
|
|
282
|
+
|
|
283
|
+
`ms:suppress` and `ms:resume` are dispatched on the message element as plain
|
|
284
|
+
`CustomEvent`s, without the prefix, and do not bubble.
|
|
285
|
+
|
|
286
|
+
## Locale
|
|
287
|
+
|
|
288
|
+
Labels follow `I18n.locale`, like any Rails app. `config.locale` is the engine's
|
|
289
|
+
own fallback for a host that has expressed no preference. Spanish and English
|
|
290
|
+
both ship complete.
|
|
291
|
+
|
|
292
|
+
## Treat everything the DOM says as untrusted
|
|
293
|
+
|
|
294
|
+
No `ms-` controller assigns a DOM-derived or payload-derived string as HTML. The
|
|
295
|
+
server sanitizer allows `data-controller`, restricted to the `ms-` namespace,
|
|
296
|
+
but it cannot tell a controller our post-pass emitted from one an injected
|
|
297
|
+
fragment asked for. A controller of your own that reads values out of rendered
|
|
298
|
+
message markup should hold the same line. See [security.md](security.md).
|