react_on_rails 17.0.0.rc.2 → 17.0.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 +1 -1
- data/lib/generators/USAGE +6 -0
- data/lib/generators/react_on_rails/base_generator.rb +20 -2
- data/lib/generators/react_on_rails/generator_helper.rb +7 -0
- data/lib/generators/react_on_rails/install_generator.rb +58 -1
- data/lib/generators/react_on_rails/js_dependency_manager.rb +36 -0
- data/lib/generators/react_on_rails/react_no_redux_generator.rb +20 -8
- data/lib/generators/react_on_rails/react_with_redux_generator.rb +28 -4
- data/lib/generators/react_on_rails/rsc_generator.rb +5 -0
- data/lib/generators/react_on_rails/rsc_setup.rb +35 -1
- data/lib/generators/react_on_rails/templates/agent_files/.cursor/rules/react-on-rails.mdc +11 -0
- data/lib/generators/react_on_rails/templates/agent_files/.github/copilot-instructions.md +7 -0
- data/lib/generators/react_on_rails/templates/agent_files/AGENTS.md +164 -0
- data/lib/generators/react_on_rails/templates/agent_files/CLAUDE.md +8 -0
- data/lib/generators/react_on_rails/templates/base/base/config/webpack/commonWebpackConfig.js.tt +81 -1
- data/lib/generators/react_on_rails/templates/base/tailwind/app/javascript/src/HelloWorld/ror_components/HelloWorld.client.jsx +30 -0
- data/lib/generators/react_on_rails/templates/base/tailwind/app/javascript/src/HelloWorld/ror_components/HelloWorld.client.tsx +34 -0
- data/lib/generators/react_on_rails/templates/base/tailwind/app/javascript/stylesheets/application.css +1 -0
- data/lib/generators/react_on_rails/templates/redux/tailwind/app/javascript/bundles/HelloWorld/components/HelloWorld.jsx +25 -0
- data/lib/generators/react_on_rails/templates/redux/tailwind/app/javascript/bundles/HelloWorld/components/HelloWorld.tsx +29 -0
- data/lib/react_on_rails/doctor.rb +187 -24
- data/lib/react_on_rails/font_helper.rb +162 -0
- data/lib/react_on_rails/helper.rb +150 -0
- data/lib/react_on_rails/smart_error.rb +135 -5
- data/lib/react_on_rails/version.rb +1 -1
- data/lib/tasks/doctor.rake +5 -2
- data/sig/react_on_rails/helper.rbs +3 -0
- data/sig/react_on_rails/smart_error.rbs +25 -2
- metadata +11 -1
data/lib/generators/react_on_rails/templates/base/base/config/webpack/commonWebpackConfig.js.tt
CHANGED
|
@@ -11,7 +11,87 @@ const commonOptions = {
|
|
|
11
11
|
},
|
|
12
12
|
};
|
|
13
13
|
|
|
14
|
+
<% if use_tailwind? -%>
|
|
15
|
+
const tailwindPostcssPlugin = require('@tailwindcss/postcss');
|
|
16
|
+
|
|
17
|
+
const tailwindPostcssLoader = {
|
|
18
|
+
loader: 'postcss-loader',
|
|
19
|
+
options: {
|
|
20
|
+
postcssOptions: {
|
|
21
|
+
plugins: [tailwindPostcssPlugin],
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const loaderName = (loader) => {
|
|
27
|
+
if (typeof loader === 'string') return loader;
|
|
28
|
+
if (loader && typeof loader.loader === 'string') return loader.loader;
|
|
29
|
+
return '';
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const postcssLoaderUsesTailwind = (loader) => {
|
|
33
|
+
const plugins = loader?.options?.postcssOptions?.plugins || [];
|
|
34
|
+
const tailwindPluginName = tailwindPostcssPlugin.postcssPlugin || tailwindPostcssPlugin.name;
|
|
35
|
+
|
|
36
|
+
return plugins.some((plugin) => {
|
|
37
|
+
if (plugin === tailwindPostcssPlugin) return true;
|
|
38
|
+
if (!tailwindPluginName) return false;
|
|
39
|
+
|
|
40
|
+
return plugin?.postcssPlugin === tailwindPluginName || plugin?.name === tailwindPluginName;
|
|
41
|
+
});
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const mergeTailwindPostcssLoader = (loader) => {
|
|
45
|
+
if (typeof loader === 'string') return tailwindPostcssLoader;
|
|
46
|
+
if (postcssLoaderUsesTailwind(loader)) return loader;
|
|
47
|
+
|
|
48
|
+
const existingOptions = loader.options || {};
|
|
49
|
+
const existingPostcssOptions = existingOptions.postcssOptions || {};
|
|
50
|
+
const existingPlugins = existingPostcssOptions.plugins || [];
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
...loader,
|
|
54
|
+
options: {
|
|
55
|
+
...existingOptions,
|
|
56
|
+
postcssOptions: {
|
|
57
|
+
...existingPostcssOptions,
|
|
58
|
+
plugins: [...existingPlugins, tailwindPostcssPlugin],
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const addTailwindPostcssLoader = (webpackConfig) => {
|
|
65
|
+
const rules = webpackConfig.module?.rules;
|
|
66
|
+
if (!Array.isArray(rules)) return webpackConfig;
|
|
67
|
+
|
|
68
|
+
rules.forEach((rule) => {
|
|
69
|
+
if (!Array.isArray(rule.use)) return;
|
|
70
|
+
|
|
71
|
+
const postcssLoaderIndex = rule.use.findIndex((loader) => loaderName(loader).includes('postcss-loader'));
|
|
72
|
+
if (postcssLoaderIndex !== -1) {
|
|
73
|
+
rule.use[postcssLoaderIndex] = mergeTailwindPostcssLoader(rule.use[postcssLoaderIndex]);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const cssLoaderIndex = rule.use.findIndex((loader) => loaderName(loader).includes('css-loader'));
|
|
78
|
+
if (cssLoaderIndex === -1) return;
|
|
79
|
+
|
|
80
|
+
rule.use.splice(cssLoaderIndex + 1, 0, tailwindPostcssLoader);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
return webpackConfig;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
<% end -%>
|
|
14
87
|
// Copy the object using merge b/c the baseClientWebpackConfig and commonOptions are mutable globals
|
|
15
|
-
const commonWebpackConfig = () =>
|
|
88
|
+
const commonWebpackConfig = () => {
|
|
89
|
+
const webpackConfig = merge({}, baseClientWebpackConfig, commonOptions);
|
|
90
|
+
<% if use_tailwind? -%>
|
|
91
|
+
return addTailwindPostcssLoader(webpackConfig);
|
|
92
|
+
<% else -%>
|
|
93
|
+
return webpackConfig;
|
|
94
|
+
<% end -%>
|
|
95
|
+
};
|
|
16
96
|
|
|
17
97
|
module.exports = commonWebpackConfig;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import '../../../stylesheets/application.css';
|
|
3
|
+
|
|
4
|
+
const HelloWorld = (props) => {
|
|
5
|
+
const [name, setName] = useState(props.name);
|
|
6
|
+
|
|
7
|
+
return (
|
|
8
|
+
<section className="mx-auto max-w-xl rounded-lg border border-slate-200 bg-white p-6 text-slate-900 shadow-sm">
|
|
9
|
+
<p className="text-sm font-semibold uppercase text-sky-600">React on Rails + Tailwind CSS</p>
|
|
10
|
+
<h3 className="mt-2 text-2xl font-bold">Hello, {name}!</h3>
|
|
11
|
+
<p className="mt-3 text-sm leading-6 text-slate-600">
|
|
12
|
+
This component is server-rendered by Rails and styled by Tailwind CSS v4.
|
|
13
|
+
</p>
|
|
14
|
+
<form className="mt-5">
|
|
15
|
+
<label className="block text-sm font-medium text-slate-700" htmlFor="name">
|
|
16
|
+
Say hello to:
|
|
17
|
+
<input
|
|
18
|
+
id="name"
|
|
19
|
+
className="mt-2 w-full rounded-md border border-slate-300 px-3 py-2 shadow-sm focus:border-sky-500 focus:outline-hidden focus:ring-2 focus:ring-sky-200"
|
|
20
|
+
type="text"
|
|
21
|
+
value={name}
|
|
22
|
+
onChange={(e) => setName(e.target.value)}
|
|
23
|
+
/>
|
|
24
|
+
</label>
|
|
25
|
+
</form>
|
|
26
|
+
</section>
|
|
27
|
+
);
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export default HelloWorld;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import '../../../stylesheets/application.css';
|
|
3
|
+
|
|
4
|
+
interface HelloWorldProps {
|
|
5
|
+
name: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const HelloWorld: React.FC<HelloWorldProps> = (props) => {
|
|
9
|
+
const [name, setName] = useState(props.name);
|
|
10
|
+
|
|
11
|
+
return (
|
|
12
|
+
<section className="mx-auto max-w-xl rounded-lg border border-slate-200 bg-white p-6 text-slate-900 shadow-sm">
|
|
13
|
+
<p className="text-sm font-semibold uppercase text-sky-600">React on Rails + Tailwind CSS</p>
|
|
14
|
+
<h3 className="mt-2 text-2xl font-bold">Hello, {name}!</h3>
|
|
15
|
+
<p className="mt-3 text-sm leading-6 text-slate-600">
|
|
16
|
+
This component is server-rendered by Rails and styled by Tailwind CSS v4.
|
|
17
|
+
</p>
|
|
18
|
+
<form className="mt-5">
|
|
19
|
+
<label className="block text-sm font-medium text-slate-700" htmlFor="name">
|
|
20
|
+
Say hello to:
|
|
21
|
+
<input
|
|
22
|
+
id="name"
|
|
23
|
+
className="mt-2 w-full rounded-md border border-slate-300 px-3 py-2 shadow-sm focus:border-sky-500 focus:outline-hidden focus:ring-2 focus:ring-sky-200"
|
|
24
|
+
type="text"
|
|
25
|
+
value={name}
|
|
26
|
+
onChange={(e) => setName(e.target.value)}
|
|
27
|
+
/>
|
|
28
|
+
</label>
|
|
29
|
+
</form>
|
|
30
|
+
</section>
|
|
31
|
+
);
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export default HelloWorld;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
@import "tailwindcss";
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
const HelloWorld = ({ name, updateName }) => (
|
|
4
|
+
<section className="mx-auto max-w-xl rounded-lg border border-slate-200 bg-white p-6 text-slate-900 shadow-sm">
|
|
5
|
+
<p className="text-sm font-semibold uppercase text-sky-600">React on Rails + Redux + Tailwind CSS</p>
|
|
6
|
+
<h3 className="mt-2 text-2xl font-bold">Hello, {name}!</h3>
|
|
7
|
+
<p className="mt-3 text-sm leading-6 text-slate-600">
|
|
8
|
+
This Redux-backed component is server-rendered by Rails and styled by Tailwind CSS v4.
|
|
9
|
+
</p>
|
|
10
|
+
<div className="mt-5">
|
|
11
|
+
<label className="block text-sm font-medium text-slate-700" htmlFor="name">
|
|
12
|
+
Say hello to:
|
|
13
|
+
<input
|
|
14
|
+
id="name"
|
|
15
|
+
className="mt-2 w-full rounded-md border border-slate-300 px-3 py-2 shadow-sm focus:border-sky-500 focus:outline-hidden focus:ring-2 focus:ring-sky-200"
|
|
16
|
+
type="text"
|
|
17
|
+
value={name}
|
|
18
|
+
onChange={(e) => updateName(e.target.value)}
|
|
19
|
+
/>
|
|
20
|
+
</label>
|
|
21
|
+
</div>
|
|
22
|
+
</section>
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
export default HelloWorld;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { PropsFromRedux } from '../containers/HelloWorldContainer';
|
|
3
|
+
|
|
4
|
+
// Component props are inferred from Redux container
|
|
5
|
+
type HelloWorldProps = PropsFromRedux;
|
|
6
|
+
|
|
7
|
+
const HelloWorld: React.FC<HelloWorldProps> = ({ name, updateName }) => (
|
|
8
|
+
<section className="mx-auto max-w-xl rounded-lg border border-slate-200 bg-white p-6 text-slate-900 shadow-sm">
|
|
9
|
+
<p className="text-sm font-semibold uppercase text-sky-600">React on Rails + Redux + Tailwind CSS</p>
|
|
10
|
+
<h3 className="mt-2 text-2xl font-bold">Hello, {name}!</h3>
|
|
11
|
+
<p className="mt-3 text-sm leading-6 text-slate-600">
|
|
12
|
+
This Redux-backed component is server-rendered by Rails and styled by Tailwind CSS v4.
|
|
13
|
+
</p>
|
|
14
|
+
<div className="mt-5">
|
|
15
|
+
<label className="block text-sm font-medium text-slate-700" htmlFor="name">
|
|
16
|
+
Say hello to:
|
|
17
|
+
<input
|
|
18
|
+
id="name"
|
|
19
|
+
className="mt-2 w-full rounded-md border border-slate-300 px-3 py-2 shadow-sm focus:border-sky-500 focus:outline-hidden focus:ring-2 focus:ring-sky-200"
|
|
20
|
+
type="text"
|
|
21
|
+
value={name}
|
|
22
|
+
onChange={(e) => updateName(e.target.value)}
|
|
23
|
+
/>
|
|
24
|
+
</label>
|
|
25
|
+
</div>
|
|
26
|
+
</section>
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
export default HelloWorld;
|
|
@@ -3,9 +3,11 @@
|
|
|
3
3
|
require "json"
|
|
4
4
|
require "erb"
|
|
5
5
|
require "stringio"
|
|
6
|
+
require "tempfile"
|
|
6
7
|
require "timeout"
|
|
7
8
|
require "yaml"
|
|
8
9
|
require_relative "utils"
|
|
10
|
+
require_relative "version"
|
|
9
11
|
require_relative "config_path_resolver"
|
|
10
12
|
require_relative "shakapacker_config_helpers"
|
|
11
13
|
require_relative "dev/server_mode"
|
|
@@ -116,15 +118,69 @@ module ReactOnRails
|
|
|
116
118
|
# than this is a sign of an unexpectedly broad pattern, not legitimate config.
|
|
117
119
|
RENDERER_CACHE_DEPLOY_SCRIPT_GLOB_MAX_MATCHES = 100
|
|
118
120
|
|
|
119
|
-
|
|
121
|
+
# Supported output formats. :text is the human-readable default; :json emits
|
|
122
|
+
# a machine-readable report (see JSON_SCHEMA_VERSION below).
|
|
123
|
+
OUTPUT_FORMATS = %i[text json].freeze
|
|
124
|
+
|
|
125
|
+
# Version of the machine-readable doctor report schema (FORMAT=json).
|
|
126
|
+
# Bump ONLY on breaking changes to the shape below. Schema (v1):
|
|
127
|
+
#
|
|
128
|
+
# {
|
|
129
|
+
# "schema_version": 1,
|
|
130
|
+
# "ror_version": "<ReactOnRails::VERSION>",
|
|
131
|
+
# "status": "pass" | "warn" | "fail", // worst check status
|
|
132
|
+
# "checks": [
|
|
133
|
+
# {
|
|
134
|
+
# "id": "<stable snake_case id from CHECK_SECTIONS>",
|
|
135
|
+
# "title": "<human section title>",
|
|
136
|
+
# "status": "pass" | "warn" | "fail",
|
|
137
|
+
# "message": "<most severe message content, or null>",
|
|
138
|
+
# "details": [ { "level": "success|warning|error|info", "content": "..." } ]
|
|
139
|
+
# }
|
|
140
|
+
# ],
|
|
141
|
+
# "summary": { "pass": <count>, "warn": <count>, "fail": <count> }
|
|
142
|
+
# }
|
|
143
|
+
#
|
|
144
|
+
# No timestamp is included so output is deterministic for a given app state.
|
|
145
|
+
# Exit code semantics match text mode: 1 if any check fails, else 0.
|
|
146
|
+
JSON_SCHEMA_VERSION = 1
|
|
147
|
+
|
|
148
|
+
# Doctor check sections. The :id values are part of the stable JSON schema
|
|
149
|
+
# contract (consumed by agents/tooling) — never rename or reuse them; add new
|
|
150
|
+
# sections with new ids instead.
|
|
151
|
+
CHECK_SECTIONS = [
|
|
152
|
+
{ id: "environment_prerequisites", title: "Environment Prerequisites", method: :check_environment },
|
|
153
|
+
{ id: "react_on_rails_versions", title: "React on Rails Versions", method: :check_react_on_rails_versions },
|
|
154
|
+
{ id: "react_on_rails_packages", title: "React on Rails Packages", method: :check_packages },
|
|
155
|
+
{ id: "javascript_package_dependencies", title: "JavaScript Package Dependencies",
|
|
156
|
+
method: :check_dependencies },
|
|
157
|
+
{ id: "key_configuration_files", title: "Key Configuration Files", method: :check_key_files },
|
|
158
|
+
{ id: "configuration_analysis", title: "Configuration Analysis", method: :check_configuration_details },
|
|
159
|
+
{ id: "bin_dev_launcher_setup", title: "bin/dev Launcher Setup", method: :check_bin_dev_launcher },
|
|
160
|
+
{ id: "rails_integration", title: "Rails Integration", method: :check_rails },
|
|
161
|
+
{ id: "bundler_configuration", title: "Bundler Configuration", method: :check_bundler_configuration },
|
|
162
|
+
{ id: "testing_setup", title: "Testing Setup", method: :check_testing_setup },
|
|
163
|
+
{ id: "development_environment", title: "Development Environment", method: :check_development },
|
|
164
|
+
{ id: "react_on_rails_pro_setup", title: "React on Rails Pro Setup", method: :check_pro_setup },
|
|
165
|
+
{ id: "react_server_components", title: "React Server Components", method: :check_rsc_setup }
|
|
166
|
+
].freeze
|
|
167
|
+
|
|
168
|
+
def initialize(verbose: false, fix: false, format: :text)
|
|
120
169
|
@verbose = verbose
|
|
121
170
|
@fix = fix
|
|
171
|
+
@format = format.respond_to?(:to_sym) ? format.to_sym : format
|
|
172
|
+
unless OUTPUT_FORMATS.include?(@format)
|
|
173
|
+
raise ArgumentError, "Invalid doctor format #{format.inspect}; expected one of #{OUTPUT_FORMATS.join(', ')}"
|
|
174
|
+
end
|
|
175
|
+
|
|
122
176
|
@checker = SystemChecker.new
|
|
123
177
|
@test_output_path_strategy = :unknown
|
|
124
178
|
@rails_environment_loaded = false
|
|
125
179
|
end
|
|
126
180
|
|
|
127
181
|
def run_diagnosis
|
|
182
|
+
return run_json_diagnosis if format == :json
|
|
183
|
+
|
|
128
184
|
print_header
|
|
129
185
|
run_all_checks
|
|
130
186
|
print_summary
|
|
@@ -135,7 +191,7 @@ module ReactOnRails
|
|
|
135
191
|
|
|
136
192
|
private
|
|
137
193
|
|
|
138
|
-
attr_reader :verbose, :fix, :checker
|
|
194
|
+
attr_reader :verbose, :fix, :format, :checker
|
|
139
195
|
|
|
140
196
|
def print_header
|
|
141
197
|
puts Rainbow("\n#{'=' * 80}").cyan
|
|
@@ -156,35 +212,139 @@ module ReactOnRails
|
|
|
156
212
|
end
|
|
157
213
|
|
|
158
214
|
def run_all_checks
|
|
159
|
-
|
|
160
|
-
["Environment Prerequisites", :check_environment],
|
|
161
|
-
["React on Rails Versions", :check_react_on_rails_versions],
|
|
162
|
-
["React on Rails Packages", :check_packages],
|
|
163
|
-
["JavaScript Package Dependencies", :check_dependencies],
|
|
164
|
-
["Key Configuration Files", :check_key_files],
|
|
165
|
-
["Configuration Analysis", :check_configuration_details],
|
|
166
|
-
["bin/dev Launcher Setup", :check_bin_dev_launcher],
|
|
167
|
-
["Rails Integration", :check_rails],
|
|
168
|
-
["Bundler Configuration", :check_bundler_configuration],
|
|
169
|
-
["Testing Setup", :check_testing_setup],
|
|
170
|
-
["Development Environment", :check_development],
|
|
171
|
-
["React on Rails Pro Setup", :check_pro_setup],
|
|
172
|
-
["React Server Components", :check_rsc_setup]
|
|
173
|
-
]
|
|
174
|
-
|
|
175
|
-
checks.each do |section_name, check_method|
|
|
215
|
+
CHECK_SECTIONS.each do |section|
|
|
176
216
|
initial_message_count = checker.messages.length
|
|
177
|
-
send(
|
|
217
|
+
send(section[:method])
|
|
178
218
|
|
|
179
219
|
# Only print header if messages were added
|
|
180
220
|
next unless checker.messages.length > initial_message_count
|
|
181
221
|
|
|
182
|
-
print_section_header(
|
|
222
|
+
print_section_header(section[:title])
|
|
183
223
|
print_recent_messages(initial_message_count)
|
|
184
224
|
puts
|
|
185
225
|
end
|
|
186
226
|
end
|
|
187
227
|
|
|
228
|
+
# JSON output mode: stdout carries ONLY the JSON report (schema documented
|
|
229
|
+
# at JSON_SCHEMA_VERSION); any stray output produced by checks is routed to
|
|
230
|
+
# stderr so the report stays parseable.
|
|
231
|
+
def run_json_diagnosis
|
|
232
|
+
results = nil
|
|
233
|
+
stray_output = capture_stdout { results = collect_check_results }
|
|
234
|
+
$stderr.print(stray_output) unless stray_output.empty?
|
|
235
|
+
|
|
236
|
+
puts JSON.pretty_generate(build_json_report(results))
|
|
237
|
+
exit(diagnosis_exit_code)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def collect_check_results
|
|
241
|
+
CHECK_SECTIONS.map do |section|
|
|
242
|
+
initial_message_count = checker.messages.length
|
|
243
|
+
send(section[:method])
|
|
244
|
+
|
|
245
|
+
{
|
|
246
|
+
id: section[:id],
|
|
247
|
+
title: section[:title],
|
|
248
|
+
messages: checker.messages[initial_message_count..]
|
|
249
|
+
}
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# Captures everything written to the stdout file descriptor (fd 1) while the
|
|
254
|
+
# block runs — including direct STDOUT writes, child processes inheriting
|
|
255
|
+
# fd 1, and C extensions — not just Ruby-level $stdout. Reassigning $stdout
|
|
256
|
+
# to a StringIO would miss those, letting stray bytes corrupt the JSON report.
|
|
257
|
+
#
|
|
258
|
+
# NOT thread-safe: STDOUT.reopen redirects fd 1 process-wide, so any
|
|
259
|
+
# concurrent thread writing to stdout is captured too. Doctor runs only as
|
|
260
|
+
# a single-threaded rake task today; revisit before using in threaded code.
|
|
261
|
+
# rubocop:disable Style/GlobalStdStream
|
|
262
|
+
def capture_stdout
|
|
263
|
+
captured_file = Tempfile.new("react_on_rails_doctor_stdout")
|
|
264
|
+
original_stdout = $stdout
|
|
265
|
+
original_fd = STDOUT.dup
|
|
266
|
+
STDOUT.flush
|
|
267
|
+
STDOUT.reopen(captured_file.path, "w")
|
|
268
|
+
$stdout = STDOUT
|
|
269
|
+
yield
|
|
270
|
+
STDOUT.flush
|
|
271
|
+
File.read(captured_file.path)
|
|
272
|
+
ensure
|
|
273
|
+
begin
|
|
274
|
+
STDOUT.flush
|
|
275
|
+
rescue IOError, SystemCallError
|
|
276
|
+
nil
|
|
277
|
+
end
|
|
278
|
+
if original_fd
|
|
279
|
+
begin
|
|
280
|
+
STDOUT.reopen(original_fd)
|
|
281
|
+
ensure
|
|
282
|
+
original_fd.close
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
$stdout = original_stdout if original_stdout
|
|
286
|
+
captured_file&.close!
|
|
287
|
+
end
|
|
288
|
+
# rubocop:enable Style/GlobalStdStream
|
|
289
|
+
|
|
290
|
+
def build_json_report(results)
|
|
291
|
+
checks = results.map { |result| build_check_entry(result) }
|
|
292
|
+
statuses = checks.map { |check| check[:status] }
|
|
293
|
+
|
|
294
|
+
{
|
|
295
|
+
schema_version: JSON_SCHEMA_VERSION,
|
|
296
|
+
ror_version: ReactOnRails::VERSION,
|
|
297
|
+
status: overall_status(statuses),
|
|
298
|
+
checks:,
|
|
299
|
+
summary: {
|
|
300
|
+
pass: statuses.count("pass"),
|
|
301
|
+
warn: statuses.count("warn"),
|
|
302
|
+
fail: statuses.count("fail")
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def build_check_entry(result)
|
|
308
|
+
messages = result[:messages]
|
|
309
|
+
status = check_status(messages)
|
|
310
|
+
|
|
311
|
+
{
|
|
312
|
+
id: result[:id],
|
|
313
|
+
title: result[:title],
|
|
314
|
+
status:,
|
|
315
|
+
message: primary_message(messages, status),
|
|
316
|
+
details: messages.map { |message| { level: message[:type].to_s, content: message[:content] } }
|
|
317
|
+
}
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def check_status(messages)
|
|
321
|
+
if messages.any? { |message| message[:type] == :error }
|
|
322
|
+
"fail"
|
|
323
|
+
elsif messages.any? { |message| message[:type] == :warning }
|
|
324
|
+
"warn"
|
|
325
|
+
else
|
|
326
|
+
"pass"
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def overall_status(statuses)
|
|
331
|
+
if statuses.include?("fail")
|
|
332
|
+
"fail"
|
|
333
|
+
elsif statuses.include?("warn")
|
|
334
|
+
"warn"
|
|
335
|
+
else
|
|
336
|
+
"pass"
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def primary_message(messages, status)
|
|
341
|
+
severity = { "fail" => :error, "warn" => :warning }[status]
|
|
342
|
+
return nil unless severity
|
|
343
|
+
|
|
344
|
+
message = messages.find { |msg| msg[:type] == severity }
|
|
345
|
+
message && message[:content]
|
|
346
|
+
end
|
|
347
|
+
|
|
188
348
|
def print_section_header(section_name)
|
|
189
349
|
puts Rainbow("#{section_name}:").blue.bold
|
|
190
350
|
puts Rainbow("-" * (section_name.length + 1)).blue
|
|
@@ -1765,14 +1925,17 @@ module ReactOnRails
|
|
|
1765
1925
|
def exit_with_status
|
|
1766
1926
|
if checker.errors?
|
|
1767
1927
|
puts Rainbow("❌ Doctor found critical issues. Please address errors above.").red.bold
|
|
1768
|
-
exit(1)
|
|
1769
1928
|
elsif checker.warnings?
|
|
1770
1929
|
puts Rainbow("⚠️ Doctor found some issues. Consider addressing warnings above.").yellow
|
|
1771
|
-
exit(0)
|
|
1772
1930
|
else
|
|
1773
1931
|
puts Rainbow("🎉 All checks passed! Your React on Rails setup is healthy.").green.bold
|
|
1774
|
-
exit(0)
|
|
1775
1932
|
end
|
|
1933
|
+
|
|
1934
|
+
exit(diagnosis_exit_code)
|
|
1935
|
+
end
|
|
1936
|
+
|
|
1937
|
+
def diagnosis_exit_code
|
|
1938
|
+
checker.errors? ? 1 : 0
|
|
1776
1939
|
end
|
|
1777
1940
|
|
|
1778
1941
|
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/concern"
|
|
4
|
+
|
|
5
|
+
module ReactOnRails
|
|
6
|
+
# First-party font optimization helper, a `next/font/local` analog for Rails.
|
|
7
|
+
#
|
|
8
|
+
# Given a self-hosted (committed + fingerprinted) `.woff2` file already served by
|
|
9
|
+
# your asset pipeline, this returns the markup an ERB view should place in the
|
|
10
|
+
# document `<head>`:
|
|
11
|
+
#
|
|
12
|
+
# 1. `<link rel="preload" as="font" type="font/woff2" crossorigin>` so the
|
|
13
|
+
# browser fetches the font in parallel with the first paint;
|
|
14
|
+
# 2. an `@font-face` rule with `font-display: swap` so text renders immediately
|
|
15
|
+
# with a fallback and swaps in the web font when ready;
|
|
16
|
+
# 3. an optional metric-matched fallback `@font-face` (`size-adjust` plus
|
|
17
|
+
# `ascent-override` / `descent-override` / `line-gap-override`) so the system
|
|
18
|
+
# fallback occupies the same space as the web font, eliminating the layout
|
|
19
|
+
# shift (CLS) that normally happens on the swap.
|
|
20
|
+
#
|
|
21
|
+
# It mirrors the `<head>`-injection convention used by `react_component_hash`
|
|
22
|
+
# (see `ReactOnRails::Helper`): the caller wraps the return value in
|
|
23
|
+
# `content_for :head`, and the layout yields it inside `<head>`.
|
|
24
|
+
#
|
|
25
|
+
# Example (ERB view):
|
|
26
|
+
#
|
|
27
|
+
# <% content_for :head do %>
|
|
28
|
+
# <%= react_on_rails_font_face(
|
|
29
|
+
# family: "Inter",
|
|
30
|
+
# src: asset_path("inter-latin-400-normal.woff2"),
|
|
31
|
+
# weight: 400,
|
|
32
|
+
# fallback: {
|
|
33
|
+
# family: "Arial",
|
|
34
|
+
# size_adjust: "107.12%",
|
|
35
|
+
# ascent_override: "90.44%",
|
|
36
|
+
# descent_override: "22.52%",
|
|
37
|
+
# line_gap_override: "0.0%"
|
|
38
|
+
# }
|
|
39
|
+
# ) %>
|
|
40
|
+
# <% end %>
|
|
41
|
+
#
|
|
42
|
+
# Then set the CSS font stack to the web font followed by its fallback face, e.g.
|
|
43
|
+
# `font-family: "Inter", "Inter Fallback", sans-serif;`.
|
|
44
|
+
module FontHelper
|
|
45
|
+
extend ActiveSupport::Concern
|
|
46
|
+
|
|
47
|
+
# CSS/HTML metacharacters that would let an argument break out of the
|
|
48
|
+
# `<style>` / `<link>` context this helper emits.
|
|
49
|
+
UNSAFE_TOKEN = /[<>"\r\n]/
|
|
50
|
+
|
|
51
|
+
# Emits the preload `<link>`, the primary `@font-face`, and (when `fallback:`
|
|
52
|
+
# is supplied) a metric-matched fallback `@font-face`.
|
|
53
|
+
#
|
|
54
|
+
# @param family [String] CSS font-family name for the web font (e.g. "Inter").
|
|
55
|
+
# @param src [String] URL/path to the `.woff2` (typically `asset_path(...)`).
|
|
56
|
+
# @param weight [Integer, String] `font-weight` (default 400). A range like
|
|
57
|
+
# "100 900" is valid for variable fonts.
|
|
58
|
+
# @param style [String] `font-style` (default "normal").
|
|
59
|
+
# @param display [String] `font-display` (default "swap").
|
|
60
|
+
# @param unicode_range [String, nil] optional `unicode-range` to subset the face.
|
|
61
|
+
# @param preload [Boolean] emit the preload `<link>` (default true).
|
|
62
|
+
# @param fallback [Hash, nil] metric-matched fallback face. Keys:
|
|
63
|
+
# :family (required, the local system font, e.g. "Arial"),
|
|
64
|
+
# :name (the generated face name, default "#{family} Fallback"),
|
|
65
|
+
# :size_adjust, :ascent_override, :descent_override, :line_gap_override.
|
|
66
|
+
# @return [ActiveSupport::SafeBuffer] head markup, ready for `content_for :head`.
|
|
67
|
+
#
|
|
68
|
+
# @note SECURITY: arguments are interpolated verbatim into the trusted CSS/HTML
|
|
69
|
+
# emitted into `<head>` and the result is marked `html_safe`. Pass only
|
|
70
|
+
# developer-controlled values (font names, asset paths), never end-user input.
|
|
71
|
+
# Values containing `<`, `>`, `"`, or a newline raise `ArgumentError`.
|
|
72
|
+
def react_on_rails_font_face(family:, src:, weight: 400, style: "normal", display: "swap",
|
|
73
|
+
unicode_range: nil, preload: true, fallback: nil)
|
|
74
|
+
ReactOnRails::FontHelper.font_face_markup(
|
|
75
|
+
family:, src:, weight:, style:, display:,
|
|
76
|
+
unicode_range:, preload:, fallback:
|
|
77
|
+
).html_safe
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Pure-string builder so the markup can be unit-tested without a view context.
|
|
81
|
+
# Returns a plain (not html_safe) String.
|
|
82
|
+
def self.font_face_markup(family:, src:, weight: 400, style: "normal", display: "swap",
|
|
83
|
+
unicode_range: nil, preload: true, fallback: nil)
|
|
84
|
+
# Validate every value interpolated into the trusted <style>/<link> markup so no
|
|
85
|
+
# argument can break out of the CSS/HTML context (see the contract in the docstring).
|
|
86
|
+
ensure_safe!("family", family)
|
|
87
|
+
ensure_safe!("src", src)
|
|
88
|
+
ensure_safe!("weight", weight)
|
|
89
|
+
ensure_safe!("style", style)
|
|
90
|
+
ensure_safe!("display", display)
|
|
91
|
+
ensure_safe!("unicode_range", unicode_range) if unicode_range
|
|
92
|
+
if fallback
|
|
93
|
+
ensure_safe!("fallback[:family]", fallback.fetch(:family))
|
|
94
|
+
ensure_safe!("fallback[:name]", fallback[:name]) if fallback[:name]
|
|
95
|
+
%i[size_adjust ascent_override descent_override line_gap_override].each do |key|
|
|
96
|
+
ensure_safe!("fallback[:#{key}]", fallback[key]) if fallback[key]
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
rule = font_face_rule(family:, src:, weight:, style:, display:, unicode_range:)
|
|
100
|
+
parts = []
|
|
101
|
+
parts << preload_link(src) if preload
|
|
102
|
+
parts << +"<style>\n#{rule}"
|
|
103
|
+
parts[-1] << "\n#{fallback_font_face_rule(family, fallback, weight:, style:)}" if fallback
|
|
104
|
+
parts[-1] << "\n</style>"
|
|
105
|
+
parts.join("\n")
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Rejects values that could break out of the CSS/HTML context. Font helper
|
|
109
|
+
# arguments are emitted into trusted `<head>` markup, so they must be
|
|
110
|
+
# developer-controlled, not end-user input.
|
|
111
|
+
def self.ensure_safe!(name, value)
|
|
112
|
+
return unless value.to_s.match?(UNSAFE_TOKEN)
|
|
113
|
+
|
|
114
|
+
raise ArgumentError,
|
|
115
|
+
"react_on_rails_font_face: #{name}=#{value.inspect} contains an unsafe character " \
|
|
116
|
+
"(<, >, \", or newline); font arguments must be developer-controlled, not end-user input."
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def self.preload_link(src)
|
|
120
|
+
%(<link rel="preload" href="#{src}" as="font" type="font/woff2" crossorigin="anonymous">)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def self.font_face_rule(family:, src:, weight:, style:, display:, unicode_range:)
|
|
124
|
+
lines = [
|
|
125
|
+
"@font-face {",
|
|
126
|
+
%( font-family: "#{family}";),
|
|
127
|
+
%( src: url("#{src}") format("woff2");),
|
|
128
|
+
" font-weight: #{weight};",
|
|
129
|
+
" font-style: #{style};",
|
|
130
|
+
" font-display: #{display};"
|
|
131
|
+
]
|
|
132
|
+
lines << " unicode-range: #{unicode_range};" if unicode_range
|
|
133
|
+
lines << "}"
|
|
134
|
+
lines.join("\n")
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Generates the metric-matched fallback face. Hardcode `size-adjust` and the
|
|
138
|
+
# override percentages from the font's published metrics (see the fonts docs
|
|
139
|
+
# for how the Inter-over-Arial numbers are derived). The `font-weight` and
|
|
140
|
+
# `font-style` mirror the primary face so the browser matches this fallback to
|
|
141
|
+
# the same elements (otherwise the size-adjust CLS protection silently fails for
|
|
142
|
+
# non-400 weights / non-normal styles), the way `next/font` generates its
|
|
143
|
+
# weight-matched fallback.
|
|
144
|
+
def self.fallback_font_face_rule(family, fallback, weight:, style:)
|
|
145
|
+
name = fallback[:name] || "#{family} Fallback"
|
|
146
|
+
local = fallback.fetch(:family)
|
|
147
|
+
lines = [
|
|
148
|
+
"@font-face {",
|
|
149
|
+
%( font-family: "#{name}";),
|
|
150
|
+
%( src: local("#{local}");),
|
|
151
|
+
" font-weight: #{weight};",
|
|
152
|
+
" font-style: #{style};"
|
|
153
|
+
]
|
|
154
|
+
lines << " size-adjust: #{fallback[:size_adjust]};" if fallback[:size_adjust]
|
|
155
|
+
lines << " ascent-override: #{fallback[:ascent_override]};" if fallback[:ascent_override]
|
|
156
|
+
lines << " descent-override: #{fallback[:descent_override]};" if fallback[:descent_override]
|
|
157
|
+
lines << " line-gap-override: #{fallback[:line_gap_override]};" if fallback[:line_gap_override]
|
|
158
|
+
lines << "}"
|
|
159
|
+
lines.join("\n")
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|