slicejs-web-framework 4.1.1 → 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
|
@@ -1,32 +1,65 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Apply a route's `metadata` to the document head.
|
|
3
3
|
*
|
|
4
|
-
* Only what the route declares is touched. A route without
|
|
5
|
-
*
|
|
4
|
+
* Only what the route declares is touched. A route without a title keeps
|
|
5
|
+
* whatever the server sent: with native SEO the prerendered HTML already
|
|
6
6
|
* carries the right title, and writing a generic one here would undo it.
|
|
7
7
|
*
|
|
8
8
|
* `canonical` and `robots` are deliberately **never** written from here.
|
|
9
9
|
* Crawlers do not run this code, so rewriting them client-side could only
|
|
10
10
|
* desync the SPA from the HTML that was actually served.
|
|
11
11
|
*
|
|
12
|
+
* Titles:
|
|
13
|
+
* title: 'Pricing' literal
|
|
14
|
+
* titleKey: 'pricing.title' a dictionary key; it wins over `title`
|
|
15
|
+
* titlePrefix / titleSuffix per-route overrides of router.titlePrefix /
|
|
16
|
+
* router.titleSuffix; the separator is part of
|
|
17
|
+
* the string and `false` disables it
|
|
18
|
+
*
|
|
19
|
+
* `description` / `descriptionKey` follow the same rules. Any field may also be
|
|
20
|
+
* `{ t: 'key', params? }` (feature 0003).
|
|
21
|
+
*
|
|
22
|
+
* A `titleKey` with the translator off shows the key and logs one warning per
|
|
23
|
+
* key: a silent fallback would hide a page that was never translated.
|
|
24
|
+
*
|
|
12
25
|
* @param {Object|null} metadata route metadata (`metadata.title`, ...)
|
|
13
26
|
* @param {Document|null} doc injectable for tests
|
|
27
|
+
* @param {(key: string, params?: Object) => string} [translate] injectable for tests
|
|
28
|
+
* @param {{ translatorEnabled?: boolean, routerConfig?: Object, warn?: (field: string, key: string) => void }} [options]
|
|
14
29
|
* @returns {void}
|
|
15
30
|
*/
|
|
16
|
-
export function applyRouteMetadata(
|
|
31
|
+
export function applyRouteMetadata(
|
|
32
|
+
metadata,
|
|
33
|
+
doc = typeof document !== 'undefined' ? document : null,
|
|
34
|
+
translate = defaultTranslate,
|
|
35
|
+
options = {}
|
|
36
|
+
) {
|
|
17
37
|
if (!doc || !metadata || typeof metadata !== 'object') return;
|
|
18
38
|
|
|
19
|
-
const
|
|
39
|
+
const enabled = options.translatorEnabled ?? translatorEnabled();
|
|
40
|
+
const routerConfig = options.routerConfig ?? globalThis.slice?.routerConfig ?? {};
|
|
41
|
+
const warn = options.warn ?? warnKey;
|
|
42
|
+
|
|
43
|
+
const title = keyedText(metadata, 'title', 'titleKey', translate, enabled, warn);
|
|
44
|
+
const description = keyedText(metadata, 'description', 'descriptionKey', translate, enabled, warn);
|
|
45
|
+
const state = stateOf(doc);
|
|
46
|
+
const descriptionMeta = doc.querySelector('meta[name="description"]');
|
|
20
47
|
|
|
21
48
|
if (title) {
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
49
|
+
const prefix = affix(metadata.titlePrefix, routerConfig.titlePrefix);
|
|
50
|
+
const suffix = affix(metadata.titleSuffix, routerConfig.titleSuffix);
|
|
51
|
+
const next = `${prefix}${String(title)}${suffix}`;
|
|
25
52
|
if (doc.title !== next) doc.title = next;
|
|
53
|
+
state.appliedTitle = next;
|
|
54
|
+
} else if (state.appliedTitle !== null && doc.title === state.appliedTitle) {
|
|
55
|
+
// The route has no title: put back what the server sent, but never clobber a
|
|
56
|
+
// title a component set after the router's last write.
|
|
57
|
+
if (doc.title !== state.title) doc.title = state.title;
|
|
58
|
+
state.appliedTitle = null;
|
|
26
59
|
}
|
|
27
60
|
|
|
28
61
|
if (description) {
|
|
29
|
-
let meta =
|
|
62
|
+
let meta = descriptionMeta;
|
|
30
63
|
if (!meta) {
|
|
31
64
|
meta = doc.createElement('meta');
|
|
32
65
|
meta.setAttribute('name', 'description');
|
|
@@ -35,5 +68,115 @@ export function applyRouteMetadata(metadata, doc = typeof document !== 'undefine
|
|
|
35
68
|
if (meta.getAttribute('content') !== description) {
|
|
36
69
|
meta.setAttribute('content', description);
|
|
37
70
|
}
|
|
71
|
+
state.appliedDescription = description;
|
|
72
|
+
} else if (state.appliedDescription !== null && descriptionMeta?.getAttribute('content') === state.appliedDescription) {
|
|
73
|
+
if (state.description === null) descriptionMeta.removeAttribute('content');
|
|
74
|
+
else if (descriptionMeta.getAttribute('content') !== state.description) {
|
|
75
|
+
descriptionMeta.setAttribute('content', state.description);
|
|
76
|
+
}
|
|
77
|
+
state.appliedDescription = null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Per-document baseline: what the server sent, and what this helper wrote last.
|
|
83
|
+
* @param {Document} doc
|
|
84
|
+
* @returns {{ title: string, description: string|null, appliedTitle: string|null, appliedDescription: string|null }}
|
|
85
|
+
*/
|
|
86
|
+
function stateOf(doc) {
|
|
87
|
+
let state = states.get(doc);
|
|
88
|
+
if (!state) {
|
|
89
|
+
const meta = doc.querySelector('meta[name="description"]');
|
|
90
|
+
state = {
|
|
91
|
+
title: doc.title,
|
|
92
|
+
description: meta ? meta.getAttribute('content') : null,
|
|
93
|
+
appliedTitle: null,
|
|
94
|
+
appliedDescription: null
|
|
95
|
+
};
|
|
96
|
+
states.set(doc, state);
|
|
38
97
|
}
|
|
98
|
+
return state;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* `titleKey` / `descriptionKey` (translated, and winning over the literal)
|
|
103
|
+
* or the literal `title` / `description` (`{ t }` included).
|
|
104
|
+
* @param {Object} metadata
|
|
105
|
+
* @param {string} field
|
|
106
|
+
* @param {string} keyField
|
|
107
|
+
* @param {(key: string, params?: Object) => string} translate
|
|
108
|
+
* @param {boolean} enabled
|
|
109
|
+
* @param {(field: string, key: string) => void} warn
|
|
110
|
+
* @returns {any}
|
|
111
|
+
*/
|
|
112
|
+
function keyedText(metadata, field, keyField, translate, enabled, warn) {
|
|
113
|
+
const key = typeof metadata[keyField] === 'string' ? metadata[keyField].trim() : '';
|
|
114
|
+
if (!key) return resolveText(metadata[field], translate);
|
|
115
|
+
if (enabled) return translate(key);
|
|
116
|
+
warn(keyField, key);
|
|
117
|
+
return key;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* A per-route affix, or the router-wide default. `false` disables it; the
|
|
122
|
+
* separator belongs to the string (`" · Site"`).
|
|
123
|
+
* @param {unknown} own
|
|
124
|
+
* @param {unknown} fallback
|
|
125
|
+
* @returns {string}
|
|
126
|
+
*/
|
|
127
|
+
function affix(own, fallback) {
|
|
128
|
+
if (own === false) return '';
|
|
129
|
+
if (typeof own === 'string') return own;
|
|
130
|
+
return typeof fallback === 'string' ? fallback : '';
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** @type {Set<string>} */
|
|
134
|
+
const warned = new Set();
|
|
135
|
+
|
|
136
|
+
/** @type {WeakMap<Document, { title: string, description: string|null, appliedTitle: string|null, appliedDescription: string|null }>} */
|
|
137
|
+
const states = new WeakMap();
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* One warning per key and field, not one per navigation.
|
|
141
|
+
* @param {string} field
|
|
142
|
+
* @param {string} key
|
|
143
|
+
*/
|
|
144
|
+
function warnKey(field, key) {
|
|
145
|
+
const id = `${field}:${key}`;
|
|
146
|
+
if (warned.has(id)) return;
|
|
147
|
+
warned.add(id);
|
|
148
|
+
const message = `Route metadata uses ${field} "${key}" while slice.translator is disabled; the page will show the key.`;
|
|
149
|
+
const logger = /** @type {any} */ (globalThis).slice?.logger;
|
|
150
|
+
if (typeof logger?.logWarning === 'function') logger.logWarning('Router', message);
|
|
151
|
+
else if (typeof logger?.warn === 'function') logger.warn('Router', message);
|
|
152
|
+
else console.warn(`[slice.router] ${message}`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* `{ t: 'key', params? }` → the translated text; anything else is returned as is.
|
|
157
|
+
* @param {unknown} value
|
|
158
|
+
* @param {(key: string, params?: Object) => string} translate
|
|
159
|
+
* @returns {any}
|
|
160
|
+
*/
|
|
161
|
+
export function resolveText(value, translate) {
|
|
162
|
+
if (value && typeof value === 'object' && typeof /** @type {any} */ (value).t === 'string') {
|
|
163
|
+
const { t: key, params } = /** @type {{ t: string, params?: Object }} */ (value);
|
|
164
|
+
return translate(key, params);
|
|
165
|
+
}
|
|
166
|
+
return value;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* `slice.translator.enabled`, read lazily: the Router can apply metadata before
|
|
171
|
+
* a test (or a very early navigation) has wired the global.
|
|
172
|
+
* @returns {boolean}
|
|
173
|
+
*/
|
|
174
|
+
function translatorEnabled() {
|
|
175
|
+
return /** @type {any} */ (globalThis).slice?.translator?.enabled === true;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** @param {string} key @param {Object} [params] */
|
|
179
|
+
function defaultTranslate(key, params) {
|
|
180
|
+
const s = /** @type {any} */ (globalThis).slice;
|
|
181
|
+
return typeof s?.t === 'function' ? s.t(key, params) : key;
|
|
39
182
|
}
|