@haystackeditor/cli 0.25.0 → 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.
@@ -106,6 +106,13 @@ export async function detectDjango(ctx) {
106
106
  label: `Django ${version?.version ?? '(unknown version)'}`,
107
107
  manifestAdapter: `django@${version?.version ?? 'unknown'}`,
108
108
  blocked: blocked.length > 0 ? blocked.join('; ') : null,
109
+ // The routes come from the source, so whatever stops init placing the tag stops the manifest too.
110
+ manifestBlocked: blocked.length > 0 ? blocked.join('; ') : null,
111
+ fromBuild: false,
112
+ rewriting: null,
113
+ publishNote: null,
114
+ buildPublishDirs: () => [],
115
+ buildNotes: () => [],
109
116
  looked: [appFile(ctx, '**/templates/**/*.html'), ...templates.map(file => repoPath(ctx, join(ctx.appDir, file)))],
110
117
  staticDir,
111
118
  async integrate(tags) {
@@ -17,6 +17,16 @@ import { detectStaticHtml, detectVite } from './vite.js';
17
17
  const DETECTORS = [
18
18
  detectNext, detectReactRouter, detectSvelteKit, detectNuxt, detectVite, detectRails, detectDjango,
19
19
  ];
20
+ /** The given adapter's view of this directory (what --adapter names), or null when that adapter does not recognize it. */
21
+ export async function detectWith(ctx, id) {
22
+ for (const detect of [...DETECTORS, detectStaticHtml]) {
23
+ const detected = await detect(ctx);
24
+ if (detected?.id === id)
25
+ return detected;
26
+ }
27
+ return null;
28
+ }
29
+ export const ADAPTER_IDS = ['next', 'vite', 'react-router', 'remix', 'sveltekit', 'nuxt', 'rails', 'django', 'static-html'];
20
30
  /** The adapter that claims this directory, or null. A directory with no package manifest, Gemfile or manage.py and an
21
31
  * index.html is a static site. */
22
32
  export async function detectApp(ctx) {
@@ -0,0 +1,113 @@
1
+ /**
2
+ * What a Next.js production build says about its routes (CAPTURE-V1 rule 4), read after `next build` from its own output,
3
+ * so wrappers, conditionals and environment-set options (`basePath: process.env.BASE_PATH`, `output: isVercel ? … : …`)
4
+ * are what this build actually used. Formats as Next 13–16 write them (packages/next/src/build/index.ts,
5
+ * generate-routes-manifest.ts, shared/lib/router/utils/app-paths.ts):
6
+ * <distDir>/routes-manifest.json { version: 3, basePath, i18n?, rewrites: { beforeFiles, afterFiles, fallback }
7
+ * (an array before Next 10), staticRoutes, dynamicRoutes, … }
8
+ * <distDir>/required-server-files.json { version: 1, config: the resolved config (pageExtensions, output, assetPrefix,
9
+ * basePath, i18n, …), appDir, relativeAppDir, files, ignore }
10
+ * <distDir>/app-path-routes-manifest.json { "/(group)/x/[id]/page": "/x/[id]", … } (app router)
11
+ * <distDir>/server/app-paths-manifest.json { "/(group)/x/[id]/page": "app/(group)/x/[id]/page.js", … } (app router)
12
+ * <distDir>/server/pages-manifest.json { "/about": "pages/about.js", "/_app": "pages/_app.js", … } (pages router)
13
+ * <distDir>/export-detail.json { version: 1, outDirectory, success } (export/index.ts): written by a static
14
+ * export (`output: 'export'`) inside the build, removed by any other build
15
+ * <distDir>/BUILD_ID
16
+ */
17
+ import { readdirSync, readFileSync, statSync } from 'node:fs';
18
+ import { isAbsolute, join, relative } from 'node:path';
19
+ import { isDirectory, isFile } from './files.js';
20
+ /** Next's default pageExtensions (config-shared.ts), in its order of precedence. */
21
+ export const DEFAULT_EXTENSIONS = ['tsx', 'ts', 'jsx', 'js'];
22
+ function json(file) {
23
+ if (!isFile(file))
24
+ return null;
25
+ return JSON.parse(readFileSync(file, 'utf8'));
26
+ }
27
+ /** The build directory: .next when it holds a build, else the one top-level directory that does (a custom distDir),
28
+ * the newest when several do (the build that just ran). */
29
+ function buildDir(appDir) {
30
+ const holdsBuild = (dir) => isFile(join(dir, 'routes-manifest.json')) && isFile(join(dir, 'BUILD_ID'));
31
+ if (holdsBuild(join(appDir, '.next')))
32
+ return join(appDir, '.next');
33
+ let entries;
34
+ try {
35
+ entries = readdirSync(appDir);
36
+ }
37
+ catch {
38
+ return null;
39
+ }
40
+ const builds = entries.filter(entry => entry !== 'node_modules').map(entry => join(appDir, entry))
41
+ .filter(dir => isDirectory(dir) && holdsBuild(dir))
42
+ .sort((a, b) => statSync(join(b, 'BUILD_ID')).mtimeMs - statSync(join(a, 'BUILD_ID')).mtimeMs);
43
+ return builds[0] ?? null;
44
+ }
45
+ /** Next's normalizeAppPath: route groups and @slots add no segment, and the trailing /page or /route goes. */
46
+ export function normalizeAppPath(key) {
47
+ const segments = key.split('/');
48
+ const kept = segments.filter((segment, index) => segment !== '' && !(segment.startsWith('(') && segment.endsWith(')'))
49
+ && !segment.startsWith('@') && !((segment === 'page' || segment === 'route') && index === segments.length - 1));
50
+ return `/${kept.join('/')}`;
51
+ }
52
+ function pageRewrites(rewrites, basePath) {
53
+ const list = Array.isArray(rewrites) ? rewrites
54
+ : rewrites && typeof rewrites === 'object'
55
+ ? ['beforeFiles', 'afterFiles', 'fallback'].flatMap(phase => rewrites[phase] ?? []) : [];
56
+ return list.filter(rewrite => {
57
+ if (typeof rewrite.destination !== 'string' || !rewrite.destination.startsWith('/'))
58
+ return false;
59
+ // Destinations carry the basePath; API routes, Next's own files and capture's own files (the origin-root routing a
60
+ // basePath build asks for) are not pages.
61
+ const path = basePath !== '' && rewrite.destination.startsWith(`${basePath}/`) ? rewrite.destination.slice(basePath.length) : rewrite.destination;
62
+ if (path === '/api' || ['/api/', '/_next/', '/_haystack/', '/.well-known/haystack-capture/'].some(prefix => path.startsWith(prefix)))
63
+ return false;
64
+ // Intercepting routes' own rewrites (matched on the Next-Url header) keep the URL's route.
65
+ const has = Array.isArray(rewrite.has) ? rewrite.has : [];
66
+ return !has.some(condition => condition.type === 'header' && typeof condition.key === 'string' && condition.key.toLowerCase() === 'next-url');
67
+ }).map(rewrite => ({ source: String(rewrite.source), destination: String(rewrite.destination) }));
68
+ }
69
+ /** The app's production build as Next wrote it, or why there is none to read. */
70
+ export function readNextBuild(appDir) {
71
+ const dir = buildDir(appDir);
72
+ if (dir === null)
73
+ return { manual: 'there is no Next.js build output (.next/routes-manifest.json) to read the routes from: run it after `next build`' };
74
+ const routes = json(join(dir, 'routes-manifest.json'));
75
+ if (routes === null)
76
+ return { manual: `${dir} has no routes-manifest.json` };
77
+ const required = json(join(dir, 'required-server-files.json'));
78
+ // The export this build wrote (every build removes the file before it exports, and a finished export says success),
79
+ // wherever it went: out/, or a custom distDir that `output: 'export'` turns into the export directory.
80
+ const exported = json(join(dir, 'export-detail.json'));
81
+ const exportPath = exported?.success === true && typeof exported.outDirectory === 'string'
82
+ ? (isAbsolute(exported.outDirectory) ? exported.outDirectory : join(appDir, exported.outDirectory)) : null;
83
+ const config = required?.config ?? {};
84
+ const appPathRoutes = json(join(dir, 'app-path-routes-manifest.json'));
85
+ const serverAppPaths = json(join(dir, 'server', 'app-paths-manifest.json'));
86
+ const appPaths = appPathRoutes ?? (serverAppPaths
87
+ ? Object.fromEntries(Object.keys(serverAppPaths).map(key => [key, normalizeAppPath(key)])) : null);
88
+ const locales = Array.isArray(routes.i18n?.locales) ? routes.i18n.locales.filter((locale) => typeof locale === 'string') : [];
89
+ const basePath = typeof routes.basePath === 'string' ? routes.basePath : '';
90
+ return {
91
+ dir,
92
+ basePath,
93
+ i18n: locales.length > 0 && typeof routes.i18n?.defaultLocale === 'string' ? { locales, defaultLocale: routes.i18n.defaultLocale } : null,
94
+ pageExtensions: Array.isArray(config.pageExtensions) && config.pageExtensions.every(item => typeof item === 'string')
95
+ ? config.pageExtensions : DEFAULT_EXTENSIONS,
96
+ exportDir: exportPath !== null && isDirectory(exportPath) ? relative(appDir, exportPath).split('\\').join('/') : null,
97
+ assetPrefix: typeof config.assetPrefix === 'string' ? config.assetPrefix : '',
98
+ appPaths,
99
+ pages: json(join(dir, 'server', 'pages-manifest.json')),
100
+ pageRewrites: pageRewrites(routes.rewrites, basePath),
101
+ };
102
+ }
103
+ /** The URL path the build's static files are served under (assetPrefix may be another origin, whose path still counts). */
104
+ export function staticPathPrefix(assetPrefix) {
105
+ if (assetPrefix === '')
106
+ return '';
107
+ let path = assetPrefix;
108
+ try {
109
+ path = new URL(assetPrefix).pathname;
110
+ }
111
+ catch { /* a path, not a URL */ }
112
+ return path.replace(/\/+$/, '');
113
+ }
@@ -2,27 +2,31 @@
2
2
  * Next.js (CAPTURE-V1 rule 8b): app router 13–16, pages router 11–16.
3
3
  *
4
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) and in pages/_app (pages router; created when the app has none, as
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
6
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
7
- * layouts (route groups without app/layout) are a manual step. Static output: public/ (served by `next start`, copied
8
- * by standalone and Vercel builds after the build command, and by static export into out/).
8
+ * layouts (route groups, several top-level segments) are a manual step.
9
9
  *
10
- * Routes: app router page files ((group) and @slot folders add no segment, intercepting (.) routes and _private folders
11
- * are not routes of their own); pages router files (api/, _app, _document, _error, 404 and 500 are not pages). Chunks:
12
- * the JS under <distDir>/static, served at /_next/static/. basePath and i18n put routes under prefixes the manifest
13
- * cannot publish at or express, so either is a manual step.
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).
14
18
  */
15
- import { readFileSync } from 'node:fs';
19
+ import { readdirSync, readFileSync } from 'node:fs';
16
20
  import { join } from 'node:path';
17
- import { defaultImportNames, importInsertion, indentUnit, lineIndent, parseFile, pathOf, startsLine, unwrapTs, walk } from '../js-ast.js';
21
+ import { configObject, defaultImportNames, importInsertion, indentUnit, lineIndent, parseFile, pathOf, readConfig, startsLine, unwrapTs, walk } from '../js-ast.js';
18
22
  import { repoPath, resolveVersion, appFile } from '../project.js';
19
23
  import { paramName, ruleFor } from '../route-pattern.js';
20
24
  import { nextScriptTags } from '../tag.js';
25
+ import { middlewareRewrites, parseUrlRewrites, URL_REWRITES_QUESTION } from '../url-rewrites.js';
21
26
  import { checkedResult, jsxElementsNamed, ourJsxElements, placeTags, reindented } from './jsx-edit.js';
22
- import { chunkIds, configFile, configValue, filesUnder, isDirectory, isFile } from './files.js';
23
- const CONFIG_NAMES = ['next.config.js', 'next.config.mjs', 'next.config.ts', 'next.config.cjs', 'next.config.mts'];
24
- const DEFAULT_EXTENSIONS = ['tsx', 'ts', 'jsx', 'js'];
25
- const PAGES_SPECIAL = new Set(['_app', '_document', '_error', '_middleware', '404', '500']);
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']);
26
30
  /** A Next.js folder or file name as a pattern segment. */
27
31
  export function nextSegment(name) {
28
32
  const optionalCatchAll = /^\[\[\.\.\.(.+)\]\]$/.exec(name);
@@ -43,51 +47,62 @@ export function nextSegment(name) {
43
47
  catch { /* a name that is not percent-encoding stays as it is */ }
44
48
  return { kind: 'literal', value: literal };
45
49
  }
46
- /** The base name of a page-like file (`page`, `layout`, `_app`) for these page extensions, or null. */
47
- function stemFor(name, extensions) {
48
- for (const extension of [...extensions].sort((a, b) => b.length - a.length)) {
49
- if (name.endsWith(`.${extension}`))
50
- return name.slice(0, -extension.length - 1);
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` };
51
64
  }
52
- return null;
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` };
53
72
  }
54
- export function appRouterRoutes(ctx, appRouter, extensions) {
55
- const routes = [];
56
- for (const file of filesUnder(appRouter)) {
57
- const parts = file.split('/');
58
- const name = parts.pop();
59
- if (stemFor(name, extensions) !== 'page')
60
- continue;
61
- const segments = [];
62
- let routable = true;
63
- for (const folder of parts) {
64
- if (folder.startsWith('_') || folder.startsWith('@') || /^\((\.{1,3}|\.\.\)\(\.\.)\)/.test(folder)) {
65
- routable = false;
66
- break;
67
- }
68
- if (/^\(.+\)$/.test(folder))
69
- continue;
70
- segments.push(nextSegment(folder));
71
- }
72
- if (routable)
73
- routes.push({ ...ruleFor(segments), source: repoPath(ctx, join(appRouter, file)) });
73
+ /** The files in `dir` named `<base>.<anything>`. */
74
+ function namedLike(dir, base) {
75
+ let names;
76
+ try {
77
+ names = readdirSync(dir);
74
78
  }
75
- return routes;
79
+ catch {
80
+ return [];
81
+ }
82
+ return names.filter(name => name.startsWith(`${base}.`) && isFile(join(dir, name)));
76
83
  }
77
- export function pagesRouterRoutes(ctx, pagesRouter, extensions) {
78
- const routes = [];
79
- for (const file of filesUnder(pagesRouter)) {
80
- const stem = stemFor(file, extensions);
81
- if (stem === null)
82
- continue;
83
- const parts = stem.split('/');
84
- if (parts[0] === 'api' || (parts.length === 1 && PAGES_SPECIAL.has(parts[0])))
85
- continue;
86
- if (parts[parts.length - 1] === 'index')
87
- parts.pop();
88
- routes.push({ ...ruleFor(parts.map(nextSegment)), source: repoPath(ctx, join(pagesRouter, file)) });
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})` };
89
95
  }
90
- return routes;
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;
91
106
  }
92
107
  /** The import style of the file: its first import's quote and whether statements end with semicolons. */
93
108
  function importStyle(ast, code) {
@@ -120,27 +135,67 @@ function scriptImport(ast, code, site) {
120
135
  return { name, components: [...components, name],
121
136
  edit: { start: at.at, end: at.at, text: `${at.before}import ${name} from ${quote}next/script${quote}${semicolon}${at.after}` } };
122
137
  }
123
- async function rootLayoutEdit(appRouter, extensions, tags) {
124
- const layout = extensions.map(extension => join(appRouter, `layout.${extension}`)).find(isFile);
125
- if (!layout) {
126
- const groupLayouts = filesUnder(appRouter).filter(file => /^\([^/]+\)\/layout\.[a-z.]+$/.test(file));
127
- return { manual: groupLayouts.length > 1
128
- ? `the app router has several root layouts (${groupLayouts.map(file => `app/${file}`).join(', ')}), each needing the tag`
129
- : 'the app router has no root layout (app/layout)' };
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);
130
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;
131
177
  const code = readFileSync(layout, 'utf8');
132
178
  const ast = parseFile(code, layout);
133
179
  if (!ast)
134
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).
135
183
  const bodies = jsxElementsNamed(ast, 'body');
136
- if (bodies.length !== 1 || !bodies[0].closingElement) {
137
- return { manual: `the root layout has ${bodies.length} <body> elements, not one with children` };
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` };
138
193
  }
139
- const script = scriptImport(ast, code, bodies[0]);
194
+ const script = scriptImport(ast, code, body);
140
195
  if ('manual' in script)
141
196
  return script;
142
197
  const rendered = nextScriptTags(tags, script.name);
143
- const edits = placeTags(code, ourJsxElements(ast, script.components), bodies[0].closingElement.start, rendered, indentUnit(code));
198
+ const edits = placeTags(code, ourJsxElements(ast, script.components), body.closingElement.start, rendered, indentUnit(code));
144
199
  if ('manual' in edits)
145
200
  return edits;
146
201
  if (edits.length === 0)
@@ -204,11 +259,27 @@ ${rendered} </>
204
259
  }
205
260
  `;
206
261
  }
207
- async function pagesAppEdit(ctx, pagesRouter, extensions, tags) {
208
- const app = extensions.map(extension => join(pagesRouter, `_app.${extension}`)).find(isFile);
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;
209
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).
210
277
  const typescript = isFile(join(ctx.appDir, 'tsconfig.json'));
211
- return { target: join(pagesRouter, typescript ? '_app.tsx' : '_app.jsx'), content: newApp('Script', tags, typescript) };
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) };
212
283
  }
213
284
  const code = readFileSync(app, 'utf8');
214
285
  const ast = parseFile(code, app);
@@ -250,6 +321,90 @@ async function pagesAppEdit(ctx, pagesRouter, extensions, tags) {
250
321
  const result = checkedResult(code, app, script.edit ? [...edits, script.edit] : edits, script.components, rendered);
251
322
  return typeof result === 'string' ? { target: app, content: result } : result;
252
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
+ }
253
408
  export async function detectNext(ctx) {
254
409
  const version = resolveVersion(ctx, 'next');
255
410
  if (!version)
@@ -260,50 +415,39 @@ export async function detectNext(ctx) {
260
415
  const pagesRouter = isDirectory(join(base, 'pages')) ? join(base, 'pages') : null;
261
416
  if (!appRouter && !pagesRouter)
262
417
  return null;
263
- const config = configFile(ctx.appDir, CONFIG_NAMES);
264
- const configName = config.file ? repoPath(ctx, config.file) : appFile(ctx, 'next.config.*');
265
418
  const routers = [appRouter && 'app router', pagesRouter && 'pages router'].filter(Boolean).join(' and ');
266
- const looked = [appRouter && repoPath(ctx, join(appRouter, 'layout.*')), pagesRouter && repoPath(ctx, join(pagesRouter, '_app.*')),
267
- configName].filter((item) => Boolean(item));
268
- const blocked = [];
419
+ const looked = [appRouter && repoPath(ctx, join(appRouter, 'layout.*')), pagesRouter && repoPath(ctx, join(pagesRouter, '_app.*'))]
420
+ .filter((item) => Boolean(item));
269
421
  const supported = appRouter ? [13, 14, 15, 16] : [11, 12, 13, 14, 15, 16];
270
- if (version.major === null || !supported.includes(version.major)) {
271
- blocked.push(`init knows Next.js ${supported[0]}–${supported[supported.length - 1]} for the ${routers}, and this app's version is ${version.version} (${version.from})`);
272
- }
273
- const basePath = configValue(config, ['basePath']);
274
- if (basePath.state === 'unreadable') {
275
- blocked.push(`${configName} is not a config init can read (it may set basePath, which would put the app under a prefix)`);
276
- }
277
- else if (basePath.state === 'value' && basePath.value !== '') {
278
- blocked.push(`${configName} sets basePath ${String(basePath.value)}: the route manifest must be published at the origin root (/.well-known/haystack-capture/), outside the base path`);
279
- }
280
- if (pagesRouter && configValue(config, ['i18n']).state !== 'absent') {
281
- blocked.push(`${configName} sets i18n: pages are served under locale prefixes the route manifest cannot express`);
282
- }
283
- const extensionsRead = configValue(config, ['pageExtensions']);
284
- const extensions = extensionsRead.state === 'value' && Array.isArray(extensionsRead.value) && extensionsRead.value.every(item => typeof item === 'string')
285
- ? extensionsRead.value : DEFAULT_EXTENSIONS;
286
- if (extensionsRead.state === 'unreadable')
287
- blocked.push(`${configName} sets pageExtensions in a way init cannot read`);
288
- const output = configValue(config, ['output']);
289
- const distDirRead = configValue(config, ['distDir']);
290
- if (output.state === 'unreadable')
291
- blocked.push(`${configName} sets output in a way init cannot read`);
292
- const exported = output.state === 'value' && output.value === 'export';
293
- if (exported && distDirRead.state !== 'absent')
294
- blocked.push(`${configName} combines output: 'export' with distDir, so where the export lands is not certain`);
295
- const distDir = distDirRead.state === 'value' && typeof distDirRead.value === 'string' ? distDirRead.value : '.next';
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);
296
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` : ''}).` : '';
297
431
  return {
298
432
  id: 'next',
299
433
  label: `Next.js ${version.version} (${routers})`,
300
434
  manifestAdapter: `next@${version.version}`,
301
- blocked: blocked.length > 0 ? blocked.join('; ') : null,
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}`,
302
442
  looked,
303
443
  staticDir: 'public',
304
444
  async integrate(tags) {
445
+ const extensions = pageExtensions(ctx, readBuild());
446
+ if ('manual' in extensions)
447
+ return extensions;
305
448
  const edits = [];
306
- for (const edit of [appRouter && await rootLayoutEdit(appRouter, extensions, tags), pagesRouter && await pagesAppEdit(ctx, pagesRouter, extensions, tags)]) {
449
+ for (const edit of [appRouter && await rootLayoutEdit(ctx, appRouter, tags, extensions),
450
+ pagesRouter && await pagesAppEdit(ctx, pagesRouter, tags, extensions)]) {
307
451
  if (!edit)
308
452
  continue;
309
453
  if ('manual' in edit)
@@ -313,15 +457,38 @@ export async function detectNext(ctx) {
313
457
  return edits;
314
458
  },
315
459
  manualTag: tags => [
316
- `In the root layout (app/layout, inside <body>) and/or pages/_app, with \`import Script from 'next/script';\`:`,
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\';`:',
317
462
  ...nextScriptTags(tags, 'Script'),
