@uniformdev/next-app-router-upgrade 0.0.0-stage → 0.2.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/README.md +289 -2
- package/bin/cli.js +167 -0
- package/package.json +35 -4
- package/src/apply.js +124 -0
- package/src/client-context.js +89 -0
- package/src/edge/akamai.js +62 -0
- package/src/edge/cloudflare.js +45 -0
- package/src/edge/common.js +400 -0
- package/src/edge/index.js +41 -0
- package/src/edge/vercel.js +38 -0
- package/src/interactive.js +111 -0
- package/src/json-text.js +95 -0
- package/src/migrate.js +386 -0
- package/src/registry.js +25 -0
- package/src/report.js +44 -0
- package/src/rules.js +176 -0
- package/src/scan.js +523 -0
- package/src/session.js +206 -0
package/src/scan.js
ADDED
|
@@ -0,0 +1,523 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import ts from "typescript";
|
|
4
|
+
|
|
5
|
+
import { COMPOSITION_METADATA, findClientContextIssues, STABLE_CONTEXT_OPTIONS } from "./client-context.js";
|
|
6
|
+
import {
|
|
7
|
+
REMOVED_CONFIG_KEYS,
|
|
8
|
+
REMOVED_EXPORTS,
|
|
9
|
+
REMOVED_OPTIONS,
|
|
10
|
+
REMOVED_PAGE_STATE_FIELDS,
|
|
11
|
+
RULES,
|
|
12
|
+
} from "./rules.js";
|
|
13
|
+
|
|
14
|
+
const SOURCE_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"]);
|
|
15
|
+
const SKIP_DIRS = new Set(["node_modules", ".next", ".git", "dist", "build", "out", "coverage", ".vercel", ".turbo"]);
|
|
16
|
+
const SDK = "@uniformdev/next-app-router";
|
|
17
|
+
const RESOLVERS = new Set(["resolveRouteFromCode", "resolvePlaygroundRoute"]);
|
|
18
|
+
const LITE_MIDDLEWARE = new Set(["uniformMiddleware", "handleUniformRoute"]);
|
|
19
|
+
const MIN_NEXT = [16, 0, 7];
|
|
20
|
+
const STABLE = "The stable SDK (20.82 to 20.85)";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Scans the project at `root` and returns { project, findings }.
|
|
24
|
+
* `contents` maps absolute file paths to text that replaces the file on disk. The migration uses it
|
|
25
|
+
* to scan its result before it writes the files.
|
|
26
|
+
*/
|
|
27
|
+
export function scanProject(root, { uniformDataDir, contents } = {}) {
|
|
28
|
+
const findings = [];
|
|
29
|
+
const add = (rule, details) => findings.push({ rule, ...RULES[rule], ...details });
|
|
30
|
+
const read = (file) => contents?.get(file) ?? fs.readFileSync(file, "utf8");
|
|
31
|
+
|
|
32
|
+
const pkg = readPackageJson(root, read);
|
|
33
|
+
const deps = { ...pkg?.dependencies, ...pkg?.devDependencies };
|
|
34
|
+
const project = {
|
|
35
|
+
root,
|
|
36
|
+
name: pkg?.name,
|
|
37
|
+
sdkVersion: deps[SDK],
|
|
38
|
+
usesV1: Boolean(deps["@uniformdev/canvas-next-rsc"]),
|
|
39
|
+
usesAkamaiWorker: false,
|
|
40
|
+
filesScanned: 0,
|
|
41
|
+
compositionsWithPersonalization: 0,
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
checkPackageJson(pkg, deps, add);
|
|
45
|
+
|
|
46
|
+
const files = listSourceFiles(root);
|
|
47
|
+
project.filesScanned = files.length;
|
|
48
|
+
const state = { usesLiteMiddleware: false, importsVercelFunctions: [] };
|
|
49
|
+
for (const file of files) {
|
|
50
|
+
scanSourceFile(root, file, add, state, read);
|
|
51
|
+
}
|
|
52
|
+
project.usesAkamaiWorker = Boolean(state.usesAkamaiWorker);
|
|
53
|
+
|
|
54
|
+
if (state.processPlaygroundPath && !state.middlewarePassesPlaygroundPath) {
|
|
55
|
+
add("playground-path", {
|
|
56
|
+
...state.processPlaygroundPath,
|
|
57
|
+
message: "createPreviewGETRouteHandler gets processPlaygroundPath. The middleware of the developer preview accepts the same option, so that it finds the playground path for the locale.",
|
|
58
|
+
fix: "Give the same processPlaygroundPath function to the middleware.",
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (state.importsVercelFunctions.length && !deps["@vercel/functions"]) {
|
|
63
|
+
for (const where of state.importsVercelFunctions) {
|
|
64
|
+
add("vercel-functions-transitive", {
|
|
65
|
+
...where,
|
|
66
|
+
message: `The app imports @vercel/functions, but package.json does not list it. ${STABLE} installs it as a dependency of the SDK. The developer preview makes it an optional peer dependency.`,
|
|
67
|
+
fix: "Add @vercel/functions to the dependencies in package.json.",
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
scanUniformData(root, uniformDataDir, project, add);
|
|
73
|
+
scanNotFoundRoutes(root, files, add);
|
|
74
|
+
|
|
75
|
+
if (state.usesLiteMiddleware) {
|
|
76
|
+
const count = project.compositionsWithPersonalization;
|
|
77
|
+
add("lite-mode-flicker", {
|
|
78
|
+
...state.liteMiddlewareLocation,
|
|
79
|
+
message:
|
|
80
|
+
`${STABLE} chooses personalization and test variants in the middleware. With uniformMiddleware or handleUniformRoute, the developer preview renders the default variants on the server, and the browser changes them after hydration.` +
|
|
81
|
+
(count ? ` The uniform-data folder has ${count} composition(s) with a personalization or a test.` : ""),
|
|
82
|
+
fix: "No code change is required. To keep the first paint correct, turn on edge mode: migrate --edge <host>.",
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (project.sdkVersion) {
|
|
87
|
+
add("not-found-html", {
|
|
88
|
+
message:
|
|
89
|
+
`${STABLE} rewrites missing routes to /404 in the middleware. The developer preview calls notFound() while the page renders. The response is still 404, but Next.js sends an empty HTML shell, and the 404 content (also app/not-found.tsx) shows only after hydration. Tested with Next.js 16.2.10 and 16.4.0.`,
|
|
90
|
+
fix: "No code change is required. Visitors without JavaScript see an empty 404 page.",
|
|
91
|
+
});
|
|
92
|
+
add("redirect-status", {
|
|
93
|
+
message: "The page applies Uniform redirects. A 301 or 308 redirect gives a 308, and other redirects give a 307. A missing route gives notFound().",
|
|
94
|
+
fix: "No change is required.",
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return { project, findings };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function readPackageJson(root, read = (file) => fs.readFileSync(file, "utf8")) {
|
|
102
|
+
try {
|
|
103
|
+
return JSON.parse(read(path.join(root, "package.json")));
|
|
104
|
+
} catch {
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function parseVersion(range) {
|
|
110
|
+
const match = /(\d+)\.(\d+)\.(\d+)/.exec(range ?? "");
|
|
111
|
+
return match ? match.slice(1, 4).map(Number) : undefined;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function isLower(a, b) {
|
|
115
|
+
for (let i = 0; i < 3; i++) {
|
|
116
|
+
if (a[i] !== b[i]) return a[i] < b[i];
|
|
117
|
+
}
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function checkPackageJson(pkg, deps, add) {
|
|
122
|
+
if (!pkg) return;
|
|
123
|
+
const where = { file: "package.json" };
|
|
124
|
+
|
|
125
|
+
const next = parseVersion(deps.next);
|
|
126
|
+
if (next && isLower(next, MIN_NEXT)) {
|
|
127
|
+
add("next-version", {
|
|
128
|
+
...where,
|
|
129
|
+
message: `package.json has next ${deps.next}. The developer preview requires Next.js 16.0.7 or later.`,
|
|
130
|
+
fix: "Upgrade Next.js to 16.0.7 or later.",
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// @uniformdev/toolbar-* and @uniformdev/context-edge have their own version lines.
|
|
135
|
+
const uniform = Object.entries(deps).filter(
|
|
136
|
+
([name]) => name.startsWith("@uniformdev/") && !name.startsWith("@uniformdev/toolbar") && name !== "@uniformdev/context-edge"
|
|
137
|
+
);
|
|
138
|
+
const versions = new Set(uniform.map(([, version]) => version));
|
|
139
|
+
if (versions.size > 1) {
|
|
140
|
+
add("uniform-version-mismatch", {
|
|
141
|
+
...where,
|
|
142
|
+
message: `The @uniformdev packages have different versions: ${uniform.map(([n, v]) => `${n}@${v}`).join(", ")}. Different versions can install two copies of @uniformdev/context.`,
|
|
143
|
+
fix: "Install all @uniformdev packages with the same version.",
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (deps["@uniformdev/toolbar-react"] && !pkg.overrides?.["@uniformdev/context"]) {
|
|
148
|
+
add("prerelease-peer-range", {
|
|
149
|
+
...where,
|
|
150
|
+
message: "@uniformdev/toolbar-react has the peer range @uniformdev/context ^20.72. That range does not accept prerelease versions, so npm shows ERESOLVE for a developer preview version. A stable release is not affected.",
|
|
151
|
+
fix: 'For a prerelease version, add "overrides": { "@uniformdev/context": "$@uniformdev/context" } to package.json.',
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (deps["@uniformdev/context-edge"]) {
|
|
156
|
+
add("context-edge-dependency", {
|
|
157
|
+
...where,
|
|
158
|
+
message: "The edge, vercel, cf-vinext and akamai entry points of @uniformdev/next-app-router contain @uniformdev/context-edge.",
|
|
159
|
+
fix: "You can remove @uniformdev/context-edge from package.json, if the app does not import it.",
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function listSourceFiles(root) {
|
|
165
|
+
const out = [];
|
|
166
|
+
const walk = (dir) => {
|
|
167
|
+
let entries;
|
|
168
|
+
try {
|
|
169
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
170
|
+
} catch {
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
for (const entry of entries) {
|
|
174
|
+
if (entry.isDirectory()) {
|
|
175
|
+
if (!SKIP_DIRS.has(entry.name) && !entry.name.startsWith(".")) walk(path.join(dir, entry.name));
|
|
176
|
+
} else if (SOURCE_EXTENSIONS.has(path.extname(entry.name)) && !entry.name.endsWith(".d.ts")) {
|
|
177
|
+
out.push(path.join(dir, entry.name));
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
walk(root);
|
|
182
|
+
return out.sort();
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function scriptKind(file) {
|
|
186
|
+
const ext = path.extname(file);
|
|
187
|
+
if (ext === ".tsx") return ts.ScriptKind.TSX;
|
|
188
|
+
if (ext === ".jsx") return ts.ScriptKind.JSX;
|
|
189
|
+
if (ext === ".ts" || ext === ".mts" || ext === ".cts") return ts.ScriptKind.TS;
|
|
190
|
+
return ts.ScriptKind.JS;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function scanSourceFile(root, file, add, state, read) {
|
|
194
|
+
const text = read(file);
|
|
195
|
+
const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, scriptKind(file));
|
|
196
|
+
const rel = path.relative(root, file);
|
|
197
|
+
const at = (node) => ({ file: rel, line: sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1 });
|
|
198
|
+
|
|
199
|
+
const isConfigFile = /^uniform\.server\.config\.[cm]?[jt]s$/.test(path.basename(file));
|
|
200
|
+
const isMiddlewareFile = /^(src\/)?(middleware|proxy)\.[cm]?[jt]s$/.test(rel.split(path.sep).join("/"));
|
|
201
|
+
|
|
202
|
+
// Local names of SDK imports: local name -> { module, imported }.
|
|
203
|
+
const sdkLocals = new Map();
|
|
204
|
+
const sdkNamespaces = new Map();
|
|
205
|
+
let importsSdk = false;
|
|
206
|
+
|
|
207
|
+
for (const statement of sf.statements) {
|
|
208
|
+
if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier)) continue;
|
|
209
|
+
const module = statement.moduleSpecifier.text;
|
|
210
|
+
|
|
211
|
+
if (module === "@vercel/functions" || module.startsWith("@vercel/functions/")) {
|
|
212
|
+
state.importsVercelFunctions.push(at(statement));
|
|
213
|
+
}
|
|
214
|
+
if (!module.startsWith(SDK)) continue;
|
|
215
|
+
importsSdk = true;
|
|
216
|
+
if (module === `${SDK}/akamai`) state.usesAkamaiWorker = true;
|
|
217
|
+
|
|
218
|
+
if (module === `${SDK}/cache`) {
|
|
219
|
+
add("cached-resolver", {
|
|
220
|
+
...at(statement),
|
|
221
|
+
message: "The resolvers of @uniformdev/next-app-router/cache now use 'use cache' only for published content. Draft and editor requests are not cached.",
|
|
222
|
+
fix: "No change is required.",
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const bindings = statement.importClause?.namedBindings;
|
|
227
|
+
if (bindings && ts.isNamespaceImport(bindings)) {
|
|
228
|
+
sdkNamespaces.set(bindings.name.text, module);
|
|
229
|
+
}
|
|
230
|
+
if (bindings && ts.isNamedImports(bindings)) {
|
|
231
|
+
for (const element of bindings.elements) {
|
|
232
|
+
const imported = (element.propertyName ?? element.name).text;
|
|
233
|
+
sdkLocals.set(element.name.text, { module, imported });
|
|
234
|
+
checkImportedName(module, imported, element, add, at, state);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (isConfigFile) importsSdk = true;
|
|
240
|
+
if (isMiddlewareFile && text.includes("processPlaygroundPath")) state.middlewarePassesPlaygroundPath = true;
|
|
241
|
+
|
|
242
|
+
scanClientContext(sf, sdkLocals, add, at);
|
|
243
|
+
|
|
244
|
+
const resultVariables = new Set();
|
|
245
|
+
|
|
246
|
+
const visit = (node) => {
|
|
247
|
+
// ns.removedExport
|
|
248
|
+
if (ts.isPropertyAccessExpression(node) && ts.isIdentifier(node.expression)) {
|
|
249
|
+
const module = sdkNamespaces.get(node.expression.text);
|
|
250
|
+
if (module) checkImportedName(module, node.name.text, node, add, at, state);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Removed options: { dataClient } and { pathPatternsWithVariations }.
|
|
254
|
+
if (importsSdk && (ts.isPropertyAssignment(node) || ts.isShorthandPropertyAssignment(node))) {
|
|
255
|
+
const name = propertyName(node.name);
|
|
256
|
+
if (name && REMOVED_OPTIONS[name]) {
|
|
257
|
+
add("removed-option", { ...at(node), message: `The option "${name}" is removed.`, fix: REMOVED_OPTIONS[name] });
|
|
258
|
+
}
|
|
259
|
+
if (name && REMOVED_CONFIG_KEYS[name] && (isConfigFile || isServerConfigContext(node))) {
|
|
260
|
+
add("removed-config-key", { ...at(node), message: `The server configuration option "${name}" is removed.`, fix: REMOVED_CONFIG_KEYS[name] });
|
|
261
|
+
}
|
|
262
|
+
if (name && STABLE_CONTEXT_OPTIONS[name] && (isConfigFile || isServerConfigContext(node))) {
|
|
263
|
+
add("context-storage-options", {
|
|
264
|
+
...at(node),
|
|
265
|
+
message: `The server configuration option "context.${name}" came in 20.85.0. The developer preview does not have it.`,
|
|
266
|
+
fix: `Remove it. ${STABLE_CONTEXT_OPTIONS[name]}`,
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
if (name === "defaultConsent" && isMiddlewareFile && !isBooleanLiteral(node)) {
|
|
270
|
+
add("per-request-consent", {
|
|
271
|
+
...at(node),
|
|
272
|
+
message: "The middleware writes the default consent into the page code. Each value makes a different cached page.",
|
|
273
|
+
fix: "If you use createUniformStaticParams, list each value in its defaultConsent option.",
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// createPreviewGETRouteHandler({ processPlaygroundPath })
|
|
279
|
+
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && sdkLocals.get(node.expression.text)?.imported === "createPreviewGETRouteHandler") {
|
|
280
|
+
const options = node.arguments[0];
|
|
281
|
+
const option = options && ts.isObjectLiteralExpression(options) && options.properties.find((p) => p.name && propertyName(p.name) === "processPlaygroundPath");
|
|
282
|
+
if (option) state.processPlaygroundPath ??= at(option);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// <UniformContext result={result}>...</UniformContext>, the API of 20.81.1-alpha.25.
|
|
286
|
+
if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node)) {
|
|
287
|
+
const opening = ts.isJsxElement(node) ? node.openingElement : node;
|
|
288
|
+
const local = ts.isIdentifier(opening.tagName) ? sdkLocals.get(opening.tagName.text) : undefined;
|
|
289
|
+
if (local?.imported === "UniformContext" && local.module.startsWith(SDK)) {
|
|
290
|
+
const hasResult = opening.attributes.properties.some((p) => ts.isJsxAttribute(p) && p.name.getText(sf) === "result");
|
|
291
|
+
const hasChildren = ts.isJsxElement(node) && node.children.some((child) => !(ts.isJsxText(child) && child.containsOnlyTriviaWhiteSpaces));
|
|
292
|
+
if (hasResult || hasChildren) {
|
|
293
|
+
add("uniform-context-props", {
|
|
294
|
+
...at(node),
|
|
295
|
+
message: "UniformContext no longer takes result or children. Render it once after the compositions of the page, or in the root layout. The primary composition sets the composition metadata and the default consent.",
|
|
296
|
+
fix: "Render the compositions, then <UniformContext />. Add pageComposition to the UniformResolvedComposition of the page.",
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// <UniformComposition dataClient={...} />
|
|
303
|
+
if (ts.isJsxAttribute(node) && importsSdk) {
|
|
304
|
+
const name = node.name.getText(sf);
|
|
305
|
+
if (REMOVED_OPTIONS[name]) {
|
|
306
|
+
add("removed-option", { ...at(node), message: `The prop "${name}" is removed.`, fix: REMOVED_OPTIONS[name] });
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// pageState.components and the other removed fields.
|
|
311
|
+
if (ts.isPropertyAccessExpression(node) && REMOVED_PAGE_STATE_FIELDS.includes(node.name.text)) {
|
|
312
|
+
if (endsWithPageState(node.expression)) {
|
|
313
|
+
add("removed-page-state-field", {
|
|
314
|
+
...at(node),
|
|
315
|
+
message: `pageState.${node.name.text} is removed. The page code no longer contains evaluation results.`,
|
|
316
|
+
fix: node.name.text === "quirks" ? "Read quirks in the browser with useQuirks()." : "No replacement.",
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
if (ts.isVariableDeclaration(node) && ts.isObjectBindingPattern(node.name) && node.initializer && endsWithPageState(node.initializer)) {
|
|
321
|
+
for (const element of node.name.elements) {
|
|
322
|
+
const name = (element.propertyName ?? element.name).getText(sf);
|
|
323
|
+
if (REMOVED_PAGE_STATE_FIELDS.includes(name)) {
|
|
324
|
+
add("removed-page-state-field", { ...at(element), message: `pageState.${name} is removed.`, fix: "No replacement." });
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// const result = await resolveRouteFromCode(...); result.code
|
|
330
|
+
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer) {
|
|
331
|
+
const call = unwrapAwait(node.initializer);
|
|
332
|
+
if (call && ts.isCallExpression(call) && ts.isIdentifier(call.expression)) {
|
|
333
|
+
const local = sdkLocals.get(call.expression.text);
|
|
334
|
+
if (local && RESOLVERS.has(local.imported)) resultVariables.add(node.name.text);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
const isResolverCall = (expression) => {
|
|
338
|
+
const call = unwrapAwait(expression);
|
|
339
|
+
if (!call || !ts.isCallExpression(call) || !ts.isIdentifier(call.expression)) return false;
|
|
340
|
+
return RESOLVERS.has(sdkLocals.get(call.expression.text)?.imported);
|
|
341
|
+
};
|
|
342
|
+
if (
|
|
343
|
+
ts.isPropertyAccessExpression(node) &&
|
|
344
|
+
node.name.text === "code" &&
|
|
345
|
+
((ts.isIdentifier(node.expression) && resultVariables.has(node.expression.text)) || isResolverCall(node.expression))
|
|
346
|
+
) {
|
|
347
|
+
add("result-code", { ...at(node), message: "ResolvedRouteResult no longer has a code field.", fix: "Keep the code from the [code] route parameter." });
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// result.route.compositionApiResponse without a type check.
|
|
351
|
+
if (importsSdk && ts.isPropertyAccessExpression(node) && node.name.text === "compositionApiResponse") {
|
|
352
|
+
const target = node.expression;
|
|
353
|
+
if (ts.isPropertyAccessExpression(target) && target.name.text === "route" && !isNarrowedToComposition(node)) {
|
|
354
|
+
add("route-union", {
|
|
355
|
+
...at(node),
|
|
356
|
+
message: "result.route can now also be a redirect, so TypeScript does not accept route.compositionApiResponse without a check.",
|
|
357
|
+
fix: 'Check result.route?.type === "composition" first, or call requireComposition(result).',
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Hard-coded old page codes.
|
|
363
|
+
if ((ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) && /(^|\/)2~\d+~/.test(node.text)) {
|
|
364
|
+
add("old-page-code", { ...at(node), message: `The string "${node.text.slice(0, 60)}" has a page code in the old format. Old codes do not work.`, fix: "Link to the path of the page, not to /uniform/<code>." });
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Lite middleware.
|
|
368
|
+
if (isMiddlewareFile && ts.isCallExpression(node) && ts.isIdentifier(node.expression)) {
|
|
369
|
+
const local = sdkLocals.get(node.expression.text);
|
|
370
|
+
if (local && local.module === `${SDK}/middleware` && LITE_MIDDLEWARE.has(local.imported)) {
|
|
371
|
+
state.usesLiteMiddleware = true;
|
|
372
|
+
state.liteMiddlewareLocation ??= at(node);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
ts.forEachChild(node, visit);
|
|
377
|
+
};
|
|
378
|
+
visit(sf);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function scanClientContext(sf, sdkLocals, add, at) {
|
|
382
|
+
const { hookArguments, props, options } = findClientContextIssues(sf, sdkLocals);
|
|
383
|
+
for (const { argument } of hookArguments) {
|
|
384
|
+
add("context-composition-metadata", {
|
|
385
|
+
...at(argument),
|
|
386
|
+
message: "useInitUniformContext takes only the callback now. The SDK sends the composition metadata of the page composition to the Context.",
|
|
387
|
+
fix: "Remove the second argument of useInitUniformContext.",
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
for (const { element, name } of props) {
|
|
391
|
+
if (name === COMPOSITION_METADATA) {
|
|
392
|
+
add("context-composition-metadata", {
|
|
393
|
+
...at(element),
|
|
394
|
+
message: "ClientContextComponentProps no longer has compositionMetadata.",
|
|
395
|
+
fix: "Remove the prop. Do not give it to useInitUniformContext.",
|
|
396
|
+
});
|
|
397
|
+
} else {
|
|
398
|
+
add("context-storage-options", {
|
|
399
|
+
...at(element),
|
|
400
|
+
message: `ClientContextComponentProps has no "${name}" in the developer preview. It came in 20.85.0.`,
|
|
401
|
+
fix: `Remove the prop. ${STABLE_CONTEXT_OPTIONS[name]}`,
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
for (const { property, name } of options) {
|
|
406
|
+
add("context-storage-options", {
|
|
407
|
+
...at(property),
|
|
408
|
+
message: `createClientUniformContext has no "${name}" option in the developer preview. It came in 20.85.0.`,
|
|
409
|
+
fix: `Remove the option. ${STABLE_CONTEXT_OPTIONS[name]}`,
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function checkImportedName(module, imported, node, add, at) {
|
|
415
|
+
const removed = REMOVED_EXPORTS[module]?.[imported];
|
|
416
|
+
if (removed) {
|
|
417
|
+
add("removed-export", { ...at(node), message: `"${imported}" is no longer exported from ${module}.`, fix: removed });
|
|
418
|
+
}
|
|
419
|
+
if ((imported === "PersonalizeProps" || imported === "TestProps") && module.startsWith(SDK)) {
|
|
420
|
+
add("placement-props", {
|
|
421
|
+
...at(node),
|
|
422
|
+
message: `${imported} changed: indexes is now defaultIndexes, index is now defaultIndex, and PersonalizeProps has a new personalization prop.`,
|
|
423
|
+
fix: "Update the custom placement component.",
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
if (imported === "createUniformStaticParams" || imported === "createUniformPlaygroundStaticParams") {
|
|
427
|
+
add("static-params", {
|
|
428
|
+
...at(node),
|
|
429
|
+
message: `${imported} now makes one code for each path (and each defaultConsent and edgeMode value), not one for each variant combination.`,
|
|
430
|
+
fix: "No change is required. With edge mode, use edgeMode: [true, false].",
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function propertyName(name) {
|
|
436
|
+
if (ts.isIdentifier(name) || ts.isStringLiteral(name)) return name.text;
|
|
437
|
+
return undefined;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function isBooleanLiteral(node) {
|
|
441
|
+
return ts.isPropertyAssignment(node) && (node.initializer.kind === ts.SyntaxKind.TrueKeyword || node.initializer.kind === ts.SyntaxKind.FalseKeyword);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function isServerConfigContext(node) {
|
|
445
|
+
for (let parent = node.parent; parent; parent = parent.parent) {
|
|
446
|
+
if (ts.isVariableDeclaration(parent) && parent.type && /UniformServerConfig/.test(parent.type.getText())) return true;
|
|
447
|
+
if (ts.isSatisfiesExpression?.(parent) && /UniformServerConfig/.test(parent.type.getText())) return true;
|
|
448
|
+
}
|
|
449
|
+
return false;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function endsWithPageState(expression) {
|
|
453
|
+
if (ts.isIdentifier(expression)) return expression.text === "pageState";
|
|
454
|
+
if (ts.isPropertyAccessExpression(expression)) return expression.name.text === "pageState";
|
|
455
|
+
return false;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function unwrapAwait(expression) {
|
|
459
|
+
let current = expression;
|
|
460
|
+
while (current && (ts.isAwaitExpression(current) || ts.isParenthesizedExpression(current))) current = current.expression;
|
|
461
|
+
return current;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// True when an enclosing if statement or conditional checks `.type === "composition"`.
|
|
465
|
+
function isNarrowedToComposition(node) {
|
|
466
|
+
for (let parent = node.parent; parent; parent = parent.parent) {
|
|
467
|
+
const condition = ts.isIfStatement(parent) || ts.isConditionalExpression(parent) ? parent.expression ?? parent.condition : undefined;
|
|
468
|
+
if (condition && /\.type\s*===?\s*["']composition["']/.test(condition.getText())) return true;
|
|
469
|
+
if (ts.isFunctionLike(parent)) {
|
|
470
|
+
const body = parent.body?.getText() ?? "";
|
|
471
|
+
if (/requireComposition\(/.test(body) || /\.type\s*!==?\s*["']composition["']/.test(body)) return true;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
return false;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function scanUniformData(root, uniformDataDir, project, add) {
|
|
478
|
+
const dir = path.resolve(root, uniformDataDir ?? "uniform-data");
|
|
479
|
+
if (!fs.existsSync(dir)) return;
|
|
480
|
+
|
|
481
|
+
const readYaml = (sub) => {
|
|
482
|
+
const folder = path.join(dir, sub);
|
|
483
|
+
if (!fs.existsSync(folder)) return [];
|
|
484
|
+
return fs
|
|
485
|
+
.readdirSync(folder)
|
|
486
|
+
.filter((f) => /\.(ya?ml|json)$/.test(f))
|
|
487
|
+
.map((f) => ({ file: path.relative(root, path.join(folder, f)), text: fs.readFileSync(path.join(folder, f), "utf8") }));
|
|
488
|
+
};
|
|
489
|
+
|
|
490
|
+
for (const { file, text } of readYaml("projectMapNode")) {
|
|
491
|
+
if (/queryStrings\s*:/.test(text) && !/queryStrings\s*:\s*\[\s*\]/.test(text)) {
|
|
492
|
+
add("project-map-query-strings", {
|
|
493
|
+
file,
|
|
494
|
+
message: "This project map node declares query strings. The developer preview middleware no longer reads them, and removes all query strings from the route path.",
|
|
495
|
+
fix: "List the query strings in the queryStrings option of the middleware.",
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
let withViz = 0;
|
|
501
|
+
for (const { text } of [...readYaml("composition"), ...readYaml("compositionPattern"), ...readYaml("componentPattern")]) {
|
|
502
|
+
if (/\$personalization|\$test\b/.test(text)) project.compositionsWithPersonalization++;
|
|
503
|
+
if (/\$viz/.test(text)) withViz++;
|
|
504
|
+
}
|
|
505
|
+
if (withViz) {
|
|
506
|
+
add("visibility-rules", {
|
|
507
|
+
file: path.relative(root, dir),
|
|
508
|
+
message: `${withViz} composition(s) or pattern(s) use visibility rules. These components render only in the browser, so they are not in the server HTML.`,
|
|
509
|
+
fix: "Do not put content for search engines in components with visibility rules.",
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function scanNotFoundRoutes(root, files, add) {
|
|
515
|
+
const notFoundRoutes = files.filter((f) => /(^|\/)app\/(.*\/)?404\/page\.[jt]sx?$/.test(path.relative(root, f).split(path.sep).join("/")));
|
|
516
|
+
for (const file of notFoundRoutes) {
|
|
517
|
+
add("not-found-page", {
|
|
518
|
+
file: path.relative(root, file),
|
|
519
|
+
message: `${STABLE} rewrites missing routes to /404. The developer preview calls notFound() on the page, so this route no longer renders for missing pages.`,
|
|
520
|
+
fix: "Move the 404 content to app/not-found.tsx.",
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
}
|