react-manifest-rails 0.2.33 → 0.3.0
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 +53 -0
- data/README.md +3 -0
- data/lib/react_manifest/ast_extractor.rb +70 -0
- data/lib/react_manifest/configuration.rb +37 -1
- data/lib/react_manifest/generator.rb +362 -63
- data/lib/react_manifest/gitignore_patcher.rb +75 -0
- data/lib/react_manifest/railtie.rb +6 -0
- data/lib/react_manifest/scanner.rb +6 -10
- data/lib/react_manifest/symbol_extractor.rb +37 -4
- data/lib/react_manifest/vendor/ast_extractor.js +19 -0
- data/lib/react_manifest/vendor/build/README.md +19 -0
- data/lib/react_manifest/vendor/build/entry.js +137 -0
- data/lib/react_manifest/vendor/build/package-lock.json +524 -0
- data/lib/react_manifest/vendor/build/package.json +11 -0
- data/lib/react_manifest/version.rb +1 -1
- data/lib/react_manifest.rb +35 -7
- data/tasks/react_manifest.rake +24 -0
- metadata +22 -1
|
@@ -42,6 +42,14 @@ 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
|
|
@@ -49,10 +57,10 @@ module ReactManifest
|
|
|
49
57
|
# written and others stale/missing.
|
|
50
58
|
def run!
|
|
51
59
|
classification = @classifier.classify
|
|
52
|
-
controller_context = build_controller_context(classification.controller_dirs)
|
|
60
|
+
controller_context = build_controller_context(classification.controller_dirs, classification.shared_dirs)
|
|
53
61
|
|
|
54
62
|
# Phase 1: build all content in memory — no I/O.
|
|
55
|
-
shared_manifest = build_shared(classification.shared_dirs)
|
|
63
|
+
shared_manifest = build_shared(classification.shared_dirs, controller_context[:promoted_files])
|
|
56
64
|
manifests = [shared_manifest] + classification.controller_dirs.map do |ctrl|
|
|
57
65
|
build_controller(ctrl, controller_context)
|
|
58
66
|
end
|
|
@@ -98,10 +106,9 @@ module ReactManifest
|
|
|
98
106
|
|
|
99
107
|
# ------------------------------------------------------------------ shared
|
|
100
108
|
|
|
101
|
-
def build_shared(shared_dirs)
|
|
109
|
+
def build_shared(shared_dirs, promoted_files = Set.new)
|
|
102
110
|
lines = header_lines
|
|
103
|
-
reqs = shared_dirs
|
|
104
|
-
.flat_map { |d| js_files_in(d[:path]) }
|
|
111
|
+
reqs = (shared_dirs.flat_map { |d| js_files_in(d[:path]) } + promoted_files.to_a)
|
|
105
112
|
.map { |f| normalize_require_path(relative_require_path(f)) }
|
|
106
113
|
.uniq
|
|
107
114
|
.sort
|
|
@@ -119,13 +126,21 @@ module ReactManifest
|
|
|
119
126
|
|
|
120
127
|
def build_controller(ctrl, controller_context)
|
|
121
128
|
lines = header_lines
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
129
|
+
promoted = controller_context[:promoted_files]
|
|
130
|
+
bundle_name = ctrl[:bundle_name]
|
|
131
|
+
# always_include bundles are NOT inlined here: resolve_bundles /
|
|
132
|
+
# react_component deliver them on every page via their own <script> tag,
|
|
133
|
+
# so inlining would load the same file twice and double-declare.
|
|
134
|
+
dep_requires = if @config.auto_shared?
|
|
135
|
+
promoted_aware_dependency_requires(bundle_name, controller_context)
|
|
136
|
+
else
|
|
137
|
+
controller_dependency_requires(bundle_name, controller_context)
|
|
138
|
+
end
|
|
139
|
+
ext_reqs = controller_context[:external_requires].fetch(bundle_name, Set.new).to_a.sort
|
|
140
|
+
|
|
141
|
+
files = js_files_in(ctrl[:path]).reject { |f| promoted.include?(f) }
|
|
127
142
|
own_requires = files.map { |f| relative_require_path(f) }
|
|
128
|
-
all_requires = (
|
|
143
|
+
all_requires = (dep_requires + ext_reqs + own_requires).uniq
|
|
129
144
|
|
|
130
145
|
if all_requires.empty?
|
|
131
146
|
lines << "// (no JSX files found in #{ctrl[:name]}/)"
|
|
@@ -136,59 +151,303 @@ module ReactManifest
|
|
|
136
151
|
{ filename: "#{ctrl[:bundle_name]}.js", content: "#{lines.join("\n")}\n" }
|
|
137
152
|
end
|
|
138
153
|
|
|
139
|
-
def build_controller_context(controller_dirs)
|
|
154
|
+
def build_controller_context(controller_dirs, shared_dirs)
|
|
155
|
+
bundle_files, file_owner, file_defs, symbol_to_bundle, symbol_to_file,
|
|
156
|
+
symbol_definers, bundle_own_symbols = index_controller_symbols(controller_dirs)
|
|
157
|
+
|
|
158
|
+
file_uses = Hash.new { |h, k| h[k] = Set.new }
|
|
159
|
+
symbol_used_by_bundles = Hash.new { |h, k| h[k] = Set.new }
|
|
160
|
+
record_symbol_usages(bundle_files, shared_dirs, file_uses, symbol_used_by_bundles)
|
|
161
|
+
|
|
162
|
+
collided = symbol_definers.each_with_object(Set.new) do |(sym, files), acc|
|
|
163
|
+
acc << sym if files.size >= 2
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
external_symbol_to_require = index_external_symbols(symbol_to_bundle)
|
|
167
|
+
|
|
168
|
+
dependencies, external_requires = build_dependency_graph(
|
|
169
|
+
bundle_files, bundle_own_symbols, file_uses, symbol_to_bundle, external_symbol_to_require
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
symbol_index = {
|
|
173
|
+
file_defs: file_defs,
|
|
174
|
+
symbol_to_file: symbol_to_file,
|
|
175
|
+
symbol_used_by_bundles: symbol_used_by_bundles
|
|
176
|
+
}
|
|
177
|
+
promoted_files =
|
|
178
|
+
if @config.auto_shared?
|
|
179
|
+
compute_promoted_files(file_owner, file_uses, symbol_index, collided)
|
|
180
|
+
else
|
|
181
|
+
Set.new
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
context = {
|
|
185
|
+
bundle_files: bundle_files,
|
|
186
|
+
dependencies: dependencies,
|
|
187
|
+
external_requires: external_requires,
|
|
188
|
+
promoted_files: promoted_files,
|
|
189
|
+
file_uses: file_uses,
|
|
190
|
+
symbol_to_file: symbol_to_file,
|
|
191
|
+
bundle_own_symbols: bundle_own_symbols
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
# Bundles guaranteed loaded on every page by always_include (used to exclude
|
|
195
|
+
# them from the legacy whole-bundle inline path and to self-exclude an
|
|
196
|
+
# always_include bundle's own manifest from the skip below).
|
|
197
|
+
context[:always_loaded_bundles] = compute_always_loaded_bundles(dependencies)
|
|
198
|
+
# Symbols actually delivered on every page by the always_include <script>
|
|
199
|
+
# tag(s) — derived from the real files those tags carry, NOT from whole dep
|
|
200
|
+
# bundles (which would over-claim symbols the tag does not load and cause a
|
|
201
|
+
# consumer to skip a definer it still needs). Unused by the legacy path.
|
|
202
|
+
context[:always_provided_symbols] =
|
|
203
|
+
@config.auto_shared? ? compute_always_provided_symbols(context, file_defs) : Set.new
|
|
204
|
+
|
|
205
|
+
warn_on_always_include_collisions(context, file_owner, file_defs, collided)
|
|
206
|
+
|
|
207
|
+
context
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Surface the one case dependency arithmetic cannot resolve: an always_include
|
|
211
|
+
# bundle that CONSUMES a collided symbol. Its tag inlines the first-writer
|
|
212
|
+
# definer file, which also lives in that file's own bundle manifest — so on the
|
|
213
|
+
# definer's own page the file loads twice (once via the always_include tag,
|
|
214
|
+
# once via the owning bundle), a duplicate declaration. We cannot auto-fix it
|
|
215
|
+
# (there is no collision-free page-global home for a collided symbol), so we
|
|
216
|
+
# warn with the actionable fix: rename or relocate the symbol.
|
|
217
|
+
def warn_on_always_include_collisions(context, file_owner, file_defs, collided)
|
|
218
|
+
return if collided.empty?
|
|
219
|
+
|
|
220
|
+
bundles = @config.always_include.map(&:to_s).reject(&:empty?).uniq
|
|
221
|
+
bundles.each do |always_bundle|
|
|
222
|
+
always_include_foreign_files(always_bundle, context).each do |file|
|
|
223
|
+
owner = file_owner[file]
|
|
224
|
+
next if owner.nil? || owner == always_bundle
|
|
225
|
+
|
|
226
|
+
collided_syms = (file_defs.fetch(file, Set.new) & collided).to_a.sort
|
|
227
|
+
next if collided_syms.empty?
|
|
228
|
+
|
|
229
|
+
plural = collided_syms.size > 1 ? "s" : ""
|
|
230
|
+
log_warn "always_include bundle '#{always_bundle}' pulls in " \
|
|
231
|
+
"'#{relative_require_path(file)}' (defines collided symbol#{plural} " \
|
|
232
|
+
"#{collided_syms.join(', ')}) owned by '#{owner}'. On #{owner}'s own page " \
|
|
233
|
+
"that file loads twice — once via the #{always_bundle} tag and once via " \
|
|
234
|
+
"#{owner} — causing a duplicate declaration. Rename the symbol or move it " \
|
|
235
|
+
"to a uniquely-named shared component."
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# Files an always_include bundle's tag delivers from OTHER bundles: under
|
|
241
|
+
# auto_shared, the exact non-promoted dependency files it inlines; in legacy
|
|
242
|
+
# mode, every file of the dependency bundles it inlines wholesale.
|
|
243
|
+
def always_include_foreign_files(always_bundle, context)
|
|
244
|
+
if @config.auto_shared?
|
|
245
|
+
promoted_aware_dependency_files(always_bundle, context, Set.new)
|
|
246
|
+
else
|
|
247
|
+
transitive_dependencies(always_bundle, context[:dependencies])
|
|
248
|
+
.flat_map { |dep| context[:bundle_files].fetch(dep, []) }
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# Symbols the always_include tag(s) put on every page: for each always_include
|
|
253
|
+
# bundle, the symbols defined by its own non-promoted files plus the canonical
|
|
254
|
+
# dependency files it inlines (its manifest's exact file closure).
|
|
255
|
+
def compute_always_provided_symbols(context, file_defs)
|
|
256
|
+
bundles = @config.always_include.map(&:to_s).reject(&:empty?).uniq
|
|
257
|
+
return Set.new if bundles.empty?
|
|
258
|
+
|
|
259
|
+
always_files = Set.new
|
|
260
|
+
bundles.each do |b|
|
|
261
|
+
context[:bundle_files].fetch(b, []).each do |file|
|
|
262
|
+
always_files << file unless context[:promoted_files].include?(file)
|
|
263
|
+
end
|
|
264
|
+
promoted_aware_dependency_files(b, context, Set.new).each { |file| always_files << file }
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
always_files.each_with_object(Set.new) { |file, syms| syms.merge(file_defs.fetch(file, Set.new)) }
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# Files that can never be promoted to ux_shared: any file defining a "collided"
|
|
271
|
+
# symbol (a name with 2+ app-dir definers — promoting one copy into the always-
|
|
272
|
+
# loaded shared bundle would double-declare against the other copy on its page),
|
|
273
|
+
# plus any file that transitively depends on such a file (it can't live in shared
|
|
274
|
+
# if a dependency can't). These fall back to legacy per-consumer inlining.
|
|
275
|
+
def unpromotable_files(file_owner, file_defs, file_uses, symbol_to_file, collided)
|
|
276
|
+
unpromotable = Set.new
|
|
277
|
+
file_owner.each_key do |file_path|
|
|
278
|
+
unpromotable << file_path if file_defs[file_path].any? { |s| collided.include?(s) }
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
loop do
|
|
282
|
+
added = false
|
|
283
|
+
file_owner.each_key do |file_path|
|
|
284
|
+
next if unpromotable.include?(file_path)
|
|
285
|
+
|
|
286
|
+
taints = file_uses.fetch(file_path, Set.new).any? do |sym|
|
|
287
|
+
dep = symbol_to_file[sym]
|
|
288
|
+
dep && unpromotable.include?(dep)
|
|
289
|
+
end
|
|
290
|
+
if taints
|
|
291
|
+
unpromotable << file_path
|
|
292
|
+
added = true
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
break unless added
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
unpromotable
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# A controller file is promoted to the shared bundle when a symbol it canonically
|
|
302
|
+
# defines is used by a bundle other than its owner, then transitively for anything
|
|
303
|
+
# a promoted file depends on. Collided/tainted files (see unpromotable_files) are
|
|
304
|
+
# never promoted. Records why each file was promoted for react_manifest:analyze.
|
|
305
|
+
#
|
|
306
|
+
# symbol_index bundles {file_defs:, symbol_to_file:, symbol_used_by_bundles:} into
|
|
307
|
+
# one param so this stays under Metrics/ParameterLists (rubocop flags 6 positional
|
|
308
|
+
# args) without changing the underlying algorithm.
|
|
309
|
+
def compute_promoted_files(file_owner, file_uses, symbol_index, collided)
|
|
310
|
+
file_defs = symbol_index[:file_defs]
|
|
311
|
+
symbol_to_file = symbol_index[:symbol_to_file]
|
|
312
|
+
symbol_used_by_bundles = symbol_index[:symbol_used_by_bundles]
|
|
313
|
+
unpromotable = unpromotable_files(file_owner, file_defs, file_uses, symbol_to_file, collided)
|
|
314
|
+
promoted = Set.new
|
|
315
|
+
reasons = Hash.new { |h, k| h[k] = Set.new }
|
|
316
|
+
|
|
317
|
+
file_owner.each do |file_path, owner|
|
|
318
|
+
next if unpromotable.include?(file_path)
|
|
319
|
+
|
|
320
|
+
forcing = Set.new
|
|
321
|
+
file_defs[file_path].each do |sym|
|
|
322
|
+
# canonical-definer guard: only the file that canonically defines the
|
|
323
|
+
# symbol is eligible (resolves collisions to one file; excludes isolated
|
|
324
|
+
# dirs, which are never registered in symbol_to_file).
|
|
325
|
+
next unless symbol_to_file[sym] == file_path
|
|
326
|
+
|
|
327
|
+
(symbol_used_by_bundles[sym] - [owner]).each { |b| forcing << b }
|
|
328
|
+
end
|
|
329
|
+
next if forcing.empty?
|
|
330
|
+
|
|
331
|
+
promoted << file_path
|
|
332
|
+
reasons[relative_require_path(file_path)] = forcing
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
worklist = promoted.to_a
|
|
336
|
+
until worklist.empty?
|
|
337
|
+
current = worklist.pop
|
|
338
|
+
file_uses.fetch(current, Set.new).each do |sym|
|
|
339
|
+
dep_file = symbol_to_file[sym]
|
|
340
|
+
next unless dep_file
|
|
341
|
+
next if unpromotable.include?(dep_file)
|
|
342
|
+
next if promoted.include?(dep_file)
|
|
343
|
+
|
|
344
|
+
promoted << dep_file
|
|
345
|
+
reasons[relative_require_path(dep_file)] << "(transitive)"
|
|
346
|
+
worklist << dep_file
|
|
347
|
+
end
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
@promotion_reasons = reasons.transform_values(&:to_a)
|
|
351
|
+
promoted
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
# Indexes each controller dir's files and the PascalCase symbols they define.
|
|
355
|
+
# Returns [bundle_files, file_owner, file_defs, symbol_to_bundle, symbol_to_file,
|
|
356
|
+
# symbol_definers, bundle_own_symbols].
|
|
357
|
+
def index_controller_symbols(controller_dirs)
|
|
140
358
|
bundle_files = {}
|
|
359
|
+
file_owner = {}
|
|
360
|
+
file_defs = Hash.new { |h, k| h[k] = Set.new }
|
|
141
361
|
symbol_to_bundle = {}
|
|
362
|
+
symbol_to_file = {}
|
|
363
|
+
symbol_definers = Hash.new { |h, k| h[k] = Set.new }
|
|
142
364
|
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
365
|
|
|
147
366
|
controller_dirs.each do |ctrl|
|
|
148
|
-
# Index controller-defined symbols for cross-app detection
|
|
149
367
|
bundle_name = ctrl[:bundle_name]
|
|
150
368
|
files = js_files_in(ctrl[:path])
|
|
151
369
|
bundle_files[bundle_name] = files
|
|
370
|
+
isolated = @config.isolated_app_dirs.include?(ctrl[:name])
|
|
152
371
|
|
|
153
372
|
files.each do |file_path|
|
|
373
|
+
file_owner[file_path] = bundle_name
|
|
154
374
|
extract_defined_symbols(file_path).each do |sym|
|
|
155
375
|
next unless sym.match?(/\A[A-Z][A-Za-z0-9_]*\z/)
|
|
156
376
|
|
|
157
|
-
|
|
377
|
+
file_defs[file_path] << sym
|
|
378
|
+
# Isolated dirs never register into the shared symbol index, so no
|
|
379
|
+
# other bundle can ever be inferred to depend on them.
|
|
380
|
+
unless isolated
|
|
381
|
+
symbol_to_bundle[sym] ||= bundle_name
|
|
382
|
+
symbol_to_file[sym] ||= file_path
|
|
383
|
+
symbol_definers[sym] << file_path
|
|
384
|
+
end
|
|
158
385
|
bundle_own_symbols[bundle_name] << sym
|
|
159
386
|
end
|
|
160
387
|
end
|
|
161
388
|
end
|
|
162
389
|
|
|
163
|
-
|
|
390
|
+
[bundle_files, file_owner, file_defs, symbol_to_bundle, symbol_to_file,
|
|
391
|
+
symbol_definers, bundle_own_symbols]
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
# Records, per controller file and per bundle, which symbols are used —
|
|
395
|
+
# plus a pseudo-bundle entry (config.shared_bundle) for any controller
|
|
396
|
+
# symbol referenced from a shared-dir file (shared code loads on every
|
|
397
|
+
# page, so that symbol's owning file must be treated as externally used).
|
|
398
|
+
def record_symbol_usages(bundle_files, shared_dirs, file_uses, symbol_used_by_bundles)
|
|
399
|
+
bundle_files.each do |bundle_name, files|
|
|
400
|
+
files.each do |file_path|
|
|
401
|
+
extract_used_component_symbols(file_path).each do |sym|
|
|
402
|
+
file_uses[file_path] << sym
|
|
403
|
+
symbol_used_by_bundles[sym] << bundle_name
|
|
404
|
+
end
|
|
405
|
+
end
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
shared_dirs.each do |dir|
|
|
409
|
+
js_files_in(dir[:path]).each do |file_path|
|
|
410
|
+
extract_used_component_symbols(file_path).each do |sym|
|
|
411
|
+
symbol_used_by_bundles[sym] << @config.shared_bundle
|
|
412
|
+
end
|
|
413
|
+
end
|
|
414
|
+
end
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
# Indexes symbols from external_roots dirs, then lets explicit
|
|
418
|
+
# external_providers win over scanned roots on symbol conflicts.
|
|
419
|
+
def index_external_symbols(symbol_to_bundle)
|
|
420
|
+
external_symbol_to_require = {}
|
|
421
|
+
|
|
164
422
|
@config.external_roots.each do |root_path|
|
|
165
423
|
abs_root = abs_external_root(root_path)
|
|
166
424
|
external_js_files_in(abs_root).each do |file_path|
|
|
167
425
|
req_path = relative_require_path(file_path)
|
|
168
|
-
|
|
169
426
|
warn_on_external_controller_references(file_path, symbol_to_bundle)
|
|
170
|
-
|
|
171
427
|
extract_defined_symbols(file_path).each do |sym|
|
|
172
428
|
external_symbol_to_require[sym] ||= req_path
|
|
173
429
|
end
|
|
174
430
|
end
|
|
175
431
|
end
|
|
176
432
|
|
|
177
|
-
# Explicit external_providers win over scanned roots on symbol conflicts
|
|
178
433
|
@config.external_providers.each do |sym, req_path|
|
|
179
434
|
external_symbol_to_require[sym] = req_path
|
|
180
435
|
end
|
|
181
436
|
|
|
182
|
-
|
|
437
|
+
external_symbol_to_require
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
# Cross-app dependency graph + external requires (dependencies kept for
|
|
441
|
+
# reporting). Returns [dependencies, external_requires].
|
|
442
|
+
def build_dependency_graph(bundle_files, bundle_own_symbols, file_uses, symbol_to_bundle,
|
|
443
|
+
external_symbol_to_require)
|
|
444
|
+
dependencies = Hash.new { |h, k| h[k] = Set.new }
|
|
445
|
+
external_requires = Hash.new { |h, k| h[k] = Set.new }
|
|
446
|
+
|
|
183
447
|
bundle_files.each do |bundle_name, files|
|
|
184
448
|
own_symbols = bundle_own_symbols[bundle_name]
|
|
185
|
-
|
|
186
449
|
files.each do |file_path|
|
|
187
|
-
|
|
188
|
-
# A symbol the bundle defines itself (in any of its own files) is
|
|
189
|
-
# always satisfied locally — never attribute it to another bundle
|
|
190
|
-
# or an external provider just because that symbol name happens
|
|
191
|
-
# to collide with something defined elsewhere.
|
|
450
|
+
file_uses[file_path].each do |sym|
|
|
192
451
|
next if own_symbols.include?(sym)
|
|
193
452
|
|
|
194
453
|
dep_bundle = symbol_to_bundle[sym]
|
|
@@ -200,24 +459,82 @@ module ReactManifest
|
|
|
200
459
|
end
|
|
201
460
|
end
|
|
202
461
|
|
|
203
|
-
|
|
462
|
+
[dependencies, external_requires]
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
# Under auto_shared, single-definer cross-used files live in ux_shared, so a
|
|
466
|
+
# controller only needs the canonical files for the symbols it uses that are NOT
|
|
467
|
+
# promoted (collided names kept bundle-local, plus their transitive needs).
|
|
468
|
+
# File-level + transitive so we never over-inline a whole dependency bundle
|
|
469
|
+
# (which could double-declare against an always_include bundle's own copy).
|
|
470
|
+
def promoted_aware_dependency_requires(bundle_name, controller_context)
|
|
471
|
+
# Symbols already on every page via an always_include tag. Skipped so a
|
|
472
|
+
# controller never inlines a (possibly colliding) second definer. Disabled
|
|
473
|
+
# when building an always_include bundle's own manifest (it needs its deps).
|
|
474
|
+
always_provided = always_provided_for(bundle_name, controller_context)
|
|
475
|
+
promoted_aware_dependency_files(bundle_name, controller_context, always_provided)
|
|
476
|
+
.map { |f| relative_require_path(f) }
|
|
477
|
+
.uniq
|
|
478
|
+
.sort
|
|
479
|
+
end
|
|
204
480
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
481
|
+
# Absolute paths of the non-promoted files a bundle must inline: for each
|
|
482
|
+
# symbol it uses (transitively) that is neither its own, promoted to shared,
|
|
483
|
+
# nor +always_provided+ on the page, the canonical (first-writer) definer file.
|
|
484
|
+
def promoted_aware_dependency_files(bundle_name, controller_context, always_provided)
|
|
485
|
+
own = controller_context[:bundle_own_symbols].fetch(bundle_name, Set.new)
|
|
486
|
+
promoted = controller_context[:promoted_files]
|
|
487
|
+
file_uses = controller_context[:file_uses]
|
|
488
|
+
sym2file = controller_context[:symbol_to_file]
|
|
489
|
+
own_files = controller_context[:bundle_files].fetch(bundle_name, [])
|
|
490
|
+
|
|
491
|
+
needed = Set.new
|
|
492
|
+
seen = Set.new
|
|
493
|
+
worklist = own_files.flat_map { |f| file_uses.fetch(f, Set.new).to_a }
|
|
494
|
+
|
|
495
|
+
until worklist.empty?
|
|
496
|
+
sym = worklist.pop
|
|
497
|
+
next if seen.include?(sym)
|
|
498
|
+
|
|
499
|
+
seen << sym
|
|
500
|
+
next if own.include?(sym)
|
|
501
|
+
next if always_provided.include?(sym)
|
|
502
|
+
|
|
503
|
+
dep_file = sym2file[sym]
|
|
504
|
+
next unless dep_file
|
|
505
|
+
next if promoted.include?(dep_file) # already served by ux_shared
|
|
506
|
+
next if own_files.include?(dep_file) # own file, added via own_requires
|
|
507
|
+
next if needed.include?(dep_file)
|
|
508
|
+
|
|
509
|
+
needed << dep_file
|
|
510
|
+
file_uses.fetch(dep_file, Set.new).each { |s| worklist << s unless seen.include?(s) }
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
needed
|
|
211
514
|
end
|
|
212
515
|
|
|
213
516
|
def controller_dependency_requires(bundle_name, controller_context)
|
|
214
517
|
deps = transitive_dependencies(bundle_name, controller_context[:dependencies])
|
|
518
|
+
# Drop always_include bundles (and their transitive deps): they load on
|
|
519
|
+
# every page via their own <script> tag, so inlining them here would
|
|
520
|
+
# double-declare. Kept when the current bundle IS an always_include bundle.
|
|
521
|
+
always = controller_context[:always_loaded_bundles]
|
|
522
|
+
deps -= always.to_a unless always.include?(bundle_name)
|
|
215
523
|
deps.flat_map { |dep_bundle| controller_context[:bundle_files].fetch(dep_bundle, []) }
|
|
216
524
|
.map { |abs_path| relative_require_path(abs_path) }
|
|
217
525
|
.uniq
|
|
218
526
|
.sort
|
|
219
527
|
end
|
|
220
528
|
|
|
529
|
+
# Symbols delivered on every page by an always_include tag, for +bundle_name+.
|
|
530
|
+
# Empty when building an always_include bundle's own manifest so it still
|
|
531
|
+
# inlines the dependencies it needs.
|
|
532
|
+
def always_provided_for(bundle_name, controller_context)
|
|
533
|
+
return Set.new if controller_context[:always_loaded_bundles].include?(bundle_name)
|
|
534
|
+
|
|
535
|
+
controller_context[:always_provided_symbols]
|
|
536
|
+
end
|
|
537
|
+
|
|
221
538
|
def transitive_dependencies(bundle_name, dependency_map)
|
|
222
539
|
ordered = []
|
|
223
540
|
visiting = Set.new
|
|
@@ -238,30 +555,16 @@ module ReactManifest
|
|
|
238
555
|
ordered
|
|
239
556
|
end
|
|
240
557
|
|
|
241
|
-
|
|
558
|
+
# The set of bundles guaranteed loaded on every page by always_include: each
|
|
559
|
+
# configured always_include bundle plus its transitive dependencies. These
|
|
560
|
+
# arrive via their own <script> tag (resolve_bundles / react_component), so
|
|
561
|
+
# controller manifests must never inline their files or symbols.
|
|
562
|
+
def compute_always_loaded_bundles(dependencies)
|
|
242
563
|
bundles = @config.always_include.map(&:to_s).reject(&:empty?).uniq
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
bundle_files.each_key do |bundle_name|
|
|
248
|
-
requires = Set.new
|
|
249
|
-
|
|
250
|
-
bundles.each do |always_bundle|
|
|
251
|
-
next if always_bundle == bundle_name
|
|
252
|
-
|
|
253
|
-
transitive = [always_bundle] + transitive_dependencies(always_bundle, dependencies)
|
|
254
|
-
transitive.each do |dep_bundle|
|
|
255
|
-
bundle_files.fetch(dep_bundle, []).each do |abs_path|
|
|
256
|
-
requires << relative_require_path(abs_path)
|
|
257
|
-
end
|
|
258
|
-
end
|
|
259
|
-
end
|
|
260
|
-
|
|
261
|
-
requires_by_bundle[bundle_name] = requires.to_a.sort
|
|
564
|
+
bundles.each_with_object(Set.new) do |always_bundle, acc|
|
|
565
|
+
acc << always_bundle
|
|
566
|
+
transitive_dependencies(always_bundle, dependencies).each { |dep| acc << dep }
|
|
262
567
|
end
|
|
263
|
-
|
|
264
|
-
requires_by_bundle
|
|
265
568
|
end
|
|
266
569
|
|
|
267
570
|
# --------------------------------------------------------------- write
|
|
@@ -388,18 +691,14 @@ module ReactManifest
|
|
|
388
691
|
|
|
389
692
|
def extract_defined_symbols(file_path)
|
|
390
693
|
content = File.read(file_path, encoding: "utf-8")
|
|
391
|
-
|
|
392
|
-
ReactManifest::Scanner::DEFINITION_PATTERNS.each do |pattern|
|
|
393
|
-
content.scan(pattern) { |m| symbols << m[0] }
|
|
394
|
-
end
|
|
395
|
-
symbols.uniq
|
|
694
|
+
SymbolExtractor.extract_definitions(content, file_path: file_path)
|
|
396
695
|
rescue Errno::ENOENT, Errno::EACCES, Encoding::InvalidByteSequenceError
|
|
397
696
|
[]
|
|
398
697
|
end
|
|
399
698
|
|
|
400
699
|
def extract_used_component_symbols(file_path)
|
|
401
700
|
content = File.read(file_path, encoding: "utf-8")
|
|
402
|
-
SymbolExtractor.extract_usages(content)
|
|
701
|
+
SymbolExtractor.extract_usages(content, file_path: file_path)
|
|
403
702
|
rescue Errno::ENOENT, Errno::EACCES, Encoding::InvalidByteSequenceError
|
|
404
703
|
[]
|
|
405
704
|
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
require "fileutils"
|
|
2
|
+
|
|
3
|
+
module ReactManifest
|
|
4
|
+
# Ensures the generated manifest dir is gitignored and that a committed .keep
|
|
5
|
+
# keeps the directory present on a fresh clone. Idempotent and best-effort:
|
|
6
|
+
# a filesystem error logs a warning and never raises.
|
|
7
|
+
class GitignorePatcher
|
|
8
|
+
include ReactManifest::Logging
|
|
9
|
+
|
|
10
|
+
Result = Struct.new(:appended, :keep_created, keyword_init: true)
|
|
11
|
+
|
|
12
|
+
def initialize(config = ReactManifest.configuration)
|
|
13
|
+
@config = config
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def patch!
|
|
17
|
+
Result.new(appended: ensure_gitignore_entry, keep_created: ensure_keep)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# patch! plus a one-time hint to untrack previously-committed manifests when the
|
|
21
|
+
# .gitignore entry was just added (the gem never runs git itself). Returns true
|
|
22
|
+
# iff the .gitignore entry was appended.
|
|
23
|
+
def reconcile!
|
|
24
|
+
result = patch!
|
|
25
|
+
if result.appended
|
|
26
|
+
base = File.dirname(pattern)
|
|
27
|
+
log_info "If #{base}/*.js were previously committed, untrack them once: " \
|
|
28
|
+
"git rm --cached #{base}/*.js"
|
|
29
|
+
end
|
|
30
|
+
result.appended
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Path (relative to Rails.root) that should be ignored, e.g.
|
|
34
|
+
# "app/assets/javascripts/ux_manifests/*.js".
|
|
35
|
+
def pattern
|
|
36
|
+
subdir = @config.normalized_manifest_subdir
|
|
37
|
+
base = subdir.empty? ? @config.output_dir : File.join(@config.output_dir, subdir)
|
|
38
|
+
File.join(base, "*.js")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def gitignore_path
|
|
44
|
+
Rails.root.join(".gitignore").to_s
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def ensure_gitignore_entry
|
|
48
|
+
path = gitignore_path
|
|
49
|
+
line = pattern
|
|
50
|
+
content = File.exist?(path) ? File.read(path, encoding: "utf-8") : ""
|
|
51
|
+
return false if content.split("\n").map(&:strip).include?(line)
|
|
52
|
+
|
|
53
|
+
prefix = content.empty? || content.end_with?("\n") ? "" : "\n"
|
|
54
|
+
File.open(path, "a") { |f| f.write("#{prefix}#{line}\n") }
|
|
55
|
+
log_info "Added #{line} to .gitignore"
|
|
56
|
+
true
|
|
57
|
+
rescue Errno::EACCES, Errno::ENOENT => e
|
|
58
|
+
log_warn "Could not update .gitignore: #{e.message}"
|
|
59
|
+
false
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def ensure_keep
|
|
63
|
+
dir = @config.abs_manifest_dir
|
|
64
|
+
keep = File.join(dir, ".keep")
|
|
65
|
+
return false if File.exist?(keep)
|
|
66
|
+
|
|
67
|
+
FileUtils.mkdir_p(dir)
|
|
68
|
+
FileUtils.touch(keep)
|
|
69
|
+
true
|
|
70
|
+
rescue Errno::EACCES => e
|
|
71
|
+
log_warn "Could not create #{dir}/.keep: #{e.message}"
|
|
72
|
+
false
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -26,6 +26,12 @@ module ReactManifest
|
|
|
26
26
|
Rails.logger&.warn(error)
|
|
27
27
|
$stdout.puts(error) if config.stdout_logging?
|
|
28
28
|
end
|
|
29
|
+
|
|
30
|
+
begin
|
|
31
|
+
ReactManifest.reconcile_gitignore!(config)
|
|
32
|
+
rescue StandardError => e
|
|
33
|
+
Rails.logger&.warn("[ReactManifest] gitignore reconcile failed: #{e.message}")
|
|
34
|
+
end
|
|
29
35
|
end
|
|
30
36
|
|
|
31
37
|
# Expose config as Rails.application.config.react_manifest
|
|
@@ -166,7 +166,7 @@ module ReactManifest
|
|
|
166
166
|
cache = self.class.file_symbol_cache
|
|
167
167
|
return cache[file_path] if cache.key?(file_path)
|
|
168
168
|
|
|
169
|
-
cache[file_path] = parse_definitions(content)
|
|
169
|
+
cache[file_path] = parse_definitions(content, file_path: file_path)
|
|
170
170
|
end
|
|
171
171
|
|
|
172
172
|
def scan_file_definitions(file_path)
|
|
@@ -175,17 +175,13 @@ module ReactManifest
|
|
|
175
175
|
rescue Errno::ENOENT, Errno::EACCES, Encoding::InvalidByteSequenceError
|
|
176
176
|
return []
|
|
177
177
|
end
|
|
178
|
-
parse_definitions(content)
|
|
178
|
+
parse_definitions(content, file_path: file_path)
|
|
179
179
|
end
|
|
180
180
|
|
|
181
|
-
def parse_definitions(content)
|
|
181
|
+
def parse_definitions(content, file_path: nil)
|
|
182
182
|
return [] unless content
|
|
183
183
|
|
|
184
|
-
|
|
185
|
-
DEFINITION_PATTERNS.each do |pattern|
|
|
186
|
-
content.scan(pattern) { |m| symbols << m[0] }
|
|
187
|
-
end
|
|
188
|
-
symbols.uniq
|
|
184
|
+
SymbolExtractor.extract_definitions(content, file_path: file_path)
|
|
189
185
|
end
|
|
190
186
|
|
|
191
187
|
def relative_require_path(abs_path)
|
|
@@ -201,7 +197,7 @@ module ReactManifest
|
|
|
201
197
|
content = shared_file_content[file_path]
|
|
202
198
|
next unless content
|
|
203
199
|
|
|
204
|
-
SymbolExtractor.extract_usages(content).each do |sym|
|
|
200
|
+
SymbolExtractor.extract_usages(content, file_path: file_path).each do |sym|
|
|
205
201
|
next unless controller_symbol_index.key?(sym)
|
|
206
202
|
|
|
207
203
|
info = controller_symbol_index[sym]
|
|
@@ -224,7 +220,7 @@ module ReactManifest
|
|
|
224
220
|
next
|
|
225
221
|
end
|
|
226
222
|
|
|
227
|
-
SymbolExtractor.extract_usages(content).each do |sym|
|
|
223
|
+
SymbolExtractor.extract_usages(content, file_path: file_path).each do |sym|
|
|
228
224
|
next unless controller_symbol_index.key?(sym)
|
|
229
225
|
|
|
230
226
|
info = controller_symbol_index[sym]
|