@haystackeditor/cli 0.24.0 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/dist/assets/capture/capture.cb4204fcc997d8e8.js +2 -0
  2. package/dist/assets/capture/release.json +4 -0
  3. package/dist/assets/telemetry/runtime.cjs +829 -1254
  4. package/dist/capture/adapters/client-routes.js +383 -0
  5. package/dist/capture/adapters/django.js +127 -0
  6. package/dist/capture/adapters/files.js +77 -0
  7. package/dist/capture/adapters/index.js +64 -0
  8. package/dist/capture/adapters/jsx-edit.js +81 -0
  9. package/dist/capture/adapters/next.js +327 -0
  10. package/dist/capture/adapters/nuxt.js +192 -0
  11. package/dist/capture/adapters/rails.js +171 -0
  12. package/dist/capture/adapters/react-router.js +432 -0
  13. package/dist/capture/adapters/sveltekit.js +102 -0
  14. package/dist/capture/adapters/types.js +4 -0
  15. package/dist/capture/adapters/vite.js +121 -0
  16. package/dist/capture/app-config.js +106 -0
  17. package/dist/capture/consent.js +127 -0
  18. package/dist/capture/csp.js +331 -0
  19. package/dist/capture/html.js +74 -0
  20. package/dist/capture/js-ast.js +400 -0
  21. package/dist/capture/manifest.js +95 -0
  22. package/dist/capture/project.js +177 -0
  23. package/dist/capture/route-pattern.js +119 -0
  24. package/dist/capture/script-release.js +47 -0
  25. package/dist/capture/tag.js +74 -0
  26. package/dist/capture-step.js +53 -0
  27. package/dist/commands/capture-brief.js +92 -0
  28. package/dist/commands/capture-contract.js +46 -0
  29. package/dist/commands/capture-manifest.js +78 -0
  30. package/dist/commands/init-capture.js +409 -0
  31. package/dist/commands/init-telemetry.js +1011 -0
  32. package/dist/commands/init.js +75 -9
  33. package/dist/commands/server-telemetry-contract.d.ts +66 -0
  34. package/dist/commands/server-telemetry-contract.js +127 -0
  35. package/dist/commands/telemetry-token.js +238 -0
  36. package/dist/commands/telemetry.d.ts +161 -8
  37. package/dist/commands/telemetry.js +940 -158
  38. package/dist/commands/verify-onboarding.js +5 -1
  39. package/dist/commands/verify.js +54 -7
  40. package/dist/index.js +83 -6
  41. package/dist/schema.js +2 -2
  42. package/dist/telemetry/next-loader.cjs +66 -9
  43. package/dist/telemetry/next.d.ts +11 -3
  44. package/dist/telemetry/next.js +95 -15
  45. package/dist/telemetry/typed-source.d.ts +47 -0
  46. package/dist/telemetry/typed-source.js +379 -0
  47. package/package.json +4 -2
  48. package/schemas/init.v1.json +63 -4
  49. package/schemas/pre-verify.v1.json +60 -3
