@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,199 @@
1
+ /**
2
+ * Nuxt 3–4 (CAPTURE-V1 rule 8b).
3
+ *
4
+ * Tag: an entry of nuxt.config's app.head.script ({ src, integrity, 'data-key', async: true }), which Nuxt renders into
5
+ * every page's <head>; init creates app, head or script where missing and leaves a config it cannot read to a manual
6
+ * step. Static output: public/ (Nuxt 4: at the root; Nuxt 3: under srcDir). Routes: the pages directory (index for the
7
+ * parent's path, [id], [[id]], [...slug], (group) directories); an app without pages/ is one page, `/`. Nitro presets
8
+ * reshape the output inside `nuxt build`, so the build step publishes the manifest into public/ BEFORE the build,
9
+ * without chunks. app.baseURL other than / is a manual step.
10
+ */
11
+ import { readFileSync } from 'node:fs';
12
+ import { join, posix } from 'node:path';
13
+ import { applyEdits, configObject, indentUnit, keyName, lineIndent, parseFile, startsLine, unwrapTs } from '../js-ast.js';
14
+ import { repoPath, resolveVersion, appFile } from '../project.js';
15
+ import { paramName, ruleFor } from '../route-pattern.js';
16
+ import { nuxtScriptEntries, ourScriptKind } from '../tag.js';
17
+ import { configFile, configValue, filesUnder, isDirectory, isFile } from './files.js';
18
+ const PAGE_EXTENSIONS = ['.vue', '.tsx', '.jsx', '.ts', '.js', '.mjs'];
19
+ export function nuxtSegment(name) {
20
+ if (/^\(.+\)$/.test(name))
21
+ return null;
22
+ const optional = /^\[\[([^\]]+)\]\]$/.exec(name);
23
+ if (optional)
24
+ return { kind: 'param', name: paramName(optional[1]), optional: true };
25
+ const rest = /^\[\.\.\.([^\]]+)\]$/.exec(name);
26
+ if (rest)
27
+ return { kind: 'splat', name: paramName(rest[1]), optional: true };
28
+ const param = /^\[([^\]]+)\]$/.exec(name);
29
+ if (param)
30
+ return { kind: 'param', name: paramName(param[1]), optional: false };
31
+ if (name.includes('['))
32
+ return { kind: 'param', name: paramName(name), optional: false, inexact: `a segment mixing text and a parameter (${name})` };
33
+ return { kind: 'literal', value: name };
34
+ }
35
+ function property(object, name) {
36
+ return object.properties.find((candidate) => candidate.type === 'ObjectProperty' && keyName(candidate) === name) ?? null;
37
+ }
38
+ /** Text that adds `text` as the first property of `object`. */
39
+ function firstProperty(code, object, text, unit) {
40
+ if (object.properties.length === 0) {
41
+ const indent = lineIndent(code, object.start);
42
+ return { start: object.start, end: object.end, text: `{\n${indent}${unit}${text},\n${indent}}` };
43
+ }
44
+ const first = object.properties[0];
45
+ if (startsLine(code, first.start)) {
46
+ const lineStart = code.lastIndexOf('\n', first.start - 1) + 1;
47
+ return { start: lineStart, end: lineStart, text: `${lineIndent(code, first.start)}${text},\n` };
48
+ }
49
+ return { start: first.start, end: first.start, text: `${text}, ` };
50
+ }
51
+ function ourEntries(array) {
52
+ return array.elements.filter((element) => {
53
+ if (element?.type !== 'ObjectExpression')
54
+ return false;
55
+ const src = property(element, 'src')?.value;
56
+ return src?.type === 'StringLiteral' && ourScriptKind(src.value) !== null;
57
+ });
58
+ }
59
+ function configEdit(file, tags) {
60
+ const code = readFileSync(file, 'utf8');
61
+ const ast = parseFile(code, file);
62
+ if (!ast)
63
+ return { manual: `${file} does not parse` };
64
+ const config = configObject(ast);
65
+ if (!config)
66
+ return { manual: 'nuxt.config does not export a config object init can read' };
67
+ const unit = indentUnit(code);
68
+ const entries = nuxtScriptEntries(tags);
69
+ const array = `[${entries.join(', ')}]`;
70
+ let edits;
71
+ const app = property(config, 'app');
72
+ const head = app && unwrapTs(app.value).type === 'ObjectExpression' ? property(unwrapTs(app.value), 'head') : null;
73
+ const script = head && unwrapTs(head.value).type === 'ObjectExpression' ? property(unwrapTs(head.value), 'script') : null;
74
+ if (!app) {
75
+ edits = [firstProperty(code, config, `app: { head: { script: ${array} } }`, unit)];
76
+ }
77
+ else if (unwrapTs(app.value).type !== 'ObjectExpression') {
78
+ return { manual: 'nuxt.config\'s app is not an object literal' };
79
+ }
80
+ else if (!head) {
81
+ edits = [firstProperty(code, unwrapTs(app.value), `head: { script: ${array} }`, unit)];
82
+ }
83
+ else if (unwrapTs(head.value).type !== 'ObjectExpression') {
84
+ return { manual: 'nuxt.config\'s app.head is not an object literal' };
85
+ }
86
+ else if (!script) {
87
+ edits = [firstProperty(code, unwrapTs(head.value), `script: ${array}`, unit)];
88
+ }
89
+ else {
90
+ const list = unwrapTs(script.value);
91
+ if (list.type !== 'ArrayExpression')
92
+ return { manual: 'nuxt.config\'s app.head.script is not an array literal' };
93
+ const ours = ourEntries(list);
94
+ const current = ours.map(node => code.slice(node.start, node.end));
95
+ if (current.length === entries.length && current.every((text, index) => text === entries[index]))
96
+ return [{ target: file, content: code }];
97
+ if (ours.length > 0) {
98
+ edits = [{ start: ours[0].start, end: ours[0].end, text: entries.join(', ') },
99
+ ...ours.slice(1).map(node => {
100
+ const after = code.slice(node.end).match(/^\s*,/);
101
+ return { start: node.start, end: node.end + (after ? after[0].length : 0), text: '' };
102
+ })];
103
+ }
104
+ else {
105
+ const last = list.elements[list.elements.length - 1];
106
+ if (!last) {
107
+ edits = [{ start: list.start, end: list.end, text: array }];
108
+ }
109
+ else {
110
+ const between = code.slice(last.end, list.end - 1);
111
+ const comma = between.indexOf(',');
112
+ const separator = startsLine(code, last.start) ? `\n${lineIndent(code, last.start)}` : ' ';
113
+ edits = comma === -1
114
+ ? [{ start: last.end, end: last.end, text: entries.map(entry => `,${separator}${entry}`).join('') }]
115
+ : [{ start: last.end + comma + 1, end: last.end + comma + 1, text: entries.map(entry => `${separator}${entry},`).join('') }];
116
+ }
117
+ }
118
+ }
119
+ const next = applyEdits(code, edits);
120
+ const check = parseFile(next, file);
121
+ const checkConfig = check ? configObject(check) : null;
122
+ const checkApp = checkConfig ? property(checkConfig, 'app') : null;
123
+ const checkHead = checkApp ? property(unwrapTs(checkApp.value), 'head') : null;
124
+ const checkScript = checkHead ? property(unwrapTs(checkHead.value), 'script') : null;
125
+ const placed = checkScript ? ourEntries(unwrapTs(checkScript.value)).map(node => next.slice(node.start, node.end)) : [];
126
+ if (placed.length !== entries.length || placed.some((text, index) => text !== entries[index])) {
127
+ return { manual: 'the edit to nuxt.config did not leave exactly one tag' };
128
+ }
129
+ return [{ target: file, content: next }];
130
+ }
131
+ export async function detectNuxt(ctx) {
132
+ const version = resolveVersion(ctx, 'nuxt');
133
+ if (!version)
134
+ return null;
135
+ const config = configFile(ctx.appDir, ['nuxt.config.ts', 'nuxt.config.js', 'nuxt.config.mjs', 'nuxt.config.mts']);
136
+ const where = config.file ? repoPath(ctx, config.file) : appFile(ctx, 'nuxt.config.ts');
137
+ const blocked = [];
138
+ const compatibility = configValue(config, ['future', 'compatibilityVersion']);
139
+ const four = version.major === 4 || (compatibility.state === 'value' && compatibility.value === 4);
140
+ if (version.major !== 3 && version.major !== 4)
141
+ blocked.push(`init knows Nuxt 3–4, and this app's version is ${version.version} (${version.from})`);
142
+ const read = (path, fallback) => {
143
+ const value = configValue(config, path);
144
+ if (value.state === 'unreadable' || (value.state === 'value' && typeof value.value !== 'string')) {
145
+ blocked.push(`${where} sets ${path.join('.')} in a way init cannot read`);
146
+ return fallback;
147
+ }
148
+ return value.state === 'value' ? value.value : fallback;
149
+ };
150
+ const srcDir = read(['srcDir'], four ? 'app' : '.');
151
+ const pagesDir = read(['dir', 'pages'], 'pages');
152
+ const publicDir = posix.join(four ? '.' : srcDir, read(['dir', 'public'], 'public'));
153
+ const baseURL = read(['app', 'baseURL'], '/');
154
+ if (baseURL !== '/')
155
+ blocked.push(`${where} sets app.baseURL ${baseURL}: the route manifest must be published at the origin root, outside it`);
156
+ const pages = join(ctx.appDir, srcDir, pagesDir);
157
+ const scripts = ctx.pkg?.scripts ?? {};
158
+ const buildScript = typeof scripts.build === 'string' ? 'build' : typeof scripts.generate === 'string' ? 'generate' : null;
159
+ return {
160
+ id: 'nuxt',
161
+ label: `Nuxt ${version.version}`,
162
+ manifestAdapter: `nuxt@${version.version}`,
163
+ blocked: blocked.length > 0 ? blocked.join('; ') : null,
164
+ // The routes come from the source, so whatever stops init placing the tag stops the manifest too.
165
+ manifestBlocked: blocked.length > 0 ? blocked.join('; ') : null,
166
+ fromBuild: false,
167
+ rewriting: null,
168
+ publishNote: null,
169
+ buildPublishDirs: () => [],
170
+ buildNotes: () => [],
171
+ looked: [where, repoPath(ctx, pages)],
172
+ staticDir: publicDir,
173
+ integrate: async (tags) => (config.file ? configEdit(config.file, tags) : { manual: `${where} is missing` }),
174
+ manualTag: tags => [`In ${where}, under app.head.script:`, ...nuxtScriptEntries(tags)],
175
+ build: buildScript ? { kind: 'package-script', script: buildScript, phase: 'before' }
176
+ : { manual: `${appFile(ctx, 'package.json')} has no build or generate script to publish the route manifest before` },
177
+ async routes() {
178
+ if (!isDirectory(pages)) {
179
+ const app = ['app.vue'].map(name => join(ctx.appDir, srcDir, name)).find(isFile);
180
+ return [{ match: '/', source: repoPath(ctx, app ?? config.file) }];
181
+ }
182
+ const routes = [];
183
+ for (const file of filesUnder(pages)) {
184
+ const extension = PAGE_EXTENSIONS.find(candidate => file.endsWith(candidate));
185
+ if (!extension)
186
+ continue;
187
+ const parts = file.slice(0, -extension.length).split('/');
188
+ const index = parts[parts.length - 1] === 'index';
189
+ if (index)
190
+ parts.pop();
191
+ const segments = parts.map(nuxtSegment).filter((segment) => segment !== null);
192
+ routes.push({ ...ruleFor(segments), source: repoPath(ctx, join(pages, file)), rank: index ? 0 : 1 });
193
+ }
194
+ return routes;
195
+ },
196
+ chunks: () => [],
197
+ publishDirs: [publicDir],
198
+ };
199
+ }
@@ -0,0 +1,178 @@
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
+ // The routes come from the source, so whatever stops init placing the tag stops the manifest too.
136
+ manifestBlocked: blocked.length > 0 ? blocked.join('; ') : null,
137
+ fromBuild: false,
138
+ rewriting: null,
139
+ publishNote: null,
140
+ buildPublishDirs: () => [],
141
+ buildNotes: () => [],
142
+ looked: [layoutsPath, repoPath(ctx, join(ctx.appDir, 'Gemfile.lock'))],
143
+ staticDir: 'public',
144
+ async integrate(tags) {
145
+ if (found.erb.length !== 1) {
146
+ const other = found.other.length > 0 ? ` (and ${found.other.map(file => repoPath(ctx, file)).join(', ')}, which init does not edit)` : '';
147
+ return { manual: `${layoutsPath} has ${found.erb.length} ERB page layouts with a <head>${other}, not one` };
148
+ }
149
+ const file = found.erb[0];
150
+ const edit = withHeadTags(readFileSync(file, 'utf8'), htmlTags(tags));
151
+ return 'manual' in edit ? { manual: `${repoPath(ctx, file)}: ${edit.manual}` } : [{ target: file, content: edit.content }];
152
+ },
153
+ manualTag: tags => [`In your page layout (${layoutsPath}/application.html.erb), inside <head>:`, ...htmlTags(tags)],
154
+ build: { kind: 'rake' },
155
+ async routes(inputs) {
156
+ if (!inputs.railsRoutes || !existsSync(inputs.railsRoutes)) {
157
+ return { manual: 'Rails routes come from the rake task after assets:precompile (--rails-routes); run the app\'s build' };
158
+ }
159
+ const listed = JSON.parse(readFileSync(inputs.railsRoutes, 'utf8'));
160
+ const routes = [];
161
+ for (const route of listed) {
162
+ if (typeof route.path !== 'string' || typeof route.controller !== 'string' || !/^[a-z0-9_/]+$/.test(route.controller))
163
+ continue;
164
+ const segments = railsSegments(route.path);
165
+ // A controller outside app/controllers belongs to a gem or engine (Active Storage, Devise): its pages are not this
166
+ // repository's files, so they stay `unknown` rather than being joined to a file that did not define them.
167
+ const controller = join(ctx.appDir, 'app', 'controllers', `${route.controller}_controller.rb`);
168
+ if (!existsSync(controller))
169
+ continue;
170
+ routes.push(segments ? { ...ruleFor(segments), source: repoPath(ctx, controller) }
171
+ : { match: '/', source: repoPath(ctx, controller), inexact: `a path spec the pattern syntax cannot express (${route.path})` });
172
+ }
173
+ return routes;
174
+ },
175
+ chunks: () => [],
176
+ publishDirs: ['public'],
177
+ };
178
+ }