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,32 @@
1
+ import MsDeferredController from "maquina_stream/controllers/ms_deferred_controller"
2
+
3
+ // `ms-math` — KaTeX, with trust disabled.
4
+ //
5
+ // `trust: false` refuses \htmlClass, \includegraphics and \href, all of which
6
+ // take attacker-controlled strings straight into the DOM. `throwOnError: false`
7
+ // keeps a malformed formula from taking the message down; ms-deferred's error
8
+ // path handles the rest.
9
+ export default class extends MsDeferredController {
10
+ static library = null
11
+
12
+ async library() {
13
+ if (!this.constructor.library) {
14
+ const katex = await import("katex")
15
+ this.constructor.library = katex.default ?? katex
16
+ }
17
+
18
+ return this.constructor.library
19
+ }
20
+
21
+ async draw(payload) {
22
+ const katex = await this.library()
23
+
24
+ return katex.renderToString(String(payload.source ?? ""), {
25
+ displayMode: payload.display !== false,
26
+ throwOnError: false,
27
+ trust: false,
28
+ strict: "ignore",
29
+ output: "html"
30
+ })
31
+ }
32
+ }
@@ -0,0 +1,167 @@
1
+ import ApplicationController from "maquina_stream/controllers/application_controller"
2
+
3
+ // `ms-repair` — reconcile the message with the server.
4
+ //
5
+ // <div id="ms-msg-42"
6
+ // data-controller="ms-stream ms-repair"
7
+ // data-ms-repair-manifest-url-value="/maquina_stream/42/manifest"
8
+ // data-ms-repair-blocks-url-value="/maquina_stream/42/blocks"
9
+ // data-ms-repair-interval-value="4000">
10
+ //
11
+ // Action Cable gives no delivery guarantee, no ordering guarantee and no gap
12
+ // detection. Deltas are an optimization; this is where correctness lives.
13
+ //
14
+ // Four triggers, per docs/design.md:
15
+ //
16
+ // 1. final seal, always
17
+ // 2. a gap in the sequence
18
+ // 3. reconnect, or the tab becoming visible again
19
+ // 4. a periodic keyframe
20
+ //
21
+ // What it fetches is decided by digests, not by guesswork: the manifest is a
22
+ // few hundred bytes, the client compares it against its own DOM, and asks only
23
+ // for the blocks that differ. Repair therefore costs what has drifted, not what
24
+ // the message weighs.
25
+ export default class extends ApplicationController {
26
+ static values = {
27
+ manifestUrl: String,
28
+ blocksUrl: String,
29
+ interval: { type: Number, default: 4000 },
30
+ seq: { type: Number, default: 0 },
31
+ rollup: { type: String, default: "" }
32
+ }
33
+
34
+ connect() {
35
+ this.onVisibility = () => { if (!document.hidden) this.repair("visible") }
36
+ this.onConnect = () => this.repair("reconnect")
37
+ this.onStreamFrame = (event) => this.observe(event)
38
+
39
+ document.addEventListener("visibilitychange", this.onVisibility)
40
+ document.addEventListener("turbo:before-stream-render", this.onStreamFrame)
41
+ window.addEventListener("online", this.onConnect)
42
+
43
+ this.start()
44
+ }
45
+
46
+ disconnect() {
47
+ this.stop()
48
+ document.removeEventListener("visibilitychange", this.onVisibility)
49
+ document.removeEventListener("turbo:before-stream-render", this.onStreamFrame)
50
+ window.removeEventListener("online", this.onConnect)
51
+ }
52
+
53
+ start() {
54
+ this.stop()
55
+ if (this.intervalValue > 0) this.timer = setInterval(() => this.repair("keyframe"), this.intervalValue)
56
+ }
57
+
58
+ stop() {
59
+ clearInterval(this.timer)
60
+ this.timer = null
61
+ }
62
+
63
+ // ------------------------------------------------------------------ triggers
64
+
65
+ // Every frame carries its sequence. A number that is not exactly one more
66
+ // than the last means a frame never arrived, and the DOM is now a guess.
67
+ observe(event) {
68
+ const stream = event.target
69
+ const seq = Number(stream.dataset.msSeq)
70
+ if (!Number.isFinite(seq) || seq === 0) return
71
+
72
+ const expected = this.seqValue + 1
73
+ this.seqValue = seq
74
+
75
+ if (this.seqValue > expected) this.repair("gap")
76
+ if (stream.dataset.msFrame === "final") this.repair("seal")
77
+ }
78
+
79
+ // ------------------------------------------------------------------ repair
80
+
81
+ async repair(reason) {
82
+ if (this.running) return
83
+ this.running = true
84
+
85
+ try {
86
+ const manifest = await this.fetchManifest()
87
+ if (!manifest) return
88
+
89
+ // History behind the window disagrees, so the window is not enough to
90
+ // reconcile from. Rare, and the same work a cold page load does.
91
+ if (manifest.cutoff > 0 && manifest.rollup !== this.rollupValue) {
92
+ const full = await this.fetchManifest({ full: true })
93
+ if (full) await this.reconcile(full, reason)
94
+ return
95
+ }
96
+
97
+ await this.reconcile(manifest, reason)
98
+ } catch (error) {
99
+ // A failed repair is not fatal: the next trigger tries again, and the
100
+ // final seal always fires.
101
+ this.notify("repair-failed", { reason, error: String(error) })
102
+ } finally {
103
+ this.running = false
104
+ }
105
+ }
106
+
107
+ async reconcile(manifest, reason) {
108
+ const stale = manifest.blocks.filter(([id, digest]) => this.digestOf(id) !== digest).map(([id]) => id)
109
+ this.rollupValue = manifest.rollup || ""
110
+ if (stale.length === 0) return
111
+
112
+ const html = await this.fetchBlocks(stale)
113
+ if (!html) return
114
+
115
+ // Silent by construction: suppress before the morph, resume after it. A
116
+ // repair that re-animates text already on screen is the strobe Phase 0
117
+ // exists to prevent.
118
+ this.suppressReveal()
119
+ try {
120
+ window.Turbo.renderStreamMessage(html)
121
+ await this.nextFrame()
122
+ } finally {
123
+ this.resumeReveal()
124
+ }
125
+
126
+ this.notify("repaired", { reason, blocks: stale.length })
127
+ }
128
+
129
+ // The digest the client can compute for what it currently holds. The server
130
+ // digests the block's rendered HTML, so the client digests the same bytes.
131
+ digestOf(id) {
132
+ const node = document.getElementById(id)
133
+ return node ? node.dataset.msBlockDigest : null
134
+ }
135
+
136
+ async fetchManifest(params = {}) {
137
+ const url = new URL(this.manifestUrlValue, window.location.origin)
138
+ if (params.full) url.searchParams.set("full", "1")
139
+
140
+ const response = await fetch(url, { headers: { Accept: "application/json" } })
141
+ return response.ok ? response.json() : null
142
+ }
143
+
144
+ async fetchBlocks(ids) {
145
+ const url = new URL(this.blocksUrlValue, window.location.origin)
146
+ ids.forEach((id) => url.searchParams.append("ids[]", id))
147
+
148
+ const response = await fetch(url, { headers: { Accept: "text/vnd.turbo-stream.html" } })
149
+ return response.ok ? response.text() : null
150
+ }
151
+
152
+ // The suppression seam. Dispatched as events rather than called directly, so
153
+ // it works for whichever reveal strategy Phase 0 settles on: a controller
154
+ // that needs to stay quiet listens, and one that is structurally immune
155
+ // (strategy C) simply does not.
156
+ suppressReveal() {
157
+ this.element.dispatchEvent(new CustomEvent("ms:suppress", { bubbles: false }))
158
+ }
159
+
160
+ resumeReveal() {
161
+ this.element.dispatchEvent(new CustomEvent("ms:resume", { bubbles: false }))
162
+ }
163
+
164
+ nextFrame() {
165
+ return new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))
166
+ }
167
+ }
@@ -0,0 +1,320 @@
1
+ import ApplicationController from "maquina_stream/controllers/application_controller"
2
+
3
+ // `ms-reveal` — the streaming reveal, mounted on the message element.
4
+ //
5
+ // <div id="ms-msg-42" data-controller="ms-stream ms-repair ms-reveal" data-ms-streaming>
6
+ // <div id="ms-42-b0" data-ms-block …>…</div>
7
+ // </div>
8
+ //
9
+ // What it does: when a block's text grows, the newly arrived tail is wrapped in
10
+ // one `<span data-ms-revealing>`, that span is animated in, and on
11
+ // `animationend` the span is unwrapped again. One live extra node per block —
12
+ // not one per word — and nothing left in the DOM once the animation ends.
13
+ //
14
+ // ## Why not the CSS mask
15
+ //
16
+ // Phase 0's strategy C masked the whole block with a horizontal gradient whose
17
+ // edge was placed at `revealed / total` **characters**. A character fraction and
18
+ // a horizontal position are the same number only while a block occupies one
19
+ // line. On a block wrapped over three lines each line ends at ~94% of the
20
+ // block's width, so a mask edge at 75% hid the last fifth of *every* line —
21
+ // including lines the reader finished seconds ago — and swept them back in on
22
+ // the next frame. That is the flash the review saw on the first few lines of a
23
+ // block, fading out as the fraction approached 100%. The geometry is wrong, not
24
+ // the easing: no gradient stop fixes a mapping from characters to pixels that
25
+ // does not exist.
26
+ //
27
+ // Wrapping the tail is reading-order correct *by construction*. There is no
28
+ // character-to-pixel mapping anywhere, because the thing that animates is the
29
+ // new text itself.
30
+ //
31
+ // ## Why the span is temporary
32
+ //
33
+ // docs/javascript.md: a block's digest covers what the
34
+ // block says, so any chrome the reveal leaves behind is drift the manifest diff
35
+ // cannot see and repair cannot correct — two tabs that lost different frames
36
+ // end up visibly different with agreeing digests. The span therefore exists
37
+ // only for the length of one animation, is removed before a repair morph
38
+ // (`ms:suppress`), and is removed on seal, on disconnect and before Turbo
39
+ // caches the page.
40
+ //
41
+ // Nothing here assigns a DOM-derived string as HTML. Per docs/security.md
42
+ // every value a controller reads from the DOM is untrusted; this one moves
43
+ // existing text nodes and never re-parses them.
44
+ export default class extends ApplicationController {
45
+ static values = {
46
+ // Milliseconds. The stylesheet reads `var(--ms-reveal-duration, 320ms)`;
47
+ // setting this writes the property rather than pinning the duration twice.
48
+ duration: { type: Number, default: 320 },
49
+ // A host that wants the markup but not the animation says so once.
50
+ disabled: Boolean
51
+ }
52
+
53
+ // Both fixed by the DOM contract (docs/javascript.md), like `data-ms-block`
54
+ // itself: the stylesheet and the controller have to agree, and an attribute a
55
+ // host could rename is an attribute the shipped CSS would miss.
56
+ static blockSelector = "[data-ms-block]"
57
+ static markerAttribute = "data-ms-revealing"
58
+
59
+ connect() {
60
+ // Transient, and deliberately not a Value. Suppression spans exactly one
61
+ // repair morph; writing it into the DOM would put client state on the
62
+ // element the morph is about to rewrite.
63
+ this.suppressed = false
64
+
65
+ // Block id → characters already revealed. Keyed by id rather than by node,
66
+ // because a repair morph may replace the element while keeping its
67
+ // identity (ids are index-derived and stable).
68
+ this.revealed = new Map()
69
+
70
+ // Spans currently animating, each with the timer that force-unwraps it if
71
+ // `animationend` never arrives (a hidden or display:none block never fires
72
+ // one, and an orphaned span would then outlive its animation).
73
+ this.pending = new Map()
74
+
75
+ if (this.hasDurationValue) {
76
+ this.element.style.setProperty("--ms-reveal-duration", `${this.durationValue}ms`)
77
+ }
78
+
79
+ // `ms-repair` dispatches these on the message element, without bubbling.
80
+ // They are wired here rather than through `data-action` because the markup
81
+ // a host renders (docs/javascript.md) carries `data-controller` and no
82
+ // actions — a reveal that needed one more attribute would silently never
83
+ // suppress, which is the failure mode docs/javascript.md warns about.
84
+ this.onSuppress = () => this.suppress()
85
+ this.onResume = () => this.resume()
86
+ this.root.addEventListener("ms:suppress", this.onSuppress)
87
+ this.root.addEventListener("ms:resume", this.onResume)
88
+
89
+ // A tab that was hidden through half a message must not replay it on
90
+ // return; the baseline moves forward instead.
91
+ this.onVisibility = () => { if (!document.hidden) this.sync() }
92
+ document.addEventListener("visibilitychange", this.onVisibility)
93
+
94
+ this.onAnimationEnd = (event) => this.settle(event)
95
+ this.element.addEventListener("animationend", this.onAnimationEnd)
96
+
97
+ // Two observers, two jobs. Content growth drives the reveal; the message
98
+ // element's `data-ms-streaming` decides whether there is a reveal at all.
99
+ this.contentObserver = new MutationObserver((records) => this.observe(records))
100
+ this.contentObserver.observe(this.element, { childList: true, subtree: true, characterData: true })
101
+
102
+ this.stateObserver = new MutationObserver(() => this.arm())
103
+ this.stateObserver.observe(this.root, { attributes: true, attributeFilter: ["data-ms-streaming"] })
104
+
105
+ this.arm()
106
+ }
107
+
108
+ disconnect() {
109
+ this.contentObserver?.disconnect()
110
+ this.stateObserver?.disconnect()
111
+ this.root.removeEventListener("ms:suppress", this.onSuppress)
112
+ this.root.removeEventListener("ms:resume", this.onResume)
113
+ document.removeEventListener("visibilitychange", this.onVisibility)
114
+ this.element.removeEventListener("animationend", this.onAnimationEnd)
115
+ this.unwrapAll()
116
+ }
117
+
118
+ // Turbo caches the page as the reader left it. `index.js` calls this before
119
+ // the snapshot, so a cached message is never restored mid-reveal.
120
+ teardown() {
121
+ this.unwrapAll()
122
+ }
123
+
124
+ // -------------------------------------------------------------- suppression
125
+
126
+ // The seam `ms-repair` drives. Both are idempotent, and both are safe on a
127
+ // sealed message — a repair can land on one.
128
+ suppress() {
129
+ this.suppressed = true
130
+ this.unwrapAll()
131
+ this.sync()
132
+ }
133
+
134
+ // Resume without re-revealing. The morph that just landed put text on screen
135
+ // the reader has already seen, so the baseline moves to the current length:
136
+ // the next delta reveals its tail, not the whole block.
137
+ resume() {
138
+ this.suppressed = false
139
+ this.sync()
140
+ }
141
+
142
+ // ------------------------------------------------------------------- arming
143
+
144
+ // A sealed message does not animate. The host owns `data-ms-streaming` and
145
+ // stamps it from `maquina_stream_open?`; the seal removing it takes the
146
+ // reveal down live.
147
+ arm() {
148
+ if (this.active) {
149
+ this.sync()
150
+ } else {
151
+ this.unwrapAll()
152
+ this.sync()
153
+ }
154
+ }
155
+
156
+ get active() {
157
+ return this.streaming && !this.disabledValue && !this.reducedMotion
158
+ }
159
+
160
+ // Reduced motion is answered here and not only in CSS. With `animation: none`
161
+ // no `animationend` ever fires, so a span wrapped anyway would never be
162
+ // unwrapped — the honest reading of the preference is to not wrap at all.
163
+ get reducedMotion() {
164
+ return window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false
165
+ }
166
+
167
+ // ---------------------------------------------------------------- revealing
168
+
169
+ observe(records) {
170
+ // Wrapping and unwrapping are themselves mutations. They never change a
171
+ // block's text length, so they cannot start a reveal, but they are dropped
172
+ // here rather than walked.
173
+ if (this.mutating) return
174
+
175
+ if (!this.active || this.suppressed || document.hidden) {
176
+ this.sync()
177
+ return
178
+ }
179
+
180
+ const touched = new Set()
181
+ records.forEach((record) => {
182
+ const block = this.blockFor(record.target)
183
+ if (block) touched.add(block)
184
+ record.addedNodes?.forEach((node) => {
185
+ const added = this.blockFor(node)
186
+ if (added) touched.add(added)
187
+ })
188
+ })
189
+
190
+ touched.forEach((block) => this.reveal(block))
191
+ }
192
+
193
+ reveal(block) {
194
+ const id = this.identify(block)
195
+ const length = block.textContent.length
196
+ const seen = this.revealed.get(id) ?? 0
197
+
198
+ this.revealed.set(id, length)
199
+ if (length <= seen) return
200
+
201
+ this.surgery(() => this.wrapTail(block, seen).forEach((span) => this.animate(span)))
202
+ }
203
+
204
+ // Walk the block's text in document order and wrap everything past `offset`.
205
+ //
206
+ // Text arriving at the end of one text node — the streaming case — yields
207
+ // exactly one span. A frame that also appends new elements (a new list item,
208
+ // a new paragraph inside the block) yields one span per newly written text
209
+ // node, which is bounded by what arrived rather than by what the block holds.
210
+ // A single span cannot span element boundaries without moving text out of the
211
+ // structure it belongs to, and restructuring a block to animate it is exactly
212
+ // the drift this controller exists to avoid.
213
+ wrapTail(block, offset) {
214
+ const walker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT)
215
+ const tails = []
216
+ let consumed = 0
217
+ let node
218
+
219
+ while ((node = walker.nextNode())) {
220
+ const start = consumed
221
+ consumed += node.nodeValue.length
222
+ if (consumed <= offset) continue
223
+ if (node.parentElement?.hasAttribute(this.constructor.markerAttribute)) continue
224
+
225
+ tails.push([node, Math.max(0, offset - start)])
226
+ }
227
+
228
+ return tails.map(([text, at]) => this.wrap(at > 0 ? text.splitText(at) : text)).filter(Boolean)
229
+ }
230
+
231
+ wrap(text) {
232
+ if (!text.nodeValue.length || !text.parentNode) return null
233
+
234
+ const span = document.createElement("span")
235
+ span.setAttribute(this.constructor.markerAttribute, "")
236
+ text.parentNode.insertBefore(span, text)
237
+ span.appendChild(text)
238
+ return span
239
+ }
240
+
241
+ animate(span) {
242
+ // The fallback timer, not the animation, is what guarantees the span goes
243
+ // away. `animationend` unwraps first in every normal case.
244
+ const timer = setTimeout(() => this.unwrap(span), this.durationValue + 400)
245
+ this.pending.set(span, timer)
246
+ }
247
+
248
+ settle(event) {
249
+ const span = event.target
250
+ if (span instanceof Element && span.hasAttribute(this.constructor.markerAttribute)) this.unwrap(span)
251
+ }
252
+
253
+ // Replace the span with the text it holds and normalize, so the block is
254
+ // plain text again and consecutive text nodes do not accumulate over a long
255
+ // message.
256
+ unwrap(span) {
257
+ clearTimeout(this.pending.get(span))
258
+ this.pending.delete(span)
259
+
260
+ const parent = span.parentNode
261
+ if (!parent) return
262
+
263
+ this.surgery(() => {
264
+ while (span.firstChild) parent.insertBefore(span.firstChild, span)
265
+ span.remove()
266
+ parent.normalize()
267
+ })
268
+ }
269
+
270
+ unwrapAll() {
271
+ Array.from(this.element.querySelectorAll(`[${this.constructor.markerAttribute}]`))
272
+ .forEach((span) => this.unwrap(span))
273
+ }
274
+
275
+ // Our own DOM writes, fenced off from the observer that watches for the
276
+ // server's.
277
+ surgery(work) {
278
+ const previous = this.mutating
279
+ this.mutating = true
280
+ try {
281
+ work()
282
+ } finally {
283
+ this.contentObserver?.takeRecords()
284
+ this.mutating = previous
285
+ }
286
+ }
287
+
288
+ // Re-baseline every block to what is currently on screen. Anything already
289
+ // rendered counts as seen, which is what keeps a reload, a background tab and
290
+ // a repair from replaying text the reader has read.
291
+ sync() {
292
+ this.blocks.forEach((block) => this.revealed.set(this.identify(block), block.textContent.length))
293
+ }
294
+
295
+ // --------------------------------------------------------------------- DOM
296
+
297
+ // The element the host stamps `data-ms-streaming` on, and the element
298
+ // `ms-repair` dispatches suppression on. Normally `this.element` itself; the
299
+ // lookup only matters for a host that nests the controller.
300
+ get root() {
301
+ return this.messageElement || this.element
302
+ }
303
+
304
+ get blocks() {
305
+ return Array.from(this.element.querySelectorAll(this.constructor.blockSelector))
306
+ }
307
+
308
+ blockFor(node) {
309
+ const element = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement
310
+ const block = element?.closest(this.constructor.blockSelector)
311
+ return block && this.element.contains(block) ? block : null
312
+ }
313
+
314
+ // Block ids are index-derived and stable across a morph (docs/streaming.md).
315
+ // A block without one is still revealed; it just cannot be tracked across a
316
+ // repair, so it re-baselines instead of re-revealing.
317
+ identify(block) {
318
+ return block.id || `@${block.dataset.msBlockIndex ?? this.blocks.indexOf(block)}`
319
+ }
320
+ }