slicejs-web-framework 4.1.0 → 4.2.0
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/Slice/Components/Structural/Controller/Controller.js +1427 -1406
- package/Slice/Components/Structural/EventManager/EventManager.js +652 -651
- package/Slice/Components/Structural/Router/Router.js +982 -820
- package/Slice/Components/Structural/Router/applyRouteMetadata.js +151 -8
- package/Slice/Components/Structural/Translator/Translator.js +1111 -0
- package/Slice/Components/Structural/Translator/TranslatorDebugger.js +511 -0
- package/Slice/Components/Structural/Translator/content.js +188 -0
- package/Slice/Components/Structural/Translator/dom.js +253 -0
- package/Slice/Components/Structural/Translator/localePath.js +90 -0
- package/Slice/Components/Structural/Translator/localized.js +80 -0
- package/Slice/Components/Structural/Translator/markers.js +148 -0
- package/Slice/Components/Structural/Translator/messages.js +211 -0
- package/Slice/Slice.js +826 -711
- package/api/framework/content.js +73 -0
- package/api/framework/seo.js +394 -43
- package/api/framework/server.js +205 -28
- package/package.json +2 -1
- package/sliceConfig.schema.json +150 -122
- package/types/index.d.ts +347 -219
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure message logic for slice.translator: no DOM, no fetch, no `slice` global.
|
|
3
|
+
* Kept apart so it runs under node:test and is shared with the server-side SEO
|
|
4
|
+
* code later (feature 0003 §9).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** Plural categories `Intl.PluralRules` can return, plus the explicit `zero`. */
|
|
8
|
+
export const PLURAL_CATEGORIES = Object.freeze(['zero', 'one', 'two', 'few', 'many', 'other']);
|
|
9
|
+
|
|
10
|
+
/** Base languages written right to left. */
|
|
11
|
+
const RTL_LANGUAGES = new Set(['ar', 'he', 'fa', 'ur']);
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* True for a plain object (dictionary group, params bag) — not an array, a Date, a marker…
|
|
15
|
+
* @param {unknown} value
|
|
16
|
+
* @returns {value is Record<string, any>}
|
|
17
|
+
*/
|
|
18
|
+
export function isPlainObject(value) {
|
|
19
|
+
if (!value || typeof value !== 'object') return false;
|
|
20
|
+
const proto = Object.getPrototypeOf(value);
|
|
21
|
+
return proto === Object.prototype || proto === null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A plural message: an object with `other` whose keys are all plural categories.
|
|
26
|
+
* @param {unknown} value
|
|
27
|
+
* @returns {boolean}
|
|
28
|
+
*/
|
|
29
|
+
export function isPluralMessage(value) {
|
|
30
|
+
if (!isPlainObject(value) || typeof value.other !== 'string') return false;
|
|
31
|
+
return Object.keys(value).every((key) => PLURAL_CATEGORIES.includes(key));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Deep-merge `source` into `target` (mutates and returns `target`). Plain objects merge,
|
|
36
|
+
* anything else replaces. Plural objects replace as a whole so categories never mix.
|
|
37
|
+
* @param {Record<string, any>} target
|
|
38
|
+
* @param {Record<string, any>} source
|
|
39
|
+
* @returns {Record<string, any>}
|
|
40
|
+
*/
|
|
41
|
+
export function mergeMessages(target, source) {
|
|
42
|
+
if (!isPlainObject(source)) return target;
|
|
43
|
+
for (const [key, value] of Object.entries(source)) {
|
|
44
|
+
if (key === '__proto__' || key === 'constructor' || key === 'prototype') continue;
|
|
45
|
+
if (isPlainObject(value) && !isPluralMessage(value) && isPlainObject(target[key]) && !isPluralMessage(target[key])) {
|
|
46
|
+
mergeMessages(target[key], value);
|
|
47
|
+
} else if (isPlainObject(value) && !isPluralMessage(value)) {
|
|
48
|
+
target[key] = mergeMessages({}, value);
|
|
49
|
+
} else {
|
|
50
|
+
target[key] = value;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return target;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Read a dotted key from a dictionary. Returns `undefined` when any segment is missing.
|
|
58
|
+
* @param {Record<string, any>|undefined} dictionary
|
|
59
|
+
* @param {string} key
|
|
60
|
+
* @returns {any}
|
|
61
|
+
*/
|
|
62
|
+
export function lookup(dictionary, key) {
|
|
63
|
+
if (!dictionary || typeof key !== 'string' || key === '') return undefined;
|
|
64
|
+
let node = dictionary;
|
|
65
|
+
for (const segment of key.split('.')) {
|
|
66
|
+
if (!node || typeof node !== 'object' || !Object.prototype.hasOwnProperty.call(node, segment)) return undefined;
|
|
67
|
+
node = node[segment];
|
|
68
|
+
}
|
|
69
|
+
return node;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Pick the supported locale for a requested tag: exact match (case-insensitive), then the
|
|
74
|
+
* base language (`es-VE` → `es`), then a supported regional variant of the same language
|
|
75
|
+
* (`pt` → `pt-BR`). Returns null when nothing matches.
|
|
76
|
+
* @param {string|null|undefined} requested
|
|
77
|
+
* @param {string[]} supported
|
|
78
|
+
* @returns {string|null}
|
|
79
|
+
*/
|
|
80
|
+
export function matchLocale(requested, supported) {
|
|
81
|
+
if (!requested || typeof requested !== 'string' || !Array.isArray(supported)) return null;
|
|
82
|
+
const wanted = requested.trim().replace('_', '-').toLowerCase();
|
|
83
|
+
if (!wanted) return null;
|
|
84
|
+
const exact = supported.find((locale) => locale.toLowerCase() === wanted);
|
|
85
|
+
if (exact) return exact;
|
|
86
|
+
const base = wanted.split('-')[0];
|
|
87
|
+
return (
|
|
88
|
+
supported.find((locale) => locale.toLowerCase() === base) ||
|
|
89
|
+
supported.find((locale) => locale.toLowerCase().split('-')[0] === base) ||
|
|
90
|
+
null
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* `rtl` for Arabic, Hebrew, Persian and Urdu (any region), `ltr` otherwise.
|
|
96
|
+
* @param {string} locale
|
|
97
|
+
* @returns {'rtl'|'ltr'}
|
|
98
|
+
*/
|
|
99
|
+
export function directionOf(locale) {
|
|
100
|
+
const base = String(locale || '').toLowerCase().split(/[-_]/)[0];
|
|
101
|
+
return RTL_LANGUAGES.has(base) ? 'rtl' : 'ltr';
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Cache of Intl objects per constructor, locale and options.
|
|
106
|
+
*/
|
|
107
|
+
export class IntlCache {
|
|
108
|
+
constructor() {
|
|
109
|
+
/** @type {Map<string, any>} */
|
|
110
|
+
this.cache = new Map();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* @template T
|
|
115
|
+
* @param {new (locale: string, options?: any) => T} Ctor
|
|
116
|
+
* @param {string} locale
|
|
117
|
+
* @param {Record<string, any>} [options]
|
|
118
|
+
* @returns {T}
|
|
119
|
+
*/
|
|
120
|
+
get(Ctor, locale, options = {}) {
|
|
121
|
+
const id = `${Ctor.name}|${locale}|${JSON.stringify(options)}`;
|
|
122
|
+
let instance = this.cache.get(id);
|
|
123
|
+
if (!instance) {
|
|
124
|
+
instance = new Ctor(locale, options);
|
|
125
|
+
this.cache.set(id, instance);
|
|
126
|
+
}
|
|
127
|
+
return instance;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Choose the variant of a plural message for `count`. An explicit `zero` wins for 0;
|
|
133
|
+
* otherwise the locale's plural category, falling back to `other`.
|
|
134
|
+
* @param {Record<string, string>} message
|
|
135
|
+
* @param {number} count
|
|
136
|
+
* @param {string} locale
|
|
137
|
+
* @param {IntlCache} intl
|
|
138
|
+
* @returns {string}
|
|
139
|
+
*/
|
|
140
|
+
export function selectPlural(message, count, locale, intl) {
|
|
141
|
+
if (count === 0 && typeof message.zero === 'string') return message.zero;
|
|
142
|
+
let category = 'other';
|
|
143
|
+
try {
|
|
144
|
+
category = intl.get(Intl.PluralRules, locale).select(count);
|
|
145
|
+
} catch {
|
|
146
|
+
category = 'other';
|
|
147
|
+
}
|
|
148
|
+
return typeof message[category] === 'string' ? message[category] : message.other;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Replace `{name}` with `params.name`. Numbers are formatted for the locale; `{{` and `}}`
|
|
153
|
+
* are literal braces. A missing param leaves `{name}` in place and is reported through
|
|
154
|
+
* `onMissing(name)`.
|
|
155
|
+
* @param {string} template
|
|
156
|
+
* @param {Record<string, any>} params
|
|
157
|
+
* @param {string} locale
|
|
158
|
+
* @param {IntlCache} intl
|
|
159
|
+
* @param {(name: string) => void} [onMissing]
|
|
160
|
+
* @returns {string}
|
|
161
|
+
*/
|
|
162
|
+
export function interpolate(template, params, locale, intl, onMissing) {
|
|
163
|
+
if (typeof template !== 'string' || template.indexOf('{') === -1 && template.indexOf('}') === -1) {
|
|
164
|
+
return template;
|
|
165
|
+
}
|
|
166
|
+
return template.replace(/\{\{|\}\}|\{\s*([A-Za-z_$][\w$]*)\s*\}/g, (match, name) => {
|
|
167
|
+
if (match === '{{') return '{';
|
|
168
|
+
if (match === '}}') return '}';
|
|
169
|
+
if (!params || !Object.prototype.hasOwnProperty.call(params, name) || params[name] === undefined) {
|
|
170
|
+
onMissing?.(name);
|
|
171
|
+
return match;
|
|
172
|
+
}
|
|
173
|
+
const value = params[name];
|
|
174
|
+
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
175
|
+
try {
|
|
176
|
+
return intl.get(Intl.NumberFormat, locale).format(value);
|
|
177
|
+
} catch {
|
|
178
|
+
return String(value);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return String(value);
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Turn a dictionary entry into text: plural selection, then interpolation.
|
|
187
|
+
* Returns `null` when the entry is not a message (missing, or a group).
|
|
188
|
+
* @param {any} entry
|
|
189
|
+
* @param {Record<string, any>|undefined} params
|
|
190
|
+
* @param {string} locale
|
|
191
|
+
* @param {IntlCache} intl
|
|
192
|
+
* @param {{ onMissingParam?: (name: string) => void, onMissingCount?: () => void }} [hooks]
|
|
193
|
+
* @returns {string|null}
|
|
194
|
+
*/
|
|
195
|
+
export function formatMessage(entry, params, locale, intl, hooks = {}) {
|
|
196
|
+
let template = null;
|
|
197
|
+
if (typeof entry === 'string') {
|
|
198
|
+
template = entry;
|
|
199
|
+
} else if (isPluralMessage(entry)) {
|
|
200
|
+
const count = Number(params?.count);
|
|
201
|
+
if (params?.count === undefined || Number.isNaN(count)) {
|
|
202
|
+
hooks.onMissingCount?.();
|
|
203
|
+
template = entry.other;
|
|
204
|
+
} else {
|
|
205
|
+
template = selectPlural(entry, count, locale, intl);
|
|
206
|
+
}
|
|
207
|
+
} else {
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
return interpolate(template, params || {}, locale, intl, hooks.onMissingParam);
|
|
211
|
+
}
|