@bleedingdev/modern-js-plugin-i18n 3.2.0-ultramodern.99 → 3.4.0-ultramodern.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.
Files changed (98) hide show
  1. package/README.md +221 -11
  2. package/dist/cjs/cli/index.js +17 -64
  3. package/dist/cjs/cli/locales.js +132 -0
  4. package/dist/cjs/runtime/I18nLink.js +17 -20
  5. package/dist/cjs/runtime/Link.js +264 -0
  6. package/dist/cjs/runtime/canonicalRoutes.js +18 -0
  7. package/dist/cjs/runtime/context.js +9 -5
  8. package/dist/cjs/runtime/hooks.js +9 -5
  9. package/dist/cjs/runtime/i18n/backend/config.js +9 -5
  10. package/dist/cjs/runtime/i18n/backend/defaults.js +20 -11
  11. package/dist/cjs/runtime/i18n/backend/defaults.node.js +79 -10
  12. package/dist/cjs/runtime/i18n/backend/index.js +9 -5
  13. package/dist/cjs/runtime/i18n/backend/middleware.common.js +9 -5
  14. package/dist/cjs/runtime/i18n/backend/middleware.js +9 -5
  15. package/dist/cjs/runtime/i18n/backend/middleware.node.js +9 -5
  16. package/dist/cjs/runtime/i18n/backend/sdk-backend.js +9 -5
  17. package/dist/cjs/runtime/i18n/backend/sdk-event.js +16 -11
  18. package/dist/cjs/runtime/i18n/detection/config.js +9 -5
  19. package/dist/cjs/runtime/i18n/detection/index.js +9 -5
  20. package/dist/cjs/runtime/i18n/detection/middleware.js +9 -5
  21. package/dist/cjs/runtime/i18n/detection/middleware.node.js +9 -5
  22. package/dist/cjs/runtime/i18n/index.js +9 -5
  23. package/dist/cjs/runtime/i18n/instance.js +17 -13
  24. package/dist/cjs/runtime/i18n/react-i18next.js +12 -8
  25. package/dist/cjs/runtime/i18n/utils.js +9 -5
  26. package/dist/cjs/runtime/index.js +32 -5
  27. package/dist/cjs/runtime/localizedPaths.js +102 -0
  28. package/dist/cjs/runtime/routerAdapter.js +11 -7
  29. package/dist/cjs/runtime/utils.js +31 -17
  30. package/dist/cjs/server/index.js +10 -14
  31. package/dist/cjs/shared/deepMerge.js +12 -8
  32. package/dist/cjs/shared/detection.js +9 -5
  33. package/dist/cjs/shared/localisedUrls.js +148 -34
  34. package/dist/cjs/shared/utils.js +15 -11
  35. package/dist/esm/cli/index.mjs +8 -48
  36. package/dist/esm/cli/locales.mjs +80 -0
  37. package/dist/esm/runtime/I18nLink.mjs +7 -14
  38. package/dist/esm/runtime/Link.mjs +221 -0
  39. package/dist/esm/runtime/canonicalRoutes.mjs +0 -0
  40. package/dist/esm/runtime/i18n/backend/defaults.mjs +6 -2
  41. package/dist/esm/runtime/i18n/backend/defaults.node.mjs +56 -5
  42. package/dist/esm/runtime/index.mjs +4 -2
  43. package/dist/esm/runtime/localizedPaths.mjs +55 -0
  44. package/dist/esm/runtime/routerAdapter.mjs +3 -3
  45. package/dist/esm/runtime/utils.mjs +19 -12
  46. package/dist/esm/server/index.mjs +2 -10
  47. package/dist/esm/shared/localisedUrls.mjs +115 -23
  48. package/dist/esm-node/cli/index.mjs +8 -48
  49. package/dist/esm-node/cli/locales.mjs +81 -0
  50. package/dist/esm-node/runtime/I18nLink.mjs +7 -14
  51. package/dist/esm-node/runtime/Link.mjs +222 -0
  52. package/dist/esm-node/runtime/canonicalRoutes.mjs +1 -0
  53. package/dist/esm-node/runtime/i18n/backend/defaults.mjs +6 -2
  54. package/dist/esm-node/runtime/i18n/backend/defaults.node.mjs +56 -5
  55. package/dist/esm-node/runtime/index.mjs +4 -2
  56. package/dist/esm-node/runtime/localizedPaths.mjs +56 -0
  57. package/dist/esm-node/runtime/routerAdapter.mjs +3 -3
  58. package/dist/esm-node/runtime/utils.mjs +19 -12
  59. package/dist/esm-node/server/index.mjs +2 -10
  60. package/dist/esm-node/shared/localisedUrls.mjs +115 -23
  61. package/dist/types/cli/index.d.ts +1 -0
  62. package/dist/types/cli/locales.d.ts +17 -0
  63. package/dist/types/runtime/I18nLink.d.ts +4 -13
  64. package/dist/types/runtime/Link.d.ts +66 -0
  65. package/dist/types/runtime/canonicalRoutes.d.ts +60 -0
  66. package/dist/types/runtime/i18n/backend/defaults.d.ts +10 -7
  67. package/dist/types/runtime/i18n/backend/defaults.node.d.ts +13 -4
  68. package/dist/types/runtime/index.d.ts +5 -1
  69. package/dist/types/runtime/localizedPaths.d.ts +39 -0
  70. package/dist/types/runtime/types.d.ts +1 -1
  71. package/dist/types/runtime/utils.d.ts +13 -4
  72. package/dist/types/shared/localisedUrls.d.ts +23 -0
  73. package/dist/types/shared/type.d.ts +27 -5
  74. package/package.json +28 -25
  75. package/rstest.config.mts +7 -2
  76. package/src/cli/index.ts +25 -98
  77. package/src/cli/locales.ts +186 -0
  78. package/src/runtime/I18nLink.tsx +13 -44
  79. package/src/runtime/Link.tsx +430 -0
  80. package/src/runtime/canonicalRoutes.ts +93 -0
  81. package/src/runtime/i18n/backend/defaults.node.ts +112 -7
  82. package/src/runtime/i18n/backend/defaults.ts +20 -18
  83. package/src/runtime/index.tsx +24 -2
  84. package/src/runtime/localizedPaths.ts +107 -0
  85. package/src/runtime/routerAdapter.tsx +4 -5
  86. package/src/runtime/types.ts +1 -1
  87. package/src/runtime/utils.ts +33 -26
  88. package/src/server/index.ts +7 -17
  89. package/src/shared/localisedUrls.ts +256 -26
  90. package/src/shared/type.ts +27 -5
  91. package/tests/backendDefaults.test.ts +51 -0
  92. package/tests/i18nUtils.test.ts +10 -3
  93. package/tests/link.test.tsx +525 -0
  94. package/tests/linkTypes.test.ts +28 -0
  95. package/tests/localisedUrls.test.ts +224 -0
  96. package/tests/routerAdapter.test.tsx +86 -12
  97. package/tests/type-fixture/linkTypes.fixture.tsx +51 -0
  98. package/tests/type-fixture/tsconfig.json +15 -0
@@ -1,5 +1,6 @@
1
1
  import type { NestedRouteForCli } from '@modern-js/types';
2
2
  import { describe, expect, test } from '@rstest/core';
3
+ import { i18nPlugin as i18nCliPlugin } from '../src/cli';
3
4
  import {
4
5
  collectApiPrefixes,
5
6
  i18nServerPlugin,
@@ -7,7 +8,12 @@ import {
7
8
  } from '../src/server';
8
9
  import {
9
10
  applyLocalisedUrlsToRoutes,
11
+ canonicalTargetPathname,
12
+ localiseTargetPathname,
13
+ matchPathPattern,
14
+ resolveCanonicalLocalisedPath,
10
15
  resolveLocalisedPath,
16
+ resolveLocalisedUrlsConfig,
11
17
  validateLocalisedUrls,
12
18
  } from '../src/shared/localisedUrls';
13
19
 
@@ -31,6 +37,74 @@ const createRequestContext = (pathname: string) =>
31
37
  },
32
38
  }) as any;
33
39
 
40
+ describe('resolveLocalisedUrlsConfig', () => {
41
+ test('is opt-in: only a non-empty map enables the feature', () => {
42
+ const map = { '/about': { en: '/about', cs: '/o-nas' } };
43
+ expect(resolveLocalisedUrlsConfig(map)).toEqual({ enabled: true, map });
44
+ });
45
+
46
+ test('absent, boolean and empty-map options resolve to disabled', () => {
47
+ const disabled = { enabled: false, map: {} };
48
+ expect(resolveLocalisedUrlsConfig(undefined)).toEqual(disabled);
49
+ expect(resolveLocalisedUrlsConfig(false)).toEqual(disabled);
50
+ expect(resolveLocalisedUrlsConfig(true)).toEqual(disabled);
51
+ expect(resolveLocalisedUrlsConfig({})).toEqual(disabled);
52
+ });
53
+ });
54
+
55
+ describe('cli modifyFileSystemRoutes', () => {
56
+ const setupModifyRoutes = (localeDetection: Record<string, unknown>) => {
57
+ let modifyRoutes:
58
+ | ((args: { entrypoint: any; routes: any[] }) => {
59
+ entrypoint: any;
60
+ routes: any[];
61
+ })
62
+ | undefined;
63
+
64
+ i18nCliPlugin({ localeDetection }).setup({
65
+ _internalRuntimePlugins: () => {},
66
+ modifyFileSystemRoutes: (fn: any) => {
67
+ modifyRoutes = fn;
68
+ },
69
+ _internalServerPlugins: () => {},
70
+ } as any);
71
+
72
+ expect(modifyRoutes).toBeDefined();
73
+ return modifyRoutes!;
74
+ };
75
+
76
+ test('upstream-style configs without a map keep routes untouched', () => {
77
+ const modifyRoutes = setupModifyRoutes({
78
+ localePathRedirect: true,
79
+ languages: ['en', 'cs'],
80
+ });
81
+ const routes = [createRoute(':lang', [createRoute('about')])];
82
+
83
+ const result = modifyRoutes({ entrypoint: { entryName: 'main' }, routes });
84
+
85
+ expect(result.routes).toBe(routes);
86
+ });
87
+
88
+ test('a configured map still expands localised route aliases', () => {
89
+ const modifyRoutes = setupModifyRoutes({
90
+ localePathRedirect: true,
91
+ languages: ['en', 'cs'],
92
+ localisedUrls: {
93
+ '/about': { en: '/about', cs: '/o-nas' },
94
+ },
95
+ });
96
+ const routes = [createRoute(':lang', [createRoute('about')])];
97
+
98
+ const result = modifyRoutes({ entrypoint: { entryName: 'main' }, routes });
99
+
100
+ const localeRoute = result.routes[0] as NestedRouteForCli;
101
+ expect(localeRoute.children?.map(route => route.path)).toEqual([
102
+ 'about',
103
+ 'o-nas',
104
+ ]);
105
+ });
106
+ });
107
+
34
108
  describe('localisedUrls', () => {
35
109
  test('requires every localisable route path to define every language', () => {
36
110
  const routes = [createRoute(':lang', [createRoute('terms-of-service')])];
@@ -163,6 +237,55 @@ describe('localisedUrls', () => {
163
237
  ).toBe('/products/cervena-bota');
164
238
  });
165
239
 
240
+ test('resolves static patterns before param patterns', () => {
241
+ const localisedUrls = {
242
+ '/products/:slug': {
243
+ en: '/products/:slug',
244
+ cs: '/produkty/:slug',
245
+ },
246
+ '/products/new': {
247
+ en: '/products/new',
248
+ cs: '/produkty/novinka',
249
+ },
250
+ };
251
+
252
+ expect(
253
+ resolveLocalisedPath('/products/new', 'cs', ['en', 'cs'], localisedUrls),
254
+ ).toBe('/produkty/novinka');
255
+ expect(
256
+ resolveCanonicalLocalisedPath(
257
+ '/produkty/novinka',
258
+ ['en', 'cs'],
259
+ localisedUrls,
260
+ ),
261
+ ).toBe('/products/new');
262
+ });
263
+
264
+ test('localises and canonicalises full target pathnames through one helper', () => {
265
+ const localisedUrls = {
266
+ '/products/:slug': {
267
+ en: '/products/:slug',
268
+ cs: '/produkty/:slug',
269
+ },
270
+ };
271
+
272
+ expect(
273
+ localiseTargetPathname(
274
+ '/en/products/cervena-bota',
275
+ 'cs',
276
+ ['en', 'cs'],
277
+ localisedUrls,
278
+ ),
279
+ ).toBe('/cs/produkty/cervena-bota');
280
+ expect(
281
+ canonicalTargetPathname(
282
+ '/cs/produkty/cervena-bota',
283
+ ['en', 'cs'],
284
+ localisedUrls,
285
+ ),
286
+ ).toBe('/products/cervena-bota');
287
+ });
288
+
166
289
  test('resolves nested optional route params with translated ancestors', () => {
167
290
  const localisedUrls = {
168
291
  '/checkout': {
@@ -198,6 +321,47 @@ describe('localisedUrls', () => {
198
321
  });
199
322
  });
200
323
 
324
+ describe('matchPathPattern decoding', () => {
325
+ test('decodes valid percent-encoded params', () => {
326
+ expect(
327
+ matchPathPattern('/produkty/%C4%8Derven%C3%A1', '/produkty/:slug'),
328
+ ).toEqual({ slug: 'červená' });
329
+ });
330
+
331
+ test('treats malformed percent-encoding as no match instead of throwing', () => {
332
+ expect(() =>
333
+ matchPathPattern('/produkty/%E0%A4%A', '/produkty/:slug'),
334
+ ).not.toThrow();
335
+ expect(
336
+ matchPathPattern('/produkty/%E0%A4%A', '/produkty/:slug'),
337
+ ).toBeNull();
338
+ });
339
+
340
+ test('keeps literal bracket segments in pathnames (no pattern rewrite)', () => {
341
+ expect(matchPathPattern('/produkty/[x]', '/produkty/:slug')).toEqual({
342
+ slug: '[x]',
343
+ });
344
+ });
345
+
346
+ test('resolveLocalisedPath returns malformed paths unchanged', () => {
347
+ const localisedUrls = {
348
+ '/products/:slug': {
349
+ en: '/products/:slug',
350
+ cs: '/produkty/:slug',
351
+ },
352
+ };
353
+
354
+ expect(
355
+ resolveLocalisedPath(
356
+ '/produkty/%E0%A4%A',
357
+ 'en',
358
+ ['en', 'cs'],
359
+ localisedUrls,
360
+ ),
361
+ ).toBe('/produkty/%E0%A4%A');
362
+ });
363
+ });
364
+
201
365
  describe('i18n server API prefix skips', () => {
202
366
  test('collects API route prefixes and normalized BFF config prefixes', () => {
203
367
  expect(
@@ -272,6 +436,66 @@ describe('i18n server API prefix skips', () => {
272
436
  }
273
437
  });
274
438
 
439
+ test('canonical redirect survives malformed percent-encoding', async () => {
440
+ const middlewares: any[] = [];
441
+ const routes = [{ entryName: 'main', entryPath: '', urlPath: '/' }];
442
+ let prepare: (() => void) | undefined;
443
+
444
+ i18nServerPlugin({
445
+ localeDetection: {
446
+ fallbackLanguage: 'en',
447
+ languages: ['en', 'cs'],
448
+ localePathRedirect: true,
449
+ localisedUrls: {
450
+ '/products/:slug': {
451
+ en: '/products/:slug',
452
+ cs: '/produkty/:slug',
453
+ },
454
+ },
455
+ },
456
+ staticRoutePrefixes: [],
457
+ }).setup({
458
+ getServerConfig: () => ({}),
459
+ getServerContext: () => ({ middlewares, routes }),
460
+ onPrepare: fn => {
461
+ prepare = fn;
462
+ },
463
+ } as any);
464
+
465
+ prepare?.();
466
+
467
+ const redirectMiddleware = middlewares.find(
468
+ middleware => middleware.name === 'i18n-server-middleware',
469
+ );
470
+ const createContext = (pathname: string) =>
471
+ ({
472
+ req: {
473
+ url: `http://localhost${pathname}`,
474
+ header: () => ({ host: 'localhost' }),
475
+ },
476
+ get: () => null,
477
+ redirect: (url: string) => ({ redirectedTo: url }),
478
+ }) as any;
479
+
480
+ // Sanity: well-formed non-canonical slugs still redirect.
481
+ const redirected = await redirectMiddleware.handler(
482
+ createContext('/cs/products/bota'),
483
+ async () => {},
484
+ );
485
+ expect(redirected).toEqual({ redirectedTo: '/cs/produkty/bota' });
486
+
487
+ // Malformed encoding must fall through to next() instead of throwing.
488
+ let nextCalls = 0;
489
+ const response = await redirectMiddleware.handler(
490
+ createContext('/cs/produkty/%E0%A4%A'),
491
+ async () => {
492
+ nextCalls++;
493
+ },
494
+ );
495
+ expect(response).toBeUndefined();
496
+ expect(nextCalls).toBe(1);
497
+ });
498
+
275
499
  test('uses /api as the BFF prefix when BFF config is present without prefix', async () => {
276
500
  const middlewares: any[] = [];
277
501
  const routes = [{ entryName: 'main', entryPath: '', urlPath: '/' }];
@@ -1,4 +1,5 @@
1
1
  import {
2
+ applyRouterRuntimeState,
2
3
  InternalRuntimeContext,
3
4
  RuntimeContext,
4
5
  } from '@modern-js/runtime/context';
@@ -28,11 +29,18 @@ const requestContext = {
28
29
  response: {},
29
30
  };
30
31
 
31
- const TanstackLink = ({ to, children, ...props }: any) => (
32
- <a href={to} data-router-link="tanstack" {...props}>
33
- {children}
34
- </a>
35
- );
32
+ const capturedTanstackLinkProps: any[] = [];
33
+
34
+ const TanstackLink = ({ to, children, ...props }: any) => {
35
+ capturedTanstackLinkProps.push({ to, ...props });
36
+ const { prefetch: _prefetch, preload: _preload, ...anchorProps } = props;
37
+
38
+ return (
39
+ <a href={to} data-router-link="tanstack" {...anchorProps}>
40
+ {children}
41
+ </a>
42
+ );
43
+ };
36
44
 
37
45
  function createI18nInstance(language = 'en'): I18nInstance {
38
46
  return {
@@ -52,22 +60,23 @@ function createRuntimeContext(
52
60
  router: unknown,
53
61
  framework: 'tanstack' | 'react-router',
54
62
  ) {
55
- return {
63
+ const context = {
56
64
  isBrowser: true,
57
65
  requestContext,
58
66
  context: requestContext,
59
- routerFramework: framework,
60
- routerInstance: router,
61
- routerRuntime: {
62
- framework,
63
- instance: router,
64
- },
65
67
  router: {
66
68
  ...(framework === 'tanstack'
67
69
  ? { Link: TanstackLink, useRouter: () => router }
68
70
  : { useLocation: () => undefined, useHref: () => undefined }),
69
71
  },
70
72
  } as any;
73
+
74
+ applyRouterRuntimeState(context, {
75
+ framework,
76
+ instance: router,
77
+ });
78
+
79
+ return context;
71
80
  }
72
81
 
73
82
  function createTanstackRuntimeContext(router: unknown) {
@@ -236,6 +245,7 @@ describe('i18n router adapter', () => {
236
245
  afterEach(() => {
237
246
  cleanup(rendered);
238
247
  rendered = undefined;
248
+ capturedTanstackLinkProps.length = 0;
239
249
  window.history.replaceState(null, '', '/');
240
250
  });
241
251
 
@@ -265,6 +275,70 @@ describe('i18n router adapter', () => {
265
275
  expect(link?.getAttribute('data-router-link')).toBe('tanstack');
266
276
  });
267
277
 
278
+ test('forwards warmup props through I18nLink with a localized string target', async () => {
279
+ const router = createTanstackRouter('/cs/podminky-pouzivani', 'cs');
280
+ rendered = await renderWithRuntime(
281
+ <ModernI18nProvider
282
+ value={{
283
+ language: 'cs',
284
+ i18nInstance: createI18nInstance('cs'),
285
+ languages: ['en', 'cs'],
286
+ localePathRedirect: true,
287
+ localisedUrls,
288
+ }}
289
+ >
290
+ <I18nLink
291
+ to="/terms-of-service"
292
+ data-testid="terms-link"
293
+ prefetch="viewport"
294
+ preload="intent"
295
+ >
296
+ Terms
297
+ </I18nLink>
298
+ </ModernI18nProvider>,
299
+ createTanstackRuntimeContext(router),
300
+ );
301
+
302
+ const linkProps = capturedTanstackLinkProps.at(-1);
303
+ // TanStack has no `prefetch` prop: the explicit native `preload` wins and
304
+ // `prefetch` must not be forwarded.
305
+ expect(linkProps).toMatchObject({
306
+ to: '/cs/podminky-pouzivani',
307
+ preload: 'intent',
308
+ });
309
+ expect(linkProps.prefetch).toBeUndefined();
310
+ });
311
+
312
+ test('does not leak warmup props to fallback anchors', async () => {
313
+ rendered = await renderI18nRoot(
314
+ <ModernI18nProvider
315
+ value={{
316
+ language: 'cs',
317
+ i18nInstance: createI18nInstance('cs'),
318
+ languages: ['en', 'cs'],
319
+ localePathRedirect: true,
320
+ localisedUrls,
321
+ }}
322
+ >
323
+ <I18nLink
324
+ to="/terms-of-service"
325
+ data-testid="terms-link"
326
+ prefetch="none"
327
+ preload={false}
328
+ >
329
+ Terms
330
+ </I18nLink>
331
+ </ModernI18nProvider>,
332
+ );
333
+
334
+ const link = rendered.container.querySelector<HTMLAnchorElement>(
335
+ '[data-testid="terms-link"]',
336
+ );
337
+ expect(link?.getAttribute('href')).toBe('/cs/podminky-pouzivani');
338
+ expect(link?.hasAttribute('prefetch')).toBe(false);
339
+ expect(link?.hasAttribute('preload')).toBe(false);
340
+ });
341
+
268
342
  test('uses TanStack-shaped replacement when changeLanguage updates the URL', async () => {
269
343
  window.history.replaceState(
270
344
  null,
@@ -0,0 +1,51 @@
1
+ import { Link } from '@modern-js/plugin-i18n/runtime';
2
+
3
+ declare module '@modern-js/plugin-i18n/runtime' {
4
+ interface UltramodernCanonicalRoutes {
5
+ '/': Record<string, never>;
6
+ '/talks': Record<string, never>;
7
+ '/talks/$slug': { slug: string };
8
+ }
9
+ }
10
+
11
+ // --- valid uses ---
12
+
13
+ // Known route with required params: must compile.
14
+ const _a = <Link to="/talks/$slug" params={{ slug: 'x' }} />;
15
+
16
+ // Known route without params: must compile.
17
+ const _b = <Link to="/talks" />;
18
+
19
+ // Root route: must compile.
20
+ const _c = <Link to="/" />;
21
+
22
+ // Canonical path with hash suffix: must compile.
23
+ const _d = <Link to="/#work-with-me" />;
24
+
25
+ // Canonical path with query and hash: must compile.
26
+ const _e = <Link to="/talks?tag=x#abstract" />;
27
+
28
+ // Bare hash: must compile.
29
+ const _f = <Link to="#hash" />;
30
+
31
+ // External HTTPS URL: must compile.
32
+ const _g = <Link to="https://example.com" />;
33
+
34
+ // Dynamic (computed) value — escape hatch: must compile.
35
+ declare function compute(): string;
36
+ const dynamic: string = compute();
37
+ const _h = <Link to={dynamic} />;
38
+
39
+ // --- invalid uses (each preceded by @ts-expect-error) ---
40
+
41
+ // Unknown route.
42
+ // @ts-expect-error
43
+ const _i = <Link to="/talkz" />;
44
+
45
+ // Known param route but params prop is missing.
46
+ // @ts-expect-error
47
+ const _j = <Link to="/talks/$slug" />;
48
+
49
+ // Known route without params but params are provided (forbidden).
50
+ // @ts-expect-error
51
+ const _k = <Link to="/talks" params={{ slug: 'x' }} />;
@@ -0,0 +1,15 @@
1
+ {
2
+ "compilerOptions": {
3
+ "jsx": "react-jsx",
4
+ "strict": true,
5
+ "moduleResolution": "Bundler",
6
+ "module": "Preserve",
7
+ "noEmit": true,
8
+ "skipLibCheck": true,
9
+ "lib": ["ESNext", "DOM"],
10
+ "paths": {
11
+ "@modern-js/plugin-i18n/runtime": ["../../src/runtime/index.tsx"]
12
+ }
13
+ },
14
+ "include": ["*.tsx", "*.ts"]
15
+ }