@haystackeditor/cli 0.24.1 → 0.25.1
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/dist/assets/capture/capture.cb4204fcc997d8e8.js +2 -0
- package/dist/assets/capture/release.json +4 -0
- package/dist/assets/telemetry/runtime.cjs +829 -1254
- package/dist/capture/adapters/client-routes.js +383 -0
- package/dist/capture/adapters/django.js +134 -0
- package/dist/capture/adapters/files.js +77 -0
- package/dist/capture/adapters/index.js +74 -0
- package/dist/capture/adapters/jsx-edit.js +81 -0
- package/dist/capture/adapters/next-build.js +113 -0
- package/dist/capture/adapters/next.js +494 -0
- package/dist/capture/adapters/nuxt.js +199 -0
- package/dist/capture/adapters/rails.js +178 -0
- package/dist/capture/adapters/react-router.js +439 -0
- package/dist/capture/adapters/sveltekit.js +109 -0
- package/dist/capture/adapters/types.js +4 -0
- package/dist/capture/adapters/vite.js +135 -0
- package/dist/capture/app-config.js +107 -0
- package/dist/capture/consent.js +127 -0
- package/dist/capture/csp.js +332 -0
- package/dist/capture/html.js +74 -0
- package/dist/capture/js-ast.js +400 -0
- package/dist/capture/manifest.js +95 -0
- package/dist/capture/project.js +177 -0
- package/dist/capture/route-pattern.js +119 -0
- package/dist/capture/script-release.js +47 -0
- package/dist/capture/tag.js +74 -0
- package/dist/capture/url-rewrites.js +232 -0
- package/dist/capture-step.js +56 -0
- package/dist/commands/capture-brief.js +92 -0
- package/dist/commands/capture-contract.js +46 -0
- package/dist/commands/capture-manifest.js +86 -0
- package/dist/commands/init-capture.js +426 -0
- package/dist/commands/init-telemetry.js +1028 -0
- package/dist/commands/init.js +78 -5
- package/dist/commands/server-telemetry-contract.d.ts +66 -0
- package/dist/commands/server-telemetry-contract.js +127 -0
- package/dist/commands/telemetry-token.js +238 -0
- package/dist/commands/telemetry.d.ts +161 -8
- package/dist/commands/telemetry.js +940 -158
- package/dist/commands/verify-onboarding.js +21 -1
- package/dist/commands/verify.js +56 -9
- package/dist/index.js +85 -6
- package/dist/schema.js +2 -2
- package/dist/telemetry/next-loader.cjs +66 -9
- package/dist/telemetry/next.d.ts +11 -3
- package/dist/telemetry/next.js +95 -15
- package/dist/telemetry/typed-source.d.ts +47 -0
- package/dist/telemetry/typed-source.js +379 -0
- package/package.json +4 -2
- package/schemas/init.v1.json +63 -4
- package/schemas/pre-verify.v1.json +60 -3
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Next.js (CAPTURE-V1 rule 8b): app router 13–16, pages router 11–16.
|
|
3
|
+
*
|
|
4
|
+
* Tag: next/script's <Script strategy="lazyOnload">, which loads the file after the page's load event (rule 1), at the
|
|
5
|
+
* end of the root layout's <body> (app router; app/layout, or the one layout of a single top-level dynamic segment such as
|
|
6
|
+
* app/[locale]/layout when the app has no app/layout) and in pages/_app (pages router; created when the app has none, as
|
|
7
|
+
* Next's default App plus the tag); a hybrid app gets both, and a page is only ever served by one of them. Several root
|
|
8
|
+
* layouts (route groups, several top-level segments) are a manual step.
|
|
9
|
+
*
|
|
10
|
+
* Routes come from the production build, never from next.config (next-build.ts): after `next build`, the manifest step
|
|
11
|
+
* reads the routes, basePath, i18n, rewrites, pageExtensions and assetPrefix the build actually used, so wrapped,
|
|
12
|
+
* conditional and environment-set configs are right by construction. Each route maps to its page file (route groups and
|
|
13
|
+
* @slots add no segment; intercepting routes and Next's own /_ entries are not routes of their own). Chunks: the JS under
|
|
14
|
+
* <distDir>/static/chunks, at the assetPrefix's path. Publication: public/, which every output serves (`next start`,
|
|
15
|
+
* Vercel, and standalone once the deployer copies public/ beside server.js, as Next's docs say), plus out/ for a static
|
|
16
|
+
* export. A basePath prefixes every route (Next serves pages only under it); the tag and the manifest are read at the origin
|
|
17
|
+
* root, so each such build says which two paths the host must route there (buildNotes).
|
|
18
|
+
*/
|
|
19
|
+
import { readdirSync, readFileSync } from 'node:fs';
|
|
20
|
+
import { join } from 'node:path';
|
|
21
|
+
import { configObject, defaultImportNames, importInsertion, indentUnit, lineIndent, parseFile, pathOf, readConfig, startsLine, unwrapTs, walk } from '../js-ast.js';
|
|
22
|
+
import { repoPath, resolveVersion, appFile } from '../project.js';
|
|
23
|
+
import { paramName, ruleFor } from '../route-pattern.js';
|
|
24
|
+
import { nextScriptTags } from '../tag.js';
|
|
25
|
+
import { middlewareRewrites, parseUrlRewrites, URL_REWRITES_QUESTION } from '../url-rewrites.js';
|
|
26
|
+
import { checkedResult, jsxElementsNamed, ourJsxElements, placeTags, reindented } from './jsx-edit.js';
|
|
27
|
+
import { chunkIds, isDirectory, isFile } from './files.js';
|
|
28
|
+
import { DEFAULT_EXTENSIONS, readNextBuild, staticPathPrefix } from './next-build.js';
|
|
29
|
+
const PAGES_RESERVED = new Set(['/_app', '/_document', '/_error', '/_middleware', '/404', '/500']);
|
|
30
|
+
/** A Next.js folder or file name as a pattern segment. */
|
|
31
|
+
export function nextSegment(name) {
|
|
32
|
+
const optionalCatchAll = /^\[\[\.\.\.(.+)\]\]$/.exec(name);
|
|
33
|
+
if (optionalCatchAll)
|
|
34
|
+
return { kind: 'splat', name: paramName(optionalCatchAll[1]), optional: true };
|
|
35
|
+
const catchAll = /^\[\.\.\.(.+)\]$/.exec(name);
|
|
36
|
+
if (catchAll)
|
|
37
|
+
return { kind: 'splat', name: paramName(catchAll[1]), optional: false };
|
|
38
|
+
const dynamic = /^\[(.+)\]$/.exec(name);
|
|
39
|
+
if (dynamic)
|
|
40
|
+
return { kind: 'param', name: paramName(dynamic[1]), optional: false };
|
|
41
|
+
if (name.includes('['))
|
|
42
|
+
return { kind: 'param', name: paramName(name), optional: false, inexact: `a segment mixing text and a parameter (${name})` };
|
|
43
|
+
let literal = name;
|
|
44
|
+
try {
|
|
45
|
+
literal = decodeURIComponent(name);
|
|
46
|
+
}
|
|
47
|
+
catch { /* a name that is not percent-encoding stays as it is */ }
|
|
48
|
+
return { kind: 'literal', value: literal };
|
|
49
|
+
}
|
|
50
|
+
const CONFIG_FILES = ['next.config.ts', 'next.config.mts', 'next.config.mjs', 'next.config.js', 'next.config.cjs'];
|
|
51
|
+
/** The pageExtensions in effect for tag placement (rule 13: a file Next ignores, or a second _app, must never be what init
|
|
52
|
+
* writes): the build's; without one, next.config's (Next's default when it sets none); Next's default assumed when the
|
|
53
|
+
* config is not one init can read at all; a manual step when it sets them in a way init cannot read. */
|
|
54
|
+
function pageExtensions(ctx, build) {
|
|
55
|
+
if (!('manual' in build))
|
|
56
|
+
return { list: build.pageExtensions, assumed: null };
|
|
57
|
+
const file = CONFIG_FILES.map(name => join(ctx.appDir, name)).find(isFile);
|
|
58
|
+
if (!file)
|
|
59
|
+
return { list: DEFAULT_EXTENSIONS, assumed: null };
|
|
60
|
+
const where = repoPath(ctx, file);
|
|
61
|
+
const ast = parseFile(readFileSync(file, 'utf8'), file);
|
|
62
|
+
if (!ast || !configObject(ast)) {
|
|
63
|
+
return { list: DEFAULT_EXTENSIONS, assumed: `${where} is not a config init can read, and there is no build output to read pageExtensions from` };
|
|
64
|
+
}
|
|
65
|
+
const read = readConfig(ast, ['pageExtensions']);
|
|
66
|
+
if (read.state === 'absent')
|
|
67
|
+
return { list: DEFAULT_EXTENSIONS, assumed: null };
|
|
68
|
+
if (read.state === 'value' && Array.isArray(read.value) && read.value.length > 0 && read.value.every(item => typeof item === 'string')) {
|
|
69
|
+
return { list: read.value, assumed: null };
|
|
70
|
+
}
|
|
71
|
+
return { manual: `${where} sets pageExtensions in a way init cannot read, and there is no build output (\`next build\`) to read them from` };
|
|
72
|
+
}
|
|
73
|
+
/** The files in `dir` named `<base>.<anything>`. */
|
|
74
|
+
function namedLike(dir, base) {
|
|
75
|
+
let names;
|
|
76
|
+
try {
|
|
77
|
+
names = readdirSync(dir);
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return [];
|
|
81
|
+
}
|
|
82
|
+
return names.filter(name => name.startsWith(`${base}.`) && isFile(join(dir, name)));
|
|
83
|
+
}
|
|
84
|
+
/** The one file `<base>.<extension>` in `dir` for the app's pageExtensions (layout.tsx, _app.page.jsx: Next's own rule,
|
|
85
|
+
* so with known extensions layout.test.tsx, layout.stories.tsx or layout.d.ts are not layouts), null when there is none,
|
|
86
|
+
* or a manual step when there are several, or when the extensions are assumed and another `<base>.*` file exists. */
|
|
87
|
+
function entryFile(ctx, dir, base, extensions) {
|
|
88
|
+
const named = namedLike(dir, base);
|
|
89
|
+
const found = extensions.list.map(extension => `${base}.${extension}`).filter(name => named.includes(name));
|
|
90
|
+
if (found.length > 1)
|
|
91
|
+
return { manual: `${repoPath(ctx, dir)} has several ${base} files (${found.join(', ')})` };
|
|
92
|
+
const others = named.filter(name => !found.includes(name));
|
|
93
|
+
if (extensions.assumed !== null && others.length > 0) {
|
|
94
|
+
return { manual: `${repoPath(ctx, dir)} has ${others.join(', ')}, which Next may use as its ${base} (${extensions.assumed})` };
|
|
95
|
+
}
|
|
96
|
+
return found.length === 1 ? join(dir, found[0]) : null;
|
|
97
|
+
}
|
|
98
|
+
/** The page file of a build entry: `<dir>/<base>.<ext>` for the build's pageExtensions, in Next's order of precedence. */
|
|
99
|
+
function pageFile(dir, base, extensions) {
|
|
100
|
+
for (const extension of extensions) {
|
|
101
|
+
const file = join(dir, `${base}.${extension}`);
|
|
102
|
+
if (isFile(file))
|
|
103
|
+
return file;
|
|
104
|
+
}
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
/** The import style of the file: its first import's quote and whether statements end with semicolons. */
|
|
108
|
+
function importStyle(ast, code) {
|
|
109
|
+
const first = ast.program.body.find((statement) => statement.type === 'ImportDeclaration');
|
|
110
|
+
if (!first)
|
|
111
|
+
return { quote: '\'', semicolon: ';' };
|
|
112
|
+
return { quote: code[first.source.start], semicolon: code.slice(first.start, first.end).trimEnd().endsWith(';') ? ';' : '' };
|
|
113
|
+
}
|
|
114
|
+
const ALIASES = ['Script', 'HaystackScript', 'HaystackCaptureScript', 'HaystackNextScript'];
|
|
115
|
+
/** The name for next/script's Script at `site` (the element the tag goes into): an import whose binding the site actually
|
|
116
|
+
* sees, else a new import under an alias nothing along the site's scope chain binds and the module does not use as a
|
|
117
|
+
* global; neither is a manual step (a local `Script` would shadow the import there). */
|
|
118
|
+
function scriptImport(ast, code, site) {
|
|
119
|
+
const sitePath = pathOf(ast, site);
|
|
120
|
+
if (!sitePath)
|
|
121
|
+
return { manual: 'init could not resolve the names in scope where the tag goes' };
|
|
122
|
+
const components = defaultImportNames(ast, 'next/script');
|
|
123
|
+
for (const local of components) {
|
|
124
|
+
const binding = sitePath.scope.getBinding(local);
|
|
125
|
+
if (binding?.kind === 'module' && binding.path.parent?.source?.value === 'next/script')
|
|
126
|
+
return { name: local, components, edit: null };
|
|
127
|
+
}
|
|
128
|
+
const program = sitePath.scope.getProgramParent();
|
|
129
|
+
const name = ALIASES.find(candidate => !sitePath.scope.hasBinding(candidate, { noGlobals: true }) && !program.hasBinding(candidate, { noGlobals: true })
|
|
130
|
+
&& !program.hasGlobal(candidate));
|
|
131
|
+
if (!name)
|
|
132
|
+
return { manual: `every name init would import next/script's Script as (${ALIASES.join(', ')}) is taken where the tag goes` };
|
|
133
|
+
const { quote, semicolon } = importStyle(ast, code);
|
|
134
|
+
const at = importInsertion(ast, code);
|
|
135
|
+
return { name, components: [...components, name],
|
|
136
|
+
edit: { start: at.at, end: at.at, text: `${at.before}import ${name} from ${quote}next/script${quote}${semicolon}${at.after}` } };
|
|
137
|
+
}
|
|
138
|
+
/** The app router's root layout: app/layout, else the one layout of a single top-level dynamic segment
|
|
139
|
+
* (app/[locale]/layout, which renders <html> for every page under it) when nothing else is a root layout. */
|
|
140
|
+
function rootLayout(ctx, appRouter, extensions) {
|
|
141
|
+
const own = entryFile(ctx, appRouter, 'layout', extensions);
|
|
142
|
+
if (own !== null)
|
|
143
|
+
return own;
|
|
144
|
+
let entries;
|
|
145
|
+
try {
|
|
146
|
+
entries = readdirSync(appRouter);
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
entries = [];
|
|
150
|
+
}
|
|
151
|
+
const candidates = [];
|
|
152
|
+
const groups = [];
|
|
153
|
+
for (const entry of entries) {
|
|
154
|
+
if (!isDirectory(join(appRouter, entry)))
|
|
155
|
+
continue;
|
|
156
|
+
const layoutFile = entryFile(ctx, join(appRouter, entry), 'layout', extensions);
|
|
157
|
+
if (layoutFile === null)
|
|
158
|
+
continue;
|
|
159
|
+
if (typeof layoutFile !== 'string')
|
|
160
|
+
return layoutFile;
|
|
161
|
+
if (/^\[[^.[\]]+\]$/.test(entry))
|
|
162
|
+
candidates.push(layoutFile);
|
|
163
|
+
else if (/^\(.+\)$/.test(entry))
|
|
164
|
+
groups.push(layoutFile);
|
|
165
|
+
}
|
|
166
|
+
if (candidates.length === 1 && groups.length === 0)
|
|
167
|
+
return candidates[0];
|
|
168
|
+
const all = [...candidates, ...groups].map(file => file.slice(appRouter.length + 1));
|
|
169
|
+
return { manual: all.length > 1
|
|
170
|
+
? `the app router has several root layouts (${all.map(file => `app/${file}`).join(', ')}), each needing the tag`
|
|
171
|
+
: 'the app router has no root layout (app/layout, or one under a single top-level dynamic segment)' };
|
|
172
|
+
}
|
|
173
|
+
async function rootLayoutEdit(ctx, appRouter, tags, extensions) {
|
|
174
|
+
const layout = rootLayout(ctx, appRouter, extensions);
|
|
175
|
+
if (typeof layout !== 'string')
|
|
176
|
+
return layout;
|
|
177
|
+
const code = readFileSync(layout, 'utf8');
|
|
178
|
+
const ast = parseFile(code, layout);
|
|
179
|
+
if (!ast)
|
|
180
|
+
return { manual: `${layout} does not parse` };
|
|
181
|
+
// The <body> that renders the pages: the one with the layout's children in it (a branch that renders an empty body,
|
|
182
|
+
// like Umami's DISABLE_UI, shows no page to record).
|
|
183
|
+
const bodies = jsxElementsNamed(ast, 'body');
|
|
184
|
+
const rendering = bodies.filter(body => {
|
|
185
|
+
let renders = false;
|
|
186
|
+
walk(body, node => { if (node.type === 'Identifier' && node.name === 'children')
|
|
187
|
+
renders = true; });
|
|
188
|
+
return renders && body.closingElement;
|
|
189
|
+
});
|
|
190
|
+
const body = bodies.length === 1 && bodies[0].closingElement ? bodies[0] : rendering.length === 1 ? rendering[0] : null;
|
|
191
|
+
if (!body) {
|
|
192
|
+
return { manual: `the root layout has ${bodies.length} <body> elements and ${rendering.length} of them render its children, not one` };
|
|
193
|
+
}
|
|
194
|
+
const script = scriptImport(ast, code, body);
|
|
195
|
+
if ('manual' in script)
|
|
196
|
+
return script;
|
|
197
|
+
const rendered = nextScriptTags(tags, script.name);
|
|
198
|
+
const edits = placeTags(code, ourJsxElements(ast, script.components), body.closingElement.start, rendered, indentUnit(code));
|
|
199
|
+
if ('manual' in edits)
|
|
200
|
+
return edits;
|
|
201
|
+
if (edits.length === 0)
|
|
202
|
+
return { target: layout, content: code };
|
|
203
|
+
const result = checkedResult(code, layout, script.edit ? [...edits, script.edit] : edits, script.components, rendered);
|
|
204
|
+
return typeof result === 'string' ? { target: layout, content: result } : result;
|
|
205
|
+
}
|
|
206
|
+
/** The JSX the default-exported App component returns (through HOC wrappers and class render methods). */
|
|
207
|
+
function appReturn(ast) {
|
|
208
|
+
const exported = ast.program.body.find((statement) => statement.type === 'ExportDefaultDeclaration')?.declaration;
|
|
209
|
+
if (!exported)
|
|
210
|
+
return { manual: 'pages/_app has no default export' };
|
|
211
|
+
const resolveComponent = (node, depth) => {
|
|
212
|
+
const current = unwrapTs(node);
|
|
213
|
+
if (!current || depth > 5)
|
|
214
|
+
return null;
|
|
215
|
+
if (current.type === 'CallExpression' && current.arguments.length > 0)
|
|
216
|
+
return resolveComponent(current.arguments[0], depth + 1);
|
|
217
|
+
if (current.type !== 'Identifier')
|
|
218
|
+
return current;
|
|
219
|
+
for (const statement of ast.program.body) {
|
|
220
|
+
const declaration = statement.type === 'ExportNamedDeclaration' ? statement.declaration : statement;
|
|
221
|
+
if ((declaration?.type === 'FunctionDeclaration' || declaration?.type === 'ClassDeclaration') && declaration.id?.name === current.name)
|
|
222
|
+
return declaration;
|
|
223
|
+
if (declaration?.type === 'VariableDeclaration') {
|
|
224
|
+
const found = declaration.declarations.find((item) => item.id.type === 'Identifier' && item.id.name === current.name);
|
|
225
|
+
if (found?.init)
|
|
226
|
+
return resolveComponent(found.init, depth + 1);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return null;
|
|
230
|
+
};
|
|
231
|
+
let component = resolveComponent(exported, 0);
|
|
232
|
+
if (component?.type === 'ClassDeclaration' || component?.type === 'ClassExpression') {
|
|
233
|
+
component = component.body.body.find((member) => member.type === 'ClassMethod' && member.key.name === 'render') ?? null;
|
|
234
|
+
}
|
|
235
|
+
if (!component || !['FunctionDeclaration', 'FunctionExpression', 'ArrowFunctionExpression', 'ClassMethod'].includes(component.type)) {
|
|
236
|
+
return { manual: 'pages/_app\'s default export is not a component init can read' };
|
|
237
|
+
}
|
|
238
|
+
if (component.body.type !== 'BlockStatement')
|
|
239
|
+
return unwrapTs(component.body);
|
|
240
|
+
const returns = [];
|
|
241
|
+
walk(component.body, node => {
|
|
242
|
+
if (node !== component.body && /Function|ClassMethod|ObjectMethod/.test(node.type))
|
|
243
|
+
return false;
|
|
244
|
+
if (node.type === 'ReturnStatement' && node.argument)
|
|
245
|
+
returns.push(unwrapTs(node.argument));
|
|
246
|
+
});
|
|
247
|
+
return returns.length === 1 ? returns[0] : { manual: `pages/_app's component has ${returns.length} return statements, not one` };
|
|
248
|
+
}
|
|
249
|
+
function newApp(name, tags, typescript) {
|
|
250
|
+
const rendered = nextScriptTags(tags, name).map(tag => ` ${tag}\n`).join('');
|
|
251
|
+
return `${typescript ? 'import type { AppProps } from \'next/app\';\n' : ''}import ${name} from 'next/script';
|
|
252
|
+
|
|
253
|
+
export default function App({ Component, pageProps }${typescript ? ': AppProps' : ''}) {
|
|
254
|
+
return (
|
|
255
|
+
<>
|
|
256
|
+
<Component {...pageProps} />
|
|
257
|
+
${rendered} </>
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
`;
|
|
261
|
+
}
|
|
262
|
+
async function pagesAppEdit(ctx, pagesRouter, tags, extensions) {
|
|
263
|
+
const app = entryFile(ctx, pagesRouter, '_app', extensions);
|
|
264
|
+
if (app !== null && typeof app !== 'string')
|
|
265
|
+
return app;
|
|
266
|
+
if (!app) {
|
|
267
|
+
// Never a second App: with any _app.* there (one Next may use, or a duplicate-page build error to come), the tag is
|
|
268
|
+
// a manual step.
|
|
269
|
+
const existing = namedLike(pagesRouter, '_app');
|
|
270
|
+
if (existing.length > 0) {
|
|
271
|
+
return { manual: `${repoPath(ctx, pagesRouter)} has ${existing.join(', ')}, none of which is pages/_app for the pageExtensions (${extensions.list.join(', ')}), and init never creates a second App` };
|
|
272
|
+
}
|
|
273
|
+
// Nor one under extensions it only assumes (Next would ignore a file outside its pageExtensions).
|
|
274
|
+
if (extensions.assumed !== null)
|
|
275
|
+
return { manual: `the app has no pages/_app, and init cannot tell which extension Next would load a new one with (${extensions.assumed})` };
|
|
276
|
+
// A new pages/_app under an extension the app's pageExtensions include (Next ignores any other).
|
|
277
|
+
const typescript = isFile(join(ctx.appDir, 'tsconfig.json'));
|
|
278
|
+
const list = extensions.list;
|
|
279
|
+
const extension = list.find(item => item.endsWith(typescript ? 'tsx' : 'jsx')) ?? (typescript ? null : list.find(item => item.endsWith('js')));
|
|
280
|
+
if (!extension)
|
|
281
|
+
return { manual: `the app has no pages/_app, and its pageExtensions (${list.join(', ')}) have none for a JSX file init could create` };
|
|
282
|
+
return { target: join(pagesRouter, `_app.${extension}`), content: newApp('Script', tags, typescript) };
|
|
283
|
+
}
|
|
284
|
+
const code = readFileSync(app, 'utf8');
|
|
285
|
+
const ast = parseFile(code, app);
|
|
286
|
+
if (!ast)
|
|
287
|
+
return { manual: `${app} does not parse` };
|
|
288
|
+
const ours = ourJsxElements(ast, defaultImportNames(ast, 'next/script'));
|
|
289
|
+
const returned = ours.length > 0 ? ours[0] : appReturn(ast);
|
|
290
|
+
if ('manual' in returned)
|
|
291
|
+
return returned;
|
|
292
|
+
const script = scriptImport(ast, code, returned);
|
|
293
|
+
if ('manual' in script)
|
|
294
|
+
return script;
|
|
295
|
+
const rendered = nextScriptTags(tags, script.name);
|
|
296
|
+
const unit = indentUnit(code);
|
|
297
|
+
let edits;
|
|
298
|
+
if (ours.length > 0) {
|
|
299
|
+
edits = placeTags(code, ours, null, rendered, unit);
|
|
300
|
+
}
|
|
301
|
+
else {
|
|
302
|
+
if (returned.type === 'JSXFragment') {
|
|
303
|
+
edits = placeTags(code, [], returned.closingFragment.start, rendered, unit);
|
|
304
|
+
}
|
|
305
|
+
else if (returned.type === 'JSXElement') {
|
|
306
|
+
const inner = startsLine(code, returned.start) ? reindented(returned, code, unit) : null;
|
|
307
|
+
const indent = lineIndent(code, returned.start);
|
|
308
|
+
const text = inner === null
|
|
309
|
+
? `<>${code.slice(returned.start, returned.end)}${rendered.join('')}</>`
|
|
310
|
+
: `<>\n${indent}${unit}${inner}\n${rendered.map(tag => `${indent}${unit}${tag}\n`).join('')}${indent}</>`;
|
|
311
|
+
edits = [{ start: returned.start, end: returned.end, text }];
|
|
312
|
+
}
|
|
313
|
+
else {
|
|
314
|
+
return { manual: 'pages/_app\'s component does not return JSX' };
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
if ('manual' in edits)
|
|
318
|
+
return edits;
|
|
319
|
+
if (edits.length === 0)
|
|
320
|
+
return { target: app, content: code };
|
|
321
|
+
const result = checkedResult(code, app, script.edit ? [...edits, script.edit] : edits, script.components, rendered);
|
|
322
|
+
return typeof result === 'string' ? { target: app, content: result } : result;
|
|
323
|
+
}
|
|
324
|
+
function formatRewrites(rewrites) {
|
|
325
|
+
return `${rewrites.slice(0, 3).map(rewrite => `${rewrite.source} -> ${rewrite.destination}`).join(', ')}${rewrites.length > 3 ? ', …' : ''}`;
|
|
326
|
+
}
|
|
327
|
+
function literals(path) {
|
|
328
|
+
return path.split('/').filter(part => part !== '').map((value) => ({ kind: 'literal', value }));
|
|
329
|
+
}
|
|
330
|
+
/** The routes this production build serves, each with its page file; manual when the build says something the manifest
|
|
331
|
+
* cannot express exactly or the user's answer about rewrites is missing. */
|
|
332
|
+
function buildRoutes(ctx, appRouter, pagesRouter, build, middleware, answer) {
|
|
333
|
+
const rewrites = answer ? parseUrlRewrites(answer) : null;
|
|
334
|
+
if (middleware.kind === 'unknown' && !rewrites)
|
|
335
|
+
return { manual: `${middleware.why}. ${URL_REWRITES_QUESTION}` };
|
|
336
|
+
const hiddenName = middleware.kind === 'unknown' && rewrites?.kind === 'hidden-segment' ? rewrites.name : null;
|
|
337
|
+
if (build.pageRewrites.length > 0) {
|
|
338
|
+
const listed = formatRewrites(build.pageRewrites);
|
|
339
|
+
if (!rewrites)
|
|
340
|
+
return { manual: `next.config rewrites page URLs (${listed}). ${URL_REWRITES_QUESTION}` };
|
|
341
|
+
// The answer covers rewrites init cannot read; next-intl's middleware init reads itself, so there it is about
|
|
342
|
+
// next.config's rewrites alone, which add no segment.
|
|
343
|
+
if (middleware.kind === 'locale-prefix' && rewrites.kind !== 'none') {
|
|
344
|
+
return { manual: `init reads ${middleware.where} as next-intl's middleware itself, so the answer is about next.config's page rewrites (${listed}) alone:`
|
|
345
|
+
+ ' `--url-rewrites none` when they never make a page\'s URL show another page' };
|
|
346
|
+
}
|
|
347
|
+
// A rewrite into or out of the hidden segment makes URLs show it, or another route's path under it.
|
|
348
|
+
const param = hiddenName === null ? null : new RegExp(`(^|/):${hiddenName}(?![A-Za-z0-9_])`);
|
|
349
|
+
const touching = param ? build.pageRewrites.filter(rewrite => param.test(rewrite.source) || param.test(rewrite.destination)) : [];
|
|
350
|
+
if (touching.length > 0) {
|
|
351
|
+
return { manual: `next.config rewrites into or out of the hidden [${hiddenName}] segment (${formatRewrites(touching)}), so those URLs show`
|
|
352
|
+
+ ' paths the route manifest cannot follow' };
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
// next-intl (never, as-needed): the locale's segment is the app router's one top-level dynamic segment.
|
|
356
|
+
const intl = middleware.kind === 'locale-prefix' && middleware.mode !== 'always' ? middleware : null;
|
|
357
|
+
let localeSegment = hiddenName === null ? null : `[${hiddenName}]`;
|
|
358
|
+
if (intl) {
|
|
359
|
+
const firsts = new Set(Object.values(build.appPaths ?? {}).map(path => path.split('/').filter(part => part !== '')[0])
|
|
360
|
+
.filter((part) => part !== undefined && /^\[[^.[\]]+\]$/.test(part)));
|
|
361
|
+
if (firsts.size !== 1) {
|
|
362
|
+
return { manual: `${intl.where} uses next-intl's middleware with localePrefix ${intl.mode}, and the app router has ${firsts.size === 0 ? 'no'
|
|
363
|
+
: `several (${[...firsts].join(', ')})`} top-level dynamic segment for the locale` };
|
|
364
|
+
}
|
|
365
|
+
localeSegment = [...firsts][0];
|
|
366
|
+
}
|
|
367
|
+
const base = literals(build.basePath);
|
|
368
|
+
const routes = [];
|
|
369
|
+
const add = (path, source, rank, entry) => {
|
|
370
|
+
const parts = path.split('/').filter(part => part !== '');
|
|
371
|
+
// The URL forms of the path: without the hidden segment (an answer, or next-intl's `never`), and for `as-needed`
|
|
372
|
+
// without it for the default locale and under each other locale's prefix.
|
|
373
|
+
const forms = localeSegment === null || parts[0] !== localeSegment ? [parts.map(nextSegment)]
|
|
374
|
+
: [parts.slice(1).map(nextSegment), ...(intl?.mode === 'as-needed' ? intl.locales.filter(locale => locale !== intl.defaultLocale)
|
|
375
|
+
.map(locale => [...literals(intl.prefixes[locale] ?? `/${locale}`), ...parts.slice(1).map(nextSegment)]) : [])];
|
|
376
|
+
for (const segments of forms) {
|
|
377
|
+
routes.push(source === null ? { match: '/', source: entry, inexact: `no page file for ${entry} was found` }
|
|
378
|
+
: { ...ruleFor([...base, ...segments]), source: repoPath(ctx, source), rank });
|
|
379
|
+
}
|
|
380
|
+
};
|
|
381
|
+
for (const [entry, path] of Object.entries(build.appPaths ?? {})) {
|
|
382
|
+
if (!entry.endsWith('/page') || !appRouter)
|
|
383
|
+
continue;
|
|
384
|
+
const folders = entry.split('/').slice(1, -1);
|
|
385
|
+
// Next's own entries (/_not-found, /_global-error), @slot pages (they render beside the page of the same URL) and
|
|
386
|
+
// intercepting routes (a client navigation's view of another route, whose URL it keeps) are not routes of their own.
|
|
387
|
+
if (path.startsWith('/_') || folders.some(folder => folder.startsWith('@') || /^\((\.{1,3}|\.\.\)\(\.\.)\)/.test(folder)))
|
|
388
|
+
continue;
|
|
389
|
+
add(path, pageFile(join(appRouter, ...folders), 'page', build.pageExtensions), 0, `app${entry}`);
|
|
390
|
+
}
|
|
391
|
+
for (const page of Object.keys(build.pages ?? {})) {
|
|
392
|
+
if (!pagesRouter || PAGES_RESERVED.has(page) || page === '/api' || page.startsWith('/api/') || page.startsWith('/_'))
|
|
393
|
+
continue;
|
|
394
|
+
// With i18n, statically generated pages are listed once per locale too (/fr/about); the page is /about.
|
|
395
|
+
const parts = page.split('/').filter(part => part !== '');
|
|
396
|
+
if (build.i18n && parts.length > 0 && build.i18n.locales.includes(parts[0]))
|
|
397
|
+
continue;
|
|
398
|
+
const base = parts.length === 0 ? 'index' : parts[parts.length - 1];
|
|
399
|
+
const dir = join(pagesRouter, ...parts.slice(0, -1));
|
|
400
|
+
const source = pageFile(dir, base, build.pageExtensions) ?? pageFile(join(dir, base), 'index', build.pageExtensions);
|
|
401
|
+
add(page, source, 0, `pages${page}`);
|
|
402
|
+
// i18n (pages router): the default locale serves the page without a prefix, and every locale with one.
|
|
403
|
+
for (const locale of build.i18n?.locales ?? [])
|
|
404
|
+
add(`/${locale}${page === '/' ? '' : page}`, source, 0, `pages${page}`);
|
|
405
|
+
}
|
|
406
|
+
return routes;
|
|
407
|
+
}
|
|
408
|
+
export async function detectNext(ctx) {
|
|
409
|
+
const version = resolveVersion(ctx, 'next');
|
|
410
|
+
if (!version)
|
|
411
|
+
return null;
|
|
412
|
+
const rootRouters = isDirectory(join(ctx.appDir, 'app')) || isDirectory(join(ctx.appDir, 'pages'));
|
|
413
|
+
const base = rootRouters ? ctx.appDir : join(ctx.appDir, 'src');
|
|
414
|
+
const appRouter = isDirectory(join(base, 'app')) ? join(base, 'app') : null;
|
|
415
|
+
const pagesRouter = isDirectory(join(base, 'pages')) ? join(base, 'pages') : null;
|
|
416
|
+
if (!appRouter && !pagesRouter)
|
|
417
|
+
return null;
|
|
418
|
+
const routers = [appRouter && 'app router', pagesRouter && 'pages router'].filter(Boolean).join(' and ');
|
|
419
|
+
const looked = [appRouter && repoPath(ctx, join(appRouter, 'layout.*')), pagesRouter && repoPath(ctx, join(pagesRouter, '_app.*'))]
|
|
420
|
+
.filter((item) => Boolean(item));
|
|
421
|
+
const supported = appRouter ? [13, 14, 15, 16] : [11, 12, 13, 14, 15, 16];
|
|
422
|
+
const blocked = version.major === null || !supported.includes(version.major)
|
|
423
|
+
? `init knows how Next.js ${supported[0]}–${supported[supported.length - 1]} load scripts for the ${routers}, and this app's version is ${version.version} (${version.from})`
|
|
424
|
+
: null;
|
|
425
|
+
const middleware = middlewareRewrites(ctx);
|
|
426
|
+
const scripts = ctx.pkg?.scripts ?? {};
|
|
427
|
+
let build = null;
|
|
428
|
+
const readBuild = () => (build ??= readNextBuild(ctx.appDir));
|
|
429
|
+
const intlNote = middleware.kind === 'locale-prefix' ? ` It reads ${middleware.where} as next-intl's middleware (localePrefix`
|
|
430
|
+
+ ` ${middleware.mode}${middleware.mode === 'as-needed' ? `: ${middleware.defaultLocale}'s URLs have no locale segment` : ''}).` : '';
|
|
431
|
+
return {
|
|
432
|
+
id: 'next',
|
|
433
|
+
label: `Next.js ${version.version} (${routers})`,
|
|
434
|
+
manifestAdapter: `next@${version.version}`,
|
|
435
|
+
blocked,
|
|
436
|
+
manifestBlocked: null,
|
|
437
|
+
fromBuild: true,
|
|
438
|
+
rewriting: middleware.kind === 'unknown' ? middleware.why : null,
|
|
439
|
+
publishNote: 'Next.js: each build writes the route manifest into public/, which next start and Vercel serve; with output: \'standalone\','
|
|
440
|
+
+ ' copy public/ beside server.js as Next\'s deployment docs say (the manifest goes with it); a static export gets it too. A build with a'
|
|
441
|
+
+ ` basePath says which two paths to route at the origin root.${intlNote}`,
|
|
442
|
+
looked,
|
|
443
|
+
staticDir: 'public',
|
|
444
|
+
async integrate(tags) {
|
|
445
|
+
const extensions = pageExtensions(ctx, readBuild());
|
|
446
|
+
if ('manual' in extensions)
|
|
447
|
+
return extensions;
|
|
448
|
+
const edits = [];
|
|
449
|
+
for (const edit of [appRouter && await rootLayoutEdit(ctx, appRouter, tags, extensions),
|
|
450
|
+
pagesRouter && await pagesAppEdit(ctx, pagesRouter, tags, extensions)]) {
|
|
451
|
+
if (!edit)
|
|
452
|
+
continue;
|
|
453
|
+
if ('manual' in edit)
|
|
454
|
+
return edit;
|
|
455
|
+
edits.push(edit);
|
|
456
|
+
}
|
|
457
|
+
return edits;
|
|
458
|
+
},
|
|
459
|
+
manualTag: tags => [
|
|
460
|
+
`In the root layout (${appRouter ? repoPath(ctx, join(appRouter, 'layout.tsx')) : 'app/layout.tsx'} or the one that renders <html>, inside <body>)`
|
|
461
|
+
+ ' and/or pages/_app, with `import Script from \'next/script\';`:',
|
|
462
|
+
...nextScriptTags(tags, 'Script'),
|
|
463
|
+
],
|
|
464
|
+
build: typeof scripts.build === 'string' ? { kind: 'package-script', script: 'build', phase: 'after' }
|
|
465
|
+
: { manual: `${appFile(ctx, 'package.json')} has no build script to publish the route manifest after` },
|
|
466
|
+
async routes(inputs) {
|
|
467
|
+
const current = readBuild();
|
|
468
|
+
return 'manual' in current ? current : buildRoutes(ctx, appRouter, pagesRouter, current, middleware, inputs.urlRewrites);
|
|
469
|
+
},
|
|
470
|
+
chunks() {
|
|
471
|
+
const current = readBuild();
|
|
472
|
+
if ('manual' in current)
|
|
473
|
+
return [];
|
|
474
|
+
// Next serves its files under the assetPrefix when one is set, else under the basePath.
|
|
475
|
+
const prefix = current.assetPrefix !== '' ? staticPathPrefix(current.assetPrefix) : current.basePath;
|
|
476
|
+
return chunkIds(join(current.dir, 'static', 'chunks'), `${prefix}/_next/static/chunks/`);
|
|
477
|
+
},
|
|
478
|
+
publishDirs: ['public'],
|
|
479
|
+
buildPublishDirs() {
|
|
480
|
+
const current = readBuild();
|
|
481
|
+
return 'manual' in current || current.exportDir === null ? [] : [current.exportDir];
|
|
482
|
+
},
|
|
483
|
+
buildNotes() {
|
|
484
|
+
const current = readBuild();
|
|
485
|
+
if ('manual' in current || current.basePath === '')
|
|
486
|
+
return [];
|
|
487
|
+
const base = current.basePath;
|
|
488
|
+
return [`this build serves the app under basePath ${base}, so Next serves public/ at ${base}/, and the tag (/_haystack/) and the route`
|
|
489
|
+
+ ` manifest (/.well-known/haystack-capture/) are read at the origin root: route those two paths to ${base}/_haystack/ and`
|
|
490
|
+
+ ` ${base}/.well-known/haystack-capture/ (on the host in front of the app, or with next.config rewrites marked basePath: false);`
|
|
491
|
+
+ ' until then the tag records nothing'];
|
|
492
|
+
},
|
|
493
|
+
};
|
|
494
|
+
}
|