@plutocms/docs 0.15.0 → 0.16.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.16.0](https://github.com/plutocms/docs/compare/v0.15.0...v0.16.0) (2026-10-31)
4
+
5
+
6
+ ### Features
7
+
8
+ * block sources and component meta mapping for the editor ([#75](https://github.com/plutocms/docs/issues/75)) ([cc04cf0](https://github.com/plutocms/docs/commit/cc04cf044112b6d4432817711c1e8ed34cce1b7e))
9
+ * discover editor blocks from definePlutoBlock in components/content ([#78](https://github.com/plutocms/docs/issues/78)) ([0ce82b9](https://github.com/plutocms/docs/commit/0ce82b9672b276c6b50edf6f647fd42490595c84))
10
+
3
11
  ## [0.15.0](https://github.com/plutocms/nuxt-content/compare/v0.14.0...v0.15.0) (2026-10-31)
4
12
 
5
13
 
package/FEATURES.md CHANGED
@@ -26,5 +26,9 @@ policies shared by every project here.
26
26
  - Block model: the pure model of the visual block editor (parse, serialize, edit, history,
27
27
  presets, registry, preview protocol). The text of an unedited block stays byte for byte. See
28
28
  @.claude/skills/block-model/SKILL.md.
29
+ - Block discovery: a component in `components/content/` (the app or a layer) that calls
30
+ `definePlutoBlock({...})` becomes a block in the palette, with its props and slots read from its
31
+ TypeScript types. Opt-in. The option `plutoDocs.blocks.discover` turns it off. See
32
+ @.claude/skills/block-model/SKILL.md.
29
33
  - Block editor: the visual editor for the page body. A live preview, the block list, the palette, and
30
34
  the controls of the selected block, with no raw MDC. See @.claude/skills/block-editor/SKILL.md.
package/README.md CHANGED
@@ -31,6 +31,7 @@ setup: the collections, the CSS entry, `PlutoRoot`, and the GitHub App.
31
31
 
32
32
  - A `docs` page type for nested, numbered Markdown pages.
33
33
  - A visual block editor with a live preview, and a Markdown mode.
34
+ - Your own components in the palette: call `definePlutoBlock({ label, icon })` in a component of `components/content/`. The props and slots come from its TypeScript types.
34
35
  - Hybrid render mode (default): a save shows on the site at once, with no wait for a build. Build and runtime modes are options.
35
36
  - "New Page" and "Edit Page" in the admin bar, and an "Edit in admin" link for the public page.
36
37
  - The pages on the public API of Pluto (`/api/pluto/v1/content/<type>`), for a site in any stack.
@@ -1,4 +1,6 @@
1
1
  <script setup lang="ts">
2
+ // The component layer. Only the two editor roots import it, so it stays out of the public pages.
3
+ import componentBlocks from '#pluto-docs/blocks'
2
4
  // The visual block editor: the canvas of the 'canvas' form layout. The canvas
3
5
  // shows the live preview of the page. The block list goes in the left panel,
4
6
  // and the controls of the selected block go in the right panel (a tab of the
@@ -24,12 +26,12 @@ const emit = defineEmits<{
24
26
 
25
27
  const { t } = usePlutoI18n()
26
28
  const form = usePlutoContentForm()
27
- const blocksConfig = usePlutoBlocksConfig()
29
+ const blockSources = usePlutoBlockSources(componentBlocks)
28
30
 
29
31
  const editor = createPlutoBlockEditor({
30
32
  source: () => props.modelValue,
31
33
  emit: (value) => emit('update:modelValue', value),
32
- overrides: blocksConfig,
34
+ sources: blockSources,
33
35
  })
34
36
  provide(plutoBlockEditorKey, editor)
35
37
 
@@ -9,6 +9,8 @@
9
9
  // here: the block model only feeds the preview and never writes.
10
10
  import type { SplitterItem } from '@nuxt/ui'
11
11
  import type { PlutoMarkdownCodeEditor as CodeEditor } from '#components'
12
+ // The component layer. Only the two editor roots import it, so it stays out of the public pages.
13
+ import componentBlocks from '#pluto-docs/blocks'
12
14
  import { findBlock } from '../../../../../shared/utils/blocks/tree'
13
15
  import PlutoMdcInsertMenu from '../PlutoMdcInsertMenu.vue'
14
16
  import PlutoBlockModeSwitch from './PlutoBlockModeSwitch.vue'
@@ -28,12 +30,12 @@ const emit = defineEmits<{
28
30
  }>()
29
31
 
30
32
  const form = usePlutoContentForm()
31
- const blocksConfig = usePlutoBlocksConfig()
33
+ const blockSources = usePlutoBlockSources(componentBlocks)
32
34
 
33
35
  const editor = createPlutoBlockEditor({
34
36
  source: () => props.modelValue,
35
37
  emit: () => {},
36
- overrides: blocksConfig,
38
+ sources: blockSources,
37
39
  })
38
40
  provide(plutoBlockEditorKey, editor)
39
41
 
@@ -13,7 +13,6 @@ import { findBlock } from '../../../../../shared/utils/blocks/tree'
13
13
 
14
14
  const editor = usePlutoBlockEditor()
15
15
  const { t, tx } = usePlutoI18n()
16
- const blocksConfig = usePlutoBlocksConfig()
17
16
 
18
17
  const open = computed({
19
18
  get: () => editor.palette.value.open,
@@ -43,19 +42,21 @@ function findBlockById(id: string) {
43
42
  }
44
43
 
45
44
  const groups = computed<CommandPaletteGroup[]>(() =>
46
- buildPalette(blocksConfig(), accepts.value).map(({ group, entries }) => ({
47
- id: group,
48
- label: tx(groupLabelKey(group)),
49
- items: entries.map((entry): CommandPaletteItem => ({
50
- label: tx(entry.label),
51
- suffix: entry.description ? tx(entry.description) : undefined,
52
- icon: entry.icon,
53
- onSelect: () => {
54
- editor.insert(entry, editor.palette.value.target)
55
- open.value = false
56
- },
57
- })),
58
- }))
45
+ buildPalette(editor.sources.value, accepts.value).map(
46
+ ({ group, entries }) => ({
47
+ id: group,
48
+ label: tx(groupLabelKey(group)),
49
+ items: entries.map((entry): CommandPaletteItem => ({
50
+ label: tx(entry.label),
51
+ suffix: entry.description ? tx(entry.description) : undefined,
52
+ icon: entry.icon,
53
+ onSelect: () => {
54
+ editor.insert(entry, editor.palette.value.target)
55
+ open.value = false
56
+ },
57
+ })),
58
+ })
59
+ )
59
60
  )
60
61
  </script>
61
62
 
@@ -64,7 +65,7 @@ const groups = computed<CommandPaletteGroup[]>(() =>
64
65
  v-model:open="open"
65
66
  :title="t('docs.editor.addBlock')"
66
67
  :description="t('docs.editor.addBlockDescription')"
67
- :ui="{ content: 'max-w-xl', header: 'sr-only' }"
68
+ :ui="{ content: 'pluto-palette max-w-xl', header: 'sr-only' }"
68
69
  >
69
70
  <template #content>
70
71
  <UCommandPalette
@@ -22,7 +22,6 @@ const props = defineProps<{
22
22
  const editor = usePlutoBlockEditor()
23
23
  const { t, tx } = usePlutoI18n()
24
24
  const router = useRouter()
25
- const blocksConfig = usePlutoBlocksConfig()
26
25
 
27
26
  const DEBOUNCE_MS = 150
28
27
  const LOAD_TIMEOUT_MS = 12000
@@ -40,7 +39,7 @@ const shownId = computed(() =>
40
39
  previewSelectionId(editor.doc.value, editor.selectedId.value)
41
40
  )
42
41
  const blocks = computed(() =>
43
- toPreviewBlocks(editor.doc.value.blocks, blocksConfig())
42
+ toPreviewBlocks(editor.doc.value.blocks, editor.sources.value)
44
43
  )
45
44
 
46
45
  function send(message: ReturnType<typeof previewMessage>) {
@@ -62,12 +62,27 @@ function setText(value: string | undefined) {
62
62
  )
63
63
  }
64
64
 
65
+ // The default of the component, as a text for a placeholder.
66
+ const defaultText = computed(() =>
67
+ props.definition.default === undefined
68
+ ? undefined
69
+ : String(props.definition.default)
70
+ )
71
+
72
+ const switchOn = computed(() =>
73
+ props.modelValue === undefined
74
+ ? props.definition.default === true
75
+ : props.modelValue === true || props.modelValue === 'true'
76
+ )
77
+
65
78
  function setBoolean(value: boolean) {
66
- // Off removes the prop. An explicit `false` in the source stays false.
67
- emit(
68
- 'update:modelValue',
69
- value ? true : props.modelValue === false ? false : undefined
70
- )
79
+ // The state of the default removes the prop. An explicit value in the source stays explicit.
80
+ const fallback = props.definition.default === true
81
+ if (value === fallback) {
82
+ emit('update:modelValue', props.modelValue === value ? value : undefined)
83
+ } else {
84
+ emit('update:modelValue', value)
85
+ }
71
86
  }
72
87
 
73
88
  const numberValue = computed(() => {
@@ -90,7 +105,12 @@ const selectItems = computed(() => {
90
105
  if (current && !items.some((item) => item.value === current)) {
91
106
  items.push({ value: current, label: current })
92
107
  }
93
- return [{ value: DEFAULT, label: t('docs.editor.default') }, ...items]
108
+ // "Default (slow)": the item shows the value that the component uses.
109
+ const fallback = items.find((item) => item.value === defaultText.value)
110
+ const defaultLabel = defaultText.value
111
+ ? `${t('docs.editor.default')} (${fallback?.label ?? defaultText.value})`
112
+ : t('docs.editor.default')
113
+ return [{ value: DEFAULT, label: defaultLabel }, ...items]
94
114
  })
95
115
 
96
116
  const colorItems = computed(() =>
@@ -156,7 +176,7 @@ const iconPreview = computed(() =>
156
176
  >
157
177
  <USwitch
158
178
  v-if="type === 'boolean'"
159
- :model-value="modelValue === true || modelValue === 'true'"
179
+ :model-value="switchOn"
160
180
  :disabled="disabled"
161
181
  :aria-label="label"
162
182
  @update:model-value="setBoolean($event)"
@@ -165,6 +185,7 @@ const iconPreview = computed(() =>
165
185
  <UInputNumber
166
186
  v-else-if="type === 'number'"
167
187
  :model-value="numberValue"
188
+ :placeholder="defaultText"
168
189
  :disabled="disabled"
169
190
  class="w-full"
170
191
  @update:model-value="emit('update:modelValue', $event ?? undefined)"
@@ -193,6 +214,7 @@ const iconPreview = computed(() =>
193
214
  <UTextarea
194
215
  v-else-if="type === 'text'"
195
216
  :model-value="text"
217
+ :placeholder="defaultText"
196
218
  :disabled="disabled"
197
219
  :rows="2"
198
220
  class="w-full"
@@ -217,7 +239,7 @@ const iconPreview = computed(() =>
217
239
  :model-value="text"
218
240
  :disabled="disabled"
219
241
  :icon="iconPreview"
220
- placeholder="i-lucide-rocket"
242
+ :placeholder="defaultText ?? 'i-lucide-rocket'"
221
243
  spellcheck="false"
222
244
  autocomplete="off"
223
245
  class="w-full"
@@ -228,7 +250,7 @@ const iconPreview = computed(() =>
228
250
  v-else-if="type === 'link'"
229
251
  :model-value="text"
230
252
  :disabled="disabled"
231
- :placeholder="t('docs.editor.linkPlaceholder')"
253
+ :placeholder="defaultText ?? t('docs.editor.linkPlaceholder')"
232
254
  icon="i-lucide-link"
233
255
  autocomplete="off"
234
256
  class="w-full"
@@ -238,6 +260,7 @@ const iconPreview = computed(() =>
238
260
  <UInput
239
261
  v-else
240
262
  :model-value="text"
263
+ :placeholder="defaultText"
241
264
  :disabled="disabled"
242
265
  class="w-full"
243
266
  @update:model-value="setText($event as string)"
@@ -8,6 +8,7 @@ import type {
8
8
  PlutoBlockDoc,
9
9
  PlutoBlockLocation,
10
10
  PlutoBlocksConfig,
11
+ PlutoBlockSources,
11
12
  PlutoCodeBlock,
12
13
  PlutoPropValue,
13
14
  } from '../../shared/utils/blocks/types'
@@ -53,12 +54,14 @@ export interface PlutoBlockEditorOptions {
53
54
  source: () => string
54
55
  /** Called with the new text after each edit, undo and redo. Never called at the start. */
55
56
  emit: (value: string) => void
56
- /** The `plutoBlocks` value of the app config. */
57
- overrides?: () => PlutoBlocksConfig | undefined
57
+ /** The layers of the block definitions: the component layer and the `plutoBlocks` of the app config. */
58
+ sources?: () => PlutoBlockSources | undefined
58
59
  now?: () => number
59
60
  }
60
61
 
61
62
  export interface PlutoBlockEditor {
63
+ /** The layers of the block definitions. The palette, the preview and the labels read them here. */
64
+ sources: ComputedRef<PlutoBlockSources>
62
65
  doc: ComputedRef<PlutoBlockDoc>
63
66
  /** The message of a parse error. The editor then offers the Markdown mode only. */
64
67
  error: Ref<string | null>
@@ -156,7 +159,7 @@ export function createPlutoBlockEditor(
156
159
  options: PlutoBlockEditorOptions
157
160
  ): PlutoBlockEditor {
158
161
  const now = options.now ?? Date.now
159
- const overrides = () => options.overrides?.() ?? {}
162
+ const sources = computed(() => options.sources?.() ?? {})
160
163
 
161
164
  const initial = tryParse(options.source())
162
165
  const error = ref<string | null>(initial.error)
@@ -244,7 +247,7 @@ export function createPlutoBlockEditor(
244
247
 
245
248
  function definitionOf(block: PlutoBlock): PlutoBlockDefinition | undefined {
246
249
  return block.kind === 'component'
247
- ? resolveBlockDefinition(block.name, { overrides: overrides(), block })
250
+ ? resolveBlockDefinition(block.name, { sources: sources.value, block })
248
251
  : undefined
249
252
  }
250
253
 
@@ -332,6 +335,7 @@ export function createPlutoBlockEditor(
332
335
  }
333
336
 
334
337
  return {
338
+ sources,
335
339
  doc,
336
340
  error,
337
341
  selection,
@@ -378,7 +382,7 @@ export function createPlutoBlockEditor(
378
382
  }
379
383
  },
380
384
  definitionOf,
381
- describe: (block) => describeBlock(block, overrides()),
385
+ describe: (block) => describeBlock(block, sources.value),
382
386
  siblings,
383
387
  }
384
388
  }
@@ -401,10 +405,22 @@ export function usePlutoBlocksConfig(): () => PlutoBlocksConfig | undefined {
401
405
  return () => appConfig.plutoBlocks as unknown as PlutoBlocksConfig | undefined
402
406
  }
403
407
 
408
+ /**
409
+ * The layers of the block definitions for a block editor: the component layer and the
410
+ * `plutoBlocks` value of the app config. The caller passes the component layer in: the two editor
411
+ * roots import it from `#pluto-docs/blocks`, so the data stays in the lazy editor chunk.
412
+ */
413
+ export function usePlutoBlockSources(
414
+ components: PlutoBlocksConfig = {}
415
+ ): () => PlutoBlockSources {
416
+ const blocksConfig = usePlutoBlocksConfig()
417
+ return () => ({ components, overrides: blocksConfig() })
418
+ }
419
+
404
420
  /** The texts of the slots of a component: the title of a slot, and the label of its "Add" button. */
405
421
  export function usePlutoBlockLabels() {
406
422
  const { t, tx } = usePlutoI18n()
407
- const blocksConfig = usePlutoBlocksConfig()
423
+ const editor = usePlutoBlockEditor()
408
424
 
409
425
  function slotLabel(
410
426
  definition: PlutoBlockDefinition | undefined,
@@ -428,7 +444,7 @@ export function usePlutoBlockLabels() {
428
444
  if (accepts?.length === 1) {
429
445
  return t('docs.editor.addItem', {
430
446
  name: tx(
431
- resolveBlockDefinition(accepts[0]!, { overrides: blocksConfig() })
447
+ resolveBlockDefinition(accepts[0]!, { sources: editor.sources.value })
432
448
  .label
433
449
  ),
434
450
  })
@@ -0,0 +1,217 @@
1
+ // Find the block components of a site and read their definitions at build time.
2
+ // A component is a block only when its file calls `definePlutoBlock(...)`. The props and slots come
3
+ // from the TypeScript meta (`nuxt-component-meta`), the label, icon and more from the call.
4
+ // This file has no Nuxt import, so plain Vitest tests it.
5
+
6
+ import type { PlutoComponentMeta } from '../../shared/utils/blocks/component-meta'
7
+ import type {
8
+ PlutoBlockPatch,
9
+ PlutoBlocksConfig,
10
+ } from '../../shared/utils/blocks/types'
11
+ import { createHash } from 'node:crypto'
12
+ import { readdirSync, readFileSync } from 'node:fs'
13
+ import { basename, join } from 'node:path'
14
+ import { blockFromComponentMeta } from '../../shared/utils/blocks/component-meta'
15
+
16
+ /** The name of the macro. A component file calls it in `<script setup>`. */
17
+ export const BLOCK_MACRO = 'definePlutoBlock'
18
+
19
+ /** The key of the macro argument in the output of the parser. */
20
+ export const MACRO_KEY = 'pluto'
21
+
22
+ /** The folder of the components that Nuxt Content renders, in the app and in each layer. */
23
+ export const CONTENT_COMPONENTS_DIR = 'components/content'
24
+
25
+ export interface BlockFile {
26
+ /** The component name in Pascal case: `PlutoHero`. */
27
+ name: string
28
+ file: string
29
+ }
30
+
31
+ /** The output of the parser, with the macro argument under `pluto`. */
32
+ export type ParsedComponent = PlutoComponentMeta & {
33
+ [MACRO_KEY]?: PlutoBlockPatch
34
+ }
35
+
36
+ export type MetaReader = (file: string) => ParsedComponent
37
+
38
+ export interface BlockEntry {
39
+ /** The hash of the file text. The entry is valid while the hash is the same. */
40
+ hash: string
41
+ block?: { name: string; definition: PlutoBlockPatch }
42
+ warnings: string[]
43
+ /** The time of the parse, in milliseconds. 0 when the entry came from the cache. */
44
+ ms: number
45
+ }
46
+
47
+ export interface BlockLog {
48
+ warn: (message: string) => void
49
+ debug: (message: string) => void
50
+ }
51
+
52
+ /** Whether the file text calls the macro: `definePlutoBlock(...)` or `definePlutoBlock<Props>(...)`. */
53
+ export function hasBlockMacro(text: string): boolean {
54
+ return /\bdefinePlutoBlock\s*[(<]/.test(text)
55
+ }
56
+
57
+ /**
58
+ * The component files of the `components/content` folders. The folders come in order of priority
59
+ * (the app first, then its layers). When two files have the same name, the first one wins. A
60
+ * `Prose*` file is a prose override, an `.client` or `.server` file is a variant: none is a block.
61
+ */
62
+ export function findContentComponents(dirs: string[]): BlockFile[] {
63
+ const found = new Map<string, BlockFile>()
64
+ const visit = (dir: string) => {
65
+ let entries
66
+ try {
67
+ entries = readdirSync(dir, { withFileTypes: true })
68
+ } catch {
69
+ return
70
+ }
71
+ for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
72
+ const path = join(dir, entry.name)
73
+ if (entry.isDirectory()) {
74
+ visit(path)
75
+ continue
76
+ }
77
+ if (!entry.name.endsWith('.vue')) {
78
+ continue
79
+ }
80
+ const name = basename(entry.name, '.vue')
81
+ if (
82
+ name.startsWith('Prose') ||
83
+ name.endsWith('.client') ||
84
+ name.endsWith('.server') ||
85
+ found.has(name)
86
+ ) {
87
+ continue
88
+ }
89
+ found.set(name, { name, file: path })
90
+ }
91
+ }
92
+ for (const dir of dirs) {
93
+ visit(dir)
94
+ }
95
+ return [...found.values()]
96
+ }
97
+
98
+ function hashOf(text: string): string {
99
+ return createHash('sha1').update(text).digest('hex')
100
+ }
101
+
102
+ /**
103
+ * Read the block of one component file. A file without the macro gives an entry with no block, and
104
+ * the parser does not run. A parse error gives a warning and no block. A macro that the parser
105
+ * cannot evaluate (for example, an argument that uses a local variable) gives a warning and no
106
+ * block: a half-read definition would hide the mistake.
107
+ */
108
+ export function readBlockEntry(
109
+ { name, file }: BlockFile,
110
+ text: string,
111
+ read: MetaReader,
112
+ now: () => number = Date.now
113
+ ): BlockEntry {
114
+ const hash = hashOf(text)
115
+ if (!hasBlockMacro(text)) {
116
+ return { hash, warnings: [], ms: 0 }
117
+ }
118
+ const start = now()
119
+ let parsed: ParsedComponent
120
+ try {
121
+ parsed = read(file)
122
+ } catch (error) {
123
+ return {
124
+ hash,
125
+ warnings: [
126
+ `${BLOCK_MACRO} in ${file} was skipped. The component did not parse: ${error instanceof Error ? error.message : String(error)}`,
127
+ ],
128
+ ms: now() - start,
129
+ }
130
+ }
131
+ const ms = now() - start
132
+ const macro = parsed[MACRO_KEY]
133
+ if (!macro) {
134
+ return {
135
+ hash,
136
+ warnings: [
137
+ `${BLOCK_MACRO} in ${file} was skipped. Its argument must be an object literal that does not use a local variable. It can use a constant imported from a file.`,
138
+ ],
139
+ ms,
140
+ }
141
+ }
142
+ // The key is not a block field. The meta is only props and slots.
143
+ const { [MACRO_KEY]: _macro, ...meta } = parsed
144
+ return {
145
+ hash,
146
+ block: blockFromComponentMeta(name, pickMeta(meta), macro),
147
+ warnings: [],
148
+ ms,
149
+ }
150
+ }
151
+
152
+ function pickMeta(meta: ParsedComponent): PlutoComponentMeta {
153
+ return { props: meta.props, slots: meta.slots }
154
+ }
155
+
156
+ /**
157
+ * The component layer of a site: the blocks of the component files, with a cache by file. A file
158
+ * is parsed again only when its text changed. Call `collect` for each build and each hot update.
159
+ */
160
+ export class BlockCatalog {
161
+ private cache = new Map<string, BlockEntry>()
162
+
163
+ constructor(
164
+ private dirs: () => string[],
165
+ private read: MetaReader,
166
+ private log: BlockLog,
167
+ private now: () => number = Date.now
168
+ ) {}
169
+
170
+ collect(): PlutoBlocksConfig {
171
+ const start = this.now()
172
+ const files = findContentComponents(this.dirs())
173
+ const result: PlutoBlocksConfig = {}
174
+ const seen = new Set<string>()
175
+ let parsed = 0
176
+ for (const item of files) {
177
+ seen.add(item.file)
178
+ let text: string
179
+ try {
180
+ text = readFileSync(item.file, 'utf8')
181
+ } catch {
182
+ continue
183
+ }
184
+ let entry = this.cache.get(item.file)
185
+ if (!entry || entry.hash !== hashOf(text)) {
186
+ entry = readBlockEntry(item, text, this.read, this.now)
187
+ this.cache.set(item.file, entry)
188
+ for (const warning of entry.warnings) {
189
+ this.log.warn(warning)
190
+ }
191
+ if (entry.block) {
192
+ parsed++
193
+ this.log.debug(`${item.name}: ${entry.ms} ms`)
194
+ }
195
+ }
196
+ if (entry.block) {
197
+ result[entry.block.name] = entry.block.definition
198
+ }
199
+ }
200
+ for (const file of this.cache.keys()) {
201
+ if (!seen.has(file)) {
202
+ this.cache.delete(file)
203
+ }
204
+ }
205
+ if (parsed > 0) {
206
+ this.log.debug(
207
+ `Read ${parsed} block component(s) in ${this.now() - start} ms.`
208
+ )
209
+ }
210
+ return result
211
+ }
212
+ }
213
+
214
+ /** The source of the Vite virtual module for a component layer. */
215
+ export function blocksModuleSource(blocks: PlutoBlocksConfig): string {
216
+ return `export default ${JSON.stringify(blocks)}\n`
217
+ }