proscenium 0.26.0-x64-mingw-ucrt

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 (71) hide show
  1. checksums.yaml +7 -0
  2. data/CODE_OF_CONDUCT.md +84 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +1020 -0
  5. data/exe/proscenium +19 -0
  6. data/exe/psm +5 -0
  7. data/lib/generators/proscenium/bun/bun_generator.rb +150 -0
  8. data/lib/generators/proscenium/bun/templates/proscenium.preload.js +61 -0
  9. data/lib/proscenium/app_package.rb +90 -0
  10. data/lib/proscenium/builder.rb +403 -0
  11. data/lib/proscenium/bundled_gems.rb +140 -0
  12. data/lib/proscenium/cli/brackets.rb +66 -0
  13. data/lib/proscenium/cli/bun_linker.rb +79 -0
  14. data/lib/proscenium/cli/capabilities.json +29 -0
  15. data/lib/proscenium/cli/collisions.rb +94 -0
  16. data/lib/proscenium/cli/contexts.rb +133 -0
  17. data/lib/proscenium/cli/diff.rb +80 -0
  18. data/lib/proscenium/cli/error.rb +291 -0
  19. data/lib/proscenium/cli/gem_archive.rb +44 -0
  20. data/lib/proscenium/cli/gem_check.rb +150 -0
  21. data/lib/proscenium/cli/gitignore.rb +63 -0
  22. data/lib/proscenium/cli/inspect.rb +257 -0
  23. data/lib/proscenium/cli/install.rb +378 -0
  24. data/lib/proscenium/cli/manager.rb +323 -0
  25. data/lib/proscenium/cli/platform_variants.rb +82 -0
  26. data/lib/proscenium/cli/pnpm_approvals.rb +109 -0
  27. data/lib/proscenium/cli/pnpm_workspace.rb +149 -0
  28. data/lib/proscenium/cli/project_lock.rb +106 -0
  29. data/lib/proscenium/cli/registration.rb +140 -0
  30. data/lib/proscenium/cli/reporter.rb +103 -0
  31. data/lib/proscenium/cli/rules.rb +169 -0
  32. data/lib/proscenium/cli/runner.rb +142 -0
  33. data/lib/proscenium/cli/verify.rb +107 -0
  34. data/lib/proscenium/cli.rb +122 -0
  35. data/lib/proscenium/context_map.rb +181 -0
  36. data/lib/proscenium/css_module/path.rb +32 -0
  37. data/lib/proscenium/css_module/transformer.rb +118 -0
  38. data/lib/proscenium/css_module.rb +63 -0
  39. data/lib/proscenium/dependency_context.rb +74 -0
  40. data/lib/proscenium/ensure_loaded.rb +31 -0
  41. data/lib/proscenium/error.rb +10 -0
  42. data/lib/proscenium/ext/proscenium.dll +0 -0
  43. data/lib/proscenium/ext/proscenium.h +113 -0
  44. data/lib/proscenium/helper.rb +82 -0
  45. data/lib/proscenium/importer.rb +189 -0
  46. data/lib/proscenium/log_subscriber.rb +38 -0
  47. data/lib/proscenium/manifest.rb +48 -0
  48. data/lib/proscenium/mapping_generation.rb +64 -0
  49. data/lib/proscenium/middleware/base.rb +102 -0
  50. data/lib/proscenium/middleware/chunks.rb +46 -0
  51. data/lib/proscenium/middleware/esbuild.rb +11 -0
  52. data/lib/proscenium/middleware/ruby_gems.rb +37 -0
  53. data/lib/proscenium/middleware/silence_request.rb +31 -0
  54. data/lib/proscenium/middleware/vendor.rb +31 -0
  55. data/lib/proscenium/middleware.rb +67 -0
  56. data/lib/proscenium/monkey.rb +77 -0
  57. data/lib/proscenium/railtie.rb +106 -0
  58. data/lib/proscenium/railties/assets.rake +19 -0
  59. data/lib/proscenium/resolver.rb +67 -0
  60. data/lib/proscenium/runtime/bootstrap.js +266 -0
  61. data/lib/proscenium/runtime/bun.js +143 -0
  62. data/lib/proscenium/runtime/server.rb +702 -0
  63. data/lib/proscenium/side_load.rb +210 -0
  64. data/lib/proscenium/source_path.rb +15 -0
  65. data/lib/proscenium/stale_contexts.rb +118 -0
  66. data/lib/proscenium/templates/rescues/build_error.html.erb +32 -0
  67. data/lib/proscenium/utils.rb +104 -0
  68. data/lib/proscenium/version.rb +5 -0
  69. data/lib/proscenium/yaml_tree.rb +48 -0
  70. data/lib/proscenium.rb +87 -0
  71. 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