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,110 @@
1
+ import ApplicationController from "maquina_stream/controllers/application_controller"
2
+
3
+ // `ms-autoscroll` — keep the bottom of a growing message in view while it
4
+ // streams, and get out of the way the instant the user scrolls up.
5
+ //
6
+ // <div data-controller="ms-autoscroll"
7
+ // data-action="scroll->ms-autoscroll#track wheel->ms-autoscroll#release
8
+ // touchmove->ms-autoscroll#release"
9
+ // style="overflow-y: auto">
10
+ // <div id="ms-msg-42">…</div>
11
+ // </div>
12
+ //
13
+ // Or against the window, for a page that scrolls as a whole:
14
+ //
15
+ // <div data-controller="ms-autoscroll"
16
+ // data-ms-autoscroll-scroller-value="window"
17
+ // data-action="scroll@window->ms-autoscroll#track
18
+ // wheel@window->ms-autoscroll#release
19
+ // touchmove@window->ms-autoscroll#release">
20
+ //
21
+ // The rule the whole controller exists to keep: **it never scrolls unless it
22
+ // is pinned, and only the user can pin it.** A programmatic scroll always
23
+ // lands at the bottom, so it re-satisfies the pin condition and never fights
24
+ // a user who is reading further up.
25
+ export default class extends ApplicationController {
26
+ static values = {
27
+ // "self" (the controller element scrolls) or "window".
28
+ scroller: { type: String, default: "self" },
29
+ // How close to the bottom still counts as "at the bottom", in pixels. A
30
+ // couple of device pixels of rounding is normal, and a sub-pixel layout
31
+ // would otherwise unpin on its own.
32
+ threshold: { type: Number, default: 32 },
33
+ // Serialized so it survives a Turbo morph and can be read from a test.
34
+ pinned: { type: Boolean, default: true }
35
+ }
36
+
37
+ connect() {
38
+ // The message grows by morph and by Turbo Stream, neither of which fires a
39
+ // DOM event this controller can hang off. An observer is the only signal.
40
+ this.observer = new MutationObserver(() => this.follow())
41
+ this.observer.observe(this.element, { childList: true, subtree: true, characterData: true })
42
+ this.follow()
43
+ }
44
+
45
+ disconnect() {
46
+ this.observer?.disconnect()
47
+ this.observer = null
48
+ cancelAnimationFrame(this.frame)
49
+ }
50
+
51
+ // ------------------------------------------------------------------ actions
52
+
53
+ // Bound to `scroll`. Pinning is derived from position, never remembered:
54
+ // the user scrolls back to the bottom and is pinned again, by the same rule
55
+ // that unpinned them.
56
+ track() {
57
+ this.pinnedValue = this.atBottom
58
+ }
59
+
60
+ // Bound to `wheel` and `touchmove`. Scroll events can lag a gesture by a
61
+ // frame or two on a long document; an upward gesture unpins immediately so
62
+ // the next append does not yank the view back down.
63
+ // An unpin here is never final: the `scroll` event that follows the gesture
64
+ // runs `track`, which pins again if the gesture ended at the bottom.
65
+ release(event) {
66
+ if (event.type === "wheel" && event.deltaY >= 0) return
67
+ this.pinnedValue = false
68
+ }
69
+
70
+ // Public, for a "jump to latest" button.
71
+ pin() {
72
+ this.pinnedValue = true
73
+ this.scrollToBottom()
74
+ }
75
+
76
+ unpin() {
77
+ this.pinnedValue = false
78
+ }
79
+
80
+ pinnedValueChanged(pinned, previous) {
81
+ if (previous === undefined) return
82
+ this.notify("autoscroll", { pinned })
83
+ }
84
+
85
+ // ------------------------------------------------------------------ scroller
86
+
87
+ get scroller() {
88
+ return this.scrollerValue === "window" ? document.scrollingElement : this.element
89
+ }
90
+
91
+ get atBottom() {
92
+ const scroller = this.scroller
93
+ if (!scroller) return true
94
+ const distance = scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight
95
+ return distance <= this.thresholdValue
96
+ }
97
+
98
+ follow() {
99
+ if (!this.pinnedValue) return
100
+ // Coalesce a burst of appends into one scroll per frame.
101
+ cancelAnimationFrame(this.frame)
102
+ this.frame = requestAnimationFrame(() => this.scrollToBottom())
103
+ }
104
+
105
+ scrollToBottom() {
106
+ const scroller = this.scroller
107
+ if (!scroller) return
108
+ scroller.scrollTop = scroller.scrollHeight
109
+ }
110
+ }
@@ -0,0 +1,96 @@
1
+ import ApplicationController from "maquina_stream/controllers/application_controller"
2
+
3
+ // `ms-code` — copy and download a rendered code block.
4
+ //
5
+ // The DOM contract (docs/javascript.md) fixes the shape:
6
+ //
7
+ // <div data-ms-code data-ms-code-lang="ruby" data-controller="ms-code">
8
+ // <button data-ms-control data-action="ms-code#copy">…</button>
9
+ // <button data-ms-control data-action="ms-code#download">…</button>
10
+ // <pre><code>…highlighted…</code></pre>
11
+ // <pre hidden data-ms-code-source>…raw source…</pre>
12
+ // </div>
13
+ //
14
+ // Copy and download read the `data-ms-code-source` carrier, never the
15
+ // highlighted markup. The carrier is a `<pre hidden>`, not a script tag: its
16
+ // content is ordinary escaped text, so `textContent` is the whole story and
17
+ // nothing has to be unescaped. Reading the highlighted `<code>` instead would
18
+ // hand back Rouge's span soup collapsed into text.
19
+ export default class extends ApplicationController {
20
+ // Late-bound so a host with a different carrier does not have to fork the
21
+ // controller. Both are scoped to `this.element`.
22
+ static values = {
23
+ sourceSelector: { type: String, default: "[data-ms-code-source]" },
24
+ filename: { type: String, default: "" }
25
+ }
26
+
27
+ static targets = ["source"]
28
+
29
+ // Extension per language. An unknown language downloads as `.txt` rather
30
+ // than guessing: a wrong extension is worse than a generic one.
31
+ static extensions = {
32
+ bash: "sh", c: "c", cpp: "cpp", csharp: "cs", css: "css", diff: "diff",
33
+ elixir: "ex", erb: "erb", go: "go", haml: "haml", html: "html",
34
+ java: "java", javascript: "js", js: "js", json: "json", jsx: "jsx",
35
+ kotlin: "kt", markdown: "md", md: "md", python: "py", py: "py",
36
+ ruby: "rb", rb: "rb", rust: "rs", scss: "scss", sh: "sh", shell: "sh",
37
+ sql: "sql", swift: "swift", toml: "toml", ts: "ts", tsx: "tsx",
38
+ typescript: "ts", xml: "xml", yaml: "yml", yml: "yml", zsh: "sh"
39
+ }
40
+
41
+ connect() {
42
+ this.startStreamGuard()
43
+ }
44
+
45
+ disconnect() {
46
+ this.stopStreamGuard()
47
+ }
48
+
49
+ // ------------------------------------------------------------------ actions
50
+
51
+ copy(event) {
52
+ if (this.refuseWhileStreaming(event)) return
53
+ this.copyText(this.source)
54
+ }
55
+
56
+ download(event) {
57
+ if (this.refuseWhileStreaming(event)) return
58
+ this.downloadText(this.source, this.downloadName, "text/plain")
59
+ }
60
+
61
+ // ------------------------------------------------------------------ reading
62
+
63
+ get sourceElement() {
64
+ if (this.hasSourceTarget) return this.sourceTarget
65
+ try {
66
+ return this.element.querySelector(this.sourceSelectorValue)
67
+ } catch {
68
+ // The selector is a DOM-derived string; a malformed one must not throw
69
+ // out of an event handler.
70
+ return null
71
+ }
72
+ }
73
+
74
+ // Untrusted text. It is only ever handed to the clipboard, to a Blob, or to
75
+ // a textarea's `value` — never assigned as HTML.
76
+ get source() {
77
+ return this.sourceElement?.textContent ?? ""
78
+ }
79
+
80
+ // The DOM contract writes the language as `data-ms-code-lang`, which is one
81
+ // suffix short of a Stimulus value (`data-ms-code-lang-value`). It is read as
82
+ // a plain attribute on purpose so the pipeline's output needs no change.
83
+ get language() {
84
+ return (this.element.getAttribute("data-ms-code-lang") || "").trim().toLowerCase()
85
+ }
86
+
87
+ get extension() {
88
+ return this.constructor.extensions[this.language] || "txt"
89
+ }
90
+
91
+ get downloadName() {
92
+ if (this.filenameValue.length) return this.filenameValue
93
+ const stem = this.language.replace(/[^a-z0-9_-]/g, "") || "snippet"
94
+ return `${stem}.${this.extension}`
95
+ }
96
+ }
@@ -0,0 +1,223 @@
1
+ import ApplicationController from "maquina_stream/controllers/application_controller"
2
+
3
+ // `ms-deferred` — the base class for a renderer that runs in the browser.
4
+ //
5
+ // Subclasses (`ms-diagram`, `ms-math`) supply two things: how to import their
6
+ // library, and how to turn a payload into markup. Everything else — when to
7
+ // render, what to do on a repair morph, how to fail, and what may reach the
8
+ // DOM — is decided here, once.
9
+ //
10
+ // <div data-controller="ms-diagram"
11
+ // data-ms-diagram-payload-value='{"source":"graph TD…"}'>
12
+ // <div data-ms-diagram-target="output" data-turbo-permanent>…</div>
13
+ // </div>
14
+ //
15
+ // Split ownership, from docs/deferred-renderers.md: the payload attribute is server
16
+ // state and belongs to morph; the output element is client state and belongs to
17
+ // this controller. A repair morph that leaves the payload byte-identical fires
18
+ // no value-changed callback, so it triggers no re-render and no flicker — which
19
+ // is the property Phase 4 depends on.
20
+ export default class extends ApplicationController {
21
+ static targets = ["output"]
22
+
23
+ static values = {
24
+ payload: Object,
25
+ // Rendering is deferred until the element is on screen. A conversation
26
+ // scrolled back through hundreds of messages should not render hundreds of
27
+ // diagrams nobody is looking at.
28
+ eager: { type: Boolean, default: false }
29
+ }
30
+
31
+ connect() {
32
+ this.connected = true
33
+
34
+ if (this.eagerValue) return this.render()
35
+
36
+ this.observer = new IntersectionObserver((entries) => {
37
+ if (entries.some((entry) => entry.isIntersecting)) this.render()
38
+ }, { rootMargin: "200px" })
39
+
40
+ this.observer.observe(this.element)
41
+ }
42
+
43
+ disconnect() {
44
+ this.connected = false
45
+ this.observer?.disconnect()
46
+ this.observer = null
47
+ }
48
+
49
+ // The payload changed under a morph: re-render, but only because it really
50
+ // changed. Stimulus only fires this when the attribute's value differs.
51
+ //
52
+ // The `connected` guard is the whole of the lazy load, and it is not
53
+ // defensive coding. Stimulus invokes every value-changed callback ONCE while
54
+ // the context connects, before `connect()` runs, and it passes as `previous`
55
+ // the value type's DEFAULT — `{}` for an Object — never `undefined`. So a
56
+ // guard written as `previous === undefined` never fires: the initial
57
+ // invocation reads as a real change, resets `rendered` and calls `render()`.
58
+ //
59
+ // That is exactly what happened. With mermaid and katex pinned in the dummy
60
+ // app, Chromium fetched both at scrollY 0 with the blocks 4,500px down the
61
+ // page. `library()` is lazy and the observer is correct; this callback was
62
+ // calling past both of them.
63
+ payloadValueChanged(payload, previous) {
64
+ if (!this.connected) return
65
+ if (JSON.stringify(payload) === JSON.stringify(previous)) return
66
+
67
+ this.rendered = false
68
+ this.render()
69
+ }
70
+
71
+ async render() {
72
+ if (this.rendered) return
73
+ if (!this.hasPayload) return
74
+
75
+ this.rendered = true
76
+
77
+ try {
78
+ const markup = await this.draw(this.payloadValue)
79
+ this.replaceOutput(markup)
80
+ } catch (error) {
81
+ this.fail(error)
82
+ }
83
+ }
84
+
85
+ get hasPayload() {
86
+ return this.payloadValue && Object.keys(this.payloadValue).length > 0
87
+ }
88
+
89
+ // ------------------------------------------------------------- subclass API
90
+
91
+ // Import the library. Called once, lazily, and only when there is something
92
+ // to draw — a page with no deferred content loads no renderer at all.
93
+ async library() {
94
+ throw new Error("ms-deferred subclasses must implement library()")
95
+ }
96
+
97
+ // Turn a payload into markup. Returns a string or a Node.
98
+ async draw(_payload) {
99
+ throw new Error("ms-deferred subclasses must implement draw()")
100
+ }
101
+
102
+ // ------------------------------------------------------------------ output
103
+
104
+ // Nothing a renderer produces is trusted. The server sanitized the document,
105
+ // and this sanitizes again on the way in: the payload is model output, the
106
+ // library is third-party, and neither is a reason to skip the check. See
107
+ // CLAUDE.md — renderer output gets sanitized client-side even though the
108
+ // server already sanitized the document.
109
+ replaceOutput(markup) {
110
+ if (!this.hasOutputTarget) return
111
+
112
+ const safe = this.sanitize(markup)
113
+ this.outputTarget.replaceChildren(safe)
114
+ this.notify("rendered", { controller: this.identifier })
115
+ }
116
+
117
+ sanitize(markup) {
118
+ const template = document.createElement("template")
119
+
120
+ if (markup instanceof Node) {
121
+ template.content.append(markup)
122
+ } else {
123
+ template.innerHTML = String(markup)
124
+ }
125
+
126
+ this.scrub(template.content)
127
+ return template.content
128
+ }
129
+
130
+ // An allowlist, not a blocklist: anything not named here loses its markup and
131
+ // keeps its text. Scripts and foreign-content subtrees go entirely.
132
+ scrub(root) {
133
+ const allowed = this.constructor.allowedElements
134
+ const dropWithContent = this.constructor.droppedElements
135
+
136
+ for (const node of [...root.querySelectorAll("*")]) {
137
+ const name = node.tagName.toLowerCase()
138
+
139
+ if (dropWithContent.has(name)) {
140
+ node.remove()
141
+ continue
142
+ }
143
+
144
+ for (const attribute of [...node.attributes]) {
145
+ if (!this.attributeAllowed(name, attribute)) node.removeAttribute(attribute.name)
146
+ }
147
+
148
+ if (!allowed.has(name)) node.replaceWith(...node.childNodes)
149
+ }
150
+ }
151
+
152
+ attributeAllowed(_element, attribute) {
153
+ const name = attribute.name.toLowerCase()
154
+
155
+ if (name.startsWith("on")) return false
156
+ if (name === "style") return false
157
+ if (["href", "xlink:href", "src", "srcdoc", "formaction"].includes(name)) {
158
+ return name === "href" && /^(https?:|mailto:|#)/i.test(attribute.value.trim())
159
+ }
160
+
161
+ return this.constructor.allowedAttributes.has(name)
162
+ }
163
+
164
+ static allowedElements = new Set([
165
+ "svg", "g", "path", "rect", "circle", "ellipse", "line", "polyline", "polygon",
166
+ "text", "tspan", "marker", "defs", "symbol", "use", "title", "desc", "foreignObject",
167
+ "div", "span", "p", "br", "sub", "sup", "table", "tbody", "tr", "td", "annotation",
168
+ "semantics", "mrow", "mi", "mn", "mo", "ms", "mtext", "mfrac", "msqrt", "mroot",
169
+ "msub", "msup", "msubsup", "munder", "mover", "munderover", "mtable", "mtr", "mtd",
170
+ "mspace", "mpadded", "mphantom", "menclose", "mstyle", "math", "a"
171
+ ])
172
+
173
+ static droppedElements = new Set([
174
+ "script", "iframe", "object", "embed", "link", "meta", "base", "form",
175
+ "input", "button", "textarea", "select", "audio", "video", "animate",
176
+ "animatetransform", "set", "handler", "listener"
177
+ ])
178
+
179
+ static allowedAttributes = new Set([
180
+ "class", "id", "width", "height", "viewbox", "d", "fill", "stroke",
181
+ "stroke-width", "stroke-linecap", "stroke-linejoin", "stroke-dasharray",
182
+ "transform", "x", "y", "x1", "x2", "y1", "y2", "cx", "cy", "r", "rx", "ry",
183
+ "points", "text-anchor", "dominant-baseline", "font-size", "font-family",
184
+ "font-weight", "opacity", "fill-opacity", "stroke-opacity", "marker-end",
185
+ "marker-start", "preserveaspectratio", "aria-label", "aria-hidden", "role",
186
+ "mathvariant", "displaystyle", "scriptlevel", "href", "dir", "lang",
187
+ "colspan", "rowspan", "columnalign", "rowalign", "open", "close", "separator"
188
+ ])
189
+
190
+ // ------------------------------------------------------------------- errors
191
+
192
+ // A broken payload never breaks the message. The block degrades to something
193
+ // readable and says why, and the rest of the message is untouched.
194
+ fail(error) {
195
+ if (!this.hasOutputTarget) return
196
+
197
+ const fallback = document.createElement("div")
198
+ fallback.setAttribute("data-ms-deferred-error", "")
199
+ fallback.setAttribute("role", "note")
200
+
201
+ const message = document.createElement("p")
202
+ message.textContent = this.errorLabel
203
+
204
+ // The source is the export fallback, and it is the same one for every
205
+ // deferred renderer: show what the model actually wrote. See
206
+ // docs/deferred-renderers.md.
207
+ const source = document.createElement("pre")
208
+ source.textContent = this.payloadValue?.source ?? ""
209
+
210
+ fallback.append(message, source)
211
+ this.outputTarget.replaceChildren(fallback)
212
+ this.notify("render-failed", { controller: this.identifier, error: String(error) })
213
+ }
214
+
215
+ // The server renders the label, translated, into the data attribute. This
216
+ // fallback is only reached when a host renders the block without one, so it
217
+ // is the language of last resort rather than the engine's default locale —
218
+ // JavaScript cannot read I18n, and hardcoding Spanish here would show Spanish
219
+ // to a host that never asked for it.
220
+ get errorLabel() {
221
+ return this.element.dataset.msDeferredErrorLabel || "This block could not be rendered."
222
+ }
223
+ }
@@ -0,0 +1,40 @@
1
+ import MsDeferredController from "maquina_stream/controllers/ms_deferred_controller"
2
+
3
+ // `ms-diagram` — Mermaid, rendered in the browser, in strict security mode.
4
+ //
5
+ // The library is imported the first time a diagram is actually about to draw,
6
+ // so a page with no diagrams never fetches it. Pinned by version: NoBuild means
7
+ // no lockfile, so the version lives in the importmap and nowhere else.
8
+ export default class extends MsDeferredController {
9
+ static values = {
10
+ ...MsDeferredController.values,
11
+ theme: { type: String, default: "default" }
12
+ }
13
+
14
+ static library = null
15
+
16
+ async library() {
17
+ // securityLevel "strict" is the point: it disables click handlers and
18
+ // inline HTML inside diagram source, which is model output.
19
+ if (!this.constructor.library) {
20
+ const mermaid = await import("mermaid")
21
+ mermaid.default.initialize({
22
+ startOnLoad: false,
23
+ securityLevel: "strict",
24
+ theme: this.themeValue
25
+ })
26
+ this.constructor.library = mermaid.default
27
+ }
28
+
29
+ return this.constructor.library
30
+ }
31
+
32
+ async draw(payload) {
33
+ const mermaid = await this.library()
34
+ const id = `ms-mermaid-${Math.random().toString(36).slice(2)}`
35
+ const { svg } = await mermaid.render(id, String(payload.source ?? ""))
36
+
37
+ // Sanitized by ms-deferred on the way in, strict mode or not.
38
+ return svg
39
+ }
40
+ }
@@ -0,0 +1,196 @@
1
+ import ApplicationController from "maquina_stream/controllers/application_controller"
2
+
3
+ // The host seam for the allowlist. It is a function, so it cannot live in a
4
+ // data attribute — and it must not: an allowlist an injected fragment can
5
+ // rewrite is not an allowlist.
6
+ //
7
+ // import { linkSafety } from "maquina_stream"
8
+ // linkSafety.allow = (url) => url.hostname.endsWith("example.com")
9
+ //
10
+ // `url` is a parsed `URL`. Returning true follows the link with no dialog.
11
+ export const linkSafety = {
12
+ allow: null
13
+ }
14
+
15
+ // `ms-link-safety` — confirm before following a link out of a rendered message.
16
+ //
17
+ // <div data-controller="ms-link-safety"
18
+ // data-action="click->ms-link-safety#intercept">
19
+ // …rendered message, links and all…
20
+ // <dialog data-ms-link-safety-target="dialog">
21
+ // <p data-ms-link-safety-target="url"></p>
22
+ // <label><input type="checkbox" data-ms-link-safety-target="remember"> …</label>
23
+ // <button data-action="ms-link-safety#confirm">Continuar</button>
24
+ // <button data-action="ms-link-safety#cancel">Cancelar</button>
25
+ // </dialog>
26
+ // </div>
27
+ //
28
+ // Mounted on the message container rather than on each anchor: the anchors are
29
+ // model output and the count is unbounded, and one delegated listener survives
30
+ // a repair morph that replaces every one of them.
31
+ export default class extends ApplicationController {
32
+ static targets = ["dialog", "url", "remember"]
33
+
34
+ static values = {
35
+ // Same origin is always followed without a prompt. Anything else is
36
+ // "external" unless the host allowlist or a remembered host says otherwise.
37
+ origin: { type: String, default: "" },
38
+ // Turns the whole guard off — for a host that does its own interstitial.
39
+ bypass: { type: Boolean, default: false },
40
+ // Hosts the user chose to trust, kept for the tab only. Session storage,
41
+ // not local: trust granted mid-conversation should not outlive it.
42
+ rememberKey: { type: String, default: "ms-link-safety.trusted" }
43
+ }
44
+
45
+ // A URL the dialog must never offer to follow, whatever the document says.
46
+ // The server sanitizer already drops these; a second check here costs
47
+ // nothing and this controller is the thing that calls `window.open`.
48
+ static allowedProtocols = ["http:", "https:", "mailto:"]
49
+
50
+ disconnect() {
51
+ this.closeDialog({ restoreFocus: false })
52
+ }
53
+
54
+ // ------------------------------------------------------------------ actions
55
+
56
+ // Delegated. Everything that is not an anchor with an href falls straight
57
+ // through to the browser.
58
+ intercept(event) {
59
+ if (event.defaultPrevented || event.button !== 0) return
60
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return
61
+
62
+ const anchor = event.target instanceof Element ? event.target.closest("a[href]") : null
63
+ if (!anchor || !this.element.contains(anchor)) return
64
+
65
+ const url = this.parse(anchor.getAttribute("href"))
66
+
67
+ // An href we cannot parse, or one on a protocol we refuse, is stopped
68
+ // here. Letting the browser have it is the one thing we must not do.
69
+ if (!url) {
70
+ event.preventDefault()
71
+ this.notify("link-refused", { href: anchor.getAttribute("href") })
72
+ return
73
+ }
74
+
75
+ if (this.permitted(url)) return
76
+
77
+ event.preventDefault()
78
+ this.ask(url, anchor)
79
+ }
80
+
81
+ confirm(event) {
82
+ event?.preventDefault()
83
+
84
+ const url = this.pending
85
+ if (this.hasRememberTarget && this.rememberTarget.checked && url) this.trust(url.host)
86
+ this.closeDialog()
87
+
88
+ if (!url) return
89
+ this.notify("link-followed", { href: url.href })
90
+ window.open(url.href, "_blank", "noopener,noreferrer")
91
+ }
92
+
93
+ cancel(event) {
94
+ event?.preventDefault()
95
+ const url = this.pending
96
+ this.closeDialog()
97
+ this.notify("link-cancelled", { href: url?.href })
98
+ }
99
+
100
+ // ------------------------------------------------------------------- policy
101
+
102
+ get origin() {
103
+ return this.originValue || window.location.origin
104
+ }
105
+
106
+ parse(href) {
107
+ let url
108
+ try {
109
+ url = new URL(href, this.origin)
110
+ } catch {
111
+ return null
112
+ }
113
+ return this.constructor.allowedProtocols.includes(url.protocol) ? url : null
114
+ }
115
+
116
+ permitted(url) {
117
+ if (this.bypassValue) return true
118
+ if (url.protocol !== "http:" && url.protocol !== "https:") return true
119
+ if (url.origin === this.origin) return true
120
+ if (this.trusted.has(url.host)) return true
121
+
122
+ // The host callback is asked last so it can only widen, never narrow, and
123
+ // a callback that throws denies rather than admits.
124
+ try {
125
+ return Boolean(linkSafety.allow?.(url))
126
+ } catch {
127
+ return false
128
+ }
129
+ }
130
+
131
+ get trusted() {
132
+ try {
133
+ return new Set(JSON.parse(window.sessionStorage.getItem(this.rememberKeyValue) || "[]"))
134
+ } catch {
135
+ return new Set()
136
+ }
137
+ }
138
+
139
+ trust(host) {
140
+ try {
141
+ const hosts = this.trusted
142
+ hosts.add(host)
143
+ window.sessionStorage.setItem(this.rememberKeyValue, JSON.stringify([...hosts]))
144
+ } catch {
145
+ // Private browsing. Trust simply does not persist.
146
+ }
147
+ }
148
+
149
+ // ------------------------------------------------------------------- dialog
150
+
151
+ ask(url, anchor) {
152
+ this.pending = url
153
+ this.trigger = anchor
154
+
155
+ if (!this.hasDialogTarget) {
156
+ // No dialog in the markup. `window.confirm` traps focus and restores it
157
+ // by itself, so the contract still holds.
158
+ if (window.confirm(url.href)) this.confirm()
159
+ else this.cancel()
160
+ return
161
+ }
162
+
163
+ // The href is attacker-influenced text. It is written as text, never as
164
+ // markup, and never into an href on the dialog itself.
165
+ if (this.hasUrlTarget) this.urlTarget.textContent = url.href
166
+ if (this.hasRememberTarget) this.rememberTarget.checked = false
167
+
168
+ // `showModal` is the focus trap: the browser makes the rest of the
169
+ // document inert and keeps Tab inside the dialog. Re-implementing that in
170
+ // JavaScript is how focus traps get holes.
171
+ this.dialogTarget.showModal()
172
+ this.boundDialogCancel ||= (event) => {
173
+ // Esc. Route it through `cancel` so one path closes the dialog.
174
+ event.preventDefault()
175
+ this.cancel()
176
+ }
177
+ this.dialogTarget.addEventListener("cancel", this.boundDialogCancel)
178
+ this.notify("link-prompted", { href: url.href })
179
+ }
180
+
181
+ closeDialog({ restoreFocus = true } = {}) {
182
+ if (this.hasDialogTarget && this.dialogTarget.open) {
183
+ if (this.boundDialogCancel) {
184
+ this.dialogTarget.removeEventListener("cancel", this.boundDialogCancel)
185
+ }
186
+ this.dialogTarget.close()
187
+ }
188
+
189
+ // Focus goes back where it came from, always — the anchor the user
190
+ // clicked, so a keyboard user is not dumped at the top of the document.
191
+ if (restoreFocus && this.trigger?.isConnected) this.trigger.focus()
192
+
193
+ this.pending = null
194
+ this.trigger = null
195
+ }
196
+ }