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.
@@ -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
- always_include_reqs = controller_context[:always_include_requires].fetch(ctrl[:bundle_name], [])
123
- dep_requires = controller_dependency_requires(ctrl[:bundle_name], controller_context)
124
- ext_reqs = controller_context[:external_requires].fetch(ctrl[:bundle_name], Set.new).to_a.sort
125
-
126
- files = js_files_in(ctrl[:path])
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 = (always_include_reqs + dep_requires + ext_reqs + own_requires).uniq
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
- symbol_to_bundle[sym] ||= bundle_name
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
- # Index symbols from external_roots dirs
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
- # Compute per-bundle cross-app and external dependencies
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
- extract_used_component_symbols(file_path).each do |sym|
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
- always_include_requires = build_always_include_requires(bundle_files, dependencies)
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
- bundle_files: bundle_files,
207
- dependencies: dependencies,
208
- always_include_requires: always_include_requires,
209
- external_requires: external_requires
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
- def build_always_include_requires(bundle_files, dependencies)
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
- return Hash.new { |h, k| h[k] = [] } if bundles.empty?
244
-
245
- requires_by_bundle = Hash.new { |h, k| h[k] = [] }
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
- symbols = []
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
- symbols = []
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]