@bleedingdev/modern-js-create 3.2.0-ultramodern.12 → 3.2.0-ultramodern.14
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/index.js +167 -17
- package/package.json +3 -3
- package/template/.github/workflows/ultramodern-gates.yml.handlebars +1 -1
- package/template/AGENTS.md +2 -0
- package/template/modern.config.ts.handlebars +18 -0
- package/template/package.json.handlebars +20 -2
- package/template/rstest.config.mts +8 -0
- package/template/scripts/validate-ultramodern.mjs.handlebars +177 -2
- package/template/src/modern-app-env.d.ts +2 -0
- package/template/src/routes/{page.tsx.handlebars → [lang]/page.tsx.handlebars} +67 -11
- package/template/src/routes/layout.tsx.handlebars +1 -1
- package/template/tests/rstest.setup.ts +4 -0
- package/template/tests/tsconfig.json +7 -0
- package/template/tests/ultramodern.contract.test.ts +67 -0
- package/template-workspace/AGENTS.md +2 -0
- package/template-workspace/scripts/validate-ultramodern-workspace.mjs.handlebars +59 -0
package/dist/index.js
CHANGED
|
@@ -1139,6 +1139,18 @@ import { moduleFederationPlugin } from '@module-federation/modern-js-v3';
|
|
|
1139
1139
|
|
|
1140
1140
|
const appId = '${app.id}';
|
|
1141
1141
|
const port = Number(process.env['${app.portEnv}'] ?? ${app.port});
|
|
1142
|
+
const configuredSiteUrl = process.env['MODERN_PUBLIC_SITE_URL'];
|
|
1143
|
+
const hasConfiguredSiteUrl = typeof configuredSiteUrl === 'string' && configuredSiteUrl.length > 0;
|
|
1144
|
+
const isProductionBuild =
|
|
1145
|
+
process.env['NODE_ENV'] === 'production' || process.argv.includes('build');
|
|
1146
|
+
|
|
1147
|
+
if (isProductionBuild && !hasConfiguredSiteUrl) {
|
|
1148
|
+
throw new Error(
|
|
1149
|
+
'MODERN_PUBLIC_SITE_URL must be set for production builds so canonical and hreflang URLs use the deployed origin.',
|
|
1150
|
+
);
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
const siteUrl = hasConfiguredSiteUrl ? configuredSiteUrl : \`http://localhost:\${port}\`;
|
|
1142
1154
|
|
|
1143
1155
|
export default defineConfig(
|
|
1144
1156
|
presetUltramodern(
|
|
@@ -1154,6 +1166,7 @@ export default defineConfig(
|
|
|
1154
1166
|
localeDetection: {
|
|
1155
1167
|
fallbackLanguage: 'en',
|
|
1156
1168
|
languages: ['en', 'cs'],
|
|
1169
|
+
localePathRedirect: true,
|
|
1157
1170
|
},
|
|
1158
1171
|
}),
|
|
1159
1172
|
tanstackRouterPlugin(),
|
|
@@ -1166,6 +1179,11 @@ export default defineConfig(
|
|
|
1166
1179
|
moduleFederationAppSSR: true,
|
|
1167
1180
|
},
|
|
1168
1181
|
},
|
|
1182
|
+
source: {
|
|
1183
|
+
globalVars: {
|
|
1184
|
+
ULTRAMODERN_SITE_URL: siteUrl,
|
|
1185
|
+
},
|
|
1186
|
+
},
|
|
1169
1187
|
},
|
|
1170
1188
|
{
|
|
1171
1189
|
appId,
|
|
@@ -1330,32 +1348,103 @@ export default defineRuntimeConfig({
|
|
|
1330
1348
|
});
|
|
1331
1349
|
`;
|
|
1332
1350
|
}
|
|
1351
|
+
function createLocalizedHeadComponent(includeLocationSuffix = false) {
|
|
1352
|
+
return `const fallbackLanguage = 'en';
|
|
1353
|
+
const supportedLanguages = ['en', 'cs'] as const;
|
|
1354
|
+
type SupportedLanguage = (typeof supportedLanguages)[number];
|
|
1355
|
+
|
|
1356
|
+
const isSupportedLanguage = (value: string): value is SupportedLanguage =>
|
|
1357
|
+
supportedLanguages.includes(value as SupportedLanguage);
|
|
1358
|
+
|
|
1359
|
+
const stripLanguagePrefix = (pathname: string) => {
|
|
1360
|
+
const segments = pathname.split('/').filter(Boolean);
|
|
1361
|
+
if (segments.length > 0 && isSupportedLanguage(segments[0] ?? '')) {
|
|
1362
|
+
segments.shift();
|
|
1363
|
+
}
|
|
1364
|
+
return \`/\${segments.join('/')}\`;
|
|
1365
|
+
};
|
|
1366
|
+
|
|
1367
|
+
const localizedPath = (pathname: string, language: SupportedLanguage) => {
|
|
1368
|
+
const pathWithoutLanguage = stripLanguagePrefix(pathname);
|
|
1369
|
+
return pathWithoutLanguage === '/' ? \`/\${language}\` : \`/\${language}\${pathWithoutLanguage}\`;
|
|
1370
|
+
};
|
|
1371
|
+
|
|
1372
|
+
const absoluteUrl = (pathname: string) => {
|
|
1373
|
+
const origin = ULTRAMODERN_SITE_URL.replace(/\\/+$/u, '');
|
|
1374
|
+
return \`\${origin}\${pathname}\`;
|
|
1375
|
+
};
|
|
1376
|
+
${includeLocationSuffix ? `
|
|
1377
|
+
const locationSuffix = (location: { hash?: unknown; search?: unknown; searchStr?: unknown }) => {
|
|
1378
|
+
const { hash, search, searchStr } = location;
|
|
1379
|
+
let locationSearch = '';
|
|
1380
|
+
if (typeof searchStr === 'string') {
|
|
1381
|
+
locationSearch = searchStr;
|
|
1382
|
+
} else if (typeof search === 'string') {
|
|
1383
|
+
locationSearch = search;
|
|
1384
|
+
}
|
|
1385
|
+
const locationHash = typeof hash === 'string' ? hash : '';
|
|
1386
|
+
return \`\${locationSearch}\${locationHash}\`;
|
|
1387
|
+
};
|
|
1388
|
+
` : ''}
|
|
1389
|
+
const LocalizedHead = () => {
|
|
1390
|
+
const { language } = useModernI18n();
|
|
1391
|
+
const location = useLocation();
|
|
1392
|
+
const currentLanguage = isSupportedLanguage(language) ? language : fallbackLanguage;
|
|
1393
|
+
const canonicalPath = localizedPath(location.pathname, currentLanguage);
|
|
1394
|
+
|
|
1395
|
+
return (
|
|
1396
|
+
<Helmet>
|
|
1397
|
+
<link rel="canonical" href={absoluteUrl(canonicalPath)} />
|
|
1398
|
+
{supportedLanguages.map((code) => (
|
|
1399
|
+
<link
|
|
1400
|
+
href={absoluteUrl(localizedPath(location.pathname, code))}
|
|
1401
|
+
hrefLang={code}
|
|
1402
|
+
key={code}
|
|
1403
|
+
rel="alternate"
|
|
1404
|
+
/>
|
|
1405
|
+
))}
|
|
1406
|
+
<link
|
|
1407
|
+
href={absoluteUrl(localizedPath(location.pathname, fallbackLanguage))}
|
|
1408
|
+
hrefLang="x-default"
|
|
1409
|
+
rel="alternate"
|
|
1410
|
+
/>
|
|
1411
|
+
</Helmet>
|
|
1412
|
+
);
|
|
1413
|
+
};
|
|
1414
|
+
`;
|
|
1415
|
+
}
|
|
1333
1416
|
function createShellPage() {
|
|
1334
|
-
return `import {
|
|
1417
|
+
return `import { Helmet } from '@modern-js/runtime/head';
|
|
1418
|
+
import { useModernI18n } from '@modern-js/plugin-i18n/runtime';
|
|
1419
|
+
import { useLocation } from '@modern-js/plugin-tanstack/runtime';
|
|
1335
1420
|
import { useTranslation } from 'react-i18next';
|
|
1336
1421
|
|
|
1337
1422
|
const remotes = ['remote-commerce', 'remote-identity', 'remote-design-system'];
|
|
1338
1423
|
|
|
1424
|
+
${createLocalizedHeadComponent(true)}
|
|
1339
1425
|
export default function ShellHome() {
|
|
1340
1426
|
const { t } = useTranslation();
|
|
1341
|
-
const {
|
|
1342
|
-
const
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1427
|
+
const { language } = useModernI18n();
|
|
1428
|
+
const location = useLocation();
|
|
1429
|
+
const currentLanguage = isSupportedLanguage(language) ? language : fallbackLanguage;
|
|
1430
|
+
const suffix = locationSuffix(location);
|
|
1431
|
+
const languageOptions = supportedLanguages.map((code) => ({
|
|
1432
|
+
code,
|
|
1433
|
+
href: \`\${localizedPath(location.pathname, code)}\${suffix}\`,
|
|
1434
|
+
label: t(\`language.\${code}\`),
|
|
1435
|
+
}));
|
|
1347
1436
|
return (
|
|
1348
1437
|
<main>
|
|
1438
|
+
<LocalizedHead />
|
|
1349
1439
|
<nav aria-label={t('language.switcher')}>
|
|
1350
1440
|
{languageOptions.map((option) => (
|
|
1351
|
-
<
|
|
1352
|
-
|
|
1441
|
+
<a
|
|
1442
|
+
aria-current={currentLanguage === option.code ? 'page' : undefined}
|
|
1443
|
+
href={option.href}
|
|
1353
1444
|
key={option.code}
|
|
1354
|
-
onClick={() => void changeLanguage(option.code)}
|
|
1355
|
-
type="button"
|
|
1356
1445
|
>
|
|
1357
1446
|
{option.label}
|
|
1358
|
-
</
|
|
1447
|
+
</a>
|
|
1359
1448
|
))}
|
|
1360
1449
|
</nav>
|
|
1361
1450
|
<h1>{t('shell.title')}</h1>
|
|
@@ -1371,13 +1460,18 @@ export default function ShellHome() {
|
|
|
1371
1460
|
`;
|
|
1372
1461
|
}
|
|
1373
1462
|
function createRemotePage(app) {
|
|
1374
|
-
return `import {
|
|
1463
|
+
return `import { Helmet } from '@modern-js/runtime/head';
|
|
1464
|
+
import { useModernI18n } from '@modern-js/plugin-i18n/runtime';
|
|
1465
|
+
import { useLocation } from '@modern-js/plugin-tanstack/runtime';
|
|
1466
|
+
import { useTranslation } from 'react-i18next';
|
|
1375
1467
|
|
|
1468
|
+
${createLocalizedHeadComponent()}
|
|
1376
1469
|
export default function ${toPascalCase(app.id)}Home() {
|
|
1377
1470
|
const { t } = useTranslation();
|
|
1378
1471
|
|
|
1379
1472
|
return (
|
|
1380
1473
|
<main>
|
|
1474
|
+
<LocalizedHead />
|
|
1381
1475
|
<h1>{t('remote.title')}</h1>
|
|
1382
1476
|
<p data-mf-role="${app.kind}">{t('remote.domain')}</p>
|
|
1383
1477
|
</main>
|
|
@@ -1837,14 +1931,14 @@ function createTemplateManifest(modernVersion, packageSource) {
|
|
|
1837
1931
|
function writeApp(targetDir, scope, app, packageSource) {
|
|
1838
1932
|
writeJson(targetDir, `${app.directory}/package.json`, createAppPackage(scope, app, packageSource));
|
|
1839
1933
|
writeJson(targetDir, `${app.directory}/tsconfig.json`, createPackageTsConfig(app.directory));
|
|
1840
|
-
writeFile(targetDir, `${app.directory}/src/modern-app-env.d.ts`, "/// <reference types='@modern-js/app-tools/types' />\n");
|
|
1934
|
+
writeFile(targetDir, `${app.directory}/src/modern-app-env.d.ts`, "/// <reference types='@modern-js/app-tools/types' />\n\ndeclare const ULTRAMODERN_SITE_URL: string;\n");
|
|
1841
1935
|
writeFile(targetDir, `${app.directory}/modern.config.ts`, createAppModernConfig(app));
|
|
1842
1936
|
writeFile(targetDir, `${app.directory}/src/modern.runtime.ts`, createAppRuntimeConfig());
|
|
1843
1937
|
writeJson(targetDir, `${app.directory}/config/public/locales/en/translation.json`, createEnglishTranslations(app));
|
|
1844
1938
|
writeJson(targetDir, `${app.directory}/config/public/locales/cs/translation.json`, createCzechTranslations(app));
|
|
1845
1939
|
writeFile(targetDir, `${app.directory}/module-federation.config.ts`, 'shell' === app.kind ? createShellModuleFederationConfig() : createRemoteModuleFederationConfig(app));
|
|
1846
1940
|
writeFile(targetDir, `${app.directory}/src/routes/layout.tsx`, createLayout(app.id));
|
|
1847
|
-
writeFile(targetDir, `${app.directory}/src/routes/page.tsx`, 'shell' === app.kind ? createShellPage() : createRemotePage(app));
|
|
1941
|
+
writeFile(targetDir, `${app.directory}/src/routes/[lang]/page.tsx`, 'shell' === app.kind ? createShellPage() : createRemotePage(app));
|
|
1848
1942
|
if ('vertical' === app.kind) {
|
|
1849
1943
|
writeFile(targetDir, `${app.directory}/src/remote-entry.tsx`, createRemoteEntry(app));
|
|
1850
1944
|
const widgetFile = 'remote-identity' === app.id ? 'identity-widget.tsx' : 'commerce-widget.tsx';
|
|
@@ -1934,7 +2028,6 @@ const templateIdPattern = /^[a-z0-9][a-z0-9._-]*$/;
|
|
|
1934
2028
|
const packageNamePattern = /^(?:@[a-z0-9._-]+\/)?[a-z0-9._-]+$/;
|
|
1935
2029
|
const requiredDeniedPaths = [
|
|
1936
2030
|
'.git/**',
|
|
1937
|
-
'.github/**',
|
|
1938
2031
|
'.npmrc',
|
|
1939
2032
|
'.yarnrc',
|
|
1940
2033
|
'.env',
|
|
@@ -2117,6 +2210,7 @@ function createBuiltinTemplateManifest(version) {
|
|
|
2117
2210
|
allowedPaths: [
|
|
2118
2211
|
'.agents/**',
|
|
2119
2212
|
'.browserslistrc',
|
|
2213
|
+
'.github/**',
|
|
2120
2214
|
'.gitignore',
|
|
2121
2215
|
'.modernjs/**',
|
|
2122
2216
|
'.nvmrc',
|
|
@@ -2129,10 +2223,12 @@ function createBuiltinTemplateManifest(version) {
|
|
|
2129
2223
|
'oxlint.config.ts',
|
|
2130
2224
|
'package.json',
|
|
2131
2225
|
'postcss.config.mjs',
|
|
2226
|
+
'rstest.config.mts',
|
|
2132
2227
|
"scripts/**",
|
|
2133
2228
|
'shared/**',
|
|
2134
2229
|
'src/**',
|
|
2135
2230
|
'tailwind.config.ts',
|
|
2231
|
+
'tests/**',
|
|
2136
2232
|
'tsconfig.json'
|
|
2137
2233
|
],
|
|
2138
2234
|
deniedPaths: requiredDeniedPaths,
|
|
@@ -2161,10 +2257,13 @@ function createBuiltinTemplateManifest(version) {
|
|
|
2161
2257
|
postMaterializationValidation: [
|
|
2162
2258
|
'ultramodern-contract-check',
|
|
2163
2259
|
'dependency-install-with-lifecycle-deny',
|
|
2260
|
+
'package-source-retained',
|
|
2261
|
+
'rstest-smoke-tests',
|
|
2164
2262
|
'template-manifest-retained'
|
|
2165
2263
|
],
|
|
2166
2264
|
expectedCommands: [
|
|
2167
2265
|
"pnpm install --ignore-scripts",
|
|
2266
|
+
'pnpm test',
|
|
2168
2267
|
'pnpm run ultramodern:check'
|
|
2169
2268
|
]
|
|
2170
2269
|
}
|
|
@@ -2393,6 +2492,45 @@ function singleAppModernPackageSpecifier(packageName, packageSource, useWorkspac
|
|
|
2393
2492
|
if ('install' !== packageSource.strategy || !packageSource.aliasScope) return packageSource.modernPackageVersion;
|
|
2394
2493
|
return `npm:${src_modernAliasPackageName(packageName, packageSource)}@${packageSource.modernPackageVersion}`;
|
|
2395
2494
|
}
|
|
2495
|
+
const singleAppModernPackages = [
|
|
2496
|
+
'@modern-js/runtime',
|
|
2497
|
+
'@modern-js/app-tools',
|
|
2498
|
+
'@modern-js/tsconfig',
|
|
2499
|
+
'@modern-js/plugin-i18n',
|
|
2500
|
+
'@modern-js/plugin-tanstack',
|
|
2501
|
+
'@modern-js/plugin-bff',
|
|
2502
|
+
'@modern-js/adapter-rstest'
|
|
2503
|
+
];
|
|
2504
|
+
function createSingleAppPackageSourceEvidence(packageSource, useWorkspaceProtocol) {
|
|
2505
|
+
const strategy = useWorkspaceProtocol ? 'workspace' : 'install';
|
|
2506
|
+
const specifier = useWorkspaceProtocol ? 'workspace:*' : packageSource.modernPackageVersion;
|
|
2507
|
+
const aliases = 'install' === strategy && packageSource.aliasScope ? Object.fromEntries(singleAppModernPackages.map((packageName)=>[
|
|
2508
|
+
packageName,
|
|
2509
|
+
src_modernAliasPackageName(packageName, packageSource)
|
|
2510
|
+
])) : void 0;
|
|
2511
|
+
return {
|
|
2512
|
+
schemaVersion: 1,
|
|
2513
|
+
preset: 'presetUltramodern',
|
|
2514
|
+
strategy,
|
|
2515
|
+
modernPackages: {
|
|
2516
|
+
specifier,
|
|
2517
|
+
packages: singleAppModernPackages,
|
|
2518
|
+
...packageSource.registry ? {
|
|
2519
|
+
registry: packageSource.registry
|
|
2520
|
+
} : {},
|
|
2521
|
+
...aliases ? {
|
|
2522
|
+
aliases
|
|
2523
|
+
} : {}
|
|
2524
|
+
}
|
|
2525
|
+
};
|
|
2526
|
+
}
|
|
2527
|
+
function writeSingleAppPackageSourceEvidence(targetDir, packageSource, useWorkspaceProtocol) {
|
|
2528
|
+
const evidencePath = node_path.join(targetDir, '.modernjs', 'ultramodern-package-source.json');
|
|
2529
|
+
node_fs.mkdirSync(node_path.dirname(evidencePath), {
|
|
2530
|
+
recursive: true
|
|
2531
|
+
});
|
|
2532
|
+
node_fs.writeFileSync(evidencePath, `${JSON.stringify(createSingleAppPackageSourceEvidence(packageSource, useWorkspaceProtocol), null, 2)}\n`);
|
|
2533
|
+
}
|
|
2396
2534
|
function isDirectoryEmpty(dirPath) {
|
|
2397
2535
|
if (!node_fs.existsSync(dirPath)) return false;
|
|
2398
2536
|
try {
|
|
@@ -2515,6 +2653,7 @@ async function main() {
|
|
|
2515
2653
|
version: useWorkspaceProtocol ? 'workspace:*' : packageSource.modernPackageVersion,
|
|
2516
2654
|
runtimeVersion: singleAppModernPackageSpecifier('@modern-js/runtime', packageSource, useWorkspaceProtocol),
|
|
2517
2655
|
appToolsVersion: singleAppModernPackageSpecifier('@modern-js/app-tools', packageSource, useWorkspaceProtocol),
|
|
2656
|
+
adapterRstestVersion: singleAppModernPackageSpecifier('@modern-js/adapter-rstest', packageSource, useWorkspaceProtocol),
|
|
2518
2657
|
tsconfigVersion: singleAppModernPackageSpecifier('@modern-js/tsconfig', packageSource, useWorkspaceProtocol),
|
|
2519
2658
|
pluginTanstackVersion: singleAppModernPackageSpecifier('@modern-js/plugin-tanstack', packageSource, useWorkspaceProtocol),
|
|
2520
2659
|
pluginBffVersion: singleAppModernPackageSpecifier('@modern-js/plugin-bff', packageSource, useWorkspaceProtocol),
|
|
@@ -2528,6 +2667,14 @@ async function main() {
|
|
|
2528
2667
|
const targetPackageJson = node_path.join(targetDir, 'package.json');
|
|
2529
2668
|
const packageJson = JSON.parse(node_fs.readFileSync(targetPackageJson, 'utf-8'));
|
|
2530
2669
|
packageJson.name = generatedPackageName;
|
|
2670
|
+
packageJson.modernjs = {
|
|
2671
|
+
...packageJson.modernjs ?? {},
|
|
2672
|
+
preset: 'presetUltramodern',
|
|
2673
|
+
packageSource: {
|
|
2674
|
+
strategy: useWorkspaceProtocol ? 'workspace' : 'install',
|
|
2675
|
+
config: './.modernjs/ultramodern-package-source.json'
|
|
2676
|
+
}
|
|
2677
|
+
};
|
|
2531
2678
|
if (isSubproject) {
|
|
2532
2679
|
delete packageJson['lint-staged'];
|
|
2533
2680
|
delete packageJson['simple-git-hooks'];
|
|
@@ -2550,6 +2697,7 @@ async function main() {
|
|
|
2550
2697
|
}
|
|
2551
2698
|
node_fs.writeFileSync(targetPackageJson, `${JSON.stringify(packageJson, null, 2)}\n`);
|
|
2552
2699
|
writeTemplateManifestEvidence(targetDir, templateManifest);
|
|
2700
|
+
writeSingleAppPackageSourceEvidence(targetDir, packageSource, useWorkspaceProtocol);
|
|
2553
2701
|
const dim = '\x1b[2m\x1b[3m';
|
|
2554
2702
|
const reset = '\x1b[0m';
|
|
2555
2703
|
console.log(`${i18n.t(localeKeys.message.success)}\n`);
|
|
@@ -2566,6 +2714,7 @@ function copyTemplate(src, dest, options) {
|
|
|
2566
2714
|
});
|
|
2567
2715
|
const excludeInSubproject = [
|
|
2568
2716
|
'.agents',
|
|
2717
|
+
'.github',
|
|
2569
2718
|
'.gitignore.handlebars',
|
|
2570
2719
|
'AGENTS.md',
|
|
2571
2720
|
'.npmrc',
|
|
@@ -2595,6 +2744,7 @@ function copyTemplate(src, dest, options) {
|
|
|
2595
2744
|
version: options.version,
|
|
2596
2745
|
runtimeVersion: options.runtimeVersion,
|
|
2597
2746
|
appToolsVersion: options.appToolsVersion,
|
|
2747
|
+
adapterRstestVersion: options.adapterRstestVersion,
|
|
2598
2748
|
tsconfigVersion: options.tsconfigVersion,
|
|
2599
2749
|
pluginTanstackVersion: options.pluginTanstackVersion,
|
|
2600
2750
|
pluginBffVersion: options.pluginBffVersion,
|
|
@@ -2606,7 +2756,7 @@ function copyTemplate(src, dest, options) {
|
|
|
2606
2756
|
useHonoBff: 'hono' === options.bffRuntime,
|
|
2607
2757
|
bffRuntime: options.bffRuntime,
|
|
2608
2758
|
enableTailwind: options.enableTailwind,
|
|
2609
|
-
|
|
2759
|
+
routerRuntimeImport: 'tanstack' === options.routerFramework ? '@modern-js/plugin-tanstack/runtime' : '@modern-js/runtime/router'
|
|
2610
2760
|
});
|
|
2611
2761
|
if (0 === rendered.trim().length) continue;
|
|
2612
2762
|
destPath = destPath.replace(/\.handlebars$/, '');
|
package/package.json
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"engines": {
|
|
22
22
|
"node": ">=20"
|
|
23
23
|
},
|
|
24
|
-
"version": "3.2.0-ultramodern.
|
|
24
|
+
"version": "3.2.0-ultramodern.14",
|
|
25
25
|
"types": "./dist/types/index.d.ts",
|
|
26
26
|
"main": "./dist/index.js",
|
|
27
27
|
"bin": {
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"@types/node": "^25.8.0",
|
|
42
42
|
"@typescript/native-preview": "7.0.0-dev.20260516.1",
|
|
43
43
|
"tsx": "^4.22.0",
|
|
44
|
-
"@modern-js/i18n-utils": "npm:@bleedingdev/modern-js-i18n-utils@3.2.0-ultramodern.
|
|
44
|
+
"@modern-js/i18n-utils": "npm:@bleedingdev/modern-js-i18n-utils@3.2.0-ultramodern.14"
|
|
45
45
|
},
|
|
46
46
|
"publishConfig": {
|
|
47
47
|
"registry": "https://registry.npmjs.org/",
|
|
@@ -54,6 +54,6 @@
|
|
|
54
54
|
"start": "node ./dist/index.js"
|
|
55
55
|
},
|
|
56
56
|
"ultramodern": {
|
|
57
|
-
"frameworkVersion": "3.2.0-ultramodern.
|
|
57
|
+
"frameworkVersion": "3.2.0-ultramodern.14"
|
|
58
58
|
}
|
|
59
59
|
}
|
package/template/AGENTS.md
CHANGED
|
@@ -14,6 +14,8 @@ This project is generated for Codex-first UltraModern.js work.
|
|
|
14
14
|
|
|
15
15
|
Runtime i18n is enabled by default. Agents must put user-visible UI copy in `config/public/locales/<lang>/translation.json` and render it through `react-i18next` or `@modern-js/plugin-i18n/runtime`. Do not add hardcoded JSX text, `aria-label`, `title`, `alt`, or `placeholder` strings unless the value is a non-translatable technical token.
|
|
16
16
|
|
|
17
|
+
Routes are locale-prefixed by default through `localePathRedirect: true`. Keep localized pages under `src/routes/[lang]`, use links for language switching, and preserve canonical plus `hreflang` metadata. Production builds fail unless `MODERN_PUBLIC_SITE_URL` is set, so deployed canonical URLs always use the production origin.
|
|
18
|
+
|
|
17
19
|
## Private Skills
|
|
18
20
|
|
|
19
21
|
Private orchestration skills are not vendored into this template. If you are authorized for `TechsioCZ/skills`, run:
|
|
@@ -12,6 +12,18 @@ const enableTelemetryExporters =
|
|
|
12
12
|
process.env['MODERN_BASELINE_ENABLE_TELEMETRY_EXPORTERS'] !== 'false';
|
|
13
13
|
const telemetryFailLoudStartup = process.env['MODERN_TELEMETRY_FAIL_LOUD_STARTUP'] !== 'false';
|
|
14
14
|
const otlpEndpoint = process.env['MODERN_TELEMETRY_OTLP_ENDPOINT'];
|
|
15
|
+
const configuredSiteUrl = process.env['MODERN_PUBLIC_SITE_URL'];
|
|
16
|
+
const hasConfiguredSiteUrl = typeof configuredSiteUrl === 'string' && configuredSiteUrl.length > 0;
|
|
17
|
+
const isProductionBuild =
|
|
18
|
+
process.env['NODE_ENV'] === 'production' || process.argv.includes('build');
|
|
19
|
+
|
|
20
|
+
if (isProductionBuild && !hasConfiguredSiteUrl) {
|
|
21
|
+
throw new Error(
|
|
22
|
+
'MODERN_PUBLIC_SITE_URL must be set for production builds so canonical and hreflang URLs use the deployed origin.',
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const siteUrl = hasConfiguredSiteUrl ? configuredSiteUrl : 'http://localhost:8080';
|
|
15
27
|
const victoriaMetricsEndpoint = process.env['MODERN_TELEMETRY_VICTORIA_ENDPOINT'];
|
|
16
28
|
|
|
17
29
|
// https://bleedingdev.github.io/ultramodern.js/configure/app/usage.html
|
|
@@ -32,6 +44,7 @@ export default defineConfig(
|
|
|
32
44
|
localeDetection: {
|
|
33
45
|
fallbackLanguage: 'en',
|
|
34
46
|
languages: ['en', 'cs'],
|
|
47
|
+
localePathRedirect: true,
|
|
35
48
|
},
|
|
36
49
|
}),
|
|
37
50
|
{{#if isTanstackRouter}}
|
|
@@ -39,6 +52,11 @@ export default defineConfig(
|
|
|
39
52
|
{{/if}}{{#if enableBff}}
|
|
40
53
|
bffPlugin(),
|
|
41
54
|
{{/if}} ],
|
|
55
|
+
source: {
|
|
56
|
+
globalVars: {
|
|
57
|
+
ULTRAMODERN_SITE_URL: siteUrl,
|
|
58
|
+
},
|
|
59
|
+
},
|
|
42
60
|
},
|
|
43
61
|
{
|
|
44
62
|
appId,
|
|
@@ -1,17 +1,22 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "{{packageName}}",
|
|
3
3
|
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
4
5
|
"type": "module",
|
|
6
|
+
"packageManager": "pnpm@11.1.2",
|
|
5
7
|
"scripts": {
|
|
6
8
|
"reset": "npx rimraf node_modules ./**/node_modules",
|
|
7
9
|
"dev": "modern dev",
|
|
8
10
|
"build": "modern build",
|
|
9
11
|
"serve": "modern serve",
|
|
12
|
+
"test": "rstest run",
|
|
10
13
|
"typecheck": "node -e \"const fs = require('node:fs'); const { execFileSync, spawnSync } = require('node:child_process'); const bin = execFileSync('effect-tsgo', ['get-exe-path'], { encoding: 'utf8' }).trim(); if (process.platform !== 'win32') fs.chmodSync(bin, 0o755); const result = spawnSync(bin, ['--noEmit', '-p', 'tsconfig.json'], { stdio: 'inherit' }); process.exit(result.status ?? 1);\"",
|
|
11
14
|
"i18n:check": "node ./scripts/check-i18n-strings.mjs",
|
|
15
|
+
{{#unless isSubproject}}
|
|
12
16
|
"skills:install": "node ./scripts/bootstrap-agent-skills.mjs",
|
|
13
17
|
"skills:check": "node ./scripts/bootstrap-agent-skills.mjs --check",
|
|
14
|
-
|
|
18
|
+
{{/unless}}
|
|
19
|
+
"ultramodern:check": "{{#unless isSubproject}}pnpm format:check && pnpm lint && {{/unless}}pnpm typecheck && pnpm i18n:check && pnpm test{{#unless isSubproject}} && pnpm skills:check{{/unless}} && node ./scripts/validate-ultramodern.mjs"{{#unless isSubproject}},
|
|
15
20
|
"format": "oxfmt .",
|
|
16
21
|
"format:check": "oxfmt --check .",
|
|
17
22
|
"lint": "oxlint .",
|
|
@@ -32,16 +37,20 @@
|
|
|
32
37
|
},
|
|
33
38
|
"devDependencies": {
|
|
34
39
|
"@effect/tsgo": "0.7.3",
|
|
40
|
+
"@modern-js/adapter-rstest": "{{adapterRstestVersion}}",
|
|
35
41
|
"@modern-js/app-tools": "{{appToolsVersion}}",
|
|
36
42
|
{{#if enableBff}} "@modern-js/plugin-bff": "{{pluginBffVersion}}",
|
|
37
43
|
{{/if}} "@modern-js/tsconfig": "{{tsconfigVersion}}",
|
|
44
|
+
"@rstest/core": "0.10.0",
|
|
38
45
|
{{#if enableTailwind}}
|
|
39
46
|
"@tailwindcss/postcss": "^4.1.18",
|
|
40
47
|
{{/if}}
|
|
48
|
+
"@testing-library/jest-dom": "^6.9.1",
|
|
41
49
|
"@types/node": "^20",
|
|
42
50
|
"@types/react": "^19.1.8",
|
|
43
51
|
"@types/react-dom": "^19.1.6",
|
|
44
52
|
"@typescript/native-preview": "7.0.0-dev.20260518.1",
|
|
53
|
+
"happy-dom": "^20.8.9",
|
|
45
54
|
{{#unless isSubproject}}
|
|
46
55
|
"lint-staged": "~15.4.0",
|
|
47
56
|
"oxfmt": "0.50.0",
|
|
@@ -62,6 +71,15 @@
|
|
|
62
71
|
]
|
|
63
72
|
}{{/unless}},
|
|
64
73
|
"engines": {
|
|
65
|
-
"node": ">=20"
|
|
74
|
+
"node": ">=20",
|
|
75
|
+
"pnpm": ">=11.0.0"
|
|
76
|
+
},
|
|
77
|
+
"pnpm": {
|
|
78
|
+
"onlyBuiltDependencies": [
|
|
79
|
+
"@swc/core",
|
|
80
|
+
"core-js",
|
|
81
|
+
"esbuild",
|
|
82
|
+
"msgpackr-extract"
|
|
83
|
+
]
|
|
66
84
|
}
|
|
67
85
|
}
|
|
@@ -3,6 +3,11 @@ import path from 'node:path';
|
|
|
3
3
|
|
|
4
4
|
const configPath = path.resolve(process.cwd(), 'modern.config.ts');
|
|
5
5
|
const templateManifestPath = path.resolve(process.cwd(), '.modernjs/mv-template-manifest.json');
|
|
6
|
+
const packageSourcePath = path.resolve(
|
|
7
|
+
process.cwd(),
|
|
8
|
+
'.modernjs/ultramodern-package-source.json',
|
|
9
|
+
);
|
|
10
|
+
const isSubproject = {{isSubproject}};
|
|
6
11
|
|
|
7
12
|
if (!fs.existsSync(configPath)) {
|
|
8
13
|
console.error('modern.config.ts not found');
|
|
@@ -17,6 +22,10 @@ const requiredTokens = [
|
|
|
17
22
|
'enableBffRequestId',
|
|
18
23
|
'enableTelemetryExporters',
|
|
19
24
|
'i18nPlugin(',
|
|
25
|
+
'localePathRedirect: true',
|
|
26
|
+
'ULTRAMODERN_SITE_URL',
|
|
27
|
+
'MODERN_PUBLIC_SITE_URL must be set for production builds',
|
|
28
|
+
'globalVars',
|
|
20
29
|
];
|
|
21
30
|
const missing = requiredTokens.filter((token) => !content.includes(token));
|
|
22
31
|
|
|
@@ -33,7 +42,6 @@ if (!fs.existsSync(templateManifestPath)) {
|
|
|
33
42
|
const templateManifest = JSON.parse(fs.readFileSync(templateManifestPath, 'utf-8'));
|
|
34
43
|
const requiredDeniedPaths = [
|
|
35
44
|
'.git/**',
|
|
36
|
-
'.github/**',
|
|
37
45
|
'.npmrc',
|
|
38
46
|
'.yarnrc',
|
|
39
47
|
'.env',
|
|
@@ -44,17 +52,28 @@ const requiredDeniedPaths = [
|
|
|
44
52
|
const requiredPostMaterialization = [
|
|
45
53
|
'ultramodern-contract-check',
|
|
46
54
|
'dependency-install-with-lifecycle-deny',
|
|
55
|
+
'package-source-retained',
|
|
56
|
+
'rstest-smoke-tests',
|
|
47
57
|
'template-manifest-retained',
|
|
48
58
|
];
|
|
49
59
|
const requiredPaths = [
|
|
60
|
+
{{#unless isSubproject}}
|
|
50
61
|
'AGENTS.md',
|
|
51
62
|
'.agents/skills-lock.json',
|
|
63
|
+
'.github/workflows/ultramodern-gates.yml',
|
|
52
64
|
'oxlint.config.ts',
|
|
53
65
|
'oxfmt.config.ts',
|
|
54
66
|
'scripts/bootstrap-agent-skills.mjs',
|
|
67
|
+
{{/unless}}
|
|
68
|
+
'.modernjs/ultramodern-package-source.json',
|
|
69
|
+
'rstest.config.mts',
|
|
55
70
|
'scripts/check-i18n-strings.mjs',
|
|
56
71
|
'config/public/locales/en/translation.json',
|
|
57
72
|
'config/public/locales/cs/translation.json',
|
|
73
|
+
'src/modern-app-env.d.ts',
|
|
74
|
+
'src/routes/[lang]/page.tsx',
|
|
75
|
+
'tests/rstest.setup.ts',
|
|
76
|
+
'tests/ultramodern.contract.test.ts',
|
|
58
77
|
];
|
|
59
78
|
const manifestErrors = [];
|
|
60
79
|
|
|
@@ -65,6 +84,38 @@ for (const requiredPath of requiredPaths) {
|
|
|
65
84
|
}
|
|
66
85
|
}
|
|
67
86
|
|
|
87
|
+
if (fs.existsSync(path.resolve(process.cwd(), 'src/routes/page.tsx'))) {
|
|
88
|
+
console.error('src/routes/page.tsx must move under src/routes/[lang]/page.tsx');
|
|
89
|
+
process.exit(1);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const pageContent = fs.readFileSync(
|
|
93
|
+
path.resolve(process.cwd(), 'src/routes/[lang]/page.tsx'),
|
|
94
|
+
'utf-8',
|
|
95
|
+
);
|
|
96
|
+
for (const token of [
|
|
97
|
+
'rel="canonical"',
|
|
98
|
+
'rel="alternate"',
|
|
99
|
+
'hrefLang="x-default"',
|
|
100
|
+
'localizedPath(',
|
|
101
|
+
'<a',
|
|
102
|
+
{{#if isTanstackRouter}}
|
|
103
|
+
"@modern-js/plugin-tanstack/runtime",
|
|
104
|
+
{{/if}}
|
|
105
|
+
]) {
|
|
106
|
+
if (!pageContent.includes(token)) {
|
|
107
|
+
console.error(`Localized route is missing ${token}`);
|
|
108
|
+
process.exit(1);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
{{#if isTanstackRouter}}
|
|
112
|
+
const deprecatedTanstackRuntime = '@modern-js/runtime/' + 'tanstack-router';
|
|
113
|
+
if (pageContent.includes(deprecatedTanstackRuntime)) {
|
|
114
|
+
console.error('Localized route must import TanStack runtime from @modern-js/plugin-tanstack/runtime');
|
|
115
|
+
process.exit(1);
|
|
116
|
+
}
|
|
117
|
+
{{/if}}
|
|
118
|
+
|
|
68
119
|
if (templateManifest.schemaVersion !== 1) {
|
|
69
120
|
manifestErrors.push('schemaVersion');
|
|
70
121
|
}
|
|
@@ -111,22 +162,32 @@ if (manifestErrors.length > 0) {
|
|
|
111
162
|
const packageJson = JSON.parse(
|
|
112
163
|
fs.readFileSync(path.resolve(process.cwd(), 'package.json'), 'utf-8'),
|
|
113
164
|
);
|
|
165
|
+
const packageSource = JSON.parse(fs.readFileSync(packageSourcePath, 'utf-8'));
|
|
114
166
|
const unresolvedTemplateMarker = String.fromCodePoint(123, 123);
|
|
115
167
|
if (JSON.stringify(packageJson).includes(unresolvedTemplateMarker)) {
|
|
116
168
|
console.error('package.json contains unresolved template markers');
|
|
117
169
|
process.exit(1);
|
|
118
170
|
}
|
|
171
|
+
if (JSON.stringify(packageSource).includes(unresolvedTemplateMarker)) {
|
|
172
|
+
console.error('package source metadata contains unresolved template markers');
|
|
173
|
+
process.exit(1);
|
|
174
|
+
}
|
|
175
|
+
{{#unless isSubproject}}
|
|
119
176
|
const skillsLock = JSON.parse(
|
|
120
177
|
fs.readFileSync(path.resolve(process.cwd(), '.agents/skills-lock.json'), 'utf-8'),
|
|
121
178
|
);
|
|
179
|
+
{{/unless}}
|
|
122
180
|
const requiredScripts = {
|
|
181
|
+
'i18n:check': 'node ./scripts/check-i18n-strings.mjs',
|
|
182
|
+
test: 'rstest run',
|
|
183
|
+
{{#unless isSubproject}}
|
|
123
184
|
format: 'oxfmt .',
|
|
124
185
|
'format:check': 'oxfmt --check .',
|
|
125
|
-
'i18n:check': 'node ./scripts/check-i18n-strings.mjs',
|
|
126
186
|
lint: 'oxlint .',
|
|
127
187
|
'lint:fix': 'oxlint . --fix',
|
|
128
188
|
'skills:check': 'node ./scripts/bootstrap-agent-skills.mjs --check',
|
|
129
189
|
'skills:install': 'node ./scripts/bootstrap-agent-skills.mjs',
|
|
190
|
+
{{/unless}}
|
|
130
191
|
};
|
|
131
192
|
|
|
132
193
|
for (const [scriptName, scriptCommand] of Object.entries(requiredScripts)) {
|
|
@@ -144,6 +205,112 @@ if (
|
|
|
144
205
|
process.exit(1);
|
|
145
206
|
}
|
|
146
207
|
|
|
208
|
+
if (!packageJson.scripts?.['ultramodern:check']?.includes('pnpm test')) {
|
|
209
|
+
console.error('ultramodern:check must run the generated Rstest suite');
|
|
210
|
+
process.exit(1);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (packageJson.private !== true) {
|
|
214
|
+
console.error('Generated app package must be private by default');
|
|
215
|
+
process.exit(1);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (packageJson.packageManager !== 'pnpm@11.1.2') {
|
|
219
|
+
console.error('Generated app package must pin pnpm@11.1.2');
|
|
220
|
+
process.exit(1);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (packageJson.engines?.pnpm !== '>=11.0.0') {
|
|
224
|
+
console.error('Generated app package must require pnpm >=11.0.0');
|
|
225
|
+
process.exit(1);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (packageJson.modernjs?.preset !== 'presetUltramodern') {
|
|
229
|
+
console.error('package.json must declare presetUltramodern metadata');
|
|
230
|
+
process.exit(1);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (
|
|
234
|
+
packageJson.modernjs?.packageSource?.config !== './.modernjs/ultramodern-package-source.json'
|
|
235
|
+
) {
|
|
236
|
+
console.error('package.json must retain package source metadata location');
|
|
237
|
+
process.exit(1);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (packageSource.schemaVersion !== 1) {
|
|
241
|
+
console.error('Package source metadata must use schemaVersion 1');
|
|
242
|
+
process.exit(1);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (packageSource.preset !== 'presetUltramodern') {
|
|
246
|
+
console.error('Package source metadata must declare presetUltramodern');
|
|
247
|
+
process.exit(1);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (packageSource.strategy !== 'workspace' && packageSource.strategy !== 'install') {
|
|
251
|
+
console.error('Package source strategy must be workspace or install');
|
|
252
|
+
process.exit(1);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const expectedModernPackages = [
|
|
256
|
+
'@modern-js/runtime',
|
|
257
|
+
'@modern-js/app-tools',
|
|
258
|
+
'@modern-js/tsconfig',
|
|
259
|
+
'@modern-js/plugin-i18n',
|
|
260
|
+
'@modern-js/plugin-tanstack',
|
|
261
|
+
'@modern-js/plugin-bff',
|
|
262
|
+
'@modern-js/adapter-rstest',
|
|
263
|
+
];
|
|
264
|
+
|
|
265
|
+
for (const packageName of expectedModernPackages) {
|
|
266
|
+
if (!packageSource.modernPackages?.packages?.includes(packageName)) {
|
|
267
|
+
console.error(`Package source metadata must include ${packageName}`);
|
|
268
|
+
process.exit(1);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const expectedModernSpecifier = packageSource.modernPackages?.specifier;
|
|
273
|
+
if (typeof expectedModernSpecifier !== 'string' || expectedModernSpecifier.length === 0) {
|
|
274
|
+
console.error('Package source metadata must provide a Modern package specifier');
|
|
275
|
+
process.exit(1);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const expectedModernDependency = (packageName) => {
|
|
279
|
+
const alias = packageSource.modernPackages?.aliases?.[packageName];
|
|
280
|
+
return typeof alias === 'string'
|
|
281
|
+
? `npm:${alias}@${expectedModernSpecifier}`
|
|
282
|
+
: expectedModernSpecifier;
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
for (const packageName of [
|
|
286
|
+
'@modern-js/runtime',
|
|
287
|
+
'@modern-js/plugin-i18n',
|
|
288
|
+
'@modern-js/plugin-tanstack',
|
|
289
|
+
]) {
|
|
290
|
+
if (
|
|
291
|
+
packageJson.dependencies?.[packageName] &&
|
|
292
|
+
packageJson.dependencies[packageName] !== expectedModernDependency(packageName)
|
|
293
|
+
) {
|
|
294
|
+
console.error(`Dependency ${packageName} must match package source metadata`);
|
|
295
|
+
process.exit(1);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
for (const packageName of [
|
|
300
|
+
'@modern-js/app-tools',
|
|
301
|
+
'@modern-js/adapter-rstest',
|
|
302
|
+
'@modern-js/tsconfig',
|
|
303
|
+
'@modern-js/plugin-bff',
|
|
304
|
+
]) {
|
|
305
|
+
if (
|
|
306
|
+
packageJson.devDependencies?.[packageName] &&
|
|
307
|
+
packageJson.devDependencies[packageName] !== expectedModernDependency(packageName)
|
|
308
|
+
) {
|
|
309
|
+
console.error(`Dev dependency ${packageName} must match package source metadata`);
|
|
310
|
+
process.exit(1);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
147
314
|
for (const dependency of ['@modern-js/plugin-i18n', 'i18next', 'react-i18next']) {
|
|
148
315
|
if (!packageJson.dependencies?.[dependency]) {
|
|
149
316
|
console.error(`Missing dependency: ${dependency}`);
|
|
@@ -153,10 +320,16 @@ for (const dependency of ['@modern-js/plugin-i18n', 'i18next', 'react-i18next'])
|
|
|
153
320
|
|
|
154
321
|
for (const dependency of [
|
|
155
322
|
'@effect/tsgo',
|
|
323
|
+
'@modern-js/adapter-rstest',
|
|
324
|
+
'@rstest/core',
|
|
325
|
+
'@testing-library/jest-dom',
|
|
156
326
|
'@typescript/native-preview',
|
|
327
|
+
'happy-dom',
|
|
328
|
+
{{#unless isSubproject}}
|
|
157
329
|
'oxlint',
|
|
158
330
|
'oxfmt',
|
|
159
331
|
'ultracite',
|
|
332
|
+
{{/unless}}
|
|
160
333
|
]) {
|
|
161
334
|
if (!packageJson.devDependencies?.[dependency]) {
|
|
162
335
|
console.error(`Missing devDependency: ${dependency}`);
|
|
@@ -164,6 +337,7 @@ for (const dependency of [
|
|
|
164
337
|
}
|
|
165
338
|
}
|
|
166
339
|
|
|
340
|
+
{{#unless isSubproject}}
|
|
167
341
|
const privateSource = skillsLock.sources?.find(
|
|
168
342
|
(source) => source.repository === 'https://github.com/TechsioCZ/skills',
|
|
169
343
|
);
|
|
@@ -174,5 +348,6 @@ for (const skillName of ['plan-graph', 'dag', 'subagent-graph', 'helm', 'debugge
|
|
|
174
348
|
process.exit(1);
|
|
175
349
|
}
|
|
176
350
|
}
|
|
351
|
+
{{/unless}}
|
|
177
352
|
|
|
178
353
|
console.log('Ultramodern contract check passed.');
|
|
@@ -1,19 +1,62 @@
|
|
|
1
1
|
import { Helmet } from '@modern-js/runtime/head';
|
|
2
2
|
import { useModernI18n } from '@modern-js/plugin-i18n/runtime';
|
|
3
|
+
import { useLocation } from '{{routerRuntimeImport}}';
|
|
3
4
|
{{#if useEffectBff}}import effectBff from '@api/effect/index';
|
|
4
5
|
import { Effect } from '@modern-js/plugin-bff/effect-client';
|
|
5
6
|
import { useEffect, useState } from 'react';
|
|
6
7
|
{{/if}}
|
|
7
8
|
import { useTranslation } from 'react-i18next';
|
|
8
|
-
import '
|
|
9
|
+
import '../index.css';
|
|
10
|
+
|
|
11
|
+
const fallbackLanguage = 'en';
|
|
12
|
+
const supportedLanguages = ['en', 'cs'] as const;
|
|
13
|
+
type SupportedLanguage = (typeof supportedLanguages)[number];
|
|
14
|
+
|
|
15
|
+
const isSupportedLanguage = (value: string): value is SupportedLanguage =>
|
|
16
|
+
supportedLanguages.includes(value as SupportedLanguage);
|
|
17
|
+
|
|
18
|
+
const stripLanguagePrefix = (pathname: string) => {
|
|
19
|
+
const segments = pathname.split('/').filter(Boolean);
|
|
20
|
+
if (segments.length > 0 && isSupportedLanguage(segments[0] ?? '')) {
|
|
21
|
+
segments.shift();
|
|
22
|
+
}
|
|
23
|
+
return `/${segments.join('/')}`;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const localizedPath = (pathname: string, language: SupportedLanguage) => {
|
|
27
|
+
const pathWithoutLanguage = stripLanguagePrefix(pathname);
|
|
28
|
+
return pathWithoutLanguage === '/' ? `/${language}` : `/${language}${pathWithoutLanguage}`;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const absoluteUrl = (pathname: string) => {
|
|
32
|
+
const origin = ULTRAMODERN_SITE_URL.replace(/\/+$/u, '');
|
|
33
|
+
return `${origin}${pathname}`;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const locationSuffix = (location: { hash?: unknown; search?: unknown; searchStr?: unknown }) => {
|
|
37
|
+
const { hash, search, searchStr } = location;
|
|
38
|
+
let locationSearch = '';
|
|
39
|
+
if (typeof searchStr === 'string') {
|
|
40
|
+
locationSearch = searchStr;
|
|
41
|
+
} else if (typeof search === 'string') {
|
|
42
|
+
locationSearch = search;
|
|
43
|
+
}
|
|
44
|
+
const locationHash = typeof hash === 'string' ? hash : '';
|
|
45
|
+
return `${locationSearch}${locationHash}`;
|
|
46
|
+
};
|
|
9
47
|
|
|
10
48
|
const Index = () => {
|
|
11
49
|
const { t } = useTranslation();
|
|
12
|
-
const {
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
50
|
+
const { language } = useModernI18n();
|
|
51
|
+
const location = useLocation();
|
|
52
|
+
const currentLanguage = isSupportedLanguage(language) ? language : fallbackLanguage;
|
|
53
|
+
const canonicalPath = localizedPath(location.pathname, currentLanguage);
|
|
54
|
+
const suffix = locationSuffix(location);
|
|
55
|
+
const languageOptions = supportedLanguages.map((code) => ({
|
|
56
|
+
code,
|
|
57
|
+
href: `${localizedPath(location.pathname, code)}${suffix}`,
|
|
58
|
+
label: t(`home.language.${code}`),
|
|
59
|
+
}));
|
|
17
60
|
{{#if useEffectBff}} const [effectMessage, setEffectMessage] = useState('loading...');
|
|
18
61
|
|
|
19
62
|
useEffect(() => {
|
|
@@ -42,18 +85,31 @@ const Index = () => {
|
|
|
42
85
|
type="image/x-icon"
|
|
43
86
|
href="https://lf3-static.bytednsdoc.com/obj/eden-cn/uhbfnupenuhf/favicon.ico"
|
|
44
87
|
/>
|
|
88
|
+
<link rel="canonical" href={absoluteUrl(canonicalPath)} />
|
|
89
|
+
{supportedLanguages.map((code) => (
|
|
90
|
+
<link
|
|
91
|
+
href={absoluteUrl(localizedPath(location.pathname, code))}
|
|
92
|
+
hrefLang={code}
|
|
93
|
+
key={code}
|
|
94
|
+
rel="alternate"
|
|
95
|
+
/>
|
|
96
|
+
))}
|
|
97
|
+
<link
|
|
98
|
+
href={absoluteUrl(localizedPath(location.pathname, fallbackLanguage))}
|
|
99
|
+
hrefLang="x-default"
|
|
100
|
+
rel="alternate"
|
|
101
|
+
/>
|
|
45
102
|
</Helmet>
|
|
46
103
|
<main>
|
|
47
104
|
<nav className="language-switcher" aria-label={t('home.language.switcher')}>
|
|
48
105
|
{languageOptions.map((option) => (
|
|
49
|
-
<
|
|
50
|
-
|
|
106
|
+
<a
|
|
107
|
+
aria-current={currentLanguage === option.code ? 'page' : undefined}
|
|
108
|
+
href={option.href}
|
|
51
109
|
key={option.code}
|
|
52
|
-
onClick={() => void changeLanguage(option.code)}
|
|
53
|
-
type="button"
|
|
54
110
|
>
|
|
55
111
|
{option.label}
|
|
56
|
-
</
|
|
112
|
+
</a>
|
|
57
113
|
))}
|
|
58
114
|
</nav>
|
|
59
115
|
<div className="title">
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { describe, expect, test } from '@rstest/core';
|
|
4
|
+
|
|
5
|
+
const root = process.cwd();
|
|
6
|
+
const readText = (relativePath: string) =>
|
|
7
|
+
fs.readFileSync(path.join(root, relativePath), 'utf-8');
|
|
8
|
+
const readJson = <T>(relativePath: string): T =>
|
|
9
|
+
JSON.parse(readText(relativePath)) as T;
|
|
10
|
+
|
|
11
|
+
describe('generated UltraModern contract', () => {
|
|
12
|
+
test('keeps localized route metadata and Rstest wiring', () => {
|
|
13
|
+
expect(fs.existsSync(path.join(root, 'src/routes/[lang]/page.tsx'))).toBe(
|
|
14
|
+
true,
|
|
15
|
+
);
|
|
16
|
+
expect(fs.existsSync(path.join(root, 'src/routes/page.tsx'))).toBe(false);
|
|
17
|
+
|
|
18
|
+
const page = readText('src/routes/[lang]/page.tsx');
|
|
19
|
+
expect(page).toContain('rel="canonical"');
|
|
20
|
+
expect(page).toContain('rel="alternate"');
|
|
21
|
+
expect(page).toContain('hrefLang="x-default"');
|
|
22
|
+
expect(page).toContain('localizedPath(');
|
|
23
|
+
|
|
24
|
+
const config = readText('rstest.config.mts');
|
|
25
|
+
expect(config).toContain("withModernConfig()");
|
|
26
|
+
expect(config).toContain("testEnvironment: 'happy-dom'");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('retains package-source metadata for generated Modern.js packages', () => {
|
|
30
|
+
const packageJson = readJson<{
|
|
31
|
+
dependencies?: Record<string, string>;
|
|
32
|
+
devDependencies?: Record<string, string>;
|
|
33
|
+
modernjs?: {
|
|
34
|
+
packageSource?: {
|
|
35
|
+
config?: string;
|
|
36
|
+
};
|
|
37
|
+
preset?: string;
|
|
38
|
+
};
|
|
39
|
+
}>('package.json');
|
|
40
|
+
const packageSource = readJson<{
|
|
41
|
+
modernPackages?: {
|
|
42
|
+
packages?: string[];
|
|
43
|
+
specifier?: string;
|
|
44
|
+
};
|
|
45
|
+
strategy?: string;
|
|
46
|
+
}>('.modernjs/ultramodern-package-source.json');
|
|
47
|
+
|
|
48
|
+
expect(packageJson.modernjs?.preset).toBe('presetUltramodern');
|
|
49
|
+
expect(packageJson.modernjs?.packageSource?.config).toBe(
|
|
50
|
+
'./.modernjs/ultramodern-package-source.json',
|
|
51
|
+
);
|
|
52
|
+
expect(packageSource.strategy).toMatch(/^(workspace|install)$/u);
|
|
53
|
+
expect(packageSource.modernPackages?.packages).toContain(
|
|
54
|
+
'@modern-js/runtime',
|
|
55
|
+
);
|
|
56
|
+
expect(packageSource.modernPackages?.packages).toContain(
|
|
57
|
+
'@modern-js/app-tools',
|
|
58
|
+
);
|
|
59
|
+
expect(packageSource.modernPackages?.packages).toContain(
|
|
60
|
+
'@modern-js/adapter-rstest',
|
|
61
|
+
);
|
|
62
|
+
expect(packageSource.modernPackages?.specifier).toBeTruthy();
|
|
63
|
+
expect(
|
|
64
|
+
packageJson.devDependencies?.['@modern-js/adapter-rstest'],
|
|
65
|
+
).toBeTruthy();
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -14,6 +14,8 @@ This workspace is generated as an agent-ready UltraModern.js SuperApp. Agents sh
|
|
|
14
14
|
|
|
15
15
|
Runtime i18n is enabled by default for generated apps. Agents must put user-visible UI copy in each app's `config/public/locales/<lang>/translation.json` and render it through `react-i18next` or `@modern-js/plugin-i18n/runtime`. Do not add hardcoded JSX text, `aria-label`, `title`, `alt`, or `placeholder` strings unless the value is a non-translatable technical token.
|
|
16
16
|
|
|
17
|
+
Routes are locale-prefixed by default through `localePathRedirect: true`. Keep localized app pages under `src/routes/[lang]`, use links for language switching, and preserve canonical plus `hreflang` metadata. Production builds fail unless `MODERN_PUBLIC_SITE_URL` is set per deployed app, so canonical URLs always use the production origin.
|
|
18
|
+
|
|
17
19
|
## Required Skill Baseline
|
|
18
20
|
|
|
19
21
|
Use these skills when the task touches the matching subsystem:
|
|
@@ -65,25 +65,33 @@ const requiredPaths = [
|
|
|
65
65
|
'apps/shell-super-app/config/public/locales/cs/translation.json',
|
|
66
66
|
'apps/shell-super-app/modern.config.ts',
|
|
67
67
|
'apps/shell-super-app/module-federation.config.ts',
|
|
68
|
+
'apps/shell-super-app/src/modern-app-env.d.ts',
|
|
68
69
|
'apps/shell-super-app/src/modern.runtime.ts',
|
|
70
|
+
'apps/shell-super-app/src/routes/[lang]/page.tsx',
|
|
69
71
|
'apps/remotes/remote-commerce/package.json',
|
|
70
72
|
'apps/remotes/remote-commerce/config/public/locales/en/translation.json',
|
|
71
73
|
'apps/remotes/remote-commerce/config/public/locales/cs/translation.json',
|
|
72
74
|
'apps/remotes/remote-commerce/modern.config.ts',
|
|
73
75
|
'apps/remotes/remote-commerce/module-federation.config.ts',
|
|
76
|
+
'apps/remotes/remote-commerce/src/modern-app-env.d.ts',
|
|
74
77
|
'apps/remotes/remote-commerce/src/modern.runtime.ts',
|
|
78
|
+
'apps/remotes/remote-commerce/src/routes/[lang]/page.tsx',
|
|
75
79
|
'apps/remotes/remote-identity/package.json',
|
|
76
80
|
'apps/remotes/remote-identity/config/public/locales/en/translation.json',
|
|
77
81
|
'apps/remotes/remote-identity/config/public/locales/cs/translation.json',
|
|
78
82
|
'apps/remotes/remote-identity/modern.config.ts',
|
|
79
83
|
'apps/remotes/remote-identity/module-federation.config.ts',
|
|
84
|
+
'apps/remotes/remote-identity/src/modern-app-env.d.ts',
|
|
80
85
|
'apps/remotes/remote-identity/src/modern.runtime.ts',
|
|
86
|
+
'apps/remotes/remote-identity/src/routes/[lang]/page.tsx',
|
|
81
87
|
'apps/remotes/remote-design-system/package.json',
|
|
82
88
|
'apps/remotes/remote-design-system/config/public/locales/en/translation.json',
|
|
83
89
|
'apps/remotes/remote-design-system/config/public/locales/cs/translation.json',
|
|
84
90
|
'apps/remotes/remote-design-system/modern.config.ts',
|
|
85
91
|
'apps/remotes/remote-design-system/module-federation.config.ts',
|
|
92
|
+
'apps/remotes/remote-design-system/src/modern-app-env.d.ts',
|
|
86
93
|
'apps/remotes/remote-design-system/src/modern.runtime.ts',
|
|
94
|
+
'apps/remotes/remote-design-system/src/routes/[lang]/page.tsx',
|
|
87
95
|
'services/service-recommendations-effect/package.json',
|
|
88
96
|
'services/service-recommendations-effect/modern.config.ts',
|
|
89
97
|
'services/service-recommendations-effect/api/effect/index.ts',
|
|
@@ -97,9 +105,23 @@ for (const requiredPath of requiredPaths) {
|
|
|
97
105
|
assertExists(requiredPath);
|
|
98
106
|
}
|
|
99
107
|
|
|
108
|
+
for (const appDirectory of [
|
|
109
|
+
'apps/shell-super-app',
|
|
110
|
+
'apps/remotes/remote-commerce',
|
|
111
|
+
'apps/remotes/remote-identity',
|
|
112
|
+
'apps/remotes/remote-design-system',
|
|
113
|
+
]) {
|
|
114
|
+
assert(
|
|
115
|
+
!fs.existsSync(path.join(root, appDirectory, 'src/routes/page.tsx')),
|
|
116
|
+
`${appDirectory} must use src/routes/[lang]/page.tsx`,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
100
120
|
const rootPackage = readJson('package.json');
|
|
101
121
|
const packageSource = readJson('.modernjs/ultramodern-package-source.json');
|
|
102
122
|
const skillsLock = readJson('.agents/skills-lock.json');
|
|
123
|
+
const pnpmWorkspace = readText('pnpm-workspace.yaml');
|
|
124
|
+
const deprecatedTanstackRuntime = '@modern-js/runtime/' + 'tanstack-router';
|
|
103
125
|
const expectedModernSpecifier =
|
|
104
126
|
packageSource.strategy === 'install' ? packageSource.modernPackages?.specifier : 'workspace:*';
|
|
105
127
|
|
|
@@ -118,6 +140,16 @@ assert(rootPackage.private === true, 'Root package must be private');
|
|
|
118
140
|
assert(rootPackage.modernjs?.preset === 'presetUltramodern', 'Root must declare presetUltramodern');
|
|
119
141
|
assert(rootPackage.packageManager === 'pnpm@11.1.2', 'Root must pin pnpm 11.1.2');
|
|
120
142
|
assert(rootPackage.engines?.pnpm === '>=11.0.0', 'Root must require pnpm >=11');
|
|
143
|
+
for (const requiredSnippet of [
|
|
144
|
+
'packages:\n - apps/*\n - apps/remotes/*\n - services/*\n - packages/*',
|
|
145
|
+
"allowBuilds:\n '@swc/core': true\n core-js: true\n esbuild: true\n msgpackr-extract: true",
|
|
146
|
+
"onlyBuiltDependencies:\n - '@swc/core'\n - core-js\n - esbuild\n - msgpackr-extract",
|
|
147
|
+
]) {
|
|
148
|
+
assert(
|
|
149
|
+
pnpmWorkspace.includes(requiredSnippet),
|
|
150
|
+
`pnpm-workspace.yaml must retain ${requiredSnippet.split('\n')[0]}`,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
121
153
|
assert(
|
|
122
154
|
rootPackage.modernjs?.packageSource?.config === './.modernjs/ultramodern-package-source.json',
|
|
123
155
|
'Root must point to the UltraModern package source metadata',
|
|
@@ -286,6 +318,12 @@ for (const configPath of [
|
|
|
286
318
|
const config = readText(configPath);
|
|
287
319
|
assert(config.includes('presetUltramodern('), `${configPath} must use presetUltramodern`);
|
|
288
320
|
assert(config.includes('i18nPlugin('), `${configPath} must enable plugin-i18n`);
|
|
321
|
+
assert(config.includes('localePathRedirect: true'), `${configPath} must prefix localized URLs`);
|
|
322
|
+
assert(config.includes('ULTRAMODERN_SITE_URL'), `${configPath} must expose site URL metadata`);
|
|
323
|
+
assert(
|
|
324
|
+
config.includes('MODERN_PUBLIC_SITE_URL must be set for production builds'),
|
|
325
|
+
`${configPath} must require MODERN_PUBLIC_SITE_URL for production builds`,
|
|
326
|
+
);
|
|
289
327
|
assert(config.includes('tanstackRouterPlugin()'), `${configPath} must enable plugin-tanstack`);
|
|
290
328
|
assert(
|
|
291
329
|
config.includes('moduleFederationPlugin()'),
|
|
@@ -293,6 +331,27 @@ for (const configPath of [
|
|
|
293
331
|
);
|
|
294
332
|
}
|
|
295
333
|
|
|
334
|
+
for (const routePath of [
|
|
335
|
+
'apps/shell-super-app/src/routes/[lang]/page.tsx',
|
|
336
|
+
'apps/remotes/remote-commerce/src/routes/[lang]/page.tsx',
|
|
337
|
+
'apps/remotes/remote-identity/src/routes/[lang]/page.tsx',
|
|
338
|
+
'apps/remotes/remote-design-system/src/routes/[lang]/page.tsx',
|
|
339
|
+
]) {
|
|
340
|
+
const route = readText(routePath);
|
|
341
|
+
assert(route.includes('rel="canonical"'), `${routePath} must emit canonical metadata`);
|
|
342
|
+
assert(route.includes('rel="alternate"'), `${routePath} must emit alternate locale metadata`);
|
|
343
|
+
assert(route.includes('hrefLang="x-default"'), `${routePath} must emit x-default metadata`);
|
|
344
|
+
assert(route.includes('localizedPath('), `${routePath} must build localized URLs`);
|
|
345
|
+
assert(
|
|
346
|
+
route.includes('@modern-js/plugin-tanstack/runtime'),
|
|
347
|
+
`${routePath} must import TanStack runtime from @modern-js/plugin-tanstack/runtime`,
|
|
348
|
+
);
|
|
349
|
+
assert(
|
|
350
|
+
!route.includes(deprecatedTanstackRuntime),
|
|
351
|
+
`${routePath} must not import deprecated TanStack runtime path`,
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
|
|
296
355
|
const shellMf = readText('apps/shell-super-app/module-federation.config.ts');
|
|
297
356
|
assert(shellMf.includes("name: 'shellSuperApp'"), 'Shell MF config must name the shell');
|
|
298
357
|
assert(
|