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,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 %>
@@ -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"