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,183 @@
|
|
|
1
|
+
import ApplicationController from "maquina_stream/controllers/application_controller"
|
|
2
|
+
|
|
3
|
+
// `ms-table` — copy and download a rendered table as markdown, CSV or TSV, and
|
|
4
|
+
// toggle fullscreen.
|
|
5
|
+
//
|
|
6
|
+
// The render post-pass already mounts this controller on the table wrapper:
|
|
7
|
+
//
|
|
8
|
+
// <div data-ms-table data-controller="ms-table">
|
|
9
|
+
// <table>…</table>
|
|
10
|
+
// </div>
|
|
11
|
+
//
|
|
12
|
+
// It emits no controls, so a host adds them inside the wrapper:
|
|
13
|
+
//
|
|
14
|
+
// <button data-ms-control data-action="ms-table#copy"
|
|
15
|
+
// data-ms-table-format-param="markdown">…</button>
|
|
16
|
+
// <button data-ms-control data-action="ms-table#download"
|
|
17
|
+
// data-ms-table-format-param="csv">…</button>
|
|
18
|
+
// <button data-ms-control data-action="ms-table#toggleFullscreen">…</button>
|
|
19
|
+
//
|
|
20
|
+
// The table is reconstructed from the DOM, cell by cell, using `textContent`.
|
|
21
|
+
// Nothing here reads or produces HTML.
|
|
22
|
+
export default class extends ApplicationController {
|
|
23
|
+
static targets = ["table"]
|
|
24
|
+
|
|
25
|
+
static classes = ["fullscreen"]
|
|
26
|
+
|
|
27
|
+
static values = {
|
|
28
|
+
tableSelector: { type: String, default: "table" },
|
|
29
|
+
filename: { type: String, default: "table" },
|
|
30
|
+
format: { type: String, default: "markdown" },
|
|
31
|
+
fullscreen: { type: Boolean, default: false }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
static mimes = {
|
|
35
|
+
markdown: "text/markdown",
|
|
36
|
+
csv: "text/csv",
|
|
37
|
+
tsv: "text/tab-separated-values"
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
static extensions = { markdown: "md", csv: "csv", tsv: "tsv" }
|
|
41
|
+
|
|
42
|
+
connect() {
|
|
43
|
+
this.startStreamGuard()
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
disconnect() {
|
|
47
|
+
this.stopStreamGuard()
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ------------------------------------------------------------------ actions
|
|
51
|
+
|
|
52
|
+
copy(event) {
|
|
53
|
+
if (this.refuseWhileStreaming(event)) return
|
|
54
|
+
this.copyText(this.serialize(this.formatFrom(event)))
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
download(event) {
|
|
58
|
+
if (this.refuseWhileStreaming(event)) return
|
|
59
|
+
|
|
60
|
+
const format = this.formatFrom(event)
|
|
61
|
+
const extension = this.constructor.extensions[format]
|
|
62
|
+
this.downloadText(
|
|
63
|
+
this.serialize(format),
|
|
64
|
+
`${this.filenameValue}.${extension}`,
|
|
65
|
+
this.constructor.mimes[format]
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
toggleFullscreen(event) {
|
|
70
|
+
if (this.refuseWhileStreaming(event)) return
|
|
71
|
+
this.fullscreenValue = !this.fullscreenValue
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
exitFullscreen() {
|
|
75
|
+
this.fullscreenValue = false
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// The Fullscreen API is not available in every embedding context (an iframe
|
|
79
|
+
// without `allowfullscreen`, for one), so the class is the source of truth
|
|
80
|
+
// and the native call is best-effort on top of it.
|
|
81
|
+
fullscreenValueChanged(fullscreen, previous) {
|
|
82
|
+
if (this.hasFullscreenClass) {
|
|
83
|
+
this.element.classList.toggle(this.fullscreenClass, fullscreen)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (previous === undefined) return
|
|
87
|
+
|
|
88
|
+
if (fullscreen) {
|
|
89
|
+
this.element.requestFullscreen?.().catch(() => {})
|
|
90
|
+
} else if (document.fullscreenElement === this.element) {
|
|
91
|
+
document.exitFullscreen?.().catch(() => {})
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
this.notify("fullscreen", { fullscreen })
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Turbo caches the page as it stands. A table left fullscreen would come
|
|
98
|
+
// back that way on a restore visit.
|
|
99
|
+
teardown() {
|
|
100
|
+
this.fullscreenValue = false
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ------------------------------------------------------------------ reading
|
|
104
|
+
|
|
105
|
+
formatFrom(event) {
|
|
106
|
+
const requested = event?.params?.format || this.formatValue
|
|
107
|
+
return this.constructor.extensions[requested] ? requested : "markdown"
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
get tableElement() {
|
|
111
|
+
if (this.hasTableTarget) return this.tableTarget
|
|
112
|
+
try {
|
|
113
|
+
return this.element.querySelector(this.tableSelectorValue)
|
|
114
|
+
} catch {
|
|
115
|
+
return null
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// `[headerRow, ...bodyRows]`, every cell a plain string. `rowSpan`/`colSpan`
|
|
120
|
+
// are not expanded: markdown tables have no such thing, and the pipeline
|
|
121
|
+
// never produces them.
|
|
122
|
+
get rows() {
|
|
123
|
+
const table = this.tableElement
|
|
124
|
+
if (!table) return []
|
|
125
|
+
|
|
126
|
+
return Array.from(table.rows).map((row) =>
|
|
127
|
+
Array.from(row.cells).map((cell) => cell.textContent.trim())
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// --------------------------------------------------------------- serializing
|
|
132
|
+
|
|
133
|
+
serialize(format) {
|
|
134
|
+
const rows = this.rows
|
|
135
|
+
if (!rows.length) return ""
|
|
136
|
+
|
|
137
|
+
switch (format) {
|
|
138
|
+
case "csv": return this.delimited(rows, ",")
|
|
139
|
+
case "tsv": return this.delimited(rows, "\t")
|
|
140
|
+
default: return this.markdown(rows)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// RFC 4180. A cell is quoted when it contains the separator, a double quote,
|
|
145
|
+
// CR or LF; embedded quotes are doubled. Applied to tabs too, so a TSV cell
|
|
146
|
+
// holding a tab or a newline still round-trips through any parser that reads
|
|
147
|
+
// RFC 4180 with a tab delimiter.
|
|
148
|
+
delimited(rows, separator) {
|
|
149
|
+
const width = Math.max(...rows.map((row) => row.length))
|
|
150
|
+
|
|
151
|
+
return rows
|
|
152
|
+
.map((row) => {
|
|
153
|
+
const padded = Array.from({ length: width }, (_, i) => row[i] ?? "")
|
|
154
|
+
return padded.map((cell) => this.quote(cell, separator)).join(separator)
|
|
155
|
+
})
|
|
156
|
+
.join("\r\n")
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
quote(cell, separator) {
|
|
160
|
+
const needsQuotes =
|
|
161
|
+
cell.includes(separator) || cell.includes('"') || cell.includes("\n") || cell.includes("\r")
|
|
162
|
+
return needsQuotes ? `"${cell.replace(/"/g, '""')}"` : cell
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// GFM pipe table. `|` is escaped as `\|` — the only escape GFM defines
|
|
166
|
+
// inside a cell — and a newline becomes `<br>`, which is what GFM itself
|
|
167
|
+
// uses, since a pipe table row cannot span lines.
|
|
168
|
+
markdown(rows) {
|
|
169
|
+
const width = Math.max(...rows.map((row) => row.length))
|
|
170
|
+
const [header, ...body] = rows
|
|
171
|
+
const line = (row) => {
|
|
172
|
+
const padded = Array.from({ length: width }, (_, i) => row[i] ?? "")
|
|
173
|
+
return `| ${padded.map((cell) => this.escapePipes(cell)).join(" | ")} |`
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const separator = `| ${Array.from({ length: width }, () => "---").join(" | ")} |`
|
|
177
|
+
return [line(header), separator, ...body.map(line)].join("\n")
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
escapePipes(cell) {
|
|
181
|
+
return cell.replace(/\\/g, "\\\\").replace(/\|/g, "\\|").replace(/\r?\n/g, "<br>")
|
|
182
|
+
}
|
|
183
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// maquina_stream — Stimulus entrypoint.
|
|
2
|
+
//
|
|
3
|
+
// The engine registers its own controllers rather than relying on the host's
|
|
4
|
+
// eager-load glob: the identifiers are part of the DOM contract
|
|
5
|
+
// (docs/javascript.md) and must not depend on where a host puts its files.
|
|
6
|
+
//
|
|
7
|
+
// import { Application } from "@hotwired/stimulus"
|
|
8
|
+
// import { registerMaquinaStreamControllers } from "maquina_stream"
|
|
9
|
+
//
|
|
10
|
+
// const application = Application.start()
|
|
11
|
+
// registerMaquinaStreamControllers(application)
|
|
12
|
+
//
|
|
13
|
+
// Nothing here imports a third-party library. The controllers that do —
|
|
14
|
+
// `ms-diagram`, `ms-math` — import lazily at render time, per the NoBuild rule
|
|
15
|
+
// in CLAUDE.md.
|
|
16
|
+
|
|
17
|
+
import MsAutoscrollController from "maquina_stream/controllers/ms_autoscroll_controller"
|
|
18
|
+
import MsCodeController from "maquina_stream/controllers/ms_code_controller"
|
|
19
|
+
import MsLinkSafetyController from "maquina_stream/controllers/ms_link_safety_controller"
|
|
20
|
+
import MsDeferredController from "maquina_stream/controllers/ms_deferred_controller"
|
|
21
|
+
import MsDiagramController from "maquina_stream/controllers/ms_diagram_controller"
|
|
22
|
+
import MsMathController from "maquina_stream/controllers/ms_math_controller"
|
|
23
|
+
import MsRepairController from "maquina_stream/controllers/ms_repair_controller"
|
|
24
|
+
import MsRevealController from "maquina_stream/controllers/ms_reveal_controller"
|
|
25
|
+
import MsTableController from "maquina_stream/controllers/ms_table_controller"
|
|
26
|
+
|
|
27
|
+
export { linkSafety } from "maquina_stream/controllers/ms_link_safety_controller"
|
|
28
|
+
|
|
29
|
+
// Identifier → controller. The identifiers are fixed by docs/javascript.md.
|
|
30
|
+
export const controllers = {
|
|
31
|
+
"ms-autoscroll": MsAutoscrollController,
|
|
32
|
+
"ms-code": MsCodeController,
|
|
33
|
+
"ms-link-safety": MsLinkSafetyController,
|
|
34
|
+
"ms-deferred": MsDeferredController,
|
|
35
|
+
"ms-diagram": MsDiagramController,
|
|
36
|
+
"ms-math": MsMathController,
|
|
37
|
+
"ms-repair": MsRepairController,
|
|
38
|
+
"ms-reveal": MsRevealController,
|
|
39
|
+
"ms-table": MsTableController
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function registerMaquinaStreamControllers(application) {
|
|
43
|
+
Object.entries(controllers).forEach(([identifier, controller]) => {
|
|
44
|
+
application.register(identifier, controller)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
// Turbo caches the page as the user left it. A controller that mutated the
|
|
48
|
+
// DOM rolls that back here rather than in `disconnect`, which also runs on
|
|
49
|
+
// ordinary navigation. See betterstimulus.com, "global teardown".
|
|
50
|
+
document.addEventListener("turbo:before-cache", () => {
|
|
51
|
+
application.controllers.forEach((controller) => {
|
|
52
|
+
if (typeof controller.teardown === "function") controller.teardown()
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
return application
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export default registerMaquinaStreamControllers
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
<%# EXTRACTION CANDIDATE → maquina_components. See MaquinaStream::VENDORED_COMPONENTS %>
|
|
2
|
+
<%# locals: (filename: nil, byte_size: nil, content_type: nil, url: nil, preview_url: nil, remove_path: nil, controls: {}, unnamed_label: nil, download_label: nil, download_aria_label: nil, remove_label: nil, remove_aria_label: nil, remove_confirm: nil, size_units: nil, size_format: "%{value} %{unit}", decimal_separator: ".", thumbnail_attributes: {}, download_attributes: {}, remove_attributes: {}, variant: :grid, size: :md, css_classes: "", **html_options) %>
|
|
3
|
+
<%#
|
|
4
|
+
One attached file. The locals are ActiveStorage's own attribute names —
|
|
5
|
+
`filename`, `byte_size`, `content_type` — plus the two URLs the host resolved
|
|
6
|
+
(`url` for the blob, `preview_url` for a variant). AI Elements' Attachment
|
|
7
|
+
takes a `FileUIPart`; that is the AI SDK's client-side model and importing it
|
|
8
|
+
would fight the server-rendered decision. We take the visual design, not the
|
|
9
|
+
props.
|
|
10
|
+
|
|
11
|
+
Three variants:
|
|
12
|
+
|
|
13
|
+
:grid a card — thumbnail (images) or icon glyph, then name and meta
|
|
14
|
+
:inline a compact pill for flowing next to text
|
|
15
|
+
:list a full-width row, name and meta on one line
|
|
16
|
+
|
|
17
|
+
The media category is derived from `content_type` here, in Ruby, never by a
|
|
18
|
+
JS utility reading the DOM.
|
|
19
|
+
|
|
20
|
+
Every label is a local, and so is every attribute that binds this markup to
|
|
21
|
+
someone's JavaScript: the thumbnail's failure hook and the download control's
|
|
22
|
+
target arrive as `thumbnail_attributes` and `download_attributes`. In this
|
|
23
|
+
engine MaquinaStream::Components::Contract supplies them (`ms-attachment`,
|
|
24
|
+
`data-ms-attachment-target`) and translates the labels; nothing about
|
|
25
|
+
maquina_stream is written down here.
|
|
26
|
+
|
|
27
|
+
`controls` switches the two affordances off individually — a control the
|
|
28
|
+
caller has not enabled, or has not named, is not rendered. An image whose
|
|
29
|
+
blob 404s must not offer a download that cannot work, which is why the
|
|
30
|
+
download control is reachable from the same controller the thumbnail reports
|
|
31
|
+
its error to.
|
|
32
|
+
%>
|
|
33
|
+
<%
|
|
34
|
+
filename = local_assigns.fetch(:filename, nil).to_s
|
|
35
|
+
byte_size = local_assigns.fetch(:byte_size, nil)
|
|
36
|
+
content_type = local_assigns.fetch(:content_type, nil).presence
|
|
37
|
+
url = local_assigns.fetch(:url, nil).presence
|
|
38
|
+
preview_url = local_assigns.fetch(:preview_url, nil).presence
|
|
39
|
+
remove_path = local_assigns.fetch(:remove_path, nil).presence
|
|
40
|
+
controls = local_assigns.fetch(:controls, nil) || {}
|
|
41
|
+
variant = (local_assigns.fetch(:variant, nil) || :grid).to_sym
|
|
42
|
+
size ||= :md
|
|
43
|
+
|
|
44
|
+
image = content_type.to_s.start_with?("image/")
|
|
45
|
+
thumbnail = image && preview_url
|
|
46
|
+
label = filename.presence || unnamed_label
|
|
47
|
+
|
|
48
|
+
downloadable = url.present? && controls.fetch(:download, true) && download_label.present?
|
|
49
|
+
removable = remove_path.present? && controls.fetch(:remove, true) && remove_label.present?
|
|
50
|
+
|
|
51
|
+
units = Array(size_units).presence || %w[B kB MB GB TB]
|
|
52
|
+
size_format = local_assigns.fetch(:size_format, nil).presence || "%{value} %{unit}"
|
|
53
|
+
separator = local_assigns.fetch(:decimal_separator, nil).presence || "."
|
|
54
|
+
|
|
55
|
+
human_size =
|
|
56
|
+
if byte_size.present?
|
|
57
|
+
bytes = Integer(byte_size)
|
|
58
|
+
index = 0
|
|
59
|
+
index += 1 while bytes >= 1024**(index + 1) && index < units.length - 1
|
|
60
|
+
number =
|
|
61
|
+
if index.zero?
|
|
62
|
+
bytes.to_s
|
|
63
|
+
else
|
|
64
|
+
format("%.1f", bytes.to_f / 1024**index).sub(".", separator)
|
|
65
|
+
end
|
|
66
|
+
format(size_format, value: number, unit: units[index])
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
control_classes = "shrink-0 rounded px-2 py-1 text-xs text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800"
|
|
70
|
+
|
|
71
|
+
download_options = {
|
|
72
|
+
download: filename.presence,
|
|
73
|
+
data: { attachment_part: "download" },
|
|
74
|
+
"aria-label" => download_aria_label.presence || download_label,
|
|
75
|
+
class: control_classes
|
|
76
|
+
}.merge(download_attributes)
|
|
77
|
+
|
|
78
|
+
remove_options = {
|
|
79
|
+
method: :delete,
|
|
80
|
+
form: { data: { turbo_confirm: remove_confirm } },
|
|
81
|
+
data: { attachment_part: "remove" },
|
|
82
|
+
"aria-label" => remove_aria_label.presence || remove_label,
|
|
83
|
+
class: control_classes
|
|
84
|
+
}.merge(remove_attributes)
|
|
85
|
+
|
|
86
|
+
layout_classes = {
|
|
87
|
+
grid: "ms-attachment--grid flex w-40 flex-col gap-2 rounded-lg border border-gray-200 bg-white p-2 text-sm dark:border-gray-800 dark:bg-gray-900",
|
|
88
|
+
inline: "ms-attachment--inline inline-flex max-w-full items-center gap-2 rounded-full border border-gray-200 bg-gray-50 px-3 py-1 text-xs dark:border-gray-800 dark:bg-gray-900",
|
|
89
|
+
list: "ms-attachment--list flex w-full items-center gap-3 rounded-md border border-gray-200 bg-white px-3 py-2 text-sm dark:border-gray-800 dark:bg-gray-900"
|
|
90
|
+
}.fetch(variant, "ms-attachment--grid flex w-40 flex-col gap-2 rounded-lg border border-gray-200 bg-white p-2 text-sm dark:border-gray-800 dark:bg-gray-900")
|
|
91
|
+
|
|
92
|
+
attributes = component_html_options(
|
|
93
|
+
html_options,
|
|
94
|
+
own_data: {
|
|
95
|
+
component: "attachment",
|
|
96
|
+
variant: variant,
|
|
97
|
+
size: size
|
|
98
|
+
},
|
|
99
|
+
css_classes: css_classes,
|
|
100
|
+
base_classes: "ms-attachment #{layout_classes}"
|
|
101
|
+
)
|
|
102
|
+
%>
|
|
103
|
+
<%= tag.figure(**attributes) do %>
|
|
104
|
+
<% if thumbnail %>
|
|
105
|
+
<img src="<%= preview_url %>"
|
|
106
|
+
alt="<%= label %>"
|
|
107
|
+
loading="lazy"
|
|
108
|
+
data-attachment-part="thumbnail"
|
|
109
|
+
class="<%= variant == :grid ? "h-24 w-full rounded object-cover" : "h-8 w-8 rounded object-cover" %>"
|
|
110
|
+
<%= tag.attributes(thumbnail_attributes) %>>
|
|
111
|
+
<% else %>
|
|
112
|
+
<span data-attachment-part="icon"
|
|
113
|
+
aria-hidden="true"
|
|
114
|
+
class="flex h-8 w-8 shrink-0 items-center justify-center rounded bg-gray-100 text-xs font-medium uppercase text-gray-500 dark:bg-gray-800 dark:text-gray-400">
|
|
115
|
+
<%= File.extname(filename).delete_prefix(".").first(4).presence || "•" %>
|
|
116
|
+
</span>
|
|
117
|
+
<% end %>
|
|
118
|
+
|
|
119
|
+
<figcaption data-attachment-part="meta" class="flex min-w-0 flex-1 flex-col">
|
|
120
|
+
<span data-attachment-part="filename" class="truncate font-medium text-gray-900 dark:text-gray-100" title="<%= label %>"><%= label %></span>
|
|
121
|
+
<% if human_size || content_type %>
|
|
122
|
+
<span data-attachment-part="details" class="truncate text-xs text-gray-500 dark:text-gray-400">
|
|
123
|
+
<%= [ human_size, content_type ].compact.join(" · ") %>
|
|
124
|
+
</span>
|
|
125
|
+
<% end %>
|
|
126
|
+
</figcaption>
|
|
127
|
+
|
|
128
|
+
<% if downloadable %>
|
|
129
|
+
<%= link_to url, download_options do %>
|
|
130
|
+
<%= download_label %>
|
|
131
|
+
<% end %>
|
|
132
|
+
<% end %>
|
|
133
|
+
|
|
134
|
+
<% if removable %>
|
|
135
|
+
<%= button_to remove_path, remove_options do %>
|
|
136
|
+
<%= remove_label %>
|
|
137
|
+
<% end %>
|
|
138
|
+
<% end %>
|
|
139
|
+
<% end %>
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
<%# EXTRACTION CANDIDATE → maquina_components. See MaquinaStream::VENDORED_COMPONENTS %>
|
|
2
|
+
<%# locals: (lang: nil, source: "", highlighted: nil, controls: {}, copy_label: nil, copy_aria_label: nil, download_label: nil, download_aria_label: nil, source_attributes: {}, copy_attributes: {}, download_attributes: {}, variant: :default, size: :md, css_classes: "", **html_options) %>
|
|
3
|
+
<%#
|
|
4
|
+
A code block: a highlighted rendering plus the raw source, verbatim, for
|
|
5
|
+
whatever offers to copy or download it.
|
|
6
|
+
|
|
7
|
+
This partial does not highlight. `highlighted` arrives already marked up by
|
|
8
|
+
the caller and is nil while a fence is still open, in which case the plain
|
|
9
|
+
source renders instead — highlighting an open fence is wasted work on every
|
|
10
|
+
frame.
|
|
11
|
+
|
|
12
|
+
Nothing here knows about maquina_stream. The engine's DOM contract
|
|
13
|
+
(`data-ms-code`, `data-ms-code-lang`, `data-ms-code-source`, the `ms-code`
|
|
14
|
+
controller and the `data-ms-control` stream guard — all fixed in
|
|
15
|
+
docs/javascript.md) arrives as `data:` and as the three `*_attributes`
|
|
16
|
+
locals, from MaquinaStream::Components::Contract. Labels arrive the same way:
|
|
17
|
+
a control with no label is not rendered, because a caller that has not named
|
|
18
|
+
a button has not asked for one.
|
|
19
|
+
%>
|
|
20
|
+
<%
|
|
21
|
+
lang = local_assigns.fetch(:lang, nil)
|
|
22
|
+
source = source.to_s
|
|
23
|
+
highlighted = local_assigns.fetch(:highlighted, nil)
|
|
24
|
+
controls = local_assigns.fetch(:controls, nil) || {}
|
|
25
|
+
variant ||= :default
|
|
26
|
+
size ||= :md
|
|
27
|
+
|
|
28
|
+
copyable = controls[:copy] && copy_label.present?
|
|
29
|
+
downloadable = controls[:download] && download_label.present?
|
|
30
|
+
|
|
31
|
+
attributes = component_html_options(
|
|
32
|
+
html_options,
|
|
33
|
+
own_data: {
|
|
34
|
+
component: "code-block",
|
|
35
|
+
variant: variant,
|
|
36
|
+
size: size
|
|
37
|
+
},
|
|
38
|
+
css_classes: css_classes,
|
|
39
|
+
base_classes: "ms-code-block relative my-4 overflow-hidden rounded-lg border border-gray-200 bg-gray-50 text-sm dark:border-gray-800 dark:bg-gray-900"
|
|
40
|
+
)
|
|
41
|
+
%>
|
|
42
|
+
<%= tag.div(**attributes) do %>
|
|
43
|
+
<% if lang.present? || controls.present? %>
|
|
44
|
+
<div data-code-block-part="header" class="flex items-center justify-between border-b border-gray-200 px-3 py-1.5 text-xs text-gray-500 dark:border-gray-800 dark:text-gray-400">
|
|
45
|
+
<span data-code-block-part="lang"><%= lang %></span>
|
|
46
|
+
|
|
47
|
+
<% if controls.present? %>
|
|
48
|
+
<span data-code-block-part="controls" class="flex gap-1">
|
|
49
|
+
<% if copyable %>
|
|
50
|
+
<button type="button"
|
|
51
|
+
data-code-block-part="copy"
|
|
52
|
+
aria-label="<%= copy_aria_label.presence || copy_label %>"
|
|
53
|
+
class="rounded px-2 py-0.5 hover:bg-gray-200 dark:hover:bg-gray-800"
|
|
54
|
+
<%= tag.attributes(copy_attributes) %>>
|
|
55
|
+
<%= copy_label %>
|
|
56
|
+
</button>
|
|
57
|
+
<% end %>
|
|
58
|
+
<% if downloadable %>
|
|
59
|
+
<button type="button"
|
|
60
|
+
data-code-block-part="download"
|
|
61
|
+
aria-label="<%= download_aria_label.presence || download_label %>"
|
|
62
|
+
class="rounded px-2 py-0.5 hover:bg-gray-200 dark:hover:bg-gray-800"
|
|
63
|
+
<%= tag.attributes(download_attributes) %>>
|
|
64
|
+
<%= download_label %>
|
|
65
|
+
</button>
|
|
66
|
+
<% end %>
|
|
67
|
+
</span>
|
|
68
|
+
<% end %>
|
|
69
|
+
</div>
|
|
70
|
+
<% end %>
|
|
71
|
+
|
|
72
|
+
<pre data-code-block-part="pre" class="overflow-x-auto p-3"><code data-code-block-part="code" class="font-mono"><%= highlighted.present? ? highlighted : source %></code></pre>
|
|
73
|
+
<pre hidden <%= tag.attributes(source_attributes) %>><%= source %></pre>
|
|
74
|
+
<% end %>
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
<%# Engine-owned, permanent. NOT an extraction candidate. %>
|
|
2
|
+
<%# locals: (lines: 3, variant: :default, size: :md, label: nil, css_classes: "", **html_options) %>
|
|
3
|
+
<%#
|
|
4
|
+
The one skeleton in the engine: open blocks and deferred payloads that have
|
|
5
|
+
not rendered yet. No ad-hoc placeholder markup exists anywhere else; if a
|
|
6
|
+
new state needs a skeleton, it renders this.
|
|
7
|
+
%>
|
|
8
|
+
<%
|
|
9
|
+
lines = Integer(local_assigns.fetch(:lines, 3))
|
|
10
|
+
variant ||= :default
|
|
11
|
+
size ||= :md
|
|
12
|
+
label = local_assigns.fetch(:label, nil).presence ||
|
|
13
|
+
t("maquina_stream.shimmer.label", default: "Cargando…")
|
|
14
|
+
|
|
15
|
+
attributes = component_html_options(
|
|
16
|
+
html_options,
|
|
17
|
+
own_data: {
|
|
18
|
+
component: "shimmer",
|
|
19
|
+
variant: variant,
|
|
20
|
+
size: size
|
|
21
|
+
},
|
|
22
|
+
css_classes: css_classes,
|
|
23
|
+
base_classes: "ms-shimmer my-3 flex w-full flex-col gap-2"
|
|
24
|
+
)
|
|
25
|
+
attributes[:role] ||= "status"
|
|
26
|
+
attributes["aria-busy"] ||= "true"
|
|
27
|
+
attributes["aria-live"] ||= "polite"
|
|
28
|
+
%>
|
|
29
|
+
<%= tag.div(**attributes) do %>
|
|
30
|
+
<span class="sr-only"><%= label %></span>
|
|
31
|
+
<% lines.times do |line| %>
|
|
32
|
+
<span data-shimmer-part="line"
|
|
33
|
+
aria-hidden="true"
|
|
34
|
+
class="ms-shimmer-line block h-3 rounded bg-gray-200 dark:bg-gray-800 <%= line == lines - 1 ? "w-2/3" : "w-full" %>"></span>
|
|
35
|
+
<% end %>
|
|
36
|
+
<% end %>
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
<%# EXTRACTION CANDIDATE → maquina_components. See MaquinaStream::VENDORED_COMPONENTS %>
|
|
2
|
+
<%# locals: (command: "", label: nil, prompt: "$", copy_label: nil, copy_aria_label: nil, source_attributes: {}, copy_attributes: {}, variant: :default, size: :md, css_classes: "", **html_options) %>
|
|
3
|
+
<%#
|
|
4
|
+
A single command with a copy affordance, and the same raw-source carrier the
|
|
5
|
+
code block uses, so copying is exact even when the visible line is decorated
|
|
6
|
+
with a prompt.
|
|
7
|
+
|
|
8
|
+
Which controller reads that carrier, and what the copy button says, are the
|
|
9
|
+
caller's business: they arrive as `data:`, `source_attributes`,
|
|
10
|
+
`copy_attributes` and `copy_label`. In this engine that caller is
|
|
11
|
+
MaquinaStream::Components::Contract, which wires `ms-code`.
|
|
12
|
+
%>
|
|
13
|
+
<%
|
|
14
|
+
command = command.to_s
|
|
15
|
+
label = local_assigns.fetch(:label, nil)
|
|
16
|
+
prompt = local_assigns.fetch(:prompt, "$")
|
|
17
|
+
variant ||= :default
|
|
18
|
+
size ||= :md
|
|
19
|
+
|
|
20
|
+
attributes = component_html_options(
|
|
21
|
+
html_options,
|
|
22
|
+
own_data: {
|
|
23
|
+
component: "snippet",
|
|
24
|
+
variant: variant,
|
|
25
|
+
size: size
|
|
26
|
+
},
|
|
27
|
+
css_classes: css_classes,
|
|
28
|
+
base_classes: "ms-snippet my-3 flex items-center gap-2 rounded-md border border-gray-200 bg-gray-50 px-3 py-2 font-mono text-sm dark:border-gray-800 dark:bg-gray-900"
|
|
29
|
+
)
|
|
30
|
+
%>
|
|
31
|
+
<%= tag.div(**attributes) do %>
|
|
32
|
+
<% if label.present? %>
|
|
33
|
+
<span data-snippet-part="label" class="font-sans text-xs text-gray-500 dark:text-gray-400"><%= label %></span>
|
|
34
|
+
<% end %>
|
|
35
|
+
|
|
36
|
+
<span data-snippet-part="prompt" aria-hidden="true" class="select-none text-gray-400"><%= prompt %></span>
|
|
37
|
+
<code data-snippet-part="command" class="flex-1 overflow-x-auto whitespace-pre"><%= command %></code>
|
|
38
|
+
|
|
39
|
+
<% if copy_label.present? %>
|
|
40
|
+
<button type="button"
|
|
41
|
+
data-snippet-part="copy"
|
|
42
|
+
aria-label="<%= copy_aria_label.presence || copy_label %>"
|
|
43
|
+
class="rounded px-2 py-1 text-xs text-gray-500 hover:bg-gray-200 dark:text-gray-400 dark:hover:bg-gray-800"
|
|
44
|
+
<%= tag.attributes(copy_attributes) %>>
|
|
45
|
+
<%= copy_label %>
|
|
46
|
+
</button>
|
|
47
|
+
<% end %>
|
|
48
|
+
|
|
49
|
+
<pre hidden <%= tag.attributes(source_attributes) %>><%= command %></pre>
|
|
50
|
+
<% end %>
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
<%# Engine-owned, permanent. NOT an extraction candidate. %>
|
|
2
|
+
<%# locals: (id: nil, title: nil, href: nil, index: nil, variant: :default, size: :md, css_classes: "", **html_options) %>
|
|
3
|
+
<%#
|
|
4
|
+
The reference implementation of `register_tag :source`. The model emits
|
|
5
|
+
<source id="123">Título</source>; the Nokogiri post-pass swaps that node for
|
|
6
|
+
this partial with the tag's attributes as locals.
|
|
7
|
+
|
|
8
|
+
`href` is whatever the host's finder resolved — the engine never dereferences
|
|
9
|
+
a model-supplied id itself, and an unresolved citation still renders, as
|
|
10
|
+
text, rather than vanishing.
|
|
11
|
+
%>
|
|
12
|
+
<%
|
|
13
|
+
id = local_assigns.fetch(:id, nil).presence
|
|
14
|
+
title = local_assigns.fetch(:title, nil).presence
|
|
15
|
+
href = local_assigns.fetch(:href, nil).presence
|
|
16
|
+
index = local_assigns.fetch(:index, nil)
|
|
17
|
+
variant ||= :default
|
|
18
|
+
size ||= :md
|
|
19
|
+
label = title || id || t("maquina_stream.source_citation.unknown", default: "Fuente")
|
|
20
|
+
|
|
21
|
+
attributes = component_html_options(
|
|
22
|
+
html_options,
|
|
23
|
+
own_data: {
|
|
24
|
+
component: "source-citation",
|
|
25
|
+
variant: variant,
|
|
26
|
+
size: size,
|
|
27
|
+
ms_source_id: id
|
|
28
|
+
},
|
|
29
|
+
css_classes: css_classes,
|
|
30
|
+
base_classes: "ms-source-citation inline-flex items-baseline gap-1 rounded border border-gray-200 px-1.5 text-xs text-gray-600 dark:border-gray-800 dark:text-gray-300"
|
|
31
|
+
)
|
|
32
|
+
%>
|
|
33
|
+
<%= tag.span(**attributes) do %>
|
|
34
|
+
<% if index.present? %>
|
|
35
|
+
<span data-source-citation-part="index" class="font-mono text-gray-400"><%= index %></span>
|
|
36
|
+
<% end %>
|
|
37
|
+
<% if href %>
|
|
38
|
+
<%= link_to label, href, data: { source_citation_part: "link" }, class: "underline underline-offset-2" %>
|
|
39
|
+
<% else %>
|
|
40
|
+
<span data-source-citation-part="label"><%= label %></span>
|
|
41
|
+
<% end %>
|
|
42
|
+
<% end %>
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
<%# EXTRACTION CANDIDATE → maquina_components. See MaquinaStream::VENDORED_COMPONENTS %>
|
|
2
|
+
<%# locals: (items: [], label: nil, controls: {}, variant: :default, size: :md, css_classes: "", **html_options) %>
|
|
3
|
+
<%#
|
|
4
|
+
A row of suggestion chips above a prompt input. Server-rendered, no client
|
|
5
|
+
state, no Stimulus controller: a chip is either a link the host supplied or a
|
|
6
|
+
form submission the host supplied, and the page it lands on decides what
|
|
7
|
+
happens next.
|
|
8
|
+
|
|
9
|
+
`items` is an array of hashes shaped by the host, not by the AI SDK:
|
|
10
|
+
|
|
11
|
+
{ text: "Resume el hilo", href: prompts_path } → link
|
|
12
|
+
{ text: "Resume el hilo", href: prompts_path, method: :post,
|
|
13
|
+
params: { prompt: "Resume el hilo" } } → form
|
|
14
|
+
|
|
15
|
+
`text` is the chip's visible label; a hash may carry `:label` when the
|
|
16
|
+
accessible name should differ from it. Anything without `text` is skipped
|
|
17
|
+
rather than rendering an empty chip.
|
|
18
|
+
|
|
19
|
+
The whole row is gated on `controls[:enabled]`, so a caller that does not
|
|
20
|
+
want suggestions turns them off once rather than in every template. In this
|
|
21
|
+
engine the caller is MaquinaStream::Components::Contract, which reads
|
|
22
|
+
`config.controls[:suggestion]` — configuration is the engine's, not this
|
|
23
|
+
component's.
|
|
24
|
+
|
|
25
|
+
`label` names the row for assistive technology, and is a local for the same
|
|
26
|
+
reason: a generic component owns no locale namespace.
|
|
27
|
+
%>
|
|
28
|
+
<%
|
|
29
|
+
items = Array(local_assigns.fetch(:items, nil)).map { |item| item.to_h.symbolize_keys }
|
|
30
|
+
items = items.select { |item| item[:text].to_s.present? }
|
|
31
|
+
label = local_assigns.fetch(:label, nil).presence
|
|
32
|
+
controls = local_assigns.fetch(:controls, nil) || {}
|
|
33
|
+
variant ||= :default
|
|
34
|
+
size ||= :md
|
|
35
|
+
|
|
36
|
+
chip_classes = "ms-suggestion-chip inline-flex max-w-full items-center rounded-full border border-gray-200 bg-white px-3 py-1 text-sm text-gray-700 hover:bg-gray-50 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-800"
|
|
37
|
+
|
|
38
|
+
attributes = component_html_options(
|
|
39
|
+
html_options,
|
|
40
|
+
own_data: {
|
|
41
|
+
component: "suggestion",
|
|
42
|
+
variant: variant,
|
|
43
|
+
size: size
|
|
44
|
+
},
|
|
45
|
+
css_classes: css_classes,
|
|
46
|
+
base_classes: "ms-suggestion flex flex-wrap gap-2"
|
|
47
|
+
)
|
|
48
|
+
attributes[:role] ||= "list"
|
|
49
|
+
attributes["aria-label"] ||= label
|
|
50
|
+
%>
|
|
51
|
+
<% if controls.fetch(:enabled, true) && items.any? %>
|
|
52
|
+
<%= tag.div(**attributes) do %>
|
|
53
|
+
<% items.each do |item| %>
|
|
54
|
+
<%= tag.div(role: "listitem", data: { suggestion_part: "item" }, class: "contents") do %>
|
|
55
|
+
<% if item[:method].present? && item[:method].to_sym != :get %>
|
|
56
|
+
<%= button_to item[:text],
|
|
57
|
+
item[:href],
|
|
58
|
+
method: item[:method],
|
|
59
|
+
params: item[:params],
|
|
60
|
+
data: { suggestion_part: "chip" },
|
|
61
|
+
"aria-label": item[:label].presence || item[:text],
|
|
62
|
+
class: chip_classes %>
|
|
63
|
+
<% else %>
|
|
64
|
+
<%= link_to item[:text],
|
|
65
|
+
item[:href],
|
|
66
|
+
data: { suggestion_part: "chip" },
|
|
67
|
+
"aria-label": item[:label].presence || item[:text],
|
|
68
|
+
class: chip_classes %>
|
|
69
|
+
<% end %>
|
|
70
|
+
<% end %>
|
|
71
|
+
<% end %>
|
|
72
|
+
<% end %>
|
|
73
|
+
<% end %>
|
data/config/importmap.rb
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Appended to the host's importmap by `MaquinaStream::Engine`.
|
|
4
|
+
#
|
|
5
|
+
# The engine pins only its own source. `@hotwired/stimulus` is the host's pin —
|
|
6
|
+
# an engine that pinned it would win or lose a version fight with the app for
|
|
7
|
+
# no reason. NoBuild: nothing here is compiled, bundled or fetched from npm.
|
|
8
|
+
pin "maquina_stream", to: "maquina_stream/index.js"
|
|
9
|
+
pin_all_from MaquinaStream::Engine.root.join("app/javascript/maquina_stream/controllers"),
|
|
10
|
+
under: "maquina_stream/controllers"
|