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
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require_relative '../context_map'
|
|
5
|
+
require_relative 'manager'
|
|
6
|
+
|
|
7
|
+
module Proscenium
|
|
8
|
+
module CLI
|
|
9
|
+
# What install checks after the manager has run (#154): no gem context came from a registry,
|
|
10
|
+
# the manager installed every context (Bun exits 0 when a workspace is missing, so this is
|
|
11
|
+
# Proscenium's to check), and a peer meant to be shared is one copy.
|
|
12
|
+
class Verify
|
|
13
|
+
# A `packages:` key in pnpm-lock.yaml, and a `packages` entry in bun.lock, for a
|
|
14
|
+
# `@rubygems/*` package that is not a link to its context. A pnpm key starting with `@` is
|
|
15
|
+
# always quoted, and a Git one holds colons: `'@rubygems/hue@git+ssh://git@github.com/...':`.
|
|
16
|
+
PNPM_REGISTRY = %r{^\s{2}'(@rubygems/[^@'\s]+)@(?!link:|workspace:)[^']*':\s*$}
|
|
17
|
+
BUN_REGISTRY = %r{"(@rubygems/[^"]+)":\s*\["@rubygems/[^@"]+@(?!workspace:)}
|
|
18
|
+
|
|
19
|
+
def initialize(root, manager, gems)
|
|
20
|
+
@root = root
|
|
21
|
+
@manager = manager
|
|
22
|
+
@gems = gems
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def call
|
|
26
|
+
lock = File.read(File.join(@root, Manager::LOCKFILES.key(@manager.name)))
|
|
27
|
+
check_registry(lock)
|
|
28
|
+
check_workspaces(lock)
|
|
29
|
+
check_peers
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# A `@rubygems/*` package resolved from a registry, rather than linked to its context.
|
|
33
|
+
# bun.lock is JSON with trailing commas, so both locks are scanned as text.
|
|
34
|
+
def check_registry(lock)
|
|
35
|
+
found = lock.scan(@manager.name == 'pnpm' ? PNPM_REGISTRY : BUN_REGISTRY).flatten
|
|
36
|
+
return if found.empty?
|
|
37
|
+
|
|
38
|
+
raise Error.new('PSM-E-REGISTRY-TARBALL', packages: found.uniq.join(', '))
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def check_workspaces(lock)
|
|
42
|
+
missing = @gems.reject do |gem|
|
|
43
|
+
path = ".proscenium/packages/#{gem}"
|
|
44
|
+
if @manager.name == 'pnpm'
|
|
45
|
+
# An importer with no dependencies is written on one line: ` <path>: {}`.
|
|
46
|
+
lock.match?(/^ #{Regexp.escape(path)}:(?: \{\})?$/)
|
|
47
|
+
else
|
|
48
|
+
lock.include?("\"#{path}\"")
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
return if missing.empty?
|
|
52
|
+
|
|
53
|
+
raise Error.new('PSM-E-WORKSPACE-MISSING', gems: missing.join(', '), manager: @manager.name)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Each peer a context declares that the app also depends on must resolve, from the context
|
|
57
|
+
# and from the app, to one real directory. A context that is missing or not valid JSON has
|
|
58
|
+
# no peers to check; StaleContexts and `install --frozen` report it themselves.
|
|
59
|
+
def check_peers
|
|
60
|
+
app = app_dependencies
|
|
61
|
+
@gems.each do |gem|
|
|
62
|
+
dir = File.join(@root, '.proscenium', 'packages', gem)
|
|
63
|
+
(peers(dir).keys & app).each do |name|
|
|
64
|
+
from_gem = resolve(dir, name)
|
|
65
|
+
from_app = resolve(@root, name)
|
|
66
|
+
next if from_gem.nil? || from_app.nil? || from_gem == from_app
|
|
67
|
+
|
|
68
|
+
raise Error.new('PSM-E-PEER-SPLIT', gem:, package: name, manager: @manager.name)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def peers(dir)
|
|
74
|
+
context = ContextMap.read_json(File.join(dir, 'package.json'))
|
|
75
|
+
peers = context['peerDependencies'] if context.is_a?(Hash)
|
|
76
|
+
peers.is_a?(Hash) ? peers : {}
|
|
77
|
+
rescue Errno::ENOENT, JSON::ParserError
|
|
78
|
+
{}
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def app_dependencies
|
|
82
|
+
package = ContextMap.read_json(File.join(@root, 'package.json'))
|
|
83
|
+
return [] unless package.is_a?(Hash)
|
|
84
|
+
|
|
85
|
+
# Peers too: pnpm installs the app's own peers at the root.
|
|
86
|
+
%w[dependencies devDependencies optionalDependencies peerDependencies].flat_map do |field|
|
|
87
|
+
package[field].is_a?(Hash) ? package[field].keys : []
|
|
88
|
+
end
|
|
89
|
+
rescue Errno::ENOENT, JSON::ParserError
|
|
90
|
+
[]
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# The real directory `name` resolves to from `dir`, walking up node_modules as Node does,
|
|
94
|
+
# but no higher than the app.
|
|
95
|
+
def resolve(dir, name)
|
|
96
|
+
current = dir
|
|
97
|
+
loop do
|
|
98
|
+
candidate = File.join(current, 'node_modules', name)
|
|
99
|
+
return File.realpath(candidate) if File.exist?(candidate)
|
|
100
|
+
return nil if current == @root || current == File.dirname(current)
|
|
101
|
+
|
|
102
|
+
current = File.dirname(current)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'version'
|
|
4
|
+
require_relative 'cli/error'
|
|
5
|
+
require_relative 'cli/reporter'
|
|
6
|
+
require_relative 'cli/gem_check'
|
|
7
|
+
require_relative 'cli/install'
|
|
8
|
+
require_relative 'cli/inspect'
|
|
9
|
+
|
|
10
|
+
autoload :OptionParser, 'optparse'
|
|
11
|
+
|
|
12
|
+
module Proscenium
|
|
13
|
+
# The `proscenium` command (#154): installs participating gems' JavaScript dependencies through
|
|
14
|
+
# the app's own package manager. It runs under `bundle exec` and reads the bundle in process.
|
|
15
|
+
#
|
|
16
|
+
# Load isolation is a contract, tested in test/cli/load_isolation_test.rb: nothing under
|
|
17
|
+
# lib/proscenium/cli/ may require `proscenium` itself, ActiveSupport, Rails, FFI or the engine
|
|
18
|
+
# library. Only Proscenium's plain-Ruby modules the engine shares with it (bundled_gems,
|
|
19
|
+
# dependency_context, context_map, stale_contexts) and these files.
|
|
20
|
+
module CLI
|
|
21
|
+
USAGE = <<~TEXT
|
|
22
|
+
Usage: bundle exec proscenium <command> [options]
|
|
23
|
+
bundle exec psm <command> [options]
|
|
24
|
+
|
|
25
|
+
Installs the NPM dependencies of the gems in your bundle that opt in.
|
|
26
|
+
|
|
27
|
+
Commands:
|
|
28
|
+
install Install your gems' NPM dependencies
|
|
29
|
+
install --frozen Check everything is up to date, without changing anything you commit
|
|
30
|
+
inspect [gem] Show which gems install NPM dependencies, and any problems
|
|
31
|
+
doctor [gem] The same as inspect
|
|
32
|
+
gem check [path] For gem authors: check a gem's package.json and gemspec
|
|
33
|
+
|
|
34
|
+
install options:
|
|
35
|
+
--frozen Change nothing you commit; fail if anything is out of date
|
|
36
|
+
--production Leave out development dependencies, and keep the committed
|
|
37
|
+
dependency context of a gem in an excluded Bundler group
|
|
38
|
+
--offline Install from the package manager's cache only
|
|
39
|
+
--manager pnpm|bun Which package manager, for an app with no package.json yet
|
|
40
|
+
--js-arg ARG Pass ARG to the package manager (repeatable)
|
|
41
|
+
--experimental-manager-version Allow a package manager version Proscenium doesn't support
|
|
42
|
+
|
|
43
|
+
Options:
|
|
44
|
+
--json Print newline-delimited JSON events instead of text
|
|
45
|
+
--quiet Print less
|
|
46
|
+
--version Print the Proscenium version
|
|
47
|
+
--help Print this help
|
|
48
|
+
TEXT
|
|
49
|
+
|
|
50
|
+
module_function
|
|
51
|
+
|
|
52
|
+
# Runs the command in `argv` and returns its exit status.
|
|
53
|
+
def start(argv, out: $stdout, err: $stderr)
|
|
54
|
+
args = argv.dup
|
|
55
|
+
reporter = Reporter.new(out:, err:, json: !args.delete('--json').nil?,
|
|
56
|
+
quiet: !args.delete('--quiet').nil?)
|
|
57
|
+
run(args, reporter)
|
|
58
|
+
rescue Error => e
|
|
59
|
+
reporter.error(e)
|
|
60
|
+
e.exit_status
|
|
61
|
+
rescue Interrupt
|
|
62
|
+
Error::EXIT.fetch(:interrupted)
|
|
63
|
+
rescue StandardError => e
|
|
64
|
+
internal = Error.new('PSM-E-INTERNAL', detail: "#{e.class}: #{e.message}")
|
|
65
|
+
reporter.error(internal)
|
|
66
|
+
internal.exit_status
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def run(args, reporter)
|
|
70
|
+
# Anywhere, so `install --help` and `gem check --help` print it too.
|
|
71
|
+
if args.intersect?(%w[--help -h])
|
|
72
|
+
reporter.result(USAGE, event: 'help')
|
|
73
|
+
return 0
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
case args.first
|
|
77
|
+
when '--version', '-v'
|
|
78
|
+
reporter.result(Proscenium::VERSION, event: 'version', version: Proscenium::VERSION)
|
|
79
|
+
when nil
|
|
80
|
+
reporter.result(USAGE, event: 'help')
|
|
81
|
+
when 'install'
|
|
82
|
+
return Install.new(project_root, reporter, **install_options(args.drop(1))).call
|
|
83
|
+
when 'inspect', 'doctor'
|
|
84
|
+
return Inspect.new(project_root, reporter, gem: args[1]).call
|
|
85
|
+
when 'gem'
|
|
86
|
+
unless args[1] == 'check'
|
|
87
|
+
raise Error.new('PSM-E-USAGE', detail: 'Did you mean `proscenium gem check`?')
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
return GemCheck.new(args[2], reporter).call
|
|
91
|
+
else
|
|
92
|
+
raise Error.new('PSM-E-USAGE', detail: "Unknown command: #{args.join(' ')}")
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
0
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# The active bundle's root: subdirectories and BUNDLE_GEMFILE both resolve to the app the
|
|
99
|
+
# bundle belongs to, so contexts are never written into another app.
|
|
100
|
+
def project_root = Bundler.root.to_s
|
|
101
|
+
|
|
102
|
+
def install_options(args)
|
|
103
|
+
options = { js_args: [] }
|
|
104
|
+
rest = OptionParser.new do |parser|
|
|
105
|
+
parser.on('--frozen') { options[:frozen] = true }
|
|
106
|
+
parser.on('--production') { options[:production] = true }
|
|
107
|
+
parser.on('--offline') { options[:offline] = true }
|
|
108
|
+
parser.on('--manager NAME') { options[:manager] = it }
|
|
109
|
+
parser.on('--experimental-manager-version') { options[:experimental] = true }
|
|
110
|
+
parser.on('--js-arg ARG') { options[:js_args] << it }
|
|
111
|
+
end.parse(args)
|
|
112
|
+
raise Error.new('PSM-E-USAGE', detail: "Unexpected argument: #{rest.join(' ')}") if rest.any?
|
|
113
|
+
if options[:js_args].any? && (options[:frozen] || options[:offline])
|
|
114
|
+
raise Error.new('PSM-E-USAGE', detail: '--js-arg cannot be used with --frozen or --offline')
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
options
|
|
118
|
+
rescue OptionParser::ParseError => e
|
|
119
|
+
raise Error.new('PSM-E-USAGE', detail: e.message)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'bundler'
|
|
4
|
+
require 'json'
|
|
5
|
+
require_relative 'app_package'
|
|
6
|
+
require_relative 'bundled_gems'
|
|
7
|
+
require_relative 'yaml_tree'
|
|
8
|
+
|
|
9
|
+
module Proscenium
|
|
10
|
+
# What the engine passes Go about gem dependency contexts (#154): which gems resolve their bare
|
|
11
|
+
# imports from `.proscenium/packages/<gem>/`, and which of the app's own packages keep their link
|
|
12
|
+
# paths. Built from committed files and Bundler only. Like BundledGems, it loads nothing the
|
|
13
|
+
# `proscenium` CLI may not.
|
|
14
|
+
module ContextMap
|
|
15
|
+
CONTEXTS = File.join('.proscenium', 'packages')
|
|
16
|
+
REGISTRATION = '.proscenium/packages/*'
|
|
17
|
+
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
# Where the `proscenium` CLI keeps contexts, its lock and its marker: the bundle's root, which
|
|
21
|
+
# need not be Rails.root.
|
|
22
|
+
def project_root = Bundler.root.to_s
|
|
23
|
+
|
|
24
|
+
# The config keys Go reads, for the app at `root`. Read once per mapping generation: a value
|
|
25
|
+
# read during an older one is kept under that one's key, never handed to the next.
|
|
26
|
+
def config(root, generation = 0)
|
|
27
|
+
@config ||= {}
|
|
28
|
+
@config[[root, generation]] ||= { DependencyContexts: contexts(root),
|
|
29
|
+
AppLocalPackages: AppPackage.local_packages(root) }.freeze
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def reset! = @config = {}
|
|
33
|
+
|
|
34
|
+
# Each registering file, with the lockfile and the name of the manager that reads it.
|
|
35
|
+
REGISTRARS = { 'pnpm-workspace.yaml' => %w[pnpm-lock.yaml pnpm],
|
|
36
|
+
'package.json' => %w[bun.lock bun] }.freeze
|
|
37
|
+
|
|
38
|
+
# Whether the app at `root` adopted gem dependency contexts: pnpm's workspace file registers
|
|
39
|
+
# them, or package.json does in a Bun app. Each counts only where its manager installs: when
|
|
40
|
+
# packageManager names no other manager, and with that manager's lockfile or no other
|
|
41
|
+
# manager's, as an app missing its lockfile is an incomplete checkout, which must not silently
|
|
42
|
+
# switch the contexts off.
|
|
43
|
+
def adopted?(root) = !registrar(root).nil?
|
|
44
|
+
|
|
45
|
+
# The file whose registration makes the app at `root` adopted, or nil.
|
|
46
|
+
def registrar(root)
|
|
47
|
+
named = AppPackage.package_manager(root)
|
|
48
|
+
REGISTRARS.find do |file, (own, manager)|
|
|
49
|
+
# bun.lockb is Bun's too, until bun.lock replaces it.
|
|
50
|
+
others = [*UNSUPPORTED.keys, *REGISTRARS.values.map(&:first)] - [own] +
|
|
51
|
+
(manager == 'bun' ? [] : ['bun.lockb'])
|
|
52
|
+
registers?(root, file) && [nil, manager].include?(named) &&
|
|
53
|
+
(File.exist?(File.join(root, own)) || others.none? { File.exist?(File.join(root, it)) })
|
|
54
|
+
end&.first
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Whether `file` lists the contexts: pnpm-workspace.yaml's `packages`, or package.json's
|
|
58
|
+
# `workspaces` (an array, or an object's `packages`). Parsed, so a commented-out line does not
|
|
59
|
+
# count. For the engine a file that does not parse counts if it names them, so a parse failure
|
|
60
|
+
# never silently switches off the context map and the stale check; `strict` (the CLI, which
|
|
61
|
+
# must register a file its manager can read) counts only a file that parses.
|
|
62
|
+
def registers?(root, file)
|
|
63
|
+
path = File.join(root, file)
|
|
64
|
+
File.exist?(path) && registered_in?(file, File.read(path))
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def registered_in?(file, text, strict: false)
|
|
68
|
+
text = utf8(text).delete_prefix(BOM)
|
|
69
|
+
list = file.end_with?('.yaml') ? yaml_packages(text) : json_workspaces(text)
|
|
70
|
+
list.is_a?(Array) && list.any? { registration?(it) } && !excludes?(list)
|
|
71
|
+
rescue Psych::SyntaxError, JSON::ParserError
|
|
72
|
+
return false if strict
|
|
73
|
+
|
|
74
|
+
file.end_with?('.yaml') ? text.include?(REGISTRATION) : AppPackage.workspaces?(text)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Whether the list has a `!` pattern that takes a context out: pnpm and Bun apply one whatever
|
|
78
|
+
# its order, so the contexts would never be installed. Generous: `*` crosses `/`, and any
|
|
79
|
+
# segment of the pattern may be a gem's directory.
|
|
80
|
+
def excludes?(list)
|
|
81
|
+
Array(list).grep(/\A!/).any? do |entry|
|
|
82
|
+
pattern = entry[1..].delete_prefix('./').chomp('/')
|
|
83
|
+
# A segment with a wildcard, class or brace, such as `[ah]ue`, may match any gem.
|
|
84
|
+
wide = pattern.split('/').map { it.match?(/[*?\[{]/) ? '*' : it }.join('/')
|
|
85
|
+
['gem', *pattern.split('/')].product([pattern, wide]).any? do |name, glob|
|
|
86
|
+
File.fnmatch?(glob, "#{CONTEXTS}/#{name}", GLOB)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
GLOB = File::FNM_EXTGLOB | File::FNM_DOTMATCH
|
|
92
|
+
|
|
93
|
+
def json_workspaces(text)
|
|
94
|
+
parsed = JSON.parse(text)
|
|
95
|
+
list = parsed['workspaces'] if parsed.is_a?(Hash)
|
|
96
|
+
list.is_a?(Hash) ? list['packages'] : list
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# pnpm-workspace.yaml's `packages`: each entry's scalar text, or nil for one that is not a
|
|
100
|
+
# scalar; nil when the document is not a mapping or the key is missing or not a list. Read from
|
|
101
|
+
# the parse tree and never built into Ruby objects, so no alias can make it slow and no tag or
|
|
102
|
+
# date can raise. Raises Psych::SyntaxError.
|
|
103
|
+
def yaml_packages(text)
|
|
104
|
+
list, anchors = YamlTree.value(text, 'packages')
|
|
105
|
+
return unless list.is_a?(Psych::Nodes::Sequence)
|
|
106
|
+
|
|
107
|
+
list.children.map do |entry|
|
|
108
|
+
entry = YamlTree.node(entry, anchors)
|
|
109
|
+
entry.value if entry.is_a?(Psych::Nodes::Scalar)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# pnpm and Bun read `./<pattern>` and `<pattern>/` as the pattern.
|
|
114
|
+
def registration?(entry)
|
|
115
|
+
entry.is_a?(String) && entry.delete_prefix('./').chomp('/') == REGISTRATION
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
BOM = "\uFEFF"
|
|
119
|
+
|
|
120
|
+
# A JSON file as Node, pnpm and Bun read it, a leading byte order mark included.
|
|
121
|
+
def read_json(path) = parse_json(File.read(path))
|
|
122
|
+
def parse_json(text) = JSON.parse(utf8(text).delete_prefix(BOM))
|
|
123
|
+
|
|
124
|
+
# Text as UTF-8, whatever encoding the caller's locale gave it.
|
|
125
|
+
def utf8(text) = text.dup.force_encoding(Encoding::UTF_8)
|
|
126
|
+
|
|
127
|
+
# Gem name => absolute context directory for each participating gem, when the app at `root`
|
|
128
|
+
# adopted dependency contexts; empty before. A gem whose context is missing is mapped all the
|
|
129
|
+
# same: its imports then fail by name rather than fall back to the app's packages.
|
|
130
|
+
def contexts(root, specs = BundledGems.installed_specs)
|
|
131
|
+
return {} unless adopted?(root)
|
|
132
|
+
|
|
133
|
+
BundledGems.participating(specs, overrides: BundledGems.overrides(root)).keys.to_h do |gem|
|
|
134
|
+
[gem, File.join(root, CONTEXTS, gem)]
|
|
135
|
+
end
|
|
136
|
+
rescue BundledGems::ConfigError
|
|
137
|
+
{} # StaleContexts reports it, so every build is refused with the reason
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
INSTALLING = File.join('.proscenium', 'installing')
|
|
141
|
+
LOCK = File.join('.proscenium', 'lock')
|
|
142
|
+
INSTALLING_MESSAGE = '`bundle exec proscenium install` is running, or stopped before it ' \
|
|
143
|
+
'finished. Wait for it, or run it again.'
|
|
144
|
+
|
|
145
|
+
# Whether an install is under way in the app at `root`, or stopped before it finished: its
|
|
146
|
+
# marker is there, or another process holds the project lock. The engine refuses to build or
|
|
147
|
+
# resolve meanwhile (C34). The probe takes a shared lock, so it never stops an install
|
|
148
|
+
# starting; the CLI retries a lock it finds taken for a moment. About 16 µs.
|
|
149
|
+
def installing?(root)
|
|
150
|
+
return true if File.exist?(File.join(root, INSTALLING))
|
|
151
|
+
|
|
152
|
+
lock = File.join(root, LOCK)
|
|
153
|
+
File.exist?(lock) && File.open(lock, File::RDONLY) { !it.flock(File::LOCK_SH | File::LOCK_NB) }
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Each file that says the app uses an unsupported manager, as Manager.select reads them.
|
|
157
|
+
UNSUPPORTED = { 'yarn.lock' => 'Yarn', '.yarnrc.yml' => 'Yarn', 'package-lock.json' => 'npm',
|
|
158
|
+
'npm-shrinkwrap.json' => 'npm' }.freeze
|
|
159
|
+
|
|
160
|
+
# The one notice a process logs at boot when gems opt in but the app has not adopted dependency
|
|
161
|
+
# contexts, or nil. Those gems' imports keep resolving as they always have, so upgrading
|
|
162
|
+
# Proscenium never breaks an app that has not run `proscenium install` (C52).
|
|
163
|
+
def adoption_notice(root, specs = BundledGems.installed_specs)
|
|
164
|
+
return if adopted?(root)
|
|
165
|
+
|
|
166
|
+
gems = BundledGems.participating(specs, overrides: BundledGems.overrides(root)).keys
|
|
167
|
+
return if gems.empty?
|
|
168
|
+
|
|
169
|
+
opted = "#{gems.join(', ')} opt in to installing their NPM dependencies " \
|
|
170
|
+
'through Proscenium'
|
|
171
|
+
manager = UNSUPPORTED.find { |file, _| File.exist?(File.join(root, file)) }&.last
|
|
172
|
+
if manager
|
|
173
|
+
"#{opted}, but #{manager} is not supported, so the app keeps managing them."
|
|
174
|
+
else
|
|
175
|
+
"#{opted}. Run `bundle exec proscenium install` to install them."
|
|
176
|
+
end
|
|
177
|
+
rescue BundledGems::ConfigError => e
|
|
178
|
+
e.message
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
@@ -8,6 +8,14 @@ module Proscenium
|
|
|
8
8
|
new(path).class_names(*names)
|
|
9
9
|
end
|
|
10
10
|
|
|
11
|
+
# Flattens `names`, and drops nil, false and blank String or Symbol names. Anything else is
|
|
12
|
+
# kept, so that `#class_names` can reject it.
|
|
13
|
+
def self.filter_names(names)
|
|
14
|
+
names.flatten.reject do |name|
|
|
15
|
+
!name || ((name.is_a?(String) || name.is_a?(Symbol)) && name.to_s.blank?)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
11
19
|
def initialize(source_path)
|
|
12
20
|
return unless (@source_path = source_path)
|
|
13
21
|
|
|
@@ -33,19 +41,22 @@ module Proscenium
|
|
|
33
41
|
# class_names "mypackage/button@large"
|
|
34
42
|
# class_names "@scoped/package/button@small"
|
|
35
43
|
#
|
|
36
|
-
#
|
|
44
|
+
# Nested arrays of names are flattened, and nil, false and blank names are dropped (see
|
|
45
|
+
# `.filter_names`). Any other name that is not a String or Symbol raises TransformError.
|
|
46
|
+
#
|
|
47
|
+
# When a block is given, it is yielded once per name with
|
|
37
48
|
# `(transformed_name, side_load_path)`. `side_load_path` is the exact string passed to
|
|
38
49
|
# `Importer.import` for that name, or `nil` for names that did not trigger a side-load (plain
|
|
39
50
|
# class names with `require_prefix: true`). The return value is unchanged — callers that don't
|
|
40
51
|
# need the path can keep ignoring the block.
|
|
41
52
|
#
|
|
42
|
-
# @param names [String,Symbol,Array<String,Symbol>]
|
|
53
|
+
# @param names [String,Symbol,nil,false,Array<String,Symbol,nil,false>]
|
|
43
54
|
# @param require_prefix: [Boolean] whether or not to require the `@` prefix.
|
|
44
55
|
# @yieldparam transformed_name [String]
|
|
45
56
|
# @yieldparam side_load_path [String, nil]
|
|
46
57
|
# @return [Array<String>] the transformed CSS module names.
|
|
47
58
|
def class_names(*names, require_prefix: true)
|
|
48
|
-
names.map do |name|
|
|
59
|
+
self.class.filter_names(names).map do |name|
|
|
49
60
|
transformed, path = transform_class_name(name, require_prefix: require_prefix)
|
|
50
61
|
yield(transformed, path) if block_given?
|
|
51
62
|
transformed
|
|
@@ -77,6 +88,10 @@ module Proscenium
|
|
|
77
88
|
def transform_class_name(name, require_prefix:)
|
|
78
89
|
original_name = name.dup
|
|
79
90
|
name = name.to_s if name.is_a?(Symbol)
|
|
91
|
+
unless name.is_a?(String)
|
|
92
|
+
raise Proscenium::CssModule::TransformError.new(original_name.inspect,
|
|
93
|
+
'class name must be a String or Symbol')
|
|
94
|
+
end
|
|
80
95
|
|
|
81
96
|
if name.include?('/')
|
|
82
97
|
if name.start_with?('@')
|
|
@@ -5,7 +5,6 @@ module Proscenium::CssModule
|
|
|
5
5
|
|
|
6
6
|
autoload :Path
|
|
7
7
|
autoload :Transformer
|
|
8
|
-
autoload :Rewriter
|
|
9
8
|
|
|
10
9
|
class TransformError < Proscenium::Error
|
|
11
10
|
def initialize(name, additional_msg = nil)
|
|
@@ -26,7 +25,7 @@ module Proscenium::CssModule
|
|
|
26
25
|
|
|
27
26
|
def class_names(*names, path: nil)
|
|
28
27
|
path ||= respond_to?(:css_module_path) ? css_module_path : path
|
|
29
|
-
names = names
|
|
28
|
+
names = Transformer.filter_names(names)
|
|
30
29
|
|
|
31
30
|
cssm = Transformer.new(path)
|
|
32
31
|
cssm.class_names(*names).join(' ') unless names.empty?
|
|
@@ -39,7 +38,7 @@ module Proscenium::CssModule
|
|
|
39
38
|
|
|
40
39
|
# Accepts one or more CSS class names, and transforms them into CSS module names.
|
|
41
40
|
#
|
|
42
|
-
# @param
|
|
41
|
+
# @param names [String,Symbol,nil,false,Array<String,Symbol,nil,false>]
|
|
43
42
|
# @param path [Pathname] the path to the CSS module file to use for the transformation.
|
|
44
43
|
# @return [String] the transformed CSS module names concatenated as a string.
|
|
45
44
|
def css_module(*names, path: nil)
|
|
@@ -47,11 +46,11 @@ module Proscenium::CssModule
|
|
|
47
46
|
transformer.class_names(*names, require_prefix: false).join(' ')
|
|
48
47
|
end
|
|
49
48
|
|
|
50
|
-
# @param
|
|
49
|
+
# @param names [String,Symbol,nil,false,Array<String,Symbol,nil,false>]
|
|
51
50
|
# @param path [Pathname] the path to the CSS file to use for the transformation.
|
|
52
|
-
# @return [String] the transformed CSS module names concatenated as a string.
|
|
51
|
+
# @return [String, nil] the transformed CSS module names concatenated as a string, or nil if none.
|
|
53
52
|
def class_names(*names, path: nil)
|
|
54
|
-
names = names
|
|
53
|
+
names = Transformer.filter_names(names)
|
|
55
54
|
transformer = path.nil? ? cssm : Transformer.new(path)
|
|
56
55
|
transformer.class_names(*names).join(' ') unless names.empty?
|
|
57
56
|
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
require 'json'
|
|
5
|
+
|
|
6
|
+
module Proscenium
|
|
7
|
+
# A participating gem's dependency context (#154): its package.json through the
|
|
8
|
+
# `dependency-context-v1` projection, which keeps installation metadata only and is committed at
|
|
9
|
+
# `.proscenium/packages/<gem>/package.json`.
|
|
10
|
+
#
|
|
11
|
+
# The one owner of the projection and its hash, shared by the CLI and the engine, so it must not
|
|
12
|
+
# load ActiveSupport or anything else the CLI may not.
|
|
13
|
+
module DependencyContext
|
|
14
|
+
PROJECTION = 'dependency-context-v1'
|
|
15
|
+
|
|
16
|
+
# The manifest fields a context keeps, in the order it writes them. Everything else (scripts,
|
|
17
|
+
# devDependencies, version, entry points, workspaces) is left in the gem's own package.json.
|
|
18
|
+
FIELDS = %w[dependencies peerDependencies peerDependenciesMeta optionalDependencies engines os
|
|
19
|
+
cpu libc].freeze
|
|
20
|
+
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
def name_for(gem) = "@rubygems/#{gem}"
|
|
24
|
+
|
|
25
|
+
def description_for(gem)
|
|
26
|
+
"Generated by Proscenium from the #{gem} gem. Do not edit; " \
|
|
27
|
+
'run bundle exec proscenium install.'
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# The context for `gem` from its parsed `manifest`. An empty field is left out, as an absent
|
|
31
|
+
# one is: the two install the same, so they hash the same.
|
|
32
|
+
def project(gem, manifest)
|
|
33
|
+
fields = FIELDS.each_with_object({}) do |key, kept|
|
|
34
|
+
value = manifest[key]
|
|
35
|
+
kept[key] = value unless value.nil? || (value.respond_to?(:empty?) && value.empty?)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
{ 'name' => name_for(gem), 'private' => true, 'description' => description_for(gem),
|
|
39
|
+
**fields,
|
|
40
|
+
'proscenium' => { 'projection' => PROJECTION, 'projectionSha256' => digest(gem, fields) } }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# The bytes committed for a context: fixed key order, two-space indent, a trailing newline, and
|
|
44
|
+
# nothing that varies by host or time.
|
|
45
|
+
def to_json(context) = "#{JSON.pretty_generate(context)}\n"
|
|
46
|
+
|
|
47
|
+
# The projection hash. It hashes a fixed-field encoding of the projected fields, not JSON, so
|
|
48
|
+
# it does not move with the json gem's output, key order, whitespace or escaping, and an edit
|
|
49
|
+
# to anything the projection drops leaves it alone.
|
|
50
|
+
def digest(gem, fields)
|
|
51
|
+
Digest::SHA256.hexdigest("#{PROJECTION}\n#{encode(name_for(gem))}#{encode(fields)}")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# A self-delimiting encoding: a type tag, a length where there is one, then the value. Hash
|
|
55
|
+
# keys are sorted by their bytes, so key order in the manifest does not matter; array order
|
|
56
|
+
# does, as it does to npm.
|
|
57
|
+
def encode(value)
|
|
58
|
+
case value
|
|
59
|
+
when String then "s#{value.bytesize}:#{value.b}"
|
|
60
|
+
when true then 't'
|
|
61
|
+
when false then 'f'
|
|
62
|
+
when nil then 'n'
|
|
63
|
+
when Integer then "i#{value};"
|
|
64
|
+
when Float then "d#{value};"
|
|
65
|
+
when Array then "a#{value.size}:#{value.map { encode(it) }.join}"
|
|
66
|
+
when Hash
|
|
67
|
+
pairs = value.sort_by { |key, _| key.to_s.b }
|
|
68
|
+
"h#{value.size}:#{pairs.map { |key, val| encode(key.to_s) + encode(val) }.join}"
|
|
69
|
+
else
|
|
70
|
+
raise ArgumentError, "cannot encode #{value.class} in a dependency context"
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Proscenium
|
|
4
|
+
# The base of every error Proscenium raises.
|
|
5
|
+
#
|
|
6
|
+
# In a file of its own so that lib/proscenium/builder.rb can load without the rest of the gem:
|
|
7
|
+
# the release workflow loads the compiled library from an installed gem to prove it works, with
|
|
8
|
+
# no Rails app and no Rails, and builder.rb subclasses this at class-definition time.
|
|
9
|
+
class Error < StandardError; end
|
|
10
|
+
end
|
data/lib/proscenium/helper.rb
CHANGED
|
@@ -2,15 +2,49 @@
|
|
|
2
2
|
|
|
3
3
|
module Proscenium
|
|
4
4
|
module Helper
|
|
5
|
+
# Sets the side load options of the template being rendered, for this render only. They are
|
|
6
|
+
# kept on the view, which lives for one request, keyed by template, because the template
|
|
7
|
+
# object itself is cached and shared by every request. `SideLoad.sideload_templates` scopes
|
|
8
|
+
# each value to the render that set it.
|
|
9
|
+
#
|
|
10
|
+
# Call it outside any `cache` block: a cache hit skips the block, and this call with it.
|
|
5
11
|
def sideload_assets(value)
|
|
12
|
+
# ActionView does not push a partial rendered with a block onto `@current_template`, so while
|
|
13
|
+
# its body renders, `@current_template` is still the calling template.
|
|
14
|
+
identifier = (@proscenium_block_partial || @current_template).identifier
|
|
15
|
+
|
|
6
16
|
if value.nil?
|
|
7
|
-
|
|
8
|
-
@current_template.remove_instance_variable(:@sideload_assets_options)
|
|
17
|
+
proscenium_sideload_assets_options.delete identifier
|
|
9
18
|
else
|
|
10
|
-
|
|
19
|
+
proscenium_sideload_assets_options[identifier] = value
|
|
11
20
|
end
|
|
12
21
|
end
|
|
13
22
|
|
|
23
|
+
# Renders a partial given `block`, and yields the block to render it with. While the partial's
|
|
24
|
+
# body renders, `sideload_assets` applies to the partial. While the block runs, wherever it is
|
|
25
|
+
# run from, it applies to the template that passed the block. Any template rendered from
|
|
26
|
+
# either pushes itself onto `@current_template`, and `Monkey::Base` clears this while it does.
|
|
27
|
+
def proscenium_render_block_partial(partial, block)
|
|
28
|
+
previous = @proscenium_block_partial
|
|
29
|
+
calling_template = previous || @current_template
|
|
30
|
+
@proscenium_block_partial = partial
|
|
31
|
+
|
|
32
|
+
yield(proc do |*args|
|
|
33
|
+
current = @proscenium_block_partial
|
|
34
|
+
@proscenium_block_partial = calling_template
|
|
35
|
+
block.call(*args)
|
|
36
|
+
ensure
|
|
37
|
+
@proscenium_block_partial = current
|
|
38
|
+
end)
|
|
39
|
+
ensure
|
|
40
|
+
@proscenium_block_partial = previous
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# @return [Hash] the `sideload_assets` value of each template in this render, by identifier.
|
|
44
|
+
def proscenium_sideload_assets_options
|
|
45
|
+
@proscenium_sideload_assets_options ||= {}
|
|
46
|
+
end
|
|
47
|
+
|
|
14
48
|
def compute_asset_path(path, options = {})
|
|
15
49
|
if %i[javascript stylesheet].include?(options[:type])
|
|
16
50
|
return Proscenium::Manifest[path] || "/#{path}"
|
|
@@ -22,7 +56,7 @@ module Proscenium
|
|
|
22
56
|
# Accepts one or more CSS class names, and transforms them into CSS module names.
|
|
23
57
|
#
|
|
24
58
|
# @see CssModule::Transformer#class_names
|
|
25
|
-
# @param
|
|
59
|
+
# @param names [String,Symbol,nil,false,Array<String,Symbol,nil,false>]
|
|
26
60
|
# @param path [Pathname] the path to the CSS module file to use for the transformation.
|
|
27
61
|
# @return [String] the transformed CSS module names concatenated as a string.
|
|
28
62
|
def css_module(*names, path: nil)
|