react-manifest-rails 0.3.0 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +48 -0
- data/README.md +7 -2
- data/lib/react_manifest/ast_extractor.rb +11 -0
- data/lib/react_manifest/component_index.rb +136 -0
- data/lib/react_manifest/generator.rb +147 -49
- data/lib/react_manifest/gitignore_patcher.rb +10 -4
- data/lib/react_manifest/symbol_extractor.rb +27 -0
- data/lib/react_manifest/vendor/ast_extractor.js +11 -11
- data/lib/react_manifest/vendor/build/entry.js +32 -0
- data/lib/react_manifest/version.rb +1 -1
- data/lib/react_manifest/view_helpers.rb +68 -10
- data/lib/react_manifest.rb +2 -71
- data/tasks/react_manifest.rake +13 -0
- metadata +16 -15
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d5ea479337143141722feeaa26cbf9e8d46e2158c399f5d29f4cb6bc5dbbdcd7
|
|
4
|
+
data.tar.gz: 0d4983f0b8ec3c35d7165fbfe801fc3f73286868e88bca1da83e76f6dcf963ce
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: dfeba95401019cb1233677138d89b99d9c286d55c89edc74fd1e9f10e4ade37f0038cfede9d12045a43d0143355c622c9c746178d0a8da75c3c6cf97e62b9a79
|
|
7
|
+
data.tar.gz: 29159646362cbdb7be57a57fdc77fd61010e607c7dfe2214f65e301b352b44914e1749304bb0f6e457b2cab97f0627f34ea5dfbbef1597ed3a93951069389baa
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,54 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.3.2] - 2026-10-13
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- `react_component` adds `data-react-persist="true"` to the root element when it runs after
|
|
14
|
+
`react_bundle_tag` in the same request. That is a component in the layout body (navbar,
|
|
15
|
+
footer, sidebar), since Rails renders the template first. A Turbolinks wrapper can keep it
|
|
16
|
+
mounted across visits and pass it new props. Template components are never marked.
|
|
17
|
+
|
|
18
|
+
## [0.3.1] - 2026-10-13
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
- `react_bundle_tag` emits `data-turbolinks-track="reload"` on the shared bundle's tag only
|
|
22
|
+
(`config.shared_bundle`). Turbolinks 5 then full-reloads once after a deploy changes
|
|
23
|
+
`ux_shared`, so old shared code never runs with new controller code. Controller and
|
|
24
|
+
`always_include` bundles stay untracked to avoid a reload on every cross-controller visit.
|
|
25
|
+
- `react_manifest:generate` warns when two different files declare the same top-level
|
|
26
|
+
`const`/`let`/`class` (destructured names included) in different bundles. Classic scripts
|
|
27
|
+
share one global scope, so under Turbolinks the second bundle throws "Identifier X has
|
|
28
|
+
already been declared" and is rejected.
|
|
29
|
+
- `react_manifest:generate` (and the dev boot/watch generation) writes
|
|
30
|
+
`ux_manifests/component_index.json`, the component-symbol -> bundle maps. `react_component`
|
|
31
|
+
loads it instead of parsing `ux/app` with mini_racer on each process's first call (no V8
|
|
32
|
+
context, no per-worker RSS cost). It is used only when its content fingerprint matches;
|
|
33
|
+
a missing or stale sidecar falls back to the runtime parse. Gitignored alongside the
|
|
34
|
+
manifests; removed by `react_manifest:clean`.
|
|
35
|
+
- `rake react_manifest:check`: analysis only, writes nothing, exits 1 with the warnings on
|
|
36
|
+
stderr when any "declared twice" clash is found (cross-bundle lexical collisions,
|
|
37
|
+
`always_include` double-declares). Meant for CI.
|
|
38
|
+
|
|
39
|
+
### Fixed
|
|
40
|
+
- `mini_racer` is now a declared runtime dependency (`>= 0.2`); `AstExtractor` already
|
|
41
|
+
required it unconditionally.
|
|
42
|
+
- The built gem no longer ships `lib/react_manifest/vendor/build/node_modules`.
|
|
43
|
+
- `react_bundle_tag` now hoists bundle `<script>` tags into the layout `<head>`. Rails renders
|
|
44
|
+
the template before the layout, so `react_component` wrote ux_shared, `always_include` and
|
|
45
|
+
controller bundle tags into the `<body>`; Turbolinks re-evaluates body scripts on every
|
|
46
|
+
visit, giving "Identifier X has already been declared". `react_component` now marks its
|
|
47
|
+
inline tags (`data-react-manifest`) and `react_bundle_tag` moves them to the head, once, in
|
|
48
|
+
shared → always_include → controller order. Layouts without `react_bundle_tag` are unchanged.
|
|
49
|
+
- Collision warnings for `always_include` bundles now cover the every-page double.
|
|
50
|
+
Previously only a file that **directly** defined a collided symbol was warned about.
|
|
51
|
+
A file that is merely *tainted* (transitively depends on a collided symbol) and gets
|
|
52
|
+
pulled into **2+** `always_include` tags double-declares on every page but was not
|
|
53
|
+
warned. The warning now reports both a *multi-tag* double (a file carried by 2+
|
|
54
|
+
always_include tags — including tainted files, naming the file, symbol, and bundles)
|
|
55
|
+
and the *owner-page* double (single tag vs. the owning bundle), with wording accurate
|
|
56
|
+
to each case rather than always claiming the double happens "on the owner's page".
|
|
57
|
+
|
|
10
58
|
## [0.3.0] - 2026-07-15
|
|
11
59
|
|
|
12
60
|
### Fixed
|
data/README.md
CHANGED
|
@@ -143,13 +143,14 @@ end
|
|
|
143
143
|
### Key option notes
|
|
144
144
|
|
|
145
145
|
- **`ignore`**: skips entire controller dirs under `ux/app/`. `ignore = ["admin"]` excludes `ux/app/admin/`.
|
|
146
|
-
- **`isolated_app_dirs`**: a controller dir's own `ux_<name>.js` is generated as usual, but its files/symbols are never inferred as a dependency of any *other* controller.
|
|
146
|
+
- **`isolated_app_dirs`**: a controller dir's own `ux_<name>.js` is generated as usual, but its files/symbols are never inferred as a dependency of any *other* controller. When the AST engine below is unavailable (e.g. `mini_racer` failed to load), symbol usage detection is regex-based, so a generic component name can collide with an unrelated word appearing as plain JSX text elsewhere (e.g. a `Show` component vs. a "Show More" button label in a different controller) and get wrongly inlined everywhere. Use this as a manual, always-available fallback for self-contained dirs — e.g. a page-builder tool bundling its own vendored library — that should never leak into other pages, whether or not `mini_racer` is installed. `isolated_app_dirs = ["rvb"]` isolates `ux/app/rvb/`.
|
|
147
147
|
- **`exclude_paths`**: excludes files whose path contains any listed segment. Not based on `application.js`.
|
|
148
148
|
- **`dry_run`**: also honoured by `DRY_RUN=1` environment variable at runtime.
|
|
149
149
|
- **`extensions`**: add `ts` and `tsx` to enable TypeScript source detection.
|
|
150
150
|
- **`external_roots` / `external_providers`**: do not point these at files already inside shared `ux/` dirs (`components/`, `hooks/`, `lib/`, etc.). The generator now skips those overlaps to prevent duplicate declarations in browser runtime.
|
|
151
151
|
- **`stdout_logging`**: `Rails.logger` always receives status lines regardless of this flag. Turn this on only if your `Rails.logger` does *not* already print to your terminal in development — if it does (common with `RAILS_LOG_TO_STDOUT`, Docker, or `bin/dev`/Foreman setups), enabling this prints every line twice.
|
|
152
|
-
- **
|
|
152
|
+
- **Component index**: `react_manifest:generate` (and dev boot/watch) also writes `ux_manifests/component_index.json`, a gitignored build artifact. `react_component` reads it instead of parsing `ux/app` with V8 on the first call in each web process. It is used only when its fingerprint (gem version, config, file list and contents) matches; otherwise the runtime parses as before.
|
|
153
|
+
- **AST-based symbol extraction**: `mini_racer` is a dependency of this gem. Definitions and usages are derived from a real JS/JSX AST (Acorn) instead of regex, which fixes false positives like a `Show` component colliding with unrelated text such as a "Show More" button label. Falls back to regex extraction per-file on any parse failure (always logged via `Rails.logger.warn` with file/line/column when available) — generation never breaks because of this.
|
|
153
154
|
|
|
154
155
|
## Commands
|
|
155
156
|
|
|
@@ -164,6 +165,10 @@ bundle exec rails react_manifest:generate
|
|
|
164
165
|
DRY_RUN=1 bundle exec rails react_manifest:setup
|
|
165
166
|
DRY_RUN=1 bundle exec rails react_manifest:generate
|
|
166
167
|
|
|
168
|
+
# CI gate: writes nothing; exits 1 and prints each "declared twice" warning to stderr
|
|
169
|
+
# (cross-bundle top-level const/let/class clashes, always_include double-declares)
|
|
170
|
+
bundle exec rails react_manifest:check
|
|
171
|
+
|
|
167
172
|
# Analyse which shared symbols are used per controller
|
|
168
173
|
bundle exec rails react_manifest:analyze
|
|
169
174
|
|
|
@@ -15,6 +15,9 @@ module ReactManifest
|
|
|
15
15
|
function __reactManifestExtractDefinitions(content) {
|
|
16
16
|
return JSON.stringify(__astExtractor.extractDefinitions(content));
|
|
17
17
|
}
|
|
18
|
+
function __reactManifestExtractLexicalBindings(content) {
|
|
19
|
+
return JSON.stringify(__astExtractor.extractLexicalBindings(content));
|
|
20
|
+
}
|
|
18
21
|
function __reactManifestExtractUsages(content) {
|
|
19
22
|
return JSON.stringify(__astExtractor.extractUsages(content));
|
|
20
23
|
}
|
|
@@ -31,6 +34,14 @@ module ReactManifest
|
|
|
31
34
|
nil
|
|
32
35
|
end
|
|
33
36
|
|
|
37
|
+
def extract_lexical_bindings(content, file_path: nil)
|
|
38
|
+
result = call_js("__reactManifestExtractLexicalBindings", content)
|
|
39
|
+
return result["bindings"] if result["success"]
|
|
40
|
+
|
|
41
|
+
report_error(file_path, result["error"])
|
|
42
|
+
nil
|
|
43
|
+
end
|
|
44
|
+
|
|
34
45
|
def extract_usages(content, file_path: nil)
|
|
35
46
|
result = call_js("__reactManifestExtractUsages", content)
|
|
36
47
|
return result["usages"] if result["success"]
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
require "digest"
|
|
2
|
+
require "json"
|
|
3
|
+
require "set"
|
|
4
|
+
|
|
5
|
+
module ReactManifest
|
|
6
|
+
# The component-symbol -> bundle maps behind react_component bundle resolution.
|
|
7
|
+
#
|
|
8
|
+
# Parsing ux/app needs a V8 context (tens of MB per process), so the generator
|
|
9
|
+
# writes the maps to a JSON sidecar next to the generated manifests and the
|
|
10
|
+
# runtime loads that instead. The sidecar carries a fingerprint of everything the
|
|
11
|
+
# maps are derived from (gem version, config, controller dirs, file paths and
|
|
12
|
+
# contents); a missing, unreadable or mismatching sidecar falls back to parsing.
|
|
13
|
+
module ComponentIndex
|
|
14
|
+
FILENAME = "component_index.json".freeze
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
def path(config)
|
|
19
|
+
File.join(config.abs_manifest_dir, FILENAME)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Sidecar maps when fresh, else freshly parsed ones.
|
|
23
|
+
def maps(config)
|
|
24
|
+
controller_dirs = TreeClassifier.new(config).classify.controller_dirs
|
|
25
|
+
load_fresh(config, controller_dirs) || build(config, controller_dirs)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Write the sidecar (skipped when unchanged). Returns the path.
|
|
29
|
+
def write!(config, controller_dirs)
|
|
30
|
+
dest = path(config)
|
|
31
|
+
json = JSON.generate(serialize(config, controller_dirs, build(config, controller_dirs)))
|
|
32
|
+
return dest if File.exist?(dest) && File.read(dest, encoding: "utf-8") == json
|
|
33
|
+
|
|
34
|
+
FileUtils.mkdir_p(File.dirname(dest))
|
|
35
|
+
File.write(dest, json, encoding: "utf-8")
|
|
36
|
+
dest
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def build(config, controller_dirs)
|
|
40
|
+
symbol_to_bundle = {}
|
|
41
|
+
bundle_own_symbols = Hash.new { |h, k| h[k] = Set.new }
|
|
42
|
+
bundle_files = Hash.new { |h, k| h[k] = [] }
|
|
43
|
+
bundle_dependencies = Hash.new { |h, k| h[k] = Set.new }
|
|
44
|
+
|
|
45
|
+
controller_dirs.each do |ctrl|
|
|
46
|
+
bundle_name = ctrl[:bundle_name]
|
|
47
|
+
files = js_files_in_controller(ctrl[:path], config)
|
|
48
|
+
bundle_files[bundle_name].concat(files)
|
|
49
|
+
isolated = config.isolated_app_dirs.include?(ctrl[:name])
|
|
50
|
+
|
|
51
|
+
files.each do |file_path|
|
|
52
|
+
extract(file_path, :extract_definitions).each do |symbol|
|
|
53
|
+
next unless symbol.match?(/\A[A-Z][A-Za-z0-9_]*\z/)
|
|
54
|
+
|
|
55
|
+
# Keep first writer to ensure deterministic behavior if a symbol is duplicated.
|
|
56
|
+
# Isolated dirs never register into the shared symbol index (see
|
|
57
|
+
# Generator#build_controller_context for the full rationale).
|
|
58
|
+
symbol_to_bundle[symbol] ||= bundle_name unless isolated
|
|
59
|
+
bundle_own_symbols[bundle_name] << symbol
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
bundle_files.each do |bundle_name, files|
|
|
65
|
+
own_symbols = bundle_own_symbols[bundle_name]
|
|
66
|
+
|
|
67
|
+
files.each do |file_path|
|
|
68
|
+
extract(file_path, :extract_usages).each do |symbol|
|
|
69
|
+
# A symbol the bundle defines itself is always satisfied locally —
|
|
70
|
+
# never attribute it to another bundle just because that symbol
|
|
71
|
+
# name happens to collide with something defined elsewhere.
|
|
72
|
+
next if own_symbols.include?(symbol)
|
|
73
|
+
|
|
74
|
+
dep_bundle = symbol_to_bundle[symbol]
|
|
75
|
+
next unless dep_bundle && dep_bundle != bundle_name
|
|
76
|
+
|
|
77
|
+
bundle_dependencies[bundle_name] << dep_bundle
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
{ symbol_to_bundle: symbol_to_bundle, bundle_dependencies: bundle_dependencies }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def load_fresh(config, controller_dirs)
|
|
86
|
+
data = JSON.parse(File.read(path(config), encoding: "utf-8"))
|
|
87
|
+
return unless data["fingerprint"] == fingerprint(config, controller_dirs)
|
|
88
|
+
|
|
89
|
+
bundle_dependencies = Hash.new { |h, k| h[k] = Set.new }
|
|
90
|
+
data["bundle_dependencies"].each { |bundle, deps| bundle_dependencies[bundle] = deps.to_set }
|
|
91
|
+
{ symbol_to_bundle: data["symbol_to_bundle"], bundle_dependencies: bundle_dependencies }
|
|
92
|
+
rescue StandardError
|
|
93
|
+
nil
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def serialize(config, controller_dirs, maps)
|
|
97
|
+
{
|
|
98
|
+
"fingerprint" => fingerprint(config, controller_dirs),
|
|
99
|
+
"symbol_to_bundle" => maps[:symbol_to_bundle],
|
|
100
|
+
"bundle_dependencies" => maps[:bundle_dependencies].transform_values { |deps| deps.to_a.sort }
|
|
101
|
+
}
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Digest of every input the maps depend on. File paths are relative to ux_root so
|
|
105
|
+
# a relocated release dir stays valid; contents (not mtimes) so a checkout that
|
|
106
|
+
# rewrites timestamps cannot make a stale sidecar look fresh or a fresh one stale.
|
|
107
|
+
def fingerprint(config, controller_dirs)
|
|
108
|
+
digest = Digest::SHA256.new
|
|
109
|
+
digest << JSON.generate([VERSION, config.extensions, config.isolated_app_dirs])
|
|
110
|
+
controller_dirs.each do |ctrl|
|
|
111
|
+
digest << "\0" << ctrl[:bundle_name]
|
|
112
|
+
js_files_in_controller(ctrl[:path], config).each do |file|
|
|
113
|
+
digest << "\0" << file.delete_prefix(config.abs_ux_root) << "\0" << File.binread(file)
|
|
114
|
+
rescue SystemCallError
|
|
115
|
+
digest << "\0unreadable"
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
digest.hexdigest
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def js_files_in_controller(dir, config)
|
|
122
|
+
return [] unless Dir.exist?(dir)
|
|
123
|
+
|
|
124
|
+
Dir.glob(File.join(dir, "**", config.extensions_glob))
|
|
125
|
+
.reject { |f| File.directory?(f) }
|
|
126
|
+
.sort
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def extract(file_path, method)
|
|
130
|
+
content = File.read(file_path, encoding: "utf-8")
|
|
131
|
+
SymbolExtractor.public_send(method, content, file_path: file_path)
|
|
132
|
+
rescue Errno::ENOENT, Errno::EACCES, Encoding::InvalidByteSequenceError
|
|
133
|
+
[]
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -56,14 +56,7 @@ module ReactManifest
|
|
|
56
56
|
# in a second pass so that a failure midway does not leave some bundles
|
|
57
57
|
# written and others stale/missing.
|
|
58
58
|
def run!
|
|
59
|
-
classification =
|
|
60
|
-
controller_context = build_controller_context(classification.controller_dirs, classification.shared_dirs)
|
|
61
|
-
|
|
62
|
-
# Phase 1: build all content in memory — no I/O.
|
|
63
|
-
shared_manifest = build_shared(classification.shared_dirs, controller_context[:promoted_files])
|
|
64
|
-
manifests = [shared_manifest] + classification.controller_dirs.map do |ctrl|
|
|
65
|
-
build_controller(ctrl, controller_context)
|
|
66
|
-
end
|
|
59
|
+
classification, manifests = build_manifests
|
|
67
60
|
|
|
68
61
|
migrate_legacy_manifests!
|
|
69
62
|
|
|
@@ -76,10 +69,20 @@ module ReactManifest
|
|
|
76
69
|
expected_filenames = manifests.map { |m| m[:filename] }
|
|
77
70
|
results.concat(remove_orphaned_manifests(expected_filenames))
|
|
78
71
|
|
|
72
|
+
ComponentIndex.write!(@config, classification.controller_dirs) unless @config.dry_run?
|
|
73
|
+
|
|
79
74
|
print_summary(results) if @config.verbose?
|
|
80
75
|
results
|
|
81
76
|
end
|
|
82
77
|
|
|
78
|
+
# Analyse without writing anything. Returns the "declared twice" warnings
|
|
79
|
+
# (cross-bundle top-level const/let/class collisions and always_include
|
|
80
|
+
# double-declares); empty when clean. Backs `rake react_manifest:check`.
|
|
81
|
+
def check
|
|
82
|
+
build_manifests
|
|
83
|
+
@clashes
|
|
84
|
+
end
|
|
85
|
+
|
|
83
86
|
# Remove all AUTO-GENERATED ux_*.js manifests. Silently skips files that
|
|
84
87
|
# disappear between the directory scan and the read (TOCTOU-safe).
|
|
85
88
|
# Returns { removed: N, skipped: N }.
|
|
@@ -99,6 +102,8 @@ module ReactManifest
|
|
|
99
102
|
end
|
|
100
103
|
end
|
|
101
104
|
|
|
105
|
+
FileUtils.rm_f(ComponentIndex.path(@config))
|
|
106
|
+
|
|
102
107
|
{ removed: removed, skipped: skipped }
|
|
103
108
|
end
|
|
104
109
|
|
|
@@ -207,34 +212,121 @@ module ReactManifest
|
|
|
207
212
|
context
|
|
208
213
|
end
|
|
209
214
|
|
|
210
|
-
# Surface the
|
|
211
|
-
#
|
|
212
|
-
#
|
|
213
|
-
#
|
|
214
|
-
#
|
|
215
|
-
# (
|
|
216
|
-
#
|
|
215
|
+
# Surface the collision cases dependency arithmetic cannot resolve. A collided
|
|
216
|
+
# symbol (2+ app-dir definers) has no collision-free page-global home, so a file
|
|
217
|
+
# that defines or transitively depends on one can never be promoted to ux_shared;
|
|
218
|
+
# it is inlined into whichever always_include tag(s) reach it and double-declares:
|
|
219
|
+
#
|
|
220
|
+
# * multi-tag (every page): the same file is carried by 2+ always_include tags.
|
|
221
|
+
# Both tags load on every page, so it double-declares everywhere — whether it
|
|
222
|
+
# DEFINES the collided symbol or merely depends on one (a tainted file).
|
|
223
|
+
# * owner-page: the file is carried by a single always_include tag but also lives
|
|
224
|
+
# in its own controller's manifest, so it double-declares on that owner's page.
|
|
225
|
+
#
|
|
226
|
+
# We cannot auto-fix either (there is no safe shared home for a collided symbol),
|
|
227
|
+
# so we warn with the actionable fix: rename or relocate the symbol.
|
|
217
228
|
def warn_on_always_include_collisions(context, file_owner, file_defs, collided)
|
|
218
229
|
return if collided.empty?
|
|
219
230
|
|
|
220
231
|
bundles = @config.always_include.map(&:to_s).reject(&:empty?).uniq
|
|
221
|
-
bundles.
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
232
|
+
return if bundles.empty?
|
|
233
|
+
|
|
234
|
+
tags_by_file = Hash.new { |h, k| h[k] = [] }
|
|
235
|
+
bundles.each { |b| always_bundle_tag_files(b, context).each { |f| tags_by_file[f] << b } }
|
|
236
|
+
|
|
237
|
+
tags_by_file.each do |file, tags|
|
|
238
|
+
owner = file_owner[file]
|
|
239
|
+
next if tags.size < 2 && (owner.nil? || tags.include?(owner))
|
|
240
|
+
|
|
241
|
+
syms = collided_symbols_reachable(file, file_defs, context, collided).to_a.sort
|
|
242
|
+
next if syms.empty?
|
|
243
|
+
|
|
244
|
+
what = "'#{relative_require_path(file)}' (#{collision_phrase(file, file_defs, collided, syms)})"
|
|
245
|
+
fix = "Rename the symbol or move it to a uniquely-named shared component."
|
|
246
|
+
if tags.size > 1
|
|
247
|
+
report_clash "always_include bundles #{tags.sort.join(', ')} all pull in #{what}. Each of those " \
|
|
248
|
+
"bundles loads on every page via its own <script> tag, so that file loads more " \
|
|
249
|
+
"than once on every page — a duplicate declaration. #{fix}"
|
|
250
|
+
else
|
|
251
|
+
report_clash "always_include bundle '#{tags.first}' pulls in #{what} owned by '#{owner}'. On " \
|
|
252
|
+
"#{owner}'s own page that file loads twice — once via the #{tags.first} tag and " \
|
|
253
|
+
"once via #{owner} — causing a duplicate declaration. #{fix}"
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Classic scripts share ONE global lexical scope, and under Turbolinks one window
|
|
259
|
+
# loads many pages' bundles. Two DIFFERENT files declaring the same top-level
|
|
260
|
+
# const/let/class in different bundles (a controller bundle vs another, or vs
|
|
261
|
+
# ux_shared) throw "Identifier already declared" and the later bundle is rejected.
|
|
262
|
+
# Works from the built manifests so a promoted file counts once (in ux_shared) and
|
|
263
|
+
# one file inlined into two bundles (the existing double-declare case) is not
|
|
264
|
+
# reported here: it takes two distinct declaring files across 2+ bundles.
|
|
265
|
+
def warn_on_cross_bundle_lexical_collisions(manifests, classification)
|
|
266
|
+
file_for = {}
|
|
267
|
+
(classification.shared_dirs + classification.controller_dirs).each do |d|
|
|
268
|
+
js_files_in(d[:path]).each { |f| file_for[relative_require_path(f)] = f }
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
bundles_by_name_file = Hash.new { |h, name| h[name] = Hash.new { |h2, f| h2[f] = Set.new } }
|
|
272
|
+
bindings = Hash.new { |h, f| h[f] = ComponentIndex.extract(f, :extract_lexical_bindings) }
|
|
273
|
+
manifests.each do |m|
|
|
274
|
+
bundle = File.basename(m[:filename], ".js")
|
|
275
|
+
m[:content].scan(%r{^//= require (\S+)$}) do |(req)|
|
|
276
|
+
file = file_for[req] or next
|
|
277
|
+
bindings[file].each { |name| bundles_by_name_file[name][file] << bundle }
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
bundles_by_name_file.sort.each do |name, files|
|
|
282
|
+
next if files.size < 2 || files.values.reduce(:|).size < 2
|
|
283
|
+
|
|
284
|
+
where = files.map { |f, bs| "#{relative_require_path(f)} (#{bs.to_a.sort.join(', ')})" }.join("; ")
|
|
285
|
+
report_clash "Top-level binding '#{name}' is declared by different files in different bundles: #{where}. " \
|
|
286
|
+
"Under Turbolinks these share one global scope, so loading both bundles throws " \
|
|
287
|
+
"\"Identifier '#{name}' has already been declared\" and rejects the second. " \
|
|
288
|
+
"Move it inside the component body, rename it, or move the file into a shared directory."
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# "defines collided symbol X" for a direct definer, "depends on ..." for a tainted file.
|
|
293
|
+
def collision_phrase(file, file_defs, collided, syms)
|
|
294
|
+
relation = (file_defs.fetch(file, Set.new) & collided).empty? ? "depends on" : "defines"
|
|
295
|
+
"#{relation} collided symbol#{'s' if syms.size > 1} #{syms.join(', ')}"
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# Every file an always_include bundle's tag delivers: its own non-promoted files
|
|
299
|
+
# plus the foreign dependency files it inlines. Used to detect a file carried by
|
|
300
|
+
# more than one tag (double-loaded on every page).
|
|
301
|
+
def always_bundle_tag_files(always_bundle, context)
|
|
302
|
+
own = context[:bundle_files].fetch(always_bundle, [])
|
|
303
|
+
.reject { |f| context[:promoted_files].include?(f) }
|
|
304
|
+
own + always_include_foreign_files(always_bundle, context).to_a
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
# Collided symbols a file defines or transitively depends on: follows file_uses →
|
|
308
|
+
# canonical definer file, collecting every collided symbol reached along the way.
|
|
309
|
+
def collided_symbols_reachable(file, file_defs, context, collided)
|
|
310
|
+
file_uses = context[:file_uses]
|
|
311
|
+
sym2file = context[:symbol_to_file]
|
|
312
|
+
result = Set.new
|
|
313
|
+
seen = Set.new
|
|
314
|
+
stack = [file]
|
|
315
|
+
|
|
316
|
+
until stack.empty?
|
|
317
|
+
current = stack.pop
|
|
318
|
+
next if seen.include?(current)
|
|
319
|
+
|
|
320
|
+
seen << current
|
|
321
|
+
result.merge(file_defs.fetch(current, Set.new) & collided)
|
|
322
|
+
file_uses.fetch(current, Set.new).each do |sym|
|
|
323
|
+
result << sym if collided.include?(sym)
|
|
324
|
+
dep = sym2file[sym]
|
|
325
|
+
stack << dep if dep && !seen.include?(dep)
|
|
236
326
|
end
|
|
237
327
|
end
|
|
328
|
+
|
|
329
|
+
result
|
|
238
330
|
end
|
|
239
331
|
|
|
240
332
|
# Files an always_include bundle's tag delivers from OTHER bundles: under
|
|
@@ -371,7 +463,7 @@ module ReactManifest
|
|
|
371
463
|
|
|
372
464
|
files.each do |file_path|
|
|
373
465
|
file_owner[file_path] = bundle_name
|
|
374
|
-
|
|
466
|
+
ComponentIndex.extract(file_path, :extract_definitions).each do |sym|
|
|
375
467
|
next unless sym.match?(/\A[A-Z][A-Za-z0-9_]*\z/)
|
|
376
468
|
|
|
377
469
|
file_defs[file_path] << sym
|
|
@@ -398,7 +490,7 @@ module ReactManifest
|
|
|
398
490
|
def record_symbol_usages(bundle_files, shared_dirs, file_uses, symbol_used_by_bundles)
|
|
399
491
|
bundle_files.each do |bundle_name, files|
|
|
400
492
|
files.each do |file_path|
|
|
401
|
-
|
|
493
|
+
ComponentIndex.extract(file_path, :extract_usages).each do |sym|
|
|
402
494
|
file_uses[file_path] << sym
|
|
403
495
|
symbol_used_by_bundles[sym] << bundle_name
|
|
404
496
|
end
|
|
@@ -407,7 +499,7 @@ module ReactManifest
|
|
|
407
499
|
|
|
408
500
|
shared_dirs.each do |dir|
|
|
409
501
|
js_files_in(dir[:path]).each do |file_path|
|
|
410
|
-
|
|
502
|
+
ComponentIndex.extract(file_path, :extract_usages).each do |sym|
|
|
411
503
|
symbol_used_by_bundles[sym] << @config.shared_bundle
|
|
412
504
|
end
|
|
413
505
|
end
|
|
@@ -424,7 +516,7 @@ module ReactManifest
|
|
|
424
516
|
external_js_files_in(abs_root).each do |file_path|
|
|
425
517
|
req_path = relative_require_path(file_path)
|
|
426
518
|
warn_on_external_controller_references(file_path, symbol_to_bundle)
|
|
427
|
-
|
|
519
|
+
ComponentIndex.extract(file_path, :extract_definitions).each do |sym|
|
|
428
520
|
external_symbol_to_require[sym] ||= req_path
|
|
429
521
|
end
|
|
430
522
|
end
|
|
@@ -567,6 +659,26 @@ module ReactManifest
|
|
|
567
659
|
end
|
|
568
660
|
end
|
|
569
661
|
|
|
662
|
+
# Phase 1: build all content in memory — no I/O besides reading sources.
|
|
663
|
+
def build_manifests
|
|
664
|
+
@clashes = []
|
|
665
|
+
classification = @classifier.classify
|
|
666
|
+
controller_context = build_controller_context(classification.controller_dirs, classification.shared_dirs)
|
|
667
|
+
|
|
668
|
+
shared_manifest = build_shared(classification.shared_dirs, controller_context[:promoted_files])
|
|
669
|
+
manifests = [shared_manifest] + classification.controller_dirs.map do |ctrl|
|
|
670
|
+
build_controller(ctrl, controller_context)
|
|
671
|
+
end
|
|
672
|
+
|
|
673
|
+
warn_on_cross_bundle_lexical_collisions(manifests, classification)
|
|
674
|
+
[classification, manifests]
|
|
675
|
+
end
|
|
676
|
+
|
|
677
|
+
def report_clash(message)
|
|
678
|
+
@clashes << message
|
|
679
|
+
log_warn message
|
|
680
|
+
end
|
|
681
|
+
|
|
570
682
|
# --------------------------------------------------------------- write
|
|
571
683
|
|
|
572
684
|
def write_manifest(filename, content)
|
|
@@ -689,20 +801,6 @@ module ReactManifest
|
|
|
689
801
|
strip_asset_extension(rel)
|
|
690
802
|
end
|
|
691
803
|
|
|
692
|
-
def extract_defined_symbols(file_path)
|
|
693
|
-
content = File.read(file_path, encoding: "utf-8")
|
|
694
|
-
SymbolExtractor.extract_definitions(content, file_path: file_path)
|
|
695
|
-
rescue Errno::ENOENT, Errno::EACCES, Encoding::InvalidByteSequenceError
|
|
696
|
-
[]
|
|
697
|
-
end
|
|
698
|
-
|
|
699
|
-
def extract_used_component_symbols(file_path)
|
|
700
|
-
content = File.read(file_path, encoding: "utf-8")
|
|
701
|
-
SymbolExtractor.extract_usages(content, file_path: file_path)
|
|
702
|
-
rescue Errno::ENOENT, Errno::EACCES, Encoding::InvalidByteSequenceError
|
|
703
|
-
[]
|
|
704
|
-
end
|
|
705
|
-
|
|
706
804
|
def external_js_files_in(dir)
|
|
707
805
|
return [] unless Dir.exist?(dir)
|
|
708
806
|
|
|
@@ -723,7 +821,7 @@ module ReactManifest
|
|
|
723
821
|
end
|
|
724
822
|
|
|
725
823
|
def warn_on_external_controller_references(file_path, symbol_to_bundle)
|
|
726
|
-
|
|
824
|
+
ComponentIndex.extract(file_path, :extract_usages).each do |sym|
|
|
727
825
|
dep_bundle = symbol_to_bundle[sym]
|
|
728
826
|
next unless dep_bundle
|
|
729
827
|
|
|
@@ -30,6 +30,11 @@ module ReactManifest
|
|
|
30
30
|
result.appended
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
+
# The component-index sidecar written next to the manifests (see ComponentIndex).
|
|
34
|
+
def json_pattern
|
|
35
|
+
File.join(File.dirname(pattern), "*.json")
|
|
36
|
+
end
|
|
37
|
+
|
|
33
38
|
# Path (relative to Rails.root) that should be ignored, e.g.
|
|
34
39
|
# "app/assets/javascripts/ux_manifests/*.js".
|
|
35
40
|
def pattern
|
|
@@ -46,13 +51,14 @@ module ReactManifest
|
|
|
46
51
|
|
|
47
52
|
def ensure_gitignore_entry
|
|
48
53
|
path = gitignore_path
|
|
49
|
-
line = pattern
|
|
50
54
|
content = File.exist?(path) ? File.read(path, encoding: "utf-8") : ""
|
|
51
|
-
|
|
55
|
+
lines = content.split("\n").map(&:strip)
|
|
56
|
+
missing = [pattern, json_pattern] - lines
|
|
57
|
+
return false if missing.empty?
|
|
52
58
|
|
|
53
59
|
prefix = content.empty? || content.end_with?("\n") ? "" : "\n"
|
|
54
|
-
File.open(path, "a") { |f| f.write("#{prefix}#{
|
|
55
|
-
log_info "Added #{
|
|
60
|
+
File.open(path, "a") { |f| f.write("#{prefix}#{missing.join("\n")}\n") }
|
|
61
|
+
log_info "Added #{missing.join(', ')} to .gitignore"
|
|
56
62
|
true
|
|
57
63
|
rescue Errno::EACCES, Errno::ENOENT => e
|
|
58
64
|
log_warn "Could not update .gitignore: #{e.message}"
|
|
@@ -44,6 +44,20 @@ module ReactManifest
|
|
|
44
44
|
regex_extract_definitions(content)
|
|
45
45
|
end
|
|
46
46
|
|
|
47
|
+
# Top-level const/let/class names of any casing (destructured ones included).
|
|
48
|
+
# These are the only declarations that throw when two classic scripts share a
|
|
49
|
+
# global lexical scope; var and function redeclaration is legal.
|
|
50
|
+
def extract_lexical_bindings(content, file_path: nil)
|
|
51
|
+
return [] unless content
|
|
52
|
+
|
|
53
|
+
if ast_engine_available?
|
|
54
|
+
result = AstExtractor.extract_lexical_bindings(content, file_path: file_path)
|
|
55
|
+
return result if result
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
regex_extract_lexical_bindings(content)
|
|
59
|
+
end
|
|
60
|
+
|
|
47
61
|
def extract_usages(content, file_path: nil)
|
|
48
62
|
return [] unless content
|
|
49
63
|
|
|
@@ -75,6 +89,19 @@ module ReactManifest
|
|
|
75
89
|
symbols.uniq
|
|
76
90
|
end
|
|
77
91
|
|
|
92
|
+
# ponytail: column-0 declarations only, flat destructuring; the AST path is exact.
|
|
93
|
+
def regex_extract_lexical_bindings(content)
|
|
94
|
+
names = content.scan(/^(?:export\s+)?class\s+([A-Za-z_$][\w$]*)/).flatten
|
|
95
|
+
content.scan(/^(?:export\s+)?(?:const|let)\s+([A-Za-z_$][\w$]*)/) { |m| names << m[0] }
|
|
96
|
+
content.scan(/^(?:export\s+)?(?:const|let)\s*[{\[]([^}\]]*)[}\]]\s*=[^=]/) do |m|
|
|
97
|
+
m[0].split(",").each do |part|
|
|
98
|
+
name = part.split("=").first.split(":").last.to_s.sub("...", "").strip
|
|
99
|
+
names << name if name.match?(/\A[A-Za-z_$][\w$]*\z/)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
names.uniq
|
|
103
|
+
end
|
|
104
|
+
|
|
78
105
|
def regex_extract_usages(content)
|
|
79
106
|
local_syms = Set.new
|
|
80
107
|
DEFINITION_PATTERNS.each { |p| content.scan(p) { |m| local_syms << m[0] } }
|