@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.
- 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 +127 -0
- package/dist/capture/adapters/files.js +77 -0
- package/dist/capture/adapters/index.js +64 -0
- package/dist/capture/adapters/jsx-edit.js +81 -0
- package/dist/capture/adapters/next.js +327 -0
- package/dist/capture/adapters/nuxt.js +192 -0
- package/dist/capture/adapters/rails.js +171 -0
- package/dist/capture/adapters/react-router.js +432 -0
- package/dist/capture/adapters/sveltekit.js +102 -0
- package/dist/capture/adapters/types.js +4 -0
- package/dist/capture/adapters/vite.js +121 -0
- package/dist/capture/app-config.js +106 -0
- package/dist/capture/consent.js +127 -0
- package/dist/capture/csp.js +331 -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-step.js +53 -0
- package/dist/commands/capture-brief.js +92 -0
- package/dist/commands/capture-contract.js +46 -0
- package/dist/commands/capture-manifest.js +78 -0
- package/dist/commands/init-capture.js +409 -0
- package/dist/commands/init-telemetry.js +1011 -0
- package/dist/commands/init.js +75 -9
- 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 +5 -1
- package/dist/commands/verify.js +54 -7
- package/dist/index.js +83 -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,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rails 6–8 (CAPTURE-V1 rule 8b).
|
|
3
|
+
*
|
|
4
|
+
* Tag: an async script before </head> of the app's one HTML layout in app/views/layouts (ERB; mailer layouts are emails
|
|
5
|
+
* and never get it; several page layouts, or Haml/Slim, are a manual step). Static output: public/.
|
|
6
|
+
*
|
|
7
|
+
* Routes: Rails' own route set, which only Rails can evaluate, so the build step is a rake file that runs after
|
|
8
|
+
* assets:precompile (when the app is booted): it writes the GET routes Rails lists (path spec, controller, action) to
|
|
9
|
+
* tmp/ and runs `haystack capture manifest --rails-routes <file>` of the CLI package.json pins, never failing the build. Path specs become
|
|
10
|
+
* patterns: (.:format) is dropped, other optional groups make their segments optional, *glob is a splat. Each route's
|
|
11
|
+
* source is its controller file. Chunks: none declared.
|
|
12
|
+
*/
|
|
13
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
import { withHeadTags, scanHtml } from '../html.js';
|
|
16
|
+
import { gemVersion, repoPath } from '../project.js';
|
|
17
|
+
import { paramName, ruleFor } from '../route-pattern.js';
|
|
18
|
+
import { htmlTags } from '../tag.js';
|
|
19
|
+
import { filesUnder, isFile } from './files.js';
|
|
20
|
+
export const RAKE_FILE = 'lib/tasks/haystack_capture.rake';
|
|
21
|
+
/** The rake file: after assets:precompile, the app's GET routes into tmp/, then the manifest command of the CLI the app
|
|
22
|
+
* pins (node_modules/.bin, never npx). The command exits 0 whatever happens, and a missing CLI is one warning. */
|
|
23
|
+
export const RAKE_FILE_CONTENT = `# Written by \`haystack init\` (CAPTURE-V1 rule 4). After assets:precompile, publishes this app's route manifest into
|
|
24
|
+
# public/.well-known/haystack-capture/ from the routes Rails itself lists, with the @haystackeditor/cli package.json pins.
|
|
25
|
+
# It never fails the build (rule 13): a manifest that cannot be made only means the tag records nothing on this release,
|
|
26
|
+
# and the previous release's current.json is removed rather than shipped.
|
|
27
|
+
if Rake::Task.task_defined?('assets:precompile')
|
|
28
|
+
Rake::Task['assets:precompile'].enhance do
|
|
29
|
+
current = Rails.root.join('public', '.well-known', 'haystack-capture', 'current.json')
|
|
30
|
+
begin
|
|
31
|
+
require 'json'
|
|
32
|
+
require 'fileutils'
|
|
33
|
+
step = Rails.root.join('node_modules', '.bin', 'haystack-capture-step')
|
|
34
|
+
if File.exist?(step)
|
|
35
|
+
routes = Rails.application.routes.routes.filter_map do |route|
|
|
36
|
+
verb = route.verb.to_s
|
|
37
|
+
next unless verb.empty? || verb.split('|').include?('GET')
|
|
38
|
+
next if route.respond_to?(:internal) && route.internal
|
|
39
|
+
controller = route.defaults[:controller]
|
|
40
|
+
next unless controller
|
|
41
|
+
{ path: route.path.spec.to_s, controller: controller.to_s, action: route.defaults[:action].to_s }
|
|
42
|
+
end
|
|
43
|
+
file = Rails.root.join('tmp', 'haystack-capture-routes.json')
|
|
44
|
+
FileUtils.mkdir_p(File.dirname(file))
|
|
45
|
+
File.write(file, JSON.generate(routes))
|
|
46
|
+
# The step exits 0 whatever happens and removes current.json itself when no manifest was written.
|
|
47
|
+
system(step.to_s, '--rails-routes', file.to_s, chdir: Rails.root.to_s)
|
|
48
|
+
else
|
|
49
|
+
File.delete(current) if File.exist?(current)
|
|
50
|
+
warn('haystack capture manifest: @haystackeditor/cli is not installed (node_modules/.bin/haystack-capture-step); the build goes on without a route manifest.')
|
|
51
|
+
end
|
|
52
|
+
rescue StandardError => e
|
|
53
|
+
begin
|
|
54
|
+
File.delete(current) if File.exist?(current)
|
|
55
|
+
rescue StandardError
|
|
56
|
+
nil
|
|
57
|
+
end
|
|
58
|
+
warn("haystack capture manifest: #{e.class}; the build goes on without a route manifest.")
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
`;
|
|
63
|
+
/** A Rails path spec (`/users/:id(.:format)`, `(/:locale)/posts/*rest`) as segments (an inexact one marked, route-pattern.ts
|
|
64
|
+
* ruleFor); null when its parentheses do not balance. */
|
|
65
|
+
export function railsSegments(spec) {
|
|
66
|
+
const parts = [];
|
|
67
|
+
let depth = 0;
|
|
68
|
+
let text = '';
|
|
69
|
+
let startDepth = -1;
|
|
70
|
+
const flush = () => {
|
|
71
|
+
if (text !== '')
|
|
72
|
+
parts.push({ text, optional: startDepth > 0 });
|
|
73
|
+
text = '';
|
|
74
|
+
startDepth = -1;
|
|
75
|
+
};
|
|
76
|
+
for (const char of spec.split('(.:format)').join('')) {
|
|
77
|
+
if (char === '(') {
|
|
78
|
+
depth += 1;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (char === ')') {
|
|
82
|
+
depth -= 1;
|
|
83
|
+
if (depth < 0)
|
|
84
|
+
return null;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (char === '/') {
|
|
88
|
+
flush();
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (startDepth === -1)
|
|
92
|
+
startDepth = depth;
|
|
93
|
+
text += char;
|
|
94
|
+
}
|
|
95
|
+
if (depth !== 0)
|
|
96
|
+
return null;
|
|
97
|
+
flush();
|
|
98
|
+
const segments = parts.map(({ text: part, optional }) => {
|
|
99
|
+
if (/^\*[A-Za-z_][A-Za-z0-9_]*$/.test(part))
|
|
100
|
+
return { kind: 'splat', name: paramName(part.slice(1)), optional };
|
|
101
|
+
if (/^:[A-Za-z_][A-Za-z0-9_]*$/.test(part))
|
|
102
|
+
return { kind: 'param', name: paramName(part.slice(1)), optional };
|
|
103
|
+
// A segment mixing text and parameters (`:id-:slug`), or an optional literal, has no exact form in the pattern syntax.
|
|
104
|
+
if (part.includes(':') || part.includes('*') || optional) {
|
|
105
|
+
return { kind: 'param', name: paramName(part), optional, inexact: `${optional ? 'an optional segment' : 'a segment mixing text and parameters'} (${part})` };
|
|
106
|
+
}
|
|
107
|
+
return { kind: 'literal', value: part };
|
|
108
|
+
});
|
|
109
|
+
return segments;
|
|
110
|
+
}
|
|
111
|
+
function layouts(appDir) {
|
|
112
|
+
const dir = join(appDir, 'app', 'views', 'layouts');
|
|
113
|
+
const files = filesUnder(dir).filter(file => !file.split('/').pop()?.startsWith('mailer') && !file.startsWith('mailer'));
|
|
114
|
+
const pages = files.filter(file => /\.html\.erb$/.test(file) && scanHtml(readFileSync(join(dir, file), 'utf8')).heads.length > 0);
|
|
115
|
+
return { erb: pages.map(file => join(dir, file)), other: files.filter(file => /\.html\.(haml|slim)$/.test(file)).map(file => join(dir, file)) };
|
|
116
|
+
}
|
|
117
|
+
export async function detectRails(ctx) {
|
|
118
|
+
const gemfile = join(ctx.appDir, 'Gemfile');
|
|
119
|
+
if (!isFile(gemfile) || !isFile(join(ctx.appDir, 'config', 'routes.rb')))
|
|
120
|
+
return null;
|
|
121
|
+
if (!/^\s*gem\s+['"]rails['"]/m.test(readFileSync(gemfile, 'utf8')))
|
|
122
|
+
return null;
|
|
123
|
+
const version = gemVersion(ctx.appDir, 'rails') ?? gemVersion(ctx.appDir, 'railties');
|
|
124
|
+
const blocked = [];
|
|
125
|
+
if (!version || version.major === null || version.major < 6 || version.major > 8) {
|
|
126
|
+
blocked.push(`init knows Rails 6–8, and this app's version is ${version ? `${version.version} (Gemfile.lock)` : 'not in a Gemfile.lock'}`);
|
|
127
|
+
}
|
|
128
|
+
const found = layouts(ctx.appDir);
|
|
129
|
+
const layoutsPath = repoPath(ctx, join(ctx.appDir, 'app', 'views', 'layouts'));
|
|
130
|
+
return {
|
|
131
|
+
id: 'rails',
|
|
132
|
+
label: `Rails ${version?.version ?? '(unknown version)'}`,
|
|
133
|
+
manifestAdapter: `rails@${version?.version ?? 'unknown'}`,
|
|
134
|
+
blocked: blocked.length > 0 ? blocked.join('; ') : null,
|
|
135
|
+
looked: [layoutsPath, repoPath(ctx, join(ctx.appDir, 'Gemfile.lock'))],
|
|
136
|
+
staticDir: 'public',
|
|
137
|
+
async integrate(tags) {
|
|
138
|
+
if (found.erb.length !== 1) {
|
|
139
|
+
const other = found.other.length > 0 ? ` (and ${found.other.map(file => repoPath(ctx, file)).join(', ')}, which init does not edit)` : '';
|
|
140
|
+
return { manual: `${layoutsPath} has ${found.erb.length} ERB page layouts with a <head>${other}, not one` };
|
|
141
|
+
}
|
|
142
|
+
const file = found.erb[0];
|
|
143
|
+
const edit = withHeadTags(readFileSync(file, 'utf8'), htmlTags(tags));
|
|
144
|
+
return 'manual' in edit ? { manual: `${repoPath(ctx, file)}: ${edit.manual}` } : [{ target: file, content: edit.content }];
|
|
145
|
+
},
|
|
146
|
+
manualTag: tags => [`In your page layout (${layoutsPath}/application.html.erb), inside <head>:`, ...htmlTags(tags)],
|
|
147
|
+
build: { kind: 'rake' },
|
|
148
|
+
async routes(inputs) {
|
|
149
|
+
if (!inputs.railsRoutes || !existsSync(inputs.railsRoutes)) {
|
|
150
|
+
return { manual: 'Rails routes come from the rake task after assets:precompile (--rails-routes); run the app\'s build' };
|
|
151
|
+
}
|
|
152
|
+
const listed = JSON.parse(readFileSync(inputs.railsRoutes, 'utf8'));
|
|
153
|
+
const routes = [];
|
|
154
|
+
for (const route of listed) {
|
|
155
|
+
if (typeof route.path !== 'string' || typeof route.controller !== 'string' || !/^[a-z0-9_/]+$/.test(route.controller))
|
|
156
|
+
continue;
|
|
157
|
+
const segments = railsSegments(route.path);
|
|
158
|
+
// A controller outside app/controllers belongs to a gem or engine (Active Storage, Devise): its pages are not this
|
|
159
|
+
// repository's files, so they stay `unknown` rather than being joined to a file that did not define them.
|
|
160
|
+
const controller = join(ctx.appDir, 'app', 'controllers', `${route.controller}_controller.rb`);
|
|
161
|
+
if (!existsSync(controller))
|
|
162
|
+
continue;
|
|
163
|
+
routes.push(segments ? { ...ruleFor(segments), source: repoPath(ctx, controller) }
|
|
164
|
+
: { match: '/', source: repoPath(ctx, controller), inexact: `a path spec the pattern syntax cannot express (${route.path})` });
|
|
165
|
+
}
|
|
166
|
+
return routes;
|
|
167
|
+
},
|
|
168
|
+
chunks: () => [],
|
|
169
|
+
publishDirs: ['public'],
|
|
170
|
+
};
|
|
171
|
+
}
|
|
@@ -0,0 +1,432 @@
|
|
|
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
|
+
looked,
|
|
416
|
+
staticDir: 'public',
|
|
417
|
+
integrate: tags => rootEdit(ctx, root, tags),
|
|
418
|
+
manualTag: tags => [`In ${repoPath(ctx, join(appDir, 'root.tsx'))}, inside <head>:`, ...jsxScriptTags(tags)],
|
|
419
|
+
build,
|
|
420
|
+
async routes() {
|
|
421
|
+
if (routeConfig)
|
|
422
|
+
return routesFile ? configRoutes(ctx, appDir, routesFile) : { manual: `${repoPath(ctx, join(appDir, 'routes.ts'))} is missing` };
|
|
423
|
+
return flatRoutes(ctx, join(appDir, 'routes'), ignored);
|
|
424
|
+
},
|
|
425
|
+
chunks: () => {
|
|
426
|
+
if (classic)
|
|
427
|
+
return chunkIds(join(ctx.appDir, assetsBuild), `/${posix.relative('public', assetsBuild)}/`);
|
|
428
|
+
return after ? chunkIds(join(ctx.appDir, buildDirectory, 'client'), '/').filter(id => !id.startsWith('/_haystack/')) : [];
|
|
429
|
+
},
|
|
430
|
+
publishDirs: [clientDir],
|
|
431
|
+
};
|
|
432
|
+
}
|