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.
@@ -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 `metadata.title`
5
- * keeps whatever the server sent: with native SEO the prerendered HTML already
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(metadata, doc = typeof document !== 'undefined' ? document : null) {
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 { title, titleTemplate, description } = metadata;
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 next = titleTemplate && titleTemplate !== '%s'
23
- ? titleTemplate.replace('%s', String(title))
24
- : String(title);
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 = doc.querySelector('meta[name="description"]');
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
  }