@@ -0,0 +1,81 @@
1
+ /** Placing init's tags in a JSX document (Next.js layouts and _app, React Router / Remix root): exactly once, replacing
2
+ * an earlier version in place, and checked by parsing the result. */
3
+ import { applyEdits, jsxName, jsxStringAttribute, lineIndent, parseFile, startsLine, walk } from '../js-ast.js';
4
+ import { ourScriptKind } from '../tag.js';
5
+ export function jsxElementsNamed(ast, name) {
6
+ const found = [];
7
+ walk(ast.program, node => { if (node.type === 'JSXElement' && jsxName(node) === name)
8
+ found.push(node); });
9
+ return found;
10
+ }
11
+ /** init's own script elements: one of `components` (Script, script) whose src is one of init's files. */
12
+ export function ourJsxElements(ast, components) {
13
+ const found = [];
14
+ walk(ast.program, node => {
15
+ if (node.type !== 'JSXElement')
16
+ return;
17
+ const name = jsxName(node);
18
+ const src = name !== null && components.includes(name) ? jsxStringAttribute(node, 'src') : null;
19
+ if (src !== null && ourScriptKind(src) !== null)
20
+ found.push(node);
21
+ });
22
+ return found;
23
+ }
24
+ function lineRange(code, node) {
25
+ const lineStart = code.lastIndexOf('\n', node.start - 1) + 1;
26
+ const lineEnd = code.indexOf('\n', node.end);
27
+ const end = lineEnd === -1 ? code.length : lineEnd + 1;
28
+ return code.slice(lineStart, node.start).trim() === '' && code.slice(node.end, end).trim() === ''
29
+ ? { start: lineStart, end } : { start: node.start, end: node.end };
30
+ }
31
+ /**
32
+ * Edits that leave exactly `tags` as init's elements: none when they are already there; the first earlier element
33
+ * replaced in place and the others removed when they differ; else inserted before `closingStart` (a `</body>`, `</head>`
34
+ * or `</>`), one per line at the children's indentation when the closing tag starts its own line.
35
+ */
36
+ export function placeTags(code, ours, closingStart, tags, unit) {
37
+ if (ours.length > 0) {
38
+ const current = ours.map(node => code.slice(node.start, node.end));
39
+ if (current.length === tags.length && current.every((text, index) => text === tags[index]))
40
+ return [];
41
+ const indent = lineIndent(code, ours[0].start);
42
+ return [
43
+ { start: ours[0].start, end: ours[0].end, text: tags.join(`\n${indent}`) },
44
+ ...ours.slice(1).map(node => ({ ...lineRange(code, node), text: '' })),
45
+ ];
46
+ }
47
+ if (closingStart === null)
48
+ return { manual: 'there is no place for the tag' };
49
+ if (startsLine(code, closingStart)) {
50
+ const indent = lineIndent(code, closingStart) + unit;
51
+ const lineStart = code.lastIndexOf('\n', closingStart - 1) + 1;
52
+ return [{ start: lineStart, end: lineStart, text: tags.map(tag => `${indent}${tag}\n`).join('') }];
53
+ }
54
+ return [{ start: closingStart, end: closingStart, text: tags.join('') }];
55
+ }
56
+ /** Applies the edits and proves the result: it parses, and init's elements in it are exactly `tags`. */
57
+ export function checkedResult(code, file, edits, components, tags) {
58
+ const next = applyEdits(code, edits);
59
+ const ast = parseFile(next, file);
60
+ if (!ast)
61
+ return { manual: 'the edited file would not parse' };
62
+ const placed = ourJsxElements(ast, components).map(node => next.slice(node.start, node.end));
63
+ if (placed.length !== tags.length || placed.some((text, index) => text !== tags[index])) {
64
+ return { manual: 'the edit did not leave exactly one tag' };
65
+ }
66
+ return next;
67
+ }
68
+ /** `text` with every line after the first indented by one more `unit`, or null when the text holds a multi-line template
69
+ * literal or string (a JSX attribute string may span lines), whose content would change. JSX text is insensitive to the
70
+ * indentation of its lines. */
71
+ export function reindented(node, code, unit) {
72
+ let multilineTemplate = false;
73
+ walk(node, inner => {
74
+ if ((inner.type === 'TemplateLiteral' || inner.type === 'StringLiteral') && code.slice(inner.start, inner.end).includes('\n')) {
75
+ multilineTemplate = true;
76
+ }
77
+ });
78
+ if (multilineTemplate)
79
+ return null;
80
+ return code.slice(node.start, node.end).split('\n').map((line, index) => index === 0 || line === '' ? line : `${unit}${line}`).join('\n');
81
+ }
@@ -0,0 +1,327 @@
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) and in pages/_app (pages router; created when the app has none, as
6
+ * 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/).
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.
14
+ */
15
+ import { readFileSync } from 'node:fs';
16
+ import { join } from 'node:path';
17
+ import { defaultImportNames, importInsertion, indentUnit, lineIndent, parseFile, pathOf, startsLine, unwrapTs, walk } from '../js-ast.js';
18
+ import { repoPath, resolveVersion, appFile } from '../project.js';
19
+ import { paramName, ruleFor } from '../route-pattern.js';
20
+ import { nextScriptTags } from '../tag.js';
21
+ 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']);
26
+ /** A Next.js folder or file name as a pattern segment. */
27
+ export function nextSegment(name) {
28
+ const optionalCatchAll = /^\[\[\.\.\.(.+)\]\]$/.exec(name);
29
+ if (optionalCatchAll)
30
+ return { kind: 'splat', name: paramName(optionalCatchAll[1]), optional: true };
31
+ const catchAll = /^\[\.\.\.(.+)\]$/.exec(name);
32
+ if (catchAll)
33
+ return { kind: 'splat', name: paramName(catchAll[1]), optional: false };
34
+ const dynamic = /^\[(.+)\]$/.exec(name);
35
+ if (dynamic)
36
+ return { kind: 'param', name: paramName(dynamic[1]), optional: false };
37
+ if (name.includes('['))
38
+ return { kind: 'param', name: paramName(name), optional: false, inexact: `a segment mixing text and a parameter (${name})` };
39
+ let literal = name;
40
+ try {
41
+ literal = decodeURIComponent(name);
42
+ }
43
+ catch { /* a name that is not percent-encoding stays as it is */ }
44
+ return { kind: 'literal', value: literal };
45
+ }
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);
51
+ }
52
+ return null;
53
+ }
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)) });
74
+ }
75
+ return routes;
76
+ }
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)) });
89
+ }
90
+ return routes;
91
+ }
92
+ /** The import style of the file: its first import's quote and whether statements end with semicolons. */
93
+ function importStyle(ast, code) {
94
+ const first = ast.program.body.find((statement) => statement.type === 'ImportDeclaration');
95
+ if (!first)
96
+ return { quote: '\'', semicolon: ';' };
97
+ return { quote: code[first.source.start], semicolon: code.slice(first.start, first.end).trimEnd().endsWith(';') ? ';' : '' };
98
+ }
99
+ const ALIASES = ['Script', 'HaystackScript', 'HaystackCaptureScript', 'HaystackNextScript'];
100
+ /** The name for next/script's Script at `site` (the element the tag goes into): an import whose binding the site actually
101
+ * sees, else a new import under an alias nothing along the site's scope chain binds and the module does not use as a
102
+ * global; neither is a manual step (a local `Script` would shadow the import there). */
103
+ function scriptImport(ast, code, site) {
104
+ const sitePath = pathOf(ast, site);
105
+ if (!sitePath)
106
+ return { manual: 'init could not resolve the names in scope where the tag goes' };
107
+ const components = defaultImportNames(ast, 'next/script');
108
+ for (const local of components) {
109
+ const binding = sitePath.scope.getBinding(local);
110
+ if (binding?.kind === 'module' && binding.path.parent?.source?.value === 'next/script')
111
+ return { name: local, components, edit: null };
112
+ }
113
+ const program = sitePath.scope.getProgramParent();
114
+ const name = ALIASES.find(candidate => !sitePath.scope.hasBinding(candidate, { noGlobals: true }) && !program.hasBinding(candidate, { noGlobals: true })
115
+ && !program.hasGlobal(candidate));
116
+ if (!name)
117
+ return { manual: `every name init would import next/script's Script as (${ALIASES.join(', ')}) is taken where the tag goes` };
118
+ const { quote, semicolon } = importStyle(ast, code);
119
+ const at = importInsertion(ast, code);
120
+ return { name, components: [...components, name],
121
+ edit: { start: at.at, end: at.at, text: `${at.before}import ${name} from ${quote}next/script${quote}${semicolon}${at.after}` } };
122
+ }
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)' };
130
+ }
131
+ const code = readFileSync(layout, 'utf8');
132
+ const ast = parseFile(code, layout);
133
+ if (!ast)
134
+ return { manual: `${layout} does not parse` };
135
+ 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` };
138
+ }
139
+ const script = scriptImport(ast, code, bodies[0]);
140
+ if ('manual' in script)
141
+ return script;
142
+ const rendered = nextScriptTags(tags, script.name);
143
+ const edits = placeTags(code, ourJsxElements(ast, script.components), bodies[0].closingElement.start, rendered, indentUnit(code));
144
+ if ('manual' in edits)
145
+ return edits;
146
+ if (edits.length === 0)
147
+ return { target: layout, content: code };
148
+ const result = checkedResult(code, layout, script.edit ? [...edits, script.edit] : edits, script.components, rendered);
149
+ return typeof result === 'string' ? { target: layout, content: result } : result;
150
+ }
151
+ /** The JSX the default-exported App component returns (through HOC wrappers and class render methods). */
152
+ function appReturn(ast) {
153
+ const exported = ast.program.body.find((statement) => statement.type === 'ExportDefaultDeclaration')?.declaration;
154
+ if (!exported)
155
+ return { manual: 'pages/_app has no default export' };
156
+ const resolveComponent = (node, depth) => {
157
+ const current = unwrapTs(node);
158
+ if (!current || depth > 5)
159
+ return null;
160
+ if (current.type === 'CallExpression' && current.arguments.length > 0)
161
+ return resolveComponent(current.arguments[0], depth + 1);
162
+ if (current.type !== 'Identifier')
163
+ return current;
164
+ for (const statement of ast.program.body) {
165
+ const declaration = statement.type === 'ExportNamedDeclaration' ? statement.declaration : statement;
166
+ if ((declaration?.type === 'FunctionDeclaration' || declaration?.type === 'ClassDeclaration') && declaration.id?.name === current.name)
167
+ return declaration;
168
+ if (declaration?.type === 'VariableDeclaration') {
169
+ const found = declaration.declarations.find((item) => item.id.type === 'Identifier' && item.id.name === current.name);
170
+ if (found?.init)
171
+ return resolveComponent(found.init, depth + 1);
172
+ }
173
+ }
174
+ return null;
175
+ };
176
+ let component = resolveComponent(exported, 0);
177
+ if (component?.type === 'ClassDeclaration' || component?.type === 'ClassExpression') {
178
+ component = component.body.body.find((member) => member.type === 'ClassMethod' && member.key.name === 'render') ?? null;
179
+ }
180
+ if (!component || !['FunctionDeclaration', 'FunctionExpression', 'ArrowFunctionExpression', 'ClassMethod'].includes(component.type)) {
181
+ return { manual: 'pages/_app\'s default export is not a component init can read' };
182
+ }
183
+ if (component.body.type !== 'BlockStatement')
184
+ return unwrapTs(component.body);
185
+ const returns = [];
186
+ walk(component.body, node => {
187
+ if (node !== component.body && /Function|ClassMethod|ObjectMethod/.test(node.type))
188
+ return false;
189
+ if (node.type === 'ReturnStatement' && node.argument)
190
+ returns.push(unwrapTs(node.argument));
191
+ });
192
+ return returns.length === 1 ? returns[0] : { manual: `pages/_app's component has ${returns.length} return statements, not one` };
193
+ }
194
+ function newApp(name, tags, typescript) {
195
+ const rendered = nextScriptTags(tags, name).map(tag => ` ${tag}\n`).join('');
196
+ return `${typescript ? 'import type { AppProps } from \'next/app\';\n' : ''}import ${name} from 'next/script';
197
+
198
+ export default function App({ Component, pageProps }${typescript ? ': AppProps' : ''}) {
199
+ return (
200
+ <>
201
+ <Component {...pageProps} />
202
+ ${rendered} </>
203
+ );
204
+ }
205
+ `;
206
+ }
207
+ async function pagesAppEdit(ctx, pagesRouter, extensions, tags) {
208
+ const app = extensions.map(extension => join(pagesRouter, `_app.${extension}`)).find(isFile);
209
+ if (!app) {
210
+ const typescript = isFile(join(ctx.appDir, 'tsconfig.json'));
211
+ return { target: join(pagesRouter, typescript ? '_app.tsx' : '_app.jsx'), content: newApp('Script', tags, typescript) };
212
+ }
213
+ const code = readFileSync(app, 'utf8');
214
+ const ast = parseFile(code, app);
215
+ if (!ast)
216
+ return { manual: `${app} does not parse` };
217
+ const ours = ourJsxElements(ast, defaultImportNames(ast, 'next/script'));
218
+ const returned = ours.length > 0 ? ours[0] : appReturn(ast);
219
+ if ('manual' in returned)
220
+ return returned;
221
+ const script = scriptImport(ast, code, returned);
222
+ if ('manual' in script)
223
+ return script;
224
+ const rendered = nextScriptTags(tags, script.name);
225
+ const unit = indentUnit(code);
226
+ let edits;
227
+ if (ours.length > 0) {
228
+ edits = placeTags(code, ours, null, rendered, unit);
229
+ }
230
+ else {
231
+ if (returned.type === 'JSXFragment') {
232
+ edits = placeTags(code, [], returned.closingFragment.start, rendered, unit);
233
+ }
234
+ else if (returned.type === 'JSXElement') {
235
+ const inner = startsLine(code, returned.start) ? reindented(returned, code, unit) : null;
236
+ const indent = lineIndent(code, returned.start);
237
+ const text = inner === null
238
+ ? `<>${code.slice(returned.start, returned.end)}${rendered.join('')}</>`
239
+ : `<>\n${indent}${unit}${inner}\n${rendered.map(tag => `${indent}${unit}${tag}\n`).join('')}${indent}</>`;
240
+ edits = [{ start: returned.start, end: returned.end, text }];
241
+ }
242
+ else {
243
+ return { manual: 'pages/_app\'s component does not return JSX' };
244
+ }
245
+ }
246
+ if ('manual' in edits)
247
+ return edits;
248
+ if (edits.length === 0)
249
+ return { target: app, content: code };
250
+ const result = checkedResult(code, app, script.edit ? [...edits, script.edit] : edits, script.components, rendered);
251
+ return typeof result === 'string' ? { target: app, content: result } : result;
252
+ }
253
+ export async function detectNext(ctx) {
254
+ const version = resolveVersion(ctx, 'next');
255
+ if (!version)
256
+ return null;
257
+ const rootRouters = isDirectory(join(ctx.appDir, 'app')) || isDirectory(join(ctx.appDir, 'pages'));
258
+ const base = rootRouters ? ctx.appDir : join(ctx.appDir, 'src');
259
+ const appRouter = isDirectory(join(base, 'app')) ? join(base, 'app') : null;
260
+ const pagesRouter = isDirectory(join(base, 'pages')) ? join(base, 'pages') : null;
261
+ if (!appRouter && !pagesRouter)
262
+ return null;
263
+ const config = configFile(ctx.appDir, CONFIG_NAMES);
264
+ const configName = config.file ? repoPath(ctx, config.file) : appFile(ctx, 'next.config.*');
265
+ 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 = [];
269
+ 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';
296
+ const scripts = ctx.pkg?.scripts ?? {};
297
+ return {
298
+ id: 'next',
299
+ label: `Next.js ${version.version} (${routers})`,
300
+ manifestAdapter: `next@${version.version}`,
301
+ blocked: blocked.length > 0 ? blocked.join('; ') : null,
302
+ looked,
303
+ staticDir: 'public',
304
+ async integrate(tags) {
305
+ const edits = [];
306
+ for (const edit of [appRouter && await rootLayoutEdit(appRouter, extensions, tags), pagesRouter && await pagesAppEdit(ctx, pagesRouter, extensions, tags)]) {
307
+ if (!edit)
308
+ continue;
309
+ if ('manual' in edit)
310
+ return edit;
311
+ edits.push(edit);
312
+ }
313
+ return edits;
314
+ },
315
+ manualTag: tags => [
316
+ `In the root layout (app/layout, inside <body>) and/or pages/_app, with \`import Script from 'next/script';\`:`,
317
+ ...nextScriptTags(tags, 'Script'),
318
+ ],
319
+ build: typeof scripts.build === 'string' ? { kind: 'package-script', script: 'build', phase: 'after' }
320
+ : { 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) : [])];
323
+ },
324
+ chunks: () => (distDirRead.state === 'unreadable' ? [] : chunkIds(join(ctx.appDir, distDir, 'static'), '/_next/static/')),
325
+ publishDirs: exported ? ['out'] : ['public'],
326
+ };
327
+ }
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Nuxt 3–4 (CAPTURE-V1 rule 8b).
3
+ *
4
+ * Tag: an entry of nuxt.config's app.head.script ({ src, integrity, 'data-key', async: true }), which Nuxt renders into
5
+ * every page's <head>; init creates app, head or script where missing and leaves a config it cannot read to a manual
6
+ * step. Static output: public/ (Nuxt 4: at the root; Nuxt 3: under srcDir). Routes: the pages directory (index for the
7
+ * parent's path, [id], [[id]], [...slug], (group) directories); an app without pages/ is one page, `/`. Nitro presets
8
+ * reshape the output inside `nuxt build`, so the build step publishes the manifest into public/ BEFORE the build,
9
+ * without chunks. app.baseURL other than / is a manual step.
10
+ */
11
+ import { readFileSync } from 'node:fs';
12
+ import { join, posix } from 'node:path';
13
+ import { applyEdits, configObject, indentUnit, keyName, lineIndent, parseFile, startsLine, unwrapTs } from '../js-ast.js';
14
+ import { repoPath, resolveVersion, appFile } from '../project.js';
15
+ import { paramName, ruleFor } from '../route-pattern.js';
16
+ import { nuxtScriptEntries, ourScriptKind } from '../tag.js';
17
+ import { configFile, configValue, filesUnder, isDirectory, isFile } from './files.js';
18
+ const PAGE_EXTENSIONS = ['.vue', '.tsx', '.jsx', '.ts', '.js', '.mjs'];
19
+ export function nuxtSegment(name) {
20
+ if (/^\(.+\)$/.test(name))
21
+ return null;
22
+ const optional = /^\[\[([^\]]+)\]\]$/.exec(name);
23
+ if (optional)
24
+ return { kind: 'param', name: paramName(optional[1]), optional: true };
25
+ const rest = /^\[\.\.\.([^\]]+)\]$/.exec(name);
26
+ if (rest)
27
+ return { kind: 'splat', name: paramName(rest[1]), optional: true };
28
+ const param = /^\[([^\]]+)\]$/.exec(name);
29
+ if (param)
30
+ return { kind: 'param', name: paramName(param[1]), optional: false };
31
+ if (name.includes('['))
32
+ return { kind: 'param', name: paramName(name), optional: false, inexact: `a segment mixing text and a parameter (${name})` };
33
+ return { kind: 'literal', value: name };
34
+ }
35
+ function property(object, name) {
36
+ return object.properties.find((candidate) => candidate.type === 'ObjectProperty' && keyName(candidate) === name) ?? null;
37
+ }
38
+ /** Text that adds `text` as the first property of `object`. */
39
+ function firstProperty(code, object, text, unit) {
40
+ if (object.properties.length === 0) {
41
+ const indent = lineIndent(code, object.start);
42
+ return { start: object.start, end: object.end, text: `{\n${indent}${unit}${text},\n${indent}}` };
43
+ }
44
+ const first = object.properties[0];
45
+ if (startsLine(code, first.start)) {
46
+ const lineStart = code.lastIndexOf('\n', first.start - 1) + 1;
47
+ return { start: lineStart, end: lineStart, text: `${lineIndent(code, first.start)}${text},\n` };
48
+ }
49
+ return { start: first.start, end: first.start, text: `${text}, ` };
50
+ }
51
+ function ourEntries(array) {
52
+ return array.elements.filter((element) => {
53
+ if (element?.type !== 'ObjectExpression')
54
+ return false;
55
+ const src = property(element, 'src')?.value;
56
+ return src?.type === 'StringLiteral' && ourScriptKind(src.value) !== null;
57
+ });
58
+ }
59
+ function configEdit(file, tags) {
60
+ const code = readFileSync(file, 'utf8');
61
+ const ast = parseFile(code, file);
62
+ if (!ast)
63
+ return { manual: `${file} does not parse` };
64
+ const config = configObject(ast);
65
+ if (!config)
66
+ return { manual: 'nuxt.config does not export a config object init can read' };
67
+ const unit = indentUnit(code);
68
+ const entries = nuxtScriptEntries(tags);
69
+ const array = `[${entries.join(', ')}]`;
70
+ let edits;
71
+ const app = property(config, 'app');
72
+ const head = app && unwrapTs(app.value).type === 'ObjectExpression' ? property(unwrapTs(app.value), 'head') : null;
73
+ const script = head && unwrapTs(head.value).type === 'ObjectExpression' ? property(unwrapTs(head.value), 'script') : null;
74
+ if (!app) {
75
+ edits = [firstProperty(code, config, `app: { head: { script: ${array} } }`, unit)];
76
+ }
77
+ else if (unwrapTs(app.value).type !== 'ObjectExpression') {
78
+ return { manual: 'nuxt.config\'s app is not an object literal' };
79
+ }
80
+ else if (!head) {
81
+ edits = [firstProperty(code, unwrapTs(app.value), `head: { script: ${array} }`, unit)];
82
+ }
83
+ else if (unwrapTs(head.value).type !== 'ObjectExpression') {
84
+ return { manual: 'nuxt.config\'s app.head is not an object literal' };
85
+ }
86
+ else if (!script) {
87
+ edits = [firstProperty(code, unwrapTs(head.value), `script: ${array}`, unit)];
88
+ }
89
+ else {
90
+ const list = unwrapTs(script.value);
91
+ if (list.type !== 'ArrayExpression')
92
+ return { manual: 'nuxt.config\'s app.head.script is not an array literal' };
93
+ const ours = ourEntries(list);
94
+ const current = ours.map(node => code.slice(node.start, node.end));
95
+ if (current.length === entries.length && current.every((text, index) => text === entries[index]))
96
+ return [{ target: file, content: code }];
97
+ if (ours.length > 0) {
98
+ edits = [{ start: ours[0].start, end: ours[0].end, text: entries.join(', ') },
99
+ ...ours.slice(1).map(node => {
100
+ const after = code.slice(node.end).match(/^\s*,/);
101
+ return { start: node.start, end: node.end + (after ? after[0].length : 0), text: '' };
102
+ })];
103
+ }
104
+ else {
105
+ const last = list.elements[list.elements.length - 1];
106
+ if (!last) {
107
+ edits = [{ start: list.start, end: list.end, text: array }];
108
+ }
109
+ else {
110
+ const between = code.slice(last.end, list.end - 1);
111
+ const comma = between.indexOf(',');
112
+ const separator = startsLine(code, last.start) ? `\n${lineIndent(code, last.start)}` : ' ';
113
+ edits = comma === -1
114
+ ? [{ start: last.end, end: last.end, text: entries.map(entry => `,${separator}${entry}`).join('') }]
115
+ : [{ start: last.end + comma + 1, end: last.end + comma + 1, text: entries.map(entry => `${separator}${entry},`).join('') }];
116
+ }
117
+ }
118
+ }
119
+ const next = applyEdits(code, edits);
120
+ const check = parseFile(next, file);
121
+ const checkConfig = check ? configObject(check) : null;
122
+ const checkApp = checkConfig ? property(checkConfig, 'app') : null;
123
+ const checkHead = checkApp ? property(unwrapTs(checkApp.value), 'head') : null;
124
+ const checkScript = checkHead ? property(unwrapTs(checkHead.value), 'script') : null;
125
+ const placed = checkScript ? ourEntries(unwrapTs(checkScript.value)).map(node => next.slice(node.start, node.end)) : [];
126
+ if (placed.length !== entries.length || placed.some((text, index) => text !== entries[index])) {
127
+ return { manual: 'the edit to nuxt.config did not leave exactly one tag' };
128
+ }
129
+ return [{ target: file, content: next }];
130
+ }
131
+ export async function detectNuxt(ctx) {
132
+ const version = resolveVersion(ctx, 'nuxt');
133
+ if (!version)
134
+ return null;
135
+ const config = configFile(ctx.appDir, ['nuxt.config.ts', 'nuxt.config.js', 'nuxt.config.mjs', 'nuxt.config.mts']);
136
+ const where = config.file ? repoPath(ctx, config.file) : appFile(ctx, 'nuxt.config.ts');
137
+ const blocked = [];
138
+ const compatibility = configValue(config, ['future', 'compatibilityVersion']);
139
+ const four = version.major === 4 || (compatibility.state === 'value' && compatibility.value === 4);
140
+ if (version.major !== 3 && version.major !== 4)
141
+ blocked.push(`init knows Nuxt 3–4, and this app's version is ${version.version} (${version.from})`);
142
+ const read = (path, fallback) => {
143
+ const value = configValue(config, path);
144
+ if (value.state === 'unreadable' || (value.state === 'value' && typeof value.value !== 'string')) {
145
+ blocked.push(`${where} sets ${path.join('.')} in a way init cannot read`);
146
+ return fallback;
147
+ }
148
+ return value.state === 'value' ? value.value : fallback;
149
+ };
150
+ const srcDir = read(['srcDir'], four ? 'app' : '.');
151
+ const pagesDir = read(['dir', 'pages'], 'pages');
152
+ const publicDir = posix.join(four ? '.' : srcDir, read(['dir', 'public'], 'public'));
153
+ const baseURL = read(['app', 'baseURL'], '/');
154
+ if (baseURL !== '/')
155
+ blocked.push(`${where} sets app.baseURL ${baseURL}: the route manifest must be published at the origin root, outside it`);
156
+ const pages = join(ctx.appDir, srcDir, pagesDir);
157
+ const scripts = ctx.pkg?.scripts ?? {};
158
+ const buildScript = typeof scripts.build === 'string' ? 'build' : typeof scripts.generate === 'string' ? 'generate' : null;
159
+ return {
160
+ id: 'nuxt',
161
+ label: `Nuxt ${version.version}`,
162
+ manifestAdapter: `nuxt@${version.version}`,
163
+ blocked: blocked.length > 0 ? blocked.join('; ') : null,
164
+ looked: [where, repoPath(ctx, pages)],
165
+ staticDir: publicDir,
166
+ integrate: async (tags) => (config.file ? configEdit(config.file, tags) : { manual: `${where} is missing` }),
167
+ manualTag: tags => [`In ${where}, under app.head.script:`, ...nuxtScriptEntries(tags)],
168
+ build: buildScript ? { kind: 'package-script', script: buildScript, phase: 'before' }
169
+ : { manual: `${appFile(ctx, 'package.json')} has no build or generate script to publish the route manifest before` },
170
+ async routes() {
171
+ if (!isDirectory(pages)) {
172
+ const app = ['app.vue'].map(name => join(ctx.appDir, srcDir, name)).find(isFile);
173
+ return [{ match: '/', source: repoPath(ctx, app ?? config.file) }];
174
+ }
175
+ const routes = [];
176
+ for (const file of filesUnder(pages)) {
177
+ const extension = PAGE_EXTENSIONS.find(candidate => file.endsWith(candidate));
178
+ if (!extension)
179
+ continue;
180
+ const parts = file.slice(0, -extension.length).split('/');
181
+ const index = parts[parts.length - 1] === 'index';
182
+ if (index)
183
+ parts.pop();
184
+ const segments = parts.map(nuxtSegment).filter((segment) => segment !== null);
185
+ routes.push({ ...ruleFor(segments), source: repoPath(ctx, join(pages, file)), rank: index ? 0 : 1 });
186
+ }
187
+ return routes;
188
+ },
189
+ chunks: () => [],
190
+ publishDirs: [publicDir],
191
+ };
192
+ }