@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.
- package/dist/capture/adapters/django.js +7 -0
- package/dist/capture/adapters/index.js +10 -0
- package/dist/capture/adapters/next-build.js +113 -0
- package/dist/capture/adapters/next.js +272 -105
- package/dist/capture/adapters/nuxt.js +7 -0
- package/dist/capture/adapters/rails.js +7 -0
- package/dist/capture/adapters/react-router.js +7 -0
- package/dist/capture/adapters/sveltekit.js +7 -0
- package/dist/capture/adapters/vite.js +14 -0
- package/dist/capture/app-config.js +2 -1
- package/dist/capture/csp.js +7 -6
- package/dist/capture/url-rewrites.js +232 -0
- package/dist/capture-step.js +3 -0
- package/dist/commands/capture-manifest.js +25 -17
- package/dist/commands/init-capture.js +44 -27
- package/dist/commands/init-telemetry.js +24 -7
- package/dist/commands/init.js +12 -2
- package/dist/commands/verify-onboarding.js +16 -0
- package/dist/commands/verify.js +2 -2
- package/dist/index.js +2 -0
- package/package.json +1 -1
- package/schemas/init.v1.json +1 -1
|
@@ -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
|
|
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
|
|
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
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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,
|
|
23
|
-
|
|
24
|
-
const
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
79
|
+
catch {
|
|
80
|
+
return [];
|
|
81
|
+
}
|
|
82
|
+
return names.filter(name => name.startsWith(`${base}.`) && isFile(join(dir, name)));
|
|
76
83
|
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
137
|
-
|
|
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,
|
|
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),
|
|
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,
|
|
208
|
-
const app =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
const
|
|
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
|
|
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(
|
|
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 (
|
|
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
|
-
|
|
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` }),
|