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.
Files changed (60) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +170 -23
  3. data/exe/proscenium +19 -0
  4. data/exe/psm +5 -0
  5. data/lib/proscenium/app_package.rb +90 -0
  6. data/lib/proscenium/builder.rb +221 -20
  7. data/lib/proscenium/bundled_gems.rb +103 -0
  8. data/lib/proscenium/cli/brackets.rb +66 -0
  9. data/lib/proscenium/cli/bun_linker.rb +79 -0
  10. data/lib/proscenium/cli/capabilities.json +29 -0
  11. data/lib/proscenium/cli/collisions.rb +94 -0
  12. data/lib/proscenium/cli/contexts.rb +133 -0
  13. data/lib/proscenium/cli/diff.rb +80 -0
  14. data/lib/proscenium/cli/error.rb +291 -0
  15. data/lib/proscenium/cli/gem_archive.rb +44 -0
  16. data/lib/proscenium/cli/gem_check.rb +150 -0
  17. data/lib/proscenium/cli/gitignore.rb +63 -0
  18. data/lib/proscenium/cli/inspect.rb +257 -0
  19. data/lib/proscenium/cli/install.rb +378 -0
  20. data/lib/proscenium/cli/manager.rb +323 -0
  21. data/lib/proscenium/cli/platform_variants.rb +82 -0
  22. data/lib/proscenium/cli/pnpm_approvals.rb +109 -0
  23. data/lib/proscenium/cli/pnpm_workspace.rb +149 -0
  24. data/lib/proscenium/cli/project_lock.rb +106 -0
  25. data/lib/proscenium/cli/registration.rb +140 -0
  26. data/lib/proscenium/cli/reporter.rb +103 -0
  27. data/lib/proscenium/cli/rules.rb +169 -0
  28. data/lib/proscenium/cli/runner.rb +142 -0
  29. data/lib/proscenium/cli/verify.rb +107 -0
  30. data/lib/proscenium/cli.rb +122 -0
  31. data/lib/proscenium/context_map.rb +181 -0
  32. data/lib/proscenium/css_module/transformer.rb +18 -3
  33. data/lib/proscenium/css_module.rb +5 -6
  34. data/lib/proscenium/dependency_context.rb +74 -0
  35. data/lib/proscenium/error.rb +10 -0
  36. data/lib/proscenium/helper.rb +38 -4
  37. data/lib/proscenium/importer.rb +53 -42
  38. data/lib/proscenium/manifest.rb +18 -23
  39. data/lib/proscenium/mapping_generation.rb +64 -0
  40. data/lib/proscenium/middleware/base.rb +5 -10
  41. data/lib/proscenium/middleware/chunks.rb +5 -0
  42. data/lib/proscenium/monkey.rb +51 -69
  43. data/lib/proscenium/railtie.rb +16 -0
  44. data/lib/proscenium/railties/assets.rake +2 -1
  45. data/lib/proscenium/resolver.rb +43 -11
  46. data/lib/proscenium/runtime/bootstrap.js +26 -6
  47. data/lib/proscenium/runtime/server.rb +57 -36
  48. data/lib/proscenium/side_load.rb +75 -18
  49. data/lib/proscenium/stale_contexts.rb +118 -0
  50. data/lib/proscenium/templates/rescues/build_error.html.erb +9 -7
  51. data/lib/proscenium/utils.rb +55 -0
  52. data/lib/proscenium/version.rb +1 -1
  53. data/lib/proscenium/yaml_tree.rb +48 -0
  54. data/lib/proscenium.rb +7 -4
  55. metadata +42 -13
  56. data/lib/proscenium/ext/proscenium +0 -0
  57. data/lib/proscenium/ext/proscenium.h +0 -113
  58. data/lib/proscenium/react-manager/index.jsx +0 -102
  59. data/lib/proscenium/react-manager/react.js +0 -2
  60. 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
- # When a block is given, it is yielded once per input name with
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.flatten.compact
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 name [String,Symbol,Array<String,Symbol>]
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 name [String,Symbol,Array<String,Symbol>]
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.flatten.compact
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
@@ -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
- @current_template.instance_variable_defined?(:@sideload_assets_options) &&
8
- @current_template.remove_instance_variable(:@sideload_assets_options)
17
+ proscenium_sideload_assets_options.delete identifier
9
18
  else
10
- @current_template.instance_variable_set :@sideload_assets_options, value
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 name [String,Symbol,Array<String,Symbol>]
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)