318
463
  ],
319
464
  build: typeof scripts.build === 'string' ? { kind: 'package-script', script: 'build', phase: 'after' }
320
465
  : { manual: `${appFile(ctx, 'package.json')} has no build script to publish the route manifest after` },
321
- async routes() {
322
- return [...(appRouter ? appRouterRoutes(ctx, appRouter, extensions) : []), ...(pagesRouter ? pagesRouterRoutes(ctx, pagesRouter, extensions) : [])];
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'];
323
492
  },
324
- chunks: () => (distDirRead.state === 'unreadable' ? [] : chunkIds(join(ctx.appDir, distDir, 'static'), '/_next/static/')),
325
- publishDirs: exported ? ['out'] : ['public'],
326
493
  };
327
494
  }
@@ -161,6 +161,13 @@ export async function detectNuxt(ctx) {
161
161
  label: `Nuxt ${version.version}`,
162
162
  manifestAdapter: `nuxt@${version.version}`,
163
163
  blocked: blocked.length > 0 ? blocked.join('; ') : null,
164
+ // The routes come from the source, so whatever stops init placing the tag stops the manifest too.
165
+ manifestBlocked: blocked.length > 0 ? blocked.join('; ') : null,
166
+ fromBuild: false,
167
+ rewriting: null,
168
+ publishNote: null,
169
+ buildPublishDirs: () => [],
170
+ buildNotes: () => [],
164
171
  looked: [where, repoPath(ctx, pages)],
165
172
  staticDir: publicDir,
166
173
  integrate: async (tags) => (config.file ? configEdit(config.file, tags) : { manual: `${where} is missing` }),