@rettangoli/fe 1.1.3 → 1.2.1

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.
@@ -0,0 +1,367 @@
1
+ import {
2
+ existsSync,
3
+ mkdirSync,
4
+ readFileSync,
5
+ writeFileSync,
6
+ } from "node:fs";
7
+ import path from "node:path";
8
+
9
+ import { load as loadYaml } from "js-yaml";
10
+
11
+ const DEFAULT_OUTPUT_DIR = "i18n";
12
+ const LOCALE_PATTERN = /^[A-Za-z0-9_-]+$/;
13
+
14
+ const isPlainObject = (value) =>
15
+ value !== null && typeof value === "object" && !Array.isArray(value);
16
+
17
+ const toPosixPath = (value) => value.split(path.sep).join("/");
18
+
19
+ const createI18nError = ({ code = "RTGL-I18N-004", message, filePath }) => ({
20
+ code,
21
+ message,
22
+ filePath: filePath || "",
23
+ });
24
+
25
+ const assertNoI18nErrors = ({ errors, errorPrefix }) => {
26
+ if (errors.length === 0) {
27
+ return;
28
+ }
29
+
30
+ const details = errors
31
+ .map((error) => `${error.code} ${error.message}${error.filePath ? ` [${error.filePath}]` : ""}`)
32
+ .join("\n");
33
+ throw new Error(`${errorPrefix} i18n validation failed:\n${details}`);
34
+ };
35
+
36
+ const validateLocaleName = ({ locale, errors, filePath }) => {
37
+ if (typeof locale !== "string" || !LOCALE_PATTERN.test(locale)) {
38
+ errors.push(createI18nError({
39
+ message: `invalid locale name ${JSON.stringify(locale)}. Locale names may contain letters, numbers, "_" and "-".`,
40
+ filePath,
41
+ }));
42
+ return false;
43
+ }
44
+ return true;
45
+ };
46
+
47
+ const flattenCatalogKeys = (catalog = {}) => {
48
+ const keys = [];
49
+ Object.entries(catalog).forEach(([namespace, messages]) => {
50
+ Object.keys(messages || {}).forEach((key) => {
51
+ keys.push(`${namespace}.${key}`);
52
+ });
53
+ });
54
+ return keys.sort();
55
+ };
56
+
57
+ const validateCatalog = ({ catalog, locale, filePath }) => {
58
+ const errors = [];
59
+ const normalized = {};
60
+
61
+ if (!isPlainObject(catalog)) {
62
+ errors.push(createI18nError({
63
+ message: `${locale}.yaml must contain a YAML object at the root.`,
64
+ filePath,
65
+ }));
66
+ return { catalog: normalized, errors };
67
+ }
68
+
69
+ Object.entries(catalog).forEach(([namespace, messages]) => {
70
+ if (!isPlainObject(messages)) {
71
+ errors.push(createI18nError({
72
+ message: `${locale}.yaml namespace "${namespace}" must be an object. Use namespace -> key -> content.`,
73
+ filePath,
74
+ }));
75
+ return;
76
+ }
77
+
78
+ normalized[namespace] = {};
79
+ Object.entries(messages).forEach(([key, content]) => {
80
+ if (isPlainObject(content) || Array.isArray(content)) {
81
+ errors.push(createI18nError({
82
+ message: `${locale}.yaml key "${namespace}.${key}" is nested too deeply. Use namespace -> key -> content only.`,
83
+ filePath,
84
+ }));
85
+ return;
86
+ }
87
+
88
+ if (typeof content !== "string") {
89
+ errors.push(createI18nError({
90
+ message: `${locale}.yaml key "${namespace}.${key}" must be a string.`,
91
+ filePath,
92
+ }));
93
+ return;
94
+ }
95
+
96
+ normalized[namespace][key] = content;
97
+ });
98
+ });
99
+
100
+ return { catalog: normalized, errors };
101
+ };
102
+
103
+ const normalizeI18nConfig = ({ cwd, i18n }) => {
104
+ const errors = [];
105
+
106
+ if (!i18n) {
107
+ return {
108
+ config: { enabled: false },
109
+ errors,
110
+ };
111
+ }
112
+
113
+ if (!isPlainObject(i18n)) {
114
+ errors.push(createI18nError({
115
+ message: "fe.i18n must be an object.",
116
+ }));
117
+ return {
118
+ config: { enabled: false },
119
+ errors,
120
+ };
121
+ }
122
+
123
+ const dir = i18n.dir;
124
+ if (typeof dir !== "string" || dir.trim() === "") {
125
+ errors.push(createI18nError({
126
+ message: "fe.i18n.dir must be a non-empty string.",
127
+ }));
128
+ }
129
+
130
+ const defaultLocale = i18n.defaultLocale;
131
+ const fallbackLocale = i18n.fallbackLocale;
132
+ validateLocaleName({ locale: defaultLocale, errors });
133
+ validateLocaleName({ locale: fallbackLocale, errors });
134
+
135
+ if (!Array.isArray(i18n.locales) || i18n.locales.length === 0) {
136
+ errors.push(createI18nError({
137
+ message: "fe.i18n.locales must be a non-empty array.",
138
+ }));
139
+ }
140
+
141
+ const locales = [];
142
+ const seen = new Set();
143
+ if (Array.isArray(i18n.locales)) {
144
+ i18n.locales.forEach((locale) => {
145
+ if (!validateLocaleName({ locale, errors })) {
146
+ return;
147
+ }
148
+ if (seen.has(locale)) {
149
+ errors.push(createI18nError({
150
+ message: `fe.i18n.locales contains duplicate locale "${locale}".`,
151
+ }));
152
+ return;
153
+ }
154
+ seen.add(locale);
155
+ locales.push(locale);
156
+ });
157
+ }
158
+
159
+ if (defaultLocale && locales.length > 0 && !seen.has(defaultLocale)) {
160
+ errors.push(createI18nError({
161
+ message: `fe.i18n.defaultLocale "${defaultLocale}" must be listed in fe.i18n.locales.`,
162
+ }));
163
+ }
164
+
165
+ if (fallbackLocale && locales.length > 0 && !seen.has(fallbackLocale)) {
166
+ errors.push(createI18nError({
167
+ message: `fe.i18n.fallbackLocale "${fallbackLocale}" must be listed in fe.i18n.locales.`,
168
+ }));
169
+ }
170
+
171
+ const outputDir = typeof i18n.outputDir === "string" && i18n.outputDir.trim()
172
+ ? i18n.outputDir
173
+ : DEFAULT_OUTPUT_DIR;
174
+ const normalizedOutputDir = outputDir.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
175
+ if (
176
+ !normalizedOutputDir ||
177
+ normalizedOutputDir.split("/").includes("..") ||
178
+ path.posix.isAbsolute(normalizedOutputDir)
179
+ ) {
180
+ errors.push(createI18nError({
181
+ message: "fe.i18n.outputDir must be a relative path when provided.",
182
+ }));
183
+ }
184
+
185
+ return {
186
+ config: {
187
+ enabled: errors.length === 0,
188
+ dir,
189
+ resolvedDir: dir ? path.resolve(cwd, dir) : null,
190
+ outputDir: normalizedOutputDir || DEFAULT_OUTPUT_DIR,
191
+ defaultLocale,
192
+ fallbackLocale,
193
+ locales,
194
+ },
195
+ errors,
196
+ };
197
+ };
198
+
199
+ export const analyzeI18nBuildContext = ({
200
+ cwd = process.cwd(),
201
+ i18n = null,
202
+ } = {}) => {
203
+ const { config, errors } = normalizeI18nConfig({ cwd, i18n });
204
+
205
+ if (!i18n || errors.length > 0) {
206
+ return {
207
+ context: { enabled: false },
208
+ errors,
209
+ };
210
+ }
211
+
212
+ if (!existsSync(config.resolvedDir)) {
213
+ errors.push(createI18nError({
214
+ message: `fe.i18n.dir does not exist: ${config.resolvedDir}`,
215
+ filePath: config.resolvedDir,
216
+ }));
217
+ return {
218
+ context: { enabled: false },
219
+ errors,
220
+ };
221
+ }
222
+
223
+ const localeFiles = {};
224
+ const catalogs = {};
225
+
226
+ config.locales.forEach((locale) => {
227
+ const filePath = path.join(config.resolvedDir, `${locale}.yaml`);
228
+ localeFiles[locale] = filePath;
229
+
230
+ if (!existsSync(filePath)) {
231
+ errors.push(createI18nError({
232
+ message: `missing i18n file for locale "${locale}". Expected ${filePath}.`,
233
+ filePath,
234
+ }));
235
+ return;
236
+ }
237
+
238
+ let yamlObject;
239
+ try {
240
+ yamlObject = loadYaml(readFileSync(filePath, "utf8")) ?? {};
241
+ } catch (error) {
242
+ errors.push(createI18nError({
243
+ message: `failed to parse ${locale}.yaml: ${error.message}`,
244
+ filePath,
245
+ }));
246
+ return;
247
+ }
248
+
249
+ const result = validateCatalog({ catalog: yamlObject, locale, filePath });
250
+ errors.push(...result.errors);
251
+ catalogs[locale] = result.catalog;
252
+ });
253
+
254
+ const defaultCatalog = catalogs[config.defaultLocale];
255
+ if (defaultCatalog) {
256
+ const defaultKeys = flattenCatalogKeys(defaultCatalog);
257
+ config.locales.forEach((locale) => {
258
+ if (locale === config.defaultLocale || !catalogs[locale]) {
259
+ return;
260
+ }
261
+
262
+ defaultKeys.forEach((fullKey) => {
263
+ const [namespace, key] = fullKey.split(".");
264
+ if (
265
+ !Object.prototype.hasOwnProperty.call(catalogs[locale] || {}, namespace) ||
266
+ !Object.prototype.hasOwnProperty.call(catalogs[locale]?.[namespace] || {}, key)
267
+ ) {
268
+ errors.push(createI18nError({
269
+ message: `${locale}.yaml is missing key "${fullKey}" from default locale "${config.defaultLocale}".`,
270
+ filePath: localeFiles[locale],
271
+ }));
272
+ }
273
+ });
274
+ });
275
+ }
276
+
277
+ const context = {
278
+ ...config,
279
+ enabled: errors.length === 0,
280
+ localeFiles,
281
+ catalogs,
282
+ };
283
+
284
+ return {
285
+ context,
286
+ errors,
287
+ };
288
+ };
289
+
290
+ export const loadI18nBuildContext = ({
291
+ cwd = process.cwd(),
292
+ i18n = null,
293
+ errorPrefix = "[Build]",
294
+ } = {}) => {
295
+ const { context, errors } = analyzeI18nBuildContext({ cwd, i18n });
296
+ assertNoI18nErrors({ errors, errorPrefix });
297
+ return context;
298
+ };
299
+
300
+ export const buildI18nAssets = ({ i18nContext }) => {
301
+ if (!i18nContext?.enabled) {
302
+ return [];
303
+ }
304
+
305
+ return i18nContext.locales.map((locale) => {
306
+ const relativeFileName = path.posix.join(
307
+ i18nContext.outputDir,
308
+ `${locale}.json`,
309
+ );
310
+ return {
311
+ locale,
312
+ relativeFileName,
313
+ sourcePath: i18nContext.localeFiles[locale],
314
+ catalog: i18nContext.catalogs[locale],
315
+ content: `${JSON.stringify(i18nContext.catalogs[locale], null, 2)}\n`,
316
+ };
317
+ });
318
+ };
319
+
320
+ export const emitI18nAssets = ({ outDir, i18nContext }) => {
321
+ const assets = buildI18nAssets({ i18nContext });
322
+
323
+ return assets.map((asset) => {
324
+ const outputPath = path.resolve(outDir, asset.relativeFileName);
325
+ mkdirSync(path.dirname(outputPath), { recursive: true });
326
+ writeFileSync(outputPath, asset.content);
327
+ return {
328
+ ...asset,
329
+ outputPath,
330
+ };
331
+ });
332
+ };
333
+
334
+ export const isI18nSourceFilePath = ({ filePath, i18nContext }) => {
335
+ if (!i18nContext?.enabled && !i18nContext?.resolvedDir) {
336
+ return false;
337
+ }
338
+
339
+ const resolvedFilePath = path.resolve(filePath);
340
+ const extension = path.extname(resolvedFilePath);
341
+ if (extension !== ".yaml") {
342
+ return false;
343
+ }
344
+
345
+ const relativePath = path.relative(i18nContext.resolvedDir, resolvedFilePath);
346
+ return (
347
+ relativePath !== "" &&
348
+ !relativePath.startsWith("..") &&
349
+ !path.isAbsolute(relativePath)
350
+ );
351
+ };
352
+
353
+ export const getI18nPublicAssetPath = ({
354
+ publicEntryPath,
355
+ relativeFileName,
356
+ }) => {
357
+ const normalizedEntry = publicEntryPath
358
+ ? publicEntryPath.replace(/\\/g, "/")
359
+ : "/main.js";
360
+ const entryPath = normalizedEntry.startsWith("/")
361
+ ? normalizedEntry
362
+ : `/${normalizedEntry}`;
363
+ return path.posix.join(
364
+ path.posix.dirname(entryPath),
365
+ toPosixPath(relativeFileName),
366
+ );
367
+ };
@@ -1,7 +1,14 @@
1
1
  import path from "node:path";
2
2
 
3
+ import { getAllFiles } from "../commonBuild.js";
3
4
  import { isSupportedComponentFile } from "./contracts.js";
4
5
  import { generateFrontendEntrySource } from "./frontendEntrySource.js";
6
+ import {
7
+ buildI18nAssets,
8
+ getI18nPublicAssetPath,
9
+ isI18nSourceFilePath,
10
+ loadI18nBuildContext,
11
+ } from "./i18nBuild.js";
5
12
 
6
13
  export const RETTANGOLI_FE_VIRTUAL_ENTRY_ID = "virtual:rettangoli-fe-entry";
7
14
  const RESOLVED_VIRTUAL_ENTRY_ID = `\0${RETTANGOLI_FE_VIRTUAL_ENTRY_ID}`;
@@ -22,26 +29,61 @@ const normalizePublicEntryPath = (value) => {
22
29
  return normalized.startsWith("/") ? normalized : `/${normalized}`;
23
30
  };
24
31
 
32
+ const getI18nWatchPaths = ({ i18nContext }) => {
33
+ if (!i18nContext?.enabled) {
34
+ return [];
35
+ }
36
+
37
+ return [
38
+ i18nContext.resolvedDir,
39
+ ...Object.values(i18nContext.localeFiles || {}),
40
+ ].filter(Boolean);
41
+ };
42
+
25
43
  export const createRettangoliFeVitePlugin = ({
26
44
  cwd = process.cwd(),
27
45
  dirs = ["./example"],
28
46
  setup = "setup.js",
47
+ i18n = null,
29
48
  errorPrefix = "[Build]",
30
49
  publicEntryPath = null,
31
50
  } = {}) => {
32
51
  const resolvedDirs = dirs.map((directory) => path.resolve(cwd, directory));
33
52
  const resolvedSetup = path.resolve(cwd, setup);
34
53
  const normalizedPublicEntryPath = normalizePublicEntryPath(publicEntryPath);
54
+ const i18nContext = loadI18nBuildContext({ cwd, i18n, errorPrefix });
35
55
 
36
56
  let currentCommand = "build";
37
57
  let devServer = null;
38
58
 
59
+ const getComponentFilePaths = () => {
60
+ return getAllFiles(resolvedDirs)
61
+ .filter((filePath) => isSupportedComponentFile(filePath));
62
+ };
63
+
64
+ const getEntryWatchPaths = ({ includeDirectories = false } = {}) => {
65
+ const i18nWatchPaths = includeDirectories
66
+ ? getI18nWatchPaths({ i18nContext })
67
+ : Object.values(i18nContext.localeFiles || {});
68
+
69
+ return [
70
+ ...(includeDirectories ? resolvedDirs : []),
71
+ resolvedSetup,
72
+ ...getComponentFilePaths(),
73
+ ...i18nWatchPaths,
74
+ ];
75
+ };
76
+
39
77
  const isTrackedFilePath = (value) => {
40
78
  const filePath = path.resolve(value);
41
79
  if (filePath === resolvedSetup) {
42
80
  return true;
43
81
  }
44
82
 
83
+ if (isI18nSourceFilePath({ filePath, i18nContext })) {
84
+ return true;
85
+ }
86
+
45
87
  if (!isSupportedComponentFile(filePath)) {
46
88
  return false;
47
89
  }
@@ -87,16 +129,64 @@ export const createRettangoliFeVitePlugin = ({
87
129
  if (id !== RESOLVED_VIRTUAL_ENTRY_ID) {
88
130
  return null;
89
131
  }
132
+
133
+ for (const filePath of getEntryWatchPaths()) {
134
+ if (typeof this.addWatchFile === "function") {
135
+ this.addWatchFile(filePath);
136
+ }
137
+ }
138
+
90
139
  return generateFrontendEntrySource({
91
140
  cwd,
92
141
  dirs,
93
142
  setup,
143
+ i18n,
94
144
  command: currentCommand,
95
145
  errorPrefix,
96
146
  });
97
147
  },
98
148
  configureServer(server) {
99
149
  devServer = server;
150
+ server.watcher.add(getEntryWatchPaths({ includeDirectories: true }));
151
+
152
+ const serveI18nAsset = (req, res, next) => {
153
+ if (!i18nContext.enabled || !normalizedPublicEntryPath) {
154
+ next();
155
+ return;
156
+ }
157
+
158
+ const reqPath = (req.url || "").split("?")[0];
159
+
160
+ try {
161
+ const currentI18nContext = loadI18nBuildContext({
162
+ cwd,
163
+ i18n,
164
+ errorPrefix,
165
+ });
166
+ const asset = buildI18nAssets({
167
+ i18nContext: currentI18nContext,
168
+ }).find((candidate) => {
169
+ const publicPath = getI18nPublicAssetPath({
170
+ publicEntryPath: normalizedPublicEntryPath,
171
+ relativeFileName: candidate.relativeFileName,
172
+ });
173
+ return reqPath === publicPath;
174
+ });
175
+
176
+ if (!asset) {
177
+ next();
178
+ return;
179
+ }
180
+
181
+ res.statusCode = 200;
182
+ res.setHeader("Content-Type", "application/json; charset=utf-8");
183
+ res.end(asset.content);
184
+ } catch (error) {
185
+ res.statusCode = 500;
186
+ res.setHeader("Content-Type", "text/plain; charset=utf-8");
187
+ res.end(error.stack || String(error));
188
+ }
189
+ };
100
190
 
101
191
  const onAdd = (filePath) => {
102
192
  if (isTrackedFilePath(filePath)) {
@@ -114,6 +204,7 @@ export const createRettangoliFeVitePlugin = ({
114
204
  server.watcher.on("unlink", onUnlink);
115
205
 
116
206
  if (normalizedPublicEntryPath) {
207
+ server.middlewares.use(serveI18nAsset);
117
208
  server.middlewares.use(async (req, res, next) => {
118
209
  const reqPath = (req.url || "").split("?")[0];
119
210
  if (reqPath !== normalizedPublicEntryPath) {
package/src/cli/watch.js CHANGED
@@ -2,7 +2,10 @@ import path from "node:path";
2
2
 
3
3
  import { createServer } from "vite";
4
4
 
5
- import { createRettangoliFeVitePlugin } from "./vitePlugin.js";
5
+ import {
6
+ RETTANGOLI_FE_VIRTUAL_ENTRY_ID,
7
+ createRettangoliFeVitePlugin,
8
+ } from "./vitePlugin.js";
6
9
 
7
10
  const toPosixPath = (value) => value.split(path.sep).join("/");
8
11
 
@@ -39,15 +42,18 @@ export const createWatchServer = async (options = {}) => {
39
42
  port = 3001,
40
43
  outfile = "./vt/static/main.js",
41
44
  setup = "setup.js",
45
+ i18n = null,
42
46
  } = options;
43
47
 
44
48
  const { root, publicEntryPath } = resolveServeContext({ cwd, outfile });
45
49
 
46
50
  const server = await createServer({
51
+ clearScreen: false,
47
52
  configFile: false,
48
53
  root,
49
54
  server: {
50
55
  port,
56
+ strictPort: true,
51
57
  host: "0.0.0.0",
52
58
  allowedHosts: true,
53
59
  },
@@ -56,6 +62,7 @@ export const createWatchServer = async (options = {}) => {
56
62
  cwd,
57
63
  dirs,
58
64
  setup,
65
+ i18n,
59
66
  errorPrefix: "[Watch]",
60
67
  publicEntryPath,
61
68
  }),
@@ -73,12 +80,13 @@ const startWatching = async (options = {}) => {
73
80
  } = options;
74
81
  const { root, publicEntryPath } = resolveServeContext({ cwd, outfile });
75
82
 
76
- console.log("watch root dir:", root);
77
- console.log("watch entry path:", publicEntryPath);
83
+ console.log(`[Watch] Root: ${root}`);
84
+ console.log(`[Watch] Entry: ${publicEntryPath}`);
78
85
 
79
86
  try {
80
87
  const server = await createWatchServer(options);
81
88
  await server.listen();
89
+ await server.transformRequest(RETTANGOLI_FE_VIRTUAL_ENTRY_ID);
82
90
  server.printUrls();
83
91
  if (enableCliShortcuts) {
84
92
  server.bindCLIShortcuts({ print: true });
@@ -1,4 +1,5 @@
1
1
  import { findUnsupportedTemplatePropertyBindingSyntax } from "../view/templatePropertyBindings.js";
2
+ import { validateViewI18nReferences } from "../i18n/viewReferences.js";
2
3
 
3
4
  export const FORBIDDEN_VIEW_KEYS = Object.freeze([
4
5
  "elementName",
@@ -51,7 +52,8 @@ export const buildComponentContractIndex = (entries = []) => {
51
52
  return index;
52
53
  };
53
54
 
54
- export const validateComponentContractIndex = (index = {}) => {
55
+ export const validateComponentContractIndex = (index = {}, options = {}) => {
56
+ const { i18nContext = { enabled: false } } = options;
55
57
  const errors = [];
56
58
 
57
59
  Object.entries(index).forEach(([category, components]) => {
@@ -90,6 +92,13 @@ export const validateComponentContractIndex = (index = {}) => {
90
92
  filePath: viewFilePath || representativeFile,
91
93
  });
92
94
  }
95
+
96
+ errors.push(...validateViewI18nReferences({
97
+ viewYaml,
98
+ componentLabel,
99
+ filePath: viewFilePath || representativeFile,
100
+ i18nContext,
101
+ }));
93
102
  });
94
103
  });
95
104