turbo-rails 0.5.9 → 0.5.10
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 +4 -4
- data/Rakefile +4 -0
- data/app/assets/javascripts/turbo.js +878 -722
- data/app/channels/turbo/streams/broadcasts.rb +16 -0
- data/app/helpers/turbo/frames_helper.rb +4 -0
- data/app/helpers/turbo/streams_helper.rb +5 -3
- data/app/javascript/turbo/cable.js +6 -3
- data/app/jobs/turbo/streams/action_broadcast_job.rb +2 -0
- data/app/jobs/turbo/streams/broadcast_job.rb +2 -0
- data/app/models/concerns/turbo/broadcastable.rb +39 -7
- data/app/models/turbo/streams/tag_builder.rb +26 -0
- data/lib/install/turbo_with_asset_pipeline.rb +1 -6
- data/lib/install/turbo_with_webpacker.rb +5 -4
- data/lib/turbo/engine.rb +5 -1
- data/lib/turbo/test_assertions.rb +2 -3
- data/lib/turbo/version.rb +1 -1
- metadata +7 -7
@@ -13,6 +13,14 @@ module Turbo::Streams::Broadcasts
|
|
13
13
|
broadcast_action_to *streamables, action: :replace, target: target, **rendering
|
14
14
|
end
|
15
15
|
|
16
|
+
def broadcast_before_to(*streamables, target:, **rendering)
|
17
|
+
broadcast_action_to *streamables, action: :before, target: target, **rendering
|
18
|
+
end
|
19
|
+
|
20
|
+
def broadcast_after_to(*streamables, target:, **rendering)
|
21
|
+
broadcast_action_to *streamables, action: :after, target: target, **rendering
|
22
|
+
end
|
23
|
+
|
16
24
|
def broadcast_append_to(*streamables, target:, **rendering)
|
17
25
|
broadcast_action_to *streamables, action: :append, target: target, **rendering
|
18
26
|
end
|
@@ -32,6 +40,14 @@ module Turbo::Streams::Broadcasts
|
|
32
40
|
broadcast_action_later_to *streamables, action: :replace, target: target, **rendering
|
33
41
|
end
|
34
42
|
|
43
|
+
def broadcast_before_later_to(*streamables, target:, **rendering)
|
44
|
+
broadcast_action_later_to *streamables, action: :before, target: target, **rendering
|
45
|
+
end
|
46
|
+
|
47
|
+
def broadcast_after_later_to(*streamables, target:, **rendering)
|
48
|
+
broadcast_action_later_to *streamables, action: :after, target: target, **rendering
|
49
|
+
end
|
50
|
+
|
35
51
|
def broadcast_append_later_to(*streamables, target:, **rendering)
|
36
52
|
broadcast_action_later_to *streamables, action: :append, target: target, **rendering
|
37
53
|
end
|
@@ -16,12 +16,16 @@ module Turbo::FramesHelper
|
|
16
16
|
# <%= turbo_frame_tag "tray", target: "other_tray" %>
|
17
17
|
# # => <turbo-frame id="tray" target="other_tray"></turbo-frame>
|
18
18
|
#
|
19
|
+
# <%= turbo_frame_tag "tray", src: tray_path(tray), loading: "lazy" %>
|
20
|
+
# # => <turbo-frame id="tray" src="http://example.com/trays/1" loading="lazy"></turbo-frame>
|
21
|
+
#
|
19
22
|
# <%= turbo_frame_tag "tray" do %>
|
20
23
|
# <div>My tray frame!</div>
|
21
24
|
# <% end %>
|
22
25
|
# # => <turbo-frame id="tray"><div>My tray frame!</div></turbo-frame>
|
23
26
|
def turbo_frame_tag(id, src: nil, target: nil, **attributes, &block)
|
24
27
|
id = id.respond_to?(:to_key) ? dom_id(id) : id
|
28
|
+
src = url_for(src) if src.present?
|
25
29
|
|
26
30
|
tag.turbo_frame(**attributes.merge(id: id, src: src, target: target).compact, &block)
|
27
31
|
end
|
@@ -1,5 +1,5 @@
|
|
1
1
|
module Turbo::StreamsHelper
|
2
|
-
# Returns a new <tt>Turbo::Streams::TagBuilder</tt> object that accepts stream actions and renders them
|
2
|
+
# Returns a new <tt>Turbo::Streams::TagBuilder</tt> object that accepts stream actions and renders them as
|
3
3
|
# the template tags needed to send across the wire. This object is automatically yielded to turbo_stream.erb templates.
|
4
4
|
#
|
5
5
|
# When responding to HTTP requests, controllers can declare `turbo_stream` format response templates in that same
|
@@ -39,7 +39,9 @@ module Turbo::StreamsHelper
|
|
39
39
|
# The example above will process all turbo streams sent to a stream name like <tt>account:5:entries</tt>
|
40
40
|
# (when Current.account.id = 5). Updates to this stream can be sent like
|
41
41
|
# <tt>entry.broadcast_append_to entry.account, :entries, target: "entries"</tt>.
|
42
|
-
def turbo_stream_from(*streamables)
|
43
|
-
|
42
|
+
def turbo_stream_from(*streamables, **attributes)
|
43
|
+
attributes[:channel] = "Turbo::StreamsChannel"
|
44
|
+
attributes[:"signed-stream-name"] = Turbo::StreamsChannel.signed_stream_name(streamables)
|
45
|
+
tag.turbo_cable_stream_source(**attributes)
|
44
46
|
end
|
45
47
|
end
|
@@ -1,15 +1,18 @@
|
|
1
1
|
let consumer
|
2
2
|
|
3
3
|
export async function getConsumer() {
|
4
|
-
|
5
|
-
const { createConsumer } = await import("@rails/actioncable/src")
|
6
|
-
return setConsumer(createConsumer())
|
4
|
+
return consumer || setConsumer(createConsumer().then(setConsumer))
|
7
5
|
}
|
8
6
|
|
9
7
|
export function setConsumer(newConsumer) {
|
10
8
|
return consumer = newConsumer
|
11
9
|
}
|
12
10
|
|
11
|
+
export async function createConsumer() {
|
12
|
+
const { createConsumer } = await import(/* webpackChunkName: "actioncable" */ "@rails/actioncable/src")
|
13
|
+
return createConsumer()
|
14
|
+
}
|
15
|
+
|
13
16
|
export async function subscribeTo(channel, mixin) {
|
14
17
|
const { subscriptions } = await getConsumer()
|
15
18
|
return subscriptions.create(channel, mixin)
|
@@ -1,5 +1,7 @@
|
|
1
1
|
# The job that powers all the <tt>broadcast_$action_later</tt> broadcasts available in <tt>Turbo::Streams::Broadcasts</tt>.
|
2
2
|
class Turbo::Streams::ActionBroadcastJob < ActiveJob::Base
|
3
|
+
discard_on ActiveJob::DeserializationError
|
4
|
+
|
3
5
|
def perform(stream, action:, target:, **rendering)
|
4
6
|
Turbo::StreamsChannel.broadcast_action_to stream, action: action, target: target, **rendering
|
5
7
|
end
|
@@ -1,6 +1,8 @@
|
|
1
1
|
# The job that powers the <tt>broadcast_render_later_to</tt> available in <tt>Turbo::Streams::Broadcasts</tt> for rendering
|
2
2
|
# turbo stream templates.
|
3
3
|
class Turbo::Streams::BroadcastJob < ActiveJob::Base
|
4
|
+
discard_on ActiveJob::DeserializationError
|
5
|
+
|
4
6
|
def perform(stream, **rendering)
|
5
7
|
Turbo::StreamsChannel.broadcast_render_to stream, **rendering
|
6
8
|
end
|
@@ -39,7 +39,7 @@ module Turbo::Broadcastable
|
|
39
39
|
module ClassMethods
|
40
40
|
# Configures the model to broadcast creates, updates, and destroys to a stream name derived at runtime by the
|
41
41
|
# <tt>stream</tt> symbol invocation. By default, the creates are appended to a dom id target name derived from
|
42
|
-
# the model's plural name. The insertion can also be made to be a prepend by overwriting <tt>
|
42
|
+
# the model's plural name. The insertion can also be made to be a prepend by overwriting <tt>inserts_by</tt> and
|
43
43
|
# the target dom id overwritten by passing <tt>target</tt>. Examples:
|
44
44
|
#
|
45
45
|
# class Message < ApplicationRecord
|
@@ -52,14 +52,14 @@ module Turbo::Broadcastable
|
|
52
52
|
# broadcasts_to ->(message) { [ message.board, :messages ] }, inserts_by: :prepend, target: "board_messages"
|
53
53
|
# end
|
54
54
|
def broadcasts_to(stream, inserts_by: :append, target: broadcast_target_default)
|
55
|
-
after_create_commit -> { broadcast_action_later_to stream.try(:call, self) || send(stream), action: inserts_by, target: target }
|
55
|
+
after_create_commit -> { broadcast_action_later_to stream.try(:call, self) || send(stream), action: inserts_by, target: target.try(:call, self) || target }
|
56
56
|
after_update_commit -> { broadcast_replace_later_to stream.try(:call, self) || send(stream) }
|
57
57
|
after_destroy_commit -> { broadcast_remove_to stream.try(:call, self) || send(stream) }
|
58
58
|
end
|
59
59
|
|
60
60
|
# Same as <tt>#broadcasts_to</tt>, but the designated stream is automatically set to the current model.
|
61
61
|
def broadcasts(inserts_by: :append, target: broadcast_target_default)
|
62
|
-
after_create_commit -> { broadcast_action_later action: inserts_by, target: target }
|
62
|
+
after_create_commit -> { broadcast_action_later action: inserts_by, target: target.try(:call, self) || target }
|
63
63
|
after_update_commit -> { broadcast_replace_later }
|
64
64
|
after_destroy_commit -> { broadcast_remove }
|
65
65
|
end
|
@@ -103,6 +103,38 @@ module Turbo::Broadcastable
|
|
103
103
|
broadcast_replace_to self, **rendering
|
104
104
|
end
|
105
105
|
|
106
|
+
# Insert a rendering of this broadcastable model before the target identified by it's dom id passed as <tt>target</tt>
|
107
|
+
# for subscribers of the stream name identified by the passed <tt>streamables</tt>. The rendering parameters can be set by
|
108
|
+
# appending named arguments to the call. Examples:
|
109
|
+
#
|
110
|
+
# # Sends <turbo-stream action="before" target="clearance_5"><template><div id="clearance_4">My Clearance</div></template></turbo-stream>
|
111
|
+
# # to the stream named "identity:2:clearances"
|
112
|
+
# clearance.broadcast_before_to examiner.identity, :clearances, target: "clearance_5"
|
113
|
+
#
|
114
|
+
# # Sends <turbo-stream action="before" target="clearance_5"><template><div id="clearance_4">Other partial</div></template></turbo-stream>
|
115
|
+
# # to the stream named "identity:2:clearances"
|
116
|
+
# clearance.broadcast_before_to examiner.identity, :clearances, target: "clearance_5",
|
117
|
+
# partial: "clearances/other_partial", locals: { a: 1 }
|
118
|
+
def broadcast_before_to(*streamables, target:, **rendering)
|
119
|
+
Turbo::StreamsChannel.broadcast_before_to *streamables, target: target, **broadcast_rendering_with_defaults(rendering)
|
120
|
+
end
|
121
|
+
|
122
|
+
# Insert a rendering of this broadcastable model after the target identified by it's dom id passed as <tt>target</tt>
|
123
|
+
# for subscribers of the stream name identified by the passed <tt>streamables</tt>. The rendering parameters can be set by
|
124
|
+
# appending named arguments to the call. Examples:
|
125
|
+
#
|
126
|
+
# # Sends <turbo-stream action="after" target="clearance_5"><template><div id="clearance_6">My Clearance</div></template></turbo-stream>
|
127
|
+
# # to the stream named "identity:2:clearances"
|
128
|
+
# clearance.broadcast_after_to examiner.identity, :clearances, target: "clearance_5"
|
129
|
+
#
|
130
|
+
# # Sends <turbo-stream action="after" target="clearance_5"><template><div id="clearance_6">Other partial</div></template></turbo-stream>
|
131
|
+
# # to the stream named "identity:2:clearances"
|
132
|
+
# clearance.broadcast_after_to examiner.identity, :clearances, target: "clearance_5",
|
133
|
+
# partial: "clearances/other_partial", locals: { a: 1 }
|
134
|
+
def broadcast_after_to(*streamables, target:, **rendering)
|
135
|
+
Turbo::StreamsChannel.broadcast_after_to *streamables, target: target, **broadcast_rendering_with_defaults(rendering)
|
136
|
+
end
|
137
|
+
|
106
138
|
# Append a rendering of this broadcastable model to the target identified by it's dom id passed as <tt>target</tt>
|
107
139
|
# for subscribers of the stream name identified by the passed <tt>streamables</tt>. The rendering parameters can be set by
|
108
140
|
# appending named arguments to the call. Examples:
|
@@ -140,7 +172,7 @@ module Turbo::Broadcastable
|
|
140
172
|
Turbo::StreamsChannel.broadcast_prepend_to *streamables, target: target, **broadcast_rendering_with_defaults(rendering)
|
141
173
|
end
|
142
174
|
|
143
|
-
# Same as <tt>#
|
175
|
+
# Same as <tt>#broadcast_prepend_to</tt>, but the designated stream is automatically set to the current model.
|
144
176
|
def broadcast_prepend(target: broadcast_target_default, **rendering)
|
145
177
|
broadcast_prepend_to self, target: target, **rendering
|
146
178
|
end
|
@@ -190,7 +222,7 @@ module Turbo::Broadcastable
|
|
190
222
|
broadcast_prepend_later_to self, target: target, **rendering
|
191
223
|
end
|
192
224
|
|
193
|
-
# Same as <tt>
|
225
|
+
# Same as <tt>broadcast_action_to</tt> but run asynchronously via a <tt>Turbo::Streams::BroadcastJob</tt>.
|
194
226
|
def broadcast_action_later_to(*streamables, action:, target: broadcast_target_default, **rendering)
|
195
227
|
Turbo::StreamsChannel.broadcast_action_later_to(*streamables, action: action, target: target, **broadcast_rendering_with_defaults(rendering))
|
196
228
|
end
|
@@ -219,7 +251,7 @@ module Turbo::Broadcastable
|
|
219
251
|
broadcast_render_later_to self, **rendering
|
220
252
|
end
|
221
253
|
|
222
|
-
# Same as <tt>
|
254
|
+
# Same as <tt>broadcast_render_later</tt> but run with the added option of naming the stream using the passed
|
223
255
|
# <tt>streamables</tt>.
|
224
256
|
def broadcast_render_later_to(*streamables, **rendering)
|
225
257
|
Turbo::StreamsChannel.broadcast_render_later_to *streamables, **broadcast_rendering_with_defaults(rendering)
|
@@ -233,7 +265,7 @@ module Turbo::Broadcastable
|
|
233
265
|
|
234
266
|
def broadcast_rendering_with_defaults(options)
|
235
267
|
options.tap do |o|
|
236
|
-
o[:
|
268
|
+
o[:object] ||= self
|
237
269
|
o[:partial] ||= to_partial_path
|
238
270
|
end
|
239
271
|
end
|
@@ -53,6 +53,32 @@ class Turbo::Streams::TagBuilder
|
|
53
53
|
action :replace, target, content, **rendering, &block
|
54
54
|
end
|
55
55
|
|
56
|
+
# Insert the <tt>content</tt> passed in, a rendering result determined by the <tt>rendering</tt> keyword arguments,
|
57
|
+
# the content in the block, or the rendering of the target as a record before the <tt>target</tt> in the dom. Examples:
|
58
|
+
#
|
59
|
+
# <%= turbo_stream.before "clearance_5", "<div id='clearance_4'>Insert before the dom target identified by clearance_5</div>" %>
|
60
|
+
# <%= turbo_stream.before clearance %>
|
61
|
+
# <%= turbo_stream.before clearance, partial: "clearances/clearance", locals: { title: "Hello" } %>
|
62
|
+
# <%= turbo_stream.before "clearance_5" do %>
|
63
|
+
# <div id='clearance_4'>Insert before the dom target identified by clearance_5</div>
|
64
|
+
# <% end %>
|
65
|
+
def before(target, content = nil, **rendering, &block)
|
66
|
+
action :before, target, content, **rendering, &block
|
67
|
+
end
|
68
|
+
|
69
|
+
# Insert the <tt>content</tt> passed in, a rendering result determined by the <tt>rendering</tt> keyword arguments,
|
70
|
+
# the content in the block, or the rendering of the target as a record after the <tt>target</tt> in the dom. Examples:
|
71
|
+
#
|
72
|
+
# <%= turbo_stream.after "clearance_5", "<div id='clearance_6'>Insert after the dom target identified by clearance_5</div>" %>
|
73
|
+
# <%= turbo_stream.after clearance %>
|
74
|
+
# <%= turbo_stream.after clearance, partial: "clearances/clearance", locals: { title: "Hello" } %>
|
75
|
+
# <%= turbo_stream.after "clearance_5" do %>
|
76
|
+
# <div id='clearance_6'>Insert after the dom target identified by clearance_5</div>
|
77
|
+
# <% end %>
|
78
|
+
def after(target, content = nil, **rendering, &block)
|
79
|
+
action :after, target, content, **rendering, &block
|
80
|
+
end
|
81
|
+
|
56
82
|
# Update the <tt>target</tt> in the dom with the either the <tt>content</tt> passed in or a rendering result determined
|
57
83
|
# by the <tt>rendering</tt> keyword arguments, the content in the block, or the rendering of the target as a record. Examples:
|
58
84
|
#
|
@@ -19,12 +19,7 @@ if APPLICATION_LAYOUT_PATH.exist?
|
|
19
19
|
end
|
20
20
|
else
|
21
21
|
say "Default application.html.erb is missing!", :red
|
22
|
-
|
23
|
-
if APPLICATION_LAYOUT_PATH.read =~ /stimulus/
|
24
|
-
say %( Add <%= javascript_include_tag("turbo", type: "module-shim") %> and <%= yield :head %> within the <head> tag after Stimulus includes in your custom layout.)
|
25
|
-
else
|
26
|
-
say %( Add <%= javascript_include_tag("turbo", type: "module") %> and <%= yield :head %> within the <head> tag in your custom layout.)
|
27
|
-
end
|
22
|
+
say %( Add <%= javascript_include_tag("turbo", type: "module-shim") %> and <%= yield :head %> within the <head> tag after Stimulus includes in your custom layout.)
|
28
23
|
end
|
29
24
|
|
30
25
|
say "Enable redis in bundle"
|
@@ -1,16 +1,17 @@
|
|
1
1
|
# Some Rails versions use commonJS(require) others use ESM(import).
|
2
2
|
TURBOLINKS_REGEX = /(import .* from "turbolinks".*\n|require\("turbolinks"\).*\n)/.freeze
|
3
|
+
ACTIVE_STORAGE_REGEX = /(import.*ActiveStorage|require.*@rails\/activestorage.*)/.freeze
|
3
4
|
|
4
5
|
abort "❌ Webpacker not found. Exiting." unless defined?(Webpacker::Engine)
|
5
6
|
|
6
7
|
say "Install Turbo"
|
7
8
|
run "yarn add @hotwired/turbo-rails"
|
8
|
-
insert_into_file "#{Webpacker.config.source_entry_path}/application.js", "import \"@hotwired/turbo-rails\"\n", before:
|
9
|
+
insert_into_file "#{Webpacker.config.source_entry_path}/application.js", "import \"@hotwired/turbo-rails\"\n", before: ACTIVE_STORAGE_REGEX
|
9
10
|
|
10
11
|
say "Remove Turbolinks"
|
11
|
-
|
12
|
-
run "bin/bundle", capture: true
|
13
|
-
run "bin/yarn remove turbolinks"
|
12
|
+
run "#{RbConfig.ruby} bin/bundle remove turbolinks"
|
13
|
+
run "#{RbConfig.ruby} bin/bundle", capture: true
|
14
|
+
run "#{RbConfig.ruby} bin/yarn remove turbolinks"
|
14
15
|
gsub_file "#{Webpacker.config.source_entry_path}/application.js", TURBOLINKS_REGEX, ''
|
15
16
|
gsub_file "#{Webpacker.config.source_entry_path}/application.js", /Turbolinks.start.*\n/, ''
|
16
17
|
|
data/lib/turbo/engine.rb
CHANGED
@@ -16,13 +16,17 @@ module Turbo
|
|
16
16
|
#{root}/app/jobs
|
17
17
|
)
|
18
18
|
|
19
|
+
initializer "turbo.no_action_cable" do
|
20
|
+
Rails.autoloaders.once.do_not_eager_load(Dir["#{root}/app/channels/turbo/*_channel.rb"]) unless defined?(ActionCable)
|
21
|
+
end
|
22
|
+
|
19
23
|
initializer "turbo.assets" do
|
20
24
|
if Rails.application.config.respond_to?(:assets)
|
21
25
|
Rails.application.config.assets.precompile += %w( turbo )
|
22
26
|
end
|
23
27
|
end
|
24
28
|
|
25
|
-
initializer "turbo.helpers" do
|
29
|
+
initializer "turbo.helpers", before: :load_config_initializers do
|
26
30
|
ActiveSupport.on_load(:action_controller_base) do
|
27
31
|
include Turbo::Streams::TurboStreamsTagBuilder, Turbo::Frames::FrameRequest, Turbo::Native::Navigation
|
28
32
|
helper Turbo::Engine.helpers
|
@@ -7,14 +7,13 @@ module Turbo
|
|
7
7
|
delegate :dom_id, :dom_class, to: ActionView::RecordIdentifier
|
8
8
|
end
|
9
9
|
|
10
|
-
def assert_turbo_stream(action:, target: nil, &block)
|
11
|
-
assert_response
|
10
|
+
def assert_turbo_stream(action:, target: nil, status: :ok, &block)
|
11
|
+
assert_response status
|
12
12
|
assert_equal Mime[:turbo_stream], response.media_type
|
13
13
|
assert_select %(turbo-stream[action="#{action}"][target="#{target.respond_to?(:to_key) ? dom_id(target) : target}"]), count: 1, &block
|
14
14
|
end
|
15
15
|
|
16
16
|
def assert_no_turbo_stream(action:, target: nil)
|
17
|
-
assert_response :ok
|
18
17
|
assert_equal Mime[:turbo_stream], response.media_type
|
19
18
|
assert_select %(turbo-stream[action="#{action}"][target="#{target.respond_to?(:to_key) ? dom_id(target) : target}"]), count: 0
|
20
19
|
end
|
data/lib/turbo/version.rb
CHANGED
metadata
CHANGED
@@ -1,16 +1,16 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: turbo-rails
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.5.
|
4
|
+
version: 0.5.10
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Sam Stephenson
|
8
8
|
- Javan Mahkmali
|
9
9
|
- David Heinemeier Hansson
|
10
|
-
autorequire:
|
10
|
+
autorequire:
|
11
11
|
bindir: bin
|
12
12
|
cert_chain: []
|
13
|
-
date: 2021-
|
13
|
+
date: 2021-06-12 00:00:00.000000000 Z
|
14
14
|
dependencies:
|
15
15
|
- !ruby/object:Gem::Dependency
|
16
16
|
name: rails
|
@@ -26,7 +26,7 @@ dependencies:
|
|
26
26
|
- - ">="
|
27
27
|
- !ruby/object:Gem::Version
|
28
28
|
version: 6.0.0
|
29
|
-
description:
|
29
|
+
description:
|
30
30
|
email: david@loudthinking.com
|
31
31
|
executables: []
|
32
32
|
extensions: []
|
@@ -67,7 +67,7 @@ homepage: https://github.com/hotwired/turbo-rails
|
|
67
67
|
licenses:
|
68
68
|
- MIT
|
69
69
|
metadata: {}
|
70
|
-
post_install_message:
|
70
|
+
post_install_message:
|
71
71
|
rdoc_options: []
|
72
72
|
require_paths:
|
73
73
|
- lib
|
@@ -82,8 +82,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
82
82
|
- !ruby/object:Gem::Version
|
83
83
|
version: '0'
|
84
84
|
requirements: []
|
85
|
-
rubygems_version: 3.1.
|
86
|
-
signing_key:
|
85
|
+
rubygems_version: 3.1.4
|
86
|
+
signing_key:
|
87
87
|
specification_version: 4
|
88
88
|
summary: The speed of a single-page web application without having to write any JavaScript.
|
89
89
|
test_files: []
|