@haystackeditor/cli 0.24.1 → 0.25.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/capture/capture.cb4204fcc997d8e8.js +2 -0
- package/dist/assets/capture/release.json +4 -0
- package/dist/assets/telemetry/runtime.cjs +829 -1254
- package/dist/capture/adapters/client-routes.js +383 -0
- package/dist/capture/adapters/django.js +134 -0
- package/dist/capture/adapters/files.js +77 -0
- package/dist/capture/adapters/index.js +74 -0
- package/dist/capture/adapters/jsx-edit.js +81 -0
- package/dist/capture/adapters/next-build.js +113 -0
- package/dist/capture/adapters/next.js +494 -0
- package/dist/capture/adapters/nuxt.js +199 -0
- package/dist/capture/adapters/rails.js +178 -0
- package/dist/capture/adapters/react-router.js +439 -0
- package/dist/capture/adapters/sveltekit.js +109 -0
- package/dist/capture/adapters/types.js +4 -0
- package/dist/capture/adapters/vite.js +135 -0
- package/dist/capture/app-config.js +107 -0
- package/dist/capture/consent.js +127 -0
- package/dist/capture/csp.js +332 -0
- package/dist/capture/html.js +74 -0
- package/dist/capture/js-ast.js +400 -0
- package/dist/capture/manifest.js +95 -0
- package/dist/capture/project.js +177 -0
- package/dist/capture/route-pattern.js +119 -0
- package/dist/capture/script-release.js +47 -0
- package/dist/capture/tag.js +74 -0
- package/dist/capture/url-rewrites.js +232 -0
- package/dist/capture-step.js +56 -0
- package/dist/commands/capture-brief.js +92 -0
- package/dist/commands/capture-contract.js +46 -0
- package/dist/commands/capture-manifest.js +86 -0
- package/dist/commands/init-capture.js +426 -0
- package/dist/commands/init-telemetry.js +1028 -0
- package/dist/commands/init.js +78 -5
- package/dist/commands/server-telemetry-contract.d.ts +66 -0
- package/dist/commands/server-telemetry-contract.js +127 -0
- package/dist/commands/telemetry-token.js +238 -0
- package/dist/commands/telemetry.d.ts +161 -8
- package/dist/commands/telemetry.js +940 -158
- package/dist/commands/verify-onboarding.js +21 -1
- package/dist/commands/verify.js +56 -9
- package/dist/index.js +85 -6
- package/dist/schema.js +2 -2
- package/dist/telemetry/next-loader.cjs +66 -9
- package/dist/telemetry/next.d.ts +11 -3
- package/dist/telemetry/next.js +95 -15
- package/dist/telemetry/typed-source.d.ts +47 -0
- package/dist/telemetry/typed-source.js +379 -0
- package/package.json +4 -2
- package/schemas/init.v1.json +63 -4
- package/schemas/pre-verify.v1.json +60 -3
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React Router 7 framework mode and Remix 2 (CAPTURE-V1 rule 8b).
|
|
3
|
+
*
|
|
4
|
+
* Tag: an async <script> as the last child of the root module's one <head> (app/root), which the server renders into
|
|
5
|
+
* every document. Static output: public/ (copied into <build>/client, or served as is by Remix's classic compiler).
|
|
6
|
+
*
|
|
7
|
+
* Routes: React Router's app/routes.ts read statically (route, index, layout and prefix from @react-router/dev/routes,
|
|
8
|
+
* and flatRoutes from @react-router/fs-routes); Remix 2's flat routes in app/routes/ (the same convention flatRoutes
|
|
9
|
+
* implements); a routes function or anything init cannot read without running the app is a manual step. Chunks: the JS
|
|
10
|
+
* under <build>/client (Remix classic: public/build). A preset (Vercel's and others) reshapes the build output inside
|
|
11
|
+
* the build, so with one the manifest is written into public/ before the build instead, without chunks.
|
|
12
|
+
*/
|
|
13
|
+
import fg from 'fast-glob';
|
|
14
|
+
import { readFileSync } from 'node:fs';
|
|
15
|
+
import { join, posix } from 'node:path';
|
|
16
|
+
import { indentUnit, keyName, literalValue, parseFile, UNREADABLE, unwrapTs, walk } from '../js-ast.js';
|
|
17
|
+
import { repoPath, resolveVersion, appFile } from '../project.js';
|
|
18
|
+
import { paramName, ruleFor } from '../route-pattern.js';
|
|
19
|
+
import { jsxScriptTags } from '../tag.js';
|
|
20
|
+
import { chunkIds, configFile, configValue, firstFile, isDirectory } from './files.js';
|
|
21
|
+
import { checkedResult, jsxElementsNamed, ourJsxElements, placeTags } from './jsx-edit.js';
|
|
22
|
+
const ROUTE_EXTENSIONS = ['js', 'jsx', 'ts', 'tsx', 'md', 'mdx'];
|
|
23
|
+
const ROOT_NAMES = ['root.tsx', 'root.jsx', 'root.ts', 'root.js'];
|
|
24
|
+
/** React Router path text (`about`, `:id`, `:lang?`, `*`, `docs/*`) as segments. */
|
|
25
|
+
export function reactRouterSegments(path) {
|
|
26
|
+
return path.split('/').filter(part => part !== '').map((part) => {
|
|
27
|
+
if (part === '*')
|
|
28
|
+
return { kind: 'splat', name: 'splat', optional: true };
|
|
29
|
+
const optional = part.endsWith('?');
|
|
30
|
+
const bare = optional ? part.slice(0, -1) : part;
|
|
31
|
+
if (bare.startsWith(':'))
|
|
32
|
+
return { kind: 'param', name: paramName(bare.slice(1)), optional };
|
|
33
|
+
// An optional literal (`en?`) has no exact form in the pattern syntax (an optional parameter would match a superset).
|
|
34
|
+
if (optional)
|
|
35
|
+
return { kind: 'param', name: paramName(bare), optional: true, inexact: `an optional literal segment (${part})` };
|
|
36
|
+
return { kind: 'literal', value: bare };
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
/* ------------------------------------------------------------ flat routes */
|
|
40
|
+
/** One flat-route id (`concerts.$city`, `_auth.login`, `($lang)._index`, `sitemap[.]xml`) as path segments, whether it
|
|
41
|
+
* is an index route, and whether its last segment is a pathless layout (which never matches a location on its own).
|
|
42
|
+
* Text inside [] is literal: `[.]` is a dot, `[_]x` does not make a pathless segment. */
|
|
43
|
+
export function flatRouteSegments(id) {
|
|
44
|
+
const raw = [];
|
|
45
|
+
let current = { text: '', escapedStart: false, escapedEnd: false };
|
|
46
|
+
let escaped = false;
|
|
47
|
+
for (const char of id) {
|
|
48
|
+
if (escaped) {
|
|
49
|
+
if (char === ']') {
|
|
50
|
+
escaped = false;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (current.text === '')
|
|
54
|
+
current.escapedStart = true;
|
|
55
|
+
current.text += char;
|
|
56
|
+
current.escapedEnd = true;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (char === '[') {
|
|
60
|
+
escaped = true;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (char === '.') {
|
|
64
|
+
raw.push(current);
|
|
65
|
+
current = { text: '', escapedStart: false, escapedEnd: false };
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
current.text += char;
|
|
69
|
+
current.escapedEnd = false;
|
|
70
|
+
}
|
|
71
|
+
if (escaped)
|
|
72
|
+
return null;
|
|
73
|
+
raw.push(current);
|
|
74
|
+
const segments = [];
|
|
75
|
+
let index = false;
|
|
76
|
+
let pathless = false;
|
|
77
|
+
raw.forEach((part, position) => {
|
|
78
|
+
pathless = false;
|
|
79
|
+
if (part.text === '_index' && !part.escapedStart && position === raw.length - 1) {
|
|
80
|
+
index = true;
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (part.text.startsWith('_') && !part.escapedStart) {
|
|
84
|
+
pathless = true;
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const name = part.text.endsWith('_') && !part.escapedEnd ? part.text.slice(0, -1) : part.text;
|
|
88
|
+
const optional = !part.escapedStart ? /^\((.*)\)$/.exec(name) : null;
|
|
89
|
+
const inner = optional ? optional[1] : name;
|
|
90
|
+
if (inner === '$' && !part.escapedStart)
|
|
91
|
+
segments.push({ kind: 'splat', name: 'splat', optional: false });
|
|
92
|
+
else if (inner.startsWith('$') && !part.escapedStart)
|
|
93
|
+
segments.push({ kind: 'param', name: paramName(inner.slice(1)), optional: Boolean(optional) });
|
|
94
|
+
else if (optional)
|
|
95
|
+
segments.push({ kind: 'param', name: paramName(inner), optional: true, inexact: `an optional literal segment (${part.text})` });
|
|
96
|
+
else if (inner !== '')
|
|
97
|
+
segments.push({ kind: 'literal', value: inner });
|
|
98
|
+
});
|
|
99
|
+
return { segments, index, pathless };
|
|
100
|
+
}
|
|
101
|
+
/** Flat routes under `routesDir` (Remix 2's convention, which @react-router/fs-routes' flatRoutes implements), under
|
|
102
|
+
* `prefix` when a route config nests them. */
|
|
103
|
+
function flatRoutes(ctx, routesDir, ignored, prefix = []) {
|
|
104
|
+
if (!isDirectory(routesDir))
|
|
105
|
+
return [];
|
|
106
|
+
const extensions = `{${ROUTE_EXTENSIONS.join(',')}}`;
|
|
107
|
+
const files = fg.sync([`*.${extensions}`, `*/route.${extensions}`], { cwd: routesDir, ignore: ignored, dot: true, onlyFiles: true });
|
|
108
|
+
const routes = [];
|
|
109
|
+
for (const file of files.sort()) {
|
|
110
|
+
const id = file.includes('/') ? file.split('/')[0] : file.replace(/\.[^.]+$/, '');
|
|
111
|
+
const parsed = flatRouteSegments(id);
|
|
112
|
+
if (!parsed) {
|
|
113
|
+
routes.push({ match: '/', source: repoPath(ctx, join(routesDir, file)), inexact: `an unclosed [ in its route file name (${id})` });
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (parsed.pathless)
|
|
117
|
+
continue;
|
|
118
|
+
// Remix's `$` splat matches the rest of the path, empty included.
|
|
119
|
+
const segments = [...prefix, ...parsed.segments].map((segment, position, all) => (segment.kind === 'splat' && position === all.length - 1
|
|
120
|
+
? { ...segment, optional: true } : segment));
|
|
121
|
+
routes.push({ ...ruleFor(segments), source: repoPath(ctx, join(routesDir, file)), rank: parsed.index ? 0 : 1 });
|
|
122
|
+
}
|
|
123
|
+
return routes;
|
|
124
|
+
}
|
|
125
|
+
function helpers(ast) {
|
|
126
|
+
const found = { route: null, index: null, layout: null, prefix: null, flatRoutes: null };
|
|
127
|
+
for (const statement of ast.program.body) {
|
|
128
|
+
if (statement.type !== 'ImportDeclaration')
|
|
129
|
+
continue;
|
|
130
|
+
const source = statement.source.value;
|
|
131
|
+
if (source === '@react-router/remix-routes-option-adapter')
|
|
132
|
+
return { manual: 'app/routes.ts adapts a Remix routes function, which init cannot read without running it' };
|
|
133
|
+
if (source !== '@react-router/dev/routes' && source !== '@remix-run/route-config' && source !== '@react-router/fs-routes' && source !== '@remix-run/fs-routes')
|
|
134
|
+
continue;
|
|
135
|
+
for (const specifier of statement.specifiers) {
|
|
136
|
+
if (specifier.type !== 'ImportSpecifier')
|
|
137
|
+
continue;
|
|
138
|
+
const imported = specifier.imported.name ?? specifier.imported.value;
|
|
139
|
+
if (imported in found)
|
|
140
|
+
found[imported] = specifier.local.name;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return found;
|
|
144
|
+
}
|
|
145
|
+
class Unreadable extends Error {
|
|
146
|
+
}
|
|
147
|
+
function stringArg(node, ast) {
|
|
148
|
+
const value = node ? literalValue(node, ast) : UNREADABLE;
|
|
149
|
+
if (typeof value !== 'string')
|
|
150
|
+
throw new Unreadable('a route path or file that is not a literal string');
|
|
151
|
+
return value;
|
|
152
|
+
}
|
|
153
|
+
function configRoutes(ctx, appDir, file) {
|
|
154
|
+
const code = readFileSync(file, 'utf8');
|
|
155
|
+
const ast = parseFile(code, file);
|
|
156
|
+
if (!ast)
|
|
157
|
+
return { manual: `${repoPath(ctx, file)} does not parse` };
|
|
158
|
+
const names = helpers(ast);
|
|
159
|
+
if ('manual' in names)
|
|
160
|
+
return names;
|
|
161
|
+
const exported = ast.program.body.find((statement) => statement.type === 'ExportDefaultDeclaration')?.declaration;
|
|
162
|
+
if (!exported)
|
|
163
|
+
return { manual: `${repoPath(ctx, file)} has no default export` };
|
|
164
|
+
const routes = [];
|
|
165
|
+
const add = (path, routeFile, index) => {
|
|
166
|
+
routes.push({ ...ruleFor(reactRouterSegments(path.join('/'))), source: repoPath(ctx, join(appDir, routeFile)), rank: index ? 0 : 1 });
|
|
167
|
+
};
|
|
168
|
+
const visitList = (node, path) => {
|
|
169
|
+
const list = unwrapTs(node);
|
|
170
|
+
if (list?.type === 'Identifier') {
|
|
171
|
+
const bound = ast.program.body.flatMap((statement) => (statement.type === 'VariableDeclaration' ? statement.declarations : []))
|
|
172
|
+
.find((declarator) => declarator.id.name === list.name)?.init;
|
|
173
|
+
if (bound) {
|
|
174
|
+
visitList(bound, path);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
if (list?.type !== 'ArrayExpression')
|
|
179
|
+
throw new Unreadable('a route list that is not an array literal');
|
|
180
|
+
for (const element of list.elements) {
|
|
181
|
+
if (!element)
|
|
182
|
+
continue;
|
|
183
|
+
if (element.type === 'SpreadElement')
|
|
184
|
+
visitSpread(element.argument, path);
|
|
185
|
+
else
|
|
186
|
+
visitEntry(element, path);
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
const visitSpread = (node, path) => {
|
|
190
|
+
const call = unwrapTs(node);
|
|
191
|
+
if (call?.type === 'CallExpression' && call.callee.type === 'Identifier' && call.callee.name === names.prefix) {
|
|
192
|
+
visitList(call.arguments[1], [...path, stringArg(call.arguments[0], ast)]);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
if (call?.type === 'CallExpression' && call.callee.type === 'Identifier' && call.callee.name === names.flatRoutes) {
|
|
196
|
+
const options = call.arguments[0] ? literalValue(call.arguments[0], ast) : {};
|
|
197
|
+
if (options === UNREADABLE || typeof options !== 'object' || options === null || Array.isArray(options))
|
|
198
|
+
throw new Unreadable('flatRoutes options that are not literal');
|
|
199
|
+
const rootDirectory = typeof options.rootDirectory === 'string' ? options.rootDirectory : 'routes';
|
|
200
|
+
const ignored = Array.isArray(options.ignoredRouteFiles) ? options.ignoredRouteFiles.filter((item) => typeof item === 'string') : [];
|
|
201
|
+
routes.push(...flatRoutes(ctx, join(appDir, rootDirectory), ignored, reactRouterSegments(path.join('/'))));
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
visitList(node, path);
|
|
205
|
+
};
|
|
206
|
+
const visitEntry = (node, path) => {
|
|
207
|
+
const entry = unwrapTs(node);
|
|
208
|
+
if (entry?.type === 'CallExpression' && entry.callee.type === 'Identifier') {
|
|
209
|
+
const callee = entry.callee.name;
|
|
210
|
+
const args = entry.arguments;
|
|
211
|
+
if (callee === names.index) {
|
|
212
|
+
add(path, stringArg(args[0], ast), true);
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
if (callee === names.route) {
|
|
216
|
+
const own = [...path, stringArg(args[0], ast)];
|
|
217
|
+
add(own, stringArg(args[1], ast), false);
|
|
218
|
+
const children = args.slice(2).find((arg) => unwrapTs(arg).type === 'ArrayExpression' || unwrapTs(arg).type === 'Identifier');
|
|
219
|
+
if (children)
|
|
220
|
+
visitList(children, own);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
if (callee === names.layout) {
|
|
224
|
+
const children = args.slice(1).find((arg) => unwrapTs(arg).type === 'ArrayExpression' || unwrapTs(arg).type === 'Identifier');
|
|
225
|
+
if (children)
|
|
226
|
+
visitList(children, path);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
if (entry?.type === 'ObjectExpression') {
|
|
231
|
+
const value = {};
|
|
232
|
+
for (const property of entry.properties) {
|
|
233
|
+
const name = property.type === 'ObjectProperty' ? keyName(property) : null;
|
|
234
|
+
if (name === null)
|
|
235
|
+
throw new Unreadable('a route object init cannot read');
|
|
236
|
+
value[name] = property.value;
|
|
237
|
+
}
|
|
238
|
+
const own = value.path ? [...path, stringArg(value.path, ast)] : path;
|
|
239
|
+
const isIndex = value.index !== undefined && literalValue(value.index, ast) === true;
|
|
240
|
+
if (value.file && (value.path || isIndex))
|
|
241
|
+
add(own, stringArg(value.file, ast), isIndex);
|
|
242
|
+
if (value.children)
|
|
243
|
+
visitList(value.children, own);
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
throw new Unreadable('a route entry that is not route(), index(), layout(), prefix() or a literal object');
|
|
247
|
+
};
|
|
248
|
+
try {
|
|
249
|
+
visitList(exported, []);
|
|
250
|
+
}
|
|
251
|
+
catch (error) {
|
|
252
|
+
if (error instanceof Unreadable)
|
|
253
|
+
return { manual: `${repoPath(ctx, file)} has ${error.message}` };
|
|
254
|
+
throw error;
|
|
255
|
+
}
|
|
256
|
+
return routes;
|
|
257
|
+
}
|
|
258
|
+
/* ------------------------------------------------------------ integration */
|
|
259
|
+
async function rootEdit(ctx, root, tags) {
|
|
260
|
+
if (!root)
|
|
261
|
+
return { manual: 'the app has no root module (app/root)' };
|
|
262
|
+
const code = readFileSync(root, 'utf8');
|
|
263
|
+
const ast = parseFile(code, root);
|
|
264
|
+
if (!ast)
|
|
265
|
+
return { manual: `${repoPath(ctx, root)} does not parse` };
|
|
266
|
+
const heads = jsxElementsNamed(ast, 'head');
|
|
267
|
+
if (heads.length !== 1 || !heads[0].closingElement)
|
|
268
|
+
return { manual: `${repoPath(ctx, root)} has ${heads.length} <head> elements, not one with children` };
|
|
269
|
+
const rendered = jsxScriptTags(tags);
|
|
270
|
+
const edits = placeTags(code, ourJsxElements(ast, ['script']), heads[0].closingElement.start, rendered, indentUnit(code));
|
|
271
|
+
if ('manual' in edits)
|
|
272
|
+
return edits;
|
|
273
|
+
if (edits.length === 0)
|
|
274
|
+
return [{ target: root, content: code }];
|
|
275
|
+
const result = checkedResult(code, root, edits, ['script'], rendered);
|
|
276
|
+
return typeof result === 'string' ? [{ target: root, content: result }] : result;
|
|
277
|
+
}
|
|
278
|
+
/** The options object of the Remix Vite plugin call in vite.config, as a config-like AST, or null. */
|
|
279
|
+
function remixPluginOptions(config) {
|
|
280
|
+
if (!config.ast)
|
|
281
|
+
return null;
|
|
282
|
+
const local = config.ast.program.body.flatMap((statement) => (statement.type === 'ImportDeclaration' && statement.source.value === '@remix-run/dev'
|
|
283
|
+
? statement.specifiers.filter((specifier) => specifier.type === 'ImportSpecifier' && (specifier.imported.name ?? specifier.imported.value) === 'vitePlugin')
|
|
284
|
+
.map((specifier) => specifier.local.name) : []))[0];
|
|
285
|
+
if (!local)
|
|
286
|
+
return null;
|
|
287
|
+
let options = null;
|
|
288
|
+
walk(config.ast.program, node => {
|
|
289
|
+
if (node.type === 'CallExpression' && node.callee.type === 'Identifier' && node.callee.name === local) {
|
|
290
|
+
const arg = node.arguments[0] ? unwrapTs(node.arguments[0]) : null;
|
|
291
|
+
options = arg === null ? { type: 'ObjectExpression', properties: [] } : arg.type === 'ObjectExpression' ? arg : 'unreadable';
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
return options;
|
|
295
|
+
}
|
|
296
|
+
function optionValue(options, ast, name) {
|
|
297
|
+
const property = options.properties.find((candidate) => candidate.type === 'ObjectProperty' && keyName(candidate) === name);
|
|
298
|
+
if (property)
|
|
299
|
+
return literalValue(property.value, ast);
|
|
300
|
+
return options.properties.some((candidate) => candidate.type !== 'ObjectProperty') ? UNREADABLE : undefined;
|
|
301
|
+
}
|
|
302
|
+
export async function detectReactRouter(ctx) {
|
|
303
|
+
const reactRouter = resolveVersion(ctx, '@react-router/dev');
|
|
304
|
+
const remix = reactRouter ? null : resolveVersion(ctx, '@remix-run/dev');
|
|
305
|
+
const version = reactRouter ?? remix;
|
|
306
|
+
if (!version)
|
|
307
|
+
return null;
|
|
308
|
+
const blocked = [];
|
|
309
|
+
const looked = [];
|
|
310
|
+
let appDirectory = 'app';
|
|
311
|
+
let buildDirectory = 'build';
|
|
312
|
+
let hasPreset = false;
|
|
313
|
+
let classic = false;
|
|
314
|
+
let customRoutes = false;
|
|
315
|
+
let routeConfig = Boolean(reactRouter);
|
|
316
|
+
let ignored = [];
|
|
317
|
+
let assetsBuild = 'public/build';
|
|
318
|
+
const read = (value, name, where) => {
|
|
319
|
+
if (value === undefined)
|
|
320
|
+
return null;
|
|
321
|
+
if (typeof value === 'string')
|
|
322
|
+
return value;
|
|
323
|
+
blocked.push(`${where} sets ${name} in a way init cannot read`);
|
|
324
|
+
return null;
|
|
325
|
+
};
|
|
326
|
+
if (reactRouter) {
|
|
327
|
+
if (version.major !== 7)
|
|
328
|
+
blocked.push(`init knows React Router 7's framework mode, and this app's version is ${version.version} (${version.from})`);
|
|
329
|
+
const config = configFile(ctx.appDir, ['react-router.config.ts', 'react-router.config.js', 'react-router.config.mjs']);
|
|
330
|
+
const where = config.file ? repoPath(ctx, config.file) : appFile(ctx, 'react-router.config.ts');
|
|
331
|
+
looked.push(where);
|
|
332
|
+
for (const [name, assign] of [['appDirectory', (value) => { appDirectory = value; }], ['buildDirectory', (value) => { buildDirectory = value; }]]) {
|
|
333
|
+
const value = configValue(config, [name]);
|
|
334
|
+
if (value.state === 'unreadable')
|
|
335
|
+
blocked.push(`${where} sets ${name} in a way init cannot read`);
|
|
336
|
+
if (value.state === 'value' && typeof value.value === 'string')
|
|
337
|
+
assign(value.value);
|
|
338
|
+
}
|
|
339
|
+
const basename = configValue(config, ['basename']);
|
|
340
|
+
if (basename.state !== 'absent' && !(basename.state === 'value' && basename.value === '/')) {
|
|
341
|
+
blocked.push(`${where} sets basename: the route manifest must be published at the origin root, outside it`);
|
|
342
|
+
}
|
|
343
|
+
hasPreset = configValue(config, ['presets']).state !== 'absent';
|
|
344
|
+
}
|
|
345
|
+
else {
|
|
346
|
+
if (version.major !== 2)
|
|
347
|
+
blocked.push(`init knows Remix 2, and this app's version is ${version.version} (${version.from})`);
|
|
348
|
+
const vite = configFile(ctx.appDir, ['vite.config.ts', 'vite.config.js', 'vite.config.mjs', 'vite.config.mts']);
|
|
349
|
+
const options = remixPluginOptions(vite);
|
|
350
|
+
if (options === 'unreadable') {
|
|
351
|
+
blocked.push(`${repoPath(ctx, vite.file)} passes the Remix plugin options init cannot read`);
|
|
352
|
+
}
|
|
353
|
+
else if (options) {
|
|
354
|
+
const where = repoPath(ctx, vite.file);
|
|
355
|
+
looked.push(where);
|
|
356
|
+
appDirectory = read(optionValue(options, vite.ast, 'appDirectory'), 'appDirectory', where) ?? appDirectory;
|
|
357
|
+
buildDirectory = read(optionValue(options, vite.ast, 'buildDirectory'), 'buildDirectory', where) ?? buildDirectory;
|
|
358
|
+
const basename = optionValue(options, vite.ast, 'basename');
|
|
359
|
+
if (basename !== undefined && basename !== '/')
|
|
360
|
+
blocked.push(`${where} sets basename: the route manifest must be published at the origin root, outside it`);
|
|
361
|
+
customRoutes = optionValue(options, vite.ast, 'routes') !== undefined;
|
|
362
|
+
hasPreset = optionValue(options, vite.ast, 'presets') !== undefined;
|
|
363
|
+
const future = optionValue(options, vite.ast, 'future');
|
|
364
|
+
routeConfig = typeof future === 'object' && future !== null && !Array.isArray(future) && future.v3_routeConfig === true;
|
|
365
|
+
const ignoredValue = optionValue(options, vite.ast, 'ignoredRouteFiles');
|
|
366
|
+
if (Array.isArray(ignoredValue))
|
|
367
|
+
ignored = ignoredValue.filter((item) => typeof item === 'string');
|
|
368
|
+
else if (ignoredValue !== undefined)
|
|
369
|
+
blocked.push(`${where} sets ignoredRouteFiles in a way init cannot read`);
|
|
370
|
+
}
|
|
371
|
+
else {
|
|
372
|
+
classic = true;
|
|
373
|
+
const config = configFile(ctx.appDir, ['remix.config.js', 'remix.config.mjs', 'remix.config.cjs']);
|
|
374
|
+
const where = config.file ? repoPath(ctx, config.file) : appFile(ctx, 'remix.config.js');
|
|
375
|
+
looked.push(where);
|
|
376
|
+
for (const name of ['appDirectory', 'assetsBuildDirectory', 'publicPath', 'routes', 'ignoredRouteFiles', 'basename']) {
|
|
377
|
+
const value = configValue(config, [name]);
|
|
378
|
+
if (value.state === 'unreadable')
|
|
379
|
+
blocked.push(`${where} sets ${name} in a way init cannot read`);
|
|
380
|
+
if (value.state !== 'value')
|
|
381
|
+
continue;
|
|
382
|
+
if (name === 'appDirectory' && typeof value.value === 'string')
|
|
383
|
+
appDirectory = value.value;
|
|
384
|
+
if (name === 'assetsBuildDirectory' && typeof value.value === 'string')
|
|
385
|
+
assetsBuild = value.value;
|
|
386
|
+
if (name === 'routes')
|
|
387
|
+
customRoutes = true;
|
|
388
|
+
if (name === 'ignoredRouteFiles' && Array.isArray(value.value))
|
|
389
|
+
ignored = value.value.filter((item) => typeof item === 'string');
|
|
390
|
+
if (name === 'publicPath' && value.value !== '/build/')
|
|
391
|
+
blocked.push(`${where} sets publicPath, so chunk paths are not where init would look`);
|
|
392
|
+
if (name === 'basename' && value.value !== '/')
|
|
393
|
+
blocked.push(`${where} sets basename: the route manifest must be published at the origin root, outside it`);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
const appDir = join(ctx.appDir, appDirectory);
|
|
398
|
+
const root = firstFile(appDir, ROOT_NAMES);
|
|
399
|
+
const routesFile = routeConfig ? firstFile(appDir, ['routes.ts', 'routes.js', 'routes.mts', 'routes.mjs']) : null;
|
|
400
|
+
looked.push(repoPath(ctx, join(appDir, 'root.*')), repoPath(ctx, join(appDir, routeConfig ? 'routes.ts' : 'routes/')));
|
|
401
|
+
if (customRoutes)
|
|
402
|
+
blocked.push('the Remix config defines routes with a function, which init cannot read without running it');
|
|
403
|
+
const label = reactRouter ? `React Router ${version.version} (framework mode)` : `Remix ${version.version}${classic ? ' (classic compiler)' : ' (Vite)'}`;
|
|
404
|
+
const scripts = ctx.pkg?.scripts ?? {};
|
|
405
|
+
const after = !hasPreset;
|
|
406
|
+
const build = typeof scripts.build === 'string'
|
|
407
|
+
? { kind: 'package-script', script: 'build', phase: after || classic ? 'after' : 'before' }
|
|
408
|
+
: { manual: `${appFile(ctx, 'package.json')} has no build script to publish the route manifest after` };
|
|
409
|
+
const clientDir = classic ? 'public' : after ? posix.join(buildDirectory, 'client') : 'public';
|
|
410
|
+
return {
|
|
411
|
+
id: reactRouter ? 'react-router' : 'remix',
|
|
412
|
+
label,
|
|
413
|
+
manifestAdapter: `${reactRouter ? 'react-router' : 'remix'}@${version.version}`,
|
|
414
|
+
blocked: blocked.length > 0 ? blocked.join('; ') : null,
|
|
415
|
+
// The routes come from the source, so whatever stops init placing the tag stops the manifest too.
|
|
416
|
+
manifestBlocked: blocked.length > 0 ? blocked.join('; ') : null,
|
|
417
|
+
fromBuild: false,
|
|
418
|
+
rewriting: null,
|
|
419
|
+
publishNote: null,
|
|
420
|
+
buildPublishDirs: () => [],
|
|
421
|
+
buildNotes: () => [],
|
|
422
|
+
looked,
|
|
423
|
+
staticDir: 'public',
|
|
424
|
+
integrate: tags => rootEdit(ctx, root, tags),
|
|
425
|
+
manualTag: tags => [`In ${repoPath(ctx, join(appDir, 'root.tsx'))}, inside <head>:`, ...jsxScriptTags(tags)],
|
|
426
|
+
build,
|
|
427
|
+
async routes() {
|
|
428
|
+
if (routeConfig)
|
|
429
|
+
return routesFile ? configRoutes(ctx, appDir, routesFile) : { manual: `${repoPath(ctx, join(appDir, 'routes.ts'))} is missing` };
|
|
430
|
+
return flatRoutes(ctx, join(appDir, 'routes'), ignored);
|
|
431
|
+
},
|
|
432
|
+
chunks: () => {
|
|
433
|
+
if (classic)
|
|
434
|
+
return chunkIds(join(ctx.appDir, assetsBuild), `/${posix.relative('public', assetsBuild)}/`);
|
|
435
|
+
return after ? chunkIds(join(ctx.appDir, buildDirectory, 'client'), '/').filter(id => !id.startsWith('/_haystack/')) : [];
|
|
436
|
+
},
|
|
437
|
+
publishDirs: [clientDir],
|
|
438
|
+
};
|
|
439
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SvelteKit 1–2 (CAPTURE-V1 rule 8b).
|
|
3
|
+
*
|
|
4
|
+
* Tag: an async script in <head> of src/app.html (kit.files.appTemplate), before </head>. Static output: static/
|
|
5
|
+
* (kit.files.assets), which every adapter copies into its output. Routes: directories under src/routes with a +page
|
|
6
|
+
* component ((group) directories add no segment; [x], [[x]], [...rest] and [x=matcher] are parameters). The adapter
|
|
7
|
+
* (node, static, Vercel, Cloudflare, auto) assembles the final output inside `vite build`, so the build step publishes
|
|
8
|
+
* the manifest into static/ BEFORE the build, without chunks. A base path (kit.paths.base) is a manual step.
|
|
9
|
+
*/
|
|
10
|
+
import { readFileSync } from 'node:fs';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
import { withHeadTags } from '../html.js';
|
|
13
|
+
import { repoPath, resolveVersion, appFile } from '../project.js';
|
|
14
|
+
import { paramName, ruleFor } from '../route-pattern.js';
|
|
15
|
+
import { htmlTags } from '../tag.js';
|
|
16
|
+
import { configFile, configValue, filesUnder, isFile } from './files.js';
|
|
17
|
+
/** A SvelteKit route directory name as a segment; null for a (group). */
|
|
18
|
+
export function svelteSegment(name) {
|
|
19
|
+
if (/^\(.+\)$/.test(name))
|
|
20
|
+
return null;
|
|
21
|
+
// [x=matcher]: the matcher constrains the parameter, which the pattern syntax cannot say.
|
|
22
|
+
const constrained = (matcher) => (matcher ? { inexact: `a parameter with a matcher (${name})` } : {});
|
|
23
|
+
const optional = /^\[\[([^\]=]+)(=[^\]]+)?\]\]$/.exec(name);
|
|
24
|
+
if (optional)
|
|
25
|
+
return { kind: 'param', name: paramName(optional[1]), optional: true, ...constrained(optional[2]) };
|
|
26
|
+
const rest = /^\[\.\.\.([^\]=]+)(=[^\]]+)?\]$/.exec(name);
|
|
27
|
+
if (rest)
|
|
28
|
+
return { kind: 'splat', name: paramName(rest[1]), optional: true, ...constrained(rest[2]) };
|
|
29
|
+
const param = /^\[([^\]=]+)(=[^\]]+)?\]$/.exec(name);
|
|
30
|
+
if (param)
|
|
31
|
+
return { kind: 'param', name: paramName(param[1]), optional: false, ...constrained(param[2]) };
|
|
32
|
+
// [x+2f] and [u+0041] encode characters that cannot appear in a directory name.
|
|
33
|
+
const decoded = name.replace(/\[x\+([0-9a-fA-F]{2})\]/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)))
|
|
34
|
+
.replace(/\[u\+([0-9a-fA-F]{4,6})\]/g, (_, hex) => String.fromCodePoint(parseInt(hex, 16)));
|
|
35
|
+
if (decoded.includes('['))
|
|
36
|
+
return { kind: 'param', name: paramName(decoded), optional: false, inexact: `a segment mixing text and a parameter (${name})` };
|
|
37
|
+
return { kind: 'literal', value: decoded };
|
|
38
|
+
}
|
|
39
|
+
export async function detectSvelteKit(ctx) {
|
|
40
|
+
const version = resolveVersion(ctx, '@sveltejs/kit');
|
|
41
|
+
if (!version)
|
|
42
|
+
return null;
|
|
43
|
+
const config = configFile(ctx.appDir, ['svelte.config.js', 'svelte.config.mjs', 'svelte.config.ts']);
|
|
44
|
+
const where = config.file ? repoPath(ctx, config.file) : appFile(ctx, 'svelte.config.js');
|
|
45
|
+
const blocked = [];
|
|
46
|
+
const read = (path, fallback) => {
|
|
47
|
+
const value = configValue(config, path);
|
|
48
|
+
if (value.state === 'unreadable' || (value.state === 'value' && typeof value.value !== 'string')) {
|
|
49
|
+
blocked.push(`${where} sets ${path.join('.')} in a way init cannot read`);
|
|
50
|
+
return fallback;
|
|
51
|
+
}
|
|
52
|
+
return value.state === 'value' ? value.value : fallback;
|
|
53
|
+
};
|
|
54
|
+
const routesDir = read(['kit', 'files', 'routes'], 'src/routes');
|
|
55
|
+
const assets = read(['kit', 'files', 'assets'], 'static');
|
|
56
|
+
const template = read(['kit', 'files', 'appTemplate'], 'src/app.html');
|
|
57
|
+
const base = read(['kit', 'paths', 'base'], '');
|
|
58
|
+
if (base !== '')
|
|
59
|
+
blocked.push(`${where} sets paths.base ${base}: the route manifest must be published at the origin root, outside it`);
|
|
60
|
+
const extensionsRead = configValue(config, ['extensions']);
|
|
61
|
+
const extensions = extensionsRead.state === 'value' && Array.isArray(extensionsRead.value) ? extensionsRead.value.filter((item) => typeof item === 'string') : ['.svelte'];
|
|
62
|
+
if (extensionsRead.state === 'unreadable')
|
|
63
|
+
blocked.push(`${where} sets extensions in a way init cannot read`);
|
|
64
|
+
if (version.major !== 1 && version.major !== 2)
|
|
65
|
+
blocked.push(`init knows SvelteKit 1–2, and this app's version is ${version.version} (${version.from})`);
|
|
66
|
+
const templateFile = join(ctx.appDir, template);
|
|
67
|
+
const templatePath = repoPath(ctx, templateFile);
|
|
68
|
+
const scripts = ctx.pkg?.scripts ?? {};
|
|
69
|
+
return {
|
|
70
|
+
id: 'sveltekit',
|
|
71
|
+
label: `SvelteKit ${version.version}`,
|
|
72
|
+
manifestAdapter: `sveltekit@${version.version}`,
|
|
73
|
+
blocked: blocked.length > 0 ? blocked.join('; ') : null,
|
|
74
|
+
// The routes come from the source, so whatever stops init placing the tag stops the manifest too.
|
|
75
|
+
manifestBlocked: blocked.length > 0 ? blocked.join('; ') : null,
|
|
76
|
+
fromBuild: false,
|
|
77
|
+
rewriting: null,
|
|
78
|
+
publishNote: null,
|
|
79
|
+
buildPublishDirs: () => [],
|
|
80
|
+
buildNotes: () => [],
|
|
81
|
+
looked: [templatePath, where, repoPath(ctx, join(ctx.appDir, routesDir))],
|
|
82
|
+
staticDir: assets,
|
|
83
|
+
async integrate(tags) {
|
|
84
|
+
if (!isFile(templateFile))
|
|
85
|
+
return { manual: `${templatePath} is missing` };
|
|
86
|
+
const edit = withHeadTags(readFileSync(templateFile, 'utf8'), htmlTags(tags));
|
|
87
|
+
return 'manual' in edit ? { manual: `${templatePath}: ${edit.manual}` } : [{ target: templateFile, content: edit.content }];
|
|
88
|
+
},
|
|
89
|
+
manualTag: tags => [`In ${templatePath}, inside <head>:`, ...htmlTags(tags)],
|
|
90
|
+
build: typeof scripts.build === 'string' ? { kind: 'package-script', script: 'build', phase: 'before' }
|
|
91
|
+
: { manual: `${appFile(ctx, 'package.json')} has no build script to publish the route manifest before` },
|
|
92
|
+
async routes() {
|
|
93
|
+
const root = join(ctx.appDir, routesDir);
|
|
94
|
+
const routes = [];
|
|
95
|
+
for (const file of filesUnder(root)) {
|
|
96
|
+
const parts = file.split('/');
|
|
97
|
+
const name = parts.pop();
|
|
98
|
+
// +page@.svelte and +page@<layout>.svelte reset the layout; they are pages of the same route.
|
|
99
|
+
if (!extensions.some(extension => name.endsWith(extension) && /^\+page(@[^/]*)?$/.test(name.slice(0, -extension.length))))
|
|
100
|
+
continue;
|
|
101
|
+
const segments = parts.map(svelteSegment).filter((segment) => segment !== null);
|
|
102
|
+
routes.push({ ...ruleFor(segments), source: repoPath(ctx, join(root, file)) });
|
|
103
|
+
}
|
|
104
|
+
return routes;
|
|
105
|
+
},
|
|
106
|
+
chunks: () => [],
|
|
107
|
+
publishDirs: [assets],
|
|
108
|
+
};
|
|
109
|
+
}
|