@bleedingdev/modern-js-create 3.2.0-ultramodern.11 → 3.2.0-ultramodern.13
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 +268 -22
- package/package.json +3 -3
- package/template/AGENTS.md +7 -0
- package/template/config/public/locales/cs/translation.json +39 -0
- package/template/config/public/locales/en/translation.json +39 -0
- package/template/modern.config.ts.handlebars +26 -1
- package/template/package.json.handlebars +8 -3
- package/template/scripts/check-i18n-strings.mjs +83 -0
- package/template/scripts/validate-ultramodern.mjs.handlebars +40 -0
- package/template/src/modern-app-env.d.ts +2 -0
- package/template/src/modern.runtime.ts.handlebars +17 -1
- package/template/src/routes/[lang]/page.tsx.handlebars +211 -0
- package/template-workspace/AGENTS.md +8 -1
- package/template-workspace/scripts/check-i18n-strings.mjs +83 -0
- package/template-workspace/scripts/validate-ultramodern-workspace.mjs.handlebars +67 -0
- package/template/src/routes/page.tsx.handlebars +0 -136
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
const root = process.cwd();
|
|
5
|
+
const scanRoots = ['src'].map((scanRoot) => path.join(root, scanRoot));
|
|
6
|
+
const ignoredDirectories = new Set(['.modern', '.modernjs', 'dist', 'node_modules']);
|
|
7
|
+
const visibleAttributePattern =
|
|
8
|
+
/\s(?:aria-label|alt|placeholder|title)=["']([^"']*[A-Za-z][^"']*)["']/gu;
|
|
9
|
+
const jsxTextPattern = />([^<>{}]*[A-Za-z][^<>{}]*)</gu;
|
|
10
|
+
|
|
11
|
+
const collectFiles = (directory) => {
|
|
12
|
+
if (!fs.existsSync(directory)) {
|
|
13
|
+
return [];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const files = [];
|
|
17
|
+
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
|
18
|
+
if (entry.isDirectory()) {
|
|
19
|
+
if (!ignoredDirectories.has(entry.name)) {
|
|
20
|
+
files.push(...collectFiles(path.join(directory, entry.name)));
|
|
21
|
+
}
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (entry.isFile() && /\.(jsx|tsx)$/u.test(entry.name) && !entry.name.endsWith('.d.ts')) {
|
|
26
|
+
files.push(path.join(directory, entry.name));
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return files;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const lineNumberForIndex = (content, index) => content.slice(0, index).split('\n').length;
|
|
33
|
+
const isIgnoredLine = (content, index) => {
|
|
34
|
+
const lineStart = content.lastIndexOf('\n', index) + 1;
|
|
35
|
+
const lineEnd = content.indexOf('\n', index);
|
|
36
|
+
const currentLineEnd = lineEnd === -1 ? content.length : lineEnd;
|
|
37
|
+
const previousLineStart = content.lastIndexOf('\n', Math.max(0, lineStart - 2)) + 1;
|
|
38
|
+
const nextLineEnd = content.indexOf('\n', currentLineEnd + 1);
|
|
39
|
+
const context = content.slice(
|
|
40
|
+
previousLineStart,
|
|
41
|
+
nextLineEnd === -1 ? content.length : nextLineEnd,
|
|
42
|
+
);
|
|
43
|
+
return /i18n-ignore/u.test(context);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const violations = [];
|
|
47
|
+
for (const filePath of scanRoots.flatMap(collectFiles)) {
|
|
48
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
49
|
+
for (const match of content.matchAll(visibleAttributePattern)) {
|
|
50
|
+
if (!isIgnoredLine(content, match.index ?? 0)) {
|
|
51
|
+
violations.push({
|
|
52
|
+
filePath,
|
|
53
|
+
line: lineNumberForIndex(content, match.index ?? 0),
|
|
54
|
+
text: match[1].trim(),
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
for (const match of content.matchAll(jsxTextPattern)) {
|
|
60
|
+
const text = match[1].replaceAll(/\s+/gu, ' ').trim();
|
|
61
|
+
if (text && !isIgnoredLine(content, match.index ?? 0)) {
|
|
62
|
+
violations.push({
|
|
63
|
+
filePath,
|
|
64
|
+
line: lineNumberForIndex(content, match.index ?? 0),
|
|
65
|
+
text,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (violations.length > 0) {
|
|
72
|
+
console.error('Hardcoded user-visible JSX strings found. Move copy to locale JSON files.');
|
|
73
|
+
for (const violation of violations) {
|
|
74
|
+
console.error(
|
|
75
|
+
`${path.relative(root, violation.filePath)}:${violation.line} ${JSON.stringify(
|
|
76
|
+
violation.text,
|
|
77
|
+
)}`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
process.exit(1);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
console.log('No hardcoded user-visible JSX strings found.');
|
|
@@ -16,6 +16,11 @@ const requiredTokens = [
|
|
|
16
16
|
'enableModuleFederationSSR',
|
|
17
17
|
'enableBffRequestId',
|
|
18
18
|
'enableTelemetryExporters',
|
|
19
|
+
'i18nPlugin(',
|
|
20
|
+
'localePathRedirect: true',
|
|
21
|
+
'ULTRAMODERN_SITE_URL',
|
|
22
|
+
'MODERN_PUBLIC_SITE_URL must be set for production builds',
|
|
23
|
+
'globalVars',
|
|
19
24
|
];
|
|
20
25
|
const missing = requiredTokens.filter((token) => !content.includes(token));
|
|
21
26
|
|
|
@@ -51,6 +56,11 @@ const requiredPaths = [
|
|
|
51
56
|
'oxlint.config.ts',
|
|
52
57
|
'oxfmt.config.ts',
|
|
53
58
|
'scripts/bootstrap-agent-skills.mjs',
|
|
59
|
+
'scripts/check-i18n-strings.mjs',
|
|
60
|
+
'config/public/locales/en/translation.json',
|
|
61
|
+
'config/public/locales/cs/translation.json',
|
|
62
|
+
'src/modern-app-env.d.ts',
|
|
63
|
+
'src/routes/[lang]/page.tsx',
|
|
54
64
|
];
|
|
55
65
|
const manifestErrors = [];
|
|
56
66
|
|
|
@@ -61,6 +71,28 @@ for (const requiredPath of requiredPaths) {
|
|
|
61
71
|
}
|
|
62
72
|
}
|
|
63
73
|
|
|
74
|
+
if (fs.existsSync(path.resolve(process.cwd(), 'src/routes/page.tsx'))) {
|
|
75
|
+
console.error('src/routes/page.tsx must move under src/routes/[lang]/page.tsx');
|
|
76
|
+
process.exit(1);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const pageContent = fs.readFileSync(
|
|
80
|
+
path.resolve(process.cwd(), 'src/routes/[lang]/page.tsx'),
|
|
81
|
+
'utf-8',
|
|
82
|
+
);
|
|
83
|
+
for (const token of [
|
|
84
|
+
'rel="canonical"',
|
|
85
|
+
'rel="alternate"',
|
|
86
|
+
'hrefLang="x-default"',
|
|
87
|
+
'localizedPath(',
|
|
88
|
+
'<a',
|
|
89
|
+
]) {
|
|
90
|
+
if (!pageContent.includes(token)) {
|
|
91
|
+
console.error(`Localized route is missing ${token}`);
|
|
92
|
+
process.exit(1);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
64
96
|
if (templateManifest.schemaVersion !== 1) {
|
|
65
97
|
manifestErrors.push('schemaVersion');
|
|
66
98
|
}
|
|
@@ -118,6 +150,7 @@ const skillsLock = JSON.parse(
|
|
|
118
150
|
const requiredScripts = {
|
|
119
151
|
format: 'oxfmt .',
|
|
120
152
|
'format:check': 'oxfmt --check .',
|
|
153
|
+
'i18n:check': 'node ./scripts/check-i18n-strings.mjs',
|
|
121
154
|
lint: 'oxlint .',
|
|
122
155
|
'lint:fix': 'oxlint . --fix',
|
|
123
156
|
'skills:check': 'node ./scripts/bootstrap-agent-skills.mjs --check',
|
|
@@ -139,6 +172,13 @@ if (
|
|
|
139
172
|
process.exit(1);
|
|
140
173
|
}
|
|
141
174
|
|
|
175
|
+
for (const dependency of ['@modern-js/plugin-i18n', 'i18next', 'react-i18next']) {
|
|
176
|
+
if (!packageJson.dependencies?.[dependency]) {
|
|
177
|
+
console.error(`Missing dependency: ${dependency}`);
|
|
178
|
+
process.exit(1);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
142
182
|
for (const dependency of [
|
|
143
183
|
'@effect/tsgo',
|
|
144
184
|
'@typescript/native-preview',
|
|
@@ -1,7 +1,23 @@
|
|
|
1
1
|
import { defineRuntimeConfig } from '@modern-js/runtime';
|
|
2
|
+
import { createInstance } from 'i18next';
|
|
3
|
+
|
|
4
|
+
const i18nInstance = createInstance();
|
|
2
5
|
|
|
3
6
|
export default defineRuntimeConfig({
|
|
7
|
+
i18n: {
|
|
8
|
+
i18nInstance,
|
|
9
|
+
initOptions: {
|
|
10
|
+
defaultNS: 'translation',
|
|
11
|
+
fallbackLng: 'en',
|
|
12
|
+
interpolation: {
|
|
13
|
+
escapeValue: false,
|
|
14
|
+
},
|
|
15
|
+
ns: ['translation'],
|
|
16
|
+
supportedLngs: ['en', 'cs'],
|
|
17
|
+
},
|
|
18
|
+
},
|
|
4
19
|
{{#if isTanstackRouter}} router: {
|
|
5
20
|
framework: 'tanstack',
|
|
6
|
-
},
|
|
21
|
+
},
|
|
22
|
+
{{/if~}}
|
|
7
23
|
});
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { Helmet } from '@modern-js/runtime/head';
|
|
2
|
+
import { useModernI18n } from '@modern-js/plugin-i18n/runtime';
|
|
3
|
+
import { useLocation } from '@modern-js/runtime/{{routerImportPath}}';
|
|
4
|
+
{{#if useEffectBff}}import effectBff from '@api/effect/index';
|
|
5
|
+
import { Effect } from '@modern-js/plugin-bff/effect-client';
|
|
6
|
+
import { useEffect, useState } from 'react';
|
|
7
|
+
{{/if}}
|
|
8
|
+
import { useTranslation } from 'react-i18next';
|
|
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
|
+
};
|
|
47
|
+
|
|
48
|
+
const Index = () => {
|
|
49
|
+
const { t } = useTranslation();
|
|
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
|
+
}));
|
|
60
|
+
{{#if useEffectBff}} const [effectMessage, setEffectMessage] = useState('loading...');
|
|
61
|
+
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
let mounted = true;
|
|
64
|
+
Effect.runFork(
|
|
65
|
+
Effect.promise(() => effectBff.client.greetings.hello({})).pipe(
|
|
66
|
+
Effect.tap((data) =>
|
|
67
|
+
Effect.sync(() => {
|
|
68
|
+
if (mounted) {
|
|
69
|
+
setEffectMessage(data.message);
|
|
70
|
+
}
|
|
71
|
+
}),
|
|
72
|
+
),
|
|
73
|
+
),
|
|
74
|
+
);
|
|
75
|
+
return () => {
|
|
76
|
+
mounted = false;
|
|
77
|
+
};
|
|
78
|
+
}, []);
|
|
79
|
+
{{/if}}
|
|
80
|
+
return (
|
|
81
|
+
<div className="container-box">
|
|
82
|
+
<Helmet>
|
|
83
|
+
<link
|
|
84
|
+
rel="icon"
|
|
85
|
+
type="image/x-icon"
|
|
86
|
+
href="https://lf3-static.bytednsdoc.com/obj/eden-cn/uhbfnupenuhf/favicon.ico"
|
|
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
|
+
/>
|
|
102
|
+
</Helmet>
|
|
103
|
+
<main>
|
|
104
|
+
<nav className="language-switcher" aria-label={t('home.language.switcher')}>
|
|
105
|
+
{languageOptions.map((option) => (
|
|
106
|
+
<a
|
|
107
|
+
aria-current={currentLanguage === option.code ? 'page' : undefined}
|
|
108
|
+
href={option.href}
|
|
109
|
+
key={option.code}
|
|
110
|
+
>
|
|
111
|
+
{option.label}
|
|
112
|
+
</a>
|
|
113
|
+
))}
|
|
114
|
+
</nav>
|
|
115
|
+
<div className="title">
|
|
116
|
+
{t('home.title')}
|
|
117
|
+
<img
|
|
118
|
+
alt={t('home.logoAlt')}
|
|
119
|
+
className="logo"
|
|
120
|
+
src="https://lf3-static.bytednsdoc.com/obj/eden-cn/zq-uylkvT/ljhwZthlaukjlkulzlp/modern-js-logo.svg"
|
|
121
|
+
/>
|
|
122
|
+
<p className="name">{t('home.name')}</p>
|
|
123
|
+
</div>
|
|
124
|
+
<p className="description{{#if enableTailwind}} text-emerald-700 font-semibold{{/if}}">
|
|
125
|
+
{t('home.description.intro')} <code className="code">presetUltramodern(...)</code>{' '}
|
|
126
|
+
{/* i18n-ignore technical token */}
|
|
127
|
+
{t('home.description.afterPreset')}
|
|
128
|
+
<code className="code">modern.config.ts</code>
|
|
129
|
+
{/* i18n-ignore technical token */}
|
|
130
|
+
{t('home.description.afterConfig')}
|
|
131
|
+
<code className="code">pnpm run ultramodern:check</code>
|
|
132
|
+
{/* i18n-ignore technical token */}
|
|
133
|
+
{t('home.description.end')}
|
|
134
|
+
</p>
|
|
135
|
+
{{#if useEffectBff}}
|
|
136
|
+
<p className="description effect-message{{#if enableTailwind}} text-emerald-700 font-semibold{{/if}}">
|
|
137
|
+
{t('home.bff.response')} <code className="code">{effectMessage}</code>
|
|
138
|
+
</p>
|
|
139
|
+
{{/if}}
|
|
140
|
+
<div className="grid">
|
|
141
|
+
<a
|
|
142
|
+
href="https://bleedingdev.github.io/ultramodern.js/guides/get-started/ultramodern.html"
|
|
143
|
+
target="_blank"
|
|
144
|
+
rel="noopener noreferrer"
|
|
145
|
+
className="card"
|
|
146
|
+
>
|
|
147
|
+
<h2>
|
|
148
|
+
{t('home.cards.guide.title')}
|
|
149
|
+
<img
|
|
150
|
+
alt=""
|
|
151
|
+
className="arrow-right"
|
|
152
|
+
src="https://lf3-static.bytednsdoc.com/obj/eden-cn/zq-uylkvT/ljhwZthlaukjlkulzlp/arrow-right.svg"
|
|
153
|
+
/>
|
|
154
|
+
</h2>
|
|
155
|
+
<p>{t('home.cards.guide.body')}</p>
|
|
156
|
+
</a>
|
|
157
|
+
<a
|
|
158
|
+
href="https://bleedingdev.github.io/ultramodern.js/configure/app/usage.html"
|
|
159
|
+
target="_blank"
|
|
160
|
+
className="card"
|
|
161
|
+
rel="noreferrer"
|
|
162
|
+
>
|
|
163
|
+
<h2>
|
|
164
|
+
{t('home.cards.config.title')}
|
|
165
|
+
<img
|
|
166
|
+
alt=""
|
|
167
|
+
className="arrow-right"
|
|
168
|
+
src="https://lf3-static.bytednsdoc.com/obj/eden-cn/zq-uylkvT/ljhwZthlaukjlkulzlp/arrow-right.svg"
|
|
169
|
+
/>
|
|
170
|
+
</h2>
|
|
171
|
+
<p>{t('home.cards.config.body')}</p>
|
|
172
|
+
</a>
|
|
173
|
+
<a
|
|
174
|
+
href="https://github.com/BleedingDev/ultramodern.js/blob/main-ultramodern/packages/toolkit/create/template/.github/workflows/ultramodern-gates.yml.handlebars"
|
|
175
|
+
target="_blank"
|
|
176
|
+
className="card"
|
|
177
|
+
rel="noreferrer"
|
|
178
|
+
>
|
|
179
|
+
<h2>
|
|
180
|
+
{t('home.cards.gates.title')}
|
|
181
|
+
<img
|
|
182
|
+
alt=""
|
|
183
|
+
className="arrow-right"
|
|
184
|
+
src="https://lf3-static.bytednsdoc.com/obj/eden-cn/zq-uylkvT/ljhwZthlaukjlkulzlp/arrow-right.svg"
|
|
185
|
+
/>
|
|
186
|
+
</h2>
|
|
187
|
+
<p>{t('home.cards.gates.body')}</p>
|
|
188
|
+
</a>
|
|
189
|
+
<a
|
|
190
|
+
href="https://bleedingdev.github.io/ultramodern.js/configure/app/bff/effect.html"
|
|
191
|
+
target="_blank"
|
|
192
|
+
rel="noopener noreferrer"
|
|
193
|
+
className="card"
|
|
194
|
+
>
|
|
195
|
+
<h2>
|
|
196
|
+
{t('home.cards.bff.title')}
|
|
197
|
+
<img
|
|
198
|
+
alt=""
|
|
199
|
+
className="arrow-right"
|
|
200
|
+
src="https://lf3-static.bytednsdoc.com/obj/eden-cn/zq-uylkvT/ljhwZthlaukjlkulzlp/arrow-right.svg"
|
|
201
|
+
/>
|
|
202
|
+
</h2>
|
|
203
|
+
<p>{t('home.cards.bff.body')}</p>
|
|
204
|
+
</a>
|
|
205
|
+
</div>
|
|
206
|
+
</main>
|
|
207
|
+
</div>
|
|
208
|
+
);
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
export default Index;
|
|
@@ -7,7 +7,14 @@ This workspace is generated as an agent-ready UltraModern.js SuperApp. Agents sh
|
|
|
7
7
|
- `pnpm lint` runs Oxlint with the Ultracite preset.
|
|
8
8
|
- `pnpm format` runs oxfmt.
|
|
9
9
|
- `pnpm typecheck` runs effect-tsgo as the TypeScript checker.
|
|
10
|
-
- `pnpm check`
|
|
10
|
+
- `pnpm i18n:check` rejects hardcoded user-visible JSX text in generated apps.
|
|
11
|
+
- `pnpm check` runs formatting, linting, effect-tsgo, i18n checks, private-skill availability checks, and the generated workspace contract.
|
|
12
|
+
|
|
13
|
+
## Internationalization
|
|
14
|
+
|
|
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
|
+
|
|
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.
|
|
11
18
|
|
|
12
19
|
## Required Skill Baseline
|
|
13
20
|
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
const root = process.cwd();
|
|
5
|
+
const scanRoots = ['apps'].map((scanRoot) => path.join(root, scanRoot));
|
|
6
|
+
const ignoredDirectories = new Set(['.modern', '.modernjs', 'dist', 'node_modules']);
|
|
7
|
+
const visibleAttributePattern =
|
|
8
|
+
/\s(?:aria-label|alt|placeholder|title)=["']([^"']*[A-Za-z][^"']*)["']/gu;
|
|
9
|
+
const jsxTextPattern = />([^<>{}]*[A-Za-z][^<>{}]*)</gu;
|
|
10
|
+
|
|
11
|
+
const collectFiles = (directory) => {
|
|
12
|
+
if (!fs.existsSync(directory)) {
|
|
13
|
+
return [];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const files = [];
|
|
17
|
+
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
|
18
|
+
if (entry.isDirectory()) {
|
|
19
|
+
if (!ignoredDirectories.has(entry.name)) {
|
|
20
|
+
files.push(...collectFiles(path.join(directory, entry.name)));
|
|
21
|
+
}
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (entry.isFile() && /\.(jsx|tsx)$/u.test(entry.name) && !entry.name.endsWith('.d.ts')) {
|
|
26
|
+
files.push(path.join(directory, entry.name));
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return files;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const lineNumberForIndex = (content, index) => content.slice(0, index).split('\n').length;
|
|
33
|
+
const isIgnoredLine = (content, index) => {
|
|
34
|
+
const lineStart = content.lastIndexOf('\n', index) + 1;
|
|
35
|
+
const lineEnd = content.indexOf('\n', index);
|
|
36
|
+
const currentLineEnd = lineEnd === -1 ? content.length : lineEnd;
|
|
37
|
+
const previousLineStart = content.lastIndexOf('\n', Math.max(0, lineStart - 2)) + 1;
|
|
38
|
+
const nextLineEnd = content.indexOf('\n', currentLineEnd + 1);
|
|
39
|
+
const context = content.slice(
|
|
40
|
+
previousLineStart,
|
|
41
|
+
nextLineEnd === -1 ? content.length : nextLineEnd,
|
|
42
|
+
);
|
|
43
|
+
return /i18n-ignore/u.test(context);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const violations = [];
|
|
47
|
+
for (const filePath of scanRoots.flatMap(collectFiles)) {
|
|
48
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
49
|
+
for (const match of content.matchAll(visibleAttributePattern)) {
|
|
50
|
+
if (!isIgnoredLine(content, match.index ?? 0)) {
|
|
51
|
+
violations.push({
|
|
52
|
+
filePath,
|
|
53
|
+
line: lineNumberForIndex(content, match.index ?? 0),
|
|
54
|
+
text: match[1].trim(),
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
for (const match of content.matchAll(jsxTextPattern)) {
|
|
60
|
+
const text = match[1].replaceAll(/\s+/gu, ' ').trim();
|
|
61
|
+
if (text && !isIgnoredLine(content, match.index ?? 0)) {
|
|
62
|
+
violations.push({
|
|
63
|
+
filePath,
|
|
64
|
+
line: lineNumberForIndex(content, match.index ?? 0),
|
|
65
|
+
text,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (violations.length > 0) {
|
|
72
|
+
console.error('Hardcoded user-visible JSX strings found. Move copy to locale JSON files.');
|
|
73
|
+
for (const violation of violations) {
|
|
74
|
+
console.error(
|
|
75
|
+
`${path.relative(root, violation.filePath)}:${violation.line} ${JSON.stringify(
|
|
76
|
+
violation.text,
|
|
77
|
+
)}`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
process.exit(1);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
console.log('No hardcoded user-visible JSX strings found.');
|
|
@@ -8,6 +8,7 @@ const rstackAgentSkillsCommit = '61c948b42512e223bad44b83af4080eba48b2677';
|
|
|
8
8
|
const modernPackages = [
|
|
9
9
|
'@modern-js/app-tools',
|
|
10
10
|
'@modern-js/plugin-bff',
|
|
11
|
+
'@modern-js/plugin-i18n',
|
|
11
12
|
'@modern-js/plugin-tanstack',
|
|
12
13
|
'@modern-js/runtime',
|
|
13
14
|
];
|
|
@@ -58,18 +59,39 @@ const requiredPaths = [
|
|
|
58
59
|
'.modernjs/ultramodern-workspace-template-manifest.json',
|
|
59
60
|
'.modernjs/ultramodern-package-source.json',
|
|
60
61
|
'scripts/bootstrap-agent-skills.mjs',
|
|
62
|
+
'scripts/check-i18n-strings.mjs',
|
|
61
63
|
'apps/shell-super-app/package.json',
|
|
64
|
+
'apps/shell-super-app/config/public/locales/en/translation.json',
|
|
65
|
+
'apps/shell-super-app/config/public/locales/cs/translation.json',
|
|
62
66
|
'apps/shell-super-app/modern.config.ts',
|
|
63
67
|
'apps/shell-super-app/module-federation.config.ts',
|
|
68
|
+
'apps/shell-super-app/src/modern-app-env.d.ts',
|
|
69
|
+
'apps/shell-super-app/src/modern.runtime.ts',
|
|
70
|
+
'apps/shell-super-app/src/routes/[lang]/page.tsx',
|
|
64
71
|
'apps/remotes/remote-commerce/package.json',
|
|
72
|
+
'apps/remotes/remote-commerce/config/public/locales/en/translation.json',
|
|
73
|
+
'apps/remotes/remote-commerce/config/public/locales/cs/translation.json',
|
|
65
74
|
'apps/remotes/remote-commerce/modern.config.ts',
|
|
66
75
|
'apps/remotes/remote-commerce/module-federation.config.ts',
|
|
76
|
+
'apps/remotes/remote-commerce/src/modern-app-env.d.ts',
|
|
77
|
+
'apps/remotes/remote-commerce/src/modern.runtime.ts',
|
|
78
|
+
'apps/remotes/remote-commerce/src/routes/[lang]/page.tsx',
|
|
67
79
|
'apps/remotes/remote-identity/package.json',
|
|
80
|
+
'apps/remotes/remote-identity/config/public/locales/en/translation.json',
|
|
81
|
+
'apps/remotes/remote-identity/config/public/locales/cs/translation.json',
|
|
68
82
|
'apps/remotes/remote-identity/modern.config.ts',
|
|
69
83
|
'apps/remotes/remote-identity/module-federation.config.ts',
|
|
84
|
+
'apps/remotes/remote-identity/src/modern-app-env.d.ts',
|
|
85
|
+
'apps/remotes/remote-identity/src/modern.runtime.ts',
|
|
86
|
+
'apps/remotes/remote-identity/src/routes/[lang]/page.tsx',
|
|
70
87
|
'apps/remotes/remote-design-system/package.json',
|
|
88
|
+
'apps/remotes/remote-design-system/config/public/locales/en/translation.json',
|
|
89
|
+
'apps/remotes/remote-design-system/config/public/locales/cs/translation.json',
|
|
71
90
|
'apps/remotes/remote-design-system/modern.config.ts',
|
|
72
91
|
'apps/remotes/remote-design-system/module-federation.config.ts',
|
|
92
|
+
'apps/remotes/remote-design-system/src/modern-app-env.d.ts',
|
|
93
|
+
'apps/remotes/remote-design-system/src/modern.runtime.ts',
|
|
94
|
+
'apps/remotes/remote-design-system/src/routes/[lang]/page.tsx',
|
|
73
95
|
'services/service-recommendations-effect/package.json',
|
|
74
96
|
'services/service-recommendations-effect/modern.config.ts',
|
|
75
97
|
'services/service-recommendations-effect/api/effect/index.ts',
|
|
@@ -83,6 +105,18 @@ for (const requiredPath of requiredPaths) {
|
|
|
83
105
|
assertExists(requiredPath);
|
|
84
106
|
}
|
|
85
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
|
+
|
|
86
120
|
const rootPackage = readJson('package.json');
|
|
87
121
|
const packageSource = readJson('.modernjs/ultramodern-package-source.json');
|
|
88
122
|
const skillsLock = readJson('.agents/skills-lock.json');
|
|
@@ -134,6 +168,7 @@ assert(
|
|
|
134
168
|
const requiredRootScripts = {
|
|
135
169
|
format: 'oxfmt .',
|
|
136
170
|
'format:check': 'oxfmt --check .',
|
|
171
|
+
'i18n:check': 'node ./scripts/check-i18n-strings.mjs',
|
|
137
172
|
lint: 'oxlint .',
|
|
138
173
|
'lint:fix': 'oxlint . --fix',
|
|
139
174
|
'skills:check': 'node ./scripts/bootstrap-agent-skills.mjs --check',
|
|
@@ -207,6 +242,13 @@ const appPackagePaths = [
|
|
|
207
242
|
|
|
208
243
|
for (const packagePath of appPackagePaths) {
|
|
209
244
|
const packageJson = readJson(packagePath);
|
|
245
|
+
assert(
|
|
246
|
+
packageJson.dependencies?.['@modern-js/plugin-i18n'] ===
|
|
247
|
+
expectedModernDependency('@modern-js/plugin-i18n'),
|
|
248
|
+
`${packagePath} must use @modern-js/plugin-i18n through ${expectedModernDependency(
|
|
249
|
+
'@modern-js/plugin-i18n',
|
|
250
|
+
)}`,
|
|
251
|
+
);
|
|
210
252
|
assert(
|
|
211
253
|
packageJson.dependencies?.['@modern-js/plugin-tanstack'] ===
|
|
212
254
|
expectedModernDependency('@modern-js/plugin-tanstack'),
|
|
@@ -236,6 +278,11 @@ for (const packagePath of appPackagePaths) {
|
|
|
236
278
|
packageJson.dependencies?.[`@${packageScope}/shared-design-tokens`] === 'workspace:*',
|
|
237
279
|
`${packagePath} must link generated shared design tokens through workspace:*`,
|
|
238
280
|
);
|
|
281
|
+
assert(packageJson.dependencies?.i18next === '26.2.0', `${packagePath} must include i18next`);
|
|
282
|
+
assert(
|
|
283
|
+
packageJson.dependencies?.['react-i18next'] === '17.0.8',
|
|
284
|
+
`${packagePath} must include react-i18next`,
|
|
285
|
+
);
|
|
239
286
|
assert(
|
|
240
287
|
packageJson.dependencies?.['@tanstack/react-router'] === tanstackVersion,
|
|
241
288
|
`${packagePath} must use @tanstack/react-router ${tanstackVersion}`,
|
|
@@ -258,6 +305,13 @@ for (const configPath of [
|
|
|
258
305
|
]) {
|
|
259
306
|
const config = readText(configPath);
|
|
260
307
|
assert(config.includes('presetUltramodern('), `${configPath} must use presetUltramodern`);
|
|
308
|
+
assert(config.includes('i18nPlugin('), `${configPath} must enable plugin-i18n`);
|
|
309
|
+
assert(config.includes('localePathRedirect: true'), `${configPath} must prefix localized URLs`);
|
|
310
|
+
assert(config.includes('ULTRAMODERN_SITE_URL'), `${configPath} must expose site URL metadata`);
|
|
311
|
+
assert(
|
|
312
|
+
config.includes('MODERN_PUBLIC_SITE_URL must be set for production builds'),
|
|
313
|
+
`${configPath} must require MODERN_PUBLIC_SITE_URL for production builds`,
|
|
314
|
+
);
|
|
261
315
|
assert(config.includes('tanstackRouterPlugin()'), `${configPath} must enable plugin-tanstack`);
|
|
262
316
|
assert(
|
|
263
317
|
config.includes('moduleFederationPlugin()'),
|
|
@@ -265,6 +319,19 @@ for (const configPath of [
|
|
|
265
319
|
);
|
|
266
320
|
}
|
|
267
321
|
|
|
322
|
+
for (const routePath of [
|
|
323
|
+
'apps/shell-super-app/src/routes/[lang]/page.tsx',
|
|
324
|
+
'apps/remotes/remote-commerce/src/routes/[lang]/page.tsx',
|
|
325
|
+
'apps/remotes/remote-identity/src/routes/[lang]/page.tsx',
|
|
326
|
+
'apps/remotes/remote-design-system/src/routes/[lang]/page.tsx',
|
|
327
|
+
]) {
|
|
328
|
+
const route = readText(routePath);
|
|
329
|
+
assert(route.includes('rel="canonical"'), `${routePath} must emit canonical metadata`);
|
|
330
|
+
assert(route.includes('rel="alternate"'), `${routePath} must emit alternate locale metadata`);
|
|
331
|
+
assert(route.includes('hrefLang="x-default"'), `${routePath} must emit x-default metadata`);
|
|
332
|
+
assert(route.includes('localizedPath('), `${routePath} must build localized URLs`);
|
|
333
|
+
}
|
|
334
|
+
|
|
268
335
|
const shellMf = readText('apps/shell-super-app/module-federation.config.ts');
|
|
269
336
|
assert(shellMf.includes("name: 'shellSuperApp'"), 'Shell MF config must name the shell');
|
|
270
337
|
assert(
|