@byline/cli 3.20.4 → 4.0.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.
Files changed (198) hide show
  1. package/README.md +42 -2
  2. package/dist/cli.js +5 -2
  3. package/dist/cli.js.map +1 -1
  4. package/dist/commands/doctor.d.ts.map +1 -1
  5. package/dist/commands/doctor.js +1 -0
  6. package/dist/commands/doctor.js.map +1 -1
  7. package/dist/commands/init.d.ts +3 -0
  8. package/dist/commands/init.d.ts.map +1 -1
  9. package/dist/commands/init.js +10 -14
  10. package/dist/commands/init.js.map +1 -1
  11. package/dist/commands/setup-checks.d.ts +9 -0
  12. package/dist/commands/setup-checks.d.ts.map +1 -1
  13. package/dist/commands/setup-checks.js +43 -18
  14. package/dist/commands/setup-checks.js.map +1 -1
  15. package/dist/commands/setup.js +1 -1
  16. package/dist/commands/setup.js.map +1 -1
  17. package/dist/context.d.ts +2 -0
  18. package/dist/context.d.ts.map +1 -1
  19. package/dist/context.js +6 -0
  20. package/dist/context.js.map +1 -1
  21. package/dist/lib/dependency-version.d.ts +12 -0
  22. package/dist/lib/dependency-version.d.ts.map +1 -0
  23. package/dist/lib/dependency-version.js +101 -0
  24. package/dist/lib/dependency-version.js.map +1 -0
  25. package/dist/lib/planned-writes.d.ts +8 -0
  26. package/dist/lib/planned-writes.d.ts.map +1 -0
  27. package/dist/lib/planned-writes.js +177 -0
  28. package/dist/lib/planned-writes.js.map +1 -0
  29. package/dist/lib/planned-writes.test.d.ts +2 -0
  30. package/dist/lib/planned-writes.test.d.ts.map +1 -0
  31. package/dist/lib/planned-writes.test.js +113 -0
  32. package/dist/lib/planned-writes.test.js.map +1 -0
  33. package/dist/lib/release-policy.d.ts +12 -0
  34. package/dist/lib/release-policy.d.ts.map +1 -0
  35. package/dist/lib/release-policy.js +20 -0
  36. package/dist/lib/release-policy.js.map +1 -0
  37. package/dist/lib/release-policy.test.d.ts +2 -0
  38. package/dist/lib/release-policy.test.d.ts.map +1 -0
  39. package/dist/lib/release-policy.test.js +36 -0
  40. package/dist/lib/release-policy.test.js.map +1 -0
  41. package/dist/lib/route-config.d.ts +40 -0
  42. package/dist/lib/route-config.d.ts.map +1 -0
  43. package/dist/lib/route-config.js +322 -0
  44. package/dist/lib/route-config.js.map +1 -0
  45. package/dist/lib/static-config.d.ts +20 -0
  46. package/dist/lib/static-config.d.ts.map +1 -0
  47. package/dist/lib/static-config.js +444 -0
  48. package/dist/lib/static-config.js.map +1 -0
  49. package/dist/lib/static-config.test.d.ts +2 -0
  50. package/dist/lib/static-config.test.d.ts.map +1 -0
  51. package/dist/lib/static-config.test.js +105 -0
  52. package/dist/lib/static-config.test.js.map +1 -0
  53. package/dist/lib/template-hook-boundary.test.node.d.ts +2 -0
  54. package/dist/lib/template-hook-boundary.test.node.d.ts.map +1 -0
  55. package/dist/lib/template-hook-boundary.test.node.js +30 -0
  56. package/dist/lib/template-hook-boundary.test.node.js.map +1 -0
  57. package/dist/lib/template-path.d.ts +5 -0
  58. package/dist/lib/template-path.d.ts.map +1 -0
  59. package/dist/lib/template-path.js +9 -0
  60. package/dist/lib/template-path.js.map +1 -0
  61. package/dist/lib/workspace-root.d.ts +28 -0
  62. package/dist/lib/workspace-root.d.ts.map +1 -0
  63. package/dist/lib/workspace-root.js +205 -0
  64. package/dist/lib/workspace-root.js.map +1 -0
  65. package/dist/manifest/deps.d.ts +1 -1
  66. package/dist/manifest/deps.d.ts.map +1 -1
  67. package/dist/manifest/deps.js +18 -8
  68. package/dist/manifest/deps.js.map +1 -1
  69. package/dist/phases/deps.d.ts +14 -1
  70. package/dist/phases/deps.d.ts.map +1 -1
  71. package/dist/phases/deps.js +312 -137
  72. package/dist/phases/deps.js.map +1 -1
  73. package/dist/phases/deps.test.d.ts +2 -0
  74. package/dist/phases/deps.test.d.ts.map +1 -0
  75. package/dist/phases/deps.test.js +343 -0
  76. package/dist/phases/deps.test.js.map +1 -0
  77. package/dist/phases/preflight.d.ts.map +1 -1
  78. package/dist/phases/preflight.js +59 -11
  79. package/dist/phases/preflight.js.map +1 -1
  80. package/dist/phases/prompts.d.ts +2 -0
  81. package/dist/phases/prompts.d.ts.map +1 -1
  82. package/dist/phases/prompts.js +51 -2
  83. package/dist/phases/prompts.js.map +1 -1
  84. package/dist/phases/routes-ui.test.d.ts +2 -0
  85. package/dist/phases/routes-ui.test.d.ts.map +1 -0
  86. package/dist/phases/routes-ui.test.js +766 -0
  87. package/dist/phases/routes-ui.test.js.map +1 -0
  88. package/dist/phases/routes.d.ts +4 -1
  89. package/dist/phases/routes.d.ts.map +1 -1
  90. package/dist/phases/routes.js +610 -122
  91. package/dist/phases/routes.js.map +1 -1
  92. package/dist/phases/scaffold-smoke.test.d.ts +2 -0
  93. package/dist/phases/scaffold-smoke.test.d.ts.map +1 -0
  94. package/dist/phases/scaffold-smoke.test.js +168 -0
  95. package/dist/phases/scaffold-smoke.test.js.map +1 -0
  96. package/dist/phases/scaffold.d.ts +4 -1
  97. package/dist/phases/scaffold.d.ts.map +1 -1
  98. package/dist/phases/scaffold.js +275 -72
  99. package/dist/phases/scaffold.js.map +1 -1
  100. package/dist/phases/scaffold.test.d.ts +2 -0
  101. package/dist/phases/scaffold.test.d.ts.map +1 -0
  102. package/dist/phases/scaffold.test.js +178 -0
  103. package/dist/phases/scaffold.test.js.map +1 -0
  104. package/dist/phases/ui.d.ts +4 -1
  105. package/dist/phases/ui.d.ts.map +1 -1
  106. package/dist/phases/ui.js +51 -59
  107. package/dist/phases/ui.js.map +1 -1
  108. package/dist/phases/wire/index.d.ts.map +1 -1
  109. package/dist/phases/wire/index.js +10 -6
  110. package/dist/phases/wire/index.js.map +1 -1
  111. package/dist/phases/wire/shared.d.ts +4 -1
  112. package/dist/phases/wire/shared.d.ts.map +1 -1
  113. package/dist/phases/wire/vite-config.d.ts.map +1 -1
  114. package/dist/phases/wire/vite-config.js +69 -20
  115. package/dist/phases/wire/vite-config.js.map +1 -1
  116. package/dist/phases/wire/vite-config.test.d.ts +2 -0
  117. package/dist/phases/wire/vite-config.test.d.ts.map +1 -0
  118. package/dist/phases/wire/vite-config.test.js +97 -0
  119. package/dist/phases/wire/vite-config.test.js.map +1 -0
  120. package/dist/prompts.d.ts +1 -0
  121. package/dist/prompts.d.ts.map +1 -1
  122. package/dist/prompts.js +7 -2
  123. package/dist/prompts.js.map +1 -1
  124. package/dist/runner.js +1 -1
  125. package/dist/runner.js.map +1 -1
  126. package/dist/templates/byline/admin.config.ts +8 -7
  127. package/dist/templates/byline/clients.server.ts +77 -0
  128. package/dist/templates/byline/collection-types.contract.ts +67 -0
  129. package/dist/templates/byline/collections/index.ts +17 -0
  130. package/dist/templates/byline/collections/server-hooks.ts +7 -0
  131. package/dist/templates/byline/generated/collection-types.ts +11 -0
  132. package/dist/templates/byline/i18n.ts +3 -3
  133. package/dist/templates/byline/locales.ts +9 -9
  134. package/dist/templates/byline/public.ts +12 -0
  135. package/dist/templates/byline/routes.ts +7 -6
  136. package/dist/templates/byline/scripts/generate-types.ts +81 -0
  137. package/dist/templates/byline/server.config.ts +5 -5
  138. package/dist/templates/byline-examples/admin.config.ts +18 -21
  139. package/dist/templates/byline-examples/blocks/photo-block.ts +3 -2
  140. package/dist/templates/byline-examples/clients.server.ts +77 -0
  141. package/dist/templates/byline-examples/collection-types.contract.ts +67 -0
  142. package/dist/templates/byline-examples/collections/docs/hooks.ts +30 -20
  143. package/dist/templates/byline-examples/collections/docs/schema.ts +18 -18
  144. package/dist/templates/byline-examples/collections/index.ts +22 -0
  145. package/dist/templates/byline-examples/collections/media/components/media-list-view.tsx +11 -6
  146. package/dist/templates/byline-examples/collections/media/schema.ts +16 -0
  147. package/dist/templates/byline-examples/collections/news/schema.ts +17 -3
  148. package/dist/templates/byline-examples/collections/news-categories/schema.ts +13 -0
  149. package/dist/templates/byline-examples/collections/pages/schema.ts +13 -0
  150. package/dist/templates/byline-examples/collections/server-hooks.ts +8 -0
  151. package/dist/templates/byline-examples/fields/ai-text.ts +1 -1
  152. package/dist/templates/byline-examples/fields/ai-textarea.ts +1 -1
  153. package/dist/templates/byline-examples/fields/lexical-richtext-ai.tsx +3 -3
  154. package/dist/templates/byline-examples/generated/collection-types.ts +139 -0
  155. package/dist/templates/byline-examples/i18n.ts +3 -3
  156. package/dist/templates/byline-examples/locales.ts +9 -9
  157. package/dist/templates/byline-examples/public.ts +12 -0
  158. package/dist/templates/byline-examples/routes.ts +7 -6
  159. package/dist/templates/byline-examples/scripts/backfill-version-locales.ts +1 -1
  160. package/dist/templates/byline-examples/scripts/generate-types.ts +81 -0
  161. package/dist/templates/byline-examples/scripts/import-docs.ts +11 -12
  162. package/dist/templates/byline-examples/scripts/lib/mdast-to-lexical.test.node.ts +93 -233
  163. package/dist/templates/byline-examples/scripts/lib/mdast-to-lexical.ts +2 -2
  164. package/dist/templates/byline-examples/scripts/lib/strip-leading-h1.test.node.ts +0 -4
  165. package/dist/templates/byline-examples/scripts/re-anchor.ts +3 -3
  166. package/dist/templates/byline-examples/scripts/regenerate-media.ts +6 -5
  167. package/dist/templates/byline-examples/server.config.ts +6 -15
  168. package/dist/templates/host/vite.config.ts +2 -0
  169. package/dist/templates/routes/_byline/admin/collections/$collection/$id/api.tsx +1 -6
  170. package/dist/templates/routes/_byline/admin/collections/$collection/$id/history.tsx +1 -7
  171. package/dist/templates/routes/_byline/admin/collections/$collection/$id/index.tsx +1 -6
  172. package/dist/templates/routes/_byline/route.lazy.tsx +1 -2
  173. package/dist/templates/routes/_byline/route.tsx +1 -1
  174. package/dist/templates/ui-byline/blocks/photo-block/index.tsx +18 -23
  175. package/dist/templates/ui-byline/blocks/richtext-block/index.tsx +1 -1
  176. package/dist/templates/ui-byline/components/admonition/index.tsx +11 -8
  177. package/dist/templates/ui-byline/components/code/code.tsx +6 -4
  178. package/dist/templates/ui-byline/components/link/lang-link.tsx +4 -8
  179. package/dist/templates/ui-byline/components/link/link-field.tsx +3 -2
  180. package/dist/templates/ui-byline/components/link/link-lexical.tsx +9 -8
  181. package/dist/templates/ui-byline/render-blocks.tsx +38 -38
  182. package/dist/templates/ui-byline/types/content.ts +25 -0
  183. package/dist/templates/ui-byline/types/i18n.ts +4 -8
  184. package/dist/test-helpers.d.ts +5 -0
  185. package/dist/test-helpers.d.ts.map +1 -0
  186. package/dist/test-helpers.js +52 -0
  187. package/dist/test-helpers.js.map +1 -0
  188. package/dist/types.d.ts +21 -1
  189. package/dist/types.d.ts.map +1 -1
  190. package/dist/ui-template-contract.test.d.ts +2 -0
  191. package/dist/ui-template-contract.test.d.ts.map +1 -0
  192. package/dist/ui-template-contract.test.js +130 -0
  193. package/dist/ui-template-contract.test.js.map +1 -0
  194. package/dist/workspace-root.test.d.ts +2 -0
  195. package/dist/workspace-root.test.d.ts.map +1 -0
  196. package/dist/workspace-root.test.js +307 -0
  197. package/dist/workspace-root.test.js.map +1 -0
  198. package/package.json +9 -1
@@ -0,0 +1,766 @@
1
+ import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync, } from 'node:fs';
2
+ import { afterEach, describe, expect, it } from 'vitest';
3
+ import { PREVIOUS_RELEASE_ROUTES_SOURCE, readStaticRoutesSource } from '../lib/route-config.js';
4
+ import { createTestContext } from '../test-helpers.js';
5
+ import { promptsPhase } from './prompts.js';
6
+ import { buildRoutesPlan, renameAdminSegment, routesPhase } from './routes.js';
7
+ import { buildUiPlan, isExampleOnlyUiPath, uiPhase } from './ui.js';
8
+ const contexts = [];
9
+ afterEach(() => {
10
+ for (const ctx of contexts.splice(0))
11
+ rmSync(ctx.cwd, { recursive: true, force: true });
12
+ });
13
+ function fixture(answers) {
14
+ const ctx = createTestContext(answers);
15
+ contexts.push(ctx);
16
+ return ctx;
17
+ }
18
+ async function prepareExistingDestinationMigration(ctx) {
19
+ await routesPhase.apply(buildRoutesPlan(ctx), ctx);
20
+ const oldPath = ctx.resolve('src/routes/_byline/auth/login.tsx');
21
+ const nextPath = ctx.resolve('src/routes/_byline/staff/login.tsx');
22
+ mkdirSync(ctx.resolve('src/routes/_byline/staff'), { recursive: true });
23
+ const destination = readFileSync(`${ctx.templatesDir()}/routes/_byline/sign-in.tsx`, 'utf8').replace('/_byline/sign-in', '/_byline/staff/login');
24
+ writeFileSync(nextPath, destination);
25
+ ctx.state.patchAnswers({ signInPath: '/staff/login' });
26
+ return { oldPath, nextPath, configPath: ctx.resolve('byline/routes.ts'), destination };
27
+ }
28
+ describe('routes planning', () => {
29
+ it('collects and persists canonical default route paths', async () => {
30
+ const ctx = fixture({});
31
+ expect((await promptsPhase.apply(await promptsPhase.plan(ctx), ctx)).state).toBe('done');
32
+ expect(ctx.state.get().answers.adminPath).toBe('/admin');
33
+ expect(ctx.state.get().answers.signInPath).toBe('/sign-in');
34
+ });
35
+ it('persists canonical custom route answers', async () => {
36
+ const ctx = fixture({ adminPath: 'cms/', signInPath: 'staff//login/' });
37
+ expect((await promptsPhase.apply(await promptsPhase.plan(ctx), ctx)).state).toBe('done');
38
+ expect(ctx.state.get().answers.adminPath).toBe('/cms');
39
+ expect(ctx.state.get().answers.signInPath).toBe('/staff/login');
40
+ });
41
+ it.each([
42
+ ['/admin', 'admin'],
43
+ ['/cms', 'cms'],
44
+ ])('keeps filesystem route IDs and runtime config aligned for %s', async (adminPath, slug) => {
45
+ const ctx = fixture({ adminPath });
46
+ mkdirSync(ctx.resolve('byline'), { recursive: true });
47
+ writeFileSync(ctx.resolve('byline/routes.ts'), readFileSync(`${ctx.templatesDir()}/byline-examples/routes.ts`, 'utf8'));
48
+ const plan = buildRoutesPlan(ctx);
49
+ expect(plan.writes.length).toBeGreaterThan(0);
50
+ await routesPhase.apply(plan, ctx);
51
+ for (const write of plan.writes)
52
+ expect(readFileSync(write.path, 'utf8')).toBe(write.contents);
53
+ expect(existsSync(ctx.resolve(`src/routes/_byline/${slug}/route.tsx`))).toBe(true);
54
+ expect(readFileSync(ctx.resolve('byline/routes.ts'), 'utf8')).toContain(`admin: '${adminPath}'`);
55
+ expect(existsSync(ctx.resolve('src/routes/_byline/sign-in.tsx'))).toBe(true);
56
+ expect(readFileSync(ctx.resolve('src/routes/_byline/sign-in.tsx'), 'utf8')).toContain("createSignInRoute('/_byline/sign-in')");
57
+ expect(readFileSync(ctx.resolve('byline/routes.ts'), 'utf8')).toContain("signIn: '/sign-in'");
58
+ });
59
+ it('generates a nested custom sign-in route whose file, ID, and config agree', async () => {
60
+ const ctx = fixture({ adminPath: '/cms', signInPath: '/staff/login' });
61
+ const plan = buildRoutesPlan(ctx);
62
+ const signInPath = ctx.resolve('src/routes/_byline/staff/login.tsx');
63
+ const signInWrite = plan.writes.find((write) => write.path === signInPath);
64
+ expect(signInWrite?.contents).toContain("createSignInRoute('/_byline/staff/login')");
65
+ expect(plan.notes).toContain('sign-in path: /staff/login');
66
+ expect((await routesPhase.apply(plan, ctx)).state).toBe('done');
67
+ expect(existsSync(signInPath)).toBe(true);
68
+ expect(existsSync(ctx.resolve('src/routes/_byline/sign-in.tsx'))).toBe(false);
69
+ expect(readFileSync(ctx.resolve('byline/routes.ts'), 'utf8')).toContain("signIn: '/staff/login'");
70
+ expect(await routesPhase.detect(ctx)).toBe('done');
71
+ });
72
+ it('installs a multi-segment admin tree with matching files, IDs, and config', async () => {
73
+ const ctx = fixture({ adminPath: '/internal/cms' });
74
+ const plan = buildRoutesPlan(ctx);
75
+ const adminRoute = ctx.resolve('src/routes/_byline/internal/cms/route.tsx');
76
+ const adminWrite = plan.writes.find((write) => write.path === adminRoute);
77
+ expect(adminWrite?.contents).toContain("'/_byline/internal/cms'");
78
+ expect((await routesPhase.apply(plan, ctx)).state).toBe('done');
79
+ expect(existsSync(adminRoute)).toBe(true);
80
+ expect(readFileSync(ctx.resolve('byline/routes.ts'), 'utf8')).toContain("admin: '/internal/cms'");
81
+ expect(await routesPhase.detect(ctx)).toBe('done');
82
+ });
83
+ it.each([
84
+ 'directory',
85
+ 'dangling symlink',
86
+ ])('blocks all route writes when an expected target is a %s', async (kind) => {
87
+ const ctx = fixture({ adminPath: '/admin' });
88
+ const routePath = ctx.resolve('src/routes/_byline/admin/route.tsx');
89
+ mkdirSync(ctx.resolve('src/routes/_byline/admin'), { recursive: true });
90
+ if (kind === 'directory')
91
+ mkdirSync(routePath);
92
+ else
93
+ symlinkSync(ctx.resolve('outside/missing-route.tsx'), routePath);
94
+ const plan = buildRoutesPlan(ctx);
95
+ expect(plan.writes).toEqual([]);
96
+ expect(plan.notes.join('\n')).toContain('not a regular file');
97
+ expect((await routesPhase.apply(plan, ctx)).state).toBe('partial');
98
+ expect(existsSync(ctx.resolve('byline/routes.ts'))).toBe(false);
99
+ });
100
+ it('accepts safe shared-prefix sibling admin, API, and sign-in trees', () => {
101
+ const ctx = fixture({ adminPath: '/internal/cms', signInPath: '/internal/login' });
102
+ mkdirSync(ctx.resolve('byline'), { recursive: true });
103
+ writeFileSync(ctx.resolve('byline/routes.ts'), `export const routes = {
104
+ admin: '/internal/cms',
105
+ api: '/internal/api',
106
+ signIn: '/internal/login',
107
+ } as const
108
+ `);
109
+ const plan = buildRoutesPlan(ctx);
110
+ expect(plan.notes.join('\n')).not.toMatch(/invalid .* path/);
111
+ expect(plan.writes.some((write) => write.path.endsWith('src/routes/_byline/internal/cms/route.tsx'))).toBe(true);
112
+ });
113
+ it.each([
114
+ '/bad/%api',
115
+ '/internal/../api',
116
+ '/internal api',
117
+ '/internal/route',
118
+ ])('fails closed for invalid configured API path %s', (api) => {
119
+ const ctx = fixture({ adminPath: '/cms' });
120
+ mkdirSync(ctx.resolve('byline'), { recursive: true });
121
+ writeFileSync(ctx.resolve('byline/routes.ts'), `export const routes = { admin: '/cms', api: '${api}', signIn: '/sign-in' }\n`);
122
+ const plan = buildRoutesPlan(ctx);
123
+ expect(plan.writes).toEqual([]);
124
+ expect(plan.notes.join('\n')).toContain('invalid API path');
125
+ });
126
+ it('normalizes backslash route paths before renaming', () => {
127
+ expect(renameAdminSegment('admin\\users\\index.tsx', ['internal', 'cms'])).toBe('internal/cms/users/index.tsx');
128
+ expect(renameAdminSegment('sign-in.tsx', ['internal', 'cms'])).toBe('sign-in.tsx');
129
+ });
130
+ it('canonicalizes leading and trailing slashes before writing runtime config', async () => {
131
+ const ctx = fixture({ adminPath: 'cms/', signInPath: 'staff//login/' });
132
+ mkdirSync(ctx.resolve('byline'), { recursive: true });
133
+ writeFileSync(ctx.resolve('byline/routes.ts'), readFileSync(`${ctx.templatesDir()}/byline-examples/routes.ts`, 'utf8'));
134
+ const plan = buildRoutesPlan(ctx);
135
+ await routesPhase.apply(plan, ctx);
136
+ expect(readFileSync(ctx.resolve('byline/routes.ts'), 'utf8')).toContain("admin: '/cms'");
137
+ expect(readFileSync(ctx.resolve('byline/routes.ts'), 'utf8')).toContain("signIn: '/staff/login'");
138
+ expect(ctx.state.get().answers.adminPath).toBe('/cms');
139
+ expect(ctx.state.get().answers.signInPath).toBe('/staff/login');
140
+ });
141
+ it.each([
142
+ '/sign-in',
143
+ '/_serverFn',
144
+ '/_build',
145
+ '/uploads',
146
+ '/api',
147
+ '/static',
148
+ '/public',
149
+ '/en',
150
+ '/fr',
151
+ ])('rejects the reserved admin path %s', (adminPath) => {
152
+ const ctx = fixture({ adminPath });
153
+ const plan = buildRoutesPlan(ctx);
154
+ expect(plan.writes).toEqual([]);
155
+ expect(plan.notes.join('\n')).toMatch(/invalid admin path/);
156
+ });
157
+ it('rejects configured API and public locale route conflicts', () => {
158
+ const apiCtx = fixture({ adminPath: '/rpc' });
159
+ mkdirSync(apiCtx.resolve('byline'), { recursive: true });
160
+ writeFileSync(apiCtx.resolve('byline/routes.ts'), "export const routes = { admin: '/admin', api: '/rpc' }\n");
161
+ expect(buildRoutesPlan(apiCtx).notes.join('\n')).toContain('conflicts');
162
+ const signInApiCtx = fixture({ adminPath: '/cms', signInPath: '/rpc/login' });
163
+ mkdirSync(signInApiCtx.resolve('byline'), { recursive: true });
164
+ writeFileSync(signInApiCtx.resolve('byline/routes.ts'), "export const routes = { admin: '/admin', api: '/rpc', signIn: '/sign-in' }\n");
165
+ expect(buildRoutesPlan(signInApiCtx).notes.join('\n')).toContain('conflicts');
166
+ const localeCtx = fixture({ adminPath: '/pt' });
167
+ mkdirSync(localeCtx.resolve('byline'), { recursive: true });
168
+ writeFileSync(localeCtx.resolve('byline/locales.ts'), "export const contentLocales = [{ code: 'pt', label: 'Português' }]\n");
169
+ expect(buildRoutesPlan(localeCtx).notes.join('\n')).toContain('conflicts');
170
+ const signInCtx = fixture({ adminPath: '/staff', signInPath: '/staff/login' });
171
+ mkdirSync(signInCtx.resolve('byline'), { recursive: true });
172
+ writeFileSync(signInCtx.resolve('byline/routes.ts'), "export const routes = { admin: '/admin', signIn: '/staff/login' }\n");
173
+ expect(buildRoutesPlan(signInCtx).notes.join('\n')).toContain('conflicts');
174
+ });
175
+ it('ignores comments, unrelated values, and interface locale definitions', () => {
176
+ const ctx = fixture({ adminPath: '/cms' });
177
+ mkdirSync(ctx.resolve('byline'), { recursive: true });
178
+ writeFileSync(ctx.resolve('byline/routes.ts'), `
179
+ // api: '/cms'
180
+ const unrelated = { api: '/cms', defaultLocale: 'cms' }
181
+ export const routes = {
182
+ admin: '/cms',
183
+ api: '/rpc',
184
+ signIn: '/sign-in',
185
+ unrelated: doNotExecute(),
186
+ }
187
+ `);
188
+ writeFileSync(ctx.resolve('byline/locales.ts'), `
189
+ export const interfaceLocales = [{ code: 'cms', label: 'Ignored' }]
190
+ export const contentLocales = [{ code: 'en', label: translateAtRuntime() }] as const
191
+ `);
192
+ const plan = buildRoutesPlan(ctx);
193
+ expect(plan.notes.join('\n')).not.toContain('conflicts');
194
+ expect(plan.writes.some((write) => write.path === ctx.resolve('byline/routes.ts'))).toBe(false);
195
+ });
196
+ it('resolves safe constants, wrappers, and the recognized core resolveRoutes call', () => {
197
+ const ctx = fixture({ adminPath: '/cms', signInPath: '/staff/login' });
198
+ mkdirSync(ctx.resolve('byline'), { recursive: true });
199
+ writeFileSync(ctx.resolve('byline/routes.ts'), `
200
+ import { resolveRoutes } from '@byline/core'
201
+ const admin = '/cms'
202
+ const base = ({ admin, api: '/rpc' } as const)
203
+ const signIn = '/staff/login' as const
204
+ export const routes = resolveRoutes(
205
+ ({ ...base, signIn } as const) satisfies Record<string, string>
206
+ )
207
+ `);
208
+ const plan = buildRoutesPlan(ctx);
209
+ expect(plan.writes.length).toBeGreaterThan(0);
210
+ expect(plan.writes.some((write) => write.path === ctx.resolve('byline/routes.ts'))).toBe(false);
211
+ expect(plan.notes.join('\n')).not.toContain('manual');
212
+ });
213
+ it.each([
214
+ "import { routes } from './shared.js'; export { routes }",
215
+ "const key = 'admin'; export const routes = { [key]: '/cms', signIn: '/sign-in' }",
216
+ ])('fails closed without route writes for unknown relevant config', (source) => {
217
+ const ctx = fixture({ adminPath: '/cms' });
218
+ mkdirSync(ctx.resolve('byline'), { recursive: true });
219
+ writeFileSync(ctx.resolve('byline/routes.ts'), source);
220
+ const plan = buildRoutesPlan(ctx);
221
+ expect(plan.writes).toEqual([]);
222
+ expect(plan.notes.join('\n')).toContain('cannot safely resolve route configuration');
223
+ });
224
+ it('fails closed when public locale config depends on imported values', () => {
225
+ const ctx = fixture({ adminPath: '/cms' });
226
+ mkdirSync(ctx.resolve('src/i18n'), { recursive: true });
227
+ writeFileSync(ctx.resolve('src/i18n/i18n-config.ts'), `
228
+ import { locales } from './shared.js'
229
+ export const i18nConfig = { locales, defaultLocale: 'en' }
230
+ `);
231
+ const plan = buildRoutesPlan(ctx);
232
+ expect(plan.writes).toEqual([]);
233
+ expect(plan.notes.join('\n')).toContain('cannot safely resolve route configuration');
234
+ });
235
+ it('reserves statically resolved public locales and their default', () => {
236
+ const ctx = fixture({ adminPath: '/it' });
237
+ mkdirSync(ctx.resolve('src/i18n'), { recursive: true });
238
+ writeFileSync(ctx.resolve('src/i18n/i18n-config.ts'), `
239
+ const locales = ['en', ...(['fr'] as const)] as const
240
+ const defaults = { defaultLocale: 'it' } as const
241
+ export const i18nConfig = ({ locales, ...defaults } as const) satisfies object
242
+ `);
243
+ const plan = buildRoutesPlan(ctx);
244
+ expect(plan.writes).toEqual([]);
245
+ expect(plan.notes.join('\n')).toContain('conflicts');
246
+ });
247
+ it.each([
248
+ ['/cms', '/cms/login'],
249
+ ['/cms', '/api/login'],
250
+ ['/cms', '/en/login'],
251
+ ['/cms', '/uploads/login'],
252
+ ['/cms', '/staff/index'],
253
+ ['/cms', 'https://evil.test/login'],
254
+ ['/cms', '/staff/%6cogin'],
255
+ ])('rejects the conflicting or unsafe sign-in path %s', (adminPath, signInPath) => {
256
+ const ctx = fixture({ adminPath, signInPath });
257
+ expect(buildRoutesPlan(ctx).writes).toEqual([]);
258
+ expect(buildRoutesPlan(ctx).notes.join('\n')).toMatch(/invalid (?:admin|sign-in) path/);
259
+ });
260
+ it('atomically moves a canonical default sign-in route to a custom nested path', async () => {
261
+ const ctx = fixture({ adminPath: '/admin', signInPath: '/sign-in' });
262
+ await routesPhase.apply(buildRoutesPlan(ctx), ctx);
263
+ const oldPath = ctx.resolve('src/routes/_byline/sign-in.tsx');
264
+ const nextPath = ctx.resolve('src/routes/_byline/staff/login.tsx');
265
+ ctx.state.patchAnswers({ signInPath: '/staff/login' });
266
+ const plan = buildRoutesPlan(ctx);
267
+ expect(plan.writes).toContainEqual(expect.objectContaining({ path: oldPath, mode: 'delete' }));
268
+ expect(plan.writes).toContainEqual(expect.objectContaining({ path: nextPath, mode: 'create' }));
269
+ expect((await routesPhase.apply(plan, ctx)).state).toBe('done');
270
+ expect(existsSync(oldPath)).toBe(false);
271
+ expect(existsSync(nextPath)).toBe(true);
272
+ });
273
+ it('atomically migrates the literal previous-release config and default route', async () => {
274
+ const ctx = fixture({ signInPath: '/staff/login' });
275
+ const configPath = ctx.resolve('byline/routes.ts');
276
+ const oldPath = ctx.resolve('src/routes/_byline/sign-in.tsx');
277
+ const nextPath = ctx.resolve('src/routes/_byline/staff/login.tsx');
278
+ mkdirSync(ctx.resolve('byline'), { recursive: true });
279
+ mkdirSync(ctx.resolve('src/routes/_byline'), { recursive: true });
280
+ writeFileSync(configPath, previousReleaseRoutesSource());
281
+ writeFileSync(oldPath, readFileSync(`${ctx.templatesDir()}/routes/_byline/sign-in.tsx`, 'utf8'));
282
+ const plan = buildRoutesPlan(ctx);
283
+ const configWriteIndex = plan.writes.findIndex((write) => write.path === configPath);
284
+ const oldDeleteIndex = plan.writes.findIndex((write) => write.path === oldPath && write.mode === 'delete');
285
+ expect(configWriteIndex).toBeGreaterThanOrEqual(0);
286
+ expect(oldDeleteIndex).toBeGreaterThan(configWriteIndex);
287
+ expect(plan.writes).toContainEqual(expect.objectContaining({ path: nextPath, mode: 'create' }));
288
+ expect((await routesPhase.apply(plan, ctx)).state).toBe('done');
289
+ expect(readFileSync(configPath, 'utf8')).toContain("signIn: '/staff/login'");
290
+ expect(existsSync(oldPath)).toBe(false);
291
+ expect(existsSync(nextPath)).toBe(true);
292
+ expect(await routesPhase.detect(ctx)).toBe('done');
293
+ });
294
+ it.each([
295
+ ['/admin', '/admin'],
296
+ ['/cms', '/cms'],
297
+ ])('rewrites the literal v3.21 routes source when its %s admin path is already aligned', async (adminPath, expectedAdminPath) => {
298
+ const ctx = fixture({ adminPath });
299
+ const configPath = ctx.resolve('byline/routes.ts');
300
+ mkdirSync(ctx.resolve('byline'), { recursive: true });
301
+ writeFileSync(configPath, previousReleaseRoutesSource().replace("admin: '/admin'", `admin: '${adminPath}'`));
302
+ const plan = buildRoutesPlan(ctx);
303
+ expect(plan.writes).toContainEqual(expect.objectContaining({ path: configPath, mode: 'patch' }));
304
+ expect(plan.notes.join('\n')).not.toContain('manual');
305
+ expect((await routesPhase.apply(plan, ctx)).state).toBe('done');
306
+ const migrated = readFileSync(configPath, 'utf8');
307
+ expect(migrated).toContain('resolveRoutes({');
308
+ expect(migrated).toContain(`admin: '${expectedAdminPath}'`);
309
+ expect(migrated).toContain("signIn: '/sign-in'");
310
+ });
311
+ it('pins the recognized predecessor to the literal v3.21 source shape', () => {
312
+ const literal = previousReleaseRoutesSource();
313
+ expect(PREVIOUS_RELEASE_ROUTES_SOURCE).toBe(literal);
314
+ expect(literal).not.toContain('signIn:');
315
+ expect(readStaticRoutesSource(PREVIOUS_RELEASE_ROUTES_SOURCE)).toEqual({
316
+ ok: true,
317
+ value: { admin: '/admin', api: '/api', signIn: '/sign-in' },
318
+ });
319
+ });
320
+ it('atomically migrates one generated custom sign-in route to another', async () => {
321
+ const ctx = fixture({ signInPath: '/auth/login' });
322
+ await routesPhase.apply(buildRoutesPlan(ctx), ctx);
323
+ const oldPath = ctx.resolve('src/routes/_byline/auth/login.tsx');
324
+ const nextPath = ctx.resolve('src/routes/_byline/staff/login.tsx');
325
+ ctx.state.patchAnswers({ signInPath: '/staff/login' });
326
+ const plan = buildRoutesPlan(ctx);
327
+ expect(plan.writes).toContainEqual(expect.objectContaining({ path: nextPath, mode: 'create' }));
328
+ expect(plan.writes).toContainEqual(expect.objectContaining({ path: oldPath, mode: 'delete' }));
329
+ expect((await routesPhase.apply(plan, ctx)).state).toBe('done');
330
+ expect(existsSync(oldPath)).toBe(false);
331
+ expect(existsSync(nextPath)).toBe(true);
332
+ expect(readFileSync(ctx.resolve('byline/routes.ts'), 'utf8')).toContain("signIn: '/staff/login'");
333
+ });
334
+ it('atomically migrates and removes a recognized generated admin tree', async () => {
335
+ const ctx = fixture({ adminPath: '/admin' });
336
+ await routesPhase.apply(buildRoutesPlan(ctx), ctx);
337
+ const oldRoute = ctx.resolve('src/routes/_byline/admin/route.tsx');
338
+ const nextRoute = ctx.resolve('src/routes/_byline/internal/cms/route.tsx');
339
+ ctx.state.patchAnswers({ adminPath: '/internal/cms' });
340
+ const plan = buildRoutesPlan(ctx);
341
+ expect(plan.writes).toContainEqual(expect.objectContaining({ path: oldRoute, mode: 'delete' }));
342
+ expect(plan.writes).toContainEqual(expect.objectContaining({ path: nextRoute, mode: 'create' }));
343
+ expect((await routesPhase.apply(plan, ctx)).state).toBe('done');
344
+ expect(existsSync(oldRoute)).toBe(false);
345
+ expect(existsSync(nextRoute)).toBe(true);
346
+ expect(readFileSync(ctx.resolve('byline/routes.ts'), 'utf8')).toContain("admin: '/internal/cms'");
347
+ });
348
+ it('atomically migrates a generated admin tree into a nested descendant', async () => {
349
+ const ctx = fixture({ adminPath: '/admin' });
350
+ await routesPhase.apply(buildRoutesPlan(ctx), ctx);
351
+ const oldRoute = ctx.resolve('src/routes/_byline/admin/route.tsx');
352
+ const nextRoute = ctx.resolve('src/routes/_byline/admin/cms/route.tsx');
353
+ ctx.state.patchAnswers({ adminPath: '/admin/cms' });
354
+ const plan = buildRoutesPlan(ctx);
355
+ expect(plan.writes).toContainEqual(expect.objectContaining({ path: oldRoute, mode: 'delete' }));
356
+ expect(plan.writes).toContainEqual(expect.objectContaining({ path: nextRoute, mode: 'create' }));
357
+ expect((await routesPhase.apply(plan, ctx)).state).toBe('done');
358
+ expect(existsSync(oldRoute)).toBe(false);
359
+ expect(existsSync(nextRoute)).toBe(true);
360
+ });
361
+ it('blocks every migration write when a destination is not a regular file', async () => {
362
+ const ctx = fixture({ adminPath: '/admin' });
363
+ await routesPhase.apply(buildRoutesPlan(ctx), ctx);
364
+ const configPath = ctx.resolve('byline/routes.ts');
365
+ const oldRoute = ctx.resolve('src/routes/_byline/admin/route.tsx');
366
+ const nextRoute = ctx.resolve('src/routes/_byline/internal/cms/route.tsx');
367
+ mkdirSync(nextRoute, { recursive: true });
368
+ ctx.state.patchAnswers({ adminPath: '/internal/cms' });
369
+ const plan = buildRoutesPlan(ctx);
370
+ expect(plan.writes).toEqual([]);
371
+ expect(plan.notes.join('\n')).toContain('not a regular file');
372
+ expect((await routesPhase.apply(plan, ctx)).state).toBe('partial');
373
+ expect(existsSync(oldRoute)).toBe(true);
374
+ expect(readFileSync(configPath, 'utf8')).toContain("admin: '/admin'");
375
+ });
376
+ it('migrates through a generated child-route collision in both directions', async () => {
377
+ const ctx = fixture({ adminPath: '/admin' });
378
+ await routesPhase.apply(buildRoutesPlan(ctx), ctx);
379
+ const collidingPath = ctx.resolve('src/routes/_byline/admin/users/index.tsx');
380
+ ctx.state.patchAnswers({ adminPath: '/admin/users' });
381
+ const descendantPlan = buildRoutesPlan(ctx);
382
+ expect(descendantPlan.notes.join('\n')).not.toContain('user-owned');
383
+ expect(descendantPlan.writes).toContainEqual(expect.objectContaining({ path: collidingPath, mode: 'patch' }));
384
+ expect((await routesPhase.apply(descendantPlan, ctx)).state).toBe('done');
385
+ expect(readFileSync(collidingPath, 'utf8')).toContain('createAdminDashboardRoute');
386
+ ctx.state.patchAnswers({ adminPath: '/admin' });
387
+ const ancestorPlan = buildRoutesPlan(ctx);
388
+ expect(ancestorPlan.notes.join('\n')).not.toContain('user-owned');
389
+ expect(ancestorPlan.writes).toContainEqual(expect.objectContaining({ path: collidingPath, mode: 'patch' }));
390
+ expect((await routesPhase.apply(ancestorPlan, ctx)).state).toBe('done');
391
+ expect(readFileSync(collidingPath, 'utf8')).toContain('createAdminUsersListRoute');
392
+ });
393
+ it('blocks every admin migration write when an old tree file is user-owned', async () => {
394
+ const ctx = fixture({ adminPath: '/admin' });
395
+ await routesPhase.apply(buildRoutesPlan(ctx), ctx);
396
+ const configPath = ctx.resolve('byline/routes.ts');
397
+ const oldRoute = ctx.resolve('src/routes/_byline/admin/route.tsx');
398
+ const nextRoute = ctx.resolve('src/routes/_byline/internal/cms/route.tsx');
399
+ writeFileSync(oldRoute, `${readFileSync(oldRoute, 'utf8')}\n// user-owned\n`);
400
+ ctx.state.patchAnswers({ adminPath: '/internal/cms' });
401
+ const plan = buildRoutesPlan(ctx);
402
+ expect(plan.writes).toEqual([]);
403
+ expect(plan.notes.join('\n')).toContain('user-owned');
404
+ expect((await routesPhase.apply(plan, ctx)).state).toBe('partial');
405
+ expect(existsSync(oldRoute)).toBe(true);
406
+ expect(existsSync(nextRoute)).toBe(false);
407
+ expect(readFileSync(configPath, 'utf8')).toContain("admin: '/admin'");
408
+ });
409
+ it('applies no part of a stale generated admin-tree migration plan', async () => {
410
+ const ctx = fixture({ adminPath: '/admin' });
411
+ await routesPhase.apply(buildRoutesPlan(ctx), ctx);
412
+ const configPath = ctx.resolve('byline/routes.ts');
413
+ const oldRoute = ctx.resolve('src/routes/_byline/admin/route.tsx');
414
+ const nextRoute = ctx.resolve('src/routes/_byline/internal/cms/route.tsx');
415
+ ctx.state.patchAnswers({ adminPath: '/internal/cms' });
416
+ const plan = buildRoutesPlan(ctx);
417
+ writeFileSync(oldRoute, `${readFileSync(oldRoute, 'utf8')}\n// concurrent edit\n`);
418
+ expect((await routesPhase.apply(plan, ctx)).state).toBe('partial');
419
+ expect(existsSync(oldRoute)).toBe(true);
420
+ expect(existsSync(nextRoute)).toBe(false);
421
+ expect(readFileSync(configPath, 'utf8')).toContain("admin: '/admin'");
422
+ });
423
+ it('applies no part of a migration when the old tree gains a file after preview', async () => {
424
+ const ctx = fixture({ adminPath: '/admin' });
425
+ await routesPhase.apply(buildRoutesPlan(ctx), ctx);
426
+ const configPath = ctx.resolve('byline/routes.ts');
427
+ const oldRoute = ctx.resolve('src/routes/_byline/admin/route.tsx');
428
+ const nextRoute = ctx.resolve('src/routes/_byline/internal/cms/route.tsx');
429
+ ctx.state.patchAnswers({ adminPath: '/internal/cms' });
430
+ const plan = buildRoutesPlan(ctx);
431
+ const lateRoute = ctx.resolve('src/routes/_byline/admin/custom.tsx');
432
+ writeFileSync(lateRoute, 'export const Route = userRoute()\n');
433
+ expect((await routesPhase.apply(plan, ctx)).state).toBe('partial');
434
+ expect(existsSync(oldRoute)).toBe(true);
435
+ expect(existsSync(nextRoute)).toBe(false);
436
+ expect(readFileSync(configPath, 'utf8')).toContain("admin: '/admin'");
437
+ expect(readFileSync(lateRoute, 'utf8')).toContain('userRoute');
438
+ });
439
+ it('revalidates an absent old admin tree before applying a migration', async () => {
440
+ const ctx = fixture({ adminPath: '/admin' });
441
+ await routesPhase.apply(buildRoutesPlan(ctx), ctx);
442
+ const oldRoot = ctx.resolve('src/routes/_byline/admin');
443
+ rmSync(oldRoot, { recursive: true });
444
+ ctx.state.patchAnswers({ adminPath: '/internal/cms' });
445
+ const plan = buildRoutesPlan(ctx);
446
+ const lateRoute = ctx.resolve('src/routes/_byline/admin/custom.tsx');
447
+ mkdirSync(oldRoot, { recursive: true });
448
+ writeFileSync(lateRoute, 'export const Route = userRoute()\n');
449
+ expect((await routesPhase.apply(plan, ctx)).state).toBe('partial');
450
+ expect(existsSync(ctx.resolve('src/routes/_byline/internal/cms/route.tsx'))).toBe(false);
451
+ expect(readFileSync(ctx.resolve('byline/routes.ts'), 'utf8')).toContain("admin: '/admin'");
452
+ expect(readFileSync(lateRoute, 'utf8')).toContain('userRoute');
453
+ });
454
+ it('detects a stale generated admin tree after config migration', async () => {
455
+ const ctx = fixture({ adminPath: '/admin' });
456
+ await routesPhase.apply(buildRoutesPlan(ctx), ctx);
457
+ const oldRoute = ctx.resolve('src/routes/_byline/admin/route.tsx');
458
+ const oldSource = readFileSync(oldRoute, 'utf8');
459
+ ctx.state.patchAnswers({ adminPath: '/internal/cms' });
460
+ await routesPhase.apply(buildRoutesPlan(ctx), ctx);
461
+ mkdirSync(ctx.resolve('src/routes/_byline/admin'), { recursive: true });
462
+ writeFileSync(oldRoute, oldSource);
463
+ expect(await routesPhase.detect(ctx)).toBe('pending');
464
+ });
465
+ it('migrates successfully when the generated destination already exists unchanged', async () => {
466
+ const ctx = fixture({ signInPath: '/auth/login' });
467
+ const { oldPath, nextPath, configPath, destination } = await prepareExistingDestinationMigration(ctx);
468
+ const plan = buildRoutesPlan(ctx);
469
+ expect(plan.writes.some((write) => write.path === nextPath)).toBe(false);
470
+ expect(plan.preconditions).toContainEqual({
471
+ type: 'file',
472
+ path: nextPath,
473
+ contents: destination,
474
+ });
475
+ expect((await routesPhase.apply(plan, ctx)).state).toBe('done');
476
+ expect(existsSync(oldPath)).toBe(false);
477
+ expect(readFileSync(nextPath, 'utf8')).toBe(destination);
478
+ expect(readFileSync(configPath, 'utf8')).toContain("signIn: '/staff/login'");
479
+ });
480
+ it('blocks every migration write when an existing destination disappears after preview', async () => {
481
+ const ctx = fixture({ signInPath: '/auth/login' });
482
+ const { oldPath, nextPath, configPath } = await prepareExistingDestinationMigration(ctx);
483
+ const plan = buildRoutesPlan(ctx);
484
+ rmSync(nextPath);
485
+ expect((await routesPhase.apply(plan, ctx)).state).toBe('partial');
486
+ expect(existsSync(oldPath)).toBe(true);
487
+ expect(existsSync(nextPath)).toBe(false);
488
+ expect(readFileSync(configPath, 'utf8')).toContain("signIn: '/auth/login'");
489
+ });
490
+ it('blocks every migration write when an existing destination mutates after preview', async () => {
491
+ const ctx = fixture({ signInPath: '/auth/login' });
492
+ const { oldPath, nextPath, configPath, destination } = await prepareExistingDestinationMigration(ctx);
493
+ const plan = buildRoutesPlan(ctx);
494
+ writeFileSync(nextPath, `${destination}\n// concurrent edit\n`);
495
+ expect((await routesPhase.apply(plan, ctx)).state).toBe('partial');
496
+ expect(existsSync(oldPath)).toBe(true);
497
+ expect(readFileSync(nextPath, 'utf8')).toContain('concurrent edit');
498
+ expect(readFileSync(configPath, 'utf8')).toContain("signIn: '/auth/login'");
499
+ });
500
+ it('applies no part of a stale custom-to-custom migration plan', async () => {
501
+ const ctx = fixture({ signInPath: '/auth/login' });
502
+ await routesPhase.apply(buildRoutesPlan(ctx), ctx);
503
+ const configPath = ctx.resolve('byline/routes.ts');
504
+ const oldPath = ctx.resolve('src/routes/_byline/auth/login.tsx');
505
+ const nextPath = ctx.resolve('src/routes/_byline/staff/login.tsx');
506
+ ctx.state.patchAnswers({ signInPath: '/staff/login' });
507
+ const plan = buildRoutesPlan(ctx);
508
+ writeFileSync(configPath, `${readFileSync(configPath, 'utf8')}\n// concurrent edit\n`);
509
+ expect((await routesPhase.apply(plan, ctx)).state).toBe('partial');
510
+ expect(existsSync(oldPath)).toBe(true);
511
+ expect(existsSync(nextPath)).toBe(false);
512
+ expect(readFileSync(configPath, 'utf8')).toContain("signIn: '/auth/login'");
513
+ });
514
+ it('leaves generated routes intact when runtime config is user-owned', async () => {
515
+ const ctx = fixture({ signInPath: '/auth/login' });
516
+ await routesPhase.apply(buildRoutesPlan(ctx), ctx);
517
+ const configPath = ctx.resolve('byline/routes.ts');
518
+ const oldPath = ctx.resolve('src/routes/_byline/auth/login.tsx');
519
+ const nextPath = ctx.resolve('src/routes/_byline/staff/login.tsx');
520
+ writeFileSync(configPath, `${readFileSync(configPath, 'utf8')}\n// user-owned\n`);
521
+ ctx.state.patchAnswers({ signInPath: '/staff/login' });
522
+ const plan = buildRoutesPlan(ctx);
523
+ expect(plan.notes.join('\n')).toContain('user-owned routes.ts');
524
+ expect(plan.writes.some((write) => write.path === configPath)).toBe(false);
525
+ expect(plan.writes.some((write) => write.path === oldPath)).toBe(false);
526
+ expect(plan.writes.some((write) => write.path === nextPath)).toBe(false);
527
+ expect((await routesPhase.apply(plan, ctx)).state).toBe('partial');
528
+ expect(existsSync(oldPath)).toBe(true);
529
+ expect(existsSync(nextPath)).toBe(false);
530
+ expect(readFileSync(configPath, 'utf8')).toContain("signIn: '/auth/login'");
531
+ });
532
+ it('leaves config and routes intact when the prior route is user-owned', async () => {
533
+ const ctx = fixture({ signInPath: '/auth/login' });
534
+ await routesPhase.apply(buildRoutesPlan(ctx), ctx);
535
+ const configPath = ctx.resolve('byline/routes.ts');
536
+ const oldPath = ctx.resolve('src/routes/_byline/auth/login.tsx');
537
+ const nextPath = ctx.resolve('src/routes/_byline/staff/login.tsx');
538
+ writeFileSync(oldPath, `${readFileSync(oldPath, 'utf8')}\n// user-owned\n`);
539
+ ctx.state.patchAnswers({ signInPath: '/staff/login' });
540
+ const plan = buildRoutesPlan(ctx);
541
+ expect(plan.notes.join('\n')).toContain('sign-in route is user-owned');
542
+ expect(plan.writes.some((write) => write.path === configPath)).toBe(false);
543
+ expect(plan.writes.some((write) => write.path === oldPath)).toBe(false);
544
+ expect(plan.writes.some((write) => write.path === nextPath)).toBe(false);
545
+ expect((await routesPhase.apply(plan, ctx)).state).toBe('partial');
546
+ expect(existsSync(oldPath)).toBe(true);
547
+ expect(existsSync(nextPath)).toBe(false);
548
+ expect(readFileSync(configPath, 'utf8')).toContain("signIn: '/auth/login'");
549
+ });
550
+ it('applies no part of a stale sign-in move plan', async () => {
551
+ const ctx = fixture({ signInPath: '/sign-in' });
552
+ await routesPhase.apply(buildRoutesPlan(ctx), ctx);
553
+ const oldPath = ctx.resolve('src/routes/_byline/sign-in.tsx');
554
+ const nextPath = ctx.resolve('src/routes/_byline/staff/login.tsx');
555
+ ctx.state.patchAnswers({ signInPath: '/staff/login' });
556
+ const plan = buildRoutesPlan(ctx);
557
+ writeFileSync(oldPath, `${readFileSync(oldPath, 'utf8')}\n// user edit\n`);
558
+ expect((await routesPhase.apply(plan, ctx)).state).toBe('partial');
559
+ expect(existsSync(oldPath)).toBe(true);
560
+ expect(existsSync(nextPath)).toBe(false);
561
+ expect(readFileSync(ctx.resolve('byline/routes.ts'), 'utf8')).toContain("signIn: '/sign-in'");
562
+ });
563
+ it('preserves a user-owned old sign-in route and reports manual cleanup', async () => {
564
+ const ctx = fixture({ signInPath: '/staff/login' });
565
+ const oldPath = ctx.resolve('src/routes/_byline/sign-in.tsx');
566
+ mkdirSync(ctx.resolve('src/routes/_byline'), { recursive: true });
567
+ writeFileSync(oldPath, 'export const Route = getMySignInRoute()\n');
568
+ const plan = buildRoutesPlan(ctx);
569
+ expect(plan.writes.some((write) => write.path === oldPath)).toBe(false);
570
+ expect(plan.notes.join('\n')).toContain('existing sign-in route was preserved');
571
+ expect((await routesPhase.apply(plan, ctx)).state).toBe('partial');
572
+ expect(readFileSync(oldPath, 'utf8')).toContain('getMySignInRoute');
573
+ expect(existsSync(ctx.resolve('src/routes/_byline/staff/login.tsx'))).toBe(true);
574
+ });
575
+ it('publishes sign-in as client-safe route configuration', () => {
576
+ const ctx = fixture({ adminPath: '/admin' });
577
+ const routesSource = readFileSync(`${ctx.templatesDir()}/byline/routes.ts`, 'utf8');
578
+ const publicSource = readFileSync(`${ctx.templatesDir()}/byline/public.ts`, 'utf8');
579
+ expect(routesSource).toContain("signIn: '/sign-in'");
580
+ expect(publicSource).toContain("export { routes } from './routes.js'");
581
+ });
582
+ it('only patches an exact canonical runtime routes predecessor', () => {
583
+ const ctx = fixture({ adminPath: '/cms' });
584
+ const path = ctx.resolve('byline/routes.ts');
585
+ mkdirSync(ctx.resolve('byline'), { recursive: true });
586
+ const canonical = readFileSync(`${ctx.templatesDir()}/byline-examples/routes.ts`, 'utf8');
587
+ writeFileSync(path, `${canonical}\n// user-owned comment\n`);
588
+ const plan = buildRoutesPlan(ctx);
589
+ expect(plan.writes.some((write) => write.path === path)).toBe(false);
590
+ expect(plan.notes.join('\n')).toContain('manual');
591
+ });
592
+ it('does not synthesize provenance from the current template with signIn removed', () => {
593
+ const ctx = fixture({ signInPath: '/staff/login' });
594
+ const path = ctx.resolve('byline/routes.ts');
595
+ mkdirSync(ctx.resolve('byline'), { recursive: true });
596
+ const canonical = readFileSync(`${ctx.templatesDir()}/byline-examples/routes.ts`, 'utf8');
597
+ writeFileSync(path, canonical.replace(/^\s*signIn:.*\n/m, ''));
598
+ const plan = buildRoutesPlan(ctx);
599
+ expect(plan.writes).toEqual([]);
600
+ expect(plan.notes.join('\n')).toContain('user-owned routes.ts');
601
+ });
602
+ it('treats matching user-owned config as aligned without modifying it', async () => {
603
+ const ctx = fixture({ adminPath: '/cms', signInPath: '/staff/login' });
604
+ const path = ctx.resolve('byline/routes.ts');
605
+ const source = `
606
+ const admin = '/cms' as const
607
+ export const routes = {
608
+ api: '/rpc',
609
+ admin,
610
+ signIn: '/staff/login',
611
+ } as const
612
+ // user-owned
613
+ `;
614
+ mkdirSync(ctx.resolve('byline'), { recursive: true });
615
+ writeFileSync(path, source);
616
+ const plan = buildRoutesPlan(ctx);
617
+ expect(plan.writes.some((write) => write.path === path)).toBe(false);
618
+ expect((await routesPhase.apply(plan, ctx)).state).toBe('done');
619
+ expect(readFileSync(path, 'utf8')).toBe(source);
620
+ expect(await routesPhase.detect(ctx)).toBe('done');
621
+ });
622
+ it('applies no route writes when aligned user config changes after preview', async () => {
623
+ const ctx = fixture({ adminPath: '/cms' });
624
+ const path = ctx.resolve('byline/routes.ts');
625
+ mkdirSync(ctx.resolve('byline'), { recursive: true });
626
+ writeFileSync(path, "export const routes = { admin: '/cms', api: '/rpc', signIn: '/sign-in' }\n");
627
+ const plan = buildRoutesPlan(ctx);
628
+ writeFileSync(path, "export const routes = { admin: '/mine', api: '/rpc', signIn: '/sign-in' }\n");
629
+ expect((await routesPhase.apply(plan, ctx)).state).toBe('partial');
630
+ expect(existsSync(ctx.resolve('src/routes/_byline/cms/route.tsx'))).toBe(false);
631
+ expect(readFileSync(path, 'utf8')).toContain("admin: '/mine'");
632
+ });
633
+ it('preserves mismatched user-owned config and blocks every route write', async () => {
634
+ const ctx = fixture({ adminPath: '/cms', signInPath: '/staff/login' });
635
+ const path = ctx.resolve('byline/routes.ts');
636
+ const source = "export const routes = { admin: '/mine', api: '/rpc', signIn: '/login' }\n";
637
+ mkdirSync(ctx.resolve('byline'), { recursive: true });
638
+ writeFileSync(path, source);
639
+ const plan = buildRoutesPlan(ctx);
640
+ expect(plan.writes).toEqual([]);
641
+ expect(plan.notes.join('\n')).toContain('user-owned routes.ts');
642
+ expect((await routesPhase.apply(plan, ctx)).state).toBe('partial');
643
+ expect(readFileSync(path, 'utf8')).toBe(source);
644
+ expect(existsSync(ctx.resolve('src/routes/_byline/cms/route.tsx'))).toBe(false);
645
+ });
646
+ it('generates every admin surface under the custom filesystem route ID', () => {
647
+ const ctx = fixture({ adminPath: '/cms' });
648
+ const plan = buildRoutesPlan(ctx);
649
+ const routes = new Map(plan.writes
650
+ .filter((write) => write.path.includes('/src/routes/_byline/cms/'))
651
+ .map((write) => [write.path.slice(write.path.indexOf('/cms/') + 5), write.contents]));
652
+ for (const route of [
653
+ 'route.tsx',
654
+ 'index.tsx',
655
+ 'collections/$collection/index.tsx',
656
+ 'collections/$collection/create.tsx',
657
+ 'collections/$collection/$id/index.tsx',
658
+ 'collections/$collection/$id/history.tsx',
659
+ 'collections/$collection/$id/api.tsx',
660
+ 'users/index.tsx',
661
+ 'users/$id/index.tsx',
662
+ 'roles/index.tsx',
663
+ 'roles/$id/index.tsx',
664
+ 'permissions/index.tsx',
665
+ 'activity/index.tsx',
666
+ 'account/index.tsx',
667
+ ]) {
668
+ expect(routes.get(route), route).toContain('/_byline/cms');
669
+ expect(routes.get(route), route).not.toContain('/_byline/admin');
670
+ }
671
+ });
672
+ it('keeps example media navigation on the resolved admin mount', () => {
673
+ const ctx = fixture({ adminPath: '/cms', examples: true });
674
+ const source = readFileSync(`${ctx.templatesDir()}/byline-examples/collections/media/components/media-list-view.tsx`, 'utf8');
675
+ expect(source).toContain("getAdminRoutePath('collections', '$collection')");
676
+ expect(source).toContain("getAdminRoutePath('collections', '$collection', 'create')");
677
+ expect(source).toContain("getAdminRoutePath('collections', '$collection', '$id')");
678
+ expect(source).not.toContain("'/admin/collections");
679
+ expect(source).not.toContain('"/admin/collections');
680
+ });
681
+ it('reports a divergent runtime routes file as manual without overwriting it', async () => {
682
+ const ctx = fixture({ adminPath: '/cms' });
683
+ const path = ctx.resolve('byline/routes.ts');
684
+ mkdirSync(ctx.resolve('byline'), { recursive: true });
685
+ writeFileSync(path, "export const routes = getMyRoutes('/private')\n");
686
+ const plan = buildRoutesPlan(ctx);
687
+ expect(plan.notes.join('\n')).toContain('manual');
688
+ expect((await routesPhase.apply(plan, ctx)).state).toBe('blocked');
689
+ expect(readFileSync(path, 'utf8')).toContain('getMyRoutes');
690
+ });
691
+ it.each([
692
+ 'missing',
693
+ 'corrupt',
694
+ 'wrong-shape',
695
+ ])('blocks all route writes when canonical routes template is %s', (state) => {
696
+ const ctx = fixture({ adminPath: '/internal/cms' });
697
+ const sourceTemplates = ctx.templatesDir();
698
+ const copiedTemplates = ctx.resolve('test-templates');
699
+ cpSync(sourceTemplates, copiedTemplates, { recursive: true });
700
+ Object.defineProperty(ctx, 'templatesDir', { value: () => copiedTemplates });
701
+ const canonical = `${copiedTemplates}/byline-examples/routes.ts`;
702
+ if (state === 'missing')
703
+ rmSync(canonical);
704
+ else if (state === 'corrupt')
705
+ writeFileSync(canonical, "export const routes = { admin: '/admin'\n");
706
+ else {
707
+ writeFileSync(canonical, "export const routes = { admin: '/admin', api: '/api', signIn: '/sign-in' }\n");
708
+ }
709
+ const plan = buildRoutesPlan(ctx);
710
+ expect(plan.writes).toEqual([]);
711
+ expect(plan.notes.join('\n')).toContain('canonical routes.ts template');
712
+ });
713
+ });
714
+ function previousReleaseRoutesSource() {
715
+ return `/**
716
+ * This Source Code is subject to the terms of the Mozilla Public
717
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
718
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
719
+ *
720
+ * Copyright (c) Infonomic Company Limited
721
+ */
722
+
723
+ /**
724
+ * URL segments for admin and (future) public API routes. Defaults of
725
+ * \`/admin\` and \`/api\` are applied automatically by \`resolveRoutes()\` —
726
+ * keys only need to be set here when overriding either default.
727
+ */
728
+
729
+ import type { RoutesConfig } from '@byline/core'
730
+
731
+ export const routes: Partial<RoutesConfig> = {
732
+ admin: '/admin',
733
+ api: '/api',
734
+ }
735
+
736
+ /**
737
+ * Fallback used by both server and admin entry points when no
738
+ * \`VITE_SERVER_URL\` env var is set. Each entry resolves the env var
739
+ * itself (Vite's \`import.meta.env\` on the client, Node's \`process.env\`
740
+ * on the server) and falls back to this literal.
741
+ */
742
+ export const DEFAULT_SERVER_URL = 'http://localhost:5173/'
743
+ `;
744
+ }
745
+ describe('UI planning', () => {
746
+ it('normalizes backslash paths before example-only filtering', () => {
747
+ expect(isExampleOnlyUiPath('blocks\\photo-block\\index.tsx')).toBe(true);
748
+ expect(isExampleOnlyUiPath('types\\content.ts')).toBe(true);
749
+ expect(isExampleOnlyUiPath('components\\code\\index.ts')).toBe(false);
750
+ });
751
+ it('includes example block UI only when examples are installed', async () => {
752
+ const withExamples = fixture({ examples: true });
753
+ const examplePlan = buildUiPlan(withExamples);
754
+ expect(examplePlan.writes.some((write) => write.path.endsWith('render-blocks.tsx'))).toBe(true);
755
+ const withoutExamples = fixture({ examples: false });
756
+ const portablePlan = buildUiPlan(withoutExamples);
757
+ expect(portablePlan.writes.some((write) => write.path.endsWith('render-blocks.tsx'))).toBe(false);
758
+ expect(portablePlan.writes.some((write) => write.path.includes('/blocks/'))).toBe(false);
759
+ expect(portablePlan.writes.every((write) => !write.contents.includes('@/i18n/'))).toBe(true);
760
+ await uiPhase.apply(portablePlan, withoutExamples);
761
+ for (const write of portablePlan.writes) {
762
+ expect(readFileSync(write.path, 'utf8')).toBe(write.contents);
763
+ }
764
+ });
765
+ });
766
+ //# sourceMappingURL=routes-ui.test.js.map