nativekeel 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +101 -0
- package/bin/nativekeel.js +162 -0
- package/package.json +38 -0
- package/src/analyze.js +360 -0
- package/src/baseline.js +23 -0
- package/src/compare.js +55 -0
- package/src/compat.js +125 -0
- package/src/config.js +3 -0
- package/src/elf-align.js +136 -0
- package/src/interop.js +53 -0
- package/src/known-issues.js +61 -0
- package/src/native.js +454 -0
- package/src/plan.js +357 -0
- package/src/project.js +112 -0
- package/src/registry.js +89 -0
- package/src/report-html.js +87 -0
- package/src/report-md.js +42 -0
- package/src/report-sarif.js +61 -0
- package/src/report-text.js +72 -0
- package/src/rules.js +59 -0
- package/src/secrets.js +98 -0
- package/src/usage.js +127 -0
package/src/plan.js
ADDED
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { UPGRADE_HELPER_URL, NEW_ARCH_ONLY_MINOR } from './rules.js';
|
|
4
|
+
import { SITE_URL } from './config.js';
|
|
5
|
+
import { COMPAT, bestRange } from './compat.js';
|
|
6
|
+
|
|
7
|
+
// Turns a scan result into an ordered upgrade plan. The order matters more than the steps:
|
|
8
|
+
// stop leaks first, then remove blockers, switch architecture on the current version,
|
|
9
|
+
// and only then move React Native itself.
|
|
10
|
+
|
|
11
|
+
function packageManager(root) {
|
|
12
|
+
let dir = root;
|
|
13
|
+
while (true) {
|
|
14
|
+
if (fs.existsSync(path.join(dir, 'pnpm-lock.yaml'))) return 'pnpm';
|
|
15
|
+
if (fs.existsSync(path.join(dir, 'yarn.lock'))) return 'yarn';
|
|
16
|
+
if (fs.existsSync(path.join(dir, 'bun.lockb')) || fs.existsSync(path.join(dir, 'bun.lock'))) return 'bun';
|
|
17
|
+
if (fs.existsSync(path.join(dir, 'package-lock.json'))) return 'npm';
|
|
18
|
+
const parent = path.dirname(dir);
|
|
19
|
+
if (parent === dir) return 'npm';
|
|
20
|
+
dir = parent;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const commands = {
|
|
25
|
+
npm: { add: 'npm install', remove: 'npm uninstall' },
|
|
26
|
+
yarn: { add: 'yarn add', remove: 'yarn remove' },
|
|
27
|
+
pnpm: { add: 'pnpm add', remove: 'pnpm remove' },
|
|
28
|
+
bun: { add: 'bun add', remove: 'bun remove' },
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// Intermediate stops roughly every three minors keep each diff reviewable.
|
|
32
|
+
export function upgradeHops(from, to) {
|
|
33
|
+
const start = Number(from.split('.')[1]);
|
|
34
|
+
const end = Number(to.split('.')[1]);
|
|
35
|
+
const hops = [];
|
|
36
|
+
for (let m = start + 3; m < end; m += 3) hops.push(`0.${m}.0`);
|
|
37
|
+
hops.push(to);
|
|
38
|
+
return hops;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const WEIGHTS = {
|
|
42
|
+
'upgrade-rn': (f) => Number(f.to.split('.')[1]) - Number(f.from.split('.')[1]),
|
|
43
|
+
'upgrade-expo': (f) => (f.to - f.from) * 2,
|
|
44
|
+
'enable-new-arch': () => 6,
|
|
45
|
+
'replace-dep': (f) => (f.noNewArch ? 4 : f.native ? 3 : 1),
|
|
46
|
+
'bump-dep': (f) => (f.native ? 1 : 0.5),
|
|
47
|
+
secret: (f) => (f.bundled ? 2 : 1),
|
|
48
|
+
'target-sdk': () => 2,
|
|
49
|
+
'align-16kb': (f) => f.libs.length,
|
|
50
|
+
'privacy-manifest': () => 1,
|
|
51
|
+
'remove-flipper': () => 1,
|
|
52
|
+
'enable-hermes': () => 2,
|
|
53
|
+
manifest: () => 0.5,
|
|
54
|
+
ats: () => 0.5,
|
|
55
|
+
'min-sdk': () => 1,
|
|
56
|
+
'ios-min': () => 1,
|
|
57
|
+
'remove-dep': () => 0.25,
|
|
58
|
+
'known-issue': () => 1,
|
|
59
|
+
compat: () => 1,
|
|
60
|
+
'app-dependency-provider': () => 1,
|
|
61
|
+
'interop-job': () => 2,
|
|
62
|
+
'podfile-cli-require': () => 0.25,
|
|
63
|
+
rctappdelegate: () => 2,
|
|
64
|
+
'patch-artifacts': () => 0.5,
|
|
65
|
+
'patch-version': () => 1,
|
|
66
|
+
'folly-flags': () => 0.25,
|
|
67
|
+
'default-react-host': () => 0.25,
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export function complexity(findings) {
|
|
71
|
+
const score = Math.round(
|
|
72
|
+
findings.reduce((sum, f) => sum + (f.fix && WEIGHTS[f.fix.kind] ? WEIGHTS[f.fix.kind](f.fix) : 0), 0),
|
|
73
|
+
);
|
|
74
|
+
const size = score < 8 ? 'Small' : score < 20 ? 'Medium' : score < 40 ? 'Large' : 'Extra large';
|
|
75
|
+
return { score, size };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function buildPlan(result) {
|
|
79
|
+
const pm = commands[packageManager(result.project.root)];
|
|
80
|
+
const installed = new Set(result.deps.map((d) => d.name));
|
|
81
|
+
const byKind = (kind) => result.findings.filter((f) => f.fix && f.fix.kind === kind).map((f) => f.fix);
|
|
82
|
+
const phases = [];
|
|
83
|
+
|
|
84
|
+
const secrets = byKind('secret');
|
|
85
|
+
if (secrets.length) {
|
|
86
|
+
const steps = [];
|
|
87
|
+
for (const s of secrets.filter((x) => x.bundled)) {
|
|
88
|
+
if (/^\.env/.test(s.file)) {
|
|
89
|
+
steps.push(`**Rotate the secret values in \`${s.file}\`.** They are compiled into the JavaScript bundle, so every installed copy of the app contains them.`);
|
|
90
|
+
} else {
|
|
91
|
+
steps.push(`**Revoke the ${s.label}** in \`${s.file}:${s.line}\` at the provider today. Removing it from code is not enough: every installed copy of the app still contains it.`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (secrets.some((x) => x.bundled)) {
|
|
95
|
+
steps.push('Move every call that needs a secret behind your own backend (a Cloud Function, API route or edge function). The app should only hold keys that are designed to be public.');
|
|
96
|
+
}
|
|
97
|
+
for (const s of secrets.filter((x) => !x.bundled)) {
|
|
98
|
+
if (s.label === 'Signing passwords') {
|
|
99
|
+
steps.push(`**Move the signing passwords out of \`${s.file}\`** into \`~/.gradle/gradle.properties\` or CI environment variables; change them if the keystore ever left your control.`);
|
|
100
|
+
} else {
|
|
101
|
+
steps.push(`**Rotate the ${s.label}** in \`${s.file}\`, then load it from environment variables or a secret manager and add the file to \`.gitignore\`.`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
steps.push('The old values stay in git history. Rotation is what makes them useless; rewriting history (e.g. `git filter-repo`) is optional cleanup.');
|
|
105
|
+
phases.push({ title: 'Stop the leaks', why: 'Leaked keys cost money and data from the moment someone finds them. Nothing else in this plan is as urgent.', steps });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const sdk = byKind('target-sdk')[0];
|
|
109
|
+
if (sdk) {
|
|
110
|
+
phases.push({
|
|
111
|
+
title: 'Meet the Google Play target SDK',
|
|
112
|
+
why: 'Below the required level Google Play rejects updates or hides the app from new users.',
|
|
113
|
+
steps: [
|
|
114
|
+
`Set \`targetSdkVersion = ${sdk.to}\` and \`compileSdkVersion = ${sdk.to}\` in \`android/build.gradle\`.`,
|
|
115
|
+
`Read the Android behavior changes for every API level between ${sdk.from} and ${sdk.to}; edge-to-edge display and foreground service types are the usual surprises.`,
|
|
116
|
+
'Build a release bundle and test on a device running the newest Android version.',
|
|
117
|
+
],
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const known = byKind('known-issue');
|
|
122
|
+
if (known.length) {
|
|
123
|
+
phases.push({
|
|
124
|
+
title: 'Avoid known traps',
|
|
125
|
+
why: 'These exact version combinations broke real apps. Handle them before the step that would trigger them.',
|
|
126
|
+
steps: known.map((k) => `**${k.title}.** ${k.detail}`),
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const unusedDeps = byKind('remove-dep');
|
|
131
|
+
if (unusedDeps.length) {
|
|
132
|
+
phases.push({
|
|
133
|
+
title: 'Remove unused packages',
|
|
134
|
+
why: 'Every package you delete is one you never have to upgrade. Native modules also cut build time.',
|
|
135
|
+
steps: [
|
|
136
|
+
`\`${pm.remove} ${unusedDeps.map((d) => d.name).join(' ')}\`${unusedDeps.some((d) => d.native) ? ', then reinstall pods' : ''}.`,
|
|
137
|
+
'Search the code once more for each name before removing it (dynamic requires are invisible to static analysis), then build both platforms.',
|
|
138
|
+
],
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const storeSteps = [];
|
|
143
|
+
if (byKind('privacy-manifest').length) {
|
|
144
|
+
storeSteps.push('Add `PrivacyInfo.xcprivacy` to the iOS app target. Start from the file in the React Native template and declare the required-reason APIs your app and SDKs use (UserDefaults, file timestamps, system boot time, disk space).');
|
|
145
|
+
}
|
|
146
|
+
const align = byKind('align-16kb')[0];
|
|
147
|
+
if (align) {
|
|
148
|
+
storeSteps.push(`Fix 16 KB alignment for ${align.libs.map((l) => `\`${l}\``).join(', ')}: find which package ships each library (\`grep -rl <lib> node_modules/*/android\`) and update it; libraries you build yourself need NDK r28+.`);
|
|
149
|
+
}
|
|
150
|
+
if (storeSteps.length) {
|
|
151
|
+
phases.push({
|
|
152
|
+
title: 'Meet App Store and Google Play requirements',
|
|
153
|
+
why: 'These block your next store submission regardless of anything else in this plan.',
|
|
154
|
+
steps: storeSteps,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const securitySteps = [];
|
|
159
|
+
for (const m of byKind('manifest')) {
|
|
160
|
+
securitySteps.push(m.attr === 'android:debuggable'
|
|
161
|
+
? 'Remove `android:debuggable="true"` from `android/app/src/main/AndroidManifest.xml`.'
|
|
162
|
+
: 'Move `android:usesCleartextTraffic="true"` to `android/app/src/debug/AndroidManifest.xml`, or allow only specific hosts with a network security config.');
|
|
163
|
+
}
|
|
164
|
+
for (const a of byKind('ats')) {
|
|
165
|
+
securitySteps.push(`In \`${a.file}\`, set \`NSAllowsArbitraryLoads\` to false and list only the hosts that need HTTP under \`NSExceptionDomains\`.`);
|
|
166
|
+
}
|
|
167
|
+
if (securitySteps.length) {
|
|
168
|
+
phases.push({ title: 'Tighten transport security', why: 'Unencrypted traffic exposes user data on public networks.', steps: securitySteps });
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const cleanup = [];
|
|
172
|
+
for (const f of byKind('default-react-host')) {
|
|
173
|
+
cleanup.push(`When moving to React Native 0.80+, change \`DefaultReactHost.getDefaultReactHost(context, host)\` in \`${f.file}\` to pass \`null\` as a third argument.`);
|
|
174
|
+
}
|
|
175
|
+
for (const f of byKind('folly-flags')) {
|
|
176
|
+
cleanup.push(`In \`${f.file}\`, add \`-DFOLLY_CFG_NO_COROUTINES=1\` and \`-DFOLLY_HAVE_CLOCK_GETTIME=1\` to OTHER_CPLUSPLUSFLAGS (Debug and Release) before moving to React Native 0.80.`);
|
|
177
|
+
}
|
|
178
|
+
for (const p of byKind('patch-artifacts')) {
|
|
179
|
+
cleanup.push(`Recreate \`patches/${p.file}\` without build output: reinstall \`${p.pkg}\`, re-apply the source edits, then \`npx patch-package ${p.pkg} --exclude '(^package\\.json$|/build/)'\`.`);
|
|
180
|
+
}
|
|
181
|
+
for (const p of byKind('patch-version')) {
|
|
182
|
+
cleanup.push(`\`patches/${p.file}\` targets ${p.pkg} ${p.version} but ${p.installed} is installed: check whether the fix is still needed, then recreate or delete it.`);
|
|
183
|
+
}
|
|
184
|
+
if (byKind('podfile-cli-require').length) {
|
|
185
|
+
cleanup.push("Delete `require_relative '../node_modules/@react-native-community/cli-platform-ios/native_modules'` from `ios/Podfile`; `use_native_modules!` comes from React Native.");
|
|
186
|
+
}
|
|
187
|
+
if (byKind('remove-flipper').length) {
|
|
188
|
+
cleanup.push('Remove Flipper: delete the `com.facebook.flipper` dependencies and `FLIPPER_VERSION` on Android, `use_flipper!` / Flipper config in the Podfile, and the `ReactNativeFlipper` initialization code. Use React Native DevTools instead.');
|
|
189
|
+
}
|
|
190
|
+
if (byKind('enable-hermes').length) {
|
|
191
|
+
cleanup.push('Switch to Hermes: set `hermesEnabled=true` in `android/gradle.properties` and `:hermes_enabled => true` in the Podfile, then test anything that relied on JavaScriptCore behavior (Intl, Date parsing).');
|
|
192
|
+
}
|
|
193
|
+
if (cleanup.length) {
|
|
194
|
+
phases.push({ title: 'Remove legacy tooling', why: 'Leftovers from older templates break on newer React Native versions; removing them first shrinks the upgrade diff.', steps: cleanup });
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const replace = byKind('replace-dep').sort((a, b) => Number(b.noNewArch) - Number(a.noNewArch) || Number(b.native) - Number(a.native));
|
|
198
|
+
if (replace.length) {
|
|
199
|
+
phases.push({
|
|
200
|
+
title: 'Replace blocking and abandoned packages',
|
|
201
|
+
why: 'These packages decide how far you can upgrade. Swap them while the app still runs on its current version, so every problem has one cause.',
|
|
202
|
+
steps: replace.map((d) => {
|
|
203
|
+
const reason = [d.noNewArch && 'no New Architecture support', d.unmaintained && 'unmaintained'].filter(Boolean).join(', ');
|
|
204
|
+
const owned = d.alternatives.find((a) => installed.has(a));
|
|
205
|
+
if (owned) {
|
|
206
|
+
return `\`${d.name}\` (${reason}${d.native ? ', native' : ''}). You already use \`${owned}\`: move the remaining usage there, then \`${pm.remove} ${d.name}\`.`;
|
|
207
|
+
}
|
|
208
|
+
const to = d.alternatives[0];
|
|
209
|
+
const cmd = to ? ` \`${pm.remove} ${d.name} && ${pm.add} ${to}\`` : '';
|
|
210
|
+
const options = d.alternatives.length ? `Options: ${d.alternatives.map((a) => `\`${a}\``).join(', ')}.${cmd}` : 'No known replacement: check for a maintained fork, or vendor and patch it with `patch-package`.';
|
|
211
|
+
return `\`${d.name}\` (${reason}${d.native ? ', native' : ''}). ${options}`;
|
|
212
|
+
}),
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const nativeBumps = byKind('bump-dep').filter((d) => d.native);
|
|
217
|
+
if (nativeBumps.length) {
|
|
218
|
+
phases.push({
|
|
219
|
+
title: 'Update native modules on the current React Native version',
|
|
220
|
+
why: 'Newer releases of native modules usually add New Architecture support. Pick the newest release that still supports your current React Native version; check each changelog for its minimum.',
|
|
221
|
+
steps: nativeBumps.map((d) => `\`${d.name}\` ${d.from} → ${d.to}`),
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const depProvider = byKind('app-dependency-provider')[0];
|
|
226
|
+
const arch = byKind('enable-new-arch')[0];
|
|
227
|
+
if (depProvider && !arch) {
|
|
228
|
+
phases.push({
|
|
229
|
+
title: 'Fix the iOS AppDelegate',
|
|
230
|
+
why: 'With the New Architecture on, this is a launch crash in Release builds.',
|
|
231
|
+
steps: [`In \`${depProvider.file}\`: \`#import <ReactAppDependencyProvider/RCTAppDependencyProvider.h>\` and \`self.dependencyProvider = [RCTAppDependencyProvider new];\` before \`[super application:didFinishLaunchingWithOptions:]\`.`],
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
if (arch) {
|
|
235
|
+
const steps = [];
|
|
236
|
+
for (const j of byKind('interop-job')) {
|
|
237
|
+
steps.push(`Patch \`${j.name}\` with patch-package so its ${j.methods.length} Kotlin @ReactMethods return Unit instead of a coroutine Job (wrap \`scope.launch { }\` in a helper that returns Unit and rename \`return@launch\` labels). Otherwise the module fails to load on Android.`);
|
|
238
|
+
}
|
|
239
|
+
if (depProvider) {
|
|
240
|
+
steps.push(`In \`${depProvider.file}\`, add \`#import <ReactAppDependencyProvider/RCTAppDependencyProvider.h>\` and \`self.dependencyProvider = [RCTAppDependencyProvider new];\` before calling super. Without it the switch causes a launch crash in Release.`);
|
|
241
|
+
}
|
|
242
|
+
if (result.project.managed) {
|
|
243
|
+
steps.push('Set `"newArchEnabled": true` in `app.json` (under `expo`), then run `npx expo prebuild --clean` and build.');
|
|
244
|
+
} else {
|
|
245
|
+
if (arch.platforms.includes('android')) steps.push('Android: set `newArchEnabled=true` in `android/gradle.properties`.');
|
|
246
|
+
if (arch.platforms.includes('ios')) steps.push('iOS: set `"newArchEnabled": "true"` in `ios/Podfile.properties.json` (or run `RCT_NEW_ARCH_ENABLED=1 bundle exec pod install`).');
|
|
247
|
+
}
|
|
248
|
+
for (const c of byKind('compat').filter((x) => x.forNewArch && x.range)) {
|
|
249
|
+
steps.unshift(`First move \`${c.name}\` to ${c.range}: the installed version does not support the New Architecture on this React Native version.`);
|
|
250
|
+
}
|
|
251
|
+
steps.push('Clean everything (`./gradlew clean`, `watchman watch-del-all`, delete `ios/build` and `ios/Pods`). Switching architecture without a clean build fails on stale codegen output (e.g. missing `Native…Spec` classes).');
|
|
252
|
+
steps.push('Build debug and release on both platforms.');
|
|
253
|
+
steps.push('Click through every screen that uses a native module. Interop-layer problems show up at runtime, not at build time.');
|
|
254
|
+
phases.push({
|
|
255
|
+
title: 'Turn on the New Architecture on your current version',
|
|
256
|
+
why: `React Native 0.${NEW_ARCH_ONLY_MINOR} removed the legacy architecture. Switching now, before changing the React Native version, keeps the two kinds of breakage apart.`,
|
|
257
|
+
steps,
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const rn = byKind('upgrade-rn')[0];
|
|
262
|
+
if (rn) {
|
|
263
|
+
let from = rn.from;
|
|
264
|
+
const tracked = result.deps.filter((d) => COMPAT[d.name]);
|
|
265
|
+
const lastRange = {};
|
|
266
|
+
const steps = upgradeHops(rn.from, rn.to).map((to) => {
|
|
267
|
+
const minor = Number(to.split('.')[1]);
|
|
268
|
+
const bumps = tracked
|
|
269
|
+
.map((d) => {
|
|
270
|
+
const best = bestRange(d.name, minor, true);
|
|
271
|
+
// Prefer staying on the installed major when it still supports this hop.
|
|
272
|
+
const major = d.version ? Number(d.version.split('.')[0]) : null;
|
|
273
|
+
const same = major !== null && best && Number(best.from.split('.')[0]) !== major ? bestRange(d.name, minor, true, { major }) : null;
|
|
274
|
+
return { name: d.name, best, same };
|
|
275
|
+
})
|
|
276
|
+
.filter((b) => b.best && (b.same || b.best).range !== lastRange[b.name])
|
|
277
|
+
.map((b) => {
|
|
278
|
+
lastRange[b.name] = (b.same || b.best).range;
|
|
279
|
+
return b.same ? `\`${b.name}\` ${b.same.range} (or ${b.best.range}, a major migration)` : `\`${b.name}\` ${b.best.range}`;
|
|
280
|
+
});
|
|
281
|
+
const step = `${from} → ${to}: apply the diff from ${UPGRADE_HELPER_URL(from, to)}${bumps.length ? `, move ${bumps.join(' and ')}` : ''}, reinstall pods, build both platforms, commit.`;
|
|
282
|
+
from = to;
|
|
283
|
+
return step;
|
|
284
|
+
});
|
|
285
|
+
steps.push('Bump `react`, `@react-native/*` packages and the Metro/Babel config to the versions the Upgrade Helper shows for the target.');
|
|
286
|
+
const patchesReview = byKind('patches-review')[0];
|
|
287
|
+
if (patchesReview) {
|
|
288
|
+
steps.push(`After every hop, re-check ${patchesReview.patches.map((x) => `\`patches/${x.file}\``).join(', ')}: still applies (\`npx patch-package\`), still needed, not undoing what the new version expects.`);
|
|
289
|
+
}
|
|
290
|
+
steps.push('If `pod install` reports that a React Native dependency (fast_float, hermes-engine, …) differs from Podfile.lock, delete `ios/Pods` and `ios/Podfile.lock` and install again; library pods keep the versions their podspecs pin.');
|
|
291
|
+
steps.push('Update the Gradle version by editing `android/gradle/wrapper/gradle-wrapper.properties`: `./gradlew wrapper` fails because the new React Native Gradle plugin already needs the new Gradle.');
|
|
292
|
+
phases.push({
|
|
293
|
+
title: `Upgrade React Native ${rn.from} → ${rn.to}`,
|
|
294
|
+
why: 'Hopping a few minors at a time gives small diffs you can actually review and bisect when something breaks.',
|
|
295
|
+
steps,
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const expo = byKind('upgrade-expo')[0];
|
|
300
|
+
if (expo) {
|
|
301
|
+
const steps = [];
|
|
302
|
+
for (let v = expo.from + 1; v <= expo.to; v++) steps.push(`SDK ${v - 1} → ${v}: \`npx expo install expo@^${v}.0.0 --fix\`, read the SDK ${v} changelog, run \`npx expo-doctor\`, build.`);
|
|
303
|
+
phases.push({ title: `Upgrade Expo SDK ${expo.from} → ${expo.to}`, why: 'Expo only supports one SDK step at a time reliably.', steps });
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const jsBumps = byKind('bump-dep').filter((d) => !d.native);
|
|
307
|
+
if (jsBumps.length) {
|
|
308
|
+
phases.push({
|
|
309
|
+
title: 'Update JavaScript-only packages',
|
|
310
|
+
why: 'These do not block the upgrade. Do them last, one major at a time, following each migration guide.',
|
|
311
|
+
steps: jsBumps.map((d) => `\`${d.name}\` ${d.from} → ${d.to}`),
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const minimums = [...byKind('min-sdk').map((m) => `Android minSdk → ${m.to}`), ...byKind('ios-min').map((m) => `iOS deployment target → ${m.to}`)];
|
|
316
|
+
if (minimums.length) {
|
|
317
|
+
// Raise minimums right before the React Native upgrade that requires them.
|
|
318
|
+
const at = phases.findIndex((p) => p.title.startsWith('Upgrade React Native'));
|
|
319
|
+
phases.splice(at >= 0 ? at : phases.length, 0, {
|
|
320
|
+
title: 'Raise platform minimums',
|
|
321
|
+
why: 'Current React Native no longer supports older OS versions. Check your analytics for how many users this drops before you ship.',
|
|
322
|
+
steps: minimums,
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
phases.push({
|
|
327
|
+
title: 'Verify before you ship',
|
|
328
|
+
why: 'An upgrade is done when the release build works on real devices, not when it compiles.',
|
|
329
|
+
steps: [
|
|
330
|
+
'Release builds on both platforms (`./gradlew bundleRelease`, Xcode Archive).',
|
|
331
|
+
'Test on a low-end Android device and on the newest iOS and Android versions.',
|
|
332
|
+
'Ship to internal testing / TestFlight first and watch crash-free sessions for a few days before a staged rollout.',
|
|
333
|
+
'Run `npx nativekeel` again: the report should have no critical or high findings.',
|
|
334
|
+
],
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
return { phases, complexity: complexity(result.findings) };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export function planToMarkdown(result, plan) {
|
|
341
|
+
const p = result.project;
|
|
342
|
+
const lines = [
|
|
343
|
+
`# Upgrade plan: ${p.name || 'your app'}`,
|
|
344
|
+
'',
|
|
345
|
+
`Generated by NativeKeel on ${result.scannedAt.slice(0, 10)}. `,
|
|
346
|
+
`React Native ${p.reactNative || '-'}${p.expo ? ` · Expo ${p.expo}` : ''} → latest ${result.latest.reactNative || '-'}. `,
|
|
347
|
+
`Estimated scope: **${plan.complexity.size}** (score ${plan.complexity.score}).`,
|
|
348
|
+
'',
|
|
349
|
+
];
|
|
350
|
+
plan.phases.forEach((phase, i) => {
|
|
351
|
+
lines.push(`## ${i + 1}. ${phase.title}`, '', `_${phase.why}_`, '');
|
|
352
|
+
phase.steps.forEach((s) => lines.push(`- [ ] ${s}`));
|
|
353
|
+
lines.push('');
|
|
354
|
+
});
|
|
355
|
+
lines.push('---', `No time to do it yourself? We do fixed-price upgrades on a branch of your repo: ${SITE_URL}/#services`);
|
|
356
|
+
return `${lines.join('\n')}\n`;
|
|
357
|
+
}
|
package/src/project.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
const readText = (file) => {
|
|
5
|
+
try {
|
|
6
|
+
return fs.readFileSync(file, 'utf8');
|
|
7
|
+
} catch {
|
|
8
|
+
return null;
|
|
9
|
+
}
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const readJson = (file) => {
|
|
13
|
+
const text = readText(file);
|
|
14
|
+
if (!text) return null;
|
|
15
|
+
try {
|
|
16
|
+
return JSON.parse(text);
|
|
17
|
+
} catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export function cleanVersion(v) {
|
|
23
|
+
const m = String(v).match(/(\d+)\.(\d+)(?:\.(\d+))?/);
|
|
24
|
+
return m ? `${m[1]}.${m[2]}.${m[3] || 0}` : null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Monorepos hoist packages, so look in every node_modules from the app up to the filesystem root.
|
|
28
|
+
export function findPackageDir(root, name) {
|
|
29
|
+
let dir = root;
|
|
30
|
+
while (true) {
|
|
31
|
+
const candidate = path.join(dir, 'node_modules', name);
|
|
32
|
+
if (fs.existsSync(path.join(candidate, 'package.json'))) return candidate;
|
|
33
|
+
const parent = path.dirname(dir);
|
|
34
|
+
if (parent === dir) return null;
|
|
35
|
+
dir = parent;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function installedVersion(root, name) {
|
|
40
|
+
const dir = findPackageDir(root, name);
|
|
41
|
+
const pkg = dir && readJson(path.join(dir, 'package.json'));
|
|
42
|
+
return pkg ? pkg.version : null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function loadProject(root) {
|
|
46
|
+
const pkg = readJson(path.join(root, 'package.json'));
|
|
47
|
+
if (!pkg) throw new Error(`No package.json found in ${root}`);
|
|
48
|
+
const deps = { ...(pkg.dependencies || {}) };
|
|
49
|
+
// Monorepos often keep react-native in devDependencies; autolinking still links native modules from there.
|
|
50
|
+
const devDeps = { ...(pkg.devDependencies || {}) };
|
|
51
|
+
const declared = (name) => deps[name] || devDeps[name];
|
|
52
|
+
if (!declared('react-native') && !declared('expo')) {
|
|
53
|
+
throw new Error('This does not look like a React Native app (no react-native or expo dependency). Run it inside your app folder.');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const hasAndroid = fs.existsSync(path.join(root, 'android'));
|
|
57
|
+
const hasIos = fs.existsSync(path.join(root, 'ios'));
|
|
58
|
+
const appConfig = readJson(path.join(root, 'app.json'));
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
root,
|
|
62
|
+
name: pkg.name,
|
|
63
|
+
deps,
|
|
64
|
+
devDeps,
|
|
65
|
+
rnVersion: cleanVersion(installedVersion(root, 'react-native') || declared('react-native') || ''),
|
|
66
|
+
expoVersion: declared('expo') ? cleanVersion(installedVersion(root, 'expo') || declared('expo')) : null,
|
|
67
|
+
// Expo "managed" apps generate android/ and ios/ at build time (Continuous Native Generation).
|
|
68
|
+
managed: !!declared('expo') && !hasAndroid && !hasIos,
|
|
69
|
+
hasNodeModules: !!findPackageDir(root, 'react-native'),
|
|
70
|
+
newArch: detectNewArch(root, appConfig),
|
|
71
|
+
android: hasAndroid ? detectAndroid(root) : null,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Returns { android, ios } with true / false / null (not set, so the React Native default applies)
|
|
76
|
+
function detectNewArch(root, appConfig) {
|
|
77
|
+
const result = { android: null, ios: null };
|
|
78
|
+
|
|
79
|
+
const gradle = readText(path.join(root, 'android', 'gradle.properties'));
|
|
80
|
+
const g = gradle && gradle.match(/^\s*newArchEnabled\s*=\s*(true|false)/m);
|
|
81
|
+
if (g) result.android = g[1] === 'true';
|
|
82
|
+
|
|
83
|
+
const podProps = readJson(path.join(root, 'ios', 'Podfile.properties.json'));
|
|
84
|
+
if (podProps && podProps.newArchEnabled !== undefined) result.ios = String(podProps.newArchEnabled) === 'true';
|
|
85
|
+
|
|
86
|
+
const podfile = readText(path.join(root, 'ios', 'Podfile'));
|
|
87
|
+
const p = podfile && podfile.match(/RCT_NEW_ARCH_ENABLED'?\]?\s*=\s*'?([01]|true|false)/);
|
|
88
|
+
if (p && result.ios === null) result.ios = p[1] === '1' || p[1] === 'true';
|
|
89
|
+
|
|
90
|
+
const expo = appConfig && appConfig.expo;
|
|
91
|
+
if (expo) {
|
|
92
|
+
const flag = (platform) =>
|
|
93
|
+
expo[platform] && expo[platform].newArchEnabled !== undefined ? expo[platform].newArchEnabled : expo.newArchEnabled;
|
|
94
|
+
for (const platform of ['android', 'ios']) {
|
|
95
|
+
if (result[platform] === null && flag(platform) !== undefined) result[platform] = !!flag(platform);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return result;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Reads targetSdk / compileSdk from the usual places: root ext block or app/build.gradle(.kts).
|
|
102
|
+
function detectAndroid(root) {
|
|
103
|
+
const files = ['build.gradle', 'build.gradle.kts', 'app/build.gradle', 'app/build.gradle.kts']
|
|
104
|
+
.map((f) => readText(path.join(root, 'android', f)))
|
|
105
|
+
.filter(Boolean)
|
|
106
|
+
.join('\n');
|
|
107
|
+
const num = (key) => {
|
|
108
|
+
const m = files.match(new RegExp(`\\b${key}(?:Version)?\\s*=?\\s*(\\d{2})\\b`));
|
|
109
|
+
return m ? Number(m[1]) : null;
|
|
110
|
+
};
|
|
111
|
+
return { targetSdk: num('targetSdk'), compileSdk: num('compileSdk'), minSdk: num('minSdk') };
|
|
112
|
+
}
|
package/src/registry.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
const UA = { 'User-Agent': 'nativekeel' };
|
|
2
|
+
|
|
3
|
+
export async function fetchJson(url, headers = {}) {
|
|
4
|
+
try {
|
|
5
|
+
const res = await fetch(url, { headers: { ...UA, ...headers }, signal: AbortSignal.timeout(30000) });
|
|
6
|
+
if (!res.ok) return null;
|
|
7
|
+
return await res.json();
|
|
8
|
+
} catch {
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// All network access goes through one `get` function so tests can replace it.
|
|
14
|
+
export function createRegistry(get = fetchJson) {
|
|
15
|
+
return {
|
|
16
|
+
// React Native Directory supports looking up many packages in one request.
|
|
17
|
+
async directoryInfo(names) {
|
|
18
|
+
const out = {};
|
|
19
|
+
for (let i = 0; i < names.length; i += 25) {
|
|
20
|
+
const chunk = names.slice(i, i + 25).map(encodeURIComponent).join(',');
|
|
21
|
+
const data = await get(`https://reactnative.directory/api/library?name=${chunk}`);
|
|
22
|
+
if (data) Object.assign(out, data);
|
|
23
|
+
}
|
|
24
|
+
return out;
|
|
25
|
+
},
|
|
26
|
+
|
|
27
|
+
async npmLatest(names, concurrency = 12) {
|
|
28
|
+
const out = {};
|
|
29
|
+
const queue = [...names];
|
|
30
|
+
const worker = async () => {
|
|
31
|
+
while (queue.length) {
|
|
32
|
+
const name = queue.shift();
|
|
33
|
+
const data = await get(`https://registry.npmjs.org/${name.replace('/', '%2F')}/latest`);
|
|
34
|
+
out[name] = data ? data.version : null;
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
await Promise.all(Array.from({ length: concurrency }, worker));
|
|
38
|
+
return out;
|
|
39
|
+
},
|
|
40
|
+
|
|
41
|
+
// Publish date of each package's latest version. Needs the full packument (the abbreviated
|
|
42
|
+
// one only has a `modified` stamp that also moves on metadata edits), so call it only for
|
|
43
|
+
// the few packages where release recency decides the verdict.
|
|
44
|
+
async publishDates(names, concurrency = 8) {
|
|
45
|
+
const out = {};
|
|
46
|
+
const queue = [...names];
|
|
47
|
+
const worker = async () => {
|
|
48
|
+
while (queue.length) {
|
|
49
|
+
const name = queue.shift();
|
|
50
|
+
const data = await get(`https://registry.npmjs.org/${name.replace('/', '%2F')}`);
|
|
51
|
+
const latest = data && data['dist-tags'] && data['dist-tags'].latest;
|
|
52
|
+
const manifest = latest && data.versions ? data.versions[latest] || {} : {};
|
|
53
|
+
out[name] = {
|
|
54
|
+
date: latest && data.time && data.time[latest] ? data.time[latest].slice(0, 10) : null,
|
|
55
|
+
// How tightly it is tied to React Native: through its dependencies ('deps'; some only
|
|
56
|
+
// indirectly, e.g. react-native-elements peers on react-native-vector-icons), only by
|
|
57
|
+
// name ('name'), or not at all (null).
|
|
58
|
+
rnLink: ['peerDependencies', 'dependencies'].some((k) =>
|
|
59
|
+
Object.keys(manifest[k] || {}).some((d) => /^(react-native|expo)(-|$)|^@react-native(-community)?\//.test(d)),
|
|
60
|
+
)
|
|
61
|
+
? 'deps'
|
|
62
|
+
: /react-native|^expo-/.test(name)
|
|
63
|
+
? 'name'
|
|
64
|
+
: null,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
await Promise.all(Array.from({ length: concurrency }, worker));
|
|
69
|
+
return out;
|
|
70
|
+
},
|
|
71
|
+
|
|
72
|
+
// Stable versions of a package, newest release line first.
|
|
73
|
+
async releaseLines(name, pick) {
|
|
74
|
+
// The abbreviated "install" document is a fraction of the full packument's size.
|
|
75
|
+
const data = await get(`https://registry.npmjs.org/${name}`, { Accept: 'application/vnd.npm.install-v1+json' });
|
|
76
|
+
if (!data || !data.versions) return null;
|
|
77
|
+
const stable = Object.keys(data.versions).filter((v) => /^\d+\.\d+\.\d+$/.test(v));
|
|
78
|
+
// The `latest` dist-tag is the authority: lines published above it are previews
|
|
79
|
+
// (Expo ships the next SDK's packages before tagging it). `pick` returns NaN for
|
|
80
|
+
// versions outside the release scheme; NaN would corrupt the sort.
|
|
81
|
+
const latest = data['dist-tags'].latest;
|
|
82
|
+
const top = pick(latest);
|
|
83
|
+
const lines = [...new Set(stable.map(pick))]
|
|
84
|
+
.filter((n) => Number.isFinite(n) && n <= top)
|
|
85
|
+
.sort((a, b) => b - a);
|
|
86
|
+
return { latest, lines };
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { SEVERITIES, countBySeverity } from './analyze.js';
|
|
2
|
+
import { SITE_URL } from './config.js';
|
|
3
|
+
|
|
4
|
+
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (ch) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[ch]);
|
|
5
|
+
// Turns `code` spans from finding text into <code>.
|
|
6
|
+
const rich = (s) => esc(s).replace(/`([^`]+)`/g, '<code>$1</code>');
|
|
7
|
+
|
|
8
|
+
// A single self-contained file that can be emailed or attached to a ticket.
|
|
9
|
+
// `brand` replaces NativeKeel branding with an agency's name.
|
|
10
|
+
export function htmlReport(result, { brand = null, plan = null } = {}) {
|
|
11
|
+
const p = result.project;
|
|
12
|
+
const n = countBySeverity(result.findings);
|
|
13
|
+
const title = `${p.name || 'App'} health report`;
|
|
14
|
+
const sevLabel = { critical: 'Critical', high: 'High', medium: 'Medium', low: 'Low', info: 'Info' };
|
|
15
|
+
const arch = (v) => (v === true ? 'on' : v === false ? 'off' : 'default');
|
|
16
|
+
|
|
17
|
+
const rows = SEVERITIES.flatMap((sev) =>
|
|
18
|
+
result.findings
|
|
19
|
+
.filter((f) => f.severity === sev)
|
|
20
|
+
.map((f) => `<li class="f ${sev}"><span class="tag">${sevLabel[sev]}</span><div><strong>${rich(f.title)}</strong><p>${rich(f.detail)}</p></div></li>`),
|
|
21
|
+
).join('\n');
|
|
22
|
+
|
|
23
|
+
const planHtml = plan
|
|
24
|
+
? `<section><h2>Upgrade plan <small>scope: ${esc(plan.complexity.size)}</small></h2>${plan.phases
|
|
25
|
+
.map((ph, i) => `<h3>${i + 1}. ${esc(ph.title)}</h3><p class="why">${esc(ph.why)}</p><ul>${ph.steps.map((s) => `<li>${rich(s)}</li>`).join('')}</ul>`)
|
|
26
|
+
.join('')}</section>`
|
|
27
|
+
: '';
|
|
28
|
+
|
|
29
|
+
const footer = brand
|
|
30
|
+
? `Prepared by ${esc(brand)} · ${esc(result.scannedAt.slice(0, 10))}`
|
|
31
|
+
: `Generated by <a href="${SITE_URL}">NativeKeel</a> · ${esc(result.scannedAt.slice(0, 10))}`;
|
|
32
|
+
|
|
33
|
+
return `<!doctype html>
|
|
34
|
+
<html lang="en">
|
|
35
|
+
<head>
|
|
36
|
+
<meta charset="utf-8">
|
|
37
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
38
|
+
<title>${esc(title)}</title>
|
|
39
|
+
<style>
|
|
40
|
+
:root{--bg:#f7f8fa;--card:#fff;--text:#16181d;--muted:#5f6673;--line:#e3e6eb;--critical:#c62828;--high:#b26a00;--medium:#8a6d00;--low:#6b7280;--info:#1e5fb4;--accent:#0b5d55}
|
|
41
|
+
@media (prefers-color-scheme:dark){:root{--bg:#0f1115;--card:#171a20;--text:#e8eaee;--muted:#9aa1ad;--line:#2a2f38;--critical:#ff6b6b;--high:#f0a73a;--medium:#d9c24a;--low:#8b93a1;--info:#6aa7ff;--accent:#3cc4b4}}
|
|
42
|
+
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font:15px/1.55 system-ui,-apple-system,Segoe UI,Roboto,sans-serif}
|
|
43
|
+
main{max-width:860px;margin:0 auto;padding:32px 16px 48px}
|
|
44
|
+
h1{font-size:26px;margin:0 0 4px}h2{font-size:19px;margin:32px 0 12px}h2 small{font-weight:400;color:var(--muted);font-size:14px}h3{font-size:16px;margin:20px 0 4px}
|
|
45
|
+
.meta{color:var(--muted);margin:0 0 20px}
|
|
46
|
+
.stats{display:grid;grid-template-columns:repeat(4,1fr);gap:10px}
|
|
47
|
+
.stat{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:12px}
|
|
48
|
+
.stat b{display:block;font-size:24px}.stat span{color:var(--muted);font-size:13px}
|
|
49
|
+
.stat.critical b{color:var(--critical)}.stat.high b{color:var(--high)}
|
|
50
|
+
.facts{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:12px 16px;margin-top:12px;display:flex;flex-wrap:wrap;gap:6px 24px;font-size:14px}
|
|
51
|
+
ul.list{list-style:none;padding:0;margin:0;display:grid;gap:8px}
|
|
52
|
+
.f{display:flex;gap:12px;background:var(--card);border:1px solid var(--line);border-left:4px solid var(--low);border-radius:8px;padding:10px 14px}
|
|
53
|
+
.f p{margin:2px 0 0;color:var(--muted);font-size:14px}.f strong{overflow-wrap:anywhere}
|
|
54
|
+
.tag{flex:none;font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.03em;min-width:64px;padding-top:2px}
|
|
55
|
+
.critical{border-left-color:var(--critical)}.critical .tag{color:var(--critical)}
|
|
56
|
+
.high{border-left-color:var(--high)}.high .tag{color:var(--high)}
|
|
57
|
+
.medium{border-left-color:var(--medium)}.medium .tag{color:var(--medium)}
|
|
58
|
+
.low .tag{color:var(--low)}.info{border-left-color:var(--info)}.info .tag{color:var(--info)}
|
|
59
|
+
code{font:13px ui-monospace,SFMono-Regular,Menlo,monospace;background:color-mix(in srgb,var(--line) 60%,transparent);padding:1px 4px;border-radius:4px;overflow-wrap:anywhere}
|
|
60
|
+
.why{color:var(--muted);font-style:italic;margin:0 0 6px}
|
|
61
|
+
footer{margin-top:40px;color:var(--muted);font-size:13px}a{color:var(--accent)}
|
|
62
|
+
@media (max-width:560px){.stats{grid-template-columns:repeat(2,1fr)}.f{flex-direction:column;gap:4px}}
|
|
63
|
+
</style>
|
|
64
|
+
</head>
|
|
65
|
+
<body><main>
|
|
66
|
+
<h1>${esc(title)}</h1>
|
|
67
|
+
<p class="meta">Scanned ${esc(result.scannedAt.slice(0, 16).replace('T', ' '))} UTC</p>
|
|
68
|
+
<div class="stats">
|
|
69
|
+
<div class="stat critical"><b>${n.critical}</b><span>critical</span></div>
|
|
70
|
+
<div class="stat high"><b>${n.high}</b><span>high</span></div>
|
|
71
|
+
<div class="stat"><b>${n.medium}</b><span>medium</span></div>
|
|
72
|
+
<div class="stat"><b>${n.low}</b><span>low</span></div>
|
|
73
|
+
</div>
|
|
74
|
+
<div class="facts">
|
|
75
|
+
${p.reactNative ? `<span>React Native <b>${esc(p.reactNative)}</b>${result.latest.reactNative ? ` (latest ${esc(result.latest.reactNative)})` : ''}</span>` : ''}
|
|
76
|
+
${p.expo ? `<span>Expo SDK <b>${esc(p.expo.split('.')[0])}</b></span>` : ''}
|
|
77
|
+
<span>New Architecture: Android <b>${arch(p.newArch.android)}</b>, iOS <b>${arch(p.newArch.ios)}</b></span>
|
|
78
|
+
${p.android && p.android.targetSdk ? `<span>targetSdk <b>${p.android.targetSdk}</b></span>` : ''}
|
|
79
|
+
<span>${result.deps.length} dependencies (${result.deps.filter((d) => d.native).length} native)</span>
|
|
80
|
+
</div>
|
|
81
|
+
<h2>Findings</h2>
|
|
82
|
+
${result.findings.length ? `<ul class="list">${rows}</ul>` : '<p>No issues found.</p>'}
|
|
83
|
+
${planHtml}
|
|
84
|
+
<footer>${footer}</footer>
|
|
85
|
+
</main></body></html>
|
|
86
|
+
`;
|
|
87
|
+
}
|