@contractkit/prettier-plugin 0.12.2 → 0.13.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.
@@ -8,6 +8,8 @@ import type {
8
8
  ContractTypeNode,
9
9
  ObjectMode,
10
10
  PluginValue,
11
+ McpConfigNode,
12
+ OpBodyKey,
11
13
  } from '@contractkit/core';
12
14
  import { SECURITY_NONE } from '@contractkit/core';
13
15
  import { printType, formatDefault } from './print-type.js';
@@ -54,17 +56,20 @@ export function flushBlocks(out: string[], blocks: CommentBlock[], idx: { value:
54
56
  // ─── Route ───────────────────────────────────────────────────────────────────
55
57
 
56
58
  /**
57
- * Render an `operation` route body from its `path: {` onward (the `operation` keyword and
58
- * any modifier are prepended by the caller). Emits the params/security blocks and each HTTP
59
- * operation, interleaving orphan comment `blocks` at their original source positions — `idx`
60
- * tracks how far through `blocks` we've consumed, and `nextRouteStart` bounds the flush to
61
- * comments before the following route. Any `route.trailingComments` (comments after the last
62
- * operation, before `}`) are emitted before the closing brace so they round-trip.
59
+ * Render an `operation` route body from its `path: {` onward (the `operation` keyword, any
60
+ * modifier, and the route's leading comments are prepended by the caller). Emits the
61
+ * params/security blocks and each HTTP operation, interleaving orphan comment `blocks` at their
62
+ * original source positions — `idx` tracks how far through `blocks` we've consumed, and
63
+ * `nextRouteStart` bounds the flush to comments before the following route. Any
64
+ * `route.trailingComments` (comments after the last operation, before `}`) are emitted before
65
+ * the closing brace so they round-trip.
66
+ *
67
+ * Blank lines between operations come from each operation's `blankLineBefore`, so the author's
68
+ * spacing survives rather than being normalized to one rule or the other.
63
69
  */
64
70
  export function printRoute(route: OpRouteNode, blocks: CommentBlock[], idx: { value: number }, nextRouteStart: number): string {
65
71
  const lines: string[] = [];
66
- const commentSuffix = route.description ? ` # ${route.description}` : '';
67
- lines.push(`${route.path}: {${commentSuffix}`);
72
+ lines.push(`${route.path}: {`);
68
73
 
69
74
  if (route.params !== undefined) {
70
75
  lines.push(...printParamsBlock(route.params, I1, route.paramsMode));
@@ -75,6 +80,8 @@ export function printRoute(route: OpRouteNode, blocks: CommentBlock[], idx: { va
75
80
  }
76
81
 
77
82
  for (const op of route.operations) {
83
+ // Reproduce the author's spacing rather than imposing our own.
84
+ if (op.blankLineBefore && lines.length > 1) lines.push('');
78
85
  // Flush comment blocks that appear before this operation (inside the route)
79
86
  flushBlocks(lines, blocks, idx, op.loc.line, I1);
80
87
  lines.push(...printOperation(op));
@@ -119,55 +126,114 @@ function printParamsBlock(source: ParamSource, indent: string, mode?: ObjectMode
119
126
 
120
127
  // ─── HTTP operation ──────────────────────────────────────────────────────────
121
128
 
122
- function printOperation(op: OpOperationNode): string[] {
123
- const lines: string[] = [];
124
- const commentSuffix = op.description ? ` # ${op.description}` : '';
125
- const modPart = op.modifiers?.length ? `(${op.modifiers[0]})` : '';
126
- lines.push(`${I1}${op.method}${modPart}: {${commentSuffix}`);
127
-
128
- if (op.name) lines.push(`${I2}name: ${op.name}`);
129
- if (op.service) lines.push(`${I2}service: ${op.service}`);
130
- if (op.sdk) lines.push(`${I2}sdk: ${op.sdk}`);
131
- if (op.signature) {
132
- const comment = op.signatureDescription ? ` # ${op.signatureDescription}` : '';
133
- if (op.signaturePolicy) {
134
- lines.push(`${I2}signature: {`);
135
- lines.push(`${I3}options: ${formatSignatureValue(op.signature)}${comment}`);
136
- lines.push(`${I3}policy: ${op.signaturePolicy}`);
129
+ /** Order the body keys are emitted in when the node carries no source order (built programmatically). */
130
+ const CANONICAL_KEY_ORDER: OpBodyKey[] = ['name', 'service', 'sdk', 'mcp', 'signature', 'security', 'plugins', 'query', 'headers', 'request', 'responses'];
131
+
132
+ /** Render a single operation-body key. Returns `[]` when the operation doesn't carry that key. */
133
+ function printOperationKey(op: OpOperationNode, key: OpBodyKey): string[] {
134
+ switch (key) {
135
+ case 'name':
136
+ return op.name ? [`${I2}name: ${op.name}`] : [];
137
+ case 'service':
138
+ return op.service ? [`${I2}service: ${op.service}`] : [];
139
+ case 'sdk':
140
+ return op.sdk ? [`${I2}sdk: ${op.sdk}`] : [];
141
+ case 'mcp':
142
+ if (op.mcp === true) return [`${I2}mcp: true`];
143
+ if (op.mcp === false) return [`${I2}mcp: false`];
144
+ return op.mcp ? printMcpBlock(op.mcp) : [];
145
+ case 'signature': {
146
+ if (!op.signature) return [];
147
+ const comment = op.signatureDescription ? ` # ${op.signatureDescription}` : '';
148
+ if (op.signaturePolicy) {
149
+ return [
150
+ `${I2}signature: {`,
151
+ `${I3}options: ${formatSignatureValue(op.signature)}${comment}`,
152
+ `${I3}policy: ${op.signaturePolicy}`,
153
+ `${I2}}`,
154
+ ];
155
+ }
156
+ return [`${I2}signature: ${formatSignatureValue(op.signature)}${comment}`];
157
+ }
158
+ case 'security':
159
+ return op.security !== undefined ? printSecurity(op.security) : [];
160
+ case 'plugins': {
161
+ if (!op.plugins || Object.keys(op.plugins).length === 0) return [];
162
+ const lines = [`${I2}plugins: {`];
163
+ for (const [k, val] of Object.entries(op.plugins)) lines.push(...printPluginEntry(k, val, I3));
137
164
  lines.push(`${I2}}`);
138
- } else {
139
- lines.push(`${I2}signature: ${formatSignatureValue(op.signature)}${comment}`);
165
+ return lines;
140
166
  }
141
- }
142
- if (op.security !== undefined) lines.push(...printSecurity(op.security));
143
- if (op.plugins && Object.keys(op.plugins).length > 0) {
144
- lines.push(`${I2}plugins: {`);
145
- for (const [key, val] of Object.entries(op.plugins)) {
146
- lines.push(...printPluginEntry(key, val, I3));
167
+ case 'query':
168
+ return op.query !== undefined ? printQueryOrHeaders('query', op.query, op.queryMode) : [];
169
+ case 'headers':
170
+ if (op.requestHeadersOptOut) return [`${I2}headers: none`];
171
+ return op.headers !== undefined ? printQueryOrHeaders('headers', op.headers, op.headersMode) : [];
172
+ case 'request': {
173
+ if (!op.request) return [];
174
+ const lines = [`${I2}request: {`];
175
+ for (const body of op.request.bodies) lines.push(...printContentTypeLine(body.contentType, body.bodyType, I3));
176
+ lines.push(`${I2}}`);
177
+ return lines;
147
178
  }
148
- lines.push(`${I2}}`);
179
+ case 'responses':
180
+ return op.responses.length > 0 ? printResponseBlock(op.responses) : [];
149
181
  }
150
- if (op.query !== undefined) lines.push(...printQueryOrHeaders('query', op.query, op.queryMode));
151
- if (op.requestHeadersOptOut) {
152
- lines.push(`${I2}headers: none`);
153
- } else if (op.headers !== undefined) {
154
- lines.push(...printQueryOrHeaders('headers', op.headers, op.headersMode));
155
- }
156
- if (op.request) {
157
- lines.push(`${I2}request: {`);
158
- for (const body of op.request.bodies) {
159
- lines.push(...printContentTypeLine(body.contentType, body.bodyType, I3));
160
- }
161
- lines.push(`${I2}}`);
182
+ }
183
+
184
+ function printOperation(op: OpOperationNode): string[] {
185
+ const lines: string[] = [];
186
+ const modPart = op.modifiers?.length ? `(${op.modifiers[0]})` : '';
187
+
188
+ // A doc comment written above the method line goes back above it; only an inline one is
189
+ // re-emitted as a trailing `#` on the header. Nodes built programmatically carry no placement,
190
+ // and default to inline — the form most `.ck` sources use and one that round-trips as written.
191
+ const inlineDescription = op.descriptionInline ?? true;
192
+ if (op.description && !inlineDescription) {
193
+ for (const line of op.description.split('\n')) lines.push(`${I1}# ${line}`);
162
194
  }
163
- if (op.responses.length > 0) {
164
- lines.push(...printResponseBlock(op.responses));
195
+ const commentSuffix = op.description && inlineDescription ? ` # ${op.description}` : '';
196
+ lines.push(`${I1}${op.method}${modPart}: {${commentSuffix}`);
197
+
198
+ // Emit in source order when the parser recorded it, so formatting never reorders a user's keys.
199
+ // Any key the source order doesn't mention (e.g. added by a later AST pass) follows in canonical order.
200
+ const order = op.keyOrder ?? [];
201
+ const rest = CANONICAL_KEY_ORDER.filter(k => !order.includes(k));
202
+ for (const key of [...order, ...rest]) {
203
+ lines.push(...printOperationKey(op, key));
165
204
  }
166
205
 
167
206
  lines.push(`${I1}}`);
168
207
  return lines;
169
208
  }
170
209
 
210
+ // ─── MCP block ───────────────────────────────────────────────────────────────
211
+
212
+ /**
213
+ * Reconstruct the `hint:` token list from the four annotation booleans, in canonical
214
+ * order. Each set boolean contributes its positive or negative token; unset hints are omitted.
215
+ */
216
+ function mcpHintTokens(mcp: McpConfigNode): string[] {
217
+ const tokens: string[] = [];
218
+ if (mcp.readOnlyHint !== undefined) tokens.push(mcp.readOnlyHint ? 'readOnly' : 'nonReadOnly');
219
+ if (mcp.idempotentHint !== undefined) tokens.push(mcp.idempotentHint ? 'idempotent' : 'nonIdempotent');
220
+ if (mcp.destructiveHint !== undefined) tokens.push(mcp.destructiveHint ? 'destructive' : 'nonDestructive');
221
+ if (mcp.openWorldHint !== undefined) tokens.push(mcp.openWorldHint ? 'openWorld' : 'closedWorld');
222
+ return tokens;
223
+ }
224
+
225
+ /** Print an `mcp: { ... }` settings block. Fields are emitted in canonical order; `hint:` is omitted when no hints are set. */
226
+ function printMcpBlock(mcp: McpConfigNode): string[] {
227
+ const lines: string[] = [`${I2}mcp: {`];
228
+ if (mcp.name !== undefined) lines.push(`${I3}name: "${escapeString(mcp.name)}"`);
229
+ if (mcp.title !== undefined) lines.push(`${I3}title: "${escapeString(mcp.title)}"`);
230
+ if (mcp.description !== undefined) lines.push(`${I3}description: "${escapeString(mcp.description)}"`);
231
+ const tokens = mcpHintTokens(mcp);
232
+ if (tokens.length > 0) lines.push(`${I3}hint: ${tokens.join(', ')}`);
233
+ lines.push(`${I2}}`);
234
+ return lines;
235
+ }
236
+
171
237
  // ─── Plugins block ───────────────────────────────────────────────────────────
172
238
 
173
239
  const IDENT_RE = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/;
@@ -318,7 +384,10 @@ function printResponseBlock(responses: OpResponseNode[]): string[] {
318
384
  const hasBody = resp.contentType && resp.bodyType;
319
385
  const hasHeaders = resp.headers && resp.headers.length > 0;
320
386
  const optOut = resp.headersOptOut;
321
- if (hasBody || hasHeaders || optOut) {
387
+ if (resp.inline && hasBody && !hasHeaders && !optOut && resp.bodyType!.kind !== 'inlineObject') {
388
+ // Written on one line in the source, so keep it there: `200: { application/json: Pet }`.
389
+ lines.push(`${I3}${resp.statusCode}: { ${resp.contentType}: ${printType(resp.bodyType!)} }`);
390
+ } else if (hasBody || hasHeaders || optOut) {
322
391
  lines.push(`${I3}${resp.statusCode}: {`);
323
392
  if (hasBody) {
324
393
  lines.push(...printContentTypeLine(resp.contentType!, resp.bodyType!, I4));
@@ -60,7 +60,9 @@ describe('printCk — route modifiers', () => {
60
60
 
61
61
  it('preserves description alongside modifier', () => {
62
62
  const ast = makeRoot([makeRoute('/users', [makeOp('get')], { modifiers: ['deprecated'], description: 'Old user list' })]);
63
- expect(printCk(ast)).toContain('operation(deprecated) /users: { # Old user list');
63
+ // A route description is emitted above the declaration, not inline: a `#` after the `{`
64
+ // would re-parse as the first operation's description instead.
65
+ expect(printCk(ast)).toContain('# Old user list\noperation(deprecated) /users: {');
64
66
  });
65
67
  });
66
68
 
@@ -531,6 +533,82 @@ describe('printCk — response headers', () => {
531
533
  });
532
534
  });
533
535
 
536
+ describe('printCk — mcp field', () => {
537
+ function roundTrip(source: string): string {
538
+ const diag = new DiagnosticCollector();
539
+ const ast = parseCk(source, 'test.ck', diag);
540
+ expect(diag.hasErrors()).toBe(false);
541
+ return printCk(ast);
542
+ }
543
+
544
+ it('prints mcp: true', () => {
545
+ const ast = makeRoot([makeRoute('/users', [makeOp('get', { mcp: true })])]);
546
+ expect(printCk(ast)).toContain(' mcp: true');
547
+ });
548
+
549
+ it('prints explicit mcp: false', () => {
550
+ const ast = makeRoot([makeRoute('/users', [makeOp('get', { mcp: false })])]);
551
+ expect(printCk(ast)).toContain(' mcp: false');
552
+ });
553
+
554
+ it('omits mcp when undefined', () => {
555
+ const ast = makeRoot([makeRoute('/users', [makeOp('get')])]);
556
+ expect(printCk(ast)).not.toContain('mcp:');
557
+ });
558
+
559
+ it('prints mcp block with text fields and reconstructed hint list', () => {
560
+ const ast = makeRoot([
561
+ makeRoute('/routes', [
562
+ makeOp('post', {
563
+ mcp: {
564
+ name: 'searchRoutes',
565
+ title: 'Search routes',
566
+ description: 'Full-text search.',
567
+ readOnlyHint: true,
568
+ idempotentHint: true,
569
+ destructiveHint: false,
570
+ loc: makeLoc(),
571
+ },
572
+ }),
573
+ ]),
574
+ ]);
575
+ const out = printCk(ast);
576
+ expect(out).toContain(' mcp: {');
577
+ expect(out).toContain(' name: "searchRoutes"');
578
+ expect(out).toContain(' title: "Search routes"');
579
+ expect(out).toContain(' description: "Full-text search."');
580
+ expect(out).toContain(' hint: readOnly, idempotent, nonDestructive');
581
+ });
582
+
583
+ it('round-trips mcp: true / false through parse', () => {
584
+ expect(roundTrip('operation /a: {\n get: {\n mcp: true\n }\n}\n')).toContain(' mcp: true');
585
+ expect(roundTrip('operation /b: {\n get: {\n mcp: false\n }\n}\n')).toContain(' mcp: false');
586
+ });
587
+
588
+ it('round-trips an mcp settings block through parse', () => {
589
+ const source = `\
590
+ operation /routes: {
591
+ post: {
592
+ mcp: {
593
+ name: "searchRoutes"
594
+ title: "Search routes"
595
+ description: "Full-text search across routes."
596
+ hint: readOnly, idempotent, nonDestructive, closedWorld
597
+ }
598
+ }
599
+ }
600
+ `;
601
+ const out = roundTrip(source);
602
+ expect(out).toContain(' mcp: {');
603
+ expect(out).toContain(' name: "searchRoutes"');
604
+ expect(out).toContain(' hint: readOnly, idempotent, nonDestructive, closedWorld');
605
+ // Idempotent: printing the round-tripped output again is stable.
606
+ const diag = new DiagnosticCollector();
607
+ expect(printCk(parseCk(out, 'test.ck', diag))).toBe(out);
608
+ expect(diag.hasErrors()).toBe(false);
609
+ });
610
+ });
611
+
534
612
  describe('printCk — options-level header globals (round-trip)', () => {
535
613
  function roundTrip(source: string): string {
536
614
  const diag = new DiagnosticCollector();
@@ -0,0 +1,268 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { readdirSync, readFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { printCk } from '../src/print-ck.js';
5
+ import { parseCk, DiagnosticCollector } from '@contractkit/core';
6
+
7
+ /**
8
+ * Formatting a `.ck` file must not change it.
9
+ *
10
+ * The prettier plugin used to fold standalone `#` comment blocks into trailing comments on the
11
+ * following declaration, reorder operation body keys, drop blank lines between operations, and
12
+ * expand single-line response bodies — all of which silently rewrote a user's file on
13
+ * `pnpm format`. These tests pin the guarantee: parse → print is the identity on well-formed
14
+ * source, and printing is idempotent.
15
+ */
16
+
17
+ function format(source: string, file = 'test.ck'): string {
18
+ const diag = new DiagnosticCollector();
19
+ const ast = parseCk(source, file, diag);
20
+ expect(diag.hasErrors()).toBe(false);
21
+ return printCk(ast);
22
+ }
23
+
24
+ // ─── Repository contracts ────────────────────────────────────────────────────
25
+
26
+ const CONTRACTS_DIR = new URL('../../../contracts', import.meta.url).pathname;
27
+ const ckFiles = readdirSync(CONTRACTS_DIR).filter(f => f.endsWith('.ck'));
28
+
29
+ describe('round-trip — repository .ck files', () => {
30
+ it('finds .ck files to check', () => {
31
+ expect(ckFiles.length).toBeGreaterThan(0);
32
+ });
33
+
34
+ for (const name of ckFiles) {
35
+ it(`formats ${name} to itself`, () => {
36
+ const source = readFileSync(join(CONTRACTS_DIR, name), 'utf8');
37
+ expect(format(source, name)).toBe(source);
38
+ });
39
+ }
40
+ });
41
+
42
+ // ─── Constructs that previously round-tripped lossily ────────────────────────
43
+
44
+ describe('round-trip — comment placement', () => {
45
+ it('keeps a standalone comment block above the declaration it precedes', () => {
46
+ const source = `# ─── Pet endpoints ───
47
+
48
+ operation /pet: {
49
+ get: {
50
+ response: {
51
+ 200:
52
+ }
53
+ }
54
+ }
55
+ `;
56
+ expect(format(source)).toBe(source);
57
+ });
58
+
59
+ it('keeps a contract doc comment on its own line', () => {
60
+ const source = `# A pet for sale
61
+ contract Pet: {
62
+ id: int
63
+ }
64
+ `;
65
+ expect(format(source)).toBe(source);
66
+ });
67
+
68
+ it('keeps a contract doc comment inline when written inline', () => {
69
+ const source = `contract Pet: { # A pet for sale
70
+ id: int
71
+ }
72
+ `;
73
+ expect(format(source)).toBe(source);
74
+ });
75
+
76
+ it('distinguishes a divider from the doc comment below it', () => {
77
+ const source = `# ─── Models ───
78
+
79
+ # A pet for sale
80
+ contract Pet: {
81
+ id: int
82
+ }
83
+ `;
84
+ expect(format(source)).toBe(source);
85
+ });
86
+
87
+ it('keeps an operation doc comment on its own line', () => {
88
+ const source = `operation /pet: {
89
+ # update an existing pet
90
+ put: {
91
+ response: {
92
+ 200:
93
+ }
94
+ }
95
+ }
96
+ `;
97
+ expect(format(source)).toBe(source);
98
+ });
99
+
100
+ it('keeps an operation doc comment inline when written inline', () => {
101
+ const source = `operation /pet: {
102
+ put: { # update an existing pet
103
+ response: {
104
+ 200:
105
+ }
106
+ }
107
+ }
108
+ `;
109
+ expect(format(source)).toBe(source);
110
+ });
111
+ });
112
+
113
+ describe('round-trip — comments in the options block', () => {
114
+ it('keeps a comment above a sub-block', () => {
115
+ const source = `options {
116
+ # where these come from
117
+ keys: {
118
+ area: ledger
119
+ }
120
+ }
121
+ `;
122
+ expect(format(source)).toBe(source);
123
+ });
124
+
125
+ it('keeps a comment run above the sub-block it precedes', () => {
126
+ const source = `options {
127
+ keys: {
128
+ area: ledger
129
+ }
130
+ # service wiring
131
+ # one per module
132
+ services: {
133
+ UserService: "#src/user.js"
134
+ }
135
+ }
136
+ `;
137
+ expect(format(source)).toBe(source);
138
+ });
139
+
140
+ it('keeps a trailing comment before the closing brace', () => {
141
+ const source = `options {
142
+ keys: {
143
+ area: ledger
144
+ }
145
+ # nothing below
146
+ }
147
+ `;
148
+ expect(format(source)).toBe(source);
149
+ });
150
+
151
+ it('keeps an options block that holds nothing but a comment', () => {
152
+ const source = `options {
153
+ # a note
154
+ }
155
+ `;
156
+ expect(format(source)).toBe(source);
157
+ });
158
+ });
159
+
160
+ describe('round-trip — operation body key order', () => {
161
+ it('does not reorder keys into a canonical order', () => {
162
+ const source = `operation /pet: {
163
+ put: {
164
+ sdk: updatePet
165
+ service: PetService.update
166
+ response: {
167
+ 200:
168
+ }
169
+ }
170
+ }
171
+ `;
172
+ expect(format(source)).toBe(source);
173
+ });
174
+
175
+ it('preserves the opposite order just as faithfully', () => {
176
+ const source = `operation /pet: {
177
+ put: {
178
+ service: PetService.update
179
+ sdk: updatePet
180
+ response: {
181
+ 200:
182
+ }
183
+ }
184
+ }
185
+ `;
186
+ expect(format(source)).toBe(source);
187
+ });
188
+ });
189
+
190
+ describe('round-trip — layout', () => {
191
+ it('keeps blank lines between operations', () => {
192
+ const source = `operation /pet: {
193
+ get: {
194
+ response: {
195
+ 200:
196
+ }
197
+ }
198
+
199
+ post: {
200
+ response: {
201
+ 201:
202
+ }
203
+ }
204
+ }
205
+ `;
206
+ expect(format(source)).toBe(source);
207
+ });
208
+
209
+ it('keeps operations packed when the source has no blank line', () => {
210
+ const source = `operation /pet: {
211
+ get: {
212
+ response: {
213
+ 200:
214
+ }
215
+ }
216
+ post: {
217
+ response: {
218
+ 201:
219
+ }
220
+ }
221
+ }
222
+ `;
223
+ expect(format(source)).toBe(source);
224
+ });
225
+
226
+ it('keeps a single-line response body on one line', () => {
227
+ const source = `operation /pet: {
228
+ get: {
229
+ response: {
230
+ 200: { application/json: Pet }
231
+ }
232
+ }
233
+ }
234
+ `;
235
+ expect(format(source)).toBe(source);
236
+ });
237
+
238
+ it('keeps an expanded response body expanded', () => {
239
+ const source = `operation /pet: {
240
+ get: {
241
+ response: {
242
+ 200: {
243
+ application/json: Pet
244
+ }
245
+ }
246
+ }
247
+ }
248
+ `;
249
+ expect(format(source)).toBe(source);
250
+ });
251
+ });
252
+
253
+ // ─── Idempotence ─────────────────────────────────────────────────────────────
254
+
255
+ describe('round-trip — idempotence', () => {
256
+ const sources = [
257
+ ...ckFiles.map(name => readFileSync(join(CONTRACTS_DIR, name), 'utf8')),
258
+ // Non-canonical spacing: formatting once must reach a fixed point.
259
+ `operation /pet: {\n get: {\n response: {\n 200:\n }\n }\n}\n`,
260
+ ];
261
+
262
+ for (const [i, source] of sources.entries()) {
263
+ it(`formatting is a fixed point for source #${i}`, () => {
264
+ const once = format(source);
265
+ expect(format(once)).toBe(once);
266
+ });
267
+ }
268
+ });