@bleedingdev/modern-js-plugin-tanstack 3.2.0-ultramodern.119 → 3.2.0-ultramodern.120

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.
@@ -52,6 +52,7 @@ var __webpack_exports__ = {};
52
52
  (()=>{
53
53
  __webpack_require__.r(__webpack_exports__);
54
54
  __webpack_require__.d(__webpack_exports__, {
55
+ collectCanonicalRoutesForEntry: ()=>external_tanstackTypes_js_namespaceObject.collectCanonicalRoutesForEntry,
55
56
  createTanstackRsbuildRouteSplittingProfile: ()=>external_routeSplitting_js_namespaceObject.createTanstackRsbuildRouteSplittingProfile,
56
57
  default: ()=>src_cli,
57
58
  generateTanstackRouterTypesSourceForEntry: ()=>external_tanstackTypes_js_namespaceObject.generateTanstackRouterTypesSourceForEntry,
@@ -90,6 +91,14 @@ var __webpack_exports__ = {};
90
91
  function createRegisterDtsContent(opts) {
91
92
  const importStatements = opts.entries.map((entryName, index)=>`import type { router as router${index} } from './${entryName}/router.gen';`).join('\n');
92
93
  const routerUnionType = opts.entries.map((_, index)=>`typeof router${index}`).join(' | ');
94
+ const canonicalEntries = Object.entries(opts.canonicalRoutes ?? {});
95
+ const canonicalRoutesAugmentation = canonicalEntries.length > 0 ? `
96
+ declare module '${opts.i18nRuntimeModule || '@modern-js/plugin-i18n/runtime'}' {
97
+ interface UltramodernCanonicalRoutes {
98
+ ${canonicalEntries.map(([routePath, paramsType])=>` '${routePath}': ${paramsType};`).join('\n')}
99
+ }
100
+ }
101
+ ` : '';
93
102
  return `// This file is auto-generated by Modern.js. Do not edit manually.
94
103
 
95
104
  ${importStatements}
@@ -99,15 +108,17 @@ declare module '${opts.runtimeModule}' {
99
108
  router: ${routerUnionType};
100
109
  }
101
110
  }
102
- `;
111
+ ${canonicalRoutesAugmentation}`;
103
112
  }
104
113
  async function writeTanstackRegisterFile(opts) {
105
- const { entries, generatedDirName = DEFAULT_GENERATED_DIR_NAME, runtimeModule = '@modern-js/plugin-tanstack/runtime', srcDirectory } = opts;
114
+ const { entries, generatedDirName = DEFAULT_GENERATED_DIR_NAME, runtimeModule = '@modern-js/plugin-tanstack/runtime', srcDirectory, canonicalRoutes, i18nRuntimeModule } = opts;
106
115
  if (0 === entries.length) return;
107
116
  const registerDtsPath = external_node_path_default().join(srcDirectory, generatedDirName, 'register.gen.d.ts');
108
117
  await writeFileIfChanged(registerDtsPath, createRegisterDtsContent({
109
118
  entries,
110
- runtimeModule
119
+ runtimeModule,
120
+ canonicalRoutes,
121
+ i18nRuntimeModule
111
122
  }));
112
123
  }
113
124
  async function writeTanstackRouterTypesForEntries(opts) {
@@ -128,10 +139,19 @@ declare module '${opts.runtimeModule}' {
128
139
  if (mainEntryName && b === mainEntryName) return 1;
129
140
  return a.localeCompare(b);
130
141
  });
142
+ let canonicalRoutes = null;
143
+ for (const entryName of registerEntries){
144
+ const entryCanonicalRoutes = (0, external_tanstackTypes_js_namespaceObject.collectCanonicalRoutesForEntry)(routesByEntry[entryName]);
145
+ if (entryCanonicalRoutes) canonicalRoutes = {
146
+ ...entryCanonicalRoutes,
147
+ ...canonicalRoutes ?? {}
148
+ };
149
+ }
131
150
  await writeTanstackRegisterFile({
132
151
  entries: registerEntries,
133
152
  generatedDirName,
134
- srcDirectory: appContext.srcDirectory
153
+ srcDirectory: appContext.srcDirectory,
154
+ canonicalRoutes
135
155
  });
136
156
  }
137
157
  function tanstackRouterPlugin(options = {}) {
@@ -256,6 +276,7 @@ declare module '${opts.runtimeModule}' {
256
276
  }
257
277
  const src_cli = tanstackRouterPlugin;
258
278
  })();
279
+ exports.collectCanonicalRoutesForEntry = __webpack_exports__.collectCanonicalRoutesForEntry;
259
280
  exports.createTanstackRsbuildRouteSplittingProfile = __webpack_exports__.createTanstackRsbuildRouteSplittingProfile;
260
281
  exports["default"] = __webpack_exports__["default"];
261
282
  exports.generateTanstackRouterTypesSourceForEntry = __webpack_exports__.generateTanstackRouterTypesSourceForEntry;
@@ -266,6 +287,7 @@ exports.tanstackRouterPlugin = __webpack_exports__.tanstackRouterPlugin;
266
287
  exports.writeTanstackRegisterFile = __webpack_exports__.writeTanstackRegisterFile;
267
288
  exports.writeTanstackRouterTypesForEntries = __webpack_exports__.writeTanstackRouterTypesForEntries;
268
289
  for(var __rspack_i in __webpack_exports__)if (-1 === [
290
+ "collectCanonicalRoutesForEntry",
269
291
  "createTanstackRsbuildRouteSplittingProfile",
270
292
  "default",
271
293
  "generateTanstackRouterTypesSourceForEntry",
@@ -37,6 +37,7 @@ var __webpack_require__ = {};
37
37
  var __webpack_exports__ = {};
38
38
  __webpack_require__.r(__webpack_exports__);
39
39
  __webpack_require__.d(__webpack_exports__, {
40
+ collectCanonicalRoutesForEntry: ()=>collectCanonicalRoutesForEntry,
40
41
  generateTanstackRouterTypesSourceForEntry: ()=>generateTanstackRouterTypesSourceForEntry,
41
42
  isTanstackRouterFrameworkEnabled: ()=>isTanstackRouterFrameworkEnabled
42
43
  });
@@ -120,6 +121,78 @@ function createRouteStaticDataSnippet(opts) {
120
121
  if (!staticDataLines.length) return null;
121
122
  return `staticData: createRouteStaticData({\n ${staticDataLines.join('\n ')}\n }),`;
122
123
  }
124
+ const LOCALE_PARAM_SEGMENTS = new Set([
125
+ ':lang',
126
+ ':locale',
127
+ ':language',
128
+ '$lang',
129
+ '$locale',
130
+ '$language'
131
+ ]);
132
+ function paramsTypeForCanonicalPath(canonicalPath) {
133
+ const fields = [];
134
+ for (const segment of canonicalPath.split('/'))if (segment) {
135
+ if ('*' === segment || '$' === segment) {
136
+ fields.push("'_splat'?: string");
137
+ continue;
138
+ }
139
+ if (segment.startsWith('{-$') && segment.endsWith('}')) {
140
+ fields.push(`${JSON.stringify(segment.slice(3, -1))}?: string`);
141
+ continue;
142
+ }
143
+ if (segment.startsWith('$')) {
144
+ fields.push(`${JSON.stringify(segment.slice(1))}: string`);
145
+ continue;
146
+ }
147
+ if (segment.startsWith(':')) {
148
+ const optional = segment.endsWith('?');
149
+ const name = segment.slice(1, optional ? void 0 : segment.length);
150
+ fields.push(`${JSON.stringify(optional ? name.slice(0, -1) : name)}${optional ? '?' : ''}: string`);
151
+ }
152
+ }
153
+ return fields.length > 0 ? `{ ${fields.join('; ')} }` : 'Record<string, never>';
154
+ }
155
+ function collectCanonicalRoutesForEntry(routes) {
156
+ const canonicalParams = new Map();
157
+ let hasI18nSurface = false;
158
+ const normalizeJoined = (joined)=>{
159
+ const collapsed = joined.replace(/\/+/g, '/');
160
+ const withLeading = collapsed.startsWith('/') ? collapsed : `/${collapsed}`;
161
+ return withLeading.length > 1 ? withLeading.replace(/\/+$/, '') : withLeading;
162
+ };
163
+ const record = (canonicalPath)=>{
164
+ const normalized = normalizeJoined(canonicalPath || '/');
165
+ const key = toTanstackPath(normalized);
166
+ if (!canonicalParams.has(key)) canonicalParams.set(key, paramsTypeForCanonicalPath(normalized));
167
+ };
168
+ const visit = (route, parentPath)=>{
169
+ let currentPath = parentPath;
170
+ if ('string' == typeof route.modernCanonicalPath) {
171
+ hasI18nSurface = true;
172
+ currentPath = normalizeJoined(route.modernCanonicalPath);
173
+ } else if ('string' == typeof route.path && route.path.length > 0) {
174
+ const segments = route.path.replace(/\[(.+?)\]/g, ':$1').split('/').filter(Boolean);
175
+ if ('' === parentPath && LOCALE_PARAM_SEGMENTS.has(segments[0])) {
176
+ hasI18nSurface = true;
177
+ segments.shift();
178
+ }
179
+ currentPath = segments.length ? normalizeJoined(`${parentPath}/${segments.join('/')}`) : parentPath;
180
+ }
181
+ const children = route.children;
182
+ if (children && children.length > 0) {
183
+ for (const child of children)visit(child, currentPath);
184
+ return;
185
+ }
186
+ record(currentPath || '/');
187
+ };
188
+ const rootModern = routes.find((route)=>route.isRoot);
189
+ const topLevel = rootModern ? rootModern.children ?? [] : routes;
190
+ for (const route of topLevel)visit(route, '');
191
+ if (!hasI18nSurface || 0 === canonicalParams.size) return null;
192
+ return Object.fromEntries([
193
+ ...canonicalParams.entries()
194
+ ].sort(([a], [b])=>a.localeCompare(b)));
195
+ }
123
196
  async function isTanstackRouterFrameworkEnabled(appContext) {
124
197
  const runtimeConfigBase = external_path_default().join(appContext.srcDirectory, appContext.runtimeConfigFile);
125
198
  const runtimeConfigFile = (0, utils_namespaceObject.findExists)(JS_OR_TS_EXTS.map((ext)=>`${runtimeConfigBase}${ext}`));
@@ -469,9 +542,11 @@ export const router = createRouter({
469
542
  routerGenTs
470
543
  };
471
544
  }
545
+ exports.collectCanonicalRoutesForEntry = __webpack_exports__.collectCanonicalRoutesForEntry;
472
546
  exports.generateTanstackRouterTypesSourceForEntry = __webpack_exports__.generateTanstackRouterTypesSourceForEntry;
473
547
  exports.isTanstackRouterFrameworkEnabled = __webpack_exports__.isTanstackRouterFrameworkEnabled;
474
548
  for(var __rspack_i in __webpack_exports__)if (-1 === [
549
+ "collectCanonicalRoutesForEntry",
475
550
  "generateTanstackRouterTypesSourceForEntry",
476
551
  "isTanstackRouterFrameworkEnabled"
477
552
  ].indexOf(__rspack_i)) exports[__rspack_i] = __webpack_exports__[__rspack_i];
@@ -1,7 +1,7 @@
1
1
  import node_path from "node:path";
2
2
  import { NESTED_ROUTE_SPEC_FILE, filterRoutesForServer, fs } from "@modern-js/utils";
3
3
  import { createTanstackRsbuildRouteSplittingProfile, isTanstackStartRouteModuleSource, resolveTanstackRouteCodeSplittingEnabled } from "./routeSplitting.mjs";
4
- import { generateTanstackRouterTypesSourceForEntry, isTanstackRouterFrameworkEnabled } from "./tanstackTypes.mjs";
4
+ import { collectCanonicalRoutesForEntry, generateTanstackRouterTypesSourceForEntry, isTanstackRouterFrameworkEnabled } from "./tanstackTypes.mjs";
5
5
  import { __webpack_require__ } from "../rslib-runtime.mjs";
6
6
  import * as __rspack_external__modern_js_runtime_cli_401ee077 from "@modern-js/runtime/cli";
7
7
  __webpack_require__.add({
@@ -32,6 +32,14 @@ async function writeFileIfChanged(filePath, content) {
32
32
  function createRegisterDtsContent(opts) {
33
33
  const importStatements = opts.entries.map((entryName, index)=>`import type { router as router${index} } from './${entryName}/router.gen';`).join('\n');
34
34
  const routerUnionType = opts.entries.map((_, index)=>`typeof router${index}`).join(' | ');
35
+ const canonicalEntries = Object.entries(opts.canonicalRoutes ?? {});
36
+ const canonicalRoutesAugmentation = canonicalEntries.length > 0 ? `
37
+ declare module '${opts.i18nRuntimeModule || '@modern-js/plugin-i18n/runtime'}' {
38
+ interface UltramodernCanonicalRoutes {
39
+ ${canonicalEntries.map(([routePath, paramsType])=>` '${routePath}': ${paramsType};`).join('\n')}
40
+ }
41
+ }
42
+ ` : '';
35
43
  return `// This file is auto-generated by Modern.js. Do not edit manually.
36
44
 
37
45
  ${importStatements}
@@ -41,15 +49,17 @@ declare module '${opts.runtimeModule}' {
41
49
  router: ${routerUnionType};
42
50
  }
43
51
  }
44
- `;
52
+ ${canonicalRoutesAugmentation}`;
45
53
  }
46
54
  async function writeTanstackRegisterFile(opts) {
47
- const { entries, generatedDirName = DEFAULT_GENERATED_DIR_NAME, runtimeModule = '@modern-js/plugin-tanstack/runtime', srcDirectory } = opts;
55
+ const { entries, generatedDirName = DEFAULT_GENERATED_DIR_NAME, runtimeModule = '@modern-js/plugin-tanstack/runtime', srcDirectory, canonicalRoutes, i18nRuntimeModule } = opts;
48
56
  if (0 === entries.length) return;
49
57
  const registerDtsPath = node_path.join(srcDirectory, generatedDirName, 'register.gen.d.ts');
50
58
  await writeFileIfChanged(registerDtsPath, createRegisterDtsContent({
51
59
  entries,
52
- runtimeModule
60
+ runtimeModule,
61
+ canonicalRoutes,
62
+ i18nRuntimeModule
53
63
  }));
54
64
  }
55
65
  async function writeTanstackRouterTypesForEntries(opts) {
@@ -70,10 +80,19 @@ async function writeTanstackRouterTypesForEntries(opts) {
70
80
  if (mainEntryName && b === mainEntryName) return 1;
71
81
  return a.localeCompare(b);
72
82
  });
83
+ let canonicalRoutes = null;
84
+ for (const entryName of registerEntries){
85
+ const entryCanonicalRoutes = collectCanonicalRoutesForEntry(routesByEntry[entryName]);
86
+ if (entryCanonicalRoutes) canonicalRoutes = {
87
+ ...entryCanonicalRoutes,
88
+ ...canonicalRoutes ?? {}
89
+ };
90
+ }
73
91
  await writeTanstackRegisterFile({
74
92
  entries: registerEntries,
75
93
  generatedDirName,
76
- srcDirectory: appContext.srcDirectory
94
+ srcDirectory: appContext.srcDirectory,
95
+ canonicalRoutes
77
96
  });
78
97
  }
79
98
  function tanstackRouterPlugin(options = {}) {
@@ -198,4 +217,4 @@ function tanstackRouterPlugin(options = {}) {
198
217
  }
199
218
  const src_cli = tanstackRouterPlugin;
200
219
  export default src_cli;
201
- export { createTanstackRsbuildRouteSplittingProfile, generateTanstackRouterTypesSourceForEntry, isTanstackRouterFrameworkEnabled, isTanstackStartRouteModuleSource, resolveTanstackRouteCodeSplittingEnabled, tanstackRouterPlugin, writeTanstackRegisterFile, writeTanstackRouterTypesForEntries };
220
+ export { collectCanonicalRoutesForEntry, createTanstackRsbuildRouteSplittingProfile, generateTanstackRouterTypesSourceForEntry, isTanstackRouterFrameworkEnabled, isTanstackStartRouteModuleSource, resolveTanstackRouteCodeSplittingEnabled, tanstackRouterPlugin, writeTanstackRegisterFile, writeTanstackRouterTypesForEntries };
@@ -77,6 +77,78 @@ function createRouteStaticDataSnippet(opts) {
77
77
  if (!staticDataLines.length) return null;
78
78
  return `staticData: createRouteStaticData({\n ${staticDataLines.join('\n ')}\n }),`;
79
79
  }
80
+ const LOCALE_PARAM_SEGMENTS = new Set([
81
+ ':lang',
82
+ ':locale',
83
+ ':language',
84
+ '$lang',
85
+ '$locale',
86
+ '$language'
87
+ ]);
88
+ function paramsTypeForCanonicalPath(canonicalPath) {
89
+ const fields = [];
90
+ for (const segment of canonicalPath.split('/'))if (segment) {
91
+ if ('*' === segment || '$' === segment) {
92
+ fields.push("'_splat'?: string");
93
+ continue;
94
+ }
95
+ if (segment.startsWith('{-$') && segment.endsWith('}')) {
96
+ fields.push(`${JSON.stringify(segment.slice(3, -1))}?: string`);
97
+ continue;
98
+ }
99
+ if (segment.startsWith('$')) {
100
+ fields.push(`${JSON.stringify(segment.slice(1))}: string`);
101
+ continue;
102
+ }
103
+ if (segment.startsWith(':')) {
104
+ const optional = segment.endsWith('?');
105
+ const name = segment.slice(1, optional ? void 0 : segment.length);
106
+ fields.push(`${JSON.stringify(optional ? name.slice(0, -1) : name)}${optional ? '?' : ''}: string`);
107
+ }
108
+ }
109
+ return fields.length > 0 ? `{ ${fields.join('; ')} }` : 'Record<string, never>';
110
+ }
111
+ function collectCanonicalRoutesForEntry(routes) {
112
+ const canonicalParams = new Map();
113
+ let hasI18nSurface = false;
114
+ const normalizeJoined = (joined)=>{
115
+ const collapsed = joined.replace(/\/+/g, '/');
116
+ const withLeading = collapsed.startsWith('/') ? collapsed : `/${collapsed}`;
117
+ return withLeading.length > 1 ? withLeading.replace(/\/+$/, '') : withLeading;
118
+ };
119
+ const record = (canonicalPath)=>{
120
+ const normalized = normalizeJoined(canonicalPath || '/');
121
+ const key = toTanstackPath(normalized);
122
+ if (!canonicalParams.has(key)) canonicalParams.set(key, paramsTypeForCanonicalPath(normalized));
123
+ };
124
+ const visit = (route, parentPath)=>{
125
+ let currentPath = parentPath;
126
+ if ('string' == typeof route.modernCanonicalPath) {
127
+ hasI18nSurface = true;
128
+ currentPath = normalizeJoined(route.modernCanonicalPath);
129
+ } else if ('string' == typeof route.path && route.path.length > 0) {
130
+ const segments = route.path.replace(/\[(.+?)\]/g, ':$1').split('/').filter(Boolean);
131
+ if ('' === parentPath && LOCALE_PARAM_SEGMENTS.has(segments[0])) {
132
+ hasI18nSurface = true;
133
+ segments.shift();
134
+ }
135
+ currentPath = segments.length ? normalizeJoined(`${parentPath}/${segments.join('/')}`) : parentPath;
136
+ }
137
+ const children = route.children;
138
+ if (children && children.length > 0) {
139
+ for (const child of children)visit(child, currentPath);
140
+ return;
141
+ }
142
+ record(currentPath || '/');
143
+ };
144
+ const rootModern = routes.find((route)=>route.isRoot);
145
+ const topLevel = rootModern ? rootModern.children ?? [] : routes;
146
+ for (const route of topLevel)visit(route, '');
147
+ if (!hasI18nSurface || 0 === canonicalParams.size) return null;
148
+ return Object.fromEntries([
149
+ ...canonicalParams.entries()
150
+ ].sort(([a], [b])=>a.localeCompare(b)));
151
+ }
80
152
  async function isTanstackRouterFrameworkEnabled(appContext) {
81
153
  const runtimeConfigBase = path.join(appContext.srcDirectory, appContext.runtimeConfigFile);
82
154
  const runtimeConfigFile = findExists(JS_OR_TS_EXTS.map((ext)=>`${runtimeConfigBase}${ext}`));
@@ -426,4 +498,4 @@ export const router = createRouter({
426
498
  routerGenTs
427
499
  };
428
500
  }
429
- export { generateTanstackRouterTypesSourceForEntry, isTanstackRouterFrameworkEnabled };
501
+ export { collectCanonicalRoutesForEntry, generateTanstackRouterTypesSourceForEntry, isTanstackRouterFrameworkEnabled };
@@ -2,7 +2,7 @@ import "node:module";
2
2
  import node_path from "node:path";
3
3
  import { NESTED_ROUTE_SPEC_FILE, filterRoutesForServer, fs } from "@modern-js/utils";
4
4
  import { createTanstackRsbuildRouteSplittingProfile, isTanstackStartRouteModuleSource, resolveTanstackRouteCodeSplittingEnabled } from "./routeSplitting.mjs";
5
- import { generateTanstackRouterTypesSourceForEntry, isTanstackRouterFrameworkEnabled } from "./tanstackTypes.mjs";
5
+ import { collectCanonicalRoutesForEntry, generateTanstackRouterTypesSourceForEntry, isTanstackRouterFrameworkEnabled } from "./tanstackTypes.mjs";
6
6
  import { __webpack_require__ } from "../rslib-runtime.mjs";
7
7
  import { fileURLToPath as __rspack_fileURLToPath } from "node:url";
8
8
  import { dirname as __rspack_dirname } from "node:path";
@@ -36,6 +36,14 @@ async function writeFileIfChanged(filePath, content) {
36
36
  function createRegisterDtsContent(opts) {
37
37
  const importStatements = opts.entries.map((entryName, index)=>`import type { router as router${index} } from './${entryName}/router.gen';`).join('\n');
38
38
  const routerUnionType = opts.entries.map((_, index)=>`typeof router${index}`).join(' | ');
39
+ const canonicalEntries = Object.entries(opts.canonicalRoutes ?? {});
40
+ const canonicalRoutesAugmentation = canonicalEntries.length > 0 ? `
41
+ declare module '${opts.i18nRuntimeModule || '@modern-js/plugin-i18n/runtime'}' {
42
+ interface UltramodernCanonicalRoutes {
43
+ ${canonicalEntries.map(([routePath, paramsType])=>` '${routePath}': ${paramsType};`).join('\n')}
44
+ }
45
+ }
46
+ ` : '';
39
47
  return `// This file is auto-generated by Modern.js. Do not edit manually.
40
48
 
41
49
  ${importStatements}
@@ -45,15 +53,17 @@ declare module '${opts.runtimeModule}' {
45
53
  router: ${routerUnionType};
46
54
  }
47
55
  }
48
- `;
56
+ ${canonicalRoutesAugmentation}`;
49
57
  }
50
58
  async function writeTanstackRegisterFile(opts) {
51
- const { entries, generatedDirName = DEFAULT_GENERATED_DIR_NAME, runtimeModule = '@modern-js/plugin-tanstack/runtime', srcDirectory } = opts;
59
+ const { entries, generatedDirName = DEFAULT_GENERATED_DIR_NAME, runtimeModule = '@modern-js/plugin-tanstack/runtime', srcDirectory, canonicalRoutes, i18nRuntimeModule } = opts;
52
60
  if (0 === entries.length) return;
53
61
  const registerDtsPath = node_path.join(srcDirectory, generatedDirName, 'register.gen.d.ts');
54
62
  await writeFileIfChanged(registerDtsPath, createRegisterDtsContent({
55
63
  entries,
56
- runtimeModule
64
+ runtimeModule,
65
+ canonicalRoutes,
66
+ i18nRuntimeModule
57
67
  }));
58
68
  }
59
69
  async function writeTanstackRouterTypesForEntries(opts) {
@@ -74,10 +84,19 @@ async function writeTanstackRouterTypesForEntries(opts) {
74
84
  if (mainEntryName && b === mainEntryName) return 1;
75
85
  return a.localeCompare(b);
76
86
  });
87
+ let canonicalRoutes = null;
88
+ for (const entryName of registerEntries){
89
+ const entryCanonicalRoutes = collectCanonicalRoutesForEntry(routesByEntry[entryName]);
90
+ if (entryCanonicalRoutes) canonicalRoutes = {
91
+ ...entryCanonicalRoutes,
92
+ ...canonicalRoutes ?? {}
93
+ };
94
+ }
77
95
  await writeTanstackRegisterFile({
78
96
  entries: registerEntries,
79
97
  generatedDirName,
80
- srcDirectory: appContext.srcDirectory
98
+ srcDirectory: appContext.srcDirectory,
99
+ canonicalRoutes
81
100
  });
82
101
  }
83
102
  function tanstackRouterPlugin(options = {}) {
@@ -202,4 +221,4 @@ function tanstackRouterPlugin(options = {}) {
202
221
  }
203
222
  const src_cli = tanstackRouterPlugin;
204
223
  export default src_cli;
205
- export { createTanstackRsbuildRouteSplittingProfile, generateTanstackRouterTypesSourceForEntry, isTanstackRouterFrameworkEnabled, isTanstackStartRouteModuleSource, resolveTanstackRouteCodeSplittingEnabled, tanstackRouterPlugin, writeTanstackRegisterFile, writeTanstackRouterTypesForEntries };
224
+ export { collectCanonicalRoutesForEntry, createTanstackRsbuildRouteSplittingProfile, generateTanstackRouterTypesSourceForEntry, isTanstackRouterFrameworkEnabled, isTanstackStartRouteModuleSource, resolveTanstackRouteCodeSplittingEnabled, tanstackRouterPlugin, writeTanstackRegisterFile, writeTanstackRouterTypesForEntries };
@@ -78,6 +78,78 @@ function createRouteStaticDataSnippet(opts) {
78
78
  if (!staticDataLines.length) return null;
79
79
  return `staticData: createRouteStaticData({\n ${staticDataLines.join('\n ')}\n }),`;
80
80
  }
81
+ const LOCALE_PARAM_SEGMENTS = new Set([
82
+ ':lang',
83
+ ':locale',
84
+ ':language',
85
+ '$lang',
86
+ '$locale',
87
+ '$language'
88
+ ]);
89
+ function paramsTypeForCanonicalPath(canonicalPath) {
90
+ const fields = [];
91
+ for (const segment of canonicalPath.split('/'))if (segment) {
92
+ if ('*' === segment || '$' === segment) {
93
+ fields.push("'_splat'?: string");
94
+ continue;
95
+ }
96
+ if (segment.startsWith('{-$') && segment.endsWith('}')) {
97
+ fields.push(`${JSON.stringify(segment.slice(3, -1))}?: string`);
98
+ continue;
99
+ }
100
+ if (segment.startsWith('$')) {
101
+ fields.push(`${JSON.stringify(segment.slice(1))}: string`);
102
+ continue;
103
+ }
104
+ if (segment.startsWith(':')) {
105
+ const optional = segment.endsWith('?');
106
+ const name = segment.slice(1, optional ? void 0 : segment.length);
107
+ fields.push(`${JSON.stringify(optional ? name.slice(0, -1) : name)}${optional ? '?' : ''}: string`);
108
+ }
109
+ }
110
+ return fields.length > 0 ? `{ ${fields.join('; ')} }` : 'Record<string, never>';
111
+ }
112
+ function collectCanonicalRoutesForEntry(routes) {
113
+ const canonicalParams = new Map();
114
+ let hasI18nSurface = false;
115
+ const normalizeJoined = (joined)=>{
116
+ const collapsed = joined.replace(/\/+/g, '/');
117
+ const withLeading = collapsed.startsWith('/') ? collapsed : `/${collapsed}`;
118
+ return withLeading.length > 1 ? withLeading.replace(/\/+$/, '') : withLeading;
119
+ };
120
+ const record = (canonicalPath)=>{
121
+ const normalized = normalizeJoined(canonicalPath || '/');
122
+ const key = toTanstackPath(normalized);
123
+ if (!canonicalParams.has(key)) canonicalParams.set(key, paramsTypeForCanonicalPath(normalized));
124
+ };
125
+ const visit = (route, parentPath)=>{
126
+ let currentPath = parentPath;
127
+ if ('string' == typeof route.modernCanonicalPath) {
128
+ hasI18nSurface = true;
129
+ currentPath = normalizeJoined(route.modernCanonicalPath);
130
+ } else if ('string' == typeof route.path && route.path.length > 0) {
131
+ const segments = route.path.replace(/\[(.+?)\]/g, ':$1').split('/').filter(Boolean);
132
+ if ('' === parentPath && LOCALE_PARAM_SEGMENTS.has(segments[0])) {
133
+ hasI18nSurface = true;
134
+ segments.shift();
135
+ }
136
+ currentPath = segments.length ? normalizeJoined(`${parentPath}/${segments.join('/')}`) : parentPath;
137
+ }
138
+ const children = route.children;
139
+ if (children && children.length > 0) {
140
+ for (const child of children)visit(child, currentPath);
141
+ return;
142
+ }
143
+ record(currentPath || '/');
144
+ };
145
+ const rootModern = routes.find((route)=>route.isRoot);
146
+ const topLevel = rootModern ? rootModern.children ?? [] : routes;
147
+ for (const route of topLevel)visit(route, '');
148
+ if (!hasI18nSurface || 0 === canonicalParams.size) return null;
149
+ return Object.fromEntries([
150
+ ...canonicalParams.entries()
151
+ ].sort(([a], [b])=>a.localeCompare(b)));
152
+ }
81
153
  async function isTanstackRouterFrameworkEnabled(appContext) {
82
154
  const runtimeConfigBase = path.join(appContext.srcDirectory, appContext.runtimeConfigFile);
83
155
  const runtimeConfigFile = findExists(JS_OR_TS_EXTS.map((ext)=>`${runtimeConfigBase}${ext}`));
@@ -427,4 +499,4 @@ export const router = createRouter({
427
499
  routerGenTs
428
500
  };
429
501
  }
430
- export { generateTanstackRouterTypesSourceForEntry, isTanstackRouterFrameworkEnabled };
502
+ export { collectCanonicalRoutesForEntry, generateTanstackRouterTypesSourceForEntry, isTanstackRouterFrameworkEnabled };
@@ -3,7 +3,7 @@ import type { NestedRouteForCli, PageRoute } from '@modern-js/types';
3
3
  import { type TanstackRouteCodeSplittingOption } from './routeSplitting';
4
4
  export type { TanstackRouteCodeSplittingOption, TanstackRsbuildRouteSplittingProfile, } from './routeSplitting';
5
5
  export { createTanstackRsbuildRouteSplittingProfile, isTanstackStartRouteModuleSource, resolveTanstackRouteCodeSplittingEnabled, } from './routeSplitting';
6
- export { generateTanstackRouterTypesSourceForEntry, isTanstackRouterFrameworkEnabled, } from './tanstackTypes';
6
+ export { collectCanonicalRoutesForEntry, generateTanstackRouterTypesSourceForEntry, isTanstackRouterFrameworkEnabled, } from './tanstackTypes';
7
7
  export type TanstackRouterPluginOptions = {
8
8
  routesDir?: string;
9
9
  generatedDirName?: string;
@@ -14,6 +14,8 @@ export declare function writeTanstackRegisterFile(opts: {
14
14
  generatedDirName?: string;
15
15
  runtimeModule?: string;
16
16
  srcDirectory: string;
17
+ canonicalRoutes?: Record<string, string> | null;
18
+ i18nRuntimeModule?: string;
17
19
  }): Promise<void>;
18
20
  export declare function writeTanstackRouterTypesForEntries(opts: {
19
21
  appContext: AppToolsContext;
@@ -1,5 +1,14 @@
1
1
  import type { AppToolsContext } from '@modern-js/app-tools';
2
2
  import type { NestedRouteForCli, PageRoute } from '@modern-js/types';
3
+ /**
4
+ * Derive the canonical (language-agnostic) route map for an entry: the
5
+ * leading locale param is stripped and localized physical variants (routes
6
+ * carrying `modernCanonicalPath` metadata from `@modern-js/plugin-i18n`)
7
+ * collapse to their canonical pattern. Returns `null` when the entry has no
8
+ * i18n routing surface (no locale param and no localized variants), so plain
9
+ * TanStack apps never get a `@modern-js/plugin-i18n` module augmentation.
10
+ */
11
+ export declare function collectCanonicalRoutesForEntry(routes: (NestedRouteForCli | PageRoute)[]): Record<string, string> | null;
3
12
  export declare function isTanstackRouterFrameworkEnabled(appContext: AppToolsContext): Promise<boolean>;
4
13
  export declare function generateTanstackRouterTypesSourceForEntry(opts: {
5
14
  appContext: AppToolsContext;
package/package.json CHANGED
@@ -18,7 +18,7 @@
18
18
  "modern.js",
19
19
  "tanstack-router"
20
20
  ],
21
- "version": "3.2.0-ultramodern.119",
21
+ "version": "3.2.0-ultramodern.120",
22
22
  "engines": {
23
23
  "node": ">=20"
24
24
  },
@@ -88,29 +88,29 @@
88
88
  "@swc/helpers": "^0.5.23",
89
89
  "@tanstack/react-router": "1.170.15",
90
90
  "@tanstack/router-core": "1.171.13",
91
- "@modern-js/plugin": "npm:@bleedingdev/modern-js-plugin@3.2.0-ultramodern.119",
92
- "@modern-js/runtime-utils": "npm:@bleedingdev/modern-js-runtime-utils@3.2.0-ultramodern.119",
93
- "@modern-js/types": "npm:@bleedingdev/modern-js-types@3.2.0-ultramodern.119",
94
- "@modern-js/utils": "npm:@bleedingdev/modern-js-utils@3.2.0-ultramodern.119"
91
+ "@modern-js/plugin": "npm:@bleedingdev/modern-js-plugin@3.2.0-ultramodern.120",
92
+ "@modern-js/runtime-utils": "npm:@bleedingdev/modern-js-runtime-utils@3.2.0-ultramodern.120",
93
+ "@modern-js/types": "npm:@bleedingdev/modern-js-types@3.2.0-ultramodern.120",
94
+ "@modern-js/utils": "npm:@bleedingdev/modern-js-utils@3.2.0-ultramodern.120"
95
95
  },
96
96
  "peerDependencies": {
97
- "@modern-js/runtime": "3.2.0-ultramodern.119",
97
+ "@modern-js/runtime": "3.2.0-ultramodern.120",
98
98
  "react": "^19.2.7",
99
99
  "react-dom": "^19.2.7"
100
100
  },
101
101
  "devDependencies": {
102
- "@rslib/core": "0.21.5",
102
+ "@rslib/core": "0.22.0",
103
103
  "@tanstack/history": "1.162.0",
104
104
  "@testing-library/dom": "^10.4.1",
105
105
  "@testing-library/react": "^16.3.2",
106
- "@types/node": "^25.9.1",
106
+ "@types/node": "^25.9.3",
107
107
  "@types/react": "^19.2.17",
108
108
  "@types/react-dom": "^19.2.3",
109
- "@typescript/native-preview": "7.0.0-dev.20260606.1",
109
+ "@typescript/native-preview": "7.0.0-dev.20260610.1",
110
110
  "react": "^19.2.7",
111
111
  "react-dom": "^19.2.7",
112
- "@modern-js/app-tools": "npm:@bleedingdev/modern-js-app-tools@3.2.0-ultramodern.119",
113
- "@modern-js/runtime": "npm:@bleedingdev/modern-js-runtime@3.2.0-ultramodern.119",
112
+ "@modern-js/app-tools": "npm:@bleedingdev/modern-js-app-tools@3.2.0-ultramodern.120",
113
+ "@modern-js/runtime": "npm:@bleedingdev/modern-js-runtime@3.2.0-ultramodern.120",
114
114
  "@scripts/rstest-config": "2.66.0"
115
115
  },
116
116
  "sideEffects": false,
package/src/cli/index.ts CHANGED
@@ -23,6 +23,7 @@ import {
23
23
  type TanstackRouteCodeSplittingOption,
24
24
  } from './routeSplitting';
25
25
  import {
26
+ collectCanonicalRoutesForEntry,
26
27
  generateTanstackRouterTypesSourceForEntry,
27
28
  isTanstackRouterFrameworkEnabled,
28
29
  } from './tanstackTypes';
@@ -37,6 +38,7 @@ export {
37
38
  resolveTanstackRouteCodeSplittingEnabled,
38
39
  } from './routeSplitting';
39
40
  export {
41
+ collectCanonicalRoutesForEntry,
40
42
  generateTanstackRouterTypesSourceForEntry,
41
43
  isTanstackRouterFrameworkEnabled,
42
44
  } from './tanstackTypes';
@@ -124,6 +126,8 @@ async function writeFileIfChanged(filePath: string, content: string) {
124
126
  function createRegisterDtsContent(opts: {
125
127
  entries: string[];
126
128
  runtimeModule: string;
129
+ canonicalRoutes?: Record<string, string> | null;
130
+ i18nRuntimeModule?: string;
127
131
  }) {
128
132
  const importStatements = opts.entries
129
133
  .map(
@@ -135,6 +139,20 @@ function createRegisterDtsContent(opts: {
135
139
  .map((_, index) => `typeof router${index}`)
136
140
  .join(' | ');
137
141
 
142
+ const canonicalEntries = Object.entries(opts.canonicalRoutes ?? {});
143
+ const canonicalRoutesAugmentation =
144
+ canonicalEntries.length > 0
145
+ ? `
146
+ declare module '${opts.i18nRuntimeModule || '@modern-js/plugin-i18n/runtime'}' {
147
+ interface UltramodernCanonicalRoutes {
148
+ ${canonicalEntries
149
+ .map(([routePath, paramsType]) => ` '${routePath}': ${paramsType};`)
150
+ .join('\n')}
151
+ }
152
+ }
153
+ `
154
+ : '';
155
+
138
156
  return `// This file is auto-generated by Modern.js. Do not edit manually.
139
157
 
140
158
  ${importStatements}
@@ -144,7 +162,7 @@ declare module '${opts.runtimeModule}' {
144
162
  router: ${routerUnionType};
145
163
  }
146
164
  }
147
- `;
165
+ ${canonicalRoutesAugmentation}`;
148
166
  }
149
167
 
150
168
  export async function writeTanstackRegisterFile(opts: {
@@ -152,12 +170,16 @@ export async function writeTanstackRegisterFile(opts: {
152
170
  generatedDirName?: string;
153
171
  runtimeModule?: string;
154
172
  srcDirectory: string;
173
+ canonicalRoutes?: Record<string, string> | null;
174
+ i18nRuntimeModule?: string;
155
175
  }) {
156
176
  const {
157
177
  entries,
158
178
  generatedDirName = DEFAULT_GENERATED_DIR_NAME,
159
179
  runtimeModule = '@modern-js/plugin-tanstack/runtime',
160
180
  srcDirectory,
181
+ canonicalRoutes,
182
+ i18nRuntimeModule,
161
183
  } = opts;
162
184
 
163
185
  if (entries.length === 0) {
@@ -172,7 +194,12 @@ export async function writeTanstackRegisterFile(opts: {
172
194
 
173
195
  await writeFileIfChanged(
174
196
  registerDtsPath,
175
- createRegisterDtsContent({ entries, runtimeModule }),
197
+ createRegisterDtsContent({
198
+ entries,
199
+ runtimeModule,
200
+ canonicalRoutes,
201
+ i18nRuntimeModule,
202
+ }),
176
203
  );
177
204
  }
178
205
 
@@ -225,10 +252,23 @@ export async function writeTanstackRouterTypesForEntries(opts: {
225
252
  return a.localeCompare(b);
226
253
  });
227
254
 
255
+ // Merge the canonical (language-agnostic) route maps of every entry so the
256
+ // typed i18n Link covers all routes the app can navigate to.
257
+ let canonicalRoutes: Record<string, string> | null = null;
258
+ for (const entryName of registerEntries) {
259
+ const entryCanonicalRoutes = collectCanonicalRoutesForEntry(
260
+ routesByEntry[entryName],
261
+ );
262
+ if (entryCanonicalRoutes) {
263
+ canonicalRoutes = { ...entryCanonicalRoutes, ...(canonicalRoutes ?? {}) };
264
+ }
265
+ }
266
+
228
267
  await writeTanstackRegisterFile({
229
268
  entries: registerEntries,
230
269
  generatedDirName,
231
270
  srcDirectory: appContext.srcDirectory,
271
+ canonicalRoutes,
232
272
  });
233
273
  }
234
274
 
@@ -137,6 +137,137 @@ function createRouteStaticDataSnippet(opts: {
137
137
  )}\n }),`;
138
138
  }
139
139
 
140
+ const LOCALE_PARAM_SEGMENTS = new Set([
141
+ ':lang',
142
+ ':locale',
143
+ ':language',
144
+ '$lang',
145
+ '$locale',
146
+ '$language',
147
+ ]);
148
+
149
+ type CanonicalAwareRoute = (NestedRouteForCli | PageRoute) & {
150
+ modernCanonicalPath?: string;
151
+ index?: boolean;
152
+ isRoot?: boolean;
153
+ children?: CanonicalAwareRoute[];
154
+ };
155
+
156
+ function paramsTypeForCanonicalPath(canonicalPath: string): string {
157
+ const fields: string[] = [];
158
+
159
+ for (const segment of canonicalPath.split('/')) {
160
+ if (!segment) {
161
+ continue;
162
+ }
163
+ if (segment === '*' || segment === '$') {
164
+ fields.push(`'_splat'?: string`);
165
+ continue;
166
+ }
167
+ if (segment.startsWith('{-$') && segment.endsWith('}')) {
168
+ fields.push(`${JSON.stringify(segment.slice(3, -1))}?: string`);
169
+ continue;
170
+ }
171
+ if (segment.startsWith('$')) {
172
+ fields.push(`${JSON.stringify(segment.slice(1))}: string`);
173
+ continue;
174
+ }
175
+ if (segment.startsWith(':')) {
176
+ const optional = segment.endsWith('?');
177
+ const name = segment.slice(1, optional ? undefined : segment.length);
178
+ fields.push(
179
+ `${JSON.stringify(optional ? name.slice(0, -1) : name)}${
180
+ optional ? '?' : ''
181
+ }: string`,
182
+ );
183
+ }
184
+ }
185
+
186
+ return fields.length > 0
187
+ ? `{ ${fields.join('; ')} }`
188
+ : 'Record<string, never>';
189
+ }
190
+
191
+ /**
192
+ * Derive the canonical (language-agnostic) route map for an entry: the
193
+ * leading locale param is stripped and localized physical variants (routes
194
+ * carrying `modernCanonicalPath` metadata from `@modern-js/plugin-i18n`)
195
+ * collapse to their canonical pattern. Returns `null` when the entry has no
196
+ * i18n routing surface (no locale param and no localized variants), so plain
197
+ * TanStack apps never get a `@modern-js/plugin-i18n` module augmentation.
198
+ */
199
+ export function collectCanonicalRoutesForEntry(
200
+ routes: (NestedRouteForCli | PageRoute)[],
201
+ ): Record<string, string> | null {
202
+ const canonicalParams = new Map<string, string>();
203
+ let hasI18nSurface = false;
204
+
205
+ const normalizeJoined = (joined: string): string => {
206
+ const collapsed = joined.replace(/\/+/g, '/');
207
+ const withLeading = collapsed.startsWith('/') ? collapsed : `/${collapsed}`;
208
+ return withLeading.length > 1
209
+ ? withLeading.replace(/\/+$/, '')
210
+ : withLeading;
211
+ };
212
+
213
+ const record = (canonicalPath: string) => {
214
+ const normalized = normalizeJoined(canonicalPath || '/');
215
+ const key = toTanstackPath(normalized);
216
+ if (!canonicalParams.has(key)) {
217
+ canonicalParams.set(key, paramsTypeForCanonicalPath(normalized));
218
+ }
219
+ };
220
+
221
+ const visit = (route: CanonicalAwareRoute, parentPath: string) => {
222
+ let currentPath = parentPath;
223
+
224
+ if (typeof route.modernCanonicalPath === 'string') {
225
+ hasI18nSurface = true;
226
+ currentPath = normalizeJoined(route.modernCanonicalPath);
227
+ } else if (typeof route.path === 'string' && route.path.length > 0) {
228
+ const segments = route.path
229
+ .replace(/\[(.+?)\]/g, ':$1')
230
+ .split('/')
231
+ .filter(Boolean);
232
+ if (parentPath === '' && LOCALE_PARAM_SEGMENTS.has(segments[0])) {
233
+ hasI18nSurface = true;
234
+ segments.shift();
235
+ }
236
+ currentPath = segments.length
237
+ ? normalizeJoined(`${parentPath}/${segments.join('/')}`)
238
+ : parentPath;
239
+ }
240
+
241
+ const children = route.children;
242
+ if (children && children.length > 0) {
243
+ for (const child of children) {
244
+ visit(child, currentPath);
245
+ }
246
+ return;
247
+ }
248
+
249
+ // Leaf page or index route: a navigable target.
250
+ record(currentPath || '/');
251
+ };
252
+
253
+ const rootModern = routes.find(
254
+ route => (route as CanonicalAwareRoute).isRoot,
255
+ ) as CanonicalAwareRoute | undefined;
256
+ const topLevel = rootModern ? (rootModern.children ?? []) : routes;
257
+
258
+ for (const route of topLevel) {
259
+ visit(route as CanonicalAwareRoute, '');
260
+ }
261
+
262
+ if (!hasI18nSurface || canonicalParams.size === 0) {
263
+ return null;
264
+ }
265
+
266
+ return Object.fromEntries(
267
+ [...canonicalParams.entries()].sort(([a], [b]) => a.localeCompare(b)),
268
+ );
269
+ }
270
+
140
271
  export async function isTanstackRouterFrameworkEnabled(
141
272
  appContext: AppToolsContext,
142
273
  ): Promise<boolean> {
@@ -123,6 +123,82 @@ describe('tanstack router cli plugin', () => {
123
123
  );
124
124
  });
125
125
 
126
+ test('writes plugin-i18n module augmentation when canonicalRoutes are provided', async () => {
127
+ tempDir = await mkdtemp(path.join(tmpdir(), 'modern-tanstack-cli-'));
128
+ const srcDirectory = path.join(tempDir, 'src');
129
+
130
+ await writeTanstackRegisterFile({
131
+ entries: ['main'],
132
+ generatedDirName: 'tanstack',
133
+ srcDirectory,
134
+ canonicalRoutes: {
135
+ '/': 'Record<string, never>',
136
+ '/products/$slug': '{ "slug": string }',
137
+ },
138
+ });
139
+
140
+ const register = await readFile(
141
+ path.join(srcDirectory, 'tanstack', 'register.gen.d.ts'),
142
+ 'utf-8',
143
+ );
144
+
145
+ expect(register).toContain(
146
+ "declare module '@modern-js/plugin-i18n/runtime'",
147
+ );
148
+ expect(register).toContain('interface UltramodernCanonicalRoutes');
149
+ expect(register).toContain("'/': Record<string, never>;");
150
+ expect(register).toContain('\'/products/$slug\': { "slug": string };');
151
+ });
152
+
153
+ test('does not emit plugin-i18n augmentation when canonicalRoutes is absent (back-compat)', async () => {
154
+ tempDir = await mkdtemp(path.join(tmpdir(), 'modern-tanstack-cli-'));
155
+ const srcDirectory = path.join(tempDir, 'src');
156
+
157
+ await writeTanstackRegisterFile({
158
+ entries: ['main'],
159
+ generatedDirName: 'tanstack',
160
+ srcDirectory,
161
+ // No canonicalRoutes provided at all — plain TanStack app
162
+ });
163
+
164
+ const register = await readFile(
165
+ path.join(srcDirectory, 'tanstack', 'register.gen.d.ts'),
166
+ 'utf-8',
167
+ );
168
+
169
+ expect(register).not.toContain('plugin-i18n');
170
+ expect(register).not.toContain('UltramodernCanonicalRoutes');
171
+ // But the standard TanStack runtime augmentation must still be present.
172
+ expect(register).toContain(
173
+ "declare module '@modern-js/plugin-tanstack/runtime'",
174
+ );
175
+ });
176
+
177
+ test('uses a custom i18nRuntimeModule when specified', async () => {
178
+ tempDir = await mkdtemp(path.join(tmpdir(), 'modern-tanstack-cli-'));
179
+ const srcDirectory = path.join(tempDir, 'src');
180
+
181
+ await writeTanstackRegisterFile({
182
+ entries: ['main'],
183
+ generatedDirName: 'tanstack',
184
+ srcDirectory,
185
+ canonicalRoutes: {
186
+ '/talks': 'Record<string, never>',
187
+ },
188
+ i18nRuntimeModule: '@my-org/i18n/runtime',
189
+ });
190
+
191
+ const register = await readFile(
192
+ path.join(srcDirectory, 'tanstack', 'register.gen.d.ts'),
193
+ 'utf-8',
194
+ );
195
+
196
+ expect(register).toContain("declare module '@my-org/i18n/runtime'");
197
+ expect(register).not.toContain(
198
+ "declare module '@modern-js/plugin-i18n/runtime'",
199
+ );
200
+ });
201
+
126
202
  test('claims custom routes, injects runtime plugin, and merges route specs', async () => {
127
203
  tempDir = await mkdtemp(path.join(tmpdir(), 'modern-tanstack-cli-'));
128
204
  const srcDirectory = path.join(tempDir, 'src');
@@ -3,7 +3,10 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
3
3
  import { tmpdir } from 'node:os';
4
4
  import path from 'node:path';
5
5
  import { promisify } from 'node:util';
6
- import { generateTanstackRouterTypesSourceForEntry } from '../../src/cli/tanstackTypes';
6
+ import {
7
+ collectCanonicalRoutesForEntry,
8
+ generateTanstackRouterTypesSourceForEntry,
9
+ } from '../../src/cli/tanstackTypes';
7
10
 
8
11
  const execFileAsync = promisify(execFile);
9
12
 
@@ -244,3 +247,230 @@ describe('tanstack router type generation', () => {
244
247
  expect(routerGenTs).not.toContain('route__lang__layout.addChildren([');
245
248
  });
246
249
  });
250
+
251
+ describe('collectCanonicalRoutesForEntry', () => {
252
+ test('returns null for a route tree with no locale param and no canonical metadata', () => {
253
+ const result = collectCanonicalRoutesForEntry([
254
+ {
255
+ type: 'nested',
256
+ id: 'layout',
257
+ isRoot: true,
258
+ children: [
259
+ {
260
+ type: 'nested',
261
+ id: 'about/page',
262
+ path: 'about',
263
+ },
264
+ {
265
+ type: 'nested',
266
+ id: 'contact/page',
267
+ path: 'contact',
268
+ },
269
+ ],
270
+ },
271
+ ] as any);
272
+
273
+ expect(result).toBeNull();
274
+ });
275
+
276
+ test('strips leading :lang param and maps index under :lang to "/"', () => {
277
+ const result = collectCanonicalRoutesForEntry([
278
+ {
279
+ type: 'nested',
280
+ id: 'layout',
281
+ isRoot: true,
282
+ children: [
283
+ {
284
+ type: 'nested',
285
+ id: '(lang)/layout',
286
+ path: ':lang',
287
+ children: [
288
+ {
289
+ type: 'nested',
290
+ id: '(lang)/page',
291
+ index: true,
292
+ },
293
+ {
294
+ type: 'nested',
295
+ id: '(lang)/about/page',
296
+ path: 'about',
297
+ },
298
+ ],
299
+ },
300
+ ],
301
+ },
302
+ ] as any);
303
+
304
+ expect(result).not.toBeNull();
305
+ expect(result!['/']).toBe('Record<string, never>');
306
+ expect(result!['/about']).toBe('Record<string, never>');
307
+ });
308
+
309
+ test('converts :slug to $slug with required params type', () => {
310
+ const result = collectCanonicalRoutesForEntry([
311
+ {
312
+ type: 'nested',
313
+ id: 'layout',
314
+ isRoot: true,
315
+ children: [
316
+ {
317
+ type: 'nested',
318
+ id: '(lang)/layout',
319
+ path: ':lang',
320
+ children: [
321
+ {
322
+ type: 'nested',
323
+ id: '(lang)/products/(slug)/page',
324
+ path: 'products/:slug',
325
+ },
326
+ ],
327
+ },
328
+ ],
329
+ },
330
+ ] as any);
331
+
332
+ expect(result).not.toBeNull();
333
+ expect(result!['/products/$slug']).toBe('{ "slug": string }');
334
+ });
335
+
336
+ test('converts :slug? to optional {-$slug} with optional params type', () => {
337
+ const result = collectCanonicalRoutesForEntry([
338
+ {
339
+ type: 'nested',
340
+ id: 'layout',
341
+ isRoot: true,
342
+ children: [
343
+ {
344
+ type: 'nested',
345
+ id: '(lang)/layout',
346
+ path: ':lang',
347
+ children: [
348
+ {
349
+ type: 'nested',
350
+ id: '(lang)/optional/(slug$)/page',
351
+ path: 'optional/:slug?',
352
+ },
353
+ ],
354
+ },
355
+ ],
356
+ },
357
+ ] as any);
358
+
359
+ expect(result).not.toBeNull();
360
+ expect(result!['/optional/{-$slug}']).toBe('{ "slug"?: string }');
361
+ });
362
+
363
+ test('converts * splat to $ with optional _splat param', () => {
364
+ const result = collectCanonicalRoutesForEntry([
365
+ {
366
+ type: 'nested',
367
+ id: 'layout',
368
+ isRoot: true,
369
+ children: [
370
+ {
371
+ type: 'nested',
372
+ id: '(lang)/layout',
373
+ path: ':lang',
374
+ children: [
375
+ {
376
+ type: 'nested',
377
+ id: '(lang)/files/page',
378
+ path: 'files/*',
379
+ },
380
+ ],
381
+ },
382
+ ],
383
+ },
384
+ ] as any);
385
+
386
+ expect(result).not.toBeNull();
387
+ expect(result!['/files/$']).toBe("{ '_splat'?: string }");
388
+ });
389
+
390
+ test('collapses localized variants with shared modernCanonicalPath to one canonical key', () => {
391
+ // This reuses the same fixture shape as the 'preserves typed child trees'
392
+ // test but adds modernCanonicalPath fields as plugin-i18n now emits.
393
+ const result = collectCanonicalRoutesForEntry([
394
+ {
395
+ type: 'nested',
396
+ id: 'layout',
397
+ isRoot: true,
398
+ children: [
399
+ {
400
+ type: 'nested',
401
+ id: '(lang)/layout',
402
+ path: ':lang',
403
+ children: [
404
+ {
405
+ type: 'nested',
406
+ id: '(lang)/products/(slug)/page',
407
+ path: 'products/:slug',
408
+ modernCanonicalPath: '/products/:slug',
409
+ },
410
+ {
411
+ type: 'nested',
412
+ id: '(lang)/products/(slug)/page__localised_produkty_slug',
413
+ path: 'produkty/:slug',
414
+ modernCanonicalPath: '/products/:slug',
415
+ },
416
+ {
417
+ type: 'nested',
418
+ id: '(lang)/optional/(slug$)/page__localised_volitelne_slug',
419
+ path: 'volitelne/:slug?',
420
+ modernCanonicalPath: '/optional/:slug?',
421
+ },
422
+ ],
423
+ },
424
+ ],
425
+ },
426
+ ] as any);
427
+
428
+ expect(result).not.toBeNull();
429
+ // Two physical variants share the same canonical path — only one entry.
430
+ const keys = Object.keys(result!);
431
+ // /products/$slug and /optional/{-$slug} — exactly 2 keys with params
432
+ expect(keys.filter(k => k.startsWith('/products'))).toHaveLength(1);
433
+ expect(result!['/products/$slug']).toBe('{ "slug": string }');
434
+ expect(result!['/optional/{-$slug}']).toBe('{ "slug"?: string }');
435
+ // The Czech localized path must not appear as a separate key.
436
+ expect('/produkty/$slug' in result!).toBe(false);
437
+ });
438
+
439
+ test('output is sorted alphabetically by canonical key', () => {
440
+ const result = collectCanonicalRoutesForEntry([
441
+ {
442
+ type: 'nested',
443
+ id: 'layout',
444
+ isRoot: true,
445
+ children: [
446
+ {
447
+ type: 'nested',
448
+ id: '(lang)/layout',
449
+ path: ':lang',
450
+ children: [
451
+ {
452
+ type: 'nested',
453
+ id: '(lang)/products/(slug)/page',
454
+ path: 'products/:slug',
455
+ },
456
+ {
457
+ type: 'nested',
458
+ id: '(lang)/about/page',
459
+ path: 'about',
460
+ },
461
+ {
462
+ type: 'nested',
463
+ id: '(lang)/page',
464
+ index: true,
465
+ },
466
+ ],
467
+ },
468
+ ],
469
+ },
470
+ ] as any);
471
+
472
+ expect(result).not.toBeNull();
473
+ const keys = Object.keys(result!);
474
+ expect(keys).toEqual([...keys].sort((a, b) => a.localeCompare(b)));
475
+ });
476
+ });