@remix-run/cli 0.2.0 → 0.3.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 (87) hide show
  1. package/dist/lib/bootstrap-project.d.ts.map +1 -1
  2. package/dist/lib/bootstrap-project.js +32 -9
  3. package/dist/lib/cli.d.ts +29 -0
  4. package/dist/lib/cli.d.ts.map +1 -1
  5. package/dist/lib/cli.js +26 -4
  6. package/dist/lib/commands/completion.d.ts.map +1 -1
  7. package/dist/lib/commands/completion.js +5 -1
  8. package/dist/lib/commands/doctor.js +18 -18
  9. package/dist/lib/commands/routes.js +3 -3
  10. package/dist/lib/completion.js +1 -5
  11. package/dist/lib/controller-files.d.ts +0 -1
  12. package/dist/lib/controller-files.d.ts.map +1 -1
  13. package/dist/lib/controller-files.js +3 -5
  14. package/dist/lib/controller-ownership.d.ts +9 -9
  15. package/dist/lib/controller-ownership.d.ts.map +1 -1
  16. package/dist/lib/controller-ownership.js +56 -91
  17. package/dist/lib/doctor/controller-findings.d.ts +1 -1
  18. package/dist/lib/doctor/controller-findings.d.ts.map +1 -1
  19. package/dist/lib/doctor/controller-findings.js +15 -87
  20. package/dist/lib/doctor/controller-fix-plans.d.ts.map +1 -1
  21. package/dist/lib/doctor/controller-fix-plans.js +13 -24
  22. package/dist/lib/doctor/controller-placeholders.d.ts +2 -3
  23. package/dist/lib/doctor/controller-placeholders.d.ts.map +1 -1
  24. package/dist/lib/doctor/controller-placeholders.js +18 -149
  25. package/dist/lib/doctor/controllers.js +1 -1
  26. package/dist/lib/doctor/project.js +60 -52
  27. package/dist/lib/doctor/types.d.ts +2 -2
  28. package/dist/lib/doctor/types.d.ts.map +1 -1
  29. package/dist/lib/help-text.js +1 -1
  30. package/dist/lib/load-route-map-worker.js +17 -9
  31. package/dist/lib/route-map.d.ts +1 -1
  32. package/dist/lib/route-map.d.ts.map +1 -1
  33. package/dist/lib/route-map.js +29 -17
  34. package/package.json +5 -5
  35. package/src/lib/bootstrap-project.ts +39 -13
  36. package/src/lib/cli.ts +39 -4
  37. package/src/lib/commands/completion.ts +6 -1
  38. package/src/lib/commands/doctor.ts +18 -21
  39. package/src/lib/commands/routes.ts +3 -3
  40. package/src/lib/completion.ts +1 -5
  41. package/src/lib/controller-files.ts +4 -8
  42. package/src/lib/controller-ownership.ts +78 -141
  43. package/src/lib/doctor/controller-findings.ts +20 -97
  44. package/src/lib/doctor/controller-fix-plans.ts +13 -29
  45. package/src/lib/doctor/controller-placeholders.ts +17 -189
  46. package/src/lib/doctor/controllers.ts +1 -1
  47. package/src/lib/doctor/project.ts +60 -52
  48. package/src/lib/doctor/types.ts +1 -5
  49. package/src/lib/help-text.ts +3 -2
  50. package/src/lib/load-route-map-worker.ts +19 -10
  51. package/src/lib/route-map.ts +61 -16
  52. package/{bootstrap → template}/.agents/skills/remix/SKILL.md +162 -77
  53. package/{bootstrap → template}/.agents/skills/remix/references/assets-and-browser-modules.md +22 -14
  54. package/{bootstrap → template}/.agents/skills/remix/references/auth-and-sessions.md +41 -18
  55. package/{bootstrap → template}/.agents/skills/remix/references/component-model.md +4 -1
  56. package/{bootstrap → template}/.agents/skills/remix/references/data-and-validation.md +21 -5
  57. package/{bootstrap → template}/.agents/skills/remix/references/middleware-and-server.md +42 -53
  58. package/{bootstrap → template}/.agents/skills/remix/references/routing-and-controllers.md +111 -44
  59. package/{bootstrap → template}/.agents/skills/remix/references/testing-patterns.md +17 -1
  60. package/{bootstrap → template}/AGENTS.md +10 -9
  61. package/template/README.md +29 -0
  62. package/template/app/actions/controller.tsx +18 -0
  63. package/template/app/assets/entry.ts +8 -0
  64. package/{bootstrap/app/ui → template/app/assets}/prompt-button.tsx +2 -1
  65. package/{bootstrap → template}/app/assets.ts +5 -3
  66. package/template/app/middleware/render.tsx +44 -0
  67. package/template/app/router.ts +20 -0
  68. package/{bootstrap → template}/app/routes.ts +1 -2
  69. package/{bootstrap → template}/app/ui/document.tsx +11 -4
  70. package/{bootstrap → template}/app/ui/scaffold-home-page.tsx +39 -38
  71. package/template/gitignore +4 -0
  72. package/{bootstrap → template}/package.json +5 -6
  73. package/template/public/favicon.svg +11 -0
  74. package/template/server.ts +38 -0
  75. package/{bootstrap → template}/tsconfig.json +3 -2
  76. package/bootstrap/README.md +0 -27
  77. package/bootstrap/app/assets/entry.ts +0 -19
  78. package/bootstrap/app/controllers/auth.tsx +0 -21
  79. package/bootstrap/app/controllers/home.tsx +0 -11
  80. package/bootstrap/app/router.ts +0 -16
  81. package/bootstrap/app/ui/layout.tsx +0 -22
  82. package/bootstrap/app/utils/render.tsx +0 -26
  83. package/bootstrap/server.ts +0 -37
  84. /package/{bootstrap → template}/.agents/skills/remix/references/animate-elements.md +0 -0
  85. /package/{bootstrap → template}/.agents/skills/remix/references/create-mixins.md +0 -0
  86. /package/{bootstrap → template}/.agents/skills/remix/references/hydration-frames-navigation.md +0 -0
  87. /package/{bootstrap → template}/.agents/skills/remix/references/mixins-styling-events.md +0 -0
@@ -1,108 +1,19 @@
1
1
  import * as path from 'node:path'
2
2
 
3
3
  import { getOwnerFileExtension } from '../controller-files.ts'
4
- import type { OwnershipRouteNode } from '../controller-ownership.ts'
5
-
6
- const RESERVED_BINDING_IDENTIFIERS = new Set([
7
- 'await',
8
- 'break',
9
- 'case',
10
- 'catch',
11
- 'class',
12
- 'const',
13
- 'continue',
14
- 'debugger',
15
- 'default',
16
- 'delete',
17
- 'do',
18
- 'else',
19
- 'enum',
20
- 'export',
21
- 'extends',
22
- 'false',
23
- 'finally',
24
- 'for',
25
- 'function',
26
- 'if',
27
- 'implements',
28
- 'import',
29
- 'in',
30
- 'instanceof',
31
- 'interface',
32
- 'let',
33
- 'new',
34
- 'null',
35
- 'package',
36
- 'private',
37
- 'protected',
38
- 'public',
39
- 'return',
40
- 'static',
41
- 'super',
42
- 'switch',
43
- 'this',
44
- 'throw',
45
- 'true',
46
- 'try',
47
- 'typeof',
48
- 'var',
49
- 'void',
50
- 'while',
51
- 'with',
52
- 'yield',
53
- ])
54
-
55
- export function renderActionPlaceholder(routeNode: OwnershipRouteNode, entryPath: string): string {
56
- let routeMessage = `TODO: implement routes.${routeNode.name}`
57
- let extension = getOwnerFileExtension(entryPath)
58
- let exportName = getActionExportName(routeNode.key)
59
-
60
- if (extension === '.js' || extension === '.jsx') {
61
- return [
62
- `export const ${exportName} = {`,
63
- ` handler() {`,
64
- ` return new Response(${JSON.stringify(routeMessage)})`,
65
- ` },`,
66
- `}`,
67
- '',
68
- ].join('\n')
69
- }
70
-
71
- let routeExpression = getRouteAccessExpression(routeNode.name)
72
- let routesImportPath = getRelativeImportPath(entryPath, 'app/routes.ts')
73
- let method = routeNode.method ?? 'ANY'
74
-
75
- return [
76
- `import type { BuildAction } from 'remix/fetch-router'`,
77
- '',
78
- `import type { routes } from '${routesImportPath}'`,
79
- '',
80
- `export const ${exportName}: BuildAction<'${method}', typeof ${routeExpression}> = {`,
81
- ` handler() {`,
82
- ` return new Response(${JSON.stringify(routeMessage)})`,
83
- ` },`,
84
- `}`,
85
- '',
86
- ].join('\n')
87
- }
4
+ import { ROOT_ROUTE_NAME, type OwnershipRouteNode } from '../controller-ownership.ts'
88
5
 
89
6
  export function renderControllerPlaceholder(
90
7
  routeNode: OwnershipRouteNode,
91
8
  entryPath: string,
92
- resolvedEntryPathByRouteName: Map<string, string>,
93
9
  ): string {
94
10
  let extension = getOwnerFileExtension(entryPath)
95
11
  let lines: string[] = []
96
- let childImports = getControllerChildImports(routeNode, entryPath, resolvedEntryPathByRouteName)
97
- let actionEntries = routeNode.children.map((childNode) =>
98
- renderControllerActionEntry(childNode, entryPath, resolvedEntryPathByRouteName),
99
- )
12
+ let actionEntries = routeNode.children
13
+ .filter((childNode) => childNode.kind === 'route')
14
+ .map((childNode) => renderControllerActionEntry(childNode))
100
15
 
101
16
  if (extension === '.js' || extension === '.jsx') {
102
- if (childImports.length > 0) {
103
- lines.push(...childImports, '')
104
- }
105
-
106
17
  lines.push('export default {', ' actions: {')
107
18
  lines.push(...actionEntries)
108
19
  lines.push(' },', '}', '')
@@ -112,85 +23,29 @@ export function renderControllerPlaceholder(
112
23
  let routeExpression = getRouteAccessExpression(routeNode.name)
113
24
  let routesImportPath = getRelativeImportPath(entryPath, 'app/routes.ts')
114
25
 
115
- lines.push(`import type { Controller } from 'remix/fetch-router'`, '')
116
- lines.push(`import type { routes } from '${routesImportPath}'`)
117
-
118
- if (childImports.length > 0) {
119
- lines.push('', ...childImports)
120
- }
26
+ lines.push(`import { createController } from 'remix/router'`, '')
27
+ lines.push(`import { routes } from '${routesImportPath}'`)
121
28
 
122
- lines.push('', 'export default {', ' actions: {')
29
+ lines.push('', `export default createController(${routeExpression}, {`, ' actions: {')
123
30
  lines.push(...actionEntries)
124
- lines.push(' },', `} satisfies Controller<typeof ${routeExpression}>`, '')
31
+ lines.push(' },', '})', '')
125
32
 
126
33
  return lines.join('\n')
127
34
  }
128
35
 
129
- function getControllerChildImports(
130
- routeNode: OwnershipRouteNode,
131
- entryPath: string,
132
- resolvedEntryPathByRouteName: Map<string, string>,
133
- ): string[] {
134
- let imports: string[] = []
135
-
136
- for (let childNode of routeNode.children) {
137
- if (childNode.kind !== 'group') {
138
- continue
139
- }
140
-
141
- let childEntryPath = resolvedEntryPathByRouteName.get(childNode.name)
142
- if (childEntryPath == null) {
143
- continue
144
- }
145
-
146
- imports.push(
147
- `import ${getControllerImportName(childNode.key)} from '${getRelativeImportPath(entryPath, childEntryPath)}'`,
148
- )
149
- }
150
-
151
- return imports
36
+ function renderControllerActionEntry(childNode: OwnershipRouteNode): string {
37
+ return [
38
+ ` ${formatObjectKey(childNode.key)}() {`,
39
+ ` return new Response(${JSON.stringify(`TODO: implement routes.${childNode.name}`)})`,
40
+ ` },`,
41
+ ].join('\n')
152
42
  }
153
43
 
154
- function renderControllerActionEntry(
155
- childNode: OwnershipRouteNode,
156
- entryPath: string,
157
- resolvedEntryPathByRouteName: Map<string, string>,
158
- ): string {
159
- if (childNode.kind === 'route') {
160
- return [
161
- ` ${formatObjectKey(childNode.key)}() {`,
162
- ` return new Response(${JSON.stringify(`TODO: implement routes.${childNode.name}`)})`,
163
- ` },`,
164
- ].join('\n')
165
- }
166
-
167
- let childEntryPath = resolvedEntryPathByRouteName.get(childNode.name)
168
- if (childEntryPath != null) {
169
- return ` ${formatObjectKey(childNode.key)}: ${getControllerImportName(childNode.key)},`
44
+ function getRouteAccessExpression(routeName: string): string {
45
+ if (routeName === ROOT_ROUTE_NAME) {
46
+ return 'routes'
170
47
  }
171
48
 
172
- return ` ${formatObjectKey(childNode.key)}: ${renderInlineControllerPlaceholder(childNode)},`
173
- }
174
-
175
- function renderInlineControllerPlaceholder(routeNode: OwnershipRouteNode): string {
176
- let childEntries = routeNode.children.map((childNode) =>
177
- childNode.kind === 'route'
178
- ? `${formatObjectKey(childNode.key)}() { return new Response(${JSON.stringify(`TODO: implement routes.${childNode.name}`)}) }`
179
- : `${formatObjectKey(childNode.key)}: ${renderInlineControllerPlaceholder(childNode)}`,
180
- )
181
-
182
- return `{ actions: { ${childEntries.join(', ')} } }`
183
- }
184
-
185
- function getControllerImportName(key: string): string {
186
- return `${toIdentifier(key)}Controller`
187
- }
188
-
189
- function getActionExportName(key: string): string {
190
- return toIdentifier(key)
191
- }
192
-
193
- function getRouteAccessExpression(routeName: string): string {
194
49
  let segments = routeName.split('.')
195
50
  let expression = 'routes'
196
51
 
@@ -210,33 +65,6 @@ function formatObjectKey(key: string): string {
210
65
  return isIdentifier(key) ? key : JSON.stringify(key)
211
66
  }
212
67
 
213
- function toIdentifier(value: string): string {
214
- let parts = value.split(/[^A-Za-z0-9_$]+/).filter(Boolean)
215
- if (parts.length === 0) {
216
- return 'route'
217
- }
218
-
219
- let identifier = parts
220
- .map((part, index) =>
221
- index === 0 ? part.replace(/^./, (char) => char.toLowerCase()) : capitalize(part),
222
- )
223
- .join('')
224
-
225
- if (!/^[A-Za-z_$]/.test(identifier)) {
226
- return `route${capitalize(identifier)}`
227
- }
228
-
229
- if (RESERVED_BINDING_IDENTIFIERS.has(identifier)) {
230
- return `route${capitalize(identifier)}`
231
- }
232
-
233
- return identifier
234
- }
235
-
236
- function capitalize(value: string): string {
237
- return value.length === 0 ? value : `${value[0].toUpperCase()}${value.slice(1)}`
238
- }
239
-
240
68
  function isIdentifier(value: string): boolean {
241
69
  return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(value)
242
70
  }
@@ -18,6 +18,6 @@ export async function checkControllerConventions(
18
18
 
19
19
  return {
20
20
  fixPlans,
21
- suite: createDoctorSuite('controllers', findings),
21
+ suite: createDoctorSuite('actions', findings),
22
22
  }
23
23
  }
@@ -62,7 +62,7 @@ export async function checkProject(projectRoot: string): Promise<ProjectDoctorRe
62
62
  export async function getProjectFixPlans(projectRoot: string): Promise<DoctorFixPlan[]> {
63
63
  let routesFile = path.join(projectRoot, 'app', 'routes.ts')
64
64
  let homeActionPath = normalizeRelativePath(
65
- path.join('app', 'controllers', `home${await inferHomeOwnerExtension(projectRoot)}`),
65
+ path.join('app', 'actions', `controller${await inferHomeOwnerExtension(projectRoot)}`),
66
66
  )
67
67
 
68
68
  if (await pathExists(routesFile)) {
@@ -225,8 +225,8 @@ function hasOnlyWhitespaceAndComments(source: string): boolean {
225
225
  }
226
226
 
227
227
  async function inferHomeOwnerExtension(projectRoot: string): Promise<OwnerFileExtension> {
228
- let controllersDir = path.join(projectRoot, 'app', 'controllers')
229
- let extensions = await collectOwnerExtensions(controllersDir)
228
+ let actionsDir = path.join(projectRoot, 'app', 'actions')
229
+ let extensions = await collectOwnerExtensions(actionsDir)
230
230
 
231
231
  if (extensions.length > 0) {
232
232
  return getMostCommonExtension(extensions)
@@ -298,7 +298,7 @@ function getMostCommonExtension(extensions: OwnerFileExtension[]): OwnerFileExte
298
298
 
299
299
  function renderDefaultRoutesFile(): string {
300
300
  return [
301
- `import { route } from 'remix/fetch-router/routes'`,
301
+ `import { route } from 'remix/routes'`,
302
302
  '',
303
303
  'export const routes = route({',
304
304
  ` home: '/',`,
@@ -318,22 +318,24 @@ function renderDefaultHomeAction(entryPath: string): string {
318
318
  `import { html } from 'remix/html-template'`,
319
319
  `import { createHtmlResponse } from 'remix/response/html'`,
320
320
  '',
321
- 'export const home = {',
322
- ' handler() {',
323
- ' let page = html`',
324
- ' <html lang="en">',
325
- ' <head>',
326
- ' <meta charset="utf-8" />',
327
- ' <meta name="viewport" content="width=device-width, initial-scale=1" />',
328
- ' <title>Home</title>',
329
- ' </head>',
330
- ' <body>',
331
- ' <h1>Home</h1>',
332
- ' <p>Update app/routes.ts and app/controllers/home to keep building your app.</p>',
333
- ' </body>',
334
- ' </html>',
335
- ' `',
336
- ' return createHtmlResponse(page)',
321
+ 'export default {',
322
+ ' actions: {',
323
+ ' home() {',
324
+ ' let page = html`',
325
+ ' <html lang="en">',
326
+ ' <head>',
327
+ ' <meta charset="utf-8" />',
328
+ ' <meta name="viewport" content="width=device-width, initial-scale=1" />',
329
+ ' <title>Home</title>',
330
+ ' </head>',
331
+ ' <body>',
332
+ ' <h1>Home</h1>',
333
+ ' <p>Update app/routes.ts and app/actions/controller to keep building your app.</p>',
334
+ ' </body>',
335
+ ' </html>',
336
+ ' `',
337
+ ' return createHtmlResponse(page)',
338
+ ' },',
337
339
  ' },',
338
340
  '}',
339
341
  '',
@@ -342,30 +344,32 @@ function renderDefaultHomeAction(entryPath: string): string {
342
344
 
343
345
  if (extension === '.ts') {
344
346
  return [
345
- `import type { BuildAction } from 'remix/fetch-router'`,
347
+ `import { createController } from 'remix/router'`,
346
348
  `import { html } from 'remix/html-template'`,
347
349
  `import { createHtmlResponse } from 'remix/response/html'`,
348
350
  '',
349
- `import type { routes } from '../routes.ts'`,
351
+ `import { routes } from '../routes.ts'`,
350
352
  '',
351
- `export const home: BuildAction<'ANY', typeof routes.home> = {`,
352
- ' handler() {',
353
- ' let page = html`',
354
- ' <html lang="en">',
355
- ' <head>',
356
- ' <meta charset="utf-8" />',
357
- ' <meta name="viewport" content="width=device-width, initial-scale=1" />',
358
- ' <title>Home</title>',
359
- ' </head>',
360
- ' <body>',
361
- ' <h1>Home</h1>',
362
- ' <p>Update app/routes.ts and app/controllers/home to keep building your app.</p>',
363
- ' </body>',
364
- ' </html>',
365
- ' `',
366
- ' return createHtmlResponse(page)',
353
+ `export default createController(routes, {`,
354
+ ' actions: {',
355
+ ' home() {',
356
+ ' let page = html`',
357
+ ' <html lang="en">',
358
+ ' <head>',
359
+ ' <meta charset="utf-8" />',
360
+ ' <meta name="viewport" content="width=device-width, initial-scale=1" />',
361
+ ' <title>Home</title>',
362
+ ' </head>',
363
+ ' <body>',
364
+ ' <h1>Home</h1>',
365
+ ' <p>Update app/routes.ts and app/actions/controller to keep building your app.</p>',
366
+ ' </body>',
367
+ ' </html>',
368
+ ' `',
369
+ ' return createHtmlResponse(page)',
370
+ ' },',
367
371
  ' },',
368
- '}',
372
+ `})`,
369
373
  '',
370
374
  ].join('\n')
371
375
  }
@@ -375,10 +379,12 @@ function renderDefaultHomeAction(entryPath: string): string {
375
379
  `import { renderToStream } from 'remix/ui/server'`,
376
380
  `import { createHtmlResponse } from 'remix/response/html'`,
377
381
  '',
378
- 'export const home = {',
379
- ' handler() {',
380
- ' let page = <HomePage />',
381
- ' return createHtmlResponse(renderToStream(page))',
382
+ 'export default {',
383
+ ' actions: {',
384
+ ' home() {',
385
+ ' let page = <HomePage />',
386
+ ' return createHtmlResponse(renderToStream(page))',
387
+ ' },',
382
388
  ' },',
383
389
  '}',
384
390
  '',
@@ -392,7 +398,7 @@ function renderDefaultHomeAction(entryPath: string): string {
392
398
  ' </head>',
393
399
  ' <body>',
394
400
  ' <h1>Home</h1>',
395
- ' <p>Update app/routes.ts and app/controllers/home to keep building your app.</p>',
401
+ ' <p>Update app/routes.ts and app/actions/controller to keep building your app.</p>',
396
402
  ' </body>',
397
403
  ' </html>',
398
404
  ' )',
@@ -402,18 +408,20 @@ function renderDefaultHomeAction(entryPath: string): string {
402
408
  }
403
409
 
404
410
  return [
405
- `import type { BuildAction } from 'remix/fetch-router'`,
411
+ `import { createController } from 'remix/router'`,
406
412
  `import { renderToStream } from 'remix/ui/server'`,
407
413
  `import { createHtmlResponse } from 'remix/response/html'`,
408
414
  '',
409
- `import type { routes } from '../routes.ts'`,
415
+ `import { routes } from '../routes.ts'`,
410
416
  '',
411
- `export const home: BuildAction<'ANY', typeof routes.home> = {`,
412
- ' handler() {',
413
- ' let page = <HomePage />',
414
- ' return createHtmlResponse(renderToStream(page))',
417
+ `export default createController(routes, {`,
418
+ ' actions: {',
419
+ ' home() {',
420
+ ' let page = <HomePage />',
421
+ ' return createHtmlResponse(renderToStream(page))',
422
+ ' },',
415
423
  ' },',
416
- '}',
424
+ `})`,
417
425
  '',
418
426
  'function HomePage() {',
419
427
  ' return () => (',
@@ -425,7 +433,7 @@ function renderDefaultHomeAction(entryPath: string): string {
425
433
  ' </head>',
426
434
  ' <body>',
427
435
  ' <h1>Home</h1>',
428
- ' <p>Update app/routes.ts and app/controllers/home to keep building your app.</p>',
436
+ ' <p>Update app/routes.ts and app/actions/controller to keep building your app.</p>',
429
437
  ' </body>',
430
438
  ' </html>',
431
439
  ' )',
@@ -1,4 +1,4 @@
1
- export type DoctorSuiteName = 'environment' | 'project' | 'controllers'
1
+ export type DoctorSuiteName = 'environment' | 'project' | 'actions'
2
2
  export type DoctorFindingSeverity = 'warn' | 'advice'
3
3
  export type DoctorSuiteStatus = 'ok' | 'issues' | 'skipped'
4
4
 
@@ -18,12 +18,8 @@ export type DoctorFindingCode =
18
18
  | 'route-map-invalid-json'
19
19
  | 'route-map-loader-signal'
20
20
  | 'missing-owner'
21
- | 'wrong-owner-kind'
22
- | 'ambiguous-owner'
23
21
  | 'duplicate-owner-file'
24
22
  | 'incomplete-controller'
25
- | 'promotion-drift'
26
- | 'orphan-action'
27
23
  | 'orphan-controller'
28
24
  | 'orphan-route-directory'
29
25
 
@@ -82,7 +82,8 @@ function formatHeading(title: string, target: NodeJS.WriteStream): string {
82
82
  }
83
83
 
84
84
  function highlightSyntax(text: string, target: NodeJS.WriteStream): string {
85
- return text.replace(/(--[A-Za-z0-9-]+|-[A-Za-z]|\[[A-Za-z][A-Za-z0-9-]*\]|<[^>\n]+>)/g, (token) =>
86
- lightYellow(token, target),
85
+ return text.replace(
86
+ /(?<![^\s[,|])(--[A-Za-z0-9-]+|-[A-Za-z])(?=$|[\s,\]|])|\[[A-Za-z][A-Za-z0-9-]*\]|<[^>\n]+>/g,
87
+ (token) => lightYellow(token, target),
87
88
  )
88
89
  }
@@ -24,13 +24,13 @@ async function run(): Promise<void> {
24
24
  }
25
25
 
26
26
  async function loadRawRouteTree(routesFile: string): Promise<RawRouteTreeNode[]> {
27
- let routeModule = await import(pathToFileURL(routesFile).href)
27
+ let routeModule: object = await import(pathToFileURL(routesFile).href)
28
28
 
29
29
  if (!('routes' in routeModule)) {
30
30
  throw new Error(`Route module ${routesFile} must export a named "routes" value.`)
31
31
  }
32
32
 
33
- return normalizeRouteGroup(Reflect.get(routeModule, 'routes'), [], new Map())
33
+ return normalizeRouteGroup(routeModule.routes, [], new Map())
34
34
  }
35
35
 
36
36
  function normalizeRouteGroup(
@@ -55,15 +55,16 @@ function normalizeRouteGroup(
55
55
  return Object.entries(value).map(([key, entryValue]) => {
56
56
  let nameSegments = [...parentSegments, key]
57
57
  let name = nameSegments.join('.')
58
+ let routeLeaf = readRouteLeaf(entryValue)
58
59
 
59
- if (isRouteLeaf(entryValue)) {
60
+ if (routeLeaf !== undefined) {
60
61
  return {
61
62
  children: [],
62
63
  key,
63
64
  kind: 'route',
64
- method: entryValue.method,
65
+ method: routeLeaf.method,
65
66
  name,
66
- pattern: entryValue.pattern.source,
67
+ pattern: routeLeaf.pattern,
67
68
  } satisfies RawRouteTreeNode
68
69
  }
69
70
 
@@ -85,14 +86,22 @@ function normalizeRouteGroup(
85
86
  }
86
87
  }
87
88
 
88
- function isRouteLeaf(value: unknown): value is { method: string; pattern: { source: string } } {
89
+ function readRouteLeaf(value: unknown): { method: string; pattern: string } | undefined {
90
+ if (typeof value !== 'object' || value == null) return undefined
91
+
92
+ let method = Reflect.get(value, 'method')
93
+ let pattern = Reflect.get(value, 'pattern')
94
+ if (typeof method !== 'string' || !hasToString(pattern)) return undefined
95
+
96
+ return { method, pattern: pattern.toString() }
97
+ }
98
+
99
+ function hasToString(value: unknown): value is { toString(): string } {
89
100
  return (
90
101
  typeof value === 'object' &&
91
102
  value != null &&
92
- typeof Reflect.get(value, 'method') === 'string' &&
93
- typeof Reflect.get(value, 'pattern') === 'object' &&
94
- Reflect.get(value, 'pattern') != null &&
95
- typeof Reflect.get(Reflect.get(value, 'pattern'), 'source') === 'string'
103
+ 'toString' in value &&
104
+ typeof value.toString === 'function'
96
105
  )
97
106
  }
98
107
 
@@ -4,7 +4,13 @@ import * as process from 'node:process'
4
4
  import { spawn } from 'node:child_process'
5
5
  import { fileURLToPath } from 'node:url'
6
6
 
7
- import { inspectControllerOwnership, type OwnedSubtree } from './controller-ownership.ts'
7
+ import {
8
+ ROOT_ROUTE_NAME,
9
+ inspectControllerOwnership,
10
+ type ControllerOwnership,
11
+ type OwnedSubtree,
12
+ type RouteDirectoryPlan,
13
+ } from './controller-ownership.ts'
8
14
  import {
9
15
  routeMapLoaderFailed,
10
16
  routeMapLoaderInvalidJson,
@@ -13,7 +19,7 @@ import {
13
19
  routesFileNotFound,
14
20
  } from './errors.ts'
15
21
 
16
- export type RouteOwnerKind = 'action' | 'controller'
22
+ export type RouteOwnerKind = 'controller' | 'directory'
17
23
  export type RouteTreeNodeKind = 'group' | 'route'
18
24
 
19
25
  export interface RouteTreeOwner {
@@ -56,7 +62,7 @@ export interface RawRouteTreeNode {
56
62
  export async function loadRouteMap(cwd: string = process.cwd()): Promise<LoadedRouteMap> {
57
63
  let manifest = await loadRouteManifest(cwd)
58
64
  let ownership = await inspectControllerOwnership(manifest.appRoot, manifest.tree)
59
- let tree = decorateRouteTree(manifest.tree, ownership.subtrees)
65
+ let tree = decorateRouteTree(manifest.tree, ownership)
60
66
 
61
67
  return {
62
68
  appRoot: manifest.appRoot,
@@ -137,28 +143,51 @@ async function loadRawRouteMap(appRoot: string, routesFile: string): Promise<Raw
137
143
 
138
144
  function decorateRouteTree(
139
145
  rawTree: RawRouteTreeNode[],
140
- subtrees: OwnedSubtree[],
141
- parentSegments: string[] = [],
146
+ ownership: ControllerOwnership,
142
147
  ): RouteTreeNode[] {
143
- let subtreesByRouteName = new Map(subtrees.map((subtree) => [subtree.routeName, subtree]))
148
+ let subtreesByRouteName = new Map(
149
+ ownership.subtrees.map((subtree) => [subtree.routeName, subtree]),
150
+ )
151
+ let directoriesByRouteName = new Map(
152
+ ownership.routeDirectories.map((directory) => [directory.routeName, directory]),
153
+ )
144
154
 
145
- return decorateRouteTreeWithLookup(rawTree, subtreesByRouteName, parentSegments)
155
+ return decorateRouteTreeWithLookup(
156
+ rawTree,
157
+ subtreesByRouteName,
158
+ directoriesByRouteName,
159
+ ownership.scan.routeDirectoryPaths,
160
+ )
146
161
  }
147
162
 
148
163
  function decorateRouteTreeWithLookup(
149
164
  rawTree: RawRouteTreeNode[],
150
165
  subtreesByRouteName: Map<string, OwnedSubtree>,
166
+ directoriesByRouteName: Map<string, RouteDirectoryPlan>,
167
+ actualRouteDirectories: Set<string>,
151
168
  parentSegments: string[] = [],
152
169
  ): RouteTreeNode[] {
153
170
  return rawTree.map((rawNode) => {
154
- let owner = getRouteOwner(rawNode, parentSegments, subtreesByRouteName)
171
+ let owner = getRouteOwner(
172
+ rawNode,
173
+ parentSegments,
174
+ subtreesByRouteName,
175
+ directoriesByRouteName,
176
+ actualRouteDirectories,
177
+ )
155
178
  let nextParentSegments =
156
179
  rawNode.kind === 'group' ? [...parentSegments, rawNode.key] : parentSegments
157
180
 
158
181
  return {
159
182
  children:
160
183
  rawNode.kind === 'group'
161
- ? decorateRouteTreeWithLookup(rawNode.children, subtreesByRouteName, nextParentSegments)
184
+ ? decorateRouteTreeWithLookup(
185
+ rawNode.children,
186
+ subtreesByRouteName,
187
+ directoriesByRouteName,
188
+ actualRouteDirectories,
189
+ nextParentSegments,
190
+ )
162
191
  : [],
163
192
  key: rawNode.key,
164
193
  kind: rawNode.kind,
@@ -174,24 +203,40 @@ function getRouteOwner(
174
203
  rawNode: RawRouteTreeNode,
175
204
  parentSegments: string[],
176
205
  subtreesByRouteName: Map<string, OwnedSubtree>,
206
+ directoriesByRouteName: Map<string, RouteDirectoryPlan>,
207
+ actualRouteDirectories: Set<string>,
177
208
  ): RouteTreeOwner {
178
209
  let ownerRouteName =
179
210
  rawNode.kind === 'group'
180
211
  ? rawNode.name
181
212
  : parentSegments.length === 0
182
- ? rawNode.name
213
+ ? ROOT_ROUTE_NAME
183
214
  : parentSegments.join('.')
184
215
  let subtree = subtreesByRouteName.get(ownerRouteName)
185
216
 
186
- if (subtree == null) {
187
- throw routeOwnerPlanUnresolved(rawNode.name)
217
+ if (subtree != null) {
218
+ return {
219
+ exists: subtree.actualEntryPath != null,
220
+ kind: 'controller',
221
+ path: subtree.actualEntryPath ?? subtree.entryDisplayPath,
222
+ }
188
223
  }
189
224
 
190
- return {
191
- exists: subtree.actualEntryPath != null,
192
- kind: subtree.kind,
193
- path: subtree.actualEntryPath ?? subtree.entryDisplayPath,
225
+ if (rawNode.kind === 'group') {
226
+ let directory = directoriesByRouteName.get(rawNode.name)
227
+
228
+ if (directory == null) {
229
+ throw routeOwnerPlanUnresolved(rawNode.name)
230
+ }
231
+
232
+ return {
233
+ exists: actualRouteDirectories.has(directory.directoryPath),
234
+ kind: 'directory',
235
+ path: directory.directoryPath,
236
+ }
194
237
  }
238
+
239
+ throw routeOwnerPlanUnresolved(rawNode.name)
195
240
  }
196
241
 
197
242
  function assertRawRouteTree(value: unknown): RawRouteTreeNode[] {