@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.
- package/dist/cjs/cli/index.js +26 -4
- package/dist/cjs/cli/tanstackTypes.js +75 -0
- package/dist/esm/cli/index.mjs +25 -6
- package/dist/esm/cli/tanstackTypes.mjs +73 -1
- package/dist/esm-node/cli/index.mjs +25 -6
- package/dist/esm-node/cli/tanstackTypes.mjs +73 -1
- package/dist/types/cli/index.d.ts +3 -1
- package/dist/types/cli/tanstackTypes.d.ts +9 -0
- package/package.json +11 -11
- package/src/cli/index.ts +42 -2
- package/src/cli/tanstackTypes.ts +131 -0
- package/tests/router/cli.test.ts +76 -0
- package/tests/router/tanstackTypes.test.ts +231 -1
package/dist/cjs/cli/index.js
CHANGED
|
@@ -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];
|
package/dist/esm/cli/index.mjs
CHANGED
|
@@ -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.
|
|
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.
|
|
92
|
-
"@modern-js/runtime-utils": "npm:@bleedingdev/modern-js-runtime-utils@3.2.0-ultramodern.
|
|
93
|
-
"@modern-js/types": "npm:@bleedingdev/modern-js-types@3.2.0-ultramodern.
|
|
94
|
-
"@modern-js/utils": "npm:@bleedingdev/modern-js-utils@3.2.0-ultramodern.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
113
|
-
"@modern-js/runtime": "npm:@bleedingdev/modern-js-runtime@3.2.0-ultramodern.
|
|
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({
|
|
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
|
|
package/src/cli/tanstackTypes.ts
CHANGED
|
@@ -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> {
|
package/tests/router/cli.test.ts
CHANGED
|
@@ -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 {
|
|
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
|
+
});
|