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,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MaquinaStream
|
|
4
|
+
# One registered element override: the element name, and the options it was
|
|
5
|
+
# registered with. See Registries#register_element.
|
|
6
|
+
Element = Struct.new(:name, :options, keyword_init: false)
|
|
7
|
+
|
|
8
|
+
# One registered custom tag: the tag name, and the options it was registered
|
|
9
|
+
# with. See Registries#register_tag.
|
|
10
|
+
Tag = Struct.new(:name, :options, keyword_init: false)
|
|
11
|
+
|
|
12
|
+
# One registered fence: the info string it matches, and the options it was
|
|
13
|
+
# registered with. See Registries#register_fence.
|
|
14
|
+
Fence = Struct.new(:info, :options, keyword_init: false)
|
|
15
|
+
|
|
16
|
+
# The three registries, extended into MaquinaStream itself — so every method
|
|
17
|
+
# here is called as `MaquinaStream.register_element`, not on a registry
|
|
18
|
+
# object. Registrations are global and are read on every render; make them
|
|
19
|
+
# once, from an initializer.
|
|
20
|
+
#
|
|
21
|
+
# See docs/registries.md for the long form.
|
|
22
|
+
module Registries
|
|
23
|
+
# Registered element overrides, keyed by element name. Read by the render
|
|
24
|
+
# pipeline; a host rarely reads it directly.
|
|
25
|
+
def elements = @elements ||= {}
|
|
26
|
+
|
|
27
|
+
# Registered custom tags, keyed by tag name.
|
|
28
|
+
def tags = @tags ||= {}
|
|
29
|
+
|
|
30
|
+
# Registered fences, keyed by info string.
|
|
31
|
+
def fences = @fences ||= {}
|
|
32
|
+
|
|
33
|
+
# Renders one markdown element through a partial of the host's instead of
|
|
34
|
+
# the engine's default markup.
|
|
35
|
+
#
|
|
36
|
+
# ```ruby
|
|
37
|
+
# MaquinaStream.register_element :h2, partial: "my/headings/h2"
|
|
38
|
+
# ```
|
|
39
|
+
#
|
|
40
|
+
# `name` is the HTML element the renderer produced (`:h2`, `:blockquote`,
|
|
41
|
+
# `:table`). The partial receives the element's content and renders in
|
|
42
|
+
# place of it. Registering the same name twice replaces the first
|
|
43
|
+
# registration; the last one in wins.
|
|
44
|
+
#
|
|
45
|
+
# The output still goes through Sanitizer, which runs unconditionally and
|
|
46
|
+
# last. A partial cannot introduce an element or attribute the allowlist
|
|
47
|
+
# does not name.
|
|
48
|
+
def register_element(name, **options)
|
|
49
|
+
elements[name.to_sym] = Element.new(name.to_sym, options)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Admits one HTML-ish tag that markdown does not define, and renders it
|
|
53
|
+
# through a partial.
|
|
54
|
+
#
|
|
55
|
+
# ```ruby
|
|
56
|
+
# MaquinaStream.register_tag :source,
|
|
57
|
+
# attributes: %w[id],
|
|
58
|
+
# partial: "my/tags/source",
|
|
59
|
+
# literal_content: false
|
|
60
|
+
# ```
|
|
61
|
+
#
|
|
62
|
+
# | Option | Meaning |
|
|
63
|
+
# |---|---|
|
|
64
|
+
# | `attributes:` | The attribute names the tag may carry. Anything else on it is dropped — the tag is model output, and model output is prompt-injectable. |
|
|
65
|
+
# | `partial:` | The partial that renders it. |
|
|
66
|
+
# | `literal_content:` | `true` keeps the tag's body as text; `false` renders it as markdown. |
|
|
67
|
+
#
|
|
68
|
+
# A tag nobody registered is not markup: it is text, and it is escaped.
|
|
69
|
+
def register_tag(name, **options)
|
|
70
|
+
tags[name.to_sym] = Tag.new(name.to_sym, options)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Decides what happens to a fenced code block with this info string.
|
|
74
|
+
#
|
|
75
|
+
# ```ruby
|
|
76
|
+
# MaquinaStream.register_fence "ruby", strategy: :server
|
|
77
|
+
# MaquinaStream.register_fence "unknown", strategy: :passthrough
|
|
78
|
+
# MaquinaStream.register_fence "mermaid",
|
|
79
|
+
# strategy: :client,
|
|
80
|
+
# controller: "ms-diagram",
|
|
81
|
+
# payload: ->(source, info) { { source: source, info: info } }
|
|
82
|
+
# ```
|
|
83
|
+
#
|
|
84
|
+
# ## The three strategies
|
|
85
|
+
#
|
|
86
|
+
# | Strategy | What it does | When to use it |
|
|
87
|
+
# |---|---|---|
|
|
88
|
+
# | `:server` | Rouge highlights the source once the fence closes, into classes — never inline colour, so a theme switch needs no re-render. The default for every unregistered language. | Anything Rouge has a lexer for. |
|
|
89
|
+
# | `:client` | Nothing is rendered server-side. The block carries a `payload:` as a data attribute and a Stimulus `controller:` draws it in the browser. | Diagrams, math — anything whose renderer is a JavaScript library the server has no business running. |
|
|
90
|
+
# | `:passthrough` | The source is emitted as escaped text and nothing else happens to it. | A language Rouge would mangle, or one whose highlighting is not worth the CPU. |
|
|
91
|
+
#
|
|
92
|
+
# **An open fence is never highlighted and never emits a payload.**
|
|
93
|
+
# Highlighting would be thrown away on the next frame, and a payload would
|
|
94
|
+
# hand the client half a diagram to draw. A `:client` fence renders a
|
|
95
|
+
# skeleton until it closes.
|
|
96
|
+
#
|
|
97
|
+
# `payload:` is a callable receiving `(source, info)` and returning a Hash;
|
|
98
|
+
# it defaults to `{source:, info:}`. It is serialized as JSON into a data
|
|
99
|
+
# attribute, so it must be JSON-representable. **The client sanitizes what
|
|
100
|
+
# its renderer produces** even though the server already sanitized the
|
|
101
|
+
# document — the payload came from the model.
|
|
102
|
+
#
|
|
103
|
+
# See Renderer::Fence and docs/deferred-renderers.md.
|
|
104
|
+
def register_fence(info, **options)
|
|
105
|
+
fences[info.to_s] = Fence.new(info.to_s, options)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Empties all three registries. For tests; a host that calls this loses
|
|
109
|
+
# every registration made in its initializer.
|
|
110
|
+
def reset_registries!
|
|
111
|
+
@elements = {}
|
|
112
|
+
@tags = {}
|
|
113
|
+
@fences = {}
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rouge"
|
|
4
|
+
|
|
5
|
+
module MaquinaStream
|
|
6
|
+
class Renderer
|
|
7
|
+
# One fenced code block, and the decision about what to do with it.
|
|
8
|
+
#
|
|
9
|
+
# The strategy comes from the fence registry; the fence's own state — open or
|
|
10
|
+
# closed — decides how much of that strategy actually runs. An open fence is
|
|
11
|
+
# never highlighted and never emits a client payload: highlighting would be
|
|
12
|
+
# thrown away on the next frame, and a payload would hand the client half a
|
|
13
|
+
# diagram to draw.
|
|
14
|
+
#
|
|
15
|
+
# Which strategy a fence gets is the host's, through
|
|
16
|
+
# MaquinaStream.register_fence. This class is what reads that registration
|
|
17
|
+
# and applies it.
|
|
18
|
+
class Fence
|
|
19
|
+
# What an unregistered language gets: Rouge highlighting once it closes.
|
|
20
|
+
DEFAULT_STRATEGY = :server
|
|
21
|
+
|
|
22
|
+
# The three strategies, documented on Registries#register_fence. A
|
|
23
|
+
# registration naming anything else falls back to DEFAULT_STRATEGY rather
|
|
24
|
+
# than raising — a typo in an initializer should not take a whole
|
|
25
|
+
# message down.
|
|
26
|
+
STRATEGIES = %i[server client passthrough].freeze
|
|
27
|
+
|
|
28
|
+
# The fence's whole info string, `"ruby"` or `"mermaid graph"`.
|
|
29
|
+
attr_reader :info
|
|
30
|
+
|
|
31
|
+
# The fence's body, as written.
|
|
32
|
+
attr_reader :source
|
|
33
|
+
|
|
34
|
+
# Whether the fence is still unterminated.
|
|
35
|
+
attr_reader :open
|
|
36
|
+
|
|
37
|
+
# The Configuration this fence reads.
|
|
38
|
+
attr_reader :config
|
|
39
|
+
|
|
40
|
+
# Builds a fence. The render pipeline does this, once per fenced block
|
|
41
|
+
# per frame.
|
|
42
|
+
def initialize(info:, source:, open:, config:)
|
|
43
|
+
@info = info.to_s
|
|
44
|
+
@source = source
|
|
45
|
+
@open = open
|
|
46
|
+
@config = config
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Whether the fence is still unterminated. An open fence is never
|
|
50
|
+
# highlighted and never emits a payload.
|
|
51
|
+
def open? = @open
|
|
52
|
+
|
|
53
|
+
# Whether the fence has closed, and may therefore be highlighted or
|
|
54
|
+
# emit a payload.
|
|
55
|
+
def closed? = !open?
|
|
56
|
+
|
|
57
|
+
# The first word of the info string — what the registry is keyed on.
|
|
58
|
+
def language
|
|
59
|
+
info.split(/\s+/).first.to_s
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# This language's registration, or nil when nobody registered it.
|
|
63
|
+
def registration
|
|
64
|
+
MaquinaStream.fences[language]
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# The strategy in force for this fence: one of STRATEGIES, and
|
|
68
|
+
# DEFAULT_STRATEGY for anything unregistered or misregistered.
|
|
69
|
+
def strategy
|
|
70
|
+
candidate = registration&.options&.fetch(:strategy, nil) || DEFAULT_STRATEGY
|
|
71
|
+
STRATEGIES.include?(candidate) ? candidate : DEFAULT_STRATEGY
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# The Stimulus identifier a `:client` fence hands its payload to, from
|
|
75
|
+
# the registration's `controller:`. Nil for any other strategy.
|
|
76
|
+
def controller
|
|
77
|
+
registration&.options&.fetch(:controller, nil)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# The Hash a `:client` fence hands its controller, or nil.
|
|
81
|
+
#
|
|
82
|
+
# Built by the registration's `payload:` callable, and `{source:, info:}`
|
|
83
|
+
# when it has none. It is serialized to JSON into a data attribute, so
|
|
84
|
+
# the callable must return something JSON-representable.
|
|
85
|
+
#
|
|
86
|
+
# The payload exists only once the fence has closed. Until then there is
|
|
87
|
+
# nothing here for the client to render, by design.
|
|
88
|
+
def payload
|
|
89
|
+
return nil unless strategy == :client && closed?
|
|
90
|
+
|
|
91
|
+
builder = registration&.options&.fetch(:payload, nil)
|
|
92
|
+
return {source: source, info: info} unless builder.respond_to?(:call)
|
|
93
|
+
|
|
94
|
+
builder.call(source, info)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Highlighted HTML, or nil when this fence should not be highlighted at
|
|
98
|
+
# all: an open fence, a passthrough language, or a language Rouge does not
|
|
99
|
+
# know.
|
|
100
|
+
def highlighted
|
|
101
|
+
return nil unless strategy == :server && closed?
|
|
102
|
+
|
|
103
|
+
lexer = Rouge::Lexer.find(language)
|
|
104
|
+
return nil unless lexer
|
|
105
|
+
|
|
106
|
+
formatter.format(lexer.lex(source))
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
private
|
|
110
|
+
def formatter
|
|
111
|
+
@formatter ||= Rouge::Formatters::HTML.new
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MaquinaStream
|
|
4
|
+
class Renderer
|
|
5
|
+
# One walk over the parsed document. Everything that needs the tree happens
|
|
6
|
+
# here: fence strategies, table wrappers, element hooks, registered custom
|
|
7
|
+
# tags, and the reveal attributes that separate streaming from static.
|
|
8
|
+
#
|
|
9
|
+
# It is one pass on purpose. Each extra traversal is paid on every frame of
|
|
10
|
+
# every message.
|
|
11
|
+
class PostPass
|
|
12
|
+
ELEMENT_HOOKS = %w[h1 h2 h3 h4 h5 h6 p ul ol li table blockquote pre hr img a].freeze
|
|
13
|
+
ELEMENT_HOOK_SET = ELEMENT_HOOKS.to_set.freeze
|
|
14
|
+
|
|
15
|
+
# Elements that hold text of their own, and are therefore the unit the
|
|
16
|
+
# bidi algorithm should decide a direction for. Containers are left out:
|
|
17
|
+
# a <ul> takes its direction from each <li>, not the other way round.
|
|
18
|
+
DIRECTIONAL = %w[p h1 h2 h3 h4 h5 h6 li blockquote td th dt dd figcaption summary].to_set.freeze
|
|
19
|
+
|
|
20
|
+
attr_reader :fragment, :markdown, :mode, :config
|
|
21
|
+
|
|
22
|
+
def initialize(fragment, markdown:, mode:, config:)
|
|
23
|
+
@fragment = fragment
|
|
24
|
+
@markdown = markdown
|
|
25
|
+
@mode = mode
|
|
26
|
+
@config = config
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def call
|
|
30
|
+
collect
|
|
31
|
+
|
|
32
|
+
rewrite_fences
|
|
33
|
+
wrap_tables
|
|
34
|
+
render_registered_tags
|
|
35
|
+
apply_overrides
|
|
36
|
+
annotate_blocks
|
|
37
|
+
fragment
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
attr_reader :tables, :tagged, :overrides
|
|
42
|
+
|
|
43
|
+
def streaming? = mode == :streaming
|
|
44
|
+
|
|
45
|
+
# The single walk this class has always claimed to be.
|
|
46
|
+
#
|
|
47
|
+
# Every CSS query over a 2,000-node document costs about 3.5ms, and this
|
|
48
|
+
# runs on every frame of every message: five queries were most of the
|
|
49
|
+
# frame. Attribute work happens inline because it cannot restructure the
|
|
50
|
+
# tree; anything that replaces a node is collected and applied after the
|
|
51
|
+
# walk, because replacing a node mid-traversal makes the walk skip
|
|
52
|
+
# siblings.
|
|
53
|
+
def collect
|
|
54
|
+
@code_blocks = []
|
|
55
|
+
@tables = []
|
|
56
|
+
@tagged = Hash.new { |hash, key| hash[key] = [] }
|
|
57
|
+
@overrides = []
|
|
58
|
+
registered = MaquinaStream.tags.keys.to_set
|
|
59
|
+
|
|
60
|
+
fragment.traverse do |node|
|
|
61
|
+
next unless node.element?
|
|
62
|
+
|
|
63
|
+
node.remove_attribute("data-sourcepos") if node.attribute("data-sourcepos")
|
|
64
|
+
|
|
65
|
+
name = node.name
|
|
66
|
+
|
|
67
|
+
if ELEMENT_HOOK_SET.include?(name)
|
|
68
|
+
node["data-ms-element"] = name
|
|
69
|
+
@overrides << node if MaquinaStream.elements.key?(name.to_sym)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
annotate_direction(node) if DIRECTIONAL.include?(name)
|
|
73
|
+
|
|
74
|
+
case name
|
|
75
|
+
when "pre" then @code_blocks << node if node.at_css("> code")
|
|
76
|
+
when "table" then @tables << node
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
@tagged[name.to_sym] << node if registered.include?(name.to_sym)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# The open fence, when there is one, is the last one in the document:
|
|
84
|
+
# the buffer can only be cut in one place.
|
|
85
|
+
def open_fence_index
|
|
86
|
+
return @open_fence_index if defined?(@open_fence_index)
|
|
87
|
+
|
|
88
|
+
open = MaquinaRemend.context(markdown).in_code_fence?
|
|
89
|
+
@open_fence_index = open ? code_blocks.length - 1 : nil
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
attr_reader :code_blocks
|
|
93
|
+
|
|
94
|
+
def rewrite_fences
|
|
95
|
+
code_blocks.each_with_index do |pre, index|
|
|
96
|
+
code = pre.at_css("code")
|
|
97
|
+
fence = Fence.new(
|
|
98
|
+
info: language_of(code),
|
|
99
|
+
source: code.text,
|
|
100
|
+
open: index == open_fence_index,
|
|
101
|
+
config: config
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
replacement = render_fence(fence)
|
|
105
|
+
guard_bidi(replacement ? pre.replace(replacement) : pre, fence.source)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Right-to-left is marked; left-to-right is the default and is left
|
|
110
|
+
# unsaid, so a document in Spanish or English pays nothing for this.
|
|
111
|
+
#
|
|
112
|
+
# Nested blocks are handled by the walk itself: a <li> inside an RTL
|
|
113
|
+
# <blockquote> is visited too, and answers for its own text. That is the
|
|
114
|
+
# point of deciding per block — a quotation in Hebrew inside an English
|
|
115
|
+
# answer reads correctly without the host configuring anything.
|
|
116
|
+
def annotate_direction(node)
|
|
117
|
+
node["dir"] = "rtl" if TextDirection.of(node.text) == :rtl
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Code is the one place where the bidi algorithm is a hazard rather than
|
|
121
|
+
# a service. An override character inside a comment reorders how the
|
|
122
|
+
# code READS without changing what it MEANS, which is the whole of a
|
|
123
|
+
# Trojan Source attack — and every character here was written by a model
|
|
124
|
+
# repeating text from somewhere else.
|
|
125
|
+
#
|
|
126
|
+
# The characters are not removed: the copy button hands back what the
|
|
127
|
+
# model actually wrote, and silently altering it would be worse. The
|
|
128
|
+
# block is pinned to one direction instead, so an override cannot escape
|
|
129
|
+
# the element it sits in.
|
|
130
|
+
def guard_bidi(node, source)
|
|
131
|
+
return unless TextDirection.controls?(source)
|
|
132
|
+
|
|
133
|
+
# `replace` answers with a node set; a passthrough fence answers with
|
|
134
|
+
# the one node it left alone. Not `Array()`: Nokogiri nodes are
|
|
135
|
+
# enumerable over their ATTRIBUTES, so it silently yields nothing.
|
|
136
|
+
nodes = node.is_a?(Nokogiri::XML::NodeSet) ? node : [node]
|
|
137
|
+
nodes.each { |inserted| inserted["dir"] = "ltr" if inserted.element? }
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def render_fence(fence)
|
|
141
|
+
case fence.strategy
|
|
142
|
+
when :client then render_client_fence(fence)
|
|
143
|
+
when :passthrough then nil
|
|
144
|
+
else render_server_fence(fence)
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Cached on the locals, which is what the partial is a pure function of.
|
|
149
|
+
# A fence that closed twenty frames ago renders identically on every
|
|
150
|
+
# frame after it, and rendering it again is most of a frame's cost.
|
|
151
|
+
def render_server_fence(fence)
|
|
152
|
+
partial = Components.partial_for(:code_block, config: config)
|
|
153
|
+
|
|
154
|
+
# An open fence gets no controls. They would be inert anyway — copying
|
|
155
|
+
# half a code block is worse than not offering to — and the open block
|
|
156
|
+
# is re-sent on every frame, so its chrome is paid for over and over.
|
|
157
|
+
controls = fence.open? ? {} : config.controls[:code]
|
|
158
|
+
|
|
159
|
+
# The partial is generic — the engine's DOM contract and its labels
|
|
160
|
+
# are added here, at the call site, by Components::Contract. Labels
|
|
161
|
+
# are locale-dependent, so the locale is part of the key.
|
|
162
|
+
ComponentCache.fetch(partial, locale, fence.language, fence.open?, controls, fence.source) do
|
|
163
|
+
view.render(partial, Components::Contract.apply(
|
|
164
|
+
:code_block,
|
|
165
|
+
{
|
|
166
|
+
lang: fence.language,
|
|
167
|
+
source: fence.source,
|
|
168
|
+
highlighted: html_safe(fence.highlighted),
|
|
169
|
+
controls: controls
|
|
170
|
+
},
|
|
171
|
+
config: config
|
|
172
|
+
))
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Rouge emits markup and escapes the source itself, so it is passed to
|
|
177
|
+
# the partial as markup rather than as text. It is not an exception to
|
|
178
|
+
# "never assign model output as raw HTML": the sanitizer still runs over
|
|
179
|
+
# the whole document afterwards, and it is the gate.
|
|
180
|
+
def html_safe(html)
|
|
181
|
+
html.respond_to?(:html_safe) ? html.html_safe : html
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Skeleton until the fence closes, then payload and controller. The
|
|
185
|
+
# skeleton is the shimmer component; there is no ad-hoc placeholder
|
|
186
|
+
# markup anywhere in the engine.
|
|
187
|
+
def render_client_fence(fence)
|
|
188
|
+
return render_shimmer(fence) if fence.open?
|
|
189
|
+
|
|
190
|
+
node = Nokogiri::XML::Node.new("div", fragment.document)
|
|
191
|
+
node["data-controller"] = fence.controller if fence.controller
|
|
192
|
+
node["data-#{fence.controller}-payload-value"] = payload_json(fence) if fence.controller
|
|
193
|
+
|
|
194
|
+
# What the reader sees when the renderer throws. It is rendered here
|
|
195
|
+
# because JavaScript cannot read I18n: a controller that hardcodes the
|
|
196
|
+
# sentence shows one language to every host, whatever locale it asked
|
|
197
|
+
# for. The controller keeps a fallback for a block a host mounted
|
|
198
|
+
# itself, and this is what makes that fallback the exception.
|
|
199
|
+
node["data-ms-deferred-error-label"] =
|
|
200
|
+
I18n.t("maquina_stream.deferred.error", locale: locale, default: "This block could not be rendered")
|
|
201
|
+
|
|
202
|
+
# Split ownership, per the DOM contract: the payload attribute is
|
|
203
|
+
# server state and belongs to morph, the output element is client
|
|
204
|
+
# state and belongs to the controller. The skeleton sits inside the
|
|
205
|
+
# output element so the controller replaces it when it renders.
|
|
206
|
+
#
|
|
207
|
+
# The stable id (ms-<sid>-b<n>-out) needs the message id, which a pure
|
|
208
|
+
# renderer does not have. Phase 3 stamps it along with the block ids,
|
|
209
|
+
# and data-turbo-permanent only takes effect once it is there.
|
|
210
|
+
output = Nokogiri::XML::Node.new("div", fragment.document)
|
|
211
|
+
output["data-#{fence.controller}-target"] = "output" if fence.controller
|
|
212
|
+
output["data-turbo-permanent"] = ""
|
|
213
|
+
output.inner_html = render_shimmer(fence)
|
|
214
|
+
|
|
215
|
+
node.add_child(output)
|
|
216
|
+
node.to_html
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# The one skeleton, resolved through the seam like every other
|
|
220
|
+
# component. Nothing here names a partial path.
|
|
221
|
+
def render_shimmer(fence)
|
|
222
|
+
partial = Components.partial_for(:shimmer, config: config)
|
|
223
|
+
|
|
224
|
+
ComponentCache.fetch(partial, locale, fence.language) do
|
|
225
|
+
view.render(partial, label: fence.language)
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# The HTML5 serializer escapes only &, " and NBSP inside an attribute
|
|
230
|
+
# value, so a fence containing "</div><script>" would sit in the payload
|
|
231
|
+
# with its angle brackets intact. It does not break out of the attribute,
|
|
232
|
+
# but it does mean the raw string is in the document; escaping at the
|
|
233
|
+
# JSON level keeps the payload inert whatever reads it next.
|
|
234
|
+
def payload_json(fence)
|
|
235
|
+
JSON.generate(fence.payload).gsub("<", "\\u003c").gsub(">", "\\u003e")
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def language_of(code)
|
|
239
|
+
code["class"].to_s[/language-(\S+)/, 1].to_s
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def wrap_tables
|
|
243
|
+
controls = config.controls[:table] || {}
|
|
244
|
+
interactive = controls.values.any?
|
|
245
|
+
|
|
246
|
+
tables.each do |table|
|
|
247
|
+
wrapper = Nokogiri::XML::Node.new("div", fragment.document)
|
|
248
|
+
wrapper["data-ms-table"] = ""
|
|
249
|
+
wrapper["data-controller"] = "ms-table" if interactive
|
|
250
|
+
table.replace(wrapper)
|
|
251
|
+
wrapper.add_child(table_controls(controls)) if interactive
|
|
252
|
+
wrapper.add_child(table)
|
|
253
|
+
table["data-ms-table-target"] = "table" if interactive
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# The ms-table controller documents the markup it expects and emits none
|
|
258
|
+
# of it itself. This is engine chrome rather than a component: there is
|
|
259
|
+
# no vendored table component, and inventing one to hold
|
|
260
|
+
# three buttons would be worse than drawing them here.
|
|
261
|
+
def table_controls(controls)
|
|
262
|
+
bar = Nokogiri::XML::Node.new("div", fragment.document)
|
|
263
|
+
bar["data-ms-table-part"] = "controls"
|
|
264
|
+
|
|
265
|
+
if controls[:copy]
|
|
266
|
+
table_button(bar, "copy", "markdown", :copy_markdown)
|
|
267
|
+
table_button(bar, "copy", "csv", :copy_csv)
|
|
268
|
+
end
|
|
269
|
+
table_button(bar, "download", "csv", :download_csv) if controls[:download]
|
|
270
|
+
table_button(bar, "toggleFullscreen", nil, :fullscreen) if controls[:fullscreen]
|
|
271
|
+
|
|
272
|
+
bar
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def table_button(bar, action, format, key)
|
|
276
|
+
node = Nokogiri::XML::Node.new("button", fragment.document)
|
|
277
|
+
node["type"] = "button"
|
|
278
|
+
# Marks it for the stream guard, which disables every control while
|
|
279
|
+
# the message is still being written.
|
|
280
|
+
node["data-ms-control"] = ""
|
|
281
|
+
node["data-action"] = "ms-table##{action}"
|
|
282
|
+
node["data-ms-table-format-param"] = format if format
|
|
283
|
+
label = I18n.t("maquina_stream.table.#{key}", locale: locale, default: key.to_s.tr("_", " "))
|
|
284
|
+
node["aria-label"] = label
|
|
285
|
+
node.content = label
|
|
286
|
+
bar.add_child(node)
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# The host owns the locale, as in any Rails app: labels follow
|
|
290
|
+
# I18n.locale. `config.locale` is the engine's own default, used when
|
|
291
|
+
# the host has expressed no preference — see docs/javascript.md.
|
|
292
|
+
def locale
|
|
293
|
+
I18n.locale || config.locale
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
# A registered tag is rendered through its partial with only the
|
|
297
|
+
# attributes its registration allows. An unregistered tag is left for the
|
|
298
|
+
# sanitizer, which drops it.
|
|
299
|
+
def render_registered_tags
|
|
300
|
+
MaquinaStream.tags.each do |name, tag|
|
|
301
|
+
tagged[name].each do |node|
|
|
302
|
+
allowed = Array(tag.options[:attributes]).to_h { |key| [key.to_sym, node[key]] }
|
|
303
|
+
content = tag.options[:literal_content] ? node.text : node.inner_html
|
|
304
|
+
|
|
305
|
+
node.replace(view.render(tag.options[:partial], **allowed, content: content))
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# One traversal, not one CSS query per element type. Eighteen queries
|
|
311
|
+
# over a 2,000-node document cost 31ms of a 68ms frame; walking it once
|
|
312
|
+
# costs a fraction of that, and this runs on every frame of every
|
|
313
|
+
# message.
|
|
314
|
+
#
|
|
315
|
+
# Overrides are collected first and applied afterwards: replacing a node
|
|
316
|
+
# while traversing the tree it is being read from is how a walk starts
|
|
317
|
+
# skipping siblings.
|
|
318
|
+
def apply_overrides
|
|
319
|
+
overrides.each { |node| apply_element_override(node.name, node) }
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def apply_element_override(name, node)
|
|
323
|
+
registration = MaquinaStream.elements[name.to_sym]
|
|
324
|
+
return unless registration&.options&.key?(:partial)
|
|
325
|
+
|
|
326
|
+
node.replace(view.render(registration.options[:partial], content: node.inner_html, node: node))
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
# Top-level children are blocks. Numbering them here - rather than
|
|
330
|
+
# letting Phase 3 match by position - means the splitter survives the
|
|
331
|
+
# sanitizer dropping an element, which position matching would not.
|
|
332
|
+
# The index is derived from order, never from content.
|
|
333
|
+
def annotate_blocks
|
|
334
|
+
fragment.children.select(&:element?).each_with_index do |node, index|
|
|
335
|
+
node["data-ms-block-index"] = index.to_s
|
|
336
|
+
end
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
# There is deliberately no per-block chrome here any more.
|
|
340
|
+
#
|
|
341
|
+
# The caret and the reveal marker used to be stamped on each block, and
|
|
342
|
+
# it made a block's bytes disagree with its digest: the digest covers
|
|
343
|
+
# what a block SAYS, so repair could not correct chrome. Two tabs that
|
|
344
|
+
# lost different frames ended up visibly different — one with a caret,
|
|
345
|
+
# one without — and nothing could reconcile them, because their digests
|
|
346
|
+
# agreed.
|
|
347
|
+
#
|
|
348
|
+
# Chrome is derived instead, from the message element the host renders:
|
|
349
|
+
#
|
|
350
|
+
# ```css
|
|
351
|
+
# [data-ms-streaming] > [data-ms-block]:last-child { /* caret */ }
|
|
352
|
+
# [data-ms-streaming] > [data-ms-block] { /* reveal */ }
|
|
353
|
+
# ```
|
|
354
|
+
#
|
|
355
|
+
# A block is then exactly its content, identical content is identical
|
|
356
|
+
# bytes, and streaming and static output are the same document.
|
|
357
|
+
|
|
358
|
+
def view
|
|
359
|
+
@view ||= ViewContext.build
|
|
360
|
+
end
|
|
361
|
+
end
|
|
362
|
+
end
|
|
363
|
+
end
|