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,184 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MaquinaStream
4
+ module Components
5
+ # The engine's half of a vendored component.
6
+ #
7
+ # A vendored partial is a `maquina_components` component that happens to
8
+ # live here for now: it knows about variants, parts and `css_classes`, and
9
+ # about nothing else. Everything that belongs to THIS engine — the
10
+ # `data-ms-*` DOM contract from docs/javascript.md, the `ms-*` Stimulus
11
+ # identifiers, and the `maquina_stream.*` labels — is supplied from the call
12
+ # site, and this is the call site.
13
+ #
14
+ # ```ruby
15
+ # Contract.apply(:code_block, {lang: "ruby", source: raw}, config: config)
16
+ # # => {lang: "ruby", source: raw,
17
+ # # data: {ms_code: "", ms_code_lang: "ruby"},
18
+ # # source_attributes: {"data-ms-code-source" => ""},
19
+ # # copy_label: "Copiar", …}
20
+ # ```
21
+ #
22
+ # Two callers, and only two: `ComponentsHelper#component` and the fence
23
+ # renderer in Renderer::PostPass, which renders the same partial from
24
+ # outside a request. **Components render through the seam, never directly**
25
+ # — a host that reaches for a vendored partial by path gets markup with none
26
+ # of this on it. Extraction deletes nothing here: this file is what the
27
+ # engine keeps when the partials leave.
28
+ #
29
+ # Caller locals win over ours, so a host can name a label itself. The
30
+ # exception is `data`, which is merged rather than replaced: the DOM
31
+ # contract is not the host's to drop, and `controller` concatenates so a
32
+ # host's own controller rides along with ours.
33
+ #
34
+ # Four components have engine locals — `:code_block`, `:snippet`,
35
+ # `:attachment` and `:suggestion`. Any other name passes its locals through
36
+ # untouched.
37
+ module Contract
38
+ class << self
39
+ # Returns `locals` with the engine's own locals folded in.
40
+ #
41
+ # `name` is the component name, `locals` the Hash a call site passed.
42
+ # Unknown names return `locals` unchanged, so this is safe to call on
43
+ # every component render.
44
+ def apply(name, locals, config: MaquinaStream.config)
45
+ engine = engine_locals(name.to_sym, locals, config)
46
+ return locals if engine.empty?
47
+
48
+ data = merge_data(engine.delete(:data) || {}, locals[:data] || locals["data"])
49
+ merged = engine.merge(locals)
50
+ merged.delete("data")
51
+ merged[:data] = data unless data.empty?
52
+ merged
53
+ end
54
+
55
+ # Merges the engine's data attributes with a caller's, and returns the
56
+ # result.
57
+ #
58
+ # Same rule as ComponentsHelper#component_data, applied one level
59
+ # earlier: ours wins its own keys, `controller` and `action`
60
+ # concatenate with ours first. The helper travels to
61
+ # `maquina_components` with the partials; this stays, so the rule is
62
+ # written out here rather than borrowed.
63
+ def merge_data(own, provided)
64
+ own = own.compact
65
+ provided = (provided || {}).transform_keys { |key| key.to_s.tr("-", "_").to_sym }
66
+
67
+ provided.merge(own) do |key, theirs, ours|
68
+ %i[controller action].include?(key) ? [ours, theirs].compact.join(" ").strip : ours
69
+ end.compact
70
+ end
71
+
72
+ private
73
+
74
+ def engine_locals(name, locals, config)
75
+ case name
76
+ when :code_block then code_block(locals)
77
+ when :snippet then snippet
78
+ when :attachment then attachment(locals, config)
79
+ when :suggestion then suggestion(config)
80
+ else {}
81
+ end
82
+ end
83
+
84
+ # `ms-code` reads the `<pre hidden data-ms-code-source>` carrier and the
85
+ # `data-ms-code-lang` attribute; `data-ms-control` is what the stream
86
+ # guard disables while a message is still being written.
87
+ #
88
+ # Controls are NOT defaulted from configuration here. The fence renderer
89
+ # decides them — an open fence gets none — and a host asking for a bare
90
+ # code block gets a bare code block.
91
+ def code_block(locals)
92
+ controls = locals[:controls] || {}
93
+
94
+ {
95
+ data: {
96
+ ms_code: "",
97
+ ms_code_lang: presence(locals[:lang]),
98
+ controller: ("ms-code" if controls.present?)
99
+ },
100
+ source_attributes: {"data-ms-code-source" => ""},
101
+ copy_attributes: {"data-ms-control" => "", "data-action" => "ms-code#copy"},
102
+ download_attributes: {"data-ms-control" => "", "data-action" => "ms-code#download"},
103
+ copy_label: translate("code.copy", "Copy"),
104
+ copy_aria_label: translate("code.copy_code", "Copy the code"),
105
+ download_label: translate("code.download", "Download"),
106
+ download_aria_label: translate("code.download_code", "Download the code")
107
+ }
108
+ end
109
+
110
+ def snippet
111
+ {
112
+ data: {controller: "ms-code"},
113
+ source_attributes: {"data-ms-code-source" => ""},
114
+ copy_attributes: {"data-action" => "ms-code#copy"},
115
+ copy_label: translate("snippet.copy", "Copy"),
116
+ copy_aria_label: translate("snippet.copy_command", "Copy the command")
117
+ }
118
+ end
119
+
120
+ # A `translate` default is only ever reached when a host has neither
121
+ # locale loaded, so it is the language of last resort and is English —
122
+ # es.yml and en.yml are where the real strings live, and Spanish being
123
+ # the engine's DEFAULT LOCALE is a matter of which file I18n reads, not
124
+ # of which language is compiled into the source.
125
+
126
+ # The host renders attachments, so the engine's control switches are
127
+ # read here rather than in the partial: a generic component does not
128
+ # know what MaquinaStream.config is.
129
+ def attachment(locals, config)
130
+ label = presence(locals[:filename].to_s) || translate("attachment.unnamed", "Attachment")
131
+ thumbnail = locals[:content_type].to_s.start_with?("image/") && presence(locals[:preview_url])
132
+
133
+ {
134
+ controls: config.controls[:attachment],
135
+ unnamed_label: translate("attachment.unnamed", "Attachment"),
136
+ download_label: translate("attachment.download", "Download"),
137
+ download_aria_label: translate("attachment.download_file", "Download %{name}", name: label),
138
+ remove_label: translate("attachment.remove", "Remove"),
139
+ remove_aria_label: translate("attachment.remove_file", "Remove %{name}", name: label),
140
+ remove_confirm: translate("attachment.remove_confirm", "Remove this attachment?"),
141
+ size_units: size_units,
142
+ size_format: translate("attachment.size", "%{value} %{unit}"),
143
+ decimal_separator: translate("number.decimal_separator", "."),
144
+ data: {
145
+ controller: ("ms-attachment" if thumbnail),
146
+ ms_attachment_content_type: presence(locals[:content_type])
147
+ },
148
+ thumbnail_attributes: thumbnail ? thumbnail_attributes : {},
149
+ download_attributes: {"data-ms-attachment-target" => "download"}
150
+ }
151
+ end
152
+
153
+ def thumbnail_attributes
154
+ {
155
+ "data-ms-attachment-target" => "thumbnail",
156
+ "data-action" => "error->ms-attachment#thumbnailFailed"
157
+ }
158
+ end
159
+
160
+ def suggestion(config)
161
+ {
162
+ controls: config.controls[:suggestion],
163
+ label: translate("suggestion.list_label", "Suggestions")
164
+ }
165
+ end
166
+
167
+ # The unit names are labels like any other: ActiveSupport ships English
168
+ # only, so the default locale would otherwise have nothing to name them
169
+ # with.
170
+ def size_units
171
+ %w[bytes kb mb gb tb].map { |unit| translate("attachment.units.#{unit}", unit) }
172
+ end
173
+
174
+ def translate(key, fallback, **interpolations)
175
+ I18n.t("maquina_stream.#{key}", default: fallback, **interpolations)
176
+ end
177
+
178
+ def presence(value)
179
+ value.to_s.empty? ? nil : value
180
+ end
181
+ end
182
+ end
183
+ end
184
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MaquinaStream
4
+ # The component seam.
5
+ #
6
+ # Every component the engine renders resolves through here, so extracting a
7
+ # vendored component into `maquina_components` is mechanical: publish the
8
+ # partial in that gem, drop the name from VENDORED_COMPONENTS, done. No call
9
+ # site changes.
10
+ #
11
+ # ```ruby
12
+ # MaquinaStream::Components.partial_for(:code_block, config: MaquinaStream.config)
13
+ # # => "maquina_components/code_block" when the gem defines it
14
+ # # => "maquina_stream/components/code_block" otherwise
15
+ # ```
16
+ #
17
+ # `maquina_components` is an optional dependency. Without it every component
18
+ # renders its vendored plain-Tailwind fallback, and `config.components =
19
+ # :plain` forces that even when the gem is installed.
20
+ module Components
21
+ # Where a component lives once it has been extracted.
22
+ DESTINATION_PREFIX = "maquina_components"
23
+
24
+ # Where the vendored copy lives while it is still ours.
25
+ VENDORED_PREFIX = "maquina_stream/components"
26
+
27
+ # Engine-owned components. Permanent; they never resolve to the gem, even
28
+ # when a partial of the same name shows up there.
29
+ ENGINE_OWNED = %i[shimmer source_citation].freeze
30
+
31
+ # Probes an installed `maquina_components` for the destination partial.
32
+ # A plain object rather than a stub: tests inject their own.
33
+ class Library
34
+ # Returns nil when the gem is absent, which is the whole point of the
35
+ # optional dependency: absence is a value, not an error.
36
+ def self.detect
37
+ return nil unless defined?(::MaquinaComponents::Engine)
38
+
39
+ new(::MaquinaComponents::Engine.root.join("app", "views"))
40
+ end
41
+
42
+ def initialize(view_root)
43
+ @view_root = Pathname(view_root)
44
+ end
45
+
46
+ # True when the gem itself ships the destination partial. We probe the
47
+ # exact path we would render, never a path we merely hope exists.
48
+ def defines?(name)
49
+ defined_names.include?(name.to_sym)
50
+ end
51
+
52
+ private
53
+
54
+ def defined_names
55
+ @defined_names ||= @view_root.glob("#{DESTINATION_PREFIX}/_*.html.erb")
56
+ .map { |path| path.basename(".html.erb").to_s.delete_prefix("_").to_sym }
57
+ .to_set
58
+ end
59
+ end
60
+
61
+ class << self
62
+ # The partial path to render for `name`.
63
+ #
64
+ # `library` is a seam: pass one in to test either side of the branch
65
+ # without touching the load path.
66
+ def partial_for(name, config: MaquinaStream.config, library: default_library)
67
+ name = name.to_sym
68
+
69
+ return vendored_partial(name) if engine_owned?(name)
70
+ return vendored_partial(name) unless config.components == :maquina
71
+ return vendored_partial(name) unless library&.defines?(name)
72
+
73
+ "#{DESTINATION_PREFIX}/#{name}"
74
+ end
75
+
76
+ # True for a component that is vendored here and destined for the gem.
77
+ def vendored?(name)
78
+ MaquinaStream::VENDORED_COMPONENTS.include?(name.to_sym)
79
+ end
80
+
81
+ # True for a component that is permanently the engine's and never
82
+ # resolves to the gem, even if a partial of the same name shows up
83
+ # there.
84
+ def engine_owned?(name)
85
+ ENGINE_OWNED.include?(name.to_sym)
86
+ end
87
+
88
+ # True for any component this engine knows how to render, vendored or
89
+ # engine-owned.
90
+ def known?(name)
91
+ vendored?(name) || engine_owned?(name)
92
+ end
93
+
94
+ # True when `name` renders from our own app/views right now.
95
+ def fallback_active?(name, config: MaquinaStream.config, library: default_library)
96
+ partial_for(name, config: config, library: library).start_with?(VENDORED_PREFIX)
97
+ end
98
+
99
+ # Asset paths for the components currently rendering from our app/views.
100
+ # One stylesheet per component, and none for a component the gem is
101
+ # serving — otherwise the day it ships we emit its selectors twice.
102
+ def stylesheets(config: MaquinaStream.config, library: default_library)
103
+ styled_components
104
+ .select { |name| fallback_active?(name, config: config, library: library) }
105
+ .map { |name| "#{VENDORED_PREFIX}/#{name}" }
106
+ end
107
+
108
+ # The components we actually ship a stylesheet for — a component with no
109
+ # fallback markup yet has no fallback CSS to load either.
110
+ def styled_components
111
+ @styled_components ||= Pathname(__dir__).join("../../app/assets/stylesheets", VENDORED_PREFIX)
112
+ .glob("*.css")
113
+ .map { |path| path.basename(".css").to_s.to_sym }
114
+ .sort
115
+ end
116
+
117
+ def default_library
118
+ return @default_library if defined?(@default_library) && !@default_library.nil?
119
+
120
+ @default_library = Library.detect
121
+ end
122
+
123
+ # Forgets the detected library. Only useful in tests.
124
+ def reset_library!
125
+ @default_library = nil
126
+ end
127
+
128
+ private
129
+
130
+ def vendored_partial(name)
131
+ "#{VENDORED_PREFIX}/#{name}"
132
+ end
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,240 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MaquinaStream
4
+ # Every key documented in docs/configuration.md, with its documented default.
5
+ #
6
+ # Three keys are seams the engine cannot supply itself and that
7
+ # docs/configuration.md names but that have no default the engine could pick:
8
+ # `find_stream`, `authorize` and `transport`. See docs/repair.md.
9
+ #
10
+ # Reach it through MaquinaStream.configure, once, from an initializer:
11
+ #
12
+ # ```ruby
13
+ # MaquinaStream.configure do |c|
14
+ # c.find_stream = ->(sid) { Message.find_by(id: sid) }
15
+ # c.authorize = ->(record, request) { record.conversation.readable_by?(request) }
16
+ # end
17
+ # ```
18
+ #
19
+ # ## Host seams
20
+ #
21
+ # | Option | Default | Consequence of changing it |
22
+ # |---|---|---|
23
+ # | `find_stream` | `nil` | The callable that turns a stream id into a record. Unset, every repair request raises ConfigurationError. See #find_stream! |
24
+ # | `authorize` | `nil` | The callable that decides whether a request may see a record. **Unset, every repair request is refused.** See #authorized? |
25
+ # | `transport` | `:turbo_streams` | Which transport Broadcaster's default emitter uses. The seam exists so SSE is possible without the broadcaster knowing about it; `:turbo_streams` is the only value the engine ships. |
26
+ #
27
+ # ## Streaming cadence
28
+ #
29
+ # | Option | Default | Consequence of changing it |
30
+ # |---|---|---|
31
+ # | `frame_budget_ms` | `100` | How long frames coalesce before one goes out. See the table below — this one has a measured cost, and it depends on how the text arrives. |
32
+ # | `seal_lag` | `2` | How many blocks must open after a block before it may freeze. Lower it and markdown's backwards reinterpretation — a paragraph becoming a heading when its underline arrives — freezes a block that was still moving. Raise it and more blocks stay in the patch set of every frame. See Document#sealed_blocks. |
33
+ # | `keyframe_interval_ms` | `4000` | How often the client reconciles its DOM against the manifest. Lower it and drift is corrected sooner at the cost of one small request per interval per open message; the manifest is bounded by `manifest_window`, not by message length. |
34
+ # | `manifest_window` | `50` | How many recent sealed blocks a manifest carries in full; everything older is covered by one rollup digest. This is what keeps the payload bounded by the window instead of by the message. See Manifest. |
35
+ #
36
+ # ### What `frame_budget_ms` costs
37
+ #
38
+ # It was 60, then 250, and is 100. Coalescing only saves bytes when frames
39
+ # arrive faster than the budget, so what it costs depends entirely on how the
40
+ # text arrives. Measured on a 20KB message, against the size of the rendered
41
+ # document:
42
+ #
43
+ # | arrival | 60ms | 100ms | 150ms | 250ms |
44
+ # |---|---|---|---|---|
45
+ # | token, 4 chars / 25ms | 3.03x | 2.34x | 1.65x | 1.08x |
46
+ # | batch, 40 chars / 200ms | 1.07x | 1.07x | 1.07x | 0.89x |
47
+ # | step, 2000 chars / 1s | 1.17x | 1.17x | 1.17x | 1.17x |
48
+ #
49
+ # 250ms is the only column inside the 1.5x budget under token arrival, which
50
+ # is why it was chosen first. Under the batched arrival this engine is built
51
+ # for it buys nothing — and it silently merges two steps into one frame,
52
+ # which costs the per-step feedback that is the point of streaming a step at
53
+ # all. Hence 100ms.
54
+ #
55
+ # **A host whose model emits token by token pays 2.34x at the default and
56
+ # should raise its own `frame_budget_ms`.** `test/broadcaster_test.rb` pins
57
+ # all three rows, so the trade-off fails loudly rather than drifting.
58
+ #
59
+ # ## Presentation
60
+ #
61
+ # | Option | Default | Consequence of changing it |
62
+ # |---|---|---|
63
+ # | `locale` | `:es` | Fallback locale for the engine's own labels when `I18n.locale` is unset. Spanish is the engine's default; English is the secondary translation. |
64
+ # | `components` | `:maquina` | `:maquina` resolves a component to `maquina_components` when that gem is installed and defines it. `:plain` forces the vendored Tailwind fallback even when the gem is present. See MaquinaStream::Components. |
65
+ # | `themes` | `{light: "github.light", dark: "github.dark"}` | Rouge theme names for the two generated highlighting stylesheets. Changing them requires re-running `rake maquina_stream:themes`; an unknown name raises rather than falling back silently. See Themes. |
66
+ # | `controls` | see DEFAULT_CONTROLS | Which interactive affordances render. Assigning a hash merges one level deep; `false` turns everything off. See #controls= and #control?. |
67
+ #
68
+ # Theme names are Rouge's own — the registry has `github.dark` and
69
+ # `github.light`, not `github_dark`.
70
+ #
71
+ # ## URL hardening
72
+ #
73
+ # Read by Sanitizer, which is the last pass before any HTML leaves the
74
+ # server. Every one of these loosens or tightens what a *model* may put in an
75
+ # `href` or a `src`, and model output is prompt-injectable.
76
+ #
77
+ # | Option | Default | Consequence of changing it |
78
+ # |---|---|---|
79
+ # | `default_origin` | `nil` | Base for resolving relative URLs. `nil` leaves a relative URL relative. Set it and a relative URL becomes absolute against that origin, and is re-checked against `allowed_protocols` afterwards. |
80
+ # | `allowed_protocols` | `%w[http https mailto]` | The only schemes that survive. Adding one admits every URL that spells it; the dangerous-scheme list is checked first and independently. |
81
+ # | `allowed_link_prefixes` | `["*"]` | `"*"` allows any destination. Replace it with a list of prefixes and every link not starting with one is stripped of its href — the text stays. |
82
+ # | `allowed_image_prefixes` | `["*"]` | Same, for images. An image whose src does not survive is removed entirely: a broken rectangle carrying an attacker-chosen `alt` is worse than nothing. |
83
+ # | `allow_data_images` | `true` | Whether `data:` image URLs survive. Only base64 rasters ever do; `data:image/svg+xml` is a scriptable document wearing an image's MIME type and is refused whatever this is set to. |
84
+ class Configuration
85
+ # Every interactive control the engine offers, and its documented default.
86
+ #
87
+ # | Group | Controls |
88
+ # |---|---|
89
+ # | `code` | the copy and download buttons on a code block |
90
+ # | `table` | copy, download and fullscreen on a table |
91
+ # | `image` | the download affordance on an inline image |
92
+ # | `attachment` | download and remove on an attachment |
93
+ # | `suggestion` | the suggestion chip row as a whole |
94
+ # | `link_safety` | the confirmation dialog on an outbound link (a flag, not a group) |
95
+ #
96
+ # A group is a hash of individually switchable controls; `link_safety` is a
97
+ # single boolean because it has exactly one. Reads go through `control?`, so
98
+ # both shapes answer the same question.
99
+ #
100
+ # ```ruby
101
+ # config.controls = { code: { copy: false } } # one control off, rest untouched
102
+ # config.controls = false # everything off, wholesale
103
+ # config.controls = true # everything back on
104
+ # ```
105
+ #
106
+ # An assigned hash is merged onto the defaults one level deep, so a host
107
+ # names only what it is changing and `controls` stays complete.
108
+ DEFAULT_CONTROLS = {
109
+ code: {copy: true, download: true},
110
+ table: {copy: true, download: true, fullscreen: true},
111
+ image: {download: true},
112
+ attachment: {download: true, remove: true},
113
+ suggestion: {enabled: true},
114
+ link_safety: true
115
+ }.freeze
116
+
117
+ # A fresh, mutable copy of DEFAULT_CONTROLS. The nested group hashes are
118
+ # duplicated, so a caller cannot mutate the frozen defaults through one.
119
+ def self.default_controls
120
+ DEFAULT_CONTROLS.transform_values { |value| value.is_a?(Hash) ? value.dup : value }
121
+ end
122
+
123
+ # Every control set to `state`. The single expression for "all of them".
124
+ def self.controls_all(state)
125
+ DEFAULT_CONTROLS.transform_values do |value|
126
+ value.is_a?(Hash) ? value.transform_values { state } : state
127
+ end
128
+ end
129
+
130
+ # The resolved control map: every group, every control, always complete.
131
+ # Assign through #controls=; read through #control?.
132
+ attr_reader :controls
133
+
134
+ # Every option, read and written directly. What each one does, its default
135
+ # and the consequence of changing it are in the tables on Configuration
136
+ # itself; they are grouped there rather than repeated once per accessor.
137
+ attr_accessor :frame_budget_ms, :keyframe_interval_ms, :seal_lag, :manifest_window,
138
+ :locale, :components, :themes,
139
+ :default_origin, :allowed_protocols,
140
+ :allowed_link_prefixes, :allowed_image_prefixes,
141
+ :allow_data_images,
142
+ :find_stream, :authorize, :transport
143
+
144
+ # A configuration holding every documented default. MaquinaStream.config
145
+ # builds one lazily; a host rarely constructs one itself, though passing a
146
+ # throwaway as `config:` is how most of this engine is tested.
147
+ def initialize
148
+ @frame_budget_ms = 100
149
+ @keyframe_interval_ms = 4_000
150
+ # How many recent sealed blocks a manifest carries in full. Everything
151
+ # older is covered by one rollup digest, which is what keeps the payload
152
+ # bounded by the window instead of by the message. See Manifest.
153
+ @manifest_window = 50
154
+ @seal_lag = 2
155
+ @locale = :es
156
+ @components = :maquina
157
+ @themes = {light: "github.light", dark: "github.dark"}
158
+
159
+ @default_origin = nil
160
+ @allowed_protocols = %w[http https mailto]
161
+ @allowed_link_prefixes = ["*"]
162
+ @allowed_image_prefixes = ["*"]
163
+ @allow_data_images = true
164
+
165
+ @controls = self.class.default_controls
166
+
167
+ # Host seams. The engine never guesses a record and never assumes it may
168
+ # serve one: with no host callable configured, every request is refused.
169
+ @find_stream = nil
170
+ @authorize = nil
171
+ @transport = :turbo_streams
172
+ end
173
+
174
+ # Sets which controls render.
175
+ #
176
+ # `false`/`nil`/`:none` turns every control off; `true`/`:all` turns every
177
+ # control back on; a hash merges onto the defaults one group at a time, so
178
+ # a host names only what it is changing.
179
+ def controls=(value)
180
+ @controls =
181
+ case value
182
+ when false, nil, :none then self.class.controls_all(false)
183
+ when true, :all then self.class.controls_all(true)
184
+ else
185
+ self.class.default_controls.merge(value.to_h.symbolize_keys) do |_key, default, given|
186
+ (default.is_a?(Hash) && given.is_a?(Hash)) ? default.merge(given.symbolize_keys) : given
187
+ end
188
+ end
189
+ end
190
+
191
+ # Whether one control, or a whole group, is enabled.
192
+ #
193
+ # ```ruby
194
+ # config.control?(:code, :copy) # => true
195
+ # config.control?(:link_safety) # => true
196
+ # config.control?(:code) # => true while any code control remains
197
+ # ```
198
+ #
199
+ # With no control name, answers whether the group has anything left enabled,
200
+ # so a header that exists only to hold controls can be dropped whole.
201
+ def control?(group, name = nil)
202
+ value = controls[group.to_sym]
203
+
204
+ case value
205
+ when Hash then name.nil? ? value.values.any? { |enabled| !!enabled } : !!value[name.to_sym]
206
+ else !!value
207
+ end
208
+ end
209
+
210
+ # Resolves the record a request is about, by calling the configured
211
+ # `find_stream`. Returns whatever that callable returns, `nil` included.
212
+ #
213
+ # Raises ConfigurationError when the host has not configured a finder: the
214
+ # engine has no model to guess at, and a silent `nil` would look like a
215
+ # missing record rather than a missing seam.
216
+ def find_stream!(sid)
217
+ raise ConfigurationError, <<~MESSAGE unless find_stream.respond_to?(:call)
218
+ MaquinaStream has no `find_stream` callable configured, so it cannot
219
+ resolve stream id #{sid.inspect}. Set one:
220
+
221
+ MaquinaStream.configure { |c| c.find_stream = ->(sid) { Message.find_by(id: sid) } }
222
+ MESSAGE
223
+
224
+ find_stream.call(sid)
225
+ end
226
+
227
+ # Whether this request may see this record, per the host's `authorize`
228
+ # callable. The callable receives `(record, request)`; its return value is
229
+ # coerced to a boolean.
230
+ #
231
+ # **Returns false when the host has not configured a callable.** Unlike
232
+ # #find_stream! this does not raise: denial is the safe answer, and the
233
+ # engine never assumes it may serve a message.
234
+ def authorized?(record, request)
235
+ return false unless authorize.respond_to?(:call)
236
+
237
+ !!authorize.call(record, request)
238
+ end
239
+ end
240
+ end