@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,287 @@
1
+ const EXPRESSION_PATTERN = /\$\{([^}]*)\}/g;
2
+ const I18N_PATH_PATTERN = /^i18n\.([A-Za-z_$][\w$]*)\.([A-Za-z_$][\w$]*)$/;
3
+ const SIMPLE_I18N_PATH_PATTERN = /^i18n((?:\.[A-Za-z_$][\w$]*)+)$/;
4
+
5
+ const createIssue = ({ message, expression, path }) => ({
6
+ message,
7
+ expression,
8
+ path,
9
+ });
10
+
11
+ const createFindingKey = (finding) => `${finding.path}\u0000${finding.expression}`;
12
+
13
+ const extractTemplateExpressions = (text) => {
14
+ const expressions = [];
15
+ let match;
16
+ EXPRESSION_PATTERN.lastIndex = 0;
17
+ while ((match = EXPRESSION_PATTERN.exec(text)) !== null) {
18
+ expressions.push(match[1].trim());
19
+ }
20
+ return expressions;
21
+ };
22
+
23
+ export const analyzeI18nExpression = ({ expression, path = "$" }) => {
24
+ const startsFromI18nRoot = /^i18n(?:\b|\.)/.test(expression);
25
+ const startsFromInternalI18nRoot = /^__rtglI18n(?:\b|\s*\()/.test(expression);
26
+
27
+ if (!startsFromI18nRoot && !startsFromInternalI18nRoot) {
28
+ return { references: [], issues: [] };
29
+ }
30
+
31
+ if (
32
+ /^__rtglI18n\s*\(/.test(expression) ||
33
+ /^i18n\s*\(/.test(expression) ||
34
+ /^i18n(?:\.[A-Za-z_$][\w$]*)+\s*\(/.test(expression)
35
+ ) {
36
+ return {
37
+ references: [],
38
+ issues: [
39
+ createIssue({
40
+ expression,
41
+ path,
42
+ message: "i18n function calls are not supported.",
43
+ }),
44
+ ],
45
+ };
46
+ }
47
+
48
+ const exactPathMatch = expression.match(I18N_PATH_PATTERN);
49
+ if (exactPathMatch) {
50
+ return {
51
+ references: [
52
+ {
53
+ namespace: exactPathMatch[1],
54
+ key: exactPathMatch[2],
55
+ expression,
56
+ path,
57
+ },
58
+ ],
59
+ issues: [],
60
+ };
61
+ }
62
+
63
+ const simplePathMatch = expression.match(SIMPLE_I18N_PATH_PATTERN);
64
+ if (simplePathMatch) {
65
+ const segments = simplePathMatch[1].split(".").filter(Boolean);
66
+ const message = segments.length < 2
67
+ ? "i18n references must use two levels: i18n.namespace.key."
68
+ : "i18n references must not be deeper than i18n.namespace.key.";
69
+ return {
70
+ references: [],
71
+ issues: [
72
+ createIssue({
73
+ expression,
74
+ path,
75
+ message,
76
+ }),
77
+ ],
78
+ };
79
+ }
80
+
81
+ if (startsFromI18nRoot || startsFromInternalI18nRoot) {
82
+ return {
83
+ references: [],
84
+ issues: [
85
+ createIssue({
86
+ expression,
87
+ path,
88
+ message: "i18n references must be direct paths: i18n.namespace.key.",
89
+ }),
90
+ ],
91
+ };
92
+ }
93
+
94
+ return { references: [], issues: [] };
95
+ };
96
+
97
+ export const collectI18nReferencesFromView = ({
98
+ value,
99
+ path = "$",
100
+ references = [],
101
+ issues = [],
102
+ } = {}) => {
103
+ if (typeof value === "string") {
104
+ const expressions = extractTemplateExpressions(value);
105
+ expressions.forEach((expression) => {
106
+ const result = analyzeI18nExpression({ expression, path });
107
+ references.push(...result.references);
108
+ issues.push(...result.issues);
109
+ });
110
+ return { references, issues };
111
+ }
112
+
113
+ if (Array.isArray(value)) {
114
+ value.forEach((item, index) => {
115
+ collectI18nReferencesFromView({
116
+ value: item,
117
+ path: `${path}[${index}]`,
118
+ references,
119
+ issues,
120
+ });
121
+ });
122
+ return { references, issues };
123
+ }
124
+
125
+ if (value && typeof value === "object") {
126
+ Object.entries(value).forEach(([key, childValue]) => {
127
+ collectI18nReferencesFromView({
128
+ value: key,
129
+ path: `${path}.{key}`,
130
+ references,
131
+ issues,
132
+ });
133
+ collectI18nReferencesFromView({
134
+ value: childValue,
135
+ path: `${path}.${key}`,
136
+ references,
137
+ issues,
138
+ });
139
+ });
140
+ }
141
+
142
+ return { references, issues };
143
+ };
144
+
145
+ const collectEventPayloadI18nReferences = ({
146
+ refs,
147
+ references,
148
+ issues,
149
+ }) => {
150
+ if (!refs || typeof refs !== "object" || Array.isArray(refs)) {
151
+ return;
152
+ }
153
+
154
+ Object.entries(refs).forEach(([refKey, refConfig]) => {
155
+ const eventListeners = refConfig?.eventListeners;
156
+ if (!eventListeners || typeof eventListeners !== "object" || Array.isArray(eventListeners)) {
157
+ return;
158
+ }
159
+
160
+ Object.entries(eventListeners).forEach(([eventType, eventConfig]) => {
161
+ if (
162
+ !eventConfig
163
+ || typeof eventConfig !== "object"
164
+ || Array.isArray(eventConfig)
165
+ || !Object.prototype.hasOwnProperty.call(eventConfig, "payload")
166
+ ) {
167
+ return;
168
+ }
169
+
170
+ collectI18nReferencesFromView({
171
+ value: eventConfig.payload,
172
+ path: `$.refs.${refKey}.eventListeners.${eventType}.payload`,
173
+ references,
174
+ issues,
175
+ });
176
+ });
177
+ });
178
+ };
179
+
180
+ export const collectRenderableI18nReferencesFromView = ({ viewYaml } = {}) => {
181
+ const references = [];
182
+ const issues = [];
183
+
184
+ if (viewYaml && typeof viewYaml === "object" && !Array.isArray(viewYaml)) {
185
+ if (Object.prototype.hasOwnProperty.call(viewYaml, "template")) {
186
+ collectI18nReferencesFromView({
187
+ value: viewYaml.template,
188
+ path: "$.template",
189
+ references,
190
+ issues,
191
+ });
192
+ }
193
+
194
+ collectEventPayloadI18nReferences({
195
+ refs: viewYaml.refs,
196
+ references,
197
+ issues,
198
+ });
199
+ } else {
200
+ collectI18nReferencesFromView({
201
+ value: viewYaml,
202
+ references,
203
+ issues,
204
+ });
205
+ }
206
+
207
+ return { references, issues };
208
+ };
209
+
210
+ export const validateViewI18nReferences = ({
211
+ viewYaml,
212
+ componentLabel,
213
+ filePath,
214
+ i18nContext = { enabled: false },
215
+ } = {}) => {
216
+ const supported = collectRenderableI18nReferencesFromView({
217
+ viewYaml,
218
+ });
219
+ const all = collectI18nReferencesFromView({
220
+ value: viewYaml,
221
+ });
222
+ const supportedFindingKeys = new Set([
223
+ ...supported.references.map(createFindingKey),
224
+ ...supported.issues.map(createFindingKey),
225
+ ]);
226
+
227
+ const unsupportedFindings = [
228
+ ...all.references,
229
+ ...all.issues,
230
+ ].filter((finding) => !supportedFindingKeys.has(createFindingKey(finding)));
231
+
232
+ const errors = unsupportedFindings.map((finding) => ({
233
+ code: "RTGL-I18N-004",
234
+ message: `${componentLabel}: unsupported i18n expression "\${${finding.expression}}" at ${finding.path}. i18n references are supported only in template values, template bindings, and event listener payloads.`,
235
+ filePath,
236
+ }));
237
+
238
+ supported.issues.forEach((issue) => {
239
+ errors.push({
240
+ code: "RTGL-I18N-001",
241
+ message: `${componentLabel}: invalid i18n expression "\${${issue.expression}}": ${issue.message}`,
242
+ filePath,
243
+ });
244
+ });
245
+
246
+ const references = supported.references;
247
+
248
+ if (errors.length > 0) {
249
+ return errors;
250
+ }
251
+
252
+ if (!i18nContext?.enabled && references.length > 0) {
253
+ references.forEach((reference) => {
254
+ errors.push({
255
+ code: "RTGL-I18N-002",
256
+ message: `${componentLabel}: i18n reference "\${${reference.expression}}" requires fe.i18n configuration.`,
257
+ filePath,
258
+ });
259
+ });
260
+ return errors;
261
+ }
262
+
263
+ if (i18nContext?.enabled) {
264
+ const defaultCatalog = i18nContext.catalogs?.[i18nContext.defaultLocale] || {};
265
+ references.forEach((reference) => {
266
+ if (!Object.prototype.hasOwnProperty.call(defaultCatalog, reference.namespace)) {
267
+ errors.push({
268
+ code: "RTGL-I18N-003",
269
+ message: `${componentLabel}: missing i18n namespace "${reference.namespace}" in default locale "${i18nContext.defaultLocale}".`,
270
+ filePath,
271
+ });
272
+ return;
273
+ }
274
+
275
+ const namespaceMessages = defaultCatalog[reference.namespace] || {};
276
+ if (!Object.prototype.hasOwnProperty.call(namespaceMessages, reference.key)) {
277
+ errors.push({
278
+ code: "RTGL-I18N-003",
279
+ message: `${componentLabel}: missing i18n key "${reference.namespace}.${reference.key}" in default locale "${i18nContext.defaultLocale}".`,
280
+ filePath,
281
+ });
282
+ }
283
+ });
284
+ }
285
+
286
+ return errors;
287
+ };
@@ -46,6 +46,7 @@ export const runConnectedComponentLifecycle = ({
46
46
  refs: instance.refs,
47
47
  handlers: instance.transformedHandlers,
48
48
  parseAndRenderFn,
49
+ getPayloadContext: () => instance.viewData,
49
50
  });
50
51
 
51
52
  renderFn();
@@ -16,6 +16,7 @@ export const createEventDispatchCallback = ({
16
16
  handlers,
17
17
  onMissingHandler,
18
18
  parseAndRenderFn,
19
+ getPayloadContext,
19
20
  }) => {
20
21
  const getPayload = (event) => {
21
22
  const payloadTemplate = (
@@ -28,7 +29,11 @@ export const createEventDispatchCallback = ({
28
29
  if (typeof parseAndRenderFn !== "function") {
29
30
  return payloadTemplate;
30
31
  }
32
+ const payloadContext = typeof getPayloadContext === "function"
33
+ ? getPayloadContext()
34
+ : {};
31
35
  return parseAndRenderFn(payloadTemplate, {
36
+ ...(payloadContext && typeof payloadContext === "object" ? payloadContext : {}),
32
37
  _event: event,
33
38
  });
34
39
  };
@@ -162,6 +167,7 @@ export const createConfiguredEventListener = ({
162
167
  stateKey,
163
168
  fallbackCurrentTarget = null,
164
169
  parseAndRenderFn,
170
+ getPayloadContext,
165
171
  onMissingHandler,
166
172
  nowFn = Date.now,
167
173
  setTimeoutFn = setTimeout,
@@ -178,6 +184,7 @@ export const createConfiguredEventListener = ({
178
184
  handlers,
179
185
  onMissingHandler,
180
186
  parseAndRenderFn,
187
+ getPayloadContext,
181
188
  });
182
189
  if (!callback) {
183
190
  return null;
@@ -21,6 +21,7 @@ export const attachGlobalRefListeners = ({
21
21
  document: globalThis.document,
22
22
  },
23
23
  parseAndRenderFn,
24
+ getPayloadContext,
24
25
  timing = {
25
26
  nowFn: Date.now,
26
27
  setTimeoutFn: setTimeout,
@@ -54,6 +55,7 @@ export const attachGlobalRefListeners = ({
54
55
  stateKey,
55
56
  fallbackCurrentTarget: target,
56
57
  parseAndRenderFn,
58
+ getPayloadContext,
57
59
  nowFn: timing.nowFn,
58
60
  setTimeoutFn: timing.setTimeoutFn,
59
61
  clearTimeoutFn: timing.clearTimeoutFn,
@@ -0,0 +1,155 @@
1
+ const getDefaultFetch = () => {
2
+ if (typeof globalThis !== "undefined" && typeof globalThis.fetch === "function") {
3
+ return globalThis.fetch.bind(globalThis);
4
+ }
5
+ return null;
6
+ };
7
+
8
+ const normalizeAvailableLocales = (locales = []) => [...new Set(locales)];
9
+
10
+ const createUnknownLocaleError = ({ locale, availableLocales }) => {
11
+ return new Error(
12
+ `[i18n] Unknown locale "${locale}". Available locales: ${availableLocales.join(", ")}.`,
13
+ );
14
+ };
15
+
16
+ export const createI18nRuntime = ({
17
+ defaultLocale,
18
+ fallbackLocale = defaultLocale,
19
+ locales = [],
20
+ urls = {},
21
+ initialCatalogs = {},
22
+ fetchFn = getDefaultFetch(),
23
+ } = {}) => {
24
+ const availableLocales = normalizeAvailableLocales(locales);
25
+ const catalogs = new Map(Object.entries(initialCatalogs || {}));
26
+ const listeners = new Set();
27
+ let currentLocale = defaultLocale || fallbackLocale || availableLocales[0] || null;
28
+ let localeRequestVersion = 0;
29
+
30
+ const assertKnownLocale = (locale) => {
31
+ if (!availableLocales.includes(locale)) {
32
+ throw createUnknownLocaleError({ locale, availableLocales });
33
+ }
34
+ };
35
+
36
+ const getMessages = () => {
37
+ return (
38
+ catalogs.get(currentLocale) ||
39
+ catalogs.get(fallbackLocale) ||
40
+ Object.create(null)
41
+ );
42
+ };
43
+
44
+ const notify = () => {
45
+ listeners.forEach((listener) => {
46
+ listener(currentLocale);
47
+ });
48
+ };
49
+
50
+ const load = async (locale) => {
51
+ assertKnownLocale(locale);
52
+
53
+ if (catalogs.has(locale)) {
54
+ return catalogs.get(locale);
55
+ }
56
+
57
+ const url = urls[locale];
58
+ if (!url) {
59
+ throw new Error(`[i18n] Missing URL for locale "${locale}".`);
60
+ }
61
+ if (!fetchFn) {
62
+ throw new Error("[i18n] fetch is not available for lazy locale loading.");
63
+ }
64
+
65
+ const response = await fetchFn(url);
66
+ if (!response?.ok) {
67
+ throw new Error(`[i18n] Failed to load locale "${locale}" from ${url}.`);
68
+ }
69
+
70
+ const catalog = await response.json();
71
+ catalogs.set(locale, catalog);
72
+ return catalog;
73
+ };
74
+
75
+ const activateLocale = (locale) => {
76
+ currentLocale = locale;
77
+ notify();
78
+ return getMessages();
79
+ };
80
+
81
+ const set = async (locale) => {
82
+ assertKnownLocale(locale);
83
+ const requestVersion = localeRequestVersion + 1;
84
+ localeRequestVersion = requestVersion;
85
+ const isLatestRequest = () => requestVersion === localeRequestVersion;
86
+
87
+ try {
88
+ await load(locale);
89
+ } catch (error) {
90
+ if (!isLatestRequest()) {
91
+ return getMessages();
92
+ }
93
+ if (locale === fallbackLocale) {
94
+ throw error;
95
+ }
96
+ try {
97
+ await load(fallbackLocale);
98
+ } catch (fallbackError) {
99
+ if (!isLatestRequest()) {
100
+ return getMessages();
101
+ }
102
+ throw fallbackError;
103
+ }
104
+ if (!isLatestRequest()) {
105
+ return getMessages();
106
+ }
107
+ return activateLocale(fallbackLocale);
108
+ }
109
+
110
+ if (!isLatestRequest()) {
111
+ return getMessages();
112
+ }
113
+ return activateLocale(locale);
114
+ };
115
+
116
+ const ready = async () => {
117
+ if (!currentLocale) {
118
+ return getMessages();
119
+ }
120
+ if (catalogs.has(currentLocale)) {
121
+ return getMessages();
122
+ }
123
+ return set(currentLocale);
124
+ };
125
+
126
+ const current = () => currentLocale;
127
+ const available = () => [...availableLocales];
128
+ const subscribe = (listener) => {
129
+ listeners.add(listener);
130
+ return () => {
131
+ listeners.delete(listener);
132
+ };
133
+ };
134
+
135
+ const localeService = {
136
+ available,
137
+ current,
138
+ load,
139
+ ready,
140
+ set,
141
+ subscribe,
142
+ };
143
+
144
+ return {
145
+ available,
146
+ current,
147
+ getMessages,
148
+ load,
149
+ locale: localeService,
150
+ localeService,
151
+ ready,
152
+ set,
153
+ subscribe,
154
+ };
155
+ };
@@ -5,13 +5,26 @@ export const createRuntimeDeps = ({
5
5
  store,
6
6
  render,
7
7
  }) => {
8
- return {
8
+ const runtimeDeps = {
9
9
  ...baseDeps,
10
10
  refs,
11
11
  dispatchEvent,
12
12
  store,
13
13
  render,
14
14
  };
15
+
16
+ if (baseDeps?.__rtglI18nRuntime) {
17
+ Object.defineProperty(runtimeDeps, "i18n", {
18
+ enumerable: true,
19
+ configurable: true,
20
+ get() {
21
+ return baseDeps.__rtglI18nRuntime.getMessages();
22
+ },
23
+ });
24
+ runtimeDeps.locale = baseDeps.__rtglI18nRuntime.locale;
25
+ }
26
+
27
+ return runtimeDeps;
15
28
  };
16
29
 
17
30
  export const createStoreActionDispatcher = ({
@@ -2,12 +2,20 @@ import { produce } from "immer";
2
2
 
3
3
  import { isObjectPayload } from "./payload.js";
4
4
 
5
- export const bindStore = (store, props, constants) => {
5
+ export const bindStore = (store, props, constants, runtimeContext = {}) => {
6
6
  const { createInitialState, ...selectorsAndActions } = store;
7
7
  const selectors = {};
8
8
  const actions = {};
9
9
  let currentState = {};
10
10
 
11
+ const createStoreContext = (state) => ({
12
+ state,
13
+ props,
14
+ constants,
15
+ i18n: runtimeContext.getI18n?.() || {},
16
+ locale: runtimeContext.locale,
17
+ });
18
+
11
19
  if (createInitialState) {
12
20
  currentState = createInitialState({ props, constants });
13
21
  }
@@ -15,7 +23,7 @@ export const bindStore = (store, props, constants) => {
15
23
  Object.entries(selectorsAndActions).forEach(([key, fn]) => {
16
24
  if (key.startsWith("select")) {
17
25
  selectors[key] = (...args) => {
18
- return fn({ state: currentState, props, constants }, ...args);
26
+ return fn(createStoreContext(currentState), ...args);
19
27
  };
20
28
  return;
21
29
  }
@@ -28,7 +36,7 @@ export const bindStore = (store, props, constants) => {
28
36
  );
29
37
  }
30
38
  currentState = produce(currentState, (draft) => {
31
- return fn({ state: draft, props, constants }, normalizedPayload);
39
+ return fn(createStoreContext(draft), normalizedPayload);
32
40
  });
33
41
  return currentState;
34
42
  };
package/src/index.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import createComponent from './createComponent.js';
2
+ import { createI18nRuntime } from "./core/runtime/i18n.js";
2
3
 
3
4
  export {
4
5
  createComponent,
6
+ createI18nRuntime,
5
7
  }
package/src/parser.js CHANGED
@@ -227,6 +227,7 @@ export const createVirtualDom = ({
227
227
  eventRateLimitState,
228
228
  stateKey,
229
229
  parseAndRenderFn: jemplParseAndRender,
230
+ getPayloadContext: () => viewData,
230
231
  onMissingHandler: (missingHandlerName) => {
231
232
  console.warn(
232
233
  `[Parser] Handler '${missingHandlerName}' for refKey '${bestMatchRefKey}' (matching '${matchIdentity}') is referenced but not found in available handlers.`,
@@ -52,6 +52,8 @@ export const createWebComponentClass = ({
52
52
  _globalListenersCleanup;
53
53
  _oldVNode;
54
54
  deps;
55
+ i18nRuntime;
56
+ _i18nUnsubscribe;
55
57
  _propsSchemaKeys = [];
56
58
  cssText;
57
59
 
@@ -64,7 +66,10 @@ export const createWebComponentClass = ({
64
66
  if (this.store.selectViewData) {
65
67
  data = this.store.selectViewData();
66
68
  }
67
- return data;
69
+ return {
70
+ ...data,
71
+ i18n: this.i18nRuntime?.getMessages?.() || {},
72
+ };
68
73
  }
69
74
 
70
75
  connectedCallback() {
@@ -74,6 +79,11 @@ export const createWebComponentClass = ({
74
79
  });
75
80
  this.shadow = dom.shadow;
76
81
  this.renderTarget = dom.renderTarget;
82
+ if (this.i18nRuntime?.subscribe && !this._i18nUnsubscribe) {
83
+ this._i18nUnsubscribe = this.i18nRuntime.subscribe(() => {
84
+ this.render();
85
+ });
86
+ }
77
87
  runConnectedComponentLifecycle({
78
88
  instance: this,
79
89
  parseAndRenderFn: parseAndRender,
@@ -82,6 +92,10 @@ export const createWebComponentClass = ({
82
92
  }
83
93
 
84
94
  disconnectedCallback() {
95
+ if (this._i18nUnsubscribe) {
96
+ this._i18nUnsubscribe();
97
+ this._i18nUnsubscribe = undefined;
98
+ }
85
99
  runDisconnectedComponentLifecycle({
86
100
  instance: this,
87
101
  clearTimerFn: clearTimeout,
@@ -140,7 +154,11 @@ export const createWebComponentClass = ({
140
154
  this._propsSchemaKeys = propsSchemaKeys;
141
155
  this.elementName = elementName;
142
156
  this.styles = styles;
143
- this.store = bindStore(store, this.props, this.constants);
157
+ this.i18nRuntime = deps?.__rtglI18nRuntime;
158
+ this.store = bindStore(store, this.props, this.constants, {
159
+ getI18n: () => this.i18nRuntime?.getMessages?.() || {},
160
+ locale: this.i18nRuntime?.locale,
161
+ });
144
162
  this.template = template;
145
163
  this.handlers = handlers;
146
164
  this.methods = methods;
@@ -148,12 +166,20 @@ export const createWebComponentClass = ({
148
166
  this.patch = patch;
149
167
  this.deps = {
150
168
  ...deps,
169
+ locale: this.i18nRuntime?.locale || deps?.locale,
151
170
  store: this.store,
152
171
  render: this.render,
153
172
  handlers,
154
173
  props: this.props,
155
174
  constants: this.constants,
156
175
  };
176
+ if (this.i18nRuntime) {
177
+ Object.defineProperty(this.deps, "i18n", {
178
+ enumerable: true,
179
+ configurable: true,
180
+ get: () => this.i18nRuntime.getMessages(),
181
+ });
182
+ }
157
183
  bindMethods(this, this.methods);
158
184
  // Keep the Snabbdom helper off public prop names (e.g. schema prop "h").
159
185
  this._snabbdomH = h;