proscenium 0.26.0-x86_64-linux-gnu
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/CODE_OF_CONDUCT.md +84 -0
- data/LICENSE.txt +21 -0
- data/README.md +1020 -0
- data/exe/proscenium +19 -0
- data/exe/psm +5 -0
- data/lib/generators/proscenium/bun/bun_generator.rb +150 -0
- data/lib/generators/proscenium/bun/templates/proscenium.preload.js +61 -0
- data/lib/proscenium/app_package.rb +90 -0
- data/lib/proscenium/builder.rb +403 -0
- data/lib/proscenium/bundled_gems.rb +140 -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/path.rb +32 -0
- data/lib/proscenium/css_module/transformer.rb +118 -0
- data/lib/proscenium/css_module.rb +63 -0
- data/lib/proscenium/dependency_context.rb +74 -0
- data/lib/proscenium/ensure_loaded.rb +31 -0
- data/lib/proscenium/error.rb +10 -0
- data/lib/proscenium/ext/proscenium +0 -0
- data/lib/proscenium/ext/proscenium.h +113 -0
- data/lib/proscenium/helper.rb +82 -0
- data/lib/proscenium/importer.rb +189 -0
- data/lib/proscenium/log_subscriber.rb +38 -0
- data/lib/proscenium/manifest.rb +48 -0
- data/lib/proscenium/mapping_generation.rb +64 -0
- data/lib/proscenium/middleware/base.rb +102 -0
- data/lib/proscenium/middleware/chunks.rb +46 -0
- data/lib/proscenium/middleware/esbuild.rb +11 -0
- data/lib/proscenium/middleware/ruby_gems.rb +37 -0
- data/lib/proscenium/middleware/silence_request.rb +31 -0
- data/lib/proscenium/middleware/vendor.rb +31 -0
- data/lib/proscenium/middleware.rb +67 -0
- data/lib/proscenium/monkey.rb +77 -0
- data/lib/proscenium/railtie.rb +106 -0
- data/lib/proscenium/railties/assets.rake +19 -0
- data/lib/proscenium/resolver.rb +67 -0
- data/lib/proscenium/runtime/bootstrap.js +266 -0
- data/lib/proscenium/runtime/bun.js +143 -0
- data/lib/proscenium/runtime/server.rb +702 -0
- data/lib/proscenium/side_load.rb +210 -0
- data/lib/proscenium/source_path.rb +15 -0
- data/lib/proscenium/stale_contexts.rb +118 -0
- data/lib/proscenium/templates/rescues/build_error.html.erb +32 -0
- data/lib/proscenium/utils.rb +104 -0
- data/lib/proscenium/version.rb +5 -0
- data/lib/proscenium/yaml_tree.rb +48 -0
- data/lib/proscenium.rb +87 -0
- metadata +148 -0
|
@@ -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
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Proscenium
|
|
4
|
+
module CssModule::Path
|
|
5
|
+
# Returns the path to the CSS module file for this class, where the file is located alongside
|
|
6
|
+
# the class file, and has the same name as the class file, but with a `.module.css` extension.
|
|
7
|
+
#
|
|
8
|
+
# If the CSS module file does not exist, it's ancestry is checked, returning the first that
|
|
9
|
+
# exists. Then finally `nil` is returned if never found.
|
|
10
|
+
#
|
|
11
|
+
# @return [Pathname]
|
|
12
|
+
def css_module_path
|
|
13
|
+
return @css_module_path if instance_variable_defined?(:@css_module_path)
|
|
14
|
+
|
|
15
|
+
path = source_path.sub_ext('.module.css')
|
|
16
|
+
@css_module_path = path.exist? ? path : nil
|
|
17
|
+
|
|
18
|
+
unless @css_module_path
|
|
19
|
+
klass = superclass
|
|
20
|
+
|
|
21
|
+
while klass.respond_to?(:css_module_path) &&
|
|
22
|
+
(klass.respond_to?(:abstract_class) ? !klass.abstract_class : true)
|
|
23
|
+
break if (@css_module_path = klass.css_module_path)
|
|
24
|
+
|
|
25
|
+
klass = klass.superclass
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
@css_module_path
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Proscenium
|
|
4
|
+
class CssModule::Transformer
|
|
5
|
+
FILE_EXT = '.module.css'
|
|
6
|
+
|
|
7
|
+
def self.class_names(path, *names)
|
|
8
|
+
new(path).class_names(*names)
|
|
9
|
+
end
|
|
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
|
+
|
|
19
|
+
def initialize(source_path)
|
|
20
|
+
return unless (@source_path = source_path)
|
|
21
|
+
|
|
22
|
+
@source_path = Pathname.new(@source_path) unless @source_path.is_a?(Pathname)
|
|
23
|
+
@source_path = @source_path.sub_ext(FILE_EXT) unless @source_path.to_s.end_with?(FILE_EXT)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Transform each of the given class `names` to their respective CSS module name, which consist
|
|
27
|
+
# of the name, and suffixed with the digest of the resolved source path.
|
|
28
|
+
#
|
|
29
|
+
# Any name beginning with '@' will be transformed to a CSS module name. If `require_prefix` is
|
|
30
|
+
# false, then all names will be transformed to a CSS module name regardless of whether or not
|
|
31
|
+
# they begin with '@'.
|
|
32
|
+
#
|
|
33
|
+
# class_names :@my_module_name, :my_class_name
|
|
34
|
+
#
|
|
35
|
+
# Note that the generated digest is based on the resolved (URL) path, not the original path.
|
|
36
|
+
#
|
|
37
|
+
# You can also provide a path specifier and class name. The path will be the URL path to a
|
|
38
|
+
# stylesheet. The class name will be the name of the class to transform.
|
|
39
|
+
#
|
|
40
|
+
# class_names "/lib/button@default"
|
|
41
|
+
# class_names "mypackage/button@large"
|
|
42
|
+
# class_names "@scoped/package/button@small"
|
|
43
|
+
#
|
|
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
|
|
48
|
+
# `(transformed_name, side_load_path)`. `side_load_path` is the exact string passed to
|
|
49
|
+
# `Importer.import` for that name, or `nil` for names that did not trigger a side-load (plain
|
|
50
|
+
# class names with `require_prefix: true`). The return value is unchanged — callers that don't
|
|
51
|
+
# need the path can keep ignoring the block.
|
|
52
|
+
#
|
|
53
|
+
# @param names [String,Symbol,nil,false,Array<String,Symbol,nil,false>]
|
|
54
|
+
# @param require_prefix: [Boolean] whether or not to require the `@` prefix.
|
|
55
|
+
# @yieldparam transformed_name [String]
|
|
56
|
+
# @yieldparam side_load_path [String, nil]
|
|
57
|
+
# @return [Array<String>] the transformed CSS module names.
|
|
58
|
+
def class_names(*names, require_prefix: true)
|
|
59
|
+
self.class.filter_names(names).map do |name|
|
|
60
|
+
transformed, path = transform_class_name(name, require_prefix: require_prefix)
|
|
61
|
+
yield(transformed, path) if block_given?
|
|
62
|
+
transformed
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def class_name!(name, original_name, path: @source_path)
|
|
67
|
+
unless path
|
|
68
|
+
raise Proscenium::CssModule::TransformError.new(original_name, 'CSS module path not given')
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
digest = Importer.import(path.to_s)
|
|
72
|
+
|
|
73
|
+
transformed_name = name.to_s
|
|
74
|
+
if transformed_name.start_with?('_')
|
|
75
|
+
"_#{transformed_name[1..]}_#{digest}"
|
|
76
|
+
else
|
|
77
|
+
"#{transformed_name}_#{digest}"
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
# Returns `[transformed_name, side_load_path]` for a single class-name reference.
|
|
84
|
+
# `side_load_path` is the exact string that `class_name!` passed to `Importer.import`, or
|
|
85
|
+
# `nil` if the name did not trigger a side-load. Extracted so `#class_names` can yield paths
|
|
86
|
+
# to consumers (e.g. proscenium-phlex's `resolved_css_module_paths` replay registry) without
|
|
87
|
+
# re-parsing names.
|
|
88
|
+
def transform_class_name(name, require_prefix:)
|
|
89
|
+
original_name = name.dup
|
|
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
|
|
95
|
+
|
|
96
|
+
if name.include?('/')
|
|
97
|
+
if name.start_with?('@')
|
|
98
|
+
# Scoped bare specifier (eg. "@scoped/package/lib/button@default").
|
|
99
|
+
_, path, name = name.split('@')
|
|
100
|
+
path = "@#{path}"
|
|
101
|
+
else
|
|
102
|
+
# Local path (eg. /some/path/to/button@default") or bare specifier (eg.
|
|
103
|
+
# "mypackage/lib/button@default").
|
|
104
|
+
path, name = name.split('@')
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
module_path = "#{path}#{FILE_EXT}"
|
|
108
|
+
[class_name!(name, original_name, path: module_path), module_path]
|
|
109
|
+
elsif name.start_with?('@')
|
|
110
|
+
[class_name!(name[1..], original_name), @source_path&.to_s]
|
|
111
|
+
elsif require_prefix
|
|
112
|
+
[name, nil]
|
|
113
|
+
else
|
|
114
|
+
[class_name!(name, original_name), @source_path&.to_s]
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Proscenium::CssModule
|
|
4
|
+
extend ActiveSupport::Autoload
|
|
5
|
+
|
|
6
|
+
autoload :Path
|
|
7
|
+
autoload :Transformer
|
|
8
|
+
|
|
9
|
+
class TransformError < Proscenium::Error
|
|
10
|
+
def initialize(name, additional_msg = nil)
|
|
11
|
+
msg = "Failed to transform CSS module `#{name}`"
|
|
12
|
+
msg << ' - ' << additional_msg if additional_msg
|
|
13
|
+
|
|
14
|
+
super(msg)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
module ClassMethods
|
|
19
|
+
def css_module(*names, path: nil)
|
|
20
|
+
path ||= respond_to?(:css_module_path) ? css_module_path : path
|
|
21
|
+
|
|
22
|
+
cssm = Transformer.new(path)
|
|
23
|
+
cssm.class_names(*names, require_prefix: false).join(' ')
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def class_names(*names, path: nil)
|
|
27
|
+
path ||= respond_to?(:css_module_path) ? css_module_path : path
|
|
28
|
+
names = Transformer.filter_names(names)
|
|
29
|
+
|
|
30
|
+
cssm = Transformer.new(path)
|
|
31
|
+
cssm.class_names(*names).join(' ') unless names.empty?
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.included(base)
|
|
36
|
+
base.extend ClassMethods
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Accepts one or more CSS class names, and transforms them into CSS module names.
|
|
40
|
+
#
|
|
41
|
+
# @param names [String,Symbol,nil,false,Array<String,Symbol,nil,false>]
|
|
42
|
+
# @param path [Pathname] the path to the CSS module file to use for the transformation.
|
|
43
|
+
# @return [String] the transformed CSS module names concatenated as a string.
|
|
44
|
+
def css_module(*names, path: nil)
|
|
45
|
+
transformer = path.nil? ? cssm : Transformer.new(path)
|
|
46
|
+
transformer.class_names(*names, require_prefix: false).join(' ')
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# @param names [String,Symbol,nil,false,Array<String,Symbol,nil,false>]
|
|
50
|
+
# @param path [Pathname] the path to the CSS file to use for the transformation.
|
|
51
|
+
# @return [String, nil] the transformed CSS module names concatenated as a string, or nil if none.
|
|
52
|
+
def class_names(*names, path: nil)
|
|
53
|
+
names = Transformer.filter_names(names)
|
|
54
|
+
transformer = path.nil? ? cssm : Transformer.new(path)
|
|
55
|
+
transformer.class_names(*names).join(' ') unless names.empty?
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def cssm
|
|
61
|
+
@cssm ||= Transformer.new(self.class.css_module_path)
|
|
62
|
+
end
|
|
63
|
+
end
|