@velumo/compiler 0.1.0-beta.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/src/index.ts ADDED
@@ -0,0 +1,730 @@
1
+ import { createHash } from "node:crypto";
2
+ import { access, mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import { dirname, extname, isAbsolute, relative, resolve } from "node:path";
4
+ import { pathToFileURL } from "node:url";
5
+ import ts from "typescript";
6
+ import type {
7
+ Asset,
8
+ VelumoConfig,
9
+ Fragment,
10
+ Layer,
11
+ Manifest,
12
+ SlideEntry,
13
+ } from "@velumo/core";
14
+ import { deck, markdown, scene, slide, validateManifest } from "@velumo/core";
15
+
16
+ export class DeckCompilerError extends Error {
17
+ constructor(message: string) {
18
+ super(message);
19
+ this.name = "DeckCompilerError";
20
+ }
21
+ }
22
+
23
+ export interface DeckValidationResult {
24
+ readonly valid: boolean;
25
+ readonly errors: readonly string[];
26
+ readonly warnings: readonly string[];
27
+ }
28
+
29
+ export interface LoadedVelumoConfig {
30
+ readonly configPath: string;
31
+ readonly projectRoot: string;
32
+ readonly config: VelumoConfig;
33
+ readonly entryPath?: string;
34
+ readonly outDir?: string;
35
+ }
36
+
37
+ function compilerCacheRoot(): string {
38
+ return resolve(process.cwd(), "node_modules", ".cache", "velumo", "compiler");
39
+ }
40
+
41
+ function cachePathForEntry(entryPath: string, source: string): string {
42
+ const hash = createHash("sha256")
43
+ .update(entryPath)
44
+ .update("\0")
45
+ .update(source)
46
+ .digest("hex");
47
+
48
+ return resolve(compilerCacheRoot(), `${hash}.mjs`);
49
+ }
50
+
51
+ function transpileEntry(entryPath: string, source: string): string {
52
+ const output = ts.transpileModule(source, {
53
+ fileName: entryPath,
54
+ compilerOptions: {
55
+ target: ts.ScriptTarget.ES2022,
56
+ module: ts.ModuleKind.ES2022,
57
+ moduleResolution: ts.ModuleResolutionKind.NodeNext,
58
+ esModuleInterop: true,
59
+ strict: true,
60
+ },
61
+ });
62
+
63
+ return output.outputText;
64
+ }
65
+
66
+ function selectDeckExport(moduleExports: Record<string, unknown>): unknown {
67
+ if (Object.hasOwn(moduleExports, "default")) {
68
+ return moduleExports.default;
69
+ }
70
+
71
+ return moduleExports.deck;
72
+ }
73
+
74
+ function selectConfigExport(moduleExports: Record<string, unknown>): unknown {
75
+ return Object.hasOwn(moduleExports, "default")
76
+ ? moduleExports.default
77
+ : undefined;
78
+ }
79
+
80
+ async function importTranspiledModule(
81
+ entryPath: string,
82
+ source: string,
83
+ ): Promise<Record<string, unknown>> {
84
+ const compiledPath = cachePathForEntry(entryPath, source);
85
+ await mkdir(dirname(compiledPath), { recursive: true });
86
+ await writeFile(compiledPath, transpileEntry(entryPath, source));
87
+
88
+ return import(pathToFileURL(compiledPath).href) as Promise<
89
+ Record<string, unknown>
90
+ >;
91
+ }
92
+
93
+ function isPreservedAssetSource(src: string): boolean {
94
+ return (
95
+ isAbsolute(src) ||
96
+ src.startsWith("/") ||
97
+ src.startsWith("http://") ||
98
+ src.startsWith("https://") ||
99
+ src.startsWith("data:") ||
100
+ src.startsWith("blob:")
101
+ );
102
+ }
103
+
104
+ function resolveAssetSource(entryPath: string, asset: Asset): Asset {
105
+ if (isPreservedAssetSource(asset.src)) {
106
+ return asset;
107
+ }
108
+
109
+ return {
110
+ ...asset,
111
+ src: resolve(dirname(entryPath), asset.src),
112
+ };
113
+ }
114
+
115
+ function resolveRelativeAssets(
116
+ manifest: Manifest,
117
+ entryPath: string,
118
+ ): Manifest {
119
+ if (manifest.assets === undefined) {
120
+ return manifest;
121
+ }
122
+
123
+ return {
124
+ ...manifest,
125
+ assets: manifest.assets.map((asset) =>
126
+ resolveAssetSource(entryPath, asset),
127
+ ),
128
+ };
129
+ }
130
+
131
+ export async function compileDeck(entryPath: string): Promise<Manifest> {
132
+ const resolvedEntryPath = resolve(entryPath);
133
+ let source: string;
134
+
135
+ try {
136
+ source = await readFile(resolvedEntryPath, "utf8");
137
+ } catch {
138
+ throw new DeckCompilerError(`deck entry not found: ${resolvedEntryPath}`);
139
+ }
140
+
141
+ const moduleExports = await importTranspiledModule(resolvedEntryPath, source);
142
+ const candidate = selectDeckExport(moduleExports);
143
+ const validation = validateManifest(candidate);
144
+
145
+ if (!validation.valid) {
146
+ throw new DeckCompilerError(
147
+ `invalid deck export: ${validation.errors.join("; ")}`,
148
+ );
149
+ }
150
+
151
+ return resolveRelativeAssets(candidate as Manifest, resolvedEntryPath);
152
+ }
153
+
154
+ export async function loadConfig(
155
+ configPath: string,
156
+ ): Promise<LoadedVelumoConfig> {
157
+ const resolvedConfigPath = resolve(configPath);
158
+ let source: string;
159
+
160
+ try {
161
+ source = await readFile(resolvedConfigPath, "utf8");
162
+ } catch {
163
+ throw new DeckCompilerError(`config not found: ${resolvedConfigPath}`);
164
+ }
165
+
166
+ const moduleExports = await importTranspiledModule(
167
+ resolvedConfigPath,
168
+ source,
169
+ );
170
+ const candidate = selectConfigExport(moduleExports);
171
+
172
+ if (!isRecord(candidate)) {
173
+ throw new DeckCompilerError(
174
+ `invalid config default export: ${resolvedConfigPath}`,
175
+ );
176
+ }
177
+
178
+ const projectRoot = dirname(resolvedConfigPath);
179
+ const entry = candidate.entry;
180
+ const outDir = candidate.outDir;
181
+ const runtime = candidate.runtime;
182
+ const exportOptions = candidate.export;
183
+
184
+ if (entry !== undefined && typeof entry !== "string") {
185
+ throw new DeckCompilerError(
186
+ `invalid config field "entry" in ${resolvedConfigPath}`,
187
+ );
188
+ }
189
+
190
+ if (outDir !== undefined && typeof outDir !== "string") {
191
+ throw new DeckCompilerError(
192
+ `invalid config field "outDir" in ${resolvedConfigPath}`,
193
+ );
194
+ }
195
+
196
+ if (runtime !== undefined && !isRecord(runtime)) {
197
+ throw new DeckCompilerError(
198
+ `invalid config field "runtime" in ${resolvedConfigPath}`,
199
+ );
200
+ }
201
+
202
+ if (exportOptions !== undefined && !isRecord(exportOptions)) {
203
+ throw new DeckCompilerError(
204
+ `invalid config field "export" in ${resolvedConfigPath}`,
205
+ );
206
+ }
207
+
208
+ return {
209
+ configPath: resolvedConfigPath,
210
+ projectRoot,
211
+ config: candidate as VelumoConfig,
212
+ ...(entry === undefined ? {} : { entryPath: resolve(projectRoot, entry) }),
213
+ ...(outDir === undefined ? {} : { outDir: resolve(projectRoot, outDir) }),
214
+ };
215
+ }
216
+
217
+ const knownLayerTypes = new Set([
218
+ "html",
219
+ "markdown",
220
+ "canvas2d",
221
+ "three",
222
+ "hud",
223
+ "fx",
224
+ "audio",
225
+ "video",
226
+ "debug",
227
+ ]);
228
+
229
+ function isRecord(value: unknown): value is Record<string, unknown> {
230
+ return typeof value === "object" && value !== null && !Array.isArray(value);
231
+ }
232
+
233
+ function isPathInside(parentPath: string, candidatePath: string): boolean {
234
+ const relativePath = relative(parentPath, candidatePath);
235
+
236
+ return (
237
+ relativePath === "" ||
238
+ (!relativePath.startsWith("..") && !isAbsolute(relativePath))
239
+ );
240
+ }
241
+
242
+ function isSkippedAssetSource(src: string, entryPath: string): boolean {
243
+ return (
244
+ src.startsWith("http://") ||
245
+ src.startsWith("https://") ||
246
+ src.startsWith("data:") ||
247
+ src.startsWith("blob:") ||
248
+ (src.startsWith("/") && !isPathInside(dirname(entryPath), src))
249
+ );
250
+ }
251
+
252
+ async function loadManifestForValidation(entryPath: string): Promise<Manifest> {
253
+ const extension = extname(entryPath);
254
+
255
+ if (extension === ".ts" || extension === ".tsx") {
256
+ return compileDeck(entryPath);
257
+ }
258
+
259
+ if (extension === ".md" || extension === ".markdown") {
260
+ return compileMarkdownDeck(entryPath);
261
+ }
262
+
263
+ if (extension !== ".json") {
264
+ throw new DeckCompilerError(
265
+ `unsupported deck entry extension: ${extension}`,
266
+ );
267
+ }
268
+
269
+ try {
270
+ return JSON.parse(await readFile(entryPath, "utf8")) as Manifest;
271
+ } catch (error) {
272
+ if (error instanceof SyntaxError) {
273
+ throw new DeckCompilerError(`invalid JSON manifest: ${entryPath}`);
274
+ }
275
+
276
+ throw new DeckCompilerError(`deck entry not found: ${entryPath}`);
277
+ }
278
+ }
279
+
280
+ function collectValidationWarnings(manifest: Manifest): string[] {
281
+ const warnings: string[] = [];
282
+
283
+ if (!Array.isArray(manifest.slides)) {
284
+ return warnings;
285
+ }
286
+
287
+ manifest.slides.forEach((slideEntry, slideIndex) => {
288
+ if (!isRecord(slideEntry) || !Array.isArray(slideEntry.layers)) {
289
+ return;
290
+ }
291
+
292
+ slideEntry.layers?.forEach((layerEntry, layerIndex) => {
293
+ if (!isRecord(layerEntry) || typeof layerEntry.type !== "string") {
294
+ return;
295
+ }
296
+
297
+ if (!knownLayerTypes.has(layerEntry.type)) {
298
+ warnings.push(
299
+ `unknown layer type: ${layerEntry.type} at slides[${slideIndex}].layers[${layerIndex}]`,
300
+ );
301
+ }
302
+ });
303
+ });
304
+
305
+ return warnings;
306
+ }
307
+
308
+ function collectCompilerValidationErrors(manifest: Manifest): string[] {
309
+ const errors: string[] = [];
310
+ const seenSlideIds = new Set<string>();
311
+
312
+ if (!Array.isArray(manifest.slides)) {
313
+ return errors;
314
+ }
315
+
316
+ if (manifest.slides.length === 0) {
317
+ errors.push("slides must contain at least one slide or scene");
318
+ }
319
+
320
+ manifest.slides.forEach((slideEntry, slideIndex) => {
321
+ if (!isRecord(slideEntry)) {
322
+ return;
323
+ }
324
+
325
+ if (typeof slideEntry.id !== "string") {
326
+ return;
327
+ }
328
+
329
+ if (seenSlideIds.has(slideEntry.id)) {
330
+ errors.push(`duplicate slide id: ${slideEntry.id}`);
331
+ }
332
+
333
+ seenSlideIds.add(slideEntry.id);
334
+
335
+ if (!Array.isArray(slideEntry.cues)) {
336
+ return;
337
+ }
338
+
339
+ slideEntry.cues.forEach((cueEntry, cueIndex) => {
340
+ if (!isRecord(cueEntry)) {
341
+ return;
342
+ }
343
+
344
+ if (
345
+ typeof cueEntry.time !== "number" ||
346
+ !Number.isFinite(cueEntry.time)
347
+ ) {
348
+ errors.push(
349
+ `slides[${slideIndex}].cues[${cueIndex}].time must be a finite number`,
350
+ );
351
+ }
352
+ });
353
+ });
354
+
355
+ return errors;
356
+ }
357
+
358
+ async function collectAssetErrors(
359
+ manifest: Manifest,
360
+ entryPath: string,
361
+ ): Promise<string[]> {
362
+ const errors: string[] = [];
363
+
364
+ if (!Array.isArray(manifest.assets)) {
365
+ return errors;
366
+ }
367
+
368
+ for (const assetEntry of manifest.assets) {
369
+ if (!isRecord(assetEntry) || typeof assetEntry.src !== "string") {
370
+ continue;
371
+ }
372
+
373
+ if (isSkippedAssetSource(assetEntry.src, entryPath)) {
374
+ continue;
375
+ }
376
+
377
+ const assetPath = isAbsolute(assetEntry.src)
378
+ ? assetEntry.src
379
+ : resolve(dirname(entryPath), assetEntry.src);
380
+
381
+ try {
382
+ await access(assetPath);
383
+ } catch {
384
+ errors.push(`missing asset: ${assetEntry.src}`);
385
+ }
386
+ }
387
+
388
+ return errors;
389
+ }
390
+
391
+ export async function validateDeck(
392
+ entryPath: string,
393
+ ): Promise<DeckValidationResult> {
394
+ const resolvedEntryPath = resolve(entryPath);
395
+
396
+ try {
397
+ const manifest = await loadManifestForValidation(resolvedEntryPath);
398
+ const baseline = validateManifest(manifest);
399
+ const errors = [
400
+ ...baseline.errors,
401
+ ...collectCompilerValidationErrors(manifest),
402
+ ...(await collectAssetErrors(manifest, resolvedEntryPath)),
403
+ ];
404
+ const warnings = collectValidationWarnings(manifest);
405
+
406
+ return {
407
+ valid: errors.length === 0,
408
+ errors,
409
+ warnings,
410
+ };
411
+ } catch (error) {
412
+ return {
413
+ valid: false,
414
+ errors: [
415
+ error instanceof Error ? error.message : "unknown validation error",
416
+ ],
417
+ warnings: [],
418
+ };
419
+ }
420
+ }
421
+
422
+ interface Line {
423
+ readonly text: string;
424
+ readonly number: number;
425
+ }
426
+
427
+ interface SlideSection {
428
+ readonly lines: readonly Line[];
429
+ readonly startLine: number;
430
+ }
431
+
432
+ interface DirectiveBlock {
433
+ readonly content: string;
434
+ readonly endIndex: number;
435
+ }
436
+
437
+ function parseFrontmatter(lines: readonly Line[]): {
438
+ readonly metadata: Record<string, string>;
439
+ readonly bodyStartIndex: number;
440
+ } {
441
+ if (lines[0]?.text.trim() !== "---") {
442
+ return { metadata: {}, bodyStartIndex: 0 };
443
+ }
444
+
445
+ const endIndex = lines.findIndex(
446
+ (line, index) => index > 0 && line.text.trim() === "---",
447
+ );
448
+
449
+ if (endIndex === -1) {
450
+ throw new DeckCompilerError("frontmatter starting on line 1 is not closed");
451
+ }
452
+
453
+ return {
454
+ metadata: parseFrontmatterLines(lines.slice(1, endIndex)),
455
+ bodyStartIndex: endIndex + 1,
456
+ };
457
+ }
458
+
459
+ function parseFrontmatterLines(lines: readonly Line[]): Record<string, string> {
460
+ const metadata: Record<string, string> = {};
461
+
462
+ for (const line of lines) {
463
+ const separatorIndex = line.text.indexOf(":");
464
+
465
+ if (separatorIndex > 0) {
466
+ metadata[line.text.slice(0, separatorIndex).trim()] = line.text
467
+ .slice(separatorIndex + 1)
468
+ .trim();
469
+ }
470
+ }
471
+
472
+ return metadata;
473
+ }
474
+
475
+ function splitSlideSections(
476
+ lines: readonly Line[],
477
+ startIndex: number,
478
+ ): readonly SlideSection[] {
479
+ const sections: SlideSection[] = [];
480
+ let sectionLines: Line[] = [];
481
+ let startLine = lines[startIndex]?.number ?? 1;
482
+
483
+ for (const line of lines.slice(startIndex)) {
484
+ if (line.text.trim() === "---") {
485
+ sections.push({ lines: sectionLines, startLine });
486
+ sectionLines = [];
487
+ startLine = line.number + 1;
488
+ continue;
489
+ }
490
+
491
+ sectionLines.push(line);
492
+ }
493
+
494
+ sections.push({ lines: sectionLines, startLine });
495
+ return sections;
496
+ }
497
+
498
+ function trimLines(lines: readonly Line[]): readonly Line[] {
499
+ let start = 0;
500
+ let end = lines.length;
501
+
502
+ while (start < end && lines[start].text.trim() === "") {
503
+ start += 1;
504
+ }
505
+
506
+ while (end > start && lines[end - 1].text.trim() === "") {
507
+ end -= 1;
508
+ }
509
+
510
+ return lines.slice(start, end);
511
+ }
512
+
513
+ function textFromLines(lines: readonly Line[]): string {
514
+ return trimLines(lines)
515
+ .map((line) => line.text)
516
+ .join("\n");
517
+ }
518
+
519
+ function firstHeading(lines: readonly Line[]): Line | undefined {
520
+ return lines.find((line) => /^# (.+)$/.test(line.text));
521
+ }
522
+
523
+ function slugFromHeading(heading: string): string {
524
+ return heading
525
+ .replace(/^#\s+/, "")
526
+ .trim()
527
+ .toLowerCase()
528
+ .replace(/[^a-z0-9]+/g, "-")
529
+ .replace(/^-+|-+$/g, "");
530
+ }
531
+
532
+ function parseDirectiveAttributes(source: string): Record<string, string> {
533
+ const attributes: Record<string, string> = {};
534
+
535
+ for (const match of source.matchAll(/([A-Za-z0-9_-]+)="([^"]*)"/g)) {
536
+ attributes[match[1]] = match[2];
537
+ }
538
+
539
+ return attributes;
540
+ }
541
+
542
+ function readDirectiveBlock(
543
+ lines: readonly Line[],
544
+ startIndex: number,
545
+ name: string,
546
+ ): DirectiveBlock {
547
+ const contentLines: Line[] = [];
548
+
549
+ for (let index = startIndex + 1; index < lines.length; index += 1) {
550
+ if (lines[index].text.trim() === ":::") {
551
+ return {
552
+ content: textFromLines(contentLines),
553
+ endIndex: index,
554
+ };
555
+ }
556
+
557
+ contentLines.push(lines[index]);
558
+ }
559
+
560
+ throw new DeckCompilerError(
561
+ `${name} directive starting on line ${lines[startIndex].number} is not closed`,
562
+ );
563
+ }
564
+
565
+ function parseSceneSection(
566
+ section: SlideSection,
567
+ lines: readonly Line[],
568
+ ): SlideEntry {
569
+ const openingLine = lines[0];
570
+ const attributes = parseDirectiveAttributes(openingLine.text);
571
+ const block = readDirectiveBlock(lines, 0, "scene");
572
+ const contentLines = block.content.split("\n").map((text, index) => ({
573
+ text,
574
+ number: openingLine.number + 1 + index,
575
+ }));
576
+ const heading = firstHeading(contentLines);
577
+
578
+ if (!heading) {
579
+ throw new DeckCompilerError(
580
+ `empty slide starting on line ${section.startLine}`,
581
+ );
582
+ }
583
+
584
+ const content = textFromLines(contentLines);
585
+ const duration =
586
+ attributes.duration === undefined ? undefined : Number(attributes.duration);
587
+
588
+ if (duration !== undefined && !Number.isFinite(duration)) {
589
+ throw new DeckCompilerError(
590
+ `scene duration on line ${openingLine.number} must be a finite number`,
591
+ );
592
+ }
593
+
594
+ return scene(slugFromHeading(heading.text), {
595
+ ...(duration === undefined ? {} : { duration }),
596
+ layers: [markdown(content)],
597
+ });
598
+ }
599
+
600
+ function parseNormalSlide(
601
+ section: SlideSection,
602
+ lines: readonly Line[],
603
+ ): SlideEntry {
604
+ const visibleLines: Line[] = [];
605
+ const extraLayers: Layer[] = [];
606
+ const fragments: Fragment[] = [];
607
+ let notes: string | undefined;
608
+ let fragmentCount = 0;
609
+
610
+ for (let index = 0; index < lines.length; index += 1) {
611
+ const line = lines[index];
612
+ const trimmed = line.text.trim();
613
+
614
+ if (trimmed === ":::notes") {
615
+ const block = readDirectiveBlock(lines, index, "notes");
616
+ notes = block.content;
617
+ index = block.endIndex;
618
+ continue;
619
+ }
620
+
621
+ if (trimmed === ":::fragment") {
622
+ const block = readDirectiveBlock(lines, index, "fragment");
623
+ fragmentCount += 1;
624
+ const id = `fragment-${fragmentCount}`;
625
+ extraLayers.push(markdown(block.content, { id }));
626
+ fragments.push({ id, layerId: id });
627
+ index = block.endIndex;
628
+ continue;
629
+ }
630
+
631
+ if (trimmed.startsWith(":::fx ")) {
632
+ const attributes = parseDirectiveAttributes(trimmed);
633
+ const { type, ...rest } = attributes;
634
+ extraLayers.push({
635
+ type: "fx",
636
+ props: {
637
+ ...(type === undefined ? {} : { effect: type }),
638
+ ...rest,
639
+ },
640
+ });
641
+ continue;
642
+ }
643
+
644
+ visibleLines.push(line);
645
+ }
646
+
647
+ const heading = firstHeading(visibleLines);
648
+ const content = textFromLines(visibleLines);
649
+
650
+ if (!heading || content.trim() === "") {
651
+ throw new DeckCompilerError(
652
+ `empty slide starting on line ${section.startLine}`,
653
+ );
654
+ }
655
+
656
+ return slide(slugFromHeading(heading.text), {
657
+ layers: [markdown(content), ...extraLayers],
658
+ ...(fragments.length === 0 ? {} : { fragments }),
659
+ ...(notes === undefined ? {} : { notes }),
660
+ });
661
+ }
662
+
663
+ function parseSlideSection(section: SlideSection): SlideEntry {
664
+ const lines = trimLines(section.lines);
665
+
666
+ if (lines.length === 0) {
667
+ throw new DeckCompilerError(
668
+ `empty slide starting on line ${section.startLine}`,
669
+ );
670
+ }
671
+
672
+ if (lines[0].text.trim().startsWith(":::scene ")) {
673
+ return parseSceneSection(section, lines);
674
+ }
675
+
676
+ return parseNormalSlide(section, lines);
677
+ }
678
+
679
+ function assertUniqueMarkdownSlideIds(slides: readonly SlideEntry[]): void {
680
+ const seen = new Set<string>();
681
+
682
+ for (const slideEntry of slides) {
683
+ if (seen.has(slideEntry.id)) {
684
+ throw new DeckCompilerError(`duplicate slide id: ${slideEntry.id}`);
685
+ }
686
+
687
+ seen.add(slideEntry.id);
688
+ }
689
+ }
690
+
691
+ export async function compileMarkdownDeck(
692
+ entryPath: string,
693
+ ): Promise<Manifest> {
694
+ const resolvedEntryPath = resolve(entryPath);
695
+ let source: string;
696
+
697
+ try {
698
+ source = await readFile(resolvedEntryPath, "utf8");
699
+ } catch {
700
+ throw new DeckCompilerError(
701
+ `markdown deck entry not found: ${resolvedEntryPath}`,
702
+ );
703
+ }
704
+
705
+ const lines = source.split(/\r?\n/).map((text, index) => ({
706
+ text,
707
+ number: index + 1,
708
+ }));
709
+ const { metadata, bodyStartIndex } = parseFrontmatter(lines);
710
+ const slides = splitSlideSections(lines, bodyStartIndex).map(
711
+ parseSlideSection,
712
+ );
713
+
714
+ assertUniqueMarkdownSlideIds(slides);
715
+
716
+ const manifest = deck({
717
+ title: metadata.title ?? "Untitled Deck",
718
+ slides,
719
+ ...(metadata.theme === undefined ? {} : { theme: { id: metadata.theme } }),
720
+ });
721
+ const validation = validateManifest(manifest);
722
+
723
+ if (!validation.valid) {
724
+ throw new DeckCompilerError(
725
+ `invalid markdown deck: ${validation.errors.join("; ")}`,
726
+ );
727
+ }
728
+
729
+ return manifest;
730
+ }