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.
@@ -15,8 +15,10 @@ import { pathToFileURL } from 'node:url';
15
15
  import { createHash } from 'node:crypto';
16
16
  import zlib from 'node:zlib';
17
17
  import {
18
+ buildTranslatorContext,
18
19
  injectHead,
19
20
  loadSeoManifest,
21
+ loadTranslatorDictionaries,
20
22
  matchPathPattern,
21
23
  normalizeOrigin,
22
24
  pickRouteEntry,
@@ -53,6 +55,54 @@ function readComponentPrefixes(projectRoot) {
53
55
  return [];
54
56
  }
55
57
 
58
+ /** The `translator` block from the deployed config (`dist` wins in production). */
59
+ function readTranslatorConfig(projectRoot, folderDeployed) {
60
+ for (const folder of [folderDeployed, 'src']) {
61
+ const file = path.join(projectRoot, folder, 'sliceConfig.json');
62
+ if (!fs.existsSync(file)) continue;
63
+ try {
64
+ return JSON.parse(fs.readFileSync(file, 'utf8')).translator || null;
65
+ } catch {
66
+ // unreadable config: SEO stays single-language, as before
67
+ }
68
+ }
69
+ return null;
70
+ }
71
+
72
+ /** The `router` block from the deployed config (`dist` wins in production). */
73
+ function readRouterConfig(projectRoot, folderDeployed) {
74
+ for (const folder of [folderDeployed, 'src']) {
75
+ const file = path.join(projectRoot, folder, 'sliceConfig.json');
76
+ if (!fs.existsSync(file)) continue;
77
+ try {
78
+ return JSON.parse(fs.readFileSync(file, 'utf8')).router || {};
79
+ } catch {
80
+ // unreadable config: no title affixes, as before
81
+ }
82
+ }
83
+ return {};
84
+ }
85
+
86
+ /**
87
+ * Folder holding the translator dictionaries, from the deployed config (`translator.path`,
88
+ * `/locales` by default). Null when the translator is off or the path is unusable, so no
89
+ * extra mount is added. `slice build` copies `src/<path>` into `dist/<path>`; without this
90
+ * mount production answers the SPA shell for every dictionary and the runtime shows keys.
91
+ */
92
+ function readTranslatorFolder(projectRoot, folderDeployed) {
93
+ const file = path.join(projectRoot, folderDeployed, 'sliceConfig.json');
94
+ if (!fs.existsSync(file)) return null;
95
+ try {
96
+ const translator = JSON.parse(fs.readFileSync(file, 'utf8')).translator;
97
+ if (!translator?.enabled) return null;
98
+ const value = typeof translator.path === 'string' && translator.path.trim() ? translator.path : '/locales';
99
+ const folder = value.replace(/^\/+|\/+$/g, '');
100
+ return folder && !folder.includes('..') ? folder : null;
101
+ } catch {
102
+ return null;
103
+ }
104
+ }
105
+
56
106
  /**
57
107
  * True for `/Components/<Category>/<Name>/<Name>.css` when the component's `.js`
58
108
  * exists but it has no stylesheet. JS-only Visual components (MultiRoute,
@@ -145,8 +195,16 @@ export function sliceFallback(options = {}) {
145
195
  // With SEO configured the route set is known, so this is also where the
146
196
  // 200-vs-404 decision happens.
147
197
  if (seo) {
148
- const { status, page } = seo.resolve(pathname);
149
- return sendRendered(req, res, page, status);
198
+ const result = seo.resolve(pathname);
199
+ if (result.location) {
200
+ const query = req.originalUrl.includes('?') ? req.originalUrl.slice(req.originalUrl.indexOf('?')) : '';
201
+ const location = result.location.includes('?') ? result.location : `${result.location}${query}`;
202
+ // A locale redirect depends on the current default locale and prefix mode; do not
203
+ // let a browser keep it for an hour after a config change.
204
+ res.setHeader('Cache-Control', 'no-cache');
205
+ return res.redirect(result.status, location);
206
+ }
207
+ return sendRendered(req, res, result.page, result.status);
150
208
  }
151
209
 
152
210
  return sendShell(req, res);
@@ -187,14 +245,24 @@ async function loadRouteDefinitions(projectRoot, folderDeployed) {
187
245
  path.join(projectRoot, folderDeployed, 'routes.js'),
188
246
  path.join(projectRoot, 'src', 'routes.js')
189
247
  ];
248
+ const normalize = (base, value) => {
249
+ const merged = `${base || ''}${value || ''}`.replace(/\/+/g, '/');
250
+ return merged.length > 1 ? merged.replace(/\/+$/, '') || '/' : '/';
251
+ };
252
+ const flatten = (list, base = '') => {
253
+ const entries = [];
254
+ for (const route of Array.isArray(list) ? list : []) {
255
+ const full = normalize(base, route?.path);
256
+ if (full) entries.push({ path: full, metadata: route?.metadata || {} });
257
+ if (Array.isArray(route?.children)) entries.push(...flatten(route.children, full));
258
+ }
259
+ return entries;
260
+ };
190
261
  for (const candidate of candidates) {
191
262
  if (!fs.existsSync(candidate)) continue;
192
263
  const module = await import(pathToFileURL(candidate).href);
193
264
  const routes = module.default ?? module.routes ?? [];
194
- return (Array.isArray(routes) ? routes : []).map((route) => ({
195
- path: String(route?.path ?? '').replace(/\/+$/, '') || '/',
196
- metadata: route?.metadata || {}
197
- })).filter((route) => route.path);
265
+ return flatten(routes).filter((route) => route.path);
198
266
  }
199
267
  return [];
200
268
  }
@@ -244,15 +312,35 @@ export async function prepareSeo(options = {}) {
244
312
  const siteName = config.siteName || manifest.defaults?.siteName || null;
245
313
  const locale = config.locale || manifest.defaults?.locale || null;
246
314
  const routes = await loadRouteDefinitions(projectRoot, folderDeployed);
315
+ if (!routes.length) {
316
+ throw new Error('seo: routes.js is missing or empty; every page would answer 404');
317
+ }
247
318
 
248
- const render = (pathname, routeMetadata) => {
249
- const meta = resolveMetadata({ pathname, routeMetadata, manifest, origin });
319
+ // Multilingual SEO (translator feature 0003, phase 5). Without a translator this is
320
+ // one `null` locale and every rule below collapses to the old single-language path.
321
+ const translatorConfig = config.translator || readTranslatorConfig(projectRoot, folderDeployed);
322
+ const routerConfig = readRouterConfig(projectRoot, folderDeployed);
323
+ const translator = buildTranslatorContext(
324
+ translatorConfig,
325
+ loadTranslatorDictionaries(projectRoot, folderDeployed, translatorConfig)
326
+ );
327
+ const locales = translator.enabled
328
+ ? (translator.localePrefix ? translator.locales : [translator.defaultLocale])
329
+ : [null];
330
+ const ogLocaleConfig = manifest.locales && typeof manifest.locales === 'object' ? manifest.locales : {};
331
+
332
+ const render = (activeLocale, pathname, routeMetadata) => {
333
+ const translate = activeLocale
334
+ ? (key, params) => translator.t(activeLocale, key, params)
335
+ : null;
336
+ const url = translator.enabled ? translator.localize(pathname, activeLocale) : pathname;
337
+ const meta = resolveMetadata({ pathname, routeMetadata, manifest, origin, translate, url, routerConfig });
250
338
 
251
339
  // Precedence: an image declared on the route entry wins, then the generated
252
340
  // card, then `defaults.image` (the generic banner). Comparing against the raw
253
341
  // entry keeps the framework from guessing where the image came from.
254
342
  const explicitImage = pickRouteEntry(pathname, manifest)?.entry?.image;
255
- const card = cards[pathname];
343
+ const card = cards[url] || cards[pathname];
256
344
  if (card && !explicitImage && !routeMetadata?.image) {
257
345
  meta.image = /^https?:\/\//i.test(card.path) ? card.path : `${origin}${card.path}`;
258
346
  meta.imageWidth = card.width ?? null;
@@ -261,7 +349,26 @@ export async function prepareSeo(options = {}) {
261
349
  meta.imageAlt = meta.imageAlt || meta.title;
262
350
  }
263
351
 
264
- return encodeSeoPage(injectHead(shell, renderHead(meta, { siteName, locale })));
352
+ const alternates = translator.enabled && translator.localePrefix && activeLocale && translator.locales.length > 1
353
+ ? translator.locales
354
+ .map((code) => ({
355
+ hreflang: code,
356
+ href: `${origin}${translator.localize(pathname, code)}`
357
+ }))
358
+ .concat([{
359
+ hreflang: 'x-default',
360
+ href: `${origin}${translator.localize(pathname, translator.defaultLocale)}`
361
+ }])
362
+ : null;
363
+
364
+ const ogLocale = translator.enabled && activeLocale
365
+ ? (ogLocaleConfig[activeLocale]?.ogLocale || translator.ogLocale(activeLocale))
366
+ : locale;
367
+
368
+ const head = renderHead(meta, { siteName, locale: ogLocale, alternates });
369
+ return encodeSeoPage(injectHead(shell, head, translator.enabled && activeLocale
370
+ ? { lang: activeLocale, dir: translator.direction(activeLocale) }
371
+ : {}));
265
372
  };
266
373
 
267
374
  const pages = new Map();
@@ -271,44 +378,100 @@ export async function prepareSeo(options = {}) {
271
378
  patterns.push(route);
272
379
  continue;
273
380
  }
274
- pages.set(route.path, render(route.path, route.metadata));
381
+ for (const activeLocale of locales) {
382
+ const url = translator.enabled ? translator.localize(route.path, activeLocale) : route.path;
383
+ pages.set(url, render(activeLocale, route.path, route.metadata));
384
+ }
275
385
  }
276
386
  // Longest pattern first so `/p/${id}/detail` wins over `/p/${id}`.
277
387
  patterns.sort((a, b) => b.path.length - a.path.length);
278
388
 
279
- const notFound = encodeSeoPage(injectHead(
280
- shell,
281
- '<title>Not found</title>\n <meta name="robots" content="noindex, nofollow" />'
282
- ));
389
+ const notFound = new Map();
390
+ const notFoundPrefix = typeof routerConfig.titlePrefix === 'string' ? routerConfig.titlePrefix : '';
391
+ const notFoundSuffix = typeof routerConfig.titleSuffix === 'string' ? routerConfig.titleSuffix : '';
392
+ for (const activeLocale of locales) {
393
+ const key = 'seo.notFound.title';
394
+ const translated = translator.enabled && activeLocale ? translator.t(activeLocale, key) : key;
395
+ const title = `${notFoundPrefix}${translated === key ? 'Not found' : translated}${notFoundSuffix}`;
396
+ const descriptionKey = 'seo.notFound.description';
397
+ const description = translator.enabled && activeLocale ? translator.t(activeLocale, descriptionKey) : descriptionKey;
398
+ const head = renderHead(
399
+ {
400
+ title,
401
+ description: description === descriptionKey ? null : description,
402
+ robots: 'noindex, nofollow'
403
+ },
404
+ { locale: translator.enabled && activeLocale ? translator.ogLocale(activeLocale) : null }
405
+ );
406
+ notFound.set(activeLocale, encodeSeoPage(injectHead(
407
+ shell,
408
+ head,
409
+ translator.enabled && activeLocale
410
+ ? { lang: activeLocale, dir: translator.direction(activeLocale) }
411
+ : {}
412
+ )));
413
+ }
283
414
 
284
415
  // A `${param}` route cannot be enumerated, so it is rendered per path. The
285
416
  // cache keeps repeated visits of the same product from recompressing.
286
417
  const dynamicPages = new Map();
287
418
  const DYNAMIC_LIMIT = 200;
288
419
 
420
+ /** The page for an app path in one locale: prerendered, cached dynamic, or 404. */
421
+ const settle = (appPath, activeLocale) => {
422
+ const url = translator.enabled ? translator.localize(appPath, activeLocale) : appPath;
423
+ const exact = pages.get(url);
424
+ if (exact) return { status: 200, page: exact };
425
+
426
+ const dynamic = dynamicPages.get(url);
427
+ if (dynamic) return { status: 200, page: dynamic };
428
+
429
+ for (const route of patterns) {
430
+ if (!matchPathPattern(route.path, appPath)) continue;
431
+ const page = render(activeLocale, appPath, route.metadata);
432
+ if (dynamicPages.size >= DYNAMIC_LIMIT) {
433
+ dynamicPages.delete(dynamicPages.keys().next().value);
434
+ }
435
+ dynamicPages.set(url, page);
436
+ return { status: 200, page };
437
+ }
438
+
439
+ return { status: 404, page: notFound.get(activeLocale) || notFound.get(null) };
440
+ };
441
+
289
442
  return {
290
443
  origin,
291
444
  manifest,
292
445
  routes,
446
+ translator,
293
447
  prerendered: pages.size,
294
448
  resolve(pathname) {
295
- const exact = pages.get(pathname);
296
- if (exact) return { status: 200, page: exact };
449
+ if (!translator.enabled || !translator.localePrefix) {
450
+ return settle(pathname, translator.defaultLocale);
451
+ }
297
452
 
298
- const dynamic = dynamicPages.get(pathname);
299
- if (dynamic) return { status: 200, page: dynamic };
453
+ const { locale: urlLocale, path: appPath } = translator.split(pathname);
300
454
 
301
- for (const route of patterns) {
302
- if (!matchPathPattern(route.path, pathname)) continue;
303
- const page = render(pathname, route.metadata);
304
- if (dynamicPages.size >= DYNAMIC_LIMIT) {
305
- dynamicPages.delete(dynamicPages.keys().next().value);
455
+ if (urlLocale) {
456
+ // `/es/pricing` under "prefix-except-default": the canonical URL is `/pricing`.
457
+ if (translator.localePrefix === 'except-default' && urlLocale === translator.defaultLocale) {
458
+ return { status: 301, location: appPath };
306
459
  }
307
- dynamicPages.set(pathname, page);
308
- return { status: 200, page };
460
+ return settle(appPath, urlLocale);
309
461
  }
310
462
 
311
- return { status: 404, page: notFound };
463
+ // With "always" every page has a locale in the URL. `/` picks the default (the
464
+ // client may then apply the saved/browser choice). A known unprefixed path is a
465
+ // 302 to its prefixed URL, not a 404: the client can render it and search engines
466
+ // must see the canonical URL.
467
+ if (translator.localePrefix === 'always') {
468
+ if (pathname === '/' || settle(pathname, translator.defaultLocale).status === 200) {
469
+ return { status: 302, location: translator.localize(pathname, translator.defaultLocale) };
470
+ }
471
+ return { status: 404, page: notFound.get(translator.defaultLocale) };
472
+ }
473
+
474
+ return settle(pathname, translator.defaultLocale);
312
475
  }
313
476
  };
314
477
  }
@@ -654,6 +817,19 @@ export function createSliceServer(options = {}) {
654
817
  app.use('/dist/', express.static(path.join(projectRoot, 'dist')));
655
818
  }
656
819
 
820
+ // Translator dictionaries (`translator.path`, `/locales` by default): app data copied
821
+ // into dist by `slice build`. Mounted in both modes so the path is explicit; without it
822
+ // production answered the SPA shell for every dictionary and the runtime showed keys.
823
+ const translatorFolder = readTranslatorFolder(projectRoot, folderDeployed);
824
+ if (translatorFolder) {
825
+ app.use(
826
+ `/${translatorFolder}`,
827
+ express.static(path.join(projectRoot, folderDeployed, translatorFolder), {
828
+ setHeaders: (res) => res.setHeader('Cache-Control', 'public, max-age=300, stale-while-revalidate=86400')
829
+ })
830
+ );
831
+ }
832
+
657
833
  // ==============================================
658
834
  // RUTAS DE API
659
835
  // ==============================================
@@ -737,7 +913,8 @@ export function createSliceServer(options = {}) {
737
913
  if (options.seo) {
738
914
  seo = await prepareSeo({ ...options, port });
739
915
  if (seo) {
740
- console.log(`🔎 SEO: ${seo.prerendered} routes prerendered (${seo.origin})`);
916
+ const localeCount = seo.translator?.enabled ? seo.translator.locales.length : 0;
917
+ console.log(`🔎 SEO: ${seo.prerendered} pages prerendered${localeCount ? ` (${localeCount} locales)` : ''} (${seo.origin})`);
741
918
  } else {
742
919
  console.warn('[slice] seo options were given but no manifest was found; SEO is off.');
743
920
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "slicejs-web-framework",
3
- "version": "4.1.1",
3
+ "version": "4.2.0",
4
4
  "description": "",
5
5
  "engines": {
6
6
  "node": ">=20"
@@ -31,6 +31,7 @@
31
31
  "./api/framework/express.js": "./api/framework/express.js",
32
32
  "./api/framework/server.js": "./api/framework/server.js",
33
33
  "./api/framework/seo.js": "./api/framework/seo.js",
34
+ "./api/framework/content.js": "./api/framework/content.js",
34
35
  "./api/framework/devDepsOptimizer.js": "./api/framework/devDepsOptimizer.js"
35
36
  },
36
37
  "types": "./types/index.d.ts",
@@ -1,122 +1,150 @@
1
- {
2
- "$schema": "https://json-schema.org/draft/2020-12/schema",
3
- "$id": "https://slicejs.local/sliceConfig.schema.json",
4
- "title": "Slice.js Config",
5
- "type": "object",
6
- "additionalProperties": true,
7
- "properties": {
8
- "debugger": {
9
- "type": "object",
10
- "additionalProperties": true,
11
- "properties": {
12
- "enabled": { "type": "boolean" },
13
- "click": { "type": "string", "enum": ["right", "left"] },
14
- "leakInspector": {
15
- "type": "object",
16
- "additionalProperties": true,
17
- "description": "Dev-only memory-leak panel. Disabled in production regardless of this flag.",
18
- "properties": {
19
- "enabled": { "type": "boolean" },
20
- "shortcut": { "type": "string" }
21
- }
22
- }
23
- }
24
- },
25
- "stylesManager": {
26
- "type": "object",
27
- "additionalProperties": true,
28
- "properties": {
29
- "requestedStyles": { "type": "array", "items": { "type": "string" } }
30
- }
31
- },
32
- "themeManager": {
33
- "type": "object",
34
- "additionalProperties": true,
35
- "properties": {
36
- "enabled": { "type": "boolean" },
37
- "defaultTheme": { "type": "string" },
38
- "saveThemeLocally": { "type": "boolean" },
39
- "useBrowserTheme": { "type": "boolean" }
40
- }
41
- },
42
- "logger": {
43
- "type": "object",
44
- "additionalProperties": true,
45
- "properties": {
46
- "enabled": { "type": "boolean" },
47
- "showLogs": { "type": "object", "additionalProperties": true },
48
- "ui": {
49
- "type": "object",
50
- "additionalProperties": true,
51
- "properties": {
52
- "enabled": { "type": "boolean" },
53
- "shortcut": { "type": "string" }
54
- }
55
- }
56
- }
57
- },
58
- "events": {
59
- "type": "object",
60
- "additionalProperties": true,
61
- "properties": {
62
- "enabled": { "type": "boolean" },
63
- "ui": {
64
- "type": "object",
65
- "additionalProperties": true,
66
- "properties": {
67
- "enabled": { "type": "boolean" },
68
- "shortcut": { "type": "string" }
69
- }
70
- }
71
- }
72
- },
73
- "context": {
74
- "type": "object",
75
- "additionalProperties": true,
76
- "properties": {
77
- "enabled": { "type": "boolean" },
78
- "ui": {
79
- "type": "object",
80
- "additionalProperties": true,
81
- "properties": {
82
- "enabled": { "type": "boolean" },
83
- "shortcut": { "type": "string" }
84
- }
85
- }
86
- }
87
- },
88
- "paths": {
89
- "type": "object",
90
- "additionalProperties": true,
91
- "properties": {
92
- "components": { "type": "object", "additionalProperties": true },
93
- "themes": { "type": "string" },
94
- "styles": { "type": "string" },
95
- "routesFile": { "type": "string" }
96
- },
97
- "required": ["components", "themes", "styles", "routesFile"]
98
- },
99
- "router": {
100
- "type": "object",
101
- "additionalProperties": true,
102
- "properties": {
103
- "defaultRoute": { "type": "string" }
104
- }
105
- },
106
- "loading": {
107
- "type": "object",
108
- "additionalProperties": true,
109
- "properties": {
110
- "enabled": { "type": "boolean" }
111
- }
112
- },
113
- "server": {
114
- "type": "object",
115
- "additionalProperties": true,
116
- "properties": {
117
- "port": { "type": "number" },
118
- "host": { "type": "string" }
119
- }
120
- }
121
- }
122
- }
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://slicejs.local/sliceConfig.schema.json",
4
+ "title": "Slice.js Config",
5
+ "type": "object",
6
+ "additionalProperties": true,
7
+ "properties": {
8
+ "debugger": {
9
+ "type": "object",
10
+ "additionalProperties": true,
11
+ "properties": {
12
+ "enabled": { "type": "boolean" },
13
+ "click": { "type": "string", "enum": ["right", "left"] },
14
+ "leakInspector": {
15
+ "type": "object",
16
+ "additionalProperties": true,
17
+ "description": "Dev-only memory-leak panel. Disabled in production regardless of this flag.",
18
+ "properties": {
19
+ "enabled": { "type": "boolean" },
20
+ "shortcut": { "type": "string" }
21
+ }
22
+ }
23
+ }
24
+ },
25
+ "stylesManager": {
26
+ "type": "object",
27
+ "additionalProperties": true,
28
+ "properties": {
29
+ "requestedStyles": { "type": "array", "items": { "type": "string" } }
30
+ }
31
+ },
32
+ "themeManager": {
33
+ "type": "object",
34
+ "additionalProperties": true,
35
+ "properties": {
36
+ "enabled": { "type": "boolean" },
37
+ "defaultTheme": { "type": "string" },
38
+ "saveThemeLocally": { "type": "boolean" },
39
+ "useBrowserTheme": { "type": "boolean" }
40
+ }
41
+ },
42
+ "logger": {
43
+ "type": "object",
44
+ "additionalProperties": true,
45
+ "properties": {
46
+ "enabled": { "type": "boolean" },
47
+ "showLogs": { "type": "object", "additionalProperties": true },
48
+ "ui": {
49
+ "type": "object",
50
+ "additionalProperties": true,
51
+ "properties": {
52
+ "enabled": { "type": "boolean" },
53
+ "shortcut": { "type": "string" }
54
+ }
55
+ }
56
+ }
57
+ },
58
+ "events": {
59
+ "type": "object",
60
+ "additionalProperties": true,
61
+ "properties": {
62
+ "enabled": { "type": "boolean" },
63
+ "ui": {
64
+ "type": "object",
65
+ "additionalProperties": true,
66
+ "properties": {
67
+ "enabled": { "type": "boolean" },
68
+ "shortcut": { "type": "string" }
69
+ }
70
+ }
71
+ }
72
+ },
73
+ "context": {
74
+ "type": "object",
75
+ "additionalProperties": true,
76
+ "properties": {
77
+ "enabled": { "type": "boolean" },
78
+ "ui": {
79
+ "type": "object",
80
+ "additionalProperties": true,
81
+ "properties": {
82
+ "enabled": { "type": "boolean" },
83
+ "shortcut": { "type": "string" }
84
+ }
85
+ }
86
+ }
87
+ },
88
+ "paths": {
89
+ "type": "object",
90
+ "additionalProperties": true,
91
+ "properties": {
92
+ "components": { "type": "object", "additionalProperties": true },
93
+ "themes": { "type": "string" },
94
+ "styles": { "type": "string" },
95
+ "routesFile": { "type": "string" }
96
+ },
97
+ "required": ["components", "themes", "styles", "routesFile"]
98
+ },
99
+ "router": {
100
+ "type": "object",
101
+ "additionalProperties": true,
102
+ "properties": {
103
+ "defaultRoute": { "type": "string" },
104
+ "updateTitles": { "type": "boolean", "description": "Write document.title and the description from route metadata. Default true." },
105
+ "titlePrefix": { "type": "string", "description": "Text before every route title. The separator is part of the string (\"Acme · \")." },
106
+ "titleSuffix": { "type": "string", "description": "Text after every route title. The separator is part of the string (\" · Acme\")." }
107
+ }
108
+ },
109
+ "translator": {
110
+ "type": "object",
111
+ "additionalProperties": false,
112
+ "description": "slice.translator: dictionaries in <path>/<locale>.json, data-t in templates, slice.t() for text and slice.t.ref() for props.",
113
+ "properties": {
114
+ "enabled": { "type": "boolean", "description": "Off: slice.t() returns the key and nothing is loaded." },
115
+ "defaultLocale": { "type": "string", "minLength": 1 },
116
+ "fallbackLocale": { "type": ["string", "null"], "description": "Asked when the active locale lacks a key." },
117
+ "locales": { "type": "array", "items": { "type": "string", "minLength": 1 }, "uniqueItems": true },
118
+ "path": { "type": "string", "description": "Where dictionaries are served from. Default /locales." },
119
+ "saveLocally": { "type": "boolean", "description": "Remember the user's choice in localStorage." },
120
+ "useBrowserLocale": { "type": "boolean", "description": "First visit: the browser's language when supported." },
121
+ "localePrefix": {
122
+ "enum": [false, "always", "except-default"],
123
+ "description": "Locale in the URL. false keeps it out; always puts it in every URL; except-default leaves the default locale unprefixed."
124
+ },
125
+ "ui": {
126
+ "type": "object",
127
+ "additionalProperties": true,
128
+ "properties": { "enabled": { "type": "boolean" }, "shortcut": { "type": "string" } }
129
+ }
130
+ },
131
+ "if": { "properties": { "enabled": { "const": true } }, "required": ["enabled"] },
132
+ "then": { "required": ["defaultLocale"] }
133
+ },
134
+ "loading": {
135
+ "type": "object",
136
+ "additionalProperties": true,
137
+ "properties": {
138
+ "enabled": { "type": "boolean" }
139
+ }
140
+ },
141
+ "server": {
142
+ "type": "object",
143
+ "additionalProperties": true,
144
+ "properties": {
145
+ "port": { "type": "number" },
146
+ "host": { "type": "string" }
147
+ }
148
+ }
149
+ }
150
+ }