react_on_rails 16.7.0.rc.2 → 16.7.0.rc.3
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/Gemfile.lock +9 -1
- data/lib/generators/react_on_rails/generator_helper.rb +23 -6
- data/lib/generators/react_on_rails/install_generator.rb +5 -3
- data/lib/generators/react_on_rails/pro_setup.rb +83 -41
- data/lib/generators/react_on_rails/rsc_setup/client_references.rb +1308 -0
- data/lib/generators/react_on_rails/rsc_setup/layouts.rb +229 -0
- data/lib/generators/react_on_rails/rsc_setup.rb +103 -232
- data/lib/generators/react_on_rails/templates/base/base/config/webpack/clientWebpackConfig.js.tt +11 -1
- data/lib/generators/react_on_rails/templates/base/base/config/webpack/serverWebpackConfig.js.tt +10 -1
- data/lib/react_on_rails/dev/server_manager.rb +19 -1
- data/lib/react_on_rails/doctor.rb +58 -0
- data/lib/react_on_rails/engine.rb +116 -0
- data/lib/react_on_rails/node_renderer_procfile.rb +43 -0
- data/lib/react_on_rails/version.rb +1 -1
- data/rakelib/run_rspec.rake +2 -0
- data/rakelib/task_helpers.rb +0 -4
- data/rakelib/update_changelog.rake +61 -6
- metadata +5 -2
|
@@ -0,0 +1,1308 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ReactOnRails
|
|
4
|
+
module Generators
|
|
5
|
+
module RscSetup
|
|
6
|
+
module ClientReferences # rubocop:disable Metrics/ModuleLength
|
|
7
|
+
JS_STRING_DELIMITERS = ["'", '"', "`"].freeze
|
|
8
|
+
JS_COMMENT_STATES = %i[line_comment block_comment].freeze
|
|
9
|
+
# Known limitation: this list only covers single-character regex preceders. Multi-character
|
|
10
|
+
# JavaScript keywords that legally precede a regex literal (`return`, `typeof`, `void`,
|
|
11
|
+
# `delete`, `throw`, `case`, `in`, `instanceof`) are not represented. A regex like `/\{/`
|
|
12
|
+
# appearing after `return` may be misidentified as a division operator and let `{` / `}`
|
|
13
|
+
# inside the regex body throw off `matching_js_closing_brace`'s depth counter. The
|
|
14
|
+
# unparseable-section detection in `rsc_plugin_options_followed_by_close_paren?` catches
|
|
15
|
+
# the resulting corruption and flags the section as unparseable, so the migration falls
|
|
16
|
+
# back safely rather than producing a wrong rewrite.
|
|
17
|
+
REGEX_LITERAL_PRECEDERS = ["(", "{", "[", "=", ":", ",", ";", "!", "?", "&", "|", "+", "-", "*", "~", "^",
|
|
18
|
+
"<", ">"].freeze
|
|
19
|
+
# Matches `new RSCWebpackPlugin(` allowing whitespace/newlines between `new`, the class
|
|
20
|
+
# name, and the open paren. Shared by the partition scanner and the routing checks so
|
|
21
|
+
# both detect the same set of invocations.
|
|
22
|
+
RSC_PLUGIN_INVOCATION_REGEX = /new\s+RSCWebpackPlugin\s*\(/
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def rsc_client_references_js
|
|
27
|
+
<<~'JS'.chomp
|
|
28
|
+
const rscClientReferences = {
|
|
29
|
+
directory: resolve(config.source_path),
|
|
30
|
+
recursive: true,
|
|
31
|
+
include: /\.(js|mjs|cjs|ts|mts|cts|jsx|tsx)$/,
|
|
32
|
+
};
|
|
33
|
+
JS
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def inject_rsc_client_imports(config_path, content, existing_imports_content)
|
|
37
|
+
replace_rsc_client_references_setup_anchor(config_path, content, is_server: false) do |anchor|
|
|
38
|
+
join_rsc_client_references_setup(
|
|
39
|
+
content,
|
|
40
|
+
[
|
|
41
|
+
anchor,
|
|
42
|
+
shakapacker_config_import_statement(existing_imports_content),
|
|
43
|
+
path_resolve_import_statement(existing_imports_content),
|
|
44
|
+
rsc_webpack_plugin_import_statement(content),
|
|
45
|
+
"",
|
|
46
|
+
rsc_client_references_js
|
|
47
|
+
]
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def inject_rsc_server_imports(config_path, content, existing_imports_content)
|
|
53
|
+
# Server from-scratch insertion only reaches this point after
|
|
54
|
+
# `shakapacker_config_blocker_reason` has confirmed `config` is already in scope.
|
|
55
|
+
# Adding `shakapacker_config_import_statement` here would duplicate that binding.
|
|
56
|
+
replace_rsc_client_references_setup_anchor(config_path, content, is_server: true) do |anchor|
|
|
57
|
+
join_rsc_client_references_setup(
|
|
58
|
+
content,
|
|
59
|
+
[
|
|
60
|
+
anchor,
|
|
61
|
+
path_resolve_import_statement(existing_imports_content),
|
|
62
|
+
rsc_webpack_plugin_import_statement(content),
|
|
63
|
+
"",
|
|
64
|
+
rsc_client_references_js
|
|
65
|
+
]
|
|
66
|
+
)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def prepare_rsc_plugin_imports(config_path, content, existing_imports_content, is_server:)
|
|
71
|
+
if rsc_setup_blocked_by_later_imports?(
|
|
72
|
+
config_path,
|
|
73
|
+
content,
|
|
74
|
+
existing_imports_content,
|
|
75
|
+
is_server: is_server,
|
|
76
|
+
plugin_pending: true
|
|
77
|
+
)
|
|
78
|
+
return inject_rsc_webpack_plugin_import(config_path, content, is_server: is_server) ? :unscoped : :failed
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# `rsc_client_references_defined?` covers both scoped and unscoped declarations.
|
|
82
|
+
# Re-emitting `rsc_client_references_js` here would produce a duplicate
|
|
83
|
+
# `const rscClientReferences = {...}` and fail the file with
|
|
84
|
+
# `Identifier 'rscClientReferences' has already been declared`. Route to the
|
|
85
|
+
# plugin-import-only path and let downstream callers honor the scoped/unscoped
|
|
86
|
+
# state of whatever the user already wrote.
|
|
87
|
+
if rsc_client_references_defined?(content)
|
|
88
|
+
return :failed unless inject_rsc_webpack_plugin_import(config_path, content, is_server: is_server)
|
|
89
|
+
return :scoped if scoped_rsc_client_references_defined?(content)
|
|
90
|
+
|
|
91
|
+
warn_unscoped_rsc_client_references_helper(config_path)
|
|
92
|
+
return :unscoped
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
return :failed unless inject_rsc_imports(config_path, content, existing_imports_content, is_server: is_server)
|
|
96
|
+
return :scoped if rsc_client_references_setup_ready?(config_path, plugin_pending: true)
|
|
97
|
+
|
|
98
|
+
:failed
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def inject_rsc_imports(config_path, content, existing_imports_content, is_server:)
|
|
102
|
+
inserted =
|
|
103
|
+
if is_server
|
|
104
|
+
inject_rsc_server_imports(config_path, content, existing_imports_content)
|
|
105
|
+
else
|
|
106
|
+
inject_rsc_client_imports(config_path, content, existing_imports_content)
|
|
107
|
+
end
|
|
108
|
+
return true if inserted
|
|
109
|
+
|
|
110
|
+
warn_rsc_client_references_injection_failed(config_path, plugin_pending: true)
|
|
111
|
+
false
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def join_rsc_client_references_setup(content, lines)
|
|
115
|
+
line_ending = js_line_ending(content)
|
|
116
|
+
lines.compact.map { |line| line.gsub(/\r?\n/, line_ending) }.join(line_ending)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def js_line_ending(content)
|
|
120
|
+
content.include?("\r\n") ? "\r\n" : "\n"
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def update_existing_rsc_webpack_config(config_path, content, is_server:)
|
|
124
|
+
return unless rsc_plugin_sections_safe_to_rewrite?(config_path, content, is_server: is_server)
|
|
125
|
+
return if rsc_plugin_uses_scoped_client_references?(content, is_server: is_server)
|
|
126
|
+
return unless rsc_client_references_rewrite_needed?(config_path, content, is_server: is_server)
|
|
127
|
+
|
|
128
|
+
return if rewrite_rsc_plugin_client_references(config_path, is_server: is_server)
|
|
129
|
+
|
|
130
|
+
rollback_incomplete_rsc_client_references_setup(config_path, content)
|
|
131
|
+
warn_missing_rsc_plugin_target(config_path, is_server: is_server)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def rsc_client_references_rewrite_needed?(config_path, content, is_server:)
|
|
135
|
+
# This predicate prepares the rewrite too: when it returns true, the scoped helper
|
|
136
|
+
# may already have been injected on disk so `rewrite_rsc_plugin_client_references`
|
|
137
|
+
# can re-read fresh content with valid offsets.
|
|
138
|
+
if rsc_plugin_references_any_scoped_client_references?(content, is_server: is_server)
|
|
139
|
+
return false unless ensure_rsc_client_references_setup(config_path, content, is_server: is_server)
|
|
140
|
+
|
|
141
|
+
return rsc_plugin_without_client_references?(content, is_server: is_server)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
rewritable_rsc_plugin?(config_path, content, is_server: is_server) &&
|
|
145
|
+
ensure_rsc_client_references_setup(config_path, content, is_server: is_server)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Detects RSCWebpackPlugin option blocks that the lightweight JS scanner could not parse
|
|
149
|
+
# cleanly (most often a regex literal with an unmatched `{` / `}` that walks the depth
|
|
150
|
+
# counter past the real closing brace). When found, we warn and refuse to rewrite anything
|
|
151
|
+
# in the file so a sibling rewrite cannot accidentally splice into a wrong location.
|
|
152
|
+
def rsc_plugin_sections_safe_to_rewrite?(config_path, content, is_server:)
|
|
153
|
+
unparseable = rsc_plugin_option_sections_partition(content, is_server: is_server).fetch(:unparseable)
|
|
154
|
+
return true if unparseable.zero?
|
|
155
|
+
|
|
156
|
+
warn_unparseable_rsc_plugin_sections(config_path, unparseable)
|
|
157
|
+
false
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def rewritable_rsc_plugin?(config_path, content, is_server:)
|
|
161
|
+
# Mixed same-target plugins are still rewritable: the later rewrite only updates plugins
|
|
162
|
+
# missing clientReferences and leaves sibling custom clientReferences untouched.
|
|
163
|
+
return true if rsc_plugin_without_client_references?(content, is_server: is_server)
|
|
164
|
+
|
|
165
|
+
if rsc_plugin_defines_client_references?(content, is_server: is_server)
|
|
166
|
+
GeneratorMessages.add_warning(
|
|
167
|
+
"Skipped scoped clientReferences migration for #{config_path} because all matching " \
|
|
168
|
+
"RSCWebpackPlugin instances already define clientReferences (some may already be " \
|
|
169
|
+
"correctly scoped to rscClientReferences). Please verify manually."
|
|
170
|
+
)
|
|
171
|
+
return false
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
warn_missing_rsc_plugin_target(config_path, is_server: is_server)
|
|
175
|
+
false
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def warn_unparseable_rsc_plugin_sections(config_path, count)
|
|
179
|
+
GeneratorMessages.add_warning(
|
|
180
|
+
"Skipped scoped clientReferences migration for #{config_path}: #{count} RSCWebpackPlugin " \
|
|
181
|
+
"options block(s) contain characters this lightweight scanner cannot parse safely " \
|
|
182
|
+
"(most often a regex literal with an unmatched `{` or `}`, e.g. `/\\{/` or `/[{]/`, " \
|
|
183
|
+
"or a regex literal after a keyword context such as `return` or `typeof`). " \
|
|
184
|
+
"All RSCWebpackPlugin calls in the file must be parseable for auto-migration to proceed, " \
|
|
185
|
+
"including calls targeting the other bundle. " \
|
|
186
|
+
"Please add `clientReferences: rscClientReferences` manually to any RSCWebpackPlugin " \
|
|
187
|
+
"that is missing it."
|
|
188
|
+
)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def ensure_rsc_client_references_setup(config_path, content, is_server:)
|
|
192
|
+
return true if scoped_rsc_client_references_defined?(content)
|
|
193
|
+
|
|
194
|
+
if rsc_client_references_defined?(content)
|
|
195
|
+
warn_unscoped_rsc_client_references_helper(config_path)
|
|
196
|
+
return false
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
unless rsc_client_references_setup_anchor?(content, is_server: is_server)
|
|
200
|
+
warn_missing_rsc_client_references_anchor(config_path)
|
|
201
|
+
return false
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
existing_imports_content = content_through_rsc_setup_anchor(content, is_server: is_server)
|
|
205
|
+
return false if rsc_setup_blocked_by_later_imports?(config_path, content, existing_imports_content,
|
|
206
|
+
is_server: is_server)
|
|
207
|
+
|
|
208
|
+
return false unless add_rsc_client_references_setup(config_path, content, existing_imports_content,
|
|
209
|
+
is_server: is_server)
|
|
210
|
+
return true if options[:skip]
|
|
211
|
+
|
|
212
|
+
rsc_client_references_setup_ready?(config_path)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def rsc_plugin_uses_scoped_client_references?(content, is_server:)
|
|
216
|
+
scoped_rsc_client_references_defined?(content) &&
|
|
217
|
+
rsc_plugin_references_scoped_client_references?(content, is_server: is_server)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def rsc_plugin_references_scoped_client_references?(content, is_server:)
|
|
221
|
+
sections = rsc_plugin_option_sections(content, is_server: is_server)
|
|
222
|
+
return false if sections.empty?
|
|
223
|
+
|
|
224
|
+
sections.all? do |section|
|
|
225
|
+
rsc_plugin_body_has_top_level_scoped_client_references?(section.fetch(:body))
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def rsc_plugin_references_any_scoped_client_references?(content, is_server:)
|
|
230
|
+
rsc_plugin_option_sections(content, is_server: is_server).any? do |section|
|
|
231
|
+
rsc_plugin_body_has_top_level_scoped_client_references?(section.fetch(:body))
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def rsc_plugin_client_references_configured?(content, is_server:)
|
|
236
|
+
sections = rsc_plugin_option_sections(content, is_server: is_server)
|
|
237
|
+
# No parseable `isServer: <bool>` section means this file's plugin call sits outside
|
|
238
|
+
# what the generator's scanner can match (e.g. options are computed at runtime, or the
|
|
239
|
+
# plugin is invoked without an options object). Verification callers intentionally
|
|
240
|
+
# under-report here: warning about "missing scoped clientReferences" when there's no
|
|
241
|
+
# section to inspect would only surface noise for dynamic invocations like
|
|
242
|
+
# `RSCWebpackPlugin(buildOptions())`, where the user has nothing actionable to do.
|
|
243
|
+
return true if sections.empty?
|
|
244
|
+
|
|
245
|
+
sections.all? do |section|
|
|
246
|
+
body = section.fetch(:body)
|
|
247
|
+
if rsc_plugin_body_has_top_level_scoped_client_references?(body)
|
|
248
|
+
scoped_rsc_client_references_defined?(content)
|
|
249
|
+
else
|
|
250
|
+
rsc_plugin_body_has_top_level_key?(body, "clientReferences")
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def rsc_plugin_defines_client_references?(content, is_server:)
|
|
256
|
+
rsc_plugin_option_sections(content, is_server: is_server).any? do |section|
|
|
257
|
+
rsc_plugin_body_has_top_level_key?(section.fetch(:body), "clientReferences")
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def rsc_plugin_without_client_references?(content, is_server:)
|
|
262
|
+
rsc_plugin_option_sections(content, is_server: is_server).any? do |section|
|
|
263
|
+
!rsc_plugin_body_has_top_level_key?(section.fetch(:body), "clientReferences")
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# Strips JavaScript line and block comments while preserving string-literal contents,
|
|
268
|
+
# so `clientReferences:` / `isServer:` substrings inside strings are not mis-detected.
|
|
269
|
+
# Shares the `advance_js_scan_state` family used by `js_top_level_position?` and
|
|
270
|
+
# `matching_js_closing_brace` so all JS-aware passes follow the same comment/string rules.
|
|
271
|
+
# Regex literals (e.g. `/a{2}/`) are still outside this scanner's supported surface
|
|
272
|
+
# because brace quantifiers can confuse `matching_js_closing_brace`'s depth counter.
|
|
273
|
+
def rsc_plugin_options_without_comments(options)
|
|
274
|
+
result = String.new(capacity: options.length)
|
|
275
|
+
state = nil
|
|
276
|
+
escaped = false
|
|
277
|
+
index = 0
|
|
278
|
+
|
|
279
|
+
while index < options.length
|
|
280
|
+
char = options[index]
|
|
281
|
+
next_char = options[index + 1]
|
|
282
|
+
previous_state = state
|
|
283
|
+
state, escaped, index = advance_js_scan_state(state, escaped, char, next_char, index)
|
|
284
|
+
|
|
285
|
+
# Emit when (a) we're outside comments and strings and not just entering one, or
|
|
286
|
+
# (b) we're inside or exiting a string (preserves the opening/closing quote and the
|
|
287
|
+
# string contents), or (c) we're closing a line comment so the trailing `\n` survives
|
|
288
|
+
# for line-anchored regex matching downstream (e.g. `^\s*isServer`).
|
|
289
|
+
emit_char = (previous_state.nil? && !JS_COMMENT_STATES.include?(state)) ||
|
|
290
|
+
JS_STRING_DELIMITERS.include?(previous_state) ||
|
|
291
|
+
(previous_state == :line_comment && char == "\n")
|
|
292
|
+
result << char if emit_char
|
|
293
|
+
|
|
294
|
+
index += 1
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
result
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# NOTE: `js_code_position?` itself walks from index 0 on each call, so the partition runs
|
|
301
|
+
# in O(n³ × m) (content-length³ × plugin-count) — `js_regex_literal_start?` calls
|
|
302
|
+
# `last_js_code_char_index` on each character, making `js_code_position?` itself O(n²),
|
|
303
|
+
# and every plugin hit triggers a fresh rescan. Acceptable for small webpack configs
|
|
304
|
+
# (a few hundred lines, a handful of plugins); if this scanner is ever reused on larger
|
|
305
|
+
# inputs, carry scanner state forward between iterations before adopting it.
|
|
306
|
+
def rsc_plugin_option_sections(content, is_server:)
|
|
307
|
+
rsc_plugin_option_sections_partition(content, is_server: is_server).fetch(:safe)
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# Returns the matching plugin sections plus a count of `RSCWebpackPlugin(` invocations
|
|
311
|
+
# whose options block could not be parsed cleanly. An invocation is treated as
|
|
312
|
+
# unparseable when the depth scanner cannot find a matching `}` (over-count caused by an
|
|
313
|
+
# unmatched `{` in a regex literal) or when the `}` it finds is not followed by the `)`
|
|
314
|
+
# that would close the `new RSCWebpackPlugin(...)` call (under-count caused by an
|
|
315
|
+
# unmatched `}` in a regex literal). Both cases mean a rewrite based on this section
|
|
316
|
+
# would corrupt the file, so callers must surface a warning instead of silently skipping.
|
|
317
|
+
def rsc_plugin_option_sections_partition(content, is_server:)
|
|
318
|
+
safe = []
|
|
319
|
+
unparseable = 0
|
|
320
|
+
search_from = 0
|
|
321
|
+
|
|
322
|
+
while (match = content.match(RSC_PLUGIN_INVOCATION_REGEX, search_from))
|
|
323
|
+
call_start = match.begin(0)
|
|
324
|
+
after_open_paren = match.end(0)
|
|
325
|
+
unless js_code_position?(content, call_start)
|
|
326
|
+
search_from = after_open_paren
|
|
327
|
+
next
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
options_start = first_significant_js_index(content, after_open_paren)
|
|
331
|
+
unless options_start && content[options_start] == "{"
|
|
332
|
+
search_from = after_open_paren
|
|
333
|
+
next
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
options_end = matching_js_closing_brace(content, options_start)
|
|
337
|
+
unless options_end
|
|
338
|
+
unparseable += 1
|
|
339
|
+
search_from = options_start + 1
|
|
340
|
+
next
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
unless rsc_plugin_options_followed_by_close_paren?(content, options_end)
|
|
344
|
+
unparseable += 1
|
|
345
|
+
search_from = options_end + 1
|
|
346
|
+
next
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
body = content[(options_start + 1)...options_end]
|
|
350
|
+
if rsc_plugin_is_server_match?(body, is_server: is_server)
|
|
351
|
+
safe << { body: body, body_start: options_start + 1, body_end: options_end }
|
|
352
|
+
end
|
|
353
|
+
search_from = options_end + 1
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
{ safe: safe, unparseable: unparseable }
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
# Walks forward from the assumed closing `}` of an options object, skipping whitespace
|
|
360
|
+
# and JS comments, and confirms the next significant character is `)`. Used to detect
|
|
361
|
+
# when `matching_js_closing_brace` was confused by a regex literal and returned an
|
|
362
|
+
# earlier `}` than the real options-object close.
|
|
363
|
+
#
|
|
364
|
+
# String literals between `}` and `)` are not handled because no valid JS places one
|
|
365
|
+
# there in `new RSCWebpackPlugin({...})` — a leading string-delimiter character would
|
|
366
|
+
# simply be returned as a non-`)` and the section would be marked unparseable, which is
|
|
367
|
+
# the safe outcome.
|
|
368
|
+
def rsc_plugin_options_followed_by_close_paren?(content, options_end)
|
|
369
|
+
state = nil
|
|
370
|
+
escaped = false
|
|
371
|
+
index = options_end + 1
|
|
372
|
+
# Tolerate one trailing comma between the options object and `)` so configs formatted
|
|
373
|
+
# with Prettier's `trailingComma: "all"` (`new RSCWebpackPlugin({...},)`) aren't flagged
|
|
374
|
+
# as unparseable. A second comma still bails — that would be invalid JS.
|
|
375
|
+
comma_seen = false
|
|
376
|
+
|
|
377
|
+
while index < content.length
|
|
378
|
+
char = content[index]
|
|
379
|
+
next_char = content[index + 1]
|
|
380
|
+
prev_state = state
|
|
381
|
+
state, escaped, index = advance_js_scan_state(state, escaped, char, next_char, index)
|
|
382
|
+
# Exiting a block comment leaves `char` as `*` and `index` pointing at the closing
|
|
383
|
+
# `/`; advance past it so the next iteration evaluates the first character after `*/`.
|
|
384
|
+
if state || prev_state == :block_comment
|
|
385
|
+
index += 1
|
|
386
|
+
next
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
unless char.match?(/\s/)
|
|
390
|
+
return true if char == ")"
|
|
391
|
+
return false if comma_seen || char != ","
|
|
392
|
+
|
|
393
|
+
comma_seen = true
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
index += 1
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
false
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
# Skips whitespace, JS line/block comments, and leading string literals so callers see the
|
|
403
|
+
# next structural character. Without comment skipping, configurations like
|
|
404
|
+
# `new RSCWebpackPlugin( /* opts */ {` would land on `/` and be rejected as "no plugin
|
|
405
|
+
# options" even though the options object is present.
|
|
406
|
+
def first_significant_js_index(content, start_index)
|
|
407
|
+
index = start_index
|
|
408
|
+
state = nil
|
|
409
|
+
escaped = false
|
|
410
|
+
|
|
411
|
+
while index < content.length
|
|
412
|
+
char = content[index]
|
|
413
|
+
next_char = content[index + 1]
|
|
414
|
+
prev_state = state
|
|
415
|
+
state, escaped, index = advance_js_scan_state(state, escaped, char, next_char, index)
|
|
416
|
+
# Exiting a block comment leaves `char` as `*` and `index` pointing at the closing
|
|
417
|
+
# `/`; advance past it so the next iteration evaluates the first character after `*/`.
|
|
418
|
+
if state || prev_state == :block_comment
|
|
419
|
+
index += 1
|
|
420
|
+
next
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
return index unless char.match?(/\s/) || JS_STRING_DELIMITERS.include?(prev_state)
|
|
424
|
+
|
|
425
|
+
index += 1
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
nil
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
# Expects `content[open_index] == "{"`; callers pass the options-object opening brace.
|
|
432
|
+
# This lightweight scanner treats template literals as opaque strings (backtick to backtick).
|
|
433
|
+
# Simple `${...}` expressions are handled correctly: while in the backtick state every
|
|
434
|
+
# character — including `{` and `}` inside the expression — is consumed as string content
|
|
435
|
+
# and never reaches the depth counter. The real unsupported case is *nested* template
|
|
436
|
+
# literals (e.g. `` `outer ${`inner`}` ``) where the inner backtick falsely closes the outer
|
|
437
|
+
# string state, exposing later braces to the depth counter. Callers detect that via
|
|
438
|
+
# `rsc_plugin_options_followed_by_close_paren?` and mark the section unparseable rather
|
|
439
|
+
# than producing a corrupt rewrite. Regex literals are outside this scanner's supported
|
|
440
|
+
# surface for the same reason.
|
|
441
|
+
def matching_js_closing_brace(content, open_index)
|
|
442
|
+
depth = 0
|
|
443
|
+
index = open_index
|
|
444
|
+
state = nil
|
|
445
|
+
escaped = false
|
|
446
|
+
|
|
447
|
+
while index < content.length
|
|
448
|
+
char = content[index]
|
|
449
|
+
# Nil at EOF is safe because downstream comparisons treat it as a non-match.
|
|
450
|
+
next_char = content[index + 1]
|
|
451
|
+
|
|
452
|
+
state, escaped, index = advance_js_scan_state(state, escaped, char, next_char, index)
|
|
453
|
+
if state
|
|
454
|
+
index += 1
|
|
455
|
+
next
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
# `block_comment_exit_guard`: relies on `*` matching neither `{` nor `}`, so the
|
|
459
|
+
# `*` returned at a `*/` exit is harmless here. If any future check below adds a
|
|
460
|
+
# branch that could fire on `*` (or `)`, `/`, etc.), guard it with
|
|
461
|
+
# `prev_state == :block_comment` per the contract on `advance_js_scan_state`.
|
|
462
|
+
depth += 1 if char == "{"
|
|
463
|
+
if char == "}"
|
|
464
|
+
depth -= 1
|
|
465
|
+
return index if depth.zero?
|
|
466
|
+
end
|
|
467
|
+
index += 1
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
nil
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
# Return index is the last consumed character. Line comments leave the newline
|
|
474
|
+
# for the caller's normal index increment; block comments consume the closing slash.
|
|
475
|
+
#
|
|
476
|
+
# IMPORTANT CALLER CONTRACT — block-comment exit:
|
|
477
|
+
# When this returns from a `*/` exit, `state` is cleared, but `char` is still the `*` and
|
|
478
|
+
# the returned `index` points at the closing `/` (so the caller's trailing `index += 1`
|
|
479
|
+
# lands on the first char after `*/`). Any caller whose post-call branch inspects `char`
|
|
480
|
+
# as a "significant character" (e.g. `==` checks against `*`, `/`, `)`, `{`, `}`) MUST
|
|
481
|
+
# explicitly guard on `prev_state == :block_comment` before that branch — otherwise the
|
|
482
|
+
# `*` from the comment terminator is misread as code. Callers that only update accumulator
|
|
483
|
+
# state (depth counters, string/comment booleans) are inherently safe because `*` doesn't
|
|
484
|
+
# affect those. Searchable invariant name: `block_comment_exit_guard`.
|
|
485
|
+
def advance_js_scan_state(state, escaped, char, next_char, index)
|
|
486
|
+
return [char == "\n" ? nil : :line_comment, escaped, index] if state == :line_comment
|
|
487
|
+
return advance_js_block_comment_state(escaped, char, next_char, index) if state == :block_comment
|
|
488
|
+
return advance_js_string_state(state, escaped, char, index) if JS_STRING_DELIMITERS.include?(state)
|
|
489
|
+
|
|
490
|
+
advance_js_default_scan_state(escaped, char, next_char, index)
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
def advance_js_block_comment_state(escaped, char, next_char, index)
|
|
494
|
+
return [nil, escaped, index + 1] if char == "*" && next_char == "/"
|
|
495
|
+
|
|
496
|
+
[:block_comment, escaped, index]
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
def advance_js_string_state(state, escaped, char, index)
|
|
500
|
+
return [state, false, index] if escaped
|
|
501
|
+
return [state, true, index] if char == "\\"
|
|
502
|
+
# Explicit `false` (rather than passing `escaped` through) so a caller starting mid-parse
|
|
503
|
+
# in a string state with a stale `escaped = true` cannot silently suppress the closing
|
|
504
|
+
# quote and leave the scanner stuck in string state.
|
|
505
|
+
return [nil, false, index] if char == state
|
|
506
|
+
|
|
507
|
+
[state, escaped, index]
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
def advance_js_default_scan_state(escaped, char, next_char, index)
|
|
511
|
+
return [:line_comment, escaped, index + 1] if char == "/" && next_char == "/"
|
|
512
|
+
return [:block_comment, escaped, index + 1] if char == "/" && next_char == "*"
|
|
513
|
+
return [char, escaped, index] if JS_STRING_DELIMITERS.include?(char)
|
|
514
|
+
|
|
515
|
+
[nil, escaped, index]
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
def js_code_position?(content, target_index)
|
|
519
|
+
state = nil
|
|
520
|
+
escaped = false
|
|
521
|
+
index = 0
|
|
522
|
+
|
|
523
|
+
while index < target_index
|
|
524
|
+
char = content[index]
|
|
525
|
+
next_char = content[index + 1]
|
|
526
|
+
if state.nil? && js_regex_literal_start?(content, index)
|
|
527
|
+
regex_end = js_regex_literal_end(content, index)
|
|
528
|
+
return false if regex_end >= target_index
|
|
529
|
+
|
|
530
|
+
index = regex_end + 1
|
|
531
|
+
next
|
|
532
|
+
end
|
|
533
|
+
|
|
534
|
+
state, escaped, index = advance_js_scan_state(state, escaped, char, next_char, index)
|
|
535
|
+
index += 1
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
state.nil?
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
def js_top_level_position?(content, target_index)
|
|
542
|
+
state = nil
|
|
543
|
+
escaped = false
|
|
544
|
+
depth = 0
|
|
545
|
+
index = 0
|
|
546
|
+
|
|
547
|
+
while index < target_index
|
|
548
|
+
char = content[index]
|
|
549
|
+
next_char = content[index + 1]
|
|
550
|
+
if state.nil? && js_regex_literal_start?(content, index)
|
|
551
|
+
regex_end = js_regex_literal_end(content, index)
|
|
552
|
+
return false if regex_end >= target_index
|
|
553
|
+
|
|
554
|
+
index = regex_end + 1
|
|
555
|
+
next
|
|
556
|
+
end
|
|
557
|
+
|
|
558
|
+
state, escaped, index = advance_js_scan_state(state, escaped, char, next_char, index)
|
|
559
|
+
if state
|
|
560
|
+
index += 1
|
|
561
|
+
next
|
|
562
|
+
end
|
|
563
|
+
|
|
564
|
+
depth += js_depth_delta(char, depth)
|
|
565
|
+
index += 1
|
|
566
|
+
end
|
|
567
|
+
|
|
568
|
+
state.nil? && depth.zero?
|
|
569
|
+
end
|
|
570
|
+
|
|
571
|
+
def js_depth_delta(char, depth)
|
|
572
|
+
return 1 if char == "{"
|
|
573
|
+
return -1 if char == "}" && depth.positive?
|
|
574
|
+
|
|
575
|
+
0
|
|
576
|
+
end
|
|
577
|
+
|
|
578
|
+
def js_regex_literal_start?(content, index)
|
|
579
|
+
return false unless content[index] == "/"
|
|
580
|
+
return false if ["/", "*"].include?(content[index + 1])
|
|
581
|
+
|
|
582
|
+
previous = previous_significant_js_char(content, index)
|
|
583
|
+
previous.nil? || REGEX_LITERAL_PRECEDERS.include?(previous)
|
|
584
|
+
end
|
|
585
|
+
|
|
586
|
+
def previous_significant_js_char(content, index)
|
|
587
|
+
prefix = content[0...index]
|
|
588
|
+
previous_index = last_js_code_char_index(prefix)
|
|
589
|
+
previous_index ? prefix[previous_index] : nil
|
|
590
|
+
end
|
|
591
|
+
|
|
592
|
+
def js_regex_literal_end(content, start_index)
|
|
593
|
+
index = start_index + 1
|
|
594
|
+
escaped = false
|
|
595
|
+
in_character_class = false
|
|
596
|
+
|
|
597
|
+
while index < content.length
|
|
598
|
+
char = content[index]
|
|
599
|
+
return js_regex_literal_flag_end(content, index) if js_regex_literal_closing_slash?(
|
|
600
|
+
char, escaped, in_character_class
|
|
601
|
+
)
|
|
602
|
+
|
|
603
|
+
escaped, in_character_class = next_js_regex_literal_state(char, escaped, in_character_class)
|
|
604
|
+
index += 1
|
|
605
|
+
end
|
|
606
|
+
|
|
607
|
+
# Unterminated regex literals leave the rest of the file in invalid JS, so treat the
|
|
608
|
+
# remaining content as non-code instead of resuming the normal brace/import scanner.
|
|
609
|
+
content.length - 1
|
|
610
|
+
end
|
|
611
|
+
|
|
612
|
+
def js_regex_literal_closing_slash?(char, escaped, in_character_class)
|
|
613
|
+
char == "/" && !escaped && !in_character_class
|
|
614
|
+
end
|
|
615
|
+
|
|
616
|
+
def js_regex_literal_flag_end(content, slash_index)
|
|
617
|
+
index = slash_index + 1
|
|
618
|
+
index += 1 while content[index]&.match?(/[a-z]/i)
|
|
619
|
+
index - 1
|
|
620
|
+
end
|
|
621
|
+
|
|
622
|
+
def next_js_regex_literal_state(char, escaped, in_character_class)
|
|
623
|
+
return [false, in_character_class] if escaped
|
|
624
|
+
return [true, in_character_class] if char == "\\"
|
|
625
|
+
return [false, true] if char == "["
|
|
626
|
+
return [false, false] if char == "]"
|
|
627
|
+
|
|
628
|
+
[false, in_character_class]
|
|
629
|
+
end
|
|
630
|
+
|
|
631
|
+
# Depth-aware: a nested `metadata: { isServer: true }` inside another option's value
|
|
632
|
+
# must NOT route the section into the `is_server: true` partition bucket — otherwise
|
|
633
|
+
# the rewrite would correctly bail (the splice helper is also depth-aware) but
|
|
634
|
+
# `warn_missing_rsc_plugin_target` would still fire and the user sees a misleading
|
|
635
|
+
# "no plugin options with isServer: true could be rewritten" warning for a config that
|
|
636
|
+
# is actually fine. Mirrors `rsc_plugin_body_has_top_level_key?` so partitioning and
|
|
637
|
+
# splicing agree on what counts as a top-level `isServer:` pair. Comment/string skipping
|
|
638
|
+
# is handled by `js_top_level_position?` via the shared `advance_js_scan_state`.
|
|
639
|
+
def rsc_plugin_is_server_match?(body, is_server:)
|
|
640
|
+
pattern = /\bisServer\s*:\s*#{Regexp.escape(is_server.to_s)}\b/
|
|
641
|
+
search_from = 0
|
|
642
|
+
while (match = pattern.match(body, search_from))
|
|
643
|
+
return true if js_top_level_position?(body, match.begin(0))
|
|
644
|
+
|
|
645
|
+
search_from = match.end(0)
|
|
646
|
+
end
|
|
647
|
+
false
|
|
648
|
+
end
|
|
649
|
+
|
|
650
|
+
def rewrite_rsc_plugin_client_references(config_path, is_server:)
|
|
651
|
+
full_path = File.join(destination_root, config_path)
|
|
652
|
+
# Re-read because ensure_rsc_client_references_setup may have just inserted the helper,
|
|
653
|
+
# making the caller's in-memory body_start/body_end offsets stale.
|
|
654
|
+
content = File.read(full_path)
|
|
655
|
+
rewrites = rsc_plugin_option_sections(content, is_server: is_server).filter_map do |candidate|
|
|
656
|
+
body = candidate.fetch(:body)
|
|
657
|
+
# Depth-aware check: a nested `clientReferences:` (e.g. inside a sibling object
|
|
658
|
+
# literal) must not be mistaken for a configured top-level option, or we'd skip
|
|
659
|
+
# the migration and leave the real plugin unscoped.
|
|
660
|
+
next if rsc_plugin_body_has_top_level_key?(body, "clientReferences")
|
|
661
|
+
|
|
662
|
+
rewritten_body = add_client_references_to_rsc_plugin_body(body, is_server: is_server)
|
|
663
|
+
next if rewritten_body == body
|
|
664
|
+
|
|
665
|
+
[candidate, rewritten_body]
|
|
666
|
+
end
|
|
667
|
+
|
|
668
|
+
# The sole caller (`update_existing_rsc_webpack_config`) translates this `false` into
|
|
669
|
+
# a `warn_missing_rsc_plugin_target` warning, so silently returning here is intentional.
|
|
670
|
+
return false if rewrites.empty?
|
|
671
|
+
|
|
672
|
+
# Reverse order so earlier offsets stay valid as later sections are spliced.
|
|
673
|
+
rewrites.reverse_each do |section, rewritten_body|
|
|
674
|
+
content[section.fetch(:body_start)...section.fetch(:body_end)] = rewritten_body
|
|
675
|
+
end
|
|
676
|
+
write_existing_rsc_config(config_path, content, action: :rewrite)
|
|
677
|
+
true
|
|
678
|
+
end
|
|
679
|
+
|
|
680
|
+
def rollback_incomplete_rsc_client_references_setup(config_path, original_content)
|
|
681
|
+
return if options[:pretend] || options[:skip]
|
|
682
|
+
return if scoped_rsc_client_references_defined?(original_content)
|
|
683
|
+
|
|
684
|
+
full_path = File.join(destination_root, config_path)
|
|
685
|
+
return unless scoped_rsc_client_references_defined?(File.read(full_path))
|
|
686
|
+
|
|
687
|
+
say_status(:revert, config_path, :yellow)
|
|
688
|
+
File.write(full_path, original_content)
|
|
689
|
+
end
|
|
690
|
+
|
|
691
|
+
# Multi-line option objects get the new key on its own line just before the closing brace,
|
|
692
|
+
# with indentation matching the last existing key, so the result reads cleanly without a
|
|
693
|
+
# formatter pass. Single-line option objects keep the same-line splice immediately after
|
|
694
|
+
# `isServer:` because that's the only readable place for a one-line object literal.
|
|
695
|
+
def add_client_references_to_rsc_plugin_body(body, is_server:)
|
|
696
|
+
pattern = /\bisServer\s*:\s*#{Regexp.escape(is_server.to_s)}\b/
|
|
697
|
+
search_from = 0
|
|
698
|
+
|
|
699
|
+
while (matched_is_server = pattern.match(body, search_from))
|
|
700
|
+
# Require depth zero so a nested `isServer:` inside a sibling object literal
|
|
701
|
+
# doesn't cause the splice to land inside the wrong object — `body` is the
|
|
702
|
+
# content between the plugin options braces, so depth 0 == top-level options.
|
|
703
|
+
if js_top_level_position?(body, matched_is_server.begin(0))
|
|
704
|
+
return splice_client_references_into_rsc_plugin_body(body, matched_is_server)
|
|
705
|
+
end
|
|
706
|
+
|
|
707
|
+
search_from = matched_is_server.end(0)
|
|
708
|
+
end
|
|
709
|
+
|
|
710
|
+
body
|
|
711
|
+
end
|
|
712
|
+
|
|
713
|
+
def inject_rsc_webpack_plugin_import(config_path, content, is_server:)
|
|
714
|
+
replace_rsc_client_references_setup_anchor(config_path, content, is_server: is_server) do |anchor|
|
|
715
|
+
join_rsc_client_references_setup(
|
|
716
|
+
content,
|
|
717
|
+
[
|
|
718
|
+
anchor,
|
|
719
|
+
rsc_webpack_plugin_import_statement(content)
|
|
720
|
+
]
|
|
721
|
+
)
|
|
722
|
+
end
|
|
723
|
+
end
|
|
724
|
+
|
|
725
|
+
# Walks every `<key>:` / shorthand `<key>` / quoted `"<key>":` match in the plugin options
|
|
726
|
+
# body and returns true when at least one sits at the top level of the options object
|
|
727
|
+
# (depth 0 from the body's perspective, outside strings and comments). Used to gate
|
|
728
|
+
# "already configured" checks so nested mentions and value references don't cause false
|
|
729
|
+
# positives.
|
|
730
|
+
def rsc_plugin_body_has_top_level_key?(body, key)
|
|
731
|
+
rsc_plugin_body_has_top_level_bare_key?(body, key) ||
|
|
732
|
+
rsc_plugin_body_has_top_level_quoted_key?(body, key)
|
|
733
|
+
end
|
|
734
|
+
|
|
735
|
+
def rsc_plugin_body_has_top_level_bare_key?(body, key)
|
|
736
|
+
pattern = /\b#{Regexp.escape(key)}\b/
|
|
737
|
+
search_from = 0
|
|
738
|
+
|
|
739
|
+
while (match = pattern.match(body, search_from))
|
|
740
|
+
return true if js_top_level_position?(body, match.begin(0)) &&
|
|
741
|
+
rsc_plugin_body_key_position?(body, match.begin(0), match.end(0))
|
|
742
|
+
|
|
743
|
+
search_from = match.end(0)
|
|
744
|
+
end
|
|
745
|
+
|
|
746
|
+
false
|
|
747
|
+
end
|
|
748
|
+
|
|
749
|
+
def rsc_plugin_body_has_top_level_quoted_key?(body, key)
|
|
750
|
+
pattern = /(['"`])#{Regexp.escape(key)}\1/
|
|
751
|
+
search_from = 0
|
|
752
|
+
|
|
753
|
+
while (match = pattern.match(body, search_from))
|
|
754
|
+
return true if js_top_level_position?(body, match.begin(0)) &&
|
|
755
|
+
rsc_plugin_body_key_position?(body, match.begin(0), match.end(0), quoted: true)
|
|
756
|
+
|
|
757
|
+
search_from = match.end(0)
|
|
758
|
+
end
|
|
759
|
+
|
|
760
|
+
false
|
|
761
|
+
end
|
|
762
|
+
|
|
763
|
+
def rsc_plugin_body_key_position?(body, key_start, key_end, quoted: false)
|
|
764
|
+
previous_char = previous_significant_js_char(body, key_start)
|
|
765
|
+
return false unless previous_char.nil? || previous_char == ","
|
|
766
|
+
|
|
767
|
+
next_index = first_significant_js_index(body, key_end)
|
|
768
|
+
next_char = next_index ? body[next_index] : nil
|
|
769
|
+
return next_char == ":" if quoted
|
|
770
|
+
|
|
771
|
+
next_char.nil? || next_char == ":" || next_char == ","
|
|
772
|
+
end
|
|
773
|
+
|
|
774
|
+
# Top-level depth-aware match for the migrated `clientReferences: rscClientReferences`
|
|
775
|
+
# pair. Mirrors `rsc_plugin_body_has_top_level_key?` so verification and gating share
|
|
776
|
+
# the same comment-, string-, and brace-aware semantics as the rewrite path.
|
|
777
|
+
def rsc_plugin_body_has_top_level_scoped_client_references?(body)
|
|
778
|
+
[
|
|
779
|
+
/\bclientReferences\s*:\s*rscClientReferences\b/,
|
|
780
|
+
/(['"`])clientReferences\1\s*:\s*rscClientReferences\b/
|
|
781
|
+
].any? do |pattern|
|
|
782
|
+
search_from = 0
|
|
783
|
+
|
|
784
|
+
while (match = pattern.match(body, search_from))
|
|
785
|
+
return true if js_top_level_position?(body, match.begin(0))
|
|
786
|
+
|
|
787
|
+
search_from = match.end(0)
|
|
788
|
+
end
|
|
789
|
+
end
|
|
790
|
+
|
|
791
|
+
false
|
|
792
|
+
end
|
|
793
|
+
|
|
794
|
+
def splice_client_references_into_rsc_plugin_body(body, is_server_match)
|
|
795
|
+
return splice_client_references_at_close_brace(body) if body.include?("\n")
|
|
796
|
+
# Other options follow `isServer:` on the same line — append after the last option so
|
|
797
|
+
# `clientReferences` lands at the end of the object, matching the multi-line path's
|
|
798
|
+
# close-brace splice rather than landing mid-object.
|
|
799
|
+
return splice_client_references_at_single_line_end(body) if trailing_options_after?(body, is_server_match)
|
|
800
|
+
|
|
801
|
+
"#{body[0...is_server_match.end(0)]}, clientReferences: rscClientReferences" \
|
|
802
|
+
"#{body[is_server_match.end(0)..]}"
|
|
803
|
+
end
|
|
804
|
+
|
|
805
|
+
def trailing_options_after?(body, is_server_match)
|
|
806
|
+
rest = body[is_server_match.end(0)..] || ""
|
|
807
|
+
# A bare trailing comma (`isServer: false,`) is structural, not another option, so
|
|
808
|
+
# consider only non-whitespace beyond it as "trailing options".
|
|
809
|
+
rest.sub(/\A\s*,/, "").match?(/\S/)
|
|
810
|
+
end
|
|
811
|
+
|
|
812
|
+
def splice_client_references_at_single_line_end(body)
|
|
813
|
+
trailing = body[/\s*\z/]
|
|
814
|
+
content = body[0...(body.length - trailing.length)]
|
|
815
|
+
content_without_comments = rsc_plugin_options_without_comments(content).rstrip
|
|
816
|
+
separator = content_without_comments.end_with?(",") ? " " : ", "
|
|
817
|
+
"#{content}#{separator}clientReferences: rscClientReferences#{trailing}"
|
|
818
|
+
end
|
|
819
|
+
|
|
820
|
+
def splice_client_references_at_close_brace(body)
|
|
821
|
+
trailing = body[/\s*\z/]
|
|
822
|
+
content = body[0...(body.length - trailing.length)]
|
|
823
|
+
line_ending = js_line_ending(body)
|
|
824
|
+
|
|
825
|
+
last_code_index = last_js_code_char_index(content)
|
|
826
|
+
last_line_start = last_code_index ? last_js_code_line_start(content, last_code_index) : 0
|
|
827
|
+
# `[ \t]+` (one-or-more) so the regex returns nil when the last line is unindented,
|
|
828
|
+
# letting the `|| " "` fallback actually fire. With `*` the regex would always match
|
|
829
|
+
# the empty string and the fallback was unreachable dead code.
|
|
830
|
+
indent = content[last_line_start..][/\A[ \t]+/] || " "
|
|
831
|
+
|
|
832
|
+
content_without_comments = rsc_plugin_options_without_comments(content).rstrip
|
|
833
|
+
needs_comma = !content_without_comments.end_with?(",")
|
|
834
|
+
prefix = build_splice_prefix(content, needs_comma: needs_comma)
|
|
835
|
+
|
|
836
|
+
"#{prefix}#{line_ending}#{indent}clientReferences: rscClientReferences,#{trailing}"
|
|
837
|
+
end
|
|
838
|
+
|
|
839
|
+
def last_js_code_line_start(content, last_code_index)
|
|
840
|
+
state = nil
|
|
841
|
+
escaped = false
|
|
842
|
+
index = 0
|
|
843
|
+
line_start = 0
|
|
844
|
+
|
|
845
|
+
while index <= last_code_index
|
|
846
|
+
char = content[index]
|
|
847
|
+
next_char = content[index + 1]
|
|
848
|
+
prev_state = state
|
|
849
|
+
state, escaped, index = advance_js_scan_state(state, escaped, char, next_char, index)
|
|
850
|
+
line_start = index + 1 if char == "\n" && !JS_STRING_DELIMITERS.include?(prev_state)
|
|
851
|
+
|
|
852
|
+
index += 1
|
|
853
|
+
end
|
|
854
|
+
|
|
855
|
+
line_start
|
|
856
|
+
end
|
|
857
|
+
|
|
858
|
+
# Inserts the trailing comma before any final line/block comment so the rewritten file reads
|
|
859
|
+
# cleanly to a human. Appending the comma to the raw `content` would tuck it inside a
|
|
860
|
+
# trailing `// note` (yielding `isServer: false // note,`) — syntactically valid because
|
|
861
|
+
# the comment hides the comma from the parser, but visually broken in code review and
|
|
862
|
+
# likely to confuse a linter.
|
|
863
|
+
def build_splice_prefix(content, needs_comma:)
|
|
864
|
+
return content unless needs_comma
|
|
865
|
+
|
|
866
|
+
last_code_index = last_js_code_char_index(content)
|
|
867
|
+
return "#{content}," unless last_code_index
|
|
868
|
+
|
|
869
|
+
"#{content[0..last_code_index]},#{content[(last_code_index + 1)..]}"
|
|
870
|
+
end
|
|
871
|
+
|
|
872
|
+
# Returns the index of the last character in `content` that is part of executable code —
|
|
873
|
+
# i.e. neither whitespace nor inside a JS line/block comment. Strings count as code so a
|
|
874
|
+
# value ending in `"foo"` keeps the closing quote in scope. Uses the shared
|
|
875
|
+
# `advance_js_scan_state` family so comment/string handling matches every other JS-aware
|
|
876
|
+
# pass in this file.
|
|
877
|
+
def last_js_code_char_index(content)
|
|
878
|
+
state = nil
|
|
879
|
+
escaped = false
|
|
880
|
+
index = 0
|
|
881
|
+
result = nil
|
|
882
|
+
|
|
883
|
+
while index < content.length
|
|
884
|
+
char = content[index]
|
|
885
|
+
next_char = content[index + 1]
|
|
886
|
+
prev_state = state
|
|
887
|
+
# Capture the pre-advance position so `result` always points at `char`'s index,
|
|
888
|
+
# not the post-advance value that `advance_js_scan_state` may bump for `//`/`/*`/`*/`
|
|
889
|
+
# transitions. The comment-state guard below covers those transitions, but capturing
|
|
890
|
+
# explicitly makes the invariant obvious without relying on that coupling.
|
|
891
|
+
char_index = index
|
|
892
|
+
state, escaped, index = advance_js_scan_state(state, escaped, char, next_char, index)
|
|
893
|
+
|
|
894
|
+
in_comment_now = JS_COMMENT_STATES.include?(state)
|
|
895
|
+
was_in_comment = JS_COMMENT_STATES.include?(prev_state)
|
|
896
|
+
result = char_index if !in_comment_now && !was_in_comment && !char.match?(/\s/)
|
|
897
|
+
|
|
898
|
+
index += 1
|
|
899
|
+
end
|
|
900
|
+
|
|
901
|
+
result
|
|
902
|
+
end
|
|
903
|
+
|
|
904
|
+
def rsc_client_references_setup_anchor?(content, is_server:)
|
|
905
|
+
!!rsc_client_references_setup_anchor_match(content, is_server: is_server)
|
|
906
|
+
end
|
|
907
|
+
|
|
908
|
+
# Called from the existing-config migration path, which is only reached after the
|
|
909
|
+
# generator has already confirmed `RSCWebpackPlugin` is imported in the file. That's why
|
|
910
|
+
# this helper deliberately omits the `RSCWebpackPlugin` import that `inject_rsc_*_imports`
|
|
911
|
+
# adds on the from-scratch path — adding it here would produce a duplicate import.
|
|
912
|
+
def add_rsc_client_references_setup(config_path, content, existing_imports_content, is_server:)
|
|
913
|
+
# Belt-and-suspenders: the only caller, `ensure_rsc_client_references_setup`, already
|
|
914
|
+
# checks both `scoped_rsc_client_references_defined?` and `rsc_client_references_defined?`
|
|
915
|
+
# before delegating here. The guards are kept so the helper is safe to call directly.
|
|
916
|
+
return false if scoped_rsc_client_references_defined?(content)
|
|
917
|
+
return false if rsc_client_references_defined?(content)
|
|
918
|
+
|
|
919
|
+
replace_rsc_client_references_setup_anchor(config_path, content, is_server: is_server) do |anchor|
|
|
920
|
+
join_rsc_client_references_setup(
|
|
921
|
+
content,
|
|
922
|
+
[
|
|
923
|
+
anchor,
|
|
924
|
+
# On the server path `shakapacker_config_blocker_reason` has already blocked when
|
|
925
|
+
# `config` is missing from `existing_imports_content`, so this call returns `nil`.
|
|
926
|
+
# Kept (rather than skipped on `is_server`) so removing the upstream blocker does not
|
|
927
|
+
# silently drop the import on the client path, where its absence is permitted.
|
|
928
|
+
shakapacker_config_import_statement(existing_imports_content),
|
|
929
|
+
path_resolve_import_statement(existing_imports_content),
|
|
930
|
+
"",
|
|
931
|
+
rsc_client_references_js
|
|
932
|
+
]
|
|
933
|
+
)
|
|
934
|
+
end
|
|
935
|
+
end
|
|
936
|
+
|
|
937
|
+
def replace_rsc_client_references_setup_anchor(config_path, content, is_server:)
|
|
938
|
+
anchor_match = rsc_client_references_setup_anchor_match(content, is_server: is_server)
|
|
939
|
+
return unless anchor_match
|
|
940
|
+
|
|
941
|
+
updated_content = content.dup
|
|
942
|
+
updated_content[anchor_match.begin(0)...anchor_match.end(0)] = yield anchor_match[0]
|
|
943
|
+
if options[:pretend]
|
|
944
|
+
# Pretend is handled here because this helper reports injection-specific wording;
|
|
945
|
+
# `write_existing_rsc_config` still handles pretend for direct rewrite calls.
|
|
946
|
+
say_status(:pretend, "Would inject rscClientReferences into #{config_path}", :yellow)
|
|
947
|
+
else
|
|
948
|
+
write_existing_rsc_config(config_path, updated_content, action: :insert)
|
|
949
|
+
end
|
|
950
|
+
true
|
|
951
|
+
end
|
|
952
|
+
|
|
953
|
+
def write_existing_rsc_config(config_path, content, action:)
|
|
954
|
+
if options[:pretend]
|
|
955
|
+
message =
|
|
956
|
+
if action == :insert
|
|
957
|
+
"Would inject rscClientReferences into #{config_path}"
|
|
958
|
+
else
|
|
959
|
+
"Would rewrite #{config_path}"
|
|
960
|
+
end
|
|
961
|
+
say_status(:pretend, message, :yellow)
|
|
962
|
+
return true
|
|
963
|
+
end
|
|
964
|
+
|
|
965
|
+
if options[:skip]
|
|
966
|
+
say_status(:skip, config_path, :yellow)
|
|
967
|
+
return true
|
|
968
|
+
end
|
|
969
|
+
|
|
970
|
+
# Direct write is intentional: multi-point rewrites cannot be expressed as one
|
|
971
|
+
# gsub_file call, so the raw write explicitly mirrors Thor's --skip behavior above.
|
|
972
|
+
File.write(File.join(destination_root, config_path), content)
|
|
973
|
+
say_status(action, config_path, :green)
|
|
974
|
+
true
|
|
975
|
+
end
|
|
976
|
+
|
|
977
|
+
def shakapacker_config_import_statement(existing_imports_content)
|
|
978
|
+
return if shakapacker_config_imported?(existing_imports_content)
|
|
979
|
+
|
|
980
|
+
"const { config } = require('shakapacker');"
|
|
981
|
+
end
|
|
982
|
+
|
|
983
|
+
def path_resolve_import_statement(existing_imports_content)
|
|
984
|
+
return if path_resolve_imported?(existing_imports_content)
|
|
985
|
+
|
|
986
|
+
"const { resolve } = require('path');"
|
|
987
|
+
end
|
|
988
|
+
|
|
989
|
+
# Unlike `shakapacker_config_import_statement` / `path_resolve_import_statement` which check
|
|
990
|
+
# `existing_imports_content` (the slice up through the anchor that they piggy-back on), this
|
|
991
|
+
# helper checks the FULL file content. A user's existing `RSCWebpackPlugin` import can sit
|
|
992
|
+
# below the anchor (e.g. a partially-edited or previously-failed migration), so dedup must
|
|
993
|
+
# see the whole file or it will inject a duplicate `const { RSCWebpackPlugin } = require(...)`
|
|
994
|
+
# and webpack will fail to load with `Identifier 'RSCWebpackPlugin' has already been declared`.
|
|
995
|
+
def rsc_webpack_plugin_import_statement(content)
|
|
996
|
+
return if commonjs_named_imported?(content, "react-on-rails-rsc/WebpackPlugin", "RSCWebpackPlugin")
|
|
997
|
+
|
|
998
|
+
"const { RSCWebpackPlugin } = require('react-on-rails-rsc/WebpackPlugin');"
|
|
999
|
+
end
|
|
1000
|
+
|
|
1001
|
+
def rsc_client_references_setup_import_pattern(is_server:)
|
|
1002
|
+
if is_server
|
|
1003
|
+
# Matches the standard bundler ternary from the serverWebpackConfig template, including
|
|
1004
|
+
# harmless comments or extra whitespace around the ternary operators.
|
|
1005
|
+
# Rspack-only configs without the webpack fallback receive the manual-migration warning.
|
|
1006
|
+
js_gap = "(?:\\s|//[^\\n]*(?:\\n|\\r\\n?)|/\\*[\\s\\S]*?\\*/)*"
|
|
1007
|
+
Regexp.new(
|
|
1008
|
+
"(const\\s+bundler\\s*=\\s*config\\.assets_bundler#{js_gap}" \
|
|
1009
|
+
"===#{js_gap}['\"]rspack['\"]#{js_gap}" \
|
|
1010
|
+
"\\?#{js_gap}require\\(['\"]@rspack/core['\"]\\)#{js_gap}" \
|
|
1011
|
+
":#{js_gap}require\\(['\"]webpack['\"]\\)#{js_gap};)"
|
|
1012
|
+
)
|
|
1013
|
+
else
|
|
1014
|
+
%r{(const commonWebpackConfig = require\(['"]\./commonWebpackConfig['"]\);)}
|
|
1015
|
+
end
|
|
1016
|
+
end
|
|
1017
|
+
|
|
1018
|
+
def rsc_client_references_defined?(content)
|
|
1019
|
+
# Module-scope guard mirrors the other detection helpers (e.g. `path_resolve_imported?`,
|
|
1020
|
+
# `shakapacker_config_imported?`) so a function-scoped `const rscClientReferences` does
|
|
1021
|
+
# not fool `ensure_rsc_client_references_setup` into skipping the helper injection — that
|
|
1022
|
+
# would leave the plugin rewrite referencing an out-of-scope binding.
|
|
1023
|
+
# `let` and `var` are matched alongside `const` because a hand-written
|
|
1024
|
+
# `let rscClientReferences = {...}` at module scope would otherwise slip past this check
|
|
1025
|
+
# and cause the migration to emit a second `const rscClientReferences = {...}`, producing
|
|
1026
|
+
# an `Identifier 'rscClientReferences' has already been declared` SyntaxError at config load.
|
|
1027
|
+
pattern = /^[ \t]*(?:const|let|var)\s+rscClientReferences\b/
|
|
1028
|
+
content.to_enum(:scan, pattern).any? do
|
|
1029
|
+
js_top_level_position?(content, Regexp.last_match.begin(0))
|
|
1030
|
+
end
|
|
1031
|
+
end
|
|
1032
|
+
|
|
1033
|
+
def scoped_rsc_client_references_defined?(content)
|
|
1034
|
+
# Locate the actual module-scope `const|let|var rscClientReferences = { ... }` site and
|
|
1035
|
+
# check the `directory:` key against the object literal body with comments stripped.
|
|
1036
|
+
# Running the regex against the raw file would treat a stale, commented-out
|
|
1037
|
+
# `// directory: resolve(config.source_path)` (e.g. left over from a prior failed
|
|
1038
|
+
# migration) as a real scoped declaration and silently short-circuit
|
|
1039
|
+
# `ensure_rsc_client_references_setup`, leaving the plugin unscoped without any warning.
|
|
1040
|
+
decl_pattern = /^[ \t]*(?:const|let|var)\s+rscClientReferences\s*=\s*\{/
|
|
1041
|
+
content.to_enum(:scan, decl_pattern).any? do
|
|
1042
|
+
match = Regexp.last_match
|
|
1043
|
+
next false unless js_top_level_position?(content, match.begin(0))
|
|
1044
|
+
|
|
1045
|
+
open_brace = match.end(0) - 1
|
|
1046
|
+
close_brace = matching_js_closing_brace(content, open_brace)
|
|
1047
|
+
next false unless close_brace
|
|
1048
|
+
|
|
1049
|
+
body = content[(open_brace + 1)...close_brace]
|
|
1050
|
+
rsc_plugin_options_without_comments(body)
|
|
1051
|
+
.match?(/\bdirectory\s*:\s*resolve\(\s*config\.source_path\s*\)/)
|
|
1052
|
+
end
|
|
1053
|
+
end
|
|
1054
|
+
|
|
1055
|
+
# Inclusive slice — the anchor itself is part of the returned content because callers
|
|
1056
|
+
# (`rsc_setup_blocked_by_later_imports?`, `inject_rsc_*_imports`, the import-detection
|
|
1057
|
+
# helpers) check whether required imports appear up to and including the anchor line.
|
|
1058
|
+
# Anything past the anchor is considered "later" and disqualifies an otherwise-valid import.
|
|
1059
|
+
def content_through_rsc_setup_anchor(content, is_server:)
|
|
1060
|
+
anchor = rsc_client_references_setup_anchor_match(content, is_server: is_server)
|
|
1061
|
+
return "" unless anchor
|
|
1062
|
+
|
|
1063
|
+
content[0...anchor.end(0)]
|
|
1064
|
+
end
|
|
1065
|
+
|
|
1066
|
+
def rsc_client_references_setup_anchor_match(content, is_server:)
|
|
1067
|
+
pattern = rsc_client_references_setup_import_pattern(is_server: is_server)
|
|
1068
|
+
content.to_enum(:scan, pattern).each do
|
|
1069
|
+
match = Regexp.last_match
|
|
1070
|
+
return match if js_code_position?(content, match.begin(0))
|
|
1071
|
+
end
|
|
1072
|
+
nil
|
|
1073
|
+
end
|
|
1074
|
+
|
|
1075
|
+
def rsc_setup_blocked_by_later_imports?(
|
|
1076
|
+
config_path,
|
|
1077
|
+
content,
|
|
1078
|
+
existing_imports_content,
|
|
1079
|
+
is_server:,
|
|
1080
|
+
plugin_pending: false
|
|
1081
|
+
)
|
|
1082
|
+
reason = rsc_setup_blocker_reason(content, existing_imports_content, is_server: is_server)
|
|
1083
|
+
return false unless reason
|
|
1084
|
+
|
|
1085
|
+
manual_action =
|
|
1086
|
+
if plugin_pending
|
|
1087
|
+
"RSCWebpackPlugin will be added without scoped clientReferences; please add clientReferences manually."
|
|
1088
|
+
elsif content.include?("RSCWebpackPlugin")
|
|
1089
|
+
"Please add clientReferences manually."
|
|
1090
|
+
else
|
|
1091
|
+
"RSCWebpackPlugin was not added to #{config_path}; please add the plugin and clientReferences manually."
|
|
1092
|
+
end
|
|
1093
|
+
|
|
1094
|
+
GeneratorMessages.add_warning(
|
|
1095
|
+
"Could not inject rscClientReferences into #{config_path}: #{reason}. " \
|
|
1096
|
+
"#{manual_action}"
|
|
1097
|
+
)
|
|
1098
|
+
true
|
|
1099
|
+
end
|
|
1100
|
+
|
|
1101
|
+
# Reports the first blocker that prevents the generator from injecting `rscClientReferences`.
|
|
1102
|
+
# Each branch returns a message specific enough that the user can act on it without re-deriving
|
|
1103
|
+
# the cause from a generic "imports unavailable" warning.
|
|
1104
|
+
def rsc_setup_blocker_reason(content, existing_imports_content, is_server:)
|
|
1105
|
+
path_resolve_blocker_reason(content, existing_imports_content) ||
|
|
1106
|
+
shakapacker_config_blocker_reason(content, existing_imports_content, is_server: is_server)
|
|
1107
|
+
end
|
|
1108
|
+
|
|
1109
|
+
def path_resolve_blocker_reason(content, existing_imports_content)
|
|
1110
|
+
return nil if path_resolve_imported?(existing_imports_content)
|
|
1111
|
+
|
|
1112
|
+
if top_level_resolve_binding?(content)
|
|
1113
|
+
"a top-level `resolve` binding already exists that would conflict with the injected " \
|
|
1114
|
+
"`const { resolve } = require('path')`"
|
|
1115
|
+
elsif path_resolve_imported?(content)
|
|
1116
|
+
"the `resolve` import from `path` appears after the expected anchor line — " \
|
|
1117
|
+
"move the import above it"
|
|
1118
|
+
end
|
|
1119
|
+
end
|
|
1120
|
+
|
|
1121
|
+
# Intentional server/client asymmetry: when shakapacker's `config` is absent from the
|
|
1122
|
+
# file entirely (not misplaced, not aliased), the client branch falls through and returns
|
|
1123
|
+
# `nil` so the generator proceeds and adds the import alongside `rscClientReferences`. The
|
|
1124
|
+
# server branch always blocks because the server anchor (`config.assets_bundler === 'rspack'`)
|
|
1125
|
+
# itself requires `config` to be in scope, so an absent shakapacker import is always a user
|
|
1126
|
+
# configuration problem there.
|
|
1127
|
+
def shakapacker_config_blocker_reason(content, existing_imports_content, is_server:)
|
|
1128
|
+
return nil if shakapacker_config_imported?(existing_imports_content)
|
|
1129
|
+
|
|
1130
|
+
shakapacker_anywhere = shakapacker_config_imported?(content)
|
|
1131
|
+
|
|
1132
|
+
if shakapacker_anywhere
|
|
1133
|
+
if is_server
|
|
1134
|
+
"shakapacker's `config` is imported after the bundler ternary anchor — " \
|
|
1135
|
+
"move the import above it"
|
|
1136
|
+
else
|
|
1137
|
+
"shakapacker's `config` is imported after the `commonWebpackConfig` anchor — " \
|
|
1138
|
+
"move the import above it"
|
|
1139
|
+
end
|
|
1140
|
+
elsif top_level_config_binding?(content)
|
|
1141
|
+
"a top-level `config` binding already exists that would conflict with the injected " \
|
|
1142
|
+
"`const { config } = require('shakapacker')`"
|
|
1143
|
+
elsif is_server
|
|
1144
|
+
"shakapacker's `config` is not imported in this file — add " \
|
|
1145
|
+
"`const { config } = require('shakapacker');` before the bundler ternary"
|
|
1146
|
+
end
|
|
1147
|
+
end
|
|
1148
|
+
|
|
1149
|
+
def shakapacker_config_imported?(content)
|
|
1150
|
+
return true if commonjs_named_imported?(content, "shakapacker", "config")
|
|
1151
|
+
|
|
1152
|
+
top_level_dot_access_import?(content,
|
|
1153
|
+
/^[ \t]*(?:const|let|var)\s+config\s*=\s*require\(['"]shakapacker['"]\)\.config/)
|
|
1154
|
+
end
|
|
1155
|
+
|
|
1156
|
+
def path_resolve_imported?(content)
|
|
1157
|
+
# Full-module imports (`const path = require('path')`) do not create the bare `resolve` binding
|
|
1158
|
+
# that rscClientReferences uses, so the generator may add a harmless named import alongside them.
|
|
1159
|
+
return true if commonjs_named_imported?(content, "path", "resolve")
|
|
1160
|
+
|
|
1161
|
+
top_level_dot_access_import?(content,
|
|
1162
|
+
/^[ \t]*(?:const|let|var)\s+resolve\s*=\s*require\(['"]path['"]\)\.resolve/)
|
|
1163
|
+
end
|
|
1164
|
+
|
|
1165
|
+
# Verifies that a regex match is at module-scope depth=0 to avoid false positives
|
|
1166
|
+
# from function-scoped `require` calls (which do not produce module-scope bindings).
|
|
1167
|
+
def top_level_dot_access_import?(content, pattern)
|
|
1168
|
+
content.to_enum(:scan, pattern).any? do
|
|
1169
|
+
js_top_level_position?(content, Regexp.last_match.begin(0))
|
|
1170
|
+
end
|
|
1171
|
+
end
|
|
1172
|
+
|
|
1173
|
+
def top_level_resolve_binding?(content)
|
|
1174
|
+
top_level_binding?(content, "resolve")
|
|
1175
|
+
end
|
|
1176
|
+
|
|
1177
|
+
def top_level_config_binding?(content)
|
|
1178
|
+
top_level_binding?(content, "config")
|
|
1179
|
+
end
|
|
1180
|
+
|
|
1181
|
+
def rsc_client_references_setup_anchor_available?(config_path, content, is_server:, plugin_pending: false)
|
|
1182
|
+
return true if rsc_client_references_setup_anchor?(content, is_server: is_server)
|
|
1183
|
+
|
|
1184
|
+
warn_missing_rsc_client_references_anchor(config_path, plugin_pending: plugin_pending)
|
|
1185
|
+
false
|
|
1186
|
+
end
|
|
1187
|
+
|
|
1188
|
+
def rsc_client_references_setup_ready?(config_path, plugin_pending: false)
|
|
1189
|
+
# In pretend/skip modes nothing is written to disk, so reading the file back would
|
|
1190
|
+
# report the helper as missing and the caller would fall through to a misleading
|
|
1191
|
+
# "injection failed" warning. Reporting success here lets callers show what *would*
|
|
1192
|
+
# be added (e.g. `, clientReferences: rscClientReferences` in the pretend plugin
|
|
1193
|
+
# output) instead.
|
|
1194
|
+
return true if options[:pretend]
|
|
1195
|
+
return true if options[:skip]
|
|
1196
|
+
return true if scoped_rsc_client_references_defined?(File.read(File.join(destination_root, config_path)))
|
|
1197
|
+
|
|
1198
|
+
warn_rsc_client_references_injection_failed(config_path, plugin_pending: plugin_pending)
|
|
1199
|
+
false
|
|
1200
|
+
end
|
|
1201
|
+
|
|
1202
|
+
def warn_missing_rsc_client_references_anchor(config_path, plugin_pending: false)
|
|
1203
|
+
GeneratorMessages.add_warning(
|
|
1204
|
+
"Could not inject rscClientReferences into #{config_path}: expected webpack import anchor was not found " \
|
|
1205
|
+
"(the generator looks for the single- or double-quoted CommonJS `require` anchor that the ROR " \
|
|
1206
|
+
"templates emit; backtick template-literal require paths and Rspack-only server configs without " \
|
|
1207
|
+
"the webpack fallback ternary must be migrated manually). " \
|
|
1208
|
+
"If your config uses ESM `import` syntax, the generator cannot migrate it automatically. " \
|
|
1209
|
+
"#{manual_rsc_plugin_action(config_path, plugin_pending: plugin_pending)}"
|
|
1210
|
+
)
|
|
1211
|
+
end
|
|
1212
|
+
|
|
1213
|
+
def warn_rsc_client_references_injection_failed(config_path, plugin_pending: false)
|
|
1214
|
+
GeneratorMessages.add_warning(
|
|
1215
|
+
"Could not inject rscClientReferences into #{config_path}: expected webpack import anchor was found, " \
|
|
1216
|
+
"but the generated scoped helper setup was not written. " \
|
|
1217
|
+
"#{manual_rsc_plugin_action(config_path, plugin_pending: plugin_pending)}"
|
|
1218
|
+
)
|
|
1219
|
+
end
|
|
1220
|
+
|
|
1221
|
+
def manual_rsc_plugin_action(config_path, plugin_pending:)
|
|
1222
|
+
if plugin_pending
|
|
1223
|
+
"RSCWebpackPlugin was not added to #{config_path}; please add the plugin and clientReferences manually."
|
|
1224
|
+
else
|
|
1225
|
+
"Please add clientReferences manually."
|
|
1226
|
+
end
|
|
1227
|
+
end
|
|
1228
|
+
|
|
1229
|
+
def warn_unscoped_rsc_client_references_helper(config_path)
|
|
1230
|
+
GeneratorMessages.add_warning(
|
|
1231
|
+
"Skipped scoped clientReferences migration for #{config_path} because rscClientReferences already exists " \
|
|
1232
|
+
"but does not point to resolve(config.source_path). Please verify it manually."
|
|
1233
|
+
)
|
|
1234
|
+
end
|
|
1235
|
+
|
|
1236
|
+
def warn_missing_rsc_plugin_target(config_path, is_server:)
|
|
1237
|
+
GeneratorMessages.add_warning(
|
|
1238
|
+
"Could not update RSCWebpackPlugin in #{config_path}: no plugin options with isServer: #{is_server} " \
|
|
1239
|
+
"could be rewritten. Please add clientReferences manually. Dynamic or computed plugin options cannot be " \
|
|
1240
|
+
"verified automatically, so verify this file manually after adding clientReferences."
|
|
1241
|
+
)
|
|
1242
|
+
end
|
|
1243
|
+
|
|
1244
|
+
def commonjs_named_imported?(content, package_name, binding_name)
|
|
1245
|
+
content_without_comments = rsc_plugin_options_without_comments(content)
|
|
1246
|
+
pattern = /^[ \t]*(?:const|let|var)\s+\{([^}]*)\}\s*=\s*require\(['"]#{Regexp.escape(package_name)}['"]\);?/
|
|
1247
|
+
|
|
1248
|
+
content_without_comments.to_enum(:scan, pattern).any? do |captures|
|
|
1249
|
+
# Module-scope check guards against false positives when the same destructuring
|
|
1250
|
+
# appears inside a function body (which does not produce a module-scope binding).
|
|
1251
|
+
next false unless js_top_level_position?(content_without_comments, Regexp.last_match.begin(0))
|
|
1252
|
+
|
|
1253
|
+
destructuring_declares_binding?(captures.first, binding_name)
|
|
1254
|
+
end
|
|
1255
|
+
end
|
|
1256
|
+
|
|
1257
|
+
def top_level_binding?(content, binding_name)
|
|
1258
|
+
direct_binding = top_level_direct_binding?(content, binding_name)
|
|
1259
|
+
destructured_binding = top_level_destructured_binding?(content, binding_name)
|
|
1260
|
+
|
|
1261
|
+
direct_binding || destructured_binding
|
|
1262
|
+
end
|
|
1263
|
+
|
|
1264
|
+
def top_level_direct_binding?(content, binding_name)
|
|
1265
|
+
binding_pattern = Regexp.escape(binding_name)
|
|
1266
|
+
pattern = /
|
|
1267
|
+
^[ \t]*
|
|
1268
|
+
(?:
|
|
1269
|
+
(?:const|let|var)\s+#{binding_pattern}\b |
|
|
1270
|
+
function\s+#{binding_pattern}\s*\( |
|
|
1271
|
+
class\s+#{binding_pattern}\b
|
|
1272
|
+
)
|
|
1273
|
+
/x
|
|
1274
|
+
|
|
1275
|
+
content.to_enum(:scan, pattern).any? do
|
|
1276
|
+
js_top_level_position?(content, Regexp.last_match.begin(0))
|
|
1277
|
+
end
|
|
1278
|
+
end
|
|
1279
|
+
|
|
1280
|
+
def top_level_destructured_binding?(content, binding_name)
|
|
1281
|
+
content_without_comments = rsc_plugin_options_without_comments(content)
|
|
1282
|
+
pattern = /^[ \t]*(?:const|let|var)\s+\{([^}]*)\}/
|
|
1283
|
+
|
|
1284
|
+
content_without_comments.to_enum(:scan, pattern).any? do |captures|
|
|
1285
|
+
next false unless js_top_level_position?(content_without_comments, Regexp.last_match.begin(0))
|
|
1286
|
+
|
|
1287
|
+
destructuring_declares_binding?(captures.first, binding_name)
|
|
1288
|
+
end
|
|
1289
|
+
end
|
|
1290
|
+
|
|
1291
|
+
def destructuring_declares_binding?(bindings, binding_name)
|
|
1292
|
+
rsc_plugin_options_without_comments(bindings).split(",").any? do |binding|
|
|
1293
|
+
destructuring_binding_declares_name?(binding.strip, binding_name)
|
|
1294
|
+
end
|
|
1295
|
+
end
|
|
1296
|
+
|
|
1297
|
+
def destructuring_binding_declares_name?(binding, binding_name)
|
|
1298
|
+
binding_pattern = Regexp.escape(binding_name)
|
|
1299
|
+
|
|
1300
|
+
# Aliases (`config: alias`) do not provide the exact binding that rscClientReferences uses.
|
|
1301
|
+
# Self-aliases (`config: config`) and defaults (`config = fallback`, `config=fallback`) do.
|
|
1302
|
+
binding.match?(/\A#{binding_pattern}(?:\z|\s*=)/) ||
|
|
1303
|
+
binding.match?(/\A#{binding_pattern}\s*:\s*#{binding_pattern}(?:\z|\s*=)/)
|
|
1304
|
+
end
|
|
1305
|
+
end
|
|
1306
|
+
end
|
|
1307
|
+
end
|
|
1308
|
+
end
|