@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.
Files changed (51) 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 +134 -0
  6. package/dist/capture/adapters/files.js +77 -0
  7. package/dist/capture/adapters/index.js +74 -0
  8. package/dist/capture/adapters/jsx-edit.js +81 -0
  9. package/dist/capture/adapters/next-build.js +113 -0
  10. package/dist/capture/adapters/next.js +494 -0
  11. package/dist/capture/adapters/nuxt.js +199 -0
  12. package/dist/capture/adapters/rails.js +178 -0
  13. package/dist/capture/adapters/react-router.js +439 -0
  14. package/dist/capture/adapters/sveltekit.js +109 -0
  15. package/dist/capture/adapters/types.js +4 -0
  16. package/dist/capture/adapters/vite.js +135 -0
  17. package/dist/capture/app-config.js +107 -0
  18. package/dist/capture/consent.js +127 -0
  19. package/dist/capture/csp.js +332 -0
  20. package/dist/capture/html.js +74 -0
  21. package/dist/capture/js-ast.js +400 -0
  22. package/dist/capture/manifest.js +95 -0
  23. package/dist/capture/project.js +177 -0
  24. package/dist/capture/route-pattern.js +119 -0
  25. package/dist/capture/script-release.js +47 -0
  26. package/dist/capture/tag.js +74 -0
  27. package/dist/capture/url-rewrites.js +232 -0
  28. package/dist/capture-step.js +56 -0
  29. package/dist/commands/capture-brief.js +92 -0
  30. package/dist/commands/capture-contract.js +46 -0
  31. package/dist/commands/capture-manifest.js +86 -0
  32. package/dist/commands/init-capture.js +426 -0
  33. package/dist/commands/init-telemetry.js +1028 -0
  34. package/dist/commands/init.js +78 -5
  35. package/dist/commands/server-telemetry-contract.d.ts +66 -0
  36. package/dist/commands/server-telemetry-contract.js +127 -0
  37. package/dist/commands/telemetry-token.js +238 -0
  38. package/dist/commands/telemetry.d.ts +161 -8
  39. package/dist/commands/telemetry.js +940 -158
  40. package/dist/commands/verify-onboarding.js +21 -1
  41. package/dist/commands/verify.js +56 -9
  42. package/dist/index.js +85 -6
  43. package/dist/schema.js +2 -2
  44. package/dist/telemetry/next-loader.cjs +66 -9
  45. package/dist/telemetry/next.d.ts +11 -3
  46. package/dist/telemetry/next.js +95 -15
  47. package/dist/telemetry/typed-source.d.ts +47 -0
  48. package/dist/telemetry/typed-source.js +379 -0
  49. package/package.json +4 -2
  50. package/schemas/init.v1.json +63 -4
  51. package/schemas/pre-verify.v1.json +60 -3
@@ -0,0 +1,383 @@
1
+ /**
2
+ * Route lists of single-page apps whose router is configured in code (CAPTURE-V1 rule 4): React Router's library mode
3
+ * (createBrowserRouter / createHashRouter / useRoutes route objects, and <Routes><Route> JSX) and Vue Router
4
+ * (createRouter({ history, routes })). Read statically from the app's source, through same-file consts and relative or
5
+ * aliased imports; a route whose path is not a literal is a manual step, never a guess. Each route's source is the file
6
+ * of the component it renders (where blast radius lands), else the file that declares it.
7
+ */
8
+ import { readFileSync } from 'node:fs';
9
+ import { join } from 'node:path';
10
+ import { importSourceOf, jsxName, keyName, literalValue, parseFile, readPathAliases, resolveImport, topLevelConst, UNREADABLE, unwrapTs, walk } from '../js-ast.js';
11
+ import { repoPath } from '../project.js';
12
+ import { paramName, ruleFor } from '../route-pattern.js';
13
+ import { reactRouterSegments } from './react-router.js';
14
+ import { filesUnder } from './files.js';
15
+ class Unreadable extends Error {
16
+ }
17
+ function sourceFiles(dir) {
18
+ const parsed = [];
19
+ for (const file of filesUnder(dir)) {
20
+ if (!/\.(m?[jt]sx?)$/.test(file) || /(^|\/)(__tests__|__mocks__)\//.test(file) || /\.(test|spec|stories)\.[^.]+$/.test(file))
21
+ continue;
22
+ const absolute = join(dir, file);
23
+ const code = readFileSync(absolute, 'utf8');
24
+ if (!code.includes('react-router') && !code.includes('vue-router') && !code.includes('Route'))
25
+ continue;
26
+ const ast = parseFile(code, absolute);
27
+ if (ast)
28
+ parsed.push({ file: absolute, code, ast });
29
+ }
30
+ return parsed;
31
+ }
32
+ /** Local names this module imports from `sources` (imported name → local name). */
33
+ function importsFrom(ast, sources) {
34
+ const names = new Map();
35
+ for (const statement of ast.program.body) {
36
+ if (statement.type !== 'ImportDeclaration' || !sources.includes(statement.source.value))
37
+ continue;
38
+ for (const specifier of statement.specifiers) {
39
+ if (specifier.type === 'ImportSpecifier')
40
+ names.set(specifier.imported.name ?? specifier.imported.value, specifier.local.name);
41
+ }
42
+ }
43
+ return names;
44
+ }
45
+ /** The file a component reference renders: `<Home />`, `Home`, `() => import('./Home')`, `lazy(() => import('./Home'))`. */
46
+ function componentFile(node, module, aliases) {
47
+ if (!node)
48
+ return null;
49
+ const current = unwrapTs(node);
50
+ if (current.type === 'JSXElement') {
51
+ const name = jsxName(current);
52
+ return name ? componentFile({ type: 'Identifier', name }, module, aliases) : null;
53
+ }
54
+ if (current.type === 'Identifier') {
55
+ const source = importSourceOf(module.ast, current.name);
56
+ if (source)
57
+ return resolveImport(module.file, source, aliases);
58
+ const init = topLevelConst(module.ast, current.name);
59
+ return init ? componentFile(init, module, aliases) : null;
60
+ }
61
+ let imported = null;
62
+ walk(current, inner => {
63
+ if (imported === null && inner.type === 'ImportExpression' && inner.source.type === 'StringLiteral')
64
+ imported = inner.source.value;
65
+ if (imported === null && inner.type === 'CallExpression' && inner.callee.type === 'Import' && inner.arguments[0]?.type === 'StringLiteral')
66
+ imported = inner.arguments[0].value;
67
+ });
68
+ return imported ? resolveImport(module.file, imported, aliases) : null;
69
+ }
70
+ /** An array of route objects through a same-file const or an imported module's export. */
71
+ function routeArray(node, module, aliases, depth = 0) {
72
+ const current = unwrapTs(node);
73
+ if (depth > 4)
74
+ throw new Unreadable('a route list init cannot follow');
75
+ if (current?.type === 'ArrayExpression')
76
+ return { list: current, module };
77
+ if (current?.type === 'Identifier') {
78
+ const init = topLevelConst(module.ast, current.name);
79
+ if (init)
80
+ return routeArray(init, module, aliases, depth + 1);
81
+ const source = importSourceOf(module.ast, current.name);
82
+ const file = source ? resolveImport(module.file, source, aliases) : null;
83
+ if (file) {
84
+ const code = readFileSync(file, 'utf8');
85
+ const ast = parseFile(code, file);
86
+ if (!ast)
87
+ throw new Unreadable(`${file}, which does not parse`);
88
+ const imported = { file, code, ast };
89
+ const specifier = module.ast.program.body.flatMap((statement) => (statement.type === 'ImportDeclaration' ? statement.specifiers : []))
90
+ .find((candidate) => candidate.local.name === current.name);
91
+ const exportedName = specifier?.type === 'ImportSpecifier' ? (specifier.imported.name ?? specifier.imported.value) : null;
92
+ if (exportedName) {
93
+ const init = topLevelConst(ast, exportedName);
94
+ if (init)
95
+ return routeArray(init, imported, aliases, depth + 1);
96
+ }
97
+ else {
98
+ const exported = ast.program.body.find((statement) => statement.type === 'ExportDefaultDeclaration')?.declaration;
99
+ if (exported)
100
+ return routeArray(exported, imported, aliases, depth + 1);
101
+ }
102
+ }
103
+ }
104
+ throw new Unreadable('a route list that is not an array literal');
105
+ }
106
+ function stringValue(node, ast, what) {
107
+ const value = node ? literalValue(node, ast) : UNREADABLE;
108
+ if (typeof value !== 'string')
109
+ throw new Unreadable(`a ${what} that is not a literal string`);
110
+ return value;
111
+ }
112
+ function joinPath(parent, child) {
113
+ if (child.startsWith('/'))
114
+ return child;
115
+ return `${parent.replace(/\/+$/, '')}/${child}`;
116
+ }
117
+ function properties(object) {
118
+ const map = new Map();
119
+ for (const property of object.properties) {
120
+ if (property.type === 'SpreadElement')
121
+ throw new Unreadable('a route object with a spread');
122
+ const name = keyName(property);
123
+ if (name === null)
124
+ throw new Unreadable('a route object with a computed key');
125
+ map.set(name, property.type === 'ObjectMethod' ? property : property.value);
126
+ }
127
+ return map;
128
+ }
129
+ /* ------------------------------------------------------------ React Router */
130
+ /** A basename as React Router reads it ('/app/' and 'app' are '/app'); '' for none. */
131
+ function normalBasename(value) {
132
+ const trimmed = value.replace(/\/+$/, '');
133
+ return trimmed === '' ? '' : trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
134
+ }
135
+ /** createBrowserRouter(routes, { basename })'s basename: '' without options; options init cannot read are Unreadable. */
136
+ function optionsBasename(node, module) {
137
+ if (!node)
138
+ return '';
139
+ const options = unwrapTs(node);
140
+ if (options?.type !== 'ObjectExpression')
141
+ throw new Unreadable('router options that are not an object literal (they may set a basename)');
142
+ const props = properties(options);
143
+ return props.has('basename') ? normalBasename(stringValue(props.get('basename'), module.ast, 'router basename')) : '';
144
+ }
145
+ /** <BrowserRouter basename="/app">'s basename. */
146
+ function jsxBasename(element, module) {
147
+ if (element.openingElement.attributes.some((item) => item.type === 'JSXSpreadAttribute'))
148
+ throw new Unreadable('a router component with spread props');
149
+ const attribute = element.openingElement.attributes.find((item) => item.type === 'JSXAttribute' && item.name.name === 'basename');
150
+ if (!attribute)
151
+ return '';
152
+ const value = attribute.value?.type === 'JSXExpressionContainer' ? attribute.value.expression : attribute.value;
153
+ return normalBasename(stringValue(value, module.ast, 'router basename'));
154
+ }
155
+ const RR_SOURCES = ['react-router', 'react-router-dom'];
156
+ export function reactRouterLibraryRoutes(ctx, srcDir) {
157
+ const aliases = readPathAliases(ctx.appDir);
158
+ const modules = sourceFiles(srcDir);
159
+ const routes = [];
160
+ let hashComponent = false;
161
+ let browserComponent = false;
162
+ let found = false;
163
+ // The router's basename prefixes every route (React Router matches below it): a router component's (one value for the
164
+ // app), or a data router's own option; one init cannot read is a manual step.
165
+ const componentBasenames = new Set();
166
+ let basename = '';
167
+ const add = (path, hash, file, module, index) => {
168
+ const full = basename === '' ? path : `${basename}${path.startsWith('/') ? '' : '/'}${path}`;
169
+ routes.push({ ...ruleFor(reactRouterSegments(full), hash), source: repoPath(ctx, file ?? module.file), rank: index ? 0 : 1 });
170
+ };
171
+ const objects = (list, module, parent, hash) => {
172
+ for (const element of list.elements) {
173
+ const route = element ? unwrapTs(element) : null;
174
+ if (!route || route.type !== 'ObjectExpression')
175
+ throw new Unreadable('a route that is not an object literal');
176
+ const props = properties(route);
177
+ const index = props.has('index') && literalValue(props.get('index'), module.ast) === true;
178
+ const path = props.has('path') ? joinPath(parent, stringValue(props.get('path'), module.ast, 'route path')) : parent;
179
+ const file = componentFile(props.get('element') ?? props.get('Component') ?? props.get('lazy'), module, aliases);
180
+ if ((props.has('path') || index) && (props.has('element') || props.has('Component') || props.has('lazy')))
181
+ add(path, hash, file, module, index);
182
+ if (props.has('children')) {
183
+ const children = routeArray(props.get('children'), module, aliases);
184
+ objects(children.list, children.module, path, hash);
185
+ }
186
+ }
187
+ };
188
+ const elements = (node, module, parent, hash, routeName) => {
189
+ for (const child of node.children ?? []) {
190
+ if (child.type === 'JSXText' && child.value.trim() === '')
191
+ continue;
192
+ if (child.type !== 'JSXElement' || jsxName(child) !== routeName) {
193
+ if (child.type === 'JSXElement' || child.type === 'JSXFragment')
194
+ elements(child, module, parent, hash, routeName);
195
+ continue;
196
+ }
197
+ const attribute = (name) => {
198
+ const found = child.openingElement.attributes.find((item) => item.type === 'JSXAttribute' && item.name.name === name);
199
+ if (!found)
200
+ return undefined;
201
+ return found.value === null ? { type: 'BooleanLiteral', value: true } : found.value.type === 'JSXExpressionContainer' ? found.value.expression : found.value;
202
+ };
203
+ if (child.openingElement.attributes.some((item) => item.type === 'JSXSpreadAttribute'))
204
+ throw new Unreadable('a <Route> with spread props');
205
+ const index = attribute('index') !== undefined && literalValue(attribute('index'), module.ast) === true;
206
+ const pathNode = attribute('path');
207
+ const path = pathNode ? joinPath(parent, stringValue(pathNode, module.ast, '<Route> path')) : parent;
208
+ const target = attribute('element') ?? attribute('Component') ?? attribute('component') ?? attribute('lazy');
209
+ if ((pathNode || index) && target)
210
+ add(path, hash, componentFile(target, module, aliases), module, index);
211
+ elements(child, module, path, hash, routeName);
212
+ }
213
+ };
214
+ try {
215
+ for (const module of modules) {
216
+ const names = importsFrom(module.ast, RR_SOURCES);
217
+ if (names.size === 0)
218
+ continue;
219
+ walk(module.ast.program, node => {
220
+ if (node.type === 'JSXElement') {
221
+ const name = jsxName(node);
222
+ const isHash = name !== null && name === names.get('HashRouter');
223
+ const isBrowser = name !== null && name === names.get('BrowserRouter');
224
+ if (isHash)
225
+ hashComponent = true;
226
+ if (isBrowser)
227
+ browserComponent = true;
228
+ if (isHash || isBrowser)
229
+ componentBasenames.add(jsxBasename(node, module));
230
+ }
231
+ });
232
+ }
233
+ if (hashComponent && browserComponent)
234
+ return { manual: 'the app uses both BrowserRouter and HashRouter' };
235
+ if (componentBasenames.size > 1)
236
+ return { manual: `the app's router components use several basenames (${[...componentBasenames].join(', ')})` };
237
+ const componentBasename = [...componentBasenames][0] ?? '';
238
+ for (const module of modules) {
239
+ const names = importsFrom(module.ast, RR_SOURCES);
240
+ if (names.size === 0)
241
+ continue;
242
+ walk(module.ast.program, node => {
243
+ if (node.type === 'CallExpression' && node.callee.type === 'Identifier') {
244
+ const callee = node.callee.name;
245
+ const hash = callee === names.get('createHashRouter');
246
+ if (callee === names.get('createBrowserRouter') || hash || callee === names.get('useRoutes')) {
247
+ found = true;
248
+ basename = callee === names.get('useRoutes') ? componentBasename : optionsBasename(node.arguments[1], module);
249
+ const arg = unwrapTs(node.arguments[0]);
250
+ if (arg?.type === 'CallExpression' && arg.callee.type === 'Identifier' && arg.callee.name === names.get('createRoutesFromElements')) {
251
+ const root = unwrapTs(arg.arguments[0]);
252
+ if (root?.type !== 'JSXElement' && root?.type !== 'JSXFragment')
253
+ throw new Unreadable('createRoutesFromElements without JSX');
254
+ elements({ children: [root] }, module, '', hash, names.get('Route') ?? 'Route');
255
+ }
256
+ else {
257
+ const routesList = routeArray(node.arguments[0], module, aliases);
258
+ objects(routesList.list, routesList.module, '', callee === names.get('useRoutes') ? hashComponent : hash);
259
+ }
260
+ return false;
261
+ }
262
+ }
263
+ if (node.type === 'JSXElement' && names.has('Routes') && jsxName(node) === names.get('Routes')) {
264
+ found = true;
265
+ basename = componentBasename;
266
+ elements(node, module, '', hashComponent, names.get('Route') ?? 'Route');
267
+ return false;
268
+ }
269
+ return undefined;
270
+ });
271
+ }
272
+ }
273
+ catch (error) {
274
+ if (error instanceof Unreadable)
275
+ return { manual: `the React Router configuration has ${error.message}` };
276
+ throw error;
277
+ }
278
+ if (!found)
279
+ return { manual: `no React Router route definitions (createBrowserRouter, createHashRouter, useRoutes, <Routes>) were found under ${repoPath(ctx, srcDir)}` };
280
+ return routes;
281
+ }
282
+ /* ------------------------------------------------------------ Vue Router */
283
+ /** A Vue Router path (`/users/:id?`, `/:pathMatch(.*)*`, `/docs/:slug+`) as segments. A custom regex (`:id(\\d+)`) or text
284
+ * mixed with a parameter constrains more than the pattern syntax can say, so such a segment is inexact (route-pattern.ts
285
+ * ruleFor); `(.*)` with `*` or `+` is exactly a catch-all. */
286
+ export function vueSegments(path) {
287
+ const parts = [];
288
+ let depth = 0;
289
+ let current = '';
290
+ for (const char of path) {
291
+ if (char === '(')
292
+ depth += 1;
293
+ if (char === ')')
294
+ depth -= 1;
295
+ if (char === '/' && depth === 0) {
296
+ parts.push(current);
297
+ current = '';
298
+ continue;
299
+ }
300
+ current += char;
301
+ }
302
+ parts.push(current);
303
+ return parts.filter(part => part !== '').map((part) => {
304
+ const whole = /^:([A-Za-z0-9_]+)(\(.*\))?([?*+])?$/.exec(part);
305
+ if (whole) {
306
+ const [, name, regex, modifier] = whole;
307
+ const repeated = modifier === '*' || modifier === '+';
308
+ const constraint = regex !== undefined && !(repeated && regex === '(.*)') ? { inexact: `a parameter with a custom pattern (${part})` } : {};
309
+ if (modifier === '*')
310
+ return { kind: 'splat', name: paramName(name), optional: true, ...constraint };
311
+ if (modifier === '+')
312
+ return { kind: 'splat', name: paramName(name), optional: false, ...constraint };
313
+ return { kind: 'param', name: paramName(name), optional: modifier === '?', ...constraint };
314
+ }
315
+ if (part.includes(':'))
316
+ return { kind: 'param', name: paramName(part), optional: false, inexact: `a segment mixing text and a parameter (${part})` };
317
+ return { kind: 'literal', value: part };
318
+ });
319
+ }
320
+ export function vueRouterRoutes(ctx, srcDir, viteBase) {
321
+ const aliases = readPathAliases(ctx.appDir);
322
+ const routes = [];
323
+ let routers = 0;
324
+ const records = (list, module, parent, hash) => {
325
+ for (const element of list.elements) {
326
+ const record = element ? unwrapTs(element) : null;
327
+ if (!record || record.type !== 'ObjectExpression')
328
+ throw new Unreadable('a route record that is not an object literal');
329
+ const props = properties(record);
330
+ const own = stringValue(props.get('path'), module.ast, 'route path');
331
+ const path = own === '' ? parent : joinPath(parent === '' ? '/' : parent, own);
332
+ if (props.has('component') || props.has('components')) {
333
+ routes.push({ ...ruleFor(vueSegments(path), hash), rank: own === '' ? 0 : 1,
334
+ source: repoPath(ctx, componentFile(props.get('component'), module, aliases) ?? module.file) });
335
+ }
336
+ if (props.has('children')) {
337
+ const children = routeArray(props.get('children'), module, aliases);
338
+ records(children.list, children.module, path, hash);
339
+ }
340
+ }
341
+ };
342
+ try {
343
+ for (const module of sourceFiles(srcDir)) {
344
+ const names = importsFrom(module.ast, ['vue-router']);
345
+ const create = names.get('createRouter');
346
+ if (!create)
347
+ continue;
348
+ walk(module.ast.program, node => {
349
+ if (node.type !== 'CallExpression' || node.callee.type !== 'Identifier' || node.callee.name !== create)
350
+ return undefined;
351
+ routers += 1;
352
+ const options = unwrapTs(node.arguments[0]);
353
+ if (options?.type !== 'ObjectExpression')
354
+ throw new Unreadable('createRouter options that are not an object literal');
355
+ const props = properties(options);
356
+ const history = unwrapTs(props.get('history'));
357
+ if (history?.type !== 'CallExpression' || history.callee.type !== 'Identifier')
358
+ throw new Unreadable('a history init cannot read');
359
+ const kind = [...names.entries()].find(([, local]) => local === history.callee.name)?.[0];
360
+ if (kind !== 'createWebHistory' && kind !== 'createWebHashHistory')
361
+ throw new Unreadable(`a history (${history.callee.name}) that is not createWebHistory or createWebHashHistory`);
362
+ const baseArg = history.arguments[0];
363
+ if (baseArg) {
364
+ const isViteBase = baseArg.type === 'MemberExpression' && module.code.slice(baseArg.start, baseArg.end).replace(/\s/g, '') === 'import.meta.env.BASE_URL';
365
+ const base = isViteBase ? viteBase : literalValue(baseArg, module.ast);
366
+ if (base !== '/' && base !== '' && !(kind === 'createWebHashHistory'))
367
+ throw new Unreadable('a history base path the manifest cannot be published under');
368
+ }
369
+ const list = routeArray(props.get('routes'), module, aliases);
370
+ records(list.list, list.module, '', kind === 'createWebHashHistory');
371
+ return false;
372
+ });
373
+ }
374
+ }
375
+ catch (error) {
376
+ if (error instanceof Unreadable)
377
+ return { manual: `the Vue Router configuration has ${error.message}` };
378
+ throw error;
379
+ }
380
+ if (routers !== 1)
381
+ return { manual: `${routers} createRouter calls were found under ${repoPath(ctx, srcDir)}, not one` };
382
+ return routes;
383
+ }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Django 3–6 (CAPTURE-V1 rule 8b): the tag goes into the one base template init can enumerate (the only template under a
3
+ * templates/ directory with a <head>), referring to the file through {% static %} (with {% load static %} added when the
4
+ * template lacks it), and the file goes into that Django app's static/ directory, where the staticfiles app collects
5
+ * it. A base template outside an app directory (a STATICFILES_DIRS entry init cannot read) is a manual step.
6
+ *
7
+ * The route manifest is a manual step for Django: its URLconf is Python only Django can evaluate, and Django serves
8
+ * static files under STATIC_URL, never at /.well-known/ on the origin root, so publishing needs a URL pattern or a
9
+ * web-server rule init does not write. Until a manifest is published the tag does nothing (rule 13a: no manifest, no
10
+ * work).
11
+ */
12
+ import fg from 'fast-glob';
13
+ import { existsSync, readFileSync } from 'node:fs';
14
+ import { join, posix } from 'node:path';
15
+ import { scanHtml, withHeadTags } from '../html.js';
16
+ import { djangoVersion, repoPath, toPosix, appFile } from '../project.js';
17
+ import { djangoTags, STATIC_SUBDIR } from '../tag.js';
18
+ import { isFile } from './files.js';
19
+ const IGNORE = ['**/node_modules/**', '**/.git/**', '**/.venv/**', '**/venv/**', '**/env/**', '**/site-packages/**',
20
+ '**/staticfiles/**', '**/static/**', '**/build/**', '**/dist/**'];
21
+ const MANIFEST_STEP = 'Publish the route manifest: Django\'s URLconf is Python only Django can evaluate, and the manifest must be'
22
+ + ' served at /.well-known/haystack-capture/current.json (and <release>.json) on each production origin, outside STATIC_URL.'
23
+ + ' Until it is, the tag does nothing.';
24
+ function mentionsDjango(appDir) {
25
+ for (const file of ['requirements.txt', 'pyproject.toml', 'Pipfile', 'setup.py', 'setup.cfg', 'uv.lock', 'poetry.lock', 'requirements/base.txt']) {
26
+ const path = join(appDir, file);
27
+ if (existsSync(path) && /\bdjango\b/i.test(readFileSync(path, 'utf8')))
28
+ return true;
29
+ }
30
+ return false;
31
+ }
32
+ /** The template's active {% %} tags in order: those outside {# #} comments and {% comment %} … {% endcomment %} blocks
33
+ * (Django ignores tags inside them). */
34
+ function activeTags(text) {
35
+ const tags = [];
36
+ let commentDepth = 0;
37
+ for (let at = 0; at < text.length;) {
38
+ if (text.startsWith('{#', at)) {
39
+ const close = text.indexOf('#}', at + 2);
40
+ at = close === -1 ? text.length : close + 2;
41
+ continue;
42
+ }
43
+ if (!text.startsWith('{%', at)) {
44
+ at += 1;
45
+ continue;
46
+ }
47
+ const close = text.indexOf('%}', at + 2);
48
+ if (close === -1)
49
+ break;
50
+ const body = text.slice(at + 2, close).trim();
51
+ const name = body.split(/\s+/)[0];
52
+ if (name === 'comment')
53
+ commentDepth += 1;
54
+ else if (name === 'endcomment')
55
+ commentDepth = Math.max(0, commentDepth - 1);
56
+ else if (commentDepth === 0)
57
+ tags.push({ start: at, end: close + 2, body });
58
+ at = close + 2;
59
+ }
60
+ return tags;
61
+ }
62
+ const loadsStatic = (tag) => {
63
+ const words = tag.body.split(/\s+/);
64
+ return words[0] === 'load' && words.slice(1).includes('static');
65
+ };
66
+ /** The template with `static` loaded by an active {% load %} before init's first tag (at `tagAt`): as it is when one is
67
+ * there; else with `{% load static %}` added right after a leading {% extends %}, or at the top when the template extends
68
+ * nothing (Django requires {% extends %} to be its first tag). Anything else is a manual step (a TemplateSyntaxError
69
+ * would break every page that uses the template). */
70
+ function withStaticLoaded(text, tagAt) {
71
+ const tags = activeTags(text);
72
+ if (tags.some(tag => tag.start < tagAt && loadsStatic(tag)))
73
+ return text;
74
+ const extendsTags = tags.filter(tag => tag.body.split(/\s+/)[0] === 'extends');
75
+ if (extendsTags.length > 1 || (extendsTags.length === 1 && tags[0] !== extendsTags[0])) {
76
+ return { manual: 'its {% extends %} is not its first tag, so init cannot place {% load static %} where Django reads it' };
77
+ }
78
+ const at = extendsTags.length === 1 ? extendsTags[0].end : 0;
79
+ const separator = at === 0 ? '' : '\n';
80
+ const next = `${text.slice(0, at)}${separator}{% load static %}${at === 0 ? '\n' : ''}${text.slice(at)}`;
81
+ // Proven on the result: an active load of static precedes the first tag init placed.
82
+ const placed = next.indexOf(`{% static '${STATIC_SUBDIR}/`);
83
+ return activeTags(next).some(tag => tag.start < placed && loadsStatic(tag)) ? next
84
+ : { manual: 'init could not place {% load static %} before its tag' };
85
+ }
86
+ export async function detectDjango(ctx) {
87
+ if (!isFile(join(ctx.appDir, 'manage.py')) || !mentionsDjango(ctx.appDir))
88
+ return null;
89
+ const version = djangoVersion(ctx.appDir);
90
+ const blocked = [];
91
+ if (!version || version.major === null || version.major < 3 || version.major > 6) {
92
+ blocked.push(`init knows Django 3–6, and this app's version is ${version ? `${version.version} (${version.from})` : 'not pinned in a lockfile or requirements file'}`);
93
+ }
94
+ const templates = fg.sync('**/templates/**/*.html', { cwd: ctx.appDir, ignore: IGNORE, deep: 8, onlyFiles: true }).sort()
95
+ .filter(file => scanHtml(readFileSync(join(ctx.appDir, file), 'utf8')).heads.length > 0);
96
+ const base = templates.length === 1 ? templates[0] : null;
97
+ // <app>/templates/...: the app directory is a Python package (an app the staticfiles finder scans for static/).
98
+ const appRoot = base ? toPosix(base).split('/templates/')[0] : null;
99
+ const appIsPackage = appRoot !== null && appRoot !== toPosix(base) && appRoot !== ''
100
+ && isFile(join(ctx.appDir, appRoot, '__init__.py'));
101
+ const staticDir = appIsPackage ? posix.join(appRoot, 'static')
102
+ : { manual: base ? `${repoPath(ctx, join(ctx.appDir, base))} is not inside a Django app, so its static directory is a STATICFILES_DIRS entry init cannot read`
103
+ : `found ${templates.length} templates with a <head> under templates/ directories, not one` };
104
+ return {
105
+ id: 'django',
106
+ label: `Django ${version?.version ?? '(unknown version)'}`,
107
+ manifestAdapter: `django@${version?.version ?? 'unknown'}`,
108
+ blocked: blocked.length > 0 ? blocked.join('; ') : null,
109
+ // The routes come from the source, so whatever stops init placing the tag stops the manifest too.
110
+ manifestBlocked: blocked.length > 0 ? blocked.join('; ') : null,
111
+ fromBuild: false,
112
+ rewriting: null,
113
+ publishNote: null,
114
+ buildPublishDirs: () => [],
115
+ buildNotes: () => [],
116
+ looked: [appFile(ctx, '**/templates/**/*.html'), ...templates.map(file => repoPath(ctx, join(ctx.appDir, file)))],
117
+ staticDir,
118
+ async integrate(tags) {
119
+ if (typeof staticDir !== 'string' || !base)
120
+ return typeof staticDir === 'string' ? { manual: 'no base template' } : staticDir;
121
+ const file = join(ctx.appDir, base);
122
+ const edit = withHeadTags(readFileSync(file, 'utf8'), djangoTags(tags));
123
+ if ('manual' in edit)
124
+ return { manual: `${repoPath(ctx, file)}: ${edit.manual}` };
125
+ const loaded = withStaticLoaded(edit.content, edit.content.indexOf(`{% static '${STATIC_SUBDIR}/`));
126
+ return typeof loaded === 'string' ? [{ target: file, content: loaded }] : { manual: `${repoPath(ctx, file)}: ${loaded.manual}` };
127
+ },
128
+ manualTag: tags => ['In your base template, inside <head> (with {% load static %} at its top):', ...djangoTags(tags)],
129
+ build: { manual: MANIFEST_STEP },
130
+ routes: async () => ({ manual: MANIFEST_STEP }),
131
+ chunks: () => [],
132
+ publishDirs: { manual: MANIFEST_STEP },
133
+ };
134
+ }
@@ -0,0 +1,77 @@
1
+ /** Reading the app's files for adapters: source trees for file-system routes, build output for chunk ids, and the
2
+ * config file a framework reads. */
3
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { parseFile, readConfig } from '../js-ast.js';
6
+ /** Every file under `root`, as posix paths relative to it; skips node_modules and dot directories. */
7
+ export function filesUnder(root) {
8
+ const found = [];
9
+ const visit = (dir, prefix) => {
10
+ let entries;
11
+ try {
12
+ entries = readdirSync(dir, { withFileTypes: true });
13
+ }
14
+ catch {
15
+ return;
16
+ }
17
+ for (const entry of entries.sort((a, b) => (a.name < b.name ? -1 : 1))) {
18
+ if (entry.isDirectory()) {
19
+ if (entry.name === 'node_modules' || entry.name.startsWith('.'))
20
+ continue;
21
+ visit(join(dir, entry.name), `${prefix}${entry.name}/`);
22
+ }
23
+ else if (entry.isFile()) {
24
+ found.push(`${prefix}${entry.name}`);
25
+ }
26
+ }
27
+ };
28
+ visit(root, '');
29
+ return found;
30
+ }
31
+ export function isDirectory(path) {
32
+ try {
33
+ return statSync(path).isDirectory();
34
+ }
35
+ catch {
36
+ return false;
37
+ }
38
+ }
39
+ export function isFile(path) {
40
+ try {
41
+ return statSync(path).isFile();
42
+ }
43
+ catch {
44
+ return false;
45
+ }
46
+ }
47
+ /** Chunk ids (manifest.ts): the URL path of every .js file of a build output directory served at `urlPrefix`. */
48
+ export function chunkIds(dir, urlPrefix) {
49
+ if (!isDirectory(dir))
50
+ return [];
51
+ return filesUnder(dir).filter(file => file.endsWith('.js')).map(file => `${urlPrefix}${file}`);
52
+ }
53
+ /** The first of `names` that exists in `dir`. */
54
+ export function firstFile(dir, names) {
55
+ for (const name of names)
56
+ if (isFile(join(dir, name)))
57
+ return join(dir, name);
58
+ return null;
59
+ }
60
+ /** A framework config file and its AST; a file that does not parse is read as unreadable everywhere. */
61
+ export function configFile(dir, names) {
62
+ const file = firstFile(dir, names);
63
+ if (!file)
64
+ return { file: null, ast: null };
65
+ return { file, ast: parseFile(readFileSync(file, 'utf8'), file) };
66
+ }
67
+ /** A config value: absent when there is no config file. */
68
+ export function configValue(config, path) {
69
+ if (!config.file)
70
+ return { state: 'absent' };
71
+ if (!config.ast)
72
+ return { state: 'unreadable' };
73
+ return readConfig(config.ast, path);
74
+ }
75
+ export function exists(path) {
76
+ return existsSync(path);
77
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * The framework adapters in the order they claim an app (a framework built on Vite, like React Router or SvelteKit,
3
+ * before Vite itself), and the one rule for which directories are deployable apps (CAPTURE-V1 rule 8a: with several,
4
+ * the coding agent names one with --app; init never picks), used by every telemetry part.
5
+ */
6
+ import { spawnSync } from 'node:child_process';
7
+ import { existsSync, readFileSync } from 'node:fs';
8
+ import { join, posix } from 'node:path';
9
+ import { appContext } from '../project.js';
10
+ import { detectDjango } from './django.js';
11
+ import { detectNext } from './next.js';
12
+ import { detectNuxt } from './nuxt.js';
13
+ import { detectRails } from './rails.js';
14
+ import { detectReactRouter } from './react-router.js';
15
+ import { detectSvelteKit } from './sveltekit.js';
16
+ import { detectStaticHtml, detectVite } from './vite.js';
17
+ const DETECTORS = [
18
+ detectNext, detectReactRouter, detectSvelteKit, detectNuxt, detectVite, detectRails, detectDjango,
19
+ ];
20
+ /** The given adapter's view of this directory (what --adapter names), or null when that adapter does not recognize it. */
21
+ export async function detectWith(ctx, id) {
22
+ for (const detect of [...DETECTORS, detectStaticHtml]) {
23
+ const detected = await detect(ctx);
24
+ if (detected?.id === id)
25
+ return detected;
26
+ }
27
+ return null;
28
+ }
29
+ export const ADAPTER_IDS = ['next', 'vite', 'react-router', 'remix', 'sveltekit', 'nuxt', 'rails', 'django', 'static-html'];
30
+ /** The adapter that claims this directory, or null. A directory with no package manifest, Gemfile or manage.py and an
31
+ * index.html is a static site. */
32
+ export async function detectApp(ctx) {
33
+ for (const detect of DETECTORS) {
34
+ const detected = await detect(ctx);
35
+ if (detected)
36
+ return detected;
37
+ }
38
+ return ctx.pkg === null ? detectStaticHtml(ctx) : null;
39
+ }
40
+ const MARKERS = ['package.json', 'Gemfile', 'manage.py'];
41
+ function startScript(dir) {
42
+ const file = join(dir, 'package.json');
43
+ if (!existsSync(file))
44
+ return false;
45
+ try {
46
+ const scripts = JSON.parse(readFileSync(file, 'utf8')).scripts;
47
+ return typeof scripts?.start === 'string';
48
+ }
49
+ catch {
50
+ // A package.json that is not JSON (a fixture) is no installable package, so no app.
51
+ return false;
52
+ }
53
+ }
54
+ /** The repository's deployable apps, repository-relative ('.' for the root): every directory with a tracked package.json,
55
+ * Gemfile or manage.py (outside node_modules) that a framework adapter claims as a web app, or whose package has a start
56
+ * script (a Node server, for server telemetry); the root as a static site only when there is nothing else. Needs
57
+ * loadBabel() (adapters read framework configs). */
58
+ export async function discoverApps(gitRoot) {
59
+ const listed = spawnSync('git', ['ls-files', '-z', '--', ...MARKERS.flatMap(marker => [marker, `:(glob)**/${marker}`])], { cwd: gitRoot, encoding: 'utf8' });
60
+ if (listed.status !== 0)
61
+ throw new Error(`git ls-files failed in ${gitRoot}: ${listed.stderr.trim()}`);
62
+ const dirs = [...new Set(listed.stdout.split('\0').filter(path => path !== '' && !path.split('/').includes('node_modules'))
63
+ .map(path => posix.dirname(path)))].sort();
64
+ const apps = [];
65
+ for (const dir of dirs) {
66
+ const appDir = join(gitRoot, dir);
67
+ const detected = await detectApp(appContext(gitRoot, appDir));
68
+ if ((detected !== null && detected.id !== 'static-html') || startScript(appDir))
69
+ apps.push(dir);
70
+ }
71
+ if (apps.length === 0 && (await detectApp(appContext(gitRoot, gitRoot)))?.id === 'static-html')
72
+ apps.push('.');
73
+ return apps;
74
+ }