react-manifest-rails 0.2.34 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +87 -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/configuration.rb +19 -0
- data/lib/react_manifest/generator.rb +477 -83
- data/lib/react_manifest/gitignore_patcher.rb +81 -0
- data/lib/react_manifest/railtie.rb +6 -0
- 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 +42 -5
- data/lib/react_manifest.rb +31 -75
- data/tasks/react_manifest.rake +37 -0
- metadata +17 -15
|
@@ -42,20 +42,21 @@ module ReactManifest
|
|
|
42
42
|
@classifier = TreeClassifier.new(config)
|
|
43
43
|
end
|
|
44
44
|
|
|
45
|
+
# After a run (or on demand), maps a promoted file's require path to the set of
|
|
46
|
+
# bundle names that forced its promotion. Used by react_manifest:analyze.
|
|
47
|
+
def promotion_reasons
|
|
48
|
+
classification = @classifier.classify
|
|
49
|
+
build_controller_context(classification.controller_dirs, classification.shared_dirs)
|
|
50
|
+
@promotion_reasons || {}
|
|
51
|
+
end
|
|
52
|
+
|
|
45
53
|
# Run full generation. Returns array of {path:, status:} hashes.
|
|
46
54
|
#
|
|
47
55
|
# All manifest content is built first (no filesystem writes), then written
|
|
48
56
|
# in a second pass so that a failure midway does not leave some bundles
|
|
49
57
|
# written and others stale/missing.
|
|
50
58
|
def run!
|
|
51
|
-
classification =
|
|
52
|
-
controller_context = build_controller_context(classification.controller_dirs)
|
|
53
|
-
|
|
54
|
-
# Phase 1: build all content in memory — no I/O.
|
|
55
|
-
shared_manifest = build_shared(classification.shared_dirs)
|
|
56
|
-
manifests = [shared_manifest] + classification.controller_dirs.map do |ctrl|
|
|
57
|
-
build_controller(ctrl, controller_context)
|
|
58
|
-
end
|
|
59
|
+
classification, manifests = build_manifests
|
|
59
60
|
|
|
60
61
|
migrate_legacy_manifests!
|
|
61
62
|
|
|
@@ -68,10 +69,20 @@ module ReactManifest
|
|
|
68
69
|
expected_filenames = manifests.map { |m| m[:filename] }
|
|
69
70
|
results.concat(remove_orphaned_manifests(expected_filenames))
|
|
70
71
|
|
|
72
|
+
ComponentIndex.write!(@config, classification.controller_dirs) unless @config.dry_run?
|
|
73
|
+
|
|
71
74
|
print_summary(results) if @config.verbose?
|
|
72
75
|
results
|
|
73
76
|
end
|
|
74
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
|
+
|
|
75
86
|
# Remove all AUTO-GENERATED ux_*.js manifests. Silently skips files that
|
|
76
87
|
# disappear between the directory scan and the read (TOCTOU-safe).
|
|
77
88
|
# Returns { removed: N, skipped: N }.
|
|
@@ -91,6 +102,8 @@ module ReactManifest
|
|
|
91
102
|
end
|
|
92
103
|
end
|
|
93
104
|
|
|
105
|
+
FileUtils.rm_f(ComponentIndex.path(@config))
|
|
106
|
+
|
|
94
107
|
{ removed: removed, skipped: skipped }
|
|
95
108
|
end
|
|
96
109
|
|
|
@@ -98,10 +111,9 @@ module ReactManifest
|
|
|
98
111
|
|
|
99
112
|
# ------------------------------------------------------------------ shared
|
|
100
113
|
|
|
101
|
-
def build_shared(shared_dirs)
|
|
114
|
+
def build_shared(shared_dirs, promoted_files = Set.new)
|
|
102
115
|
lines = header_lines
|
|
103
|
-
reqs = shared_dirs
|
|
104
|
-
.flat_map { |d| js_files_in(d[:path]) }
|
|
116
|
+
reqs = (shared_dirs.flat_map { |d| js_files_in(d[:path]) } + promoted_files.to_a)
|
|
105
117
|
.map { |f| normalize_require_path(relative_require_path(f)) }
|
|
106
118
|
.uniq
|
|
107
119
|
.sort
|
|
@@ -119,13 +131,21 @@ module ReactManifest
|
|
|
119
131
|
|
|
120
132
|
def build_controller(ctrl, controller_context)
|
|
121
133
|
lines = header_lines
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
134
|
+
promoted = controller_context[:promoted_files]
|
|
135
|
+
bundle_name = ctrl[:bundle_name]
|
|
136
|
+
# always_include bundles are NOT inlined here: resolve_bundles /
|
|
137
|
+
# react_component deliver them on every page via their own <script> tag,
|
|
138
|
+
# so inlining would load the same file twice and double-declare.
|
|
139
|
+
dep_requires = if @config.auto_shared?
|
|
140
|
+
promoted_aware_dependency_requires(bundle_name, controller_context)
|
|
141
|
+
else
|
|
142
|
+
controller_dependency_requires(bundle_name, controller_context)
|
|
143
|
+
end
|
|
144
|
+
ext_reqs = controller_context[:external_requires].fetch(bundle_name, Set.new).to_a.sort
|
|
145
|
+
|
|
146
|
+
files = js_files_in(ctrl[:path]).reject { |f| promoted.include?(f) }
|
|
127
147
|
own_requires = files.map { |f| relative_require_path(f) }
|
|
128
|
-
all_requires = (
|
|
148
|
+
all_requires = (dep_requires + ext_reqs + own_requires).uniq
|
|
129
149
|
|
|
130
150
|
if all_requires.empty?
|
|
131
151
|
lines << "// (no JSX files found in #{ctrl[:name]}/)"
|
|
@@ -136,66 +156,390 @@ module ReactManifest
|
|
|
136
156
|
{ filename: "#{ctrl[:bundle_name]}.js", content: "#{lines.join("\n")}\n" }
|
|
137
157
|
end
|
|
138
158
|
|
|
139
|
-
def build_controller_context(controller_dirs)
|
|
159
|
+
def build_controller_context(controller_dirs, shared_dirs)
|
|
160
|
+
bundle_files, file_owner, file_defs, symbol_to_bundle, symbol_to_file,
|
|
161
|
+
symbol_definers, bundle_own_symbols = index_controller_symbols(controller_dirs)
|
|
162
|
+
|
|
163
|
+
file_uses = Hash.new { |h, k| h[k] = Set.new }
|
|
164
|
+
symbol_used_by_bundles = Hash.new { |h, k| h[k] = Set.new }
|
|
165
|
+
record_symbol_usages(bundle_files, shared_dirs, file_uses, symbol_used_by_bundles)
|
|
166
|
+
|
|
167
|
+
collided = symbol_definers.each_with_object(Set.new) do |(sym, files), acc|
|
|
168
|
+
acc << sym if files.size >= 2
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
external_symbol_to_require = index_external_symbols(symbol_to_bundle)
|
|
172
|
+
|
|
173
|
+
dependencies, external_requires = build_dependency_graph(
|
|
174
|
+
bundle_files, bundle_own_symbols, file_uses, symbol_to_bundle, external_symbol_to_require
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
symbol_index = {
|
|
178
|
+
file_defs: file_defs,
|
|
179
|
+
symbol_to_file: symbol_to_file,
|
|
180
|
+
symbol_used_by_bundles: symbol_used_by_bundles
|
|
181
|
+
}
|
|
182
|
+
promoted_files =
|
|
183
|
+
if @config.auto_shared?
|
|
184
|
+
compute_promoted_files(file_owner, file_uses, symbol_index, collided)
|
|
185
|
+
else
|
|
186
|
+
Set.new
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
context = {
|
|
190
|
+
bundle_files: bundle_files,
|
|
191
|
+
dependencies: dependencies,
|
|
192
|
+
external_requires: external_requires,
|
|
193
|
+
promoted_files: promoted_files,
|
|
194
|
+
file_uses: file_uses,
|
|
195
|
+
symbol_to_file: symbol_to_file,
|
|
196
|
+
bundle_own_symbols: bundle_own_symbols
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
# Bundles guaranteed loaded on every page by always_include (used to exclude
|
|
200
|
+
# them from the legacy whole-bundle inline path and to self-exclude an
|
|
201
|
+
# always_include bundle's own manifest from the skip below).
|
|
202
|
+
context[:always_loaded_bundles] = compute_always_loaded_bundles(dependencies)
|
|
203
|
+
# Symbols actually delivered on every page by the always_include <script>
|
|
204
|
+
# tag(s) — derived from the real files those tags carry, NOT from whole dep
|
|
205
|
+
# bundles (which would over-claim symbols the tag does not load and cause a
|
|
206
|
+
# consumer to skip a definer it still needs). Unused by the legacy path.
|
|
207
|
+
context[:always_provided_symbols] =
|
|
208
|
+
@config.auto_shared? ? compute_always_provided_symbols(context, file_defs) : Set.new
|
|
209
|
+
|
|
210
|
+
warn_on_always_include_collisions(context, file_owner, file_defs, collided)
|
|
211
|
+
|
|
212
|
+
context
|
|
213
|
+
end
|
|
214
|
+
|
|
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.
|
|
228
|
+
def warn_on_always_include_collisions(context, file_owner, file_defs, collided)
|
|
229
|
+
return if collided.empty?
|
|
230
|
+
|
|
231
|
+
bundles = @config.always_include.map(&:to_s).reject(&:empty?).uniq
|
|
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)
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
result
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# Files an always_include bundle's tag delivers from OTHER bundles: under
|
|
333
|
+
# auto_shared, the exact non-promoted dependency files it inlines; in legacy
|
|
334
|
+
# mode, every file of the dependency bundles it inlines wholesale.
|
|
335
|
+
def always_include_foreign_files(always_bundle, context)
|
|
336
|
+
if @config.auto_shared?
|
|
337
|
+
promoted_aware_dependency_files(always_bundle, context, Set.new)
|
|
338
|
+
else
|
|
339
|
+
transitive_dependencies(always_bundle, context[:dependencies])
|
|
340
|
+
.flat_map { |dep| context[:bundle_files].fetch(dep, []) }
|
|
341
|
+
end
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
# Symbols the always_include tag(s) put on every page: for each always_include
|
|
345
|
+
# bundle, the symbols defined by its own non-promoted files plus the canonical
|
|
346
|
+
# dependency files it inlines (its manifest's exact file closure).
|
|
347
|
+
def compute_always_provided_symbols(context, file_defs)
|
|
348
|
+
bundles = @config.always_include.map(&:to_s).reject(&:empty?).uniq
|
|
349
|
+
return Set.new if bundles.empty?
|
|
350
|
+
|
|
351
|
+
always_files = Set.new
|
|
352
|
+
bundles.each do |b|
|
|
353
|
+
context[:bundle_files].fetch(b, []).each do |file|
|
|
354
|
+
always_files << file unless context[:promoted_files].include?(file)
|
|
355
|
+
end
|
|
356
|
+
promoted_aware_dependency_files(b, context, Set.new).each { |file| always_files << file }
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
always_files.each_with_object(Set.new) { |file, syms| syms.merge(file_defs.fetch(file, Set.new)) }
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
# Files that can never be promoted to ux_shared: any file defining a "collided"
|
|
363
|
+
# symbol (a name with 2+ app-dir definers — promoting one copy into the always-
|
|
364
|
+
# loaded shared bundle would double-declare against the other copy on its page),
|
|
365
|
+
# plus any file that transitively depends on such a file (it can't live in shared
|
|
366
|
+
# if a dependency can't). These fall back to legacy per-consumer inlining.
|
|
367
|
+
def unpromotable_files(file_owner, file_defs, file_uses, symbol_to_file, collided)
|
|
368
|
+
unpromotable = Set.new
|
|
369
|
+
file_owner.each_key do |file_path|
|
|
370
|
+
unpromotable << file_path if file_defs[file_path].any? { |s| collided.include?(s) }
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
loop do
|
|
374
|
+
added = false
|
|
375
|
+
file_owner.each_key do |file_path|
|
|
376
|
+
next if unpromotable.include?(file_path)
|
|
377
|
+
|
|
378
|
+
taints = file_uses.fetch(file_path, Set.new).any? do |sym|
|
|
379
|
+
dep = symbol_to_file[sym]
|
|
380
|
+
dep && unpromotable.include?(dep)
|
|
381
|
+
end
|
|
382
|
+
if taints
|
|
383
|
+
unpromotable << file_path
|
|
384
|
+
added = true
|
|
385
|
+
end
|
|
386
|
+
end
|
|
387
|
+
break unless added
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
unpromotable
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
# A controller file is promoted to the shared bundle when a symbol it canonically
|
|
394
|
+
# defines is used by a bundle other than its owner, then transitively for anything
|
|
395
|
+
# a promoted file depends on. Collided/tainted files (see unpromotable_files) are
|
|
396
|
+
# never promoted. Records why each file was promoted for react_manifest:analyze.
|
|
397
|
+
#
|
|
398
|
+
# symbol_index bundles {file_defs:, symbol_to_file:, symbol_used_by_bundles:} into
|
|
399
|
+
# one param so this stays under Metrics/ParameterLists (rubocop flags 6 positional
|
|
400
|
+
# args) without changing the underlying algorithm.
|
|
401
|
+
def compute_promoted_files(file_owner, file_uses, symbol_index, collided)
|
|
402
|
+
file_defs = symbol_index[:file_defs]
|
|
403
|
+
symbol_to_file = symbol_index[:symbol_to_file]
|
|
404
|
+
symbol_used_by_bundles = symbol_index[:symbol_used_by_bundles]
|
|
405
|
+
unpromotable = unpromotable_files(file_owner, file_defs, file_uses, symbol_to_file, collided)
|
|
406
|
+
promoted = Set.new
|
|
407
|
+
reasons = Hash.new { |h, k| h[k] = Set.new }
|
|
408
|
+
|
|
409
|
+
file_owner.each do |file_path, owner|
|
|
410
|
+
next if unpromotable.include?(file_path)
|
|
411
|
+
|
|
412
|
+
forcing = Set.new
|
|
413
|
+
file_defs[file_path].each do |sym|
|
|
414
|
+
# canonical-definer guard: only the file that canonically defines the
|
|
415
|
+
# symbol is eligible (resolves collisions to one file; excludes isolated
|
|
416
|
+
# dirs, which are never registered in symbol_to_file).
|
|
417
|
+
next unless symbol_to_file[sym] == file_path
|
|
418
|
+
|
|
419
|
+
(symbol_used_by_bundles[sym] - [owner]).each { |b| forcing << b }
|
|
420
|
+
end
|
|
421
|
+
next if forcing.empty?
|
|
422
|
+
|
|
423
|
+
promoted << file_path
|
|
424
|
+
reasons[relative_require_path(file_path)] = forcing
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
worklist = promoted.to_a
|
|
428
|
+
until worklist.empty?
|
|
429
|
+
current = worklist.pop
|
|
430
|
+
file_uses.fetch(current, Set.new).each do |sym|
|
|
431
|
+
dep_file = symbol_to_file[sym]
|
|
432
|
+
next unless dep_file
|
|
433
|
+
next if unpromotable.include?(dep_file)
|
|
434
|
+
next if promoted.include?(dep_file)
|
|
435
|
+
|
|
436
|
+
promoted << dep_file
|
|
437
|
+
reasons[relative_require_path(dep_file)] << "(transitive)"
|
|
438
|
+
worklist << dep_file
|
|
439
|
+
end
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
@promotion_reasons = reasons.transform_values(&:to_a)
|
|
443
|
+
promoted
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
# Indexes each controller dir's files and the PascalCase symbols they define.
|
|
447
|
+
# Returns [bundle_files, file_owner, file_defs, symbol_to_bundle, symbol_to_file,
|
|
448
|
+
# symbol_definers, bundle_own_symbols].
|
|
449
|
+
def index_controller_symbols(controller_dirs)
|
|
140
450
|
bundle_files = {}
|
|
451
|
+
file_owner = {}
|
|
452
|
+
file_defs = Hash.new { |h, k| h[k] = Set.new }
|
|
141
453
|
symbol_to_bundle = {}
|
|
454
|
+
symbol_to_file = {}
|
|
455
|
+
symbol_definers = Hash.new { |h, k| h[k] = Set.new }
|
|
142
456
|
bundle_own_symbols = Hash.new { |h, k| h[k] = Set.new }
|
|
143
|
-
external_symbol_to_require = {}
|
|
144
|
-
dependencies = Hash.new { |h, k| h[k] = Set.new }
|
|
145
|
-
external_requires = Hash.new { |h, k| h[k] = Set.new }
|
|
146
457
|
|
|
147
458
|
controller_dirs.each do |ctrl|
|
|
148
|
-
# Index controller-defined symbols for cross-app detection
|
|
149
459
|
bundle_name = ctrl[:bundle_name]
|
|
150
460
|
files = js_files_in(ctrl[:path])
|
|
151
461
|
bundle_files[bundle_name] = files
|
|
152
|
-
|
|
153
462
|
isolated = @config.isolated_app_dirs.include?(ctrl[:name])
|
|
154
463
|
|
|
155
464
|
files.each do |file_path|
|
|
156
|
-
|
|
465
|
+
file_owner[file_path] = bundle_name
|
|
466
|
+
ComponentIndex.extract(file_path, :extract_definitions).each do |sym|
|
|
157
467
|
next unless sym.match?(/\A[A-Z][A-Za-z0-9_]*\z/)
|
|
158
468
|
|
|
159
|
-
|
|
160
|
-
#
|
|
161
|
-
#
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
469
|
+
file_defs[file_path] << sym
|
|
470
|
+
# Isolated dirs never register into the shared symbol index, so no
|
|
471
|
+
# other bundle can ever be inferred to depend on them.
|
|
472
|
+
unless isolated
|
|
473
|
+
symbol_to_bundle[sym] ||= bundle_name
|
|
474
|
+
symbol_to_file[sym] ||= file_path
|
|
475
|
+
symbol_definers[sym] << file_path
|
|
476
|
+
end
|
|
165
477
|
bundle_own_symbols[bundle_name] << sym
|
|
166
478
|
end
|
|
167
479
|
end
|
|
168
480
|
end
|
|
169
481
|
|
|
170
|
-
|
|
482
|
+
[bundle_files, file_owner, file_defs, symbol_to_bundle, symbol_to_file,
|
|
483
|
+
symbol_definers, bundle_own_symbols]
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
# Records, per controller file and per bundle, which symbols are used —
|
|
487
|
+
# plus a pseudo-bundle entry (config.shared_bundle) for any controller
|
|
488
|
+
# symbol referenced from a shared-dir file (shared code loads on every
|
|
489
|
+
# page, so that symbol's owning file must be treated as externally used).
|
|
490
|
+
def record_symbol_usages(bundle_files, shared_dirs, file_uses, symbol_used_by_bundles)
|
|
491
|
+
bundle_files.each do |bundle_name, files|
|
|
492
|
+
files.each do |file_path|
|
|
493
|
+
ComponentIndex.extract(file_path, :extract_usages).each do |sym|
|
|
494
|
+
file_uses[file_path] << sym
|
|
495
|
+
symbol_used_by_bundles[sym] << bundle_name
|
|
496
|
+
end
|
|
497
|
+
end
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
shared_dirs.each do |dir|
|
|
501
|
+
js_files_in(dir[:path]).each do |file_path|
|
|
502
|
+
ComponentIndex.extract(file_path, :extract_usages).each do |sym|
|
|
503
|
+
symbol_used_by_bundles[sym] << @config.shared_bundle
|
|
504
|
+
end
|
|
505
|
+
end
|
|
506
|
+
end
|
|
507
|
+
end
|
|
508
|
+
|
|
509
|
+
# Indexes symbols from external_roots dirs, then lets explicit
|
|
510
|
+
# external_providers win over scanned roots on symbol conflicts.
|
|
511
|
+
def index_external_symbols(symbol_to_bundle)
|
|
512
|
+
external_symbol_to_require = {}
|
|
513
|
+
|
|
171
514
|
@config.external_roots.each do |root_path|
|
|
172
515
|
abs_root = abs_external_root(root_path)
|
|
173
516
|
external_js_files_in(abs_root).each do |file_path|
|
|
174
517
|
req_path = relative_require_path(file_path)
|
|
175
|
-
|
|
176
518
|
warn_on_external_controller_references(file_path, symbol_to_bundle)
|
|
177
|
-
|
|
178
|
-
extract_defined_symbols(file_path).each do |sym|
|
|
519
|
+
ComponentIndex.extract(file_path, :extract_definitions).each do |sym|
|
|
179
520
|
external_symbol_to_require[sym] ||= req_path
|
|
180
521
|
end
|
|
181
522
|
end
|
|
182
523
|
end
|
|
183
524
|
|
|
184
|
-
# Explicit external_providers win over scanned roots on symbol conflicts
|
|
185
525
|
@config.external_providers.each do |sym, req_path|
|
|
186
526
|
external_symbol_to_require[sym] = req_path
|
|
187
527
|
end
|
|
188
528
|
|
|
189
|
-
|
|
529
|
+
external_symbol_to_require
|
|
530
|
+
end
|
|
531
|
+
|
|
532
|
+
# Cross-app dependency graph + external requires (dependencies kept for
|
|
533
|
+
# reporting). Returns [dependencies, external_requires].
|
|
534
|
+
def build_dependency_graph(bundle_files, bundle_own_symbols, file_uses, symbol_to_bundle,
|
|
535
|
+
external_symbol_to_require)
|
|
536
|
+
dependencies = Hash.new { |h, k| h[k] = Set.new }
|
|
537
|
+
external_requires = Hash.new { |h, k| h[k] = Set.new }
|
|
538
|
+
|
|
190
539
|
bundle_files.each do |bundle_name, files|
|
|
191
540
|
own_symbols = bundle_own_symbols[bundle_name]
|
|
192
|
-
|
|
193
541
|
files.each do |file_path|
|
|
194
|
-
|
|
195
|
-
# A symbol the bundle defines itself (in any of its own files) is
|
|
196
|
-
# always satisfied locally — never attribute it to another bundle
|
|
197
|
-
# or an external provider just because that symbol name happens
|
|
198
|
-
# to collide with something defined elsewhere.
|
|
542
|
+
file_uses[file_path].each do |sym|
|
|
199
543
|
next if own_symbols.include?(sym)
|
|
200
544
|
|
|
201
545
|
dep_bundle = symbol_to_bundle[sym]
|
|
@@ -207,24 +551,82 @@ module ReactManifest
|
|
|
207
551
|
end
|
|
208
552
|
end
|
|
209
553
|
|
|
210
|
-
|
|
554
|
+
[dependencies, external_requires]
|
|
555
|
+
end
|
|
211
556
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
557
|
+
# Under auto_shared, single-definer cross-used files live in ux_shared, so a
|
|
558
|
+
# controller only needs the canonical files for the symbols it uses that are NOT
|
|
559
|
+
# promoted (collided names kept bundle-local, plus their transitive needs).
|
|
560
|
+
# File-level + transitive so we never over-inline a whole dependency bundle
|
|
561
|
+
# (which could double-declare against an always_include bundle's own copy).
|
|
562
|
+
def promoted_aware_dependency_requires(bundle_name, controller_context)
|
|
563
|
+
# Symbols already on every page via an always_include tag. Skipped so a
|
|
564
|
+
# controller never inlines a (possibly colliding) second definer. Disabled
|
|
565
|
+
# when building an always_include bundle's own manifest (it needs its deps).
|
|
566
|
+
always_provided = always_provided_for(bundle_name, controller_context)
|
|
567
|
+
promoted_aware_dependency_files(bundle_name, controller_context, always_provided)
|
|
568
|
+
.map { |f| relative_require_path(f) }
|
|
569
|
+
.uniq
|
|
570
|
+
.sort
|
|
571
|
+
end
|
|
572
|
+
|
|
573
|
+
# Absolute paths of the non-promoted files a bundle must inline: for each
|
|
574
|
+
# symbol it uses (transitively) that is neither its own, promoted to shared,
|
|
575
|
+
# nor +always_provided+ on the page, the canonical (first-writer) definer file.
|
|
576
|
+
def promoted_aware_dependency_files(bundle_name, controller_context, always_provided)
|
|
577
|
+
own = controller_context[:bundle_own_symbols].fetch(bundle_name, Set.new)
|
|
578
|
+
promoted = controller_context[:promoted_files]
|
|
579
|
+
file_uses = controller_context[:file_uses]
|
|
580
|
+
sym2file = controller_context[:symbol_to_file]
|
|
581
|
+
own_files = controller_context[:bundle_files].fetch(bundle_name, [])
|
|
582
|
+
|
|
583
|
+
needed = Set.new
|
|
584
|
+
seen = Set.new
|
|
585
|
+
worklist = own_files.flat_map { |f| file_uses.fetch(f, Set.new).to_a }
|
|
586
|
+
|
|
587
|
+
until worklist.empty?
|
|
588
|
+
sym = worklist.pop
|
|
589
|
+
next if seen.include?(sym)
|
|
590
|
+
|
|
591
|
+
seen << sym
|
|
592
|
+
next if own.include?(sym)
|
|
593
|
+
next if always_provided.include?(sym)
|
|
594
|
+
|
|
595
|
+
dep_file = sym2file[sym]
|
|
596
|
+
next unless dep_file
|
|
597
|
+
next if promoted.include?(dep_file) # already served by ux_shared
|
|
598
|
+
next if own_files.include?(dep_file) # own file, added via own_requires
|
|
599
|
+
next if needed.include?(dep_file)
|
|
600
|
+
|
|
601
|
+
needed << dep_file
|
|
602
|
+
file_uses.fetch(dep_file, Set.new).each { |s| worklist << s unless seen.include?(s) }
|
|
603
|
+
end
|
|
604
|
+
|
|
605
|
+
needed
|
|
218
606
|
end
|
|
219
607
|
|
|
220
608
|
def controller_dependency_requires(bundle_name, controller_context)
|
|
221
609
|
deps = transitive_dependencies(bundle_name, controller_context[:dependencies])
|
|
610
|
+
# Drop always_include bundles (and their transitive deps): they load on
|
|
611
|
+
# every page via their own <script> tag, so inlining them here would
|
|
612
|
+
# double-declare. Kept when the current bundle IS an always_include bundle.
|
|
613
|
+
always = controller_context[:always_loaded_bundles]
|
|
614
|
+
deps -= always.to_a unless always.include?(bundle_name)
|
|
222
615
|
deps.flat_map { |dep_bundle| controller_context[:bundle_files].fetch(dep_bundle, []) }
|
|
223
616
|
.map { |abs_path| relative_require_path(abs_path) }
|
|
224
617
|
.uniq
|
|
225
618
|
.sort
|
|
226
619
|
end
|
|
227
620
|
|
|
621
|
+
# Symbols delivered on every page by an always_include tag, for +bundle_name+.
|
|
622
|
+
# Empty when building an always_include bundle's own manifest so it still
|
|
623
|
+
# inlines the dependencies it needs.
|
|
624
|
+
def always_provided_for(bundle_name, controller_context)
|
|
625
|
+
return Set.new if controller_context[:always_loaded_bundles].include?(bundle_name)
|
|
626
|
+
|
|
627
|
+
controller_context[:always_provided_symbols]
|
|
628
|
+
end
|
|
629
|
+
|
|
228
630
|
def transitive_dependencies(bundle_name, dependency_map)
|
|
229
631
|
ordered = []
|
|
230
632
|
visiting = Set.new
|
|
@@ -245,30 +647,36 @@ module ReactManifest
|
|
|
245
647
|
ordered
|
|
246
648
|
end
|
|
247
649
|
|
|
248
|
-
|
|
650
|
+
# The set of bundles guaranteed loaded on every page by always_include: each
|
|
651
|
+
# configured always_include bundle plus its transitive dependencies. These
|
|
652
|
+
# arrive via their own <script> tag (resolve_bundles / react_component), so
|
|
653
|
+
# controller manifests must never inline their files or symbols.
|
|
654
|
+
def compute_always_loaded_bundles(dependencies)
|
|
249
655
|
bundles = @config.always_include.map(&:to_s).reject(&:empty?).uniq
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
requires = Set.new
|
|
256
|
-
|
|
257
|
-
bundles.each do |always_bundle|
|
|
258
|
-
next if always_bundle == bundle_name
|
|
656
|
+
bundles.each_with_object(Set.new) do |always_bundle, acc|
|
|
657
|
+
acc << always_bundle
|
|
658
|
+
transitive_dependencies(always_bundle, dependencies).each { |dep| acc << dep }
|
|
659
|
+
end
|
|
660
|
+
end
|
|
259
661
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
end
|
|
266
|
-
end
|
|
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)
|
|
267
667
|
|
|
268
|
-
|
|
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)
|
|
269
671
|
end
|
|
270
672
|
|
|
271
|
-
|
|
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
|
|
272
680
|
end
|
|
273
681
|
|
|
274
682
|
# --------------------------------------------------------------- write
|
|
@@ -393,20 +801,6 @@ module ReactManifest
|
|
|
393
801
|
strip_asset_extension(rel)
|
|
394
802
|
end
|
|
395
803
|
|
|
396
|
-
def extract_defined_symbols(file_path)
|
|
397
|
-
content = File.read(file_path, encoding: "utf-8")
|
|
398
|
-
SymbolExtractor.extract_definitions(content, file_path: file_path)
|
|
399
|
-
rescue Errno::ENOENT, Errno::EACCES, Encoding::InvalidByteSequenceError
|
|
400
|
-
[]
|
|
401
|
-
end
|
|
402
|
-
|
|
403
|
-
def extract_used_component_symbols(file_path)
|
|
404
|
-
content = File.read(file_path, encoding: "utf-8")
|
|
405
|
-
SymbolExtractor.extract_usages(content, file_path: file_path)
|
|
406
|
-
rescue Errno::ENOENT, Errno::EACCES, Encoding::InvalidByteSequenceError
|
|
407
|
-
[]
|
|
408
|
-
end
|
|
409
|
-
|
|
410
804
|
def external_js_files_in(dir)
|
|
411
805
|
return [] unless Dir.exist?(dir)
|
|
412
806
|
|
|
@@ -427,7 +821,7 @@ module ReactManifest
|
|
|
427
821
|
end
|
|
428
822
|
|
|
429
823
|
def warn_on_external_controller_references(file_path, symbol_to_bundle)
|
|
430
|
-
|
|
824
|
+
ComponentIndex.extract(file_path, :extract_usages).each do |sym|
|
|
431
825
|
dep_bundle = symbol_to_bundle[sym]
|
|
432
826
|
next unless dep_bundle
|
|
433
827
|
|