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