@uniweb/content-writer 0.2.8 → 0.2.9

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/content-writer",
3
- "version": "0.2.8",
3
+ "version": "0.2.9",
4
4
  "description": "ProseMirror document structure to Markdown converter",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -20,7 +20,7 @@
20
20
  },
21
21
  "devDependencies": {
22
22
  "vitest": "^4.1.7",
23
- "@uniweb/content-reader": "1.1.16"
23
+ "@uniweb/content-reader": "1.1.17"
24
24
  },
25
25
  "directories": {
26
26
  "test": "tests"
package/probe.tmp.mjs ADDED
@@ -0,0 +1,18 @@
1
+ import { proseMirrorToMarkdown } from './src/index.js'
2
+ const doc = c => ({ type: 'doc', content: [c] })
3
+ const text = t => ({ type: 'text', text: t })
4
+
5
+ console.log('WarningBlock (text*, today):')
6
+ console.log(' ', JSON.stringify(proseMirrorToMarkdown(doc({ type: 'WarningBlock', attrs: {}, content: [text('Be careful.')] })).trim()))
7
+
8
+ console.log('WarningBlock with type:')
9
+ console.log(' ', JSON.stringify(proseMirrorToMarkdown(doc({ type: 'WarningBlock', attrs: { type: 'warning' }, content: [text('Careful.')] })).trim()))
10
+
11
+ console.log('WarningBlock (block+, after widening):')
12
+ console.log(' ', JSON.stringify(proseMirrorToMarkdown(doc({ type: 'WarningBlock', attrs: {}, content: [
13
+ { type: 'paragraph', content: [text('One.')] }, { type: 'paragraph', content: [text('Two.')] }] })).trim()))
14
+
15
+ console.log('details:')
16
+ console.log(' ', JSON.stringify(proseMirrorToMarkdown(doc({ type: 'details', content: [
17
+ { type: 'detailsSummary', content: [text('Breaking '), { type: 'text', text: 'changes', marks: [{ type: 'bold' }] }] },
18
+ { type: 'detailsContent', content: [{ type: 'paragraph', content: [text('Body text.')] }] }] })).trim()))
package/src/attributes.js CHANGED
@@ -2,7 +2,13 @@
2
2
  * @fileoverview Serialize attributes to curly brace syntax
3
3
  *
4
4
  * Inverse of content-reader's attributes.js.
5
- * Produces syntax like: {role=hero width=1200 .class #id autoplay}
5
+ * Produces syntax like: {role=hero width=1200 #id autoplay}
6
+ *
7
+ * There is no CSS-class form. A `class` attribute serializes as an ordinary
8
+ * `class=value` pair, and a name that starts with a dot (`{".featured": true}`)
9
+ * serializes through the plain boolean branch as `{.featured}` — the dot is
10
+ * part of the name. Both directions need no special case, which is why this
11
+ * file no longer has one.
6
12
  */
7
13
 
8
14
  /**
@@ -22,13 +28,7 @@ export function serializeAttributes(attrs, skipKeys = []) {
22
28
  if (skipSet.has(key)) continue
23
29
  if (value === null || value === undefined) continue
24
30
 
25
- if (key === 'class') {
26
- // Split class string into individual .class entries
27
- const classes = String(value).split(/\s+/).filter(Boolean)
28
- for (const cls of classes) {
29
- parts.push(`.${cls}`)
30
- }
31
- } else if (key === 'id') {
31
+ if (key === 'id') {
32
32
  parts.push(`#${value}`)
33
33
  } else if (value === true) {
34
34
  // Boolean attribute
package/src/marks.js CHANGED
@@ -38,6 +38,32 @@ function marksEqual(a, b) {
38
38
  return true
39
39
  }
40
40
 
41
+ /**
42
+ * Would `inner` on its own re-parse to exactly this link?
43
+ *
44
+ * An author who writes a bare `https://example.com` gets a link mark whose
45
+ * href equals its text. Writing that back as `[https://example.com](https://example.com)`
46
+ * is content-identical but rewrites the author's file — and content-writer is
47
+ * what the editor uses to sync back to a developer's repository, so every save
48
+ * would churn every bare URL in it.
49
+ *
50
+ * Kept deliberately narrow. The bare form is only emitted when the mark
51
+ * carries nothing but an href, the text IS that href (or its address, for
52
+ * mailto), and the URL is one GFM autolinking will recapture with the same
53
+ * bounds: no whitespace or brackets, and no trailing punctuation, which
54
+ * autolink detection excludes from the match. Anything else keeps the
55
+ * explicit form, which is always correct if more verbose.
56
+ */
57
+ const AUTOLINK_SAFE = /^[^\s<>()[\]]*[^\s<>()[\].,;:!?'"]$/
58
+
59
+ function isBareAutolink(mark, inner) {
60
+ const { href, title, ...rest } = mark.attrs || {}
61
+ if (!href || title || Object.keys(rest).length > 0) return false
62
+ if (href === inner) return /^https?:\/\//.test(href) && AUTOLINK_SAFE.test(href)
63
+ if (href === `mailto:${inner}`) return AUTOLINK_SAFE.test(inner) && inner.includes('@')
64
+ return false
65
+ }
66
+
41
67
  /**
42
68
  * Serialize a link mark's suffix: (href "title"){attrs}
43
69
  */
@@ -313,7 +339,7 @@ function applyMark(inner, mark) {
313
339
  // rule, Mod-Shift-s) and had no markdown form, so it was dropped.
314
340
  return `~~${inner}~~`
315
341
  case 'link':
316
- return `[${inner}]${serializeLinkSuffix(mark)}`
342
+ return isBareAutolink(mark, inner) ? inner : `[${inner}]${serializeLinkSuffix(mark)}`
317
343
  case 'button':
318
344
  return `[${inner}]${serializeButtonSuffix(mark)}`
319
345
  case 'span':
package/src/nodes.js CHANGED
@@ -6,6 +6,7 @@
6
6
 
7
7
  import yaml from 'js-yaml'
8
8
  import { serializeInlineContent, serializeBlockImage, serializeInsetRef } from './marks.js'
9
+ import { serializeAttributes } from './attributes.js'
9
10
 
10
11
  // inset_ref serialization is shared with the inline path; it lives in
11
12
  // marks.js (alongside the other inline-attribute serializers) and is
@@ -95,6 +96,36 @@ export function serializeMathDisplay(node) {
95
96
  : '$$' + latex + '$$'
96
97
  }
97
98
 
99
+ /**
100
+ * Serialize a container node back to its fence.
101
+ *
102
+ * The inverse of content-reader's `@Component{params}` fence. The info string
103
+ * is the inset's own token, so a container is byte-for-byte the block form of
104
+ * an inset — same concept, two levels.
105
+ *
106
+ * The fence widens past any backtick run in the body, which is what lets a
107
+ * container hold a code sample: `fenceWidthFor` is the same computation the
108
+ * plain code-block path uses.
109
+ *
110
+ * @param {Object} node - inset_block node with attrs.component + params
111
+ * @returns {string} Fenced container
112
+ */
113
+ export function serializeInsetBlock(node) {
114
+ const { component, ...params } = node.attrs || {}
115
+ if (!component) return ''
116
+
117
+ const { serializeNode } = await_serializer()
118
+ const body = (node.content || [])
119
+ .map(child => serializeNode(child))
120
+ .filter(Boolean)
121
+ .join('\n\n')
122
+
123
+ const fence = '`'.repeat(fenceWidthFor(body))
124
+ const attrStr = serializeAttributes(params)
125
+
126
+ return `${fence}@${component}${attrStr}\n${body}\n${fence}`
127
+ }
128
+
98
129
  /**
99
130
  * Serialize a data block node.
100
131
  *
@@ -154,30 +185,35 @@ export function serializeBlockquote(node) {
154
185
 
155
186
  /**
156
187
  * Serialize a bullet list node.
188
+ *
189
+ * `pad` is the literal indentation this list sits at — the parent item's
190
+ * CONTENT COLUMN, not a level count. See serializeListItem for why.
191
+ *
157
192
  * @param {Object} node - Bullet list node with content (listItem nodes)
158
- * @param {number} [indent=0] - Indentation level
193
+ * @param {string} [pad=''] - Indentation prefix for every marker in this list
159
194
  * @returns {string} Markdown bullet list
160
195
  */
161
- export function serializeBulletList(node, indent = 0) {
196
+ export function serializeBulletList(node, pad = '') {
162
197
  if (!node.content) return ''
163
- const prefix = ' '.repeat(indent)
164
198
  return node.content
165
- .map(item => serializeListItem(item, `${prefix}- `, indent, isLoose(node)))
199
+ .map(item => serializeListItem(item, `${pad}- `, isLoose(node)))
166
200
  .join(itemSeparator(node))
167
201
  }
168
202
 
169
203
  /**
170
204
  * Serialize an ordered list node.
205
+ *
206
+ * `pad` is the literal indentation this list sits at — see serializeBulletList.
207
+ *
171
208
  * @param {Object} node - Ordered list node with attrs.start and content
172
- * @param {number} [indent=0] - Indentation level
209
+ * @param {string} [pad=''] - Indentation prefix for every marker in this list
173
210
  * @returns {string} Markdown ordered list
174
211
  */
175
- export function serializeOrderedList(node, indent = 0) {
212
+ export function serializeOrderedList(node, pad = '') {
176
213
  if (!node.content) return ''
177
214
  const start = node.attrs?.start || 1
178
- const prefix = ' '.repeat(indent)
179
215
  return node.content
180
- .map((item, i) => serializeListItem(item, `${prefix}${start + i}. `, indent, isLoose(node)))
216
+ .map((item, i) => serializeListItem(item, `${pad}${start + i}. `, isLoose(node)))
181
217
  .join(itemSeparator(node))
182
218
  }
183
219
 
@@ -207,13 +243,24 @@ function isLoose(node) {
207
243
 
208
244
  /**
209
245
  * Serialize a list item.
246
+ *
247
+ * A nested list is indented to this item's CONTENT COLUMN — `bullet.length` —
248
+ * not by a fixed two spaces per level. The marker's width varies (`- ` is 2,
249
+ * `9. ` is 3, `10. ` is 4), and a child indented less than the parent's
250
+ * content column is not a child at all: markdown reads it as ending the
251
+ * outer list. That is a source-corrupting bug, not a cosmetic one — a
252
+ * three-item ordered list with a nested bullet list came back as three
253
+ * separate top-level nodes.
254
+ *
255
+ * `pad` already computes that column for every other block in the item; nested
256
+ * lists now use the same value instead of their own count.
257
+ *
210
258
  * @param {Object} node - List item node with content
211
259
  * @param {string} bullet - The bullet prefix (e.g., "- " or "1. ")
212
- * @param {number} indent - Current indentation level
213
260
  * @param {boolean} [loose=false] - Whether the parent list is loose
214
261
  * @returns {string} Markdown list item
215
262
  */
216
- function serializeListItem(node, bullet, indent, loose = false) {
263
+ function serializeListItem(node, bullet, loose = false) {
217
264
  if (!node.content) return bullet
218
265
 
219
266
  const parts = []
@@ -224,11 +271,11 @@ function serializeListItem(node, bullet, indent, loose = false) {
224
271
 
225
272
  // Nested lists carry their own indentation.
226
273
  if (child.type === 'bulletList') {
227
- parts.push(serializeBulletList(child, indent + 1))
274
+ parts.push(serializeBulletList(child, pad))
228
275
  continue
229
276
  }
230
277
  if (child.type === 'orderedList') {
231
- parts.push(serializeOrderedList(child, indent + 1))
278
+ parts.push(serializeOrderedList(child, pad))
232
279
  continue
233
280
  }
234
281
 
package/src/normalize.js CHANGED
@@ -192,6 +192,15 @@ function mapEditorNode(node) {
192
192
  case 'UniwebIcon':
193
193
  case 'Icon':
194
194
  return mapUniwebIcon(node)
195
+ case 'WarningBlock':
196
+ case 'details': {
197
+ const attrs = EDITOR_CONTAINERS[node.type](node)
198
+ return {
199
+ type: 'inset_block',
200
+ attrs,
201
+ content: node.type === 'details' ? detailsBody(node) : asBlocks(node.content),
202
+ }
203
+ }
195
204
  case 'emoji':
196
205
  // The node carries the character alongside a name; the character is the
197
206
  // whole markdown form. Guarded rather than assumed to be populated.
@@ -237,6 +246,68 @@ function mapEditorNode(node) {
237
246
  */
238
247
  const NAMED_INLINE_STYLES = new Set(['accent', 'highlight', 'callout', 'muted'])
239
248
 
249
+ /**
250
+ * Editor container nodes → the framework's `inset_block`, as an explicit table.
251
+ *
252
+ * Deliberately not derived from the node name. The editor's node is
253
+ * `WarningBlock` while the component is `Alert`, and its `type` defaults to
254
+ * `info` rather than `warning` — a name-derived mapping drops exactly that row,
255
+ * which is how `mapUniwebIcon` came to lose every icon the editor produces. A
256
+ * missing entry here is a visible gap rather than a silent assumption.
257
+ */
258
+ const EDITOR_CONTAINERS = {
259
+ WarningBlock: node => ({ component: 'Alert', type: node.attrs?.type || 'info' }),
260
+ details: () => ({ component: 'Details' }),
261
+ }
262
+
263
+ /** Inline node types, for deciding whether a container's body needs wrapping. */
264
+ const INLINE_TYPES = new Set([
265
+ 'text',
266
+ 'hardBreak',
267
+ 'image',
268
+ 'math_inline',
269
+ 'inset_ref',
270
+ 'emoji',
271
+ 'UniwebIcon',
272
+ ])
273
+
274
+ /**
275
+ * A container body as block content.
276
+ *
277
+ * `WarningBlock` holds `text*` today and `block+` after the editor's widening,
278
+ * so both shapes arrive here. Inline content is wrapped in one paragraph;
279
+ * block content passes through.
280
+ *
281
+ * @param {Array} content
282
+ * @returns {Array}
283
+ */
284
+ function asBlocks(content) {
285
+ if (!content?.length) return []
286
+ return content.every(node => INLINE_TYPES.has(node.type))
287
+ ? [{ type: 'paragraph', content }]
288
+ : content
289
+ }
290
+
291
+ /**
292
+ * Flatten the editor's `details` › `detailsSummary` + `detailsContent` into the
293
+ * container's body, summary first.
294
+ *
295
+ * The summary leads rather than riding on an attribute: `detailsSummary` can
296
+ * carry marks, and `summary="…"` would flatten emphasis and links.
297
+ *
298
+ * @param {Object} node
299
+ * @returns {Array}
300
+ */
301
+ function detailsBody(node) {
302
+ const body = []
303
+ for (const child of node.content || []) {
304
+ if (child.type === 'detailsSummary') body.push(...asBlocks(child.content))
305
+ else if (child.type === 'detailsContent') body.push(...(child.content || []))
306
+ else body.push(child)
307
+ }
308
+ return body
309
+ }
310
+
240
311
  /** A mark that carries nothing and should be removed rather than reported. */
241
312
  const DROP_MARK = Symbol('drop-mark')
242
313
 
package/src/serializer.js CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  serializeParagraph,
10
10
  serializeImage,
11
11
  serializeInsetRef,
12
+ serializeInsetBlock,
12
13
  serializeCodeBlock,
13
14
  serializeDataBlock,
14
15
  serializeBlockquote,
@@ -27,6 +28,7 @@ const NODE_SERIALIZERS = {
27
28
  paragraph: serializeParagraph,
28
29
  image: serializeImage,
29
30
  inset_ref: serializeInsetRef,
31
+ inset_block: serializeInsetBlock,
30
32
  codeBlock: serializeCodeBlock,
31
33
  dataBlock: serializeDataBlock,
32
34
  blockquote: serializeBlockquote,
@@ -27,12 +27,12 @@ describe('Attribute Serialization', () => {
27
27
  expect(serializeAttributes({ autoplay: true, muted: true })).toBe('{autoplay muted}')
28
28
  })
29
29
 
30
- test('serializes class as dot notation', () => {
31
- expect(serializeAttributes({ class: 'featured' })).toBe('{.featured}')
30
+ test('serializes a dotted NAME through the plain boolean branch', () => {
31
+ expect(serializeAttributes({ '.featured': true })).toBe('{.featured}')
32
32
  })
33
33
 
34
- test('serializes multiple classes', () => {
35
- expect(serializeAttributes({ class: 'featured rounded shadow' })).toBe('{.featured .rounded .shadow}')
34
+ test('serializes several dotted names', () => {
35
+ expect(serializeAttributes({ '.featured': true, '.rounded': true, '.shadow': true })).toBe('{.featured .rounded .shadow}')
36
36
  })
37
37
 
38
38
  test('serializes id as hash notation', () => {
@@ -41,7 +41,7 @@ describe('Attribute Serialization', () => {
41
41
 
42
42
  test('serializes mixed attributes', () => {
43
43
  const result = serializeAttributes({
44
- class: 'featured',
44
+ '.featured': true,
45
45
  id: 'main-logo',
46
46
  width: 800,
47
47
  loading: 'lazy',
@@ -95,7 +95,7 @@ describe('Attribute Serialization', () => {
95
95
 
96
96
  test('serializes data attributes', () => {
97
97
  const result = serializeAttributes({
98
- class: 'info',
98
+ '.info': true,
99
99
  'data-tooltip': 'More info',
100
100
  })
101
101
  expect(result).toBe('{.info data-tooltip="More info"}')
@@ -0,0 +1,7 @@
1
+ Open http://localhost:5173 in your browser.
2
+
3
+ Email me at a@b.com when it works.
4
+
5
+ A labelled link to [the docs](https://example.com) stays labelled.
6
+
7
+ A link whose label happens to be a URL, [https://example.com](https://example.com "titled"), keeps its explicit form.
@@ -0,0 +1,22 @@
1
+ # Containers
2
+
3
+ ```@Details
4
+ A summary with **marks**
5
+
6
+ Body content, [links](/x), the lot.
7
+ ```
8
+
9
+ ```@Alert{type=warning}
10
+ Be careful with this.
11
+
12
+ - one
13
+ - two
14
+ ```
15
+
16
+ ````@Alert{type=info}
17
+ Do not do this:
18
+
19
+ ```js
20
+ const x = 1
21
+ ```
22
+ ````
@@ -0,0 +1,18 @@
1
+ 1. Push your repo to the host
2
+ 2. Configure:
3
+ - Base directory: `site`
4
+ - Build command: `pnpm build`
5
+ 3. Deploy
6
+
7
+ ## A two-digit marker is wider
8
+
9
+ 8. eight
10
+ 9. nine
11
+ 10. ten
12
+ - nested under a two-digit marker
13
+
14
+ ## A bullet parent stays at two
15
+
16
+ - a
17
+ - b
18
+ - c
@@ -202,7 +202,7 @@ describe('Inline Content Serialization', () => {
202
202
  {
203
203
  type: 'text',
204
204
  text: 'highlighted text',
205
- marks: [{ type: 'span', attrs: { class: 'highlight' } }],
205
+ marks: [{ type: 'span', attrs: { '.highlight': true } }],
206
206
  },
207
207
  { type: 'text', text: ' in a sentence.' },
208
208
  ]
@@ -216,7 +216,7 @@ describe('Inline Content Serialization', () => {
216
216
  {
217
217
  type: 'text',
218
218
  text: 'important note',
219
- marks: [{ type: 'span', attrs: { class: 'callout bold' } }],
219
+ marks: [{ type: 'span', attrs: { '.callout': true, '.bold': true } }],
220
220
  },
221
221
  ]
222
222
  expect(serializeInlineContent(content)).toBe('[important note]{.callout .bold}')
@@ -227,7 +227,7 @@ describe('Inline Content Serialization', () => {
227
227
  {
228
228
  type: 'text',
229
229
  text: 'key term',
230
- marks: [{ type: 'span', attrs: { class: 'highlight', id: 'glossary-term' } }],
230
+ marks: [{ type: 'span', attrs: { '.highlight': true, id: 'glossary-term' } }],
231
231
  },
232
232
  ]
233
233
  expect(serializeInlineContent(content)).toBe('[key term]{.highlight #glossary-term}')
@@ -238,7 +238,7 @@ describe('Inline Content Serialization', () => {
238
238
  {
239
239
  type: 'text',
240
240
  text: 'tooltip text',
241
- marks: [{ type: 'span', attrs: { class: 'info', 'data-tooltip': 'More info' } }],
241
+ marks: [{ type: 'span', attrs: { '.info': true, 'data-tooltip': 'More info' } }],
242
242
  },
243
243
  ]
244
244
  expect(serializeInlineContent(content)).toBe('[tooltip text]{.info data-tooltip="More info"}')
@@ -274,13 +274,13 @@ describe('Inline Content Serialization', () => {
274
274
  {
275
275
  type: 'text',
276
276
  text: 'first',
277
- marks: [{ type: 'span', attrs: { class: 'highlight' } }],
277
+ marks: [{ type: 'span', attrs: { '.highlight': true } }],
278
278
  },
279
279
  { type: 'text', text: ' normal ' },
280
280
  {
281
281
  type: 'text',
282
282
  text: 'second',
283
- marks: [{ type: 'span', attrs: { class: 'muted' } }],
283
+ marks: [{ type: 'span', attrs: { '.muted': true } }],
284
284
  },
285
285
  ]
286
286
  expect(serializeInlineContent(content)).toBe('[first]{.highlight} normal [second]{.muted}')
@@ -297,7 +297,7 @@ describe('Inline Content Serialization', () => {
297
297
  {
298
298
  type: 'text',
299
299
  text: 'span',
300
- marks: [{ type: 'span', attrs: { class: 'highlight' } }],
300
+ marks: [{ type: 'span', attrs: { '.highlight': true } }],
301
301
  },
302
302
  ]
303
303
  expect(serializeInlineContent(content)).toBe(
@@ -242,3 +242,43 @@ describe('blocks inside list items', () => {
242
242
  expect(rt(source)).toBe(source)
243
243
  })
244
244
  })
245
+
246
+ describe('containers — the block form of an inset', () => {
247
+ const rt = md => proseMirrorToMarkdown(markdownToProseMirror(md)).trim()
248
+
249
+ test('a container round-trips with its params', () => {
250
+ const source = '```@Alert{type=warning}\nBe careful.\n```'
251
+ expect(rt(source)).toBe(source)
252
+ })
253
+
254
+ test('a rich body survives — marks, links and blocks', () => {
255
+ const source = '```@Details\nA **summary**\n\nBody with [a link](/x).\n```'
256
+ expect(rt(source)).toBe(source)
257
+ })
258
+
259
+ test('the outer fence widens for a code block inside', () => {
260
+ // What makes a code sample in a callout possible, and why the body
261
+ // cannot be inline-only.
262
+ const source = '````@Alert{type=info}\nDo not:\n\n```js\nconst x = 1\n```\n````'
263
+ expect(rt(source)).toBe(source)
264
+ })
265
+
266
+ test('the editor container shapes normalize onto it', () => {
267
+ const text = t => ({ type: 'text', text: t })
268
+ const doc = node => proseMirrorToMarkdown({ type: 'doc', content: [node] }).trim()
269
+
270
+ // WarningBlock ↔ @Alert, defaulting to info, NOT warning — a name-derived
271
+ // mapping drops exactly this.
272
+ expect(doc({ type: 'WarningBlock', attrs: {}, content: [text('Careful.')] }))
273
+ .toBe('```@Alert{type=info}\nCareful.\n```')
274
+
275
+ // The summary is the first block and keeps its marks.
276
+ expect(doc({
277
+ type: 'details',
278
+ content: [
279
+ { type: 'detailsSummary', content: [text('Breaking '), { type: 'text', text: 'changes', marks: [{ type: 'bold' }] }] },
280
+ { type: 'detailsContent', content: [{ type: 'paragraph', content: [text('Body.')] }] },
281
+ ],
282
+ })).toBe('```@Details\nBreaking **changes**\n\nBody.\n```')
283
+ })
284
+ })
@@ -180,7 +180,7 @@ describe('Image Serialization', () => {
180
180
  alt: 'Logo',
181
181
  caption: null,
182
182
  role: 'image',
183
- class: 'featured',
183
+ '.featured': true,
184
184
  id: 'main-logo',
185
185
  },
186
186
  },
@@ -200,7 +200,7 @@ describe('Image Serialization', () => {
200
200
  alt: 'Gallery',
201
201
  caption: null,
202
202
  role: 'image',
203
- class: 'featured rounded shadow',
203
+ '.featured': true, '.rounded': true, '.shadow': true,
204
204
  },
205
205
  },
206
206
  ],