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