proscenium 0.25.3 → 0.26.1
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/README.md +170 -23
- data/exe/proscenium +19 -0
- data/exe/psm +5 -0
- data/lib/proscenium/app_package.rb +90 -0
- data/lib/proscenium/builder.rb +221 -20
- data/lib/proscenium/bundled_gems.rb +103 -0
- data/lib/proscenium/cli/brackets.rb +66 -0
- data/lib/proscenium/cli/bun_linker.rb +79 -0
- data/lib/proscenium/cli/capabilities.json +29 -0
- data/lib/proscenium/cli/collisions.rb +94 -0
- data/lib/proscenium/cli/contexts.rb +133 -0
- data/lib/proscenium/cli/diff.rb +80 -0
- data/lib/proscenium/cli/error.rb +291 -0
- data/lib/proscenium/cli/gem_archive.rb +44 -0
- data/lib/proscenium/cli/gem_check.rb +150 -0
- data/lib/proscenium/cli/gitignore.rb +63 -0
- data/lib/proscenium/cli/inspect.rb +257 -0
- data/lib/proscenium/cli/install.rb +378 -0
- data/lib/proscenium/cli/manager.rb +323 -0
- data/lib/proscenium/cli/platform_variants.rb +82 -0
- data/lib/proscenium/cli/pnpm_approvals.rb +109 -0
- data/lib/proscenium/cli/pnpm_workspace.rb +149 -0
- data/lib/proscenium/cli/project_lock.rb +106 -0
- data/lib/proscenium/cli/registration.rb +140 -0
- data/lib/proscenium/cli/reporter.rb +103 -0
- data/lib/proscenium/cli/rules.rb +169 -0
- data/lib/proscenium/cli/runner.rb +142 -0
- data/lib/proscenium/cli/verify.rb +107 -0
- data/lib/proscenium/cli.rb +122 -0
- data/lib/proscenium/context_map.rb +181 -0
- data/lib/proscenium/css_module/transformer.rb +18 -3
- data/lib/proscenium/css_module.rb +5 -6
- data/lib/proscenium/dependency_context.rb +74 -0
- data/lib/proscenium/error.rb +10 -0
- data/lib/proscenium/helper.rb +38 -4
- data/lib/proscenium/importer.rb +53 -42
- data/lib/proscenium/manifest.rb +18 -23
- data/lib/proscenium/mapping_generation.rb +64 -0
- data/lib/proscenium/middleware/base.rb +5 -10
- data/lib/proscenium/middleware/chunks.rb +5 -0
- data/lib/proscenium/monkey.rb +51 -69
- data/lib/proscenium/railtie.rb +16 -0
- data/lib/proscenium/railties/assets.rake +2 -1
- data/lib/proscenium/resolver.rb +43 -11
- data/lib/proscenium/runtime/bootstrap.js +26 -6
- data/lib/proscenium/runtime/server.rb +57 -36
- data/lib/proscenium/side_load.rb +75 -18
- data/lib/proscenium/stale_contexts.rb +118 -0
- data/lib/proscenium/templates/rescues/build_error.html.erb +9 -7
- data/lib/proscenium/utils.rb +55 -0
- data/lib/proscenium/version.rb +1 -1
- data/lib/proscenium/yaml_tree.rb +48 -0
- data/lib/proscenium.rb +7 -4
- metadata +42 -13
- data/lib/proscenium/ext/proscenium +0 -0
- data/lib/proscenium/ext/proscenium.h +0 -113
- data/lib/proscenium/react-manager/index.jsx +0 -102
- data/lib/proscenium/react-manager/react.js +0 -2
- data/lib/proscenium/react_componentable.rb +0 -94
data/lib/proscenium/importer.rb
CHANGED
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'active_support/current_attributes'
|
|
4
|
+
require 'concurrent/map'
|
|
4
5
|
|
|
5
6
|
module Proscenium
|
|
6
7
|
class Importer < ActiveSupport::CurrentAttributes
|
|
7
8
|
JS_EXTENSIONS = %w[.tsx .ts .jsx .js].freeze
|
|
8
9
|
CSS_EXTENSIONS = %w[.module.css .css].freeze
|
|
9
10
|
|
|
11
|
+
# The readable suffix of a CSS module class name, by absolute path - or by URL, for a remote
|
|
12
|
+
# module, which has no file. It is a pure function of that identity, but `import` runs once per
|
|
13
|
+
# class name a view emits and `relative_path_from` is most of its cost, so it is built once per
|
|
14
|
+
# file for the life of the process.
|
|
15
|
+
SUFFIXES = Concurrent::Map.new
|
|
16
|
+
|
|
10
17
|
# Holds the JS and CSS files to include in the current request.
|
|
11
18
|
#
|
|
12
19
|
# Example:
|
|
@@ -21,59 +28,45 @@ module Proscenium
|
|
|
21
28
|
class << self
|
|
22
29
|
# Import the given `filepath`. This is idempotent - it will never include duplicates.
|
|
23
30
|
#
|
|
24
|
-
# @param filepath [String] Absolute URL path (relative to Rails root) of the file to import
|
|
25
|
-
# Should be the actual asset file, eg. app.css,
|
|
31
|
+
# @param filepath [String] Absolute URL path (relative to Rails root) of the file to import,
|
|
32
|
+
# or a URL, which has no file on disk. Should be the actual asset file, eg. app.css,
|
|
33
|
+
# some/component.js.
|
|
26
34
|
# @return [String|nil] the digest of the imported file path if a css module (*.module.css).
|
|
27
35
|
def import(filepath = nil, sideloaded: false, **)
|
|
28
36
|
self.imported ||= {}
|
|
29
37
|
|
|
30
|
-
digest = nil
|
|
31
|
-
|
|
32
38
|
if filepath.end_with?('.module.css')
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
ActiveSupport::Notifications.instrument 'sideload.proscenium', identifier: filepath,
|
|
43
|
-
sideloaded: do
|
|
44
|
-
imported[filepath] = { ** }
|
|
45
|
-
imported[filepath][:digest] = digest
|
|
46
|
-
imported[filepath][:abs_path] = abs_path
|
|
47
|
-
end
|
|
48
|
-
else
|
|
49
|
-
imported[filepath] = { ** }
|
|
50
|
-
imported[filepath][:digest] = digest
|
|
51
|
-
imported[filepath][:abs_path] = abs_path
|
|
52
|
-
end
|
|
53
|
-
end
|
|
39
|
+
manifest_path, non_manifest_path, abs_path = Resolver.resolve(filepath, as_array: true)
|
|
40
|
+
filepath = Array(manifest_path || non_manifest_path)[0]
|
|
41
|
+
|
|
42
|
+
# A URL has no file on disk, so the URL is the module's identity. Taken after the
|
|
43
|
+
# reassignment above, so the digest and the suffix cache below agree on it. An entry the
|
|
44
|
+
# stylesheet got as a plain file has no digest of its own.
|
|
45
|
+
digest = imported.dig(filepath, :digest) ||
|
|
46
|
+
Utils.css_module_digest(abs_path.presence || filepath)
|
|
47
|
+
store(filepath, sideloaded, **, digest:)
|
|
54
48
|
|
|
55
49
|
transformed_path = ''
|
|
56
50
|
# Mirrors ConfigT#ShouldMinify - the suffix exists whenever identifiers are not
|
|
57
51
|
# minified, and a class name the stylesheet does not define is worse than a long one.
|
|
58
52
|
if Proscenium.config.debug || !Rails.env.production?
|
|
59
|
-
|
|
60
|
-
|
|
53
|
+
# Keyed and built from the file's path under the app, or from the URL when there is
|
|
54
|
+
# no file: `Pathname.new('').relative_path_from(Rails.root)` raises.
|
|
55
|
+
identity = abs_path.presence || filepath
|
|
56
|
+
transformed_path = SUFFIXES.compute_if_absent(identity) do
|
|
57
|
+
relative = if abs_path.present?
|
|
58
|
+
Utils.css_module_relative_path(abs_path)
|
|
59
|
+
else
|
|
60
|
+
identity
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
"_#{Utils.css_module_suffix(relative)}"
|
|
64
|
+
end
|
|
61
65
|
end
|
|
62
66
|
|
|
63
67
|
"#{digest}#{transformed_path}"
|
|
64
68
|
else
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
Array(Resolver.resolve(filepath)).each do |fp|
|
|
68
|
-
if sideloaded
|
|
69
|
-
ActiveSupport::Notifications.instrument 'sideload.proscenium', identifier: fp,
|
|
70
|
-
sideloaded: do
|
|
71
|
-
imported[fp] = { ** }
|
|
72
|
-
end
|
|
73
|
-
else
|
|
74
|
-
imported[fp] = { ** }
|
|
75
|
-
end
|
|
76
|
-
end
|
|
69
|
+
Array(Resolver.resolve(filepath)).each { |fp| store(fp, sideloaded, **) }
|
|
77
70
|
end
|
|
78
71
|
end
|
|
79
72
|
|
|
@@ -118,9 +111,8 @@ module Proscenium
|
|
|
118
111
|
|
|
119
112
|
# @param filepath [Pathname] Absolute file system path of the Ruby file to sideload.
|
|
120
113
|
# @param extensions [Array<String>] Supported file extensions to sideload.
|
|
121
|
-
# @param options [Hash] Options to pass to `import`.
|
|
122
114
|
# @raise [ArgumentError] if `filepath` is not an absolute file system path.
|
|
123
|
-
private def _sideload(filepath, extensions, **
|
|
115
|
+
private def _sideload(filepath, extensions, **) # rubocop:disable Style/AccessModifierDeclarations
|
|
124
116
|
return unless Proscenium.config.side_load
|
|
125
117
|
|
|
126
118
|
if !filepath.is_a?(Pathname) || !filepath.absolute?
|
|
@@ -133,7 +125,7 @@ module Proscenium
|
|
|
133
125
|
extensions.find do |x|
|
|
134
126
|
next unless (fp = filepath.sub_ext(x)).exist?
|
|
135
127
|
|
|
136
|
-
import(fp.to_s, sideloaded: filepath, **
|
|
128
|
+
import(fp.to_s, sideloaded: filepath, **)
|
|
137
129
|
end
|
|
138
130
|
end
|
|
139
131
|
|
|
@@ -173,6 +165,25 @@ module Proscenium
|
|
|
173
165
|
def imported?(filepath = nil)
|
|
174
166
|
filepath ? imported&.key?(filepath) : !imported.blank?
|
|
175
167
|
end
|
|
168
|
+
|
|
169
|
+
private
|
|
170
|
+
|
|
171
|
+
# Keyed by the resolved path, never the path given: a sideload passes an absolute file
|
|
172
|
+
# system path. A repeat import merges its options in, later ones winning key by key, and does
|
|
173
|
+
# not notify again; proscenium-ui's Select sideloads itself `lazy: true` over an eager
|
|
174
|
+
# sideload of the same file.
|
|
175
|
+
def store(key, sideloaded, **options)
|
|
176
|
+
if (existing = imported[key])
|
|
177
|
+
existing.merge!(options)
|
|
178
|
+
elsif sideloaded
|
|
179
|
+
ActiveSupport::Notifications.instrument 'sideload.proscenium', identifier: key,
|
|
180
|
+
sideloaded: do
|
|
181
|
+
imported[key] = options
|
|
182
|
+
end
|
|
183
|
+
else
|
|
184
|
+
imported[key] = options
|
|
185
|
+
end
|
|
186
|
+
end
|
|
176
187
|
end
|
|
177
188
|
end
|
|
178
189
|
end
|
data/lib/proscenium/manifest.rb
CHANGED
|
@@ -3,51 +3,46 @@
|
|
|
3
3
|
module Proscenium
|
|
4
4
|
module Manifest
|
|
5
5
|
mattr_accessor :manifest, default: {}
|
|
6
|
-
mattr_accessor :loaded, default: false
|
|
7
6
|
|
|
8
7
|
module_function
|
|
9
8
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
end
|
|
13
|
-
|
|
9
|
+
# Built aside and assigned once, so a resolve during a load never sees a half-built manifest,
|
|
10
|
+
# and a manifest that fails to parse leaves the previous one in place.
|
|
14
11
|
def load!
|
|
15
12
|
public_path = Rails.configuration.paths['public'].first
|
|
16
|
-
|
|
17
|
-
self.loaded = false
|
|
13
|
+
entries = {}
|
|
18
14
|
|
|
19
15
|
if Proscenium.config.manifest_path.exist?
|
|
20
|
-
self.loaded = true
|
|
21
|
-
|
|
22
16
|
JSON.parse(Proscenium.config.manifest_path.read)['outputs'].each do |outpath, details|
|
|
23
17
|
next if !details.key?('entryPoint')
|
|
24
18
|
|
|
25
|
-
outpath = outpath.delete_prefix "#{public_path}/"
|
|
19
|
+
outpath = fs_path(outpath).delete_prefix "#{public_path}/"
|
|
26
20
|
|
|
27
|
-
ep = details['entryPoint']
|
|
28
|
-
ep =
|
|
29
|
-
"@rubygems/#{gem[0]}#{ep.delete_prefix(gem[1])}"
|
|
30
|
-
else
|
|
31
|
-
ep.delete_prefix(Rails.root.to_s)
|
|
32
|
-
end
|
|
21
|
+
ep = fs_path(details['entryPoint'])
|
|
22
|
+
ep = BundledGems.virtual_path(ep) || ep.delete_prefix(Rails.root.to_s)
|
|
33
23
|
|
|
34
|
-
|
|
24
|
+
entries[ep] = [
|
|
35
25
|
"/#{outpath}",
|
|
36
|
-
details['cssBundle']
|
|
26
|
+
details['cssBundle'] && fs_path(details['cssBundle']).delete_prefix(public_path)
|
|
37
27
|
].compact
|
|
38
28
|
end
|
|
39
29
|
end
|
|
40
30
|
|
|
41
|
-
manifest
|
|
31
|
+
self.manifest = entries
|
|
42
32
|
end
|
|
43
33
|
|
|
34
|
+
# esbuild records absolute paths in the metafile in the form the platform produced. Left in
|
|
35
|
+
# Windows form, every delete_prefix above failed, the manifest was keyed by full Windows
|
|
36
|
+
# paths, and every lookup missed: the app served source paths instead of the digest URLs it
|
|
37
|
+
# had just compiled, silently.
|
|
38
|
+
def fs_path(path) = Utils.fs_path(path)
|
|
39
|
+
|
|
40
|
+
# Empty, not nil or a lazy re-read: the runtime daemon relies on a reset manifest staying
|
|
41
|
+
# empty until `load!`.
|
|
44
42
|
def reset!
|
|
45
43
|
self.manifest = {}
|
|
46
|
-
self.loaded = false
|
|
47
44
|
end
|
|
48
45
|
|
|
49
|
-
def [](key)
|
|
50
|
-
loaded? ? manifest[key] : nil
|
|
51
|
-
end
|
|
46
|
+
def [](key) = manifest[key]
|
|
52
47
|
end
|
|
53
48
|
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'bundled_gems'
|
|
4
|
+
require_relative 'context_map'
|
|
5
|
+
require_relative 'stale_contexts'
|
|
6
|
+
|
|
7
|
+
module Proscenium
|
|
8
|
+
# Which mapping generation a build uses (#154). A generation is the context map and the
|
|
9
|
+
# staleness verdict, read once and kept, so every build sees one consistent view. In production
|
|
10
|
+
# a generation lasts the process: restart after a deploy. In development and test, `refresh`
|
|
11
|
+
# looks at most once a second for a change to the files the map is built from, and starts a new
|
|
12
|
+
# generation when one changed, such as `proscenium install` finishing.
|
|
13
|
+
#
|
|
14
|
+
# Gemfile.lock is watched, but a gem added or upgraded still needs a restart: Bundler loads the
|
|
15
|
+
# bundle once per process.
|
|
16
|
+
module MappingGeneration
|
|
17
|
+
# The lockfiles too: with contexts adopted, resolved paths carry each package's version.
|
|
18
|
+
WATCHED = ['Gemfile.lock', 'package.json', 'pnpm-workspace.yaml', '.npmrc',
|
|
19
|
+
'bunfig.toml', 'pnpm-lock.yaml', 'bun.lock', ContextMap::INSTALLING].freeze
|
|
20
|
+
EVERY = 1.0
|
|
21
|
+
LOCK = Mutex.new
|
|
22
|
+
|
|
23
|
+
module_function
|
|
24
|
+
|
|
25
|
+
# The current generation number, starting a new one first if a watched file changed and the
|
|
26
|
+
# last look was at least `every` seconds ago. Returns [number, whether it just changed].
|
|
27
|
+
def refresh(root, every: EVERY)
|
|
28
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
29
|
+
LOCK.synchronize do
|
|
30
|
+
@looked ||= {}
|
|
31
|
+
@number ||= 0
|
|
32
|
+
at, before = @looked[root]
|
|
33
|
+
return [@number, false] if at && now - at < every
|
|
34
|
+
|
|
35
|
+
after = signature(root)
|
|
36
|
+
@looked[root] = [now, after]
|
|
37
|
+
return [@number, false] if before.nil? || before == after
|
|
38
|
+
|
|
39
|
+
ContextMap.reset!
|
|
40
|
+
StaleContexts.reset!
|
|
41
|
+
[@number += 1, true]
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# The modification time of every file the map is built from, by path, nil for a missing one.
|
|
46
|
+
def signature(root)
|
|
47
|
+
files = WATCHED.map { File.join(root, it) } +
|
|
48
|
+
Dir.glob(File.join(root, ContextMap::CONTEXTS, '*', 'package.json')) +
|
|
49
|
+
manifests(root)
|
|
50
|
+
files.sort.to_h { [it, File.exist?(it) ? File.mtime(it).to_f : nil] }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Each participating gem's package.json: editing a path gem's manifest makes its context stale.
|
|
54
|
+
def manifests(root)
|
|
55
|
+
BundledGems.participating(BundledGems.installed_specs, overrides: BundledGems.overrides(root))
|
|
56
|
+
.values.filter_map { BundledGems.manifest_root(it) }
|
|
57
|
+
.map { File.join(it, 'package.json') }
|
|
58
|
+
rescue BundledGems::ConfigError
|
|
59
|
+
[]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def reset! = LOCK.synchronize { @looked = {} }
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -48,9 +48,12 @@ module Proscenium
|
|
|
48
48
|
file_readable?
|
|
49
49
|
end
|
|
50
50
|
|
|
51
|
+
# Probes the path exactly as it will be built. `real_path` is already decoded and cleaned, so
|
|
52
|
+
# decoding it again here - as this once did - probed a different file than the one built
|
|
53
|
+
# whenever the request was double-encoded: `%252e%252e` was built as a literal `%2e%2e`
|
|
54
|
+
# segment but probed as `..`.
|
|
51
55
|
def file_readable?
|
|
52
|
-
|
|
53
|
-
|
|
56
|
+
path = sourcemap? ? real_path[0...-4] : real_path
|
|
54
57
|
file_stat = File.stat(root_for_readable.join(path.delete_prefix('/').b).to_s)
|
|
55
58
|
rescue SystemCallError
|
|
56
59
|
false
|
|
@@ -62,14 +65,6 @@ module Proscenium
|
|
|
62
65
|
Rails.root
|
|
63
66
|
end
|
|
64
67
|
|
|
65
|
-
# Secondary to `Middleware.normalise_path`, which every path here has already been through
|
|
66
|
-
# - this is idempotent on that output, and earns its keep by stripping the leading slash
|
|
67
|
-
# that the `join` above needs, and by handling the `.map`-stripped variant.
|
|
68
|
-
def clean_path(file)
|
|
69
|
-
path = Rack::Utils.unescape_path file.chomp('/').delete_prefix('/')
|
|
70
|
-
Rack::Utils.clean_path_info path if Rack::Utils.valid_path? path
|
|
71
|
-
end
|
|
72
|
-
|
|
73
68
|
def content_type
|
|
74
69
|
case ::File.extname(path_to_build)
|
|
75
70
|
when '.js', '.mjs', '.ts', '.tsx', '.jsx' then 'application/javascript'
|
|
@@ -22,7 +22,12 @@ module Proscenium
|
|
|
22
22
|
# The path also has to yield a content hash, not just match the prefix - `CHUNKS_PATH`
|
|
23
23
|
# checks only the latter, and `/_asset_chunks/nohash.js` used to index the nil match and
|
|
24
24
|
# return a 500 from here.
|
|
25
|
+
#
|
|
26
|
+
# FileHandler decodes `PATH_INFO` again, and that cannot be switched off, so a path still
|
|
27
|
+
# holding a `%` after the first decode is refused: the handler would resolve it to a
|
|
28
|
+
# different file than the one checked here. esbuild never puts a `%` in a chunk name.
|
|
25
29
|
return @app.call(env) unless (path = Middleware.normalise_path(request.path)) &&
|
|
30
|
+
!path.include?('%') &&
|
|
26
31
|
path.match?(CHUNKS_PATH) &&
|
|
27
32
|
(etag = path[/-\$([a-z0-9]+)\$/i, 1])
|
|
28
33
|
|
data/lib/proscenium/monkey.rb
CHANGED
|
@@ -2,47 +2,39 @@
|
|
|
2
2
|
|
|
3
3
|
module Proscenium
|
|
4
4
|
module Monkey
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
result
|
|
5
|
+
# A template ActionView pushes onto `@current_template` is not the body of a partial rendered
|
|
6
|
+
# with a block, so `Helper#sideload_assets` must not see that partial while one renders. Rails
|
|
7
|
+
# caches template objects, so the pushed template can be the very one that rendered the partial.
|
|
8
|
+
#
|
|
9
|
+
# Overrides ActionView's private `ActionView::Base#_run(method, template, locals, buffer,
|
|
10
|
+
# add_to_stack: true, ...)`, unchanged from Rails 7.2 to 8.1. `@proscenium_block_partial` is
|
|
11
|
+
# set by `Helper#proscenium_render_block_partial`. Every template render runs through here, so
|
|
12
|
+
# arguments are forwarded with `...`, which allocates nothing. Reading `add_to_stack` does
|
|
13
|
+
# allocate, so it is only read while a block partial is rendering.
|
|
14
|
+
module Base
|
|
15
|
+
def _run(...)
|
|
16
|
+
return super unless @proscenium_block_partial && proscenium_pushes_template?(...)
|
|
17
|
+
|
|
18
|
+
owner = @proscenium_block_partial
|
|
19
|
+
@proscenium_block_partial = nil
|
|
20
|
+
super
|
|
21
|
+
ensure
|
|
22
|
+
@proscenium_block_partial = owner if owner
|
|
24
23
|
end
|
|
25
24
|
|
|
26
|
-
|
|
27
|
-
return unless (tpl_path = Pathname.new(tpl.identifier)).file?
|
|
25
|
+
private
|
|
28
26
|
|
|
29
|
-
|
|
30
|
-
|
|
27
|
+
def proscenium_pushes_template?(*, add_to_stack: true, **) = add_to_stack
|
|
28
|
+
end
|
|
31
29
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
options = case tpl_options
|
|
35
|
-
when Hash then options.deep_merge(tpl_options)
|
|
36
|
-
else
|
|
37
|
-
{ js: tpl_options, css: tpl_options }
|
|
38
|
-
end
|
|
39
|
-
end
|
|
30
|
+
module TemplateRenderer
|
|
31
|
+
private
|
|
40
32
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
end
|
|
33
|
+
def render_template(view, template, layout_name, locals)
|
|
34
|
+
return super unless SideLoad.sideloadable?(view, template)
|
|
44
35
|
|
|
45
|
-
|
|
36
|
+
layout = find_layout(layout_name, locals.keys, [formats.first])
|
|
37
|
+
SideLoad.sideload_templates(view, [layout, template]) { super }
|
|
46
38
|
end
|
|
47
39
|
end
|
|
48
40
|
|
|
@@ -50,45 +42,35 @@ module Proscenium
|
|
|
50
42
|
private
|
|
51
43
|
|
|
52
44
|
def render_partial_template(view, locals, template, layout, block)
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
45
|
+
return super unless SideLoad.sideloadable?(view, template)
|
|
46
|
+
|
|
47
|
+
SideLoad.sideload_templates(view, [layout, template]) do
|
|
48
|
+
# A view rendered before Proscenium::Helper is included cannot call `sideload_assets`.
|
|
49
|
+
if block && view.respond_to?(:proscenium_render_block_partial)
|
|
50
|
+
view.proscenium_render_block_partial(template, block) do |wrapped|
|
|
51
|
+
super(view, locals, template, layout, wrapped)
|
|
52
|
+
end
|
|
53
|
+
else
|
|
54
|
+
super
|
|
55
|
+
end
|
|
63
56
|
end
|
|
64
|
-
|
|
65
|
-
result
|
|
66
57
|
end
|
|
58
|
+
end
|
|
67
59
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
options = {} if options.nil?
|
|
76
|
-
options = { js: options, css: options } unless options.is_a?(Hash)
|
|
77
|
-
|
|
78
|
-
if tpl.instance_variable_defined?(:@sideload_assets_options)
|
|
79
|
-
tpl_options = tpl.instance_variable_get(:@sideload_assets_options)
|
|
80
|
-
options = if tpl_options.is_a?(Hash)
|
|
81
|
-
options.deep_merge tpl_options
|
|
82
|
-
else
|
|
83
|
-
{ js: tpl_options, css: tpl_options }
|
|
84
|
-
end
|
|
85
|
-
end
|
|
60
|
+
# A collection renders each item with `Template#render`, so never reaches
|
|
61
|
+
# `PartialRenderer#render_partial_template`. Hooked above the collection cache, which skips
|
|
62
|
+
# rendering entirely on a full hit. A mixed collection has no single template, and is not side
|
|
63
|
+
# loaded.
|
|
64
|
+
module CollectionRenderer
|
|
65
|
+
private
|
|
86
66
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
67
|
+
# `args` is `path, template, layout, block`. `collection` is ActionView's CollectionIterator,
|
|
68
|
+
# which has `length` but no `empty?`.
|
|
69
|
+
def render_collection(collection, view, *args)
|
|
70
|
+
_path, template, layout, = args
|
|
71
|
+
return super unless collection.length.positive? && SideLoad.sideloadable?(view, template)
|
|
90
72
|
|
|
91
|
-
|
|
73
|
+
SideLoad.sideload_templates(view, [layout, template]) { super }
|
|
92
74
|
end
|
|
93
75
|
end
|
|
94
76
|
end
|
data/lib/proscenium/railtie.rb
CHANGED
|
@@ -36,6 +36,20 @@ module Proscenium
|
|
|
36
36
|
|
|
37
37
|
Proscenium::Manifest.load!
|
|
38
38
|
|
|
39
|
+
project = Proscenium::ContextMap.project_root
|
|
40
|
+
if (notice = Proscenium::ContextMap.adoption_notice(project))
|
|
41
|
+
Rails.logger.info "[Proscenium] #{notice}"
|
|
42
|
+
end
|
|
43
|
+
# Production finds stale contexts at assets:precompile, which refuses to compile. Elsewhere
|
|
44
|
+
# the first mapping generation starts here, so a change after boot, such as running
|
|
45
|
+
# `proscenium install`, starts the next one. No Go call: Puma may fork after boot.
|
|
46
|
+
unless Rails.env.production?
|
|
47
|
+
generation, = Proscenium::MappingGeneration.refresh(project)
|
|
48
|
+
if (stale = Proscenium::StaleContexts.message(project, generation))
|
|
49
|
+
Rails.logger.warn "[Proscenium] #{stale}"
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
39
53
|
if config.proscenium.logging
|
|
40
54
|
require 'proscenium/log_subscriber'
|
|
41
55
|
Proscenium::LogSubscriber.attach_to :proscenium
|
|
@@ -78,8 +92,10 @@ module Proscenium
|
|
|
78
92
|
|
|
79
93
|
initializer 'proscenium.monkey_patches' do
|
|
80
94
|
ActiveSupport.on_load(:action_view) do
|
|
95
|
+
ActionView::Base.prepend Monkey::Base
|
|
81
96
|
ActionView::TemplateRenderer.prepend Monkey::TemplateRenderer
|
|
82
97
|
ActionView::PartialRenderer.prepend Monkey::PartialRenderer
|
|
98
|
+
ActionView::CollectionRenderer.prepend Monkey::CollectionRenderer
|
|
83
99
|
end
|
|
84
100
|
end
|
|
85
101
|
|
|
@@ -5,7 +5,8 @@ namespace :assets do
|
|
|
5
5
|
task precompile: :environment do
|
|
6
6
|
puts "\nPre-compiling assets..."
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
# Raises Proscenium::Builder::CompileError, with esbuild's messages, when the build fails.
|
|
9
|
+
Proscenium::Builder.compile
|
|
9
10
|
|
|
10
11
|
puts "\nAssets pre-compiled successfully."
|
|
11
12
|
|
data/lib/proscenium/resolver.rb
CHANGED
|
@@ -11,21 +11,53 @@ module Proscenium
|
|
|
11
11
|
# absolute file system path as an array. Only returns the resolved path if false (default).
|
|
12
12
|
# @return [String, Array<String>]
|
|
13
13
|
def self.resolve(path, as_array: false)
|
|
14
|
+
# Normalised before anything reads it, the guard and the cache key included. The bun test
|
|
15
|
+
# harness hands this Bun's own spelling of a module path, which on Windows is `D:\...`;
|
|
16
|
+
# matched against a slash-form Rails.root it fell through to the Go resolver and came back
|
|
17
|
+
# as the url path unchanged - a backslash path the harness then refused to build.
|
|
18
|
+
path = Utils.fs_path(path)
|
|
19
|
+
|
|
14
20
|
if path.start_with?('./', '../')
|
|
15
21
|
raise ArgumentError, '`path` must be an absolute file system or URL path'
|
|
16
22
|
end
|
|
17
23
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
as_array ?
|
|
24
|
+
# A new mapping generation forgets cached resolutions (#154): polled here too, so a path
|
|
25
|
+
# resolved before an install does not keep its old URL when no Builder is made after it.
|
|
26
|
+
generation
|
|
27
|
+
|
|
28
|
+
# Caches the manifest key, not its value, so a manifest loaded or reset later is still
|
|
29
|
+
# honoured. Every string is frozen: the URL path is handed back to callers, and in the
|
|
30
|
+
# Rails.root branch it is the key itself, so mutating it would redirect later lookups.
|
|
31
|
+
key, url_path, abs_path = resolved[path] ||= entry_for(path).map(&:-@).freeze
|
|
32
|
+
manifest_path = Proscenium::Manifest[key]
|
|
33
|
+
|
|
34
|
+
as_array ? [manifest_path, url_path, abs_path] : manifest_path || url_path
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# The manifest key, URL path and absolute file system path for `path`. In the gem branch the
|
|
38
|
+
# key differs from the URL path, so it is kept rather than derived.
|
|
39
|
+
def self.entry_for(path)
|
|
40
|
+
if (vpath = BundledGems.virtual_path(path))
|
|
41
|
+
[vpath, "/node_modules/#{vpath}", path]
|
|
42
|
+
elsif path.start_with?("#{Rails.root}/")
|
|
43
|
+
vpath = path.delete_prefix(Rails.root.to_s)
|
|
44
|
+
[vpath, vpath, path]
|
|
45
|
+
else
|
|
46
|
+
[path, *Builder.resolve(path)]
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
private_class_method :entry_for
|
|
50
|
+
|
|
51
|
+
# The mapping generation builds and resolutions use now (#154). Development and test start a
|
|
52
|
+
# new one when the files the context map is built from change, and forget resolved paths with
|
|
53
|
+
# it; production keeps one for the process. Here rather than on Builder, which loads the Go
|
|
54
|
+
# library when first referenced, so resolving a path never needs it.
|
|
55
|
+
def self.generation
|
|
56
|
+
return 0 if Rails.env.production?
|
|
57
|
+
|
|
58
|
+
number, changed = MappingGeneration.refresh(ContextMap.project_root)
|
|
59
|
+
reset if changed
|
|
60
|
+
number
|
|
29
61
|
end
|
|
30
62
|
|
|
31
63
|
def self.reset
|
|
@@ -15,6 +15,13 @@ class Daemon {
|
|
|
15
15
|
#buffer = "";
|
|
16
16
|
#nextId = 1;
|
|
17
17
|
|
|
18
|
+
// Null while the connection is usable, and the error that ended it once it is not. A closed
|
|
19
|
+
// socket does not refuse a write - Bun's `write` returns -1 and throws nothing - so without this
|
|
20
|
+
// a request sent after the daemon died registered a promise nothing would ever settle, and every
|
|
21
|
+
// later module load hung, outside any per-test timeout. A reply that does not parse ends it too:
|
|
22
|
+
// the stream is no longer line-aligned, so nothing read from it after that can be trusted.
|
|
23
|
+
#failure = null;
|
|
24
|
+
|
|
18
25
|
// Decodes across chunk boundaries. `chunk.toString()` decodes each chunk on its own, so a
|
|
19
26
|
// multi-byte character split by the socket becomes U+FFFD on both sides - a module whose bytes
|
|
20
27
|
// silently differ from the ones Rails served, which is the one thing this harness exists to
|
|
@@ -32,8 +39,8 @@ class Daemon {
|
|
|
32
39
|
unix: socketPath,
|
|
33
40
|
socket: {
|
|
34
41
|
data: (_sock, chunk) => daemon.#onData(chunk),
|
|
35
|
-
close: () => daemon.#
|
|
36
|
-
error: (_sock, error) => daemon.#
|
|
42
|
+
close: () => daemon.#fail(new Error("proscenium daemon closed the connection")),
|
|
43
|
+
error: (_sock, error) => daemon.#fail(error),
|
|
37
44
|
},
|
|
38
45
|
});
|
|
39
46
|
daemon = new Daemon(socket);
|
|
@@ -41,6 +48,8 @@ class Daemon {
|
|
|
41
48
|
}
|
|
42
49
|
|
|
43
50
|
send(op, args = {}) {
|
|
51
|
+
if (this.#failure) return Promise.reject(this.#failure);
|
|
52
|
+
|
|
44
53
|
return new Promise((resolve, reject) => {
|
|
45
54
|
const id = this.#nextId++;
|
|
46
55
|
this.#pending.set(id, { resolve, reject });
|
|
@@ -65,7 +74,7 @@ class Daemon {
|
|
|
65
74
|
try {
|
|
66
75
|
reply = JSON.parse(line);
|
|
67
76
|
} catch (error) {
|
|
68
|
-
this.#
|
|
77
|
+
this.#fail(error);
|
|
69
78
|
return;
|
|
70
79
|
}
|
|
71
80
|
|
|
@@ -78,8 +87,11 @@ class Daemon {
|
|
|
78
87
|
}
|
|
79
88
|
}
|
|
80
89
|
|
|
81
|
-
|
|
82
|
-
|
|
90
|
+
// The first error wins, so a parse failure is still what callers see once the close that
|
|
91
|
+
// usually follows it arrives.
|
|
92
|
+
#fail(error) {
|
|
93
|
+
this.#failure ??= error;
|
|
94
|
+
for (const { reject } of this.#pending.values()) reject(this.#failure);
|
|
83
95
|
this.#pending.clear();
|
|
84
96
|
}
|
|
85
97
|
}
|
|
@@ -123,8 +135,13 @@ async function spawnDaemon({ env, timeoutMs }) {
|
|
|
123
135
|
// Must match Server::SOCKET_NAME. The daemon is handed the directory, not the path.
|
|
124
136
|
const socketPath = join(socketDir, "d.sock");
|
|
125
137
|
|
|
138
|
+
// On Windows `bundle` is `bundle.bat`, and a batch file's arguments are re-parsed by cmd.exe.
|
|
139
|
+
// Bun 1.4 refuses that outright for an argument holding a cmd.exe special character, which the
|
|
140
|
+
// Ruby code below always does ("The argument 'args[6]' contains a cmd.exe special character").
|
|
141
|
+
// `ruby -S bundle` runs the same `bundle` script from PATH with ruby.exe, so no batch file and
|
|
142
|
+
// no cmd.exe are involved and the argument arrives exactly as written.
|
|
126
143
|
const command = [
|
|
127
|
-
"bundle",
|
|
144
|
+
...(process.platform === "win32" ? ["ruby", "-S", "bundle"] : ["bundle"]),
|
|
128
145
|
"exec",
|
|
129
146
|
"rails",
|
|
130
147
|
"runner",
|
|
@@ -244,3 +261,6 @@ export async function register(options = {}) {
|
|
|
244
261
|
}
|
|
245
262
|
|
|
246
263
|
export default register;
|
|
264
|
+
|
|
265
|
+
// Exported for its tests, which drive it against a fake daemon.
|
|
266
|
+
export { Daemon };
|