@seed-ship/mcp-ui-solid 6.21.0 → 6.23.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 (93) hide show
  1. package/CHANGELOG.md +173 -0
  2. package/README.md +102 -0
  3. package/dist/components/ChartJSRenderer.cjs +244 -68
  4. package/dist/components/ChartJSRenderer.cjs.map +1 -1
  5. package/dist/components/ChartJSRenderer.d.ts +9 -0
  6. package/dist/components/ChartJSRenderer.d.ts.map +1 -1
  7. package/dist/components/ChartJSRenderer.js +246 -70
  8. package/dist/components/ChartJSRenderer.js.map +1 -1
  9. package/dist/components/ExpandableWrapper.cjs +9 -1
  10. package/dist/components/ExpandableWrapper.cjs.map +1 -1
  11. package/dist/components/ExpandableWrapper.d.ts.map +1 -1
  12. package/dist/components/ExpandableWrapper.js +9 -1
  13. package/dist/components/ExpandableWrapper.js.map +1 -1
  14. package/dist/components/LightboxOverlay.cjs +139 -16
  15. package/dist/components/LightboxOverlay.cjs.map +1 -1
  16. package/dist/components/LightboxOverlay.d.ts +6 -0
  17. package/dist/components/LightboxOverlay.d.ts.map +1 -1
  18. package/dist/components/LightboxOverlay.js +141 -18
  19. package/dist/components/LightboxOverlay.js.map +1 -1
  20. package/dist/components/UIResourceRenderer.cjs +15 -5
  21. package/dist/components/UIResourceRenderer.cjs.map +1 -1
  22. package/dist/components/UIResourceRenderer.d.ts +2 -1
  23. package/dist/components/UIResourceRenderer.d.ts.map +1 -1
  24. package/dist/components/UIResourceRenderer.js +15 -5
  25. package/dist/components/UIResourceRenderer.js.map +1 -1
  26. package/dist/context/MCPUIConfigContext.cjs +2 -1
  27. package/dist/context/MCPUIConfigContext.cjs.map +1 -1
  28. package/dist/context/MCPUIConfigContext.d.ts +90 -12
  29. package/dist/context/MCPUIConfigContext.d.ts.map +1 -1
  30. package/dist/context/MCPUIConfigContext.js +2 -1
  31. package/dist/context/MCPUIConfigContext.js.map +1 -1
  32. package/dist/context/MCPUIMathContext.cjs +19 -0
  33. package/dist/context/MCPUIMathContext.cjs.map +1 -0
  34. package/dist/context/MCPUIMathContext.d.ts +18 -0
  35. package/dist/context/MCPUIMathContext.d.ts.map +1 -0
  36. package/dist/context/MCPUIMathContext.js +19 -0
  37. package/dist/context/MCPUIMathContext.js.map +1 -0
  38. package/dist/context/MCPUIStringsContext.cjs +6 -1
  39. package/dist/context/MCPUIStringsContext.cjs.map +1 -1
  40. package/dist/context/MCPUIStringsContext.d.ts +22 -0
  41. package/dist/context/MCPUIStringsContext.d.ts.map +1 -1
  42. package/dist/context/MCPUIStringsContext.js +6 -1
  43. package/dist/context/MCPUIStringsContext.js.map +1 -1
  44. package/dist/hooks/index.d.ts +2 -0
  45. package/dist/hooks/index.d.ts.map +1 -1
  46. package/dist/hooks.cjs +2 -0
  47. package/dist/hooks.cjs.map +1 -1
  48. package/dist/hooks.js +2 -0
  49. package/dist/hooks.js.map +1 -1
  50. package/dist/index.cjs +8 -0
  51. package/dist/index.cjs.map +1 -1
  52. package/dist/index.d.cts +5 -1
  53. package/dist/index.d.ts +5 -1
  54. package/dist/index.d.ts.map +1 -1
  55. package/dist/index.js +9 -1
  56. package/dist/index.js.map +1 -1
  57. package/dist/plugins/katex.cjs +23 -0
  58. package/dist/plugins/katex.cjs.map +1 -0
  59. package/dist/plugins/katex.d.cts +7 -0
  60. package/dist/plugins/katex.d.ts +7 -0
  61. package/dist/plugins/katex.d.ts.map +1 -0
  62. package/dist/plugins/katex.js +23 -0
  63. package/dist/plugins/katex.js.map +1 -0
  64. package/dist/utils/iframe-coep.cjs.map +1 -1
  65. package/dist/utils/iframe-coep.d.ts +8 -0
  66. package/dist/utils/iframe-coep.d.ts.map +1 -1
  67. package/dist/utils/iframe-coep.js.map +1 -1
  68. package/dist/utils/math-markdown.cjs +240 -0
  69. package/dist/utils/math-markdown.cjs.map +1 -0
  70. package/dist/utils/math-markdown.d.ts +16 -0
  71. package/dist/utils/math-markdown.d.ts.map +1 -0
  72. package/dist/utils/math-markdown.js +240 -0
  73. package/dist/utils/math-markdown.js.map +1 -0
  74. package/dist/utils/pan-zoom.cjs +312 -0
  75. package/dist/utils/pan-zoom.cjs.map +1 -0
  76. package/dist/utils/pan-zoom.d.ts +235 -0
  77. package/dist/utils/pan-zoom.d.ts.map +1 -0
  78. package/dist/utils/pan-zoom.js +312 -0
  79. package/dist/utils/pan-zoom.js.map +1 -0
  80. package/package.json +26 -3
  81. package/src/components/ChartJSRenderer.tsx +396 -1
  82. package/src/components/ExpandableWrapper.tsx +26 -0
  83. package/src/components/LightboxOverlay.tsx +280 -4
  84. package/src/components/UIResourceRenderer.tsx +24 -3
  85. package/src/context/MCPUIConfigContext.tsx +92 -12
  86. package/src/context/MCPUIMathContext.tsx +32 -0
  87. package/src/context/MCPUIStringsContext.tsx +30 -0
  88. package/src/hooks/index.ts +13 -0
  89. package/src/index.ts +20 -0
  90. package/src/plugins/katex.ts +25 -0
  91. package/src/utils/iframe-coep.ts +8 -0
  92. package/src/utils/math-markdown.ts +222 -0
  93. package/src/utils/pan-zoom.ts +779 -0
@@ -274,6 +274,30 @@ export interface MCPUIStrings {
274
274
  /** `aria-label` of the lightbox "next image" button. */
275
275
  lightboxNext?: string
276
276
 
277
+ // ── Zoom controls (lightbox, chart) ────────────────────────
278
+ /**
279
+ * `aria-label` / `title` of the "zoom in" button.
280
+ * @since 6.22.0
281
+ */
282
+ zoomIn?: string
283
+ /**
284
+ * `aria-label` / `title` of the "zoom out" button.
285
+ * @since 6.22.0
286
+ */
287
+ zoomOut?: string
288
+ /**
289
+ * `aria-label` / `title` of the "reset zoom" button — the one the chart
290
+ * toolbar only shows while the chart is actually zoomed.
291
+ * @since 6.22.0
292
+ */
293
+ zoomReset?: string
294
+ /**
295
+ * Zoom-level readout next to the zoom buttons.
296
+ * Template — `{percent}` = the scale as a whole percentage (`100` at rest).
297
+ * @since 6.22.0
298
+ */
299
+ zoomLevel?: string
300
+
277
301
  // ── ModalRenderer ──────────────────────────────────────────
278
302
  /** `aria-label` of the modal close button. */
279
303
  modalClose?: string
@@ -1095,6 +1119,12 @@ export const DEFAULT_MCPUI_STRINGS: Required<MCPUIStrings> = {
1095
1119
 
1096
1120
  // ── 6.21.0 — iframe COEP fallback ──────────────────────────
1097
1121
  iframeOpenInNewTab: 'Open in a new tab',
1122
+
1123
+ // ── 6.22.0 — zoom / pan controls ───────────────────────────
1124
+ zoomIn: 'Zoom in',
1125
+ zoomOut: 'Zoom out',
1126
+ zoomReset: 'Reset zoom',
1127
+ zoomLevel: 'Zoom {percent}%',
1098
1128
  }
1099
1129
 
1100
1130
  /**
@@ -59,6 +59,19 @@ export type {
59
59
  UseAutocompleteReturn,
60
60
  } from './useAutocomplete'
61
61
 
62
+ // Pan/zoom primitive (v6.22.0). It lives under `src/utils` because it is pure
63
+ // arithmetic plus two signals, but it is reactive and registers an onCleanup,
64
+ // so it is used exactly like a hook — hence this re-export on the `./hooks`
65
+ // subpath as well as on the root barrel.
66
+ export { createPanZoom } from '../utils/pan-zoom'
67
+ export type {
68
+ PanZoomOptions,
69
+ PanZoomController,
70
+ PanZoomState,
71
+ PanZoomPoint,
72
+ PanZoomCursor,
73
+ } from '../utils/pan-zoom'
74
+
62
75
  // Data Validator hooks (v3.1.0 — anti-hallucination)
63
76
  export { useDataValidator } from './useDataValidator'
64
77
  export type {
package/src/index.ts CHANGED
@@ -128,6 +128,22 @@ export type {
128
128
  DuplicateMountReporter,
129
129
  } from './utils/duplicate-mount-registry'
130
130
 
131
+ // Cursor-anchored zoom + drag-pan primitive (v6.22.0 — lightbox, expanded views)
132
+ export {
133
+ createPanZoom,
134
+ clampScale,
135
+ contentPointAt,
136
+ viewportPointOf,
137
+ zoomAtAnchor,
138
+ } from './utils/pan-zoom'
139
+ export type {
140
+ PanZoomOptions,
141
+ PanZoomController,
142
+ PanZoomState,
143
+ PanZoomPoint,
144
+ PanZoomCursor,
145
+ } from './utils/pan-zoom'
146
+
131
147
  // Chrome i18n — library's own strings, EN defaults (v6.6.0 — D2/R4)
132
148
  export {
133
149
  MCPUIStringsProvider,
@@ -151,6 +167,7 @@ export {
151
167
  } from './context/MCPUIConfigContext'
152
168
  export type {
153
169
  MCPUIConfig,
170
+ MCPUIConfigInput,
154
171
  MCPUIConfigProviderProps,
155
172
  } from './context/MCPUIConfigContext'
156
173
 
@@ -373,6 +390,9 @@ export {
373
390
  DEGRADED_PROJECTION_LABELS,
374
391
  } from './utils/degraded-projections'
375
392
  export type { DegradedProjectionLabels } from './utils/degraded-projections'
393
+ export { MCPUIMathProvider } from './context/MCPUIMathContext'
394
+ export type { MathRenderer, MathRenderOptions, MCPUIMathProviderProps } from './context/MCPUIMathContext'
395
+
376
396
  export { chartToDataTable, CHART_DATA_TABLE_LABELS } from './components/chart-data-table'
377
397
  export type { ChartDataTableLabels } from './components/chart-data-table'
378
398
 
@@ -0,0 +1,25 @@
1
+ import katex from 'katex'
2
+ import type { MathRenderer } from '../context/MCPUIMathContext'
3
+
4
+ /**
5
+ * Optional KaTeX adapter. It emits MathML only: no HTML layout tree, inline
6
+ * styles, fonts, stylesheet, shared macro state, or trusted TeX commands.
7
+ */
8
+ export const renderKatexMath: MathRenderer = (tex, { displayMode }) => {
9
+ try {
10
+ return katex.renderToString(tex, {
11
+ displayMode,
12
+ output: 'mathml',
13
+ // Invalid TeX must return null so the core can preserve the complete
14
+ // source token, including its delimiters, instead of showing KaTeX's
15
+ // red error MathML and silently consuming the dollars.
16
+ throwOnError: true,
17
+ trust: false,
18
+ maxExpand: 1000,
19
+ maxSize: 20,
20
+ macros: {},
21
+ })
22
+ } catch {
23
+ return null
24
+ }
25
+ }
@@ -26,6 +26,12 @@ import type { MCPUIConfig } from '../context/MCPUIConfigContext'
26
26
  * cookies and a COEP host must opt in with `'always'`.
27
27
  *
28
28
  * An unparsable URL is not trusted, so it gets the attribute.
29
+ *
30
+ * Takes a RESOLVED config — `MCPUIConfig`, which is what `useMCPUIConfig()`
31
+ * and `DEFAULT_MCPUI_CONFIG` hand out, every key present. The authored view is
32
+ * `MCPUIConfigInput`, whose fields are optional so that a new key stays
33
+ * backward compatible; accepting one of those here would mean re-implementing
34
+ * the defaults next to their source, and silently drifting from them.
29
35
  */
30
36
  export function shouldSetCredentialless(
31
37
  url: string,
@@ -62,6 +68,8 @@ export function createCrossOriginIsolated(): Accessor<boolean> {
62
68
  * `'auto'` shows it only on a cross-origin-isolated page — exactly where COEP
63
69
  * can block the frame, and where the blocking is silent (a refused
64
70
  * cross-origin document fires no `error` event the parent can observe).
71
+ *
72
+ * Takes a resolved config, for the reason given on `shouldSetCredentialless`.
65
73
  */
66
74
  export function shouldShowIframeFallbackLink(
67
75
  config: MCPUIConfig,
@@ -0,0 +1,222 @@
1
+ import type { Config } from 'dompurify'
2
+ import { Marked, Renderer, type MarkedExtension, type Tokens } from 'marked'
3
+ import type { MathRenderer } from '../context/MCPUIMathContext'
4
+ import { escapeHtml } from './escape-html'
5
+ import { canSanitizeHtml, sanitizeHtml } from './sanitize-html'
6
+
7
+ export type MathMarkdownProfile = 'prose' | 'cellMarkdown'
8
+ export type MarkdownTextTransform = (escapedText: string) => string
9
+
10
+ export const MATH_MARKDOWN_LIMITS = {
11
+ sourceLength: 100_000,
12
+ expressionLength: 4_096,
13
+ expressionCount: 128,
14
+ } as const
15
+
16
+ const MATH_TAGS = [
17
+ 'math', 'semantics', 'mrow', 'mi', 'mo', 'mn', 'mtext', 'mspace', 'ms',
18
+ 'msup', 'msub', 'msubsup', 'mfrac', 'msqrt', 'mroot', 'mtable', 'mtr',
19
+ 'mtd', 'mover', 'munder', 'munderover', 'mpadded', 'mphantom', 'mstyle', 'menclose',
20
+ ] as const
21
+
22
+ const MATH_ATTRS = [
23
+ 'xmlns', 'display', 'class', 'mathvariant', 'stretchy', 'fence', 'separator',
24
+ 'lspace', 'rspace', 'accent', 'accentunder', 'columnalign', 'rowalign',
25
+ 'columnspacing', 'rowspacing', 'columnlines', 'rowlines', 'frame', 'notation',
26
+ 'width', 'height', 'depth', 'voffset', 'scriptlevel', 'displaystyle',
27
+ ] as const
28
+
29
+ const STRICT_MATHML_CONFIG = {
30
+ ALLOWED_TAGS: [...MATH_TAGS, 'span'],
31
+ ALLOWED_ATTR: [...MATH_ATTRS],
32
+ FORBID_TAGS: ['annotation', 'annotation-xml', 'style', 'a'],
33
+ // DOMPurify normally keeps the text of forbidden tags. TeX annotations can
34
+ // be large and duplicate the accessible MathML, so remove their subtree.
35
+ ADD_FORBID_CONTENTS: ['annotation', 'annotation-xml'],
36
+ FORBID_ATTR: ['style'],
37
+ } as Config
38
+
39
+ const CELL_MARKDOWN_TAGS = [
40
+ 'a', 'strong', 'em', 'b', 'i', 'code', 'span', 'br', 'button', 'svg', 'path',
41
+ 'p', 'ul', 'ol', 'li', 'pre', 'blockquote', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
42
+ ...MATH_TAGS,
43
+ ]
44
+
45
+ const FINAL_PROFILES: Record<MathMarkdownProfile, Config> = {
46
+ prose: {
47
+ ADD_TAGS: [...MATH_TAGS],
48
+ ADD_ATTR: [...MATH_ATTRS, 'target', 'rel'],
49
+ FORBID_TAGS: ['annotation', 'annotation-xml', 'style', 'form', 'textarea', 'select', 'button', 'iframe', 'object', 'embed'],
50
+ ADD_FORBID_CONTENTS: ['annotation', 'annotation-xml'],
51
+ FORBID_ATTR: ['style'],
52
+ } as Config,
53
+ cellMarkdown: {
54
+ ALLOWED_TAGS: CELL_MARKDOWN_TAGS,
55
+ ALLOWED_ATTR: [
56
+ 'href', 'target', 'rel', 'class', 'data-citation-page', 'data-citation-source',
57
+ 'data-citation-doc', 'data-citation-verified', 'title', 'fill', 'stroke',
58
+ 'viewBox', 'stroke-linecap', 'stroke-linejoin', 'stroke-width', 'd',
59
+ ...MATH_ATTRS,
60
+ ],
61
+ FORBID_TAGS: ['annotation', 'annotation-xml', 'style'],
62
+ ADD_FORBID_CONTENTS: ['annotation', 'annotation-xml'],
63
+ FORBID_ATTR: ['style'],
64
+ } as Config,
65
+ }
66
+
67
+ interface MathToken extends Tokens.Generic {
68
+ type: 'mcpDisplayMath' | 'mcpInlineMath'
69
+ raw: string
70
+ tex: string
71
+ displayMode: boolean
72
+ render: boolean
73
+ }
74
+
75
+ function isCurrencyLike(raw: string, tex: string, following: string): boolean {
76
+ if (/^\$\s|\s\$$/.test(raw)) return true
77
+ if (/^\d+(?:[.,]\d+)?-\s*$/.test(tex)) return true
78
+ if (/^\d/.test(tex) && /^\d/.test(following)) return true
79
+
80
+ // A currency opener can otherwise pair with the opening delimiter of a
81
+ // later expression ("$5 then ($x$)"). Reinterpret that first candidate
82
+ // only when it starts with a bounded numeric amount and its apparent closer
83
+ // is immediately followed by a complete later inline body. This deliberately
84
+ // leaves inherently ambiguous standalone forms ($5$, $2 + 3$, $2\pi$) as
85
+ // math and does not depend on the prose between the two delimiters.
86
+ const amount = /^\d+(?:[.,]\d{1,2})?(?=$|[\s,;:!?()[\]+*/-])/.exec(tex)?.[0]
87
+ if (!amount) return false
88
+
89
+ // A standalone numeric token is valid math even when followed by prose or
90
+ // another expression ("$5$, then $x$"). Reassignment requires content
91
+ // beyond the amount itself.
92
+ const trailing = tex.slice(amount.length)
93
+ if (!trailing) return false
94
+
95
+ // During streaming, the later formula may not have closed yet. An unmatched
96
+ // opening parenthesis before its dollar is sufficient structural evidence
97
+ // that the numeric dollar was currency; otherwise a permissive renderer can
98
+ // briefly flash "5 then (" as math.
99
+ const hasUnmatchedOpeningParen = (trailing.match(/\(/g)?.length ?? 0) > (trailing.match(/\)/g)?.length ?? 0)
100
+ const containsTexCommand = /\\[A-Za-z]+/.test(trailing)
101
+ if (hasUnmatchedOpeningParen && !containsTexCommand) return true
102
+
103
+ const closerLooksLikeLaterOpener = /^(?![\s,.;:!?)}\]])(?:\\[^\n]|[^\\\n$])+\$(?!\$)/.test(following)
104
+ return closerLooksLikeLaterOpener
105
+ }
106
+
107
+ const CELL_LINK_ATTRIBUTES = 'target="_blank" rel="noopener noreferrer" class="text-blue-600 dark:text-blue-400 hover:underline"'
108
+
109
+ function cellLinkExtension(): MarkedExtension {
110
+ return {
111
+ renderer: {
112
+ link(this: Renderer, token: Tokens.Link): string {
113
+ // Marked owns escaping of the destination, title and nested label
114
+ // tokens. Only inject fixed attributes into its already-safe anchor.
115
+ const anchor = Renderer.prototype.link.call(this, token) as string
116
+ return anchor.replace('<a ', `<a ${CELL_LINK_ATTRIBUTES} `)
117
+ },
118
+ },
119
+ }
120
+ }
121
+
122
+ function mathExtension(renderer: MathRenderer): MarkedExtension {
123
+ let count = 0
124
+ const token = (raw: string, tex: string, displayMode: boolean): MathToken => ({
125
+ type: displayMode ? 'mcpDisplayMath' : 'mcpInlineMath',
126
+ raw,
127
+ tex,
128
+ displayMode,
129
+ render: ++count <= MATH_MARKDOWN_LIMITS.expressionCount && tex.length <= MATH_MARKDOWN_LIMITS.expressionLength,
130
+ })
131
+
132
+ return {
133
+ extensions: [
134
+ {
135
+ name: 'mcpDisplayMath',
136
+ level: 'block',
137
+ start(src) {
138
+ const index = src.indexOf('$$')
139
+ return index < 0 ? undefined : index
140
+ },
141
+ tokenizer(src) {
142
+ const match = /^\$\$\s*\n?([\s\S]+?)\n?\s*\$\$(?:\n|$)/.exec(src)
143
+ if (!match) return undefined
144
+ return token(match[0], match[1], true)
145
+ },
146
+ renderer(tokenValue) {
147
+ return renderToken(tokenValue as MathToken, renderer)
148
+ },
149
+ },
150
+ {
151
+ name: 'mcpInlineMath',
152
+ level: 'inline',
153
+ start(src) {
154
+ const index = src.indexOf('$')
155
+ return index < 0 ? undefined : index
156
+ },
157
+ tokenizer(src) {
158
+ const match = /^\$(?!\$)((?:\\[^\n]|[^\\\n$])+?)\$(?!\$)/.exec(src)
159
+ if (!match || isCurrencyLike(match[0], match[1], src.slice(match[0].length))) return undefined
160
+ return token(match[0], match[1], false)
161
+ },
162
+ renderer(tokenValue) {
163
+ return renderToken(tokenValue as MathToken, renderer)
164
+ },
165
+ },
166
+ ],
167
+ }
168
+ }
169
+
170
+ function renderToken(token: MathToken, renderer: MathRenderer): string {
171
+ if (!token.render) return escapeHtml(token.raw)
172
+ try {
173
+ const rendered = renderer(token.tex, { displayMode: token.displayMode })
174
+ if (!rendered) return escapeHtml(token.raw)
175
+ return sanitizeHtml(rendered, STRICT_MATHML_CONFIG)
176
+ } catch {
177
+ return escapeHtml(token.raw)
178
+ }
179
+ }
180
+
181
+ /**
182
+ * Parse Markdown with isolated, bounded math support and sanitize the complete
183
+ * result for its final sink. The optional transform sees escaped ordinary text
184
+ * tokens only. Math, code, raw HTML and link destinations never pass through
185
+ * it, so citation syntax cannot corrupt TeX optional arguments or attributes.
186
+ */
187
+ export function parseMathMarkdown(
188
+ source: string,
189
+ renderer: MathRenderer,
190
+ profile: MathMarkdownProfile,
191
+ transformText?: MarkdownTextTransform,
192
+ ): string {
193
+ const input = source === null || source === undefined ? '' : String(source)
194
+ if (!canSanitizeHtml()) return escapeHtml(input)
195
+ if (input.length > MATH_MARKDOWN_LIMITS.sourceLength) return escapeHtml(input)
196
+
197
+ const textRenderer = transformText
198
+ ? {
199
+ renderer: {
200
+ text(this: Renderer, token: Tokens.Text | Tokens.Escape): string {
201
+ // Block text tokens (notably list-item text) can own already
202
+ // tokenized inline children. Recurse first so math and code keep
203
+ // their own renderers; leaf text callbacks run as parsing descends.
204
+ if ('tokens' in token && token.tokens) {
205
+ return this.parser.parseInline(token.tokens)
206
+ }
207
+ // Delegate entity handling and nested token rendering to Marked's
208
+ // own renderer. `escapeHtml` is deliberately not equivalent: it
209
+ // turns source `&amp;` into visible `&amp;` by double-escaping it.
210
+ const safeText = Renderer.prototype.text.call(this, token)
211
+ return transformText(safeText as string)
212
+ },
213
+ },
214
+ }
215
+ : undefined
216
+ const extensions = [mathExtension(renderer)]
217
+ if (profile === 'cellMarkdown') extensions.push(cellLinkExtension())
218
+ if (textRenderer) extensions.push(textRenderer)
219
+ const parser = new Marked({ gfm: true }, ...extensions)
220
+ const parsed = parser.parse(input, { async: false }) as string
221
+ return sanitizeHtml(parsed, FINAL_PROFILES[profile])
222
+ }