mulmocast 2.1.29 → 2.1.30

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.
@@ -0,0 +1,127 @@
1
+ import { marked } from "marked";
2
+ import { generateMermaidHtml } from "./mermaid.js";
3
+ // Regex to match mermaid code blocks
4
+ const mermaidBlockRegex = /```mermaid\n([\s\S]*?)```/g;
5
+ // Convert string or string array to markdown string
6
+ const toMarkdownString = (content) => {
7
+ if (Array.isArray(content)) {
8
+ return content.join("\n");
9
+ }
10
+ return content;
11
+ };
12
+ // Replace mermaid code blocks with rendered HTML
13
+ const convertMermaidBlocks = (text) => {
14
+ return text.replace(mermaidBlockRegex, (_match, code) => {
15
+ return generateMermaidHtml(code.trim());
16
+ });
17
+ };
18
+ // Parse markdown content to HTML (with mermaid support)
19
+ const parseMarkdown = async (content) => {
20
+ const text = toMarkdownString(content);
21
+ const textWithMermaidHtml = convertMermaidBlocks(text);
22
+ return await marked.parse(textWithMermaidHtml);
23
+ };
24
+ // Generate header HTML
25
+ const generateHeaderHtml = async (data) => {
26
+ const headerHtml = await parseMarkdown(data);
27
+ return `
28
+ <div class="shrink-0 px-8 py-4 border-b border-gray-200 bg-gray-50">
29
+ <div class="prose prose-lg max-w-none">${headerHtml}</div>
30
+ </div>
31
+ `;
32
+ };
33
+ // Generate sidebar HTML
34
+ const generateSidebarHtml = async (data) => {
35
+ const sidebarHtml = await parseMarkdown(data);
36
+ return `
37
+ <div class="shrink-0 w-56 px-4 py-4 border-r border-gray-200 bg-gray-100 overflow-auto">
38
+ <div class="prose prose-sm max-w-none">${sidebarHtml}</div>
39
+ </div>
40
+ `;
41
+ };
42
+ // Generate row-2 layout HTML (two columns)
43
+ const generateRow2Html = async (data) => {
44
+ const [left, right] = data;
45
+ const leftHtml = await parseMarkdown(left);
46
+ const rightHtml = await parseMarkdown(right);
47
+ return `
48
+ <div class="h-full flex gap-6">
49
+ <div class="flex-1 overflow-auto">
50
+ <div class="prose max-w-none">${leftHtml}</div>
51
+ </div>
52
+ <div class="flex-1 overflow-auto">
53
+ <div class="prose max-w-none">${rightHtml}</div>
54
+ </div>
55
+ </div>
56
+ `;
57
+ };
58
+ // Generate 2x2 grid layout HTML
59
+ const generate2x2Html = async (data) => {
60
+ const [tl, tr, bl, br] = data;
61
+ const [tlHtml, trHtml, blHtml, brHtml] = await Promise.all([parseMarkdown(tl), parseMarkdown(tr), parseMarkdown(bl), parseMarkdown(br)]);
62
+ return `
63
+ <div class="h-full grid grid-cols-2 grid-rows-2 gap-4">
64
+ <div class="overflow-auto p-4 bg-gray-50 rounded-lg">
65
+ <div class="prose prose-sm max-w-none">${tlHtml}</div>
66
+ </div>
67
+ <div class="overflow-auto p-4 bg-gray-50 rounded-lg">
68
+ <div class="prose prose-sm max-w-none">${trHtml}</div>
69
+ </div>
70
+ <div class="overflow-auto p-4 bg-gray-50 rounded-lg">
71
+ <div class="prose prose-sm max-w-none">${blHtml}</div>
72
+ </div>
73
+ <div class="overflow-auto p-4 bg-gray-50 rounded-lg">
74
+ <div class="prose prose-sm max-w-none">${brHtml}</div>
75
+ </div>
76
+ </div>
77
+ `;
78
+ };
79
+ // Generate content HTML (single column)
80
+ const generateContentHtml = async (data) => {
81
+ const contentHtml = await parseMarkdown(data);
82
+ return `<div class="prose max-w-none">${contentHtml}</div>`;
83
+ };
84
+ // Generate Tailwind HTML for layout
85
+ export const generateLayoutHtml = async (md) => {
86
+ const parts = ['<div class="w-full h-full flex flex-col overflow-hidden">'];
87
+ if (md.header) {
88
+ parts.push(await generateHeaderHtml(md.header));
89
+ }
90
+ parts.push('<div class="flex-1 flex min-h-0 overflow-hidden">');
91
+ if (md["sidebar-left"]) {
92
+ parts.push(await generateSidebarHtml(md["sidebar-left"]));
93
+ }
94
+ parts.push('<div class="flex-1 p-6 overflow-auto">');
95
+ if ("row-2" in md) {
96
+ parts.push(await generateRow2Html(md["row-2"]));
97
+ }
98
+ else if ("2x2" in md) {
99
+ parts.push(await generate2x2Html(md["2x2"]));
100
+ }
101
+ else if ("content" in md) {
102
+ parts.push(await generateContentHtml(md.content));
103
+ }
104
+ parts.push("</div>", "</div>", "</div>");
105
+ return parts.join("");
106
+ };
107
+ // Convert layout to plain markdown string
108
+ export const layoutToMarkdown = (md) => {
109
+ const parts = [];
110
+ if (md.header) {
111
+ parts.push(toMarkdownString(md.header));
112
+ }
113
+ if (md["sidebar-left"]) {
114
+ parts.push(toMarkdownString(md["sidebar-left"]));
115
+ }
116
+ if ("row-2" in md) {
117
+ parts.push(...md["row-2"].map(toMarkdownString));
118
+ }
119
+ else if ("2x2" in md) {
120
+ parts.push(...md["2x2"].map(toMarkdownString));
121
+ }
122
+ else if ("content" in md) {
123
+ parts.push(toMarkdownString(md.content));
124
+ }
125
+ return parts.join("\n\n");
126
+ };
127
+ export { toMarkdownString, parseMarkdown };
@@ -1,5 +1,6 @@
1
1
  import { ImageProcessorParams } from "../../types/index.js";
2
2
  export declare const imageType = "mermaid";
3
+ export declare const generateMermaidHtml: (code: string, title?: string) => string;
3
4
  export declare const process: (params: ImageProcessorParams) => Promise<string | undefined>;
4
5
  export declare const path: (params: ImageProcessorParams) => string;
5
6
  export declare const markdown: (params: ImageProcessorParams) => string | undefined;
@@ -1,9 +1,22 @@
1
1
  import { MulmoMediaSourceMethods } from "../../methods/index.js";
2
2
  import { getHTMLFile } from "../file.js";
3
3
  import { renderHTMLToImage, interpolate } from "../html_render.js";
4
- import { parrotingImagePath } from "./utils.js";
5
- import nodeProcess from "node:process";
4
+ import { parrotingImagePath, generateUniqueId } from "./utils.js";
6
5
  export const imageType = "mermaid";
6
+ // Generate mermaid HTML from code string (shared utility)
7
+ export const generateMermaidHtml = (code, title) => {
8
+ const diagramId = generateUniqueId("mermaid");
9
+ const titleHtml = title ? `<h3 class="text-xl font-semibold mb-4">${title}</h3>` : "";
10
+ return `
11
+ <div class="mermaid-container mb-6">
12
+ ${titleHtml}
13
+ <div class="flex justify-center">
14
+ <div id="${diagramId}" class="mermaid">
15
+ ${code.trim()}
16
+ </div>
17
+ </div>
18
+ </div>`;
19
+ };
7
20
  const processMermaid = async (params) => {
8
21
  const { beat, imagePath, canvasSize, context, textSlideStyle } = params;
9
22
  if (!beat?.image || beat.image.type !== imageType)
@@ -38,17 +51,7 @@ const dumpHtml = async (params) => {
38
51
  const title = beat.image.title || "Diagram";
39
52
  const appendix = beat.image.appendix?.join("\n") || "";
40
53
  const fullCode = `${diagramCode}\n${appendix}`.trim();
41
- // eslint-disable-next-line sonarjs/pseudo-random
42
- const diagramId = nodeProcess.env.NODE_ENV === "test" ? "id" : `mermaid-${Math.random().toString(36).substr(2, 9)}`;
43
- return `
44
- <div class="mermaid-container mb-6">
45
- <h3 class="text-xl font-semibold mb-4">${title}</h3>
46
- <div class="flex justify-center">
47
- <div id="${diagramId}" class="mermaid">
48
- ${fullCode}
49
- </div>
50
- </div>
51
- </div>`;
54
+ return generateMermaidHtml(fullCode, title);
52
55
  };
53
56
  export const process = processMermaid;
54
57
  export const path = parrotingImagePath;
@@ -1,5 +1,5 @@
1
1
  import { renderMarkdownToImage } from "../html_render.js";
2
- import { parrotingImagePath } from "./utils.js";
2
+ import { parrotingImagePath, resolveStyle } from "./utils.js";
3
3
  import { marked } from "marked";
4
4
  export const imageType = "textSlide";
5
5
  const processTextSlide = async (params) => {
@@ -7,6 +7,7 @@ const processTextSlide = async (params) => {
7
7
  if (!beat.image || beat.image.type !== imageType)
8
8
  return;
9
9
  const slide = beat.image.slide;
10
+ const style = resolveStyle(beat.image.style, textSlideStyle);
10
11
  const markdown = dumpMarkdown(params) ?? "";
11
12
  const topMargin = (() => {
12
13
  if (slide.bullets?.length && slide.bullets.length > 0) {
@@ -15,7 +16,7 @@ const processTextSlide = async (params) => {
15
16
  const marginTop = slide.subtitle ? canvasSize.height * 0.4 : canvasSize.height * 0.45;
16
17
  return `body {margin-top: ${marginTop}px;}`;
17
18
  })();
18
- await renderMarkdownToImage(markdown, textSlideStyle + topMargin, imagePath, canvasSize.width, canvasSize.height);
19
+ await renderMarkdownToImage(markdown, style + topMargin, imagePath, canvasSize.width, canvasSize.height);
19
20
  return imagePath;
20
21
  };
21
22
  const dumpMarkdown = (params) => {
@@ -1,2 +1,4 @@
1
1
  import { ImageProcessorParams } from "../../types/index.js";
2
2
  export declare const parrotingImagePath: (params: ImageProcessorParams) => string;
3
+ export declare const resolveStyle: (styleName: string | undefined, fallbackStyle: string) => string;
4
+ export declare const generateUniqueId: (prefix: string) => string;
@@ -1,3 +1,16 @@
1
+ import { getMarkdownStyle } from "../../data/markdownStyles.js";
2
+ import { randomUUID } from "node:crypto";
3
+ import nodeProcess from "node:process";
1
4
  export const parrotingImagePath = (params) => {
2
5
  return params.imagePath;
3
6
  };
7
+ export const resolveStyle = (styleName, fallbackStyle) => {
8
+ const customStyle = styleName ? getMarkdownStyle(styleName) : undefined;
9
+ return customStyle ? customStyle.css : fallbackStyle;
10
+ };
11
+ export const generateUniqueId = (prefix) => {
12
+ if (nodeProcess.env.NODE_ENV === "test") {
13
+ return "id";
14
+ }
15
+ return `${prefix}-${randomUUID().slice(0, 8)}`;
16
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mulmocast",
3
- "version": "2.1.29",
3
+ "version": "2.1.30",
4
4
  "description": "",
5
5
  "type": "module",
6
6
  "main": "lib/index.node.js",
@@ -0,0 +1,152 @@
1
+ {
2
+ "$mulmocast": {
3
+ "version": "1.0"
4
+ },
5
+ "title": "Markdown Layout Test",
6
+ "lang": "ja",
7
+ "speechParams": {
8
+ "speakers": {
9
+ "Presenter": {
10
+ "voiceId": "shimmer",
11
+ "displayName": { "en": "Presenter" }
12
+ }
13
+ }
14
+ },
15
+ "beats": [
16
+ {
17
+ "id": "row2-only",
18
+ "speaker": "Presenter",
19
+ "text": "row-2レイアウトのテストです。",
20
+ "image": {
21
+ "type": "markdown",
22
+ "markdown": {
23
+ "row-2": ["# 左側\n\n- ポイント1\n- ポイント2\n- ポイント3", "# 右側\n\n```typescript\nconst x = 1;\nconsole.log(x);\n```"]
24
+ }
25
+ }
26
+ },
27
+ {
28
+ "id": "2x2-only",
29
+ "speaker": "Presenter",
30
+ "text": "2x2グリッドレイアウトのテストです。",
31
+ "image": {
32
+ "type": "markdown",
33
+ "markdown": {
34
+ "2x2": ["## Q1\n\n売上: 100万円", "## Q2\n\n売上: 120万円", "## Q3\n\n売上: 150万円", "## Q4\n\n売上: 180万円"]
35
+ }
36
+ }
37
+ },
38
+ {
39
+ "id": "row2-with-header",
40
+ "speaker": "Presenter",
41
+ "text": "ヘッダー付きrow-2レイアウトのテストです。",
42
+ "image": {
43
+ "type": "markdown",
44
+ "markdown": {
45
+ "header": "# プレゼンテーションタイトル",
46
+ "row-2": ["## 概要\n\nこれは左側のコンテンツです。", "## 詳細\n\nこれは右側のコンテンツです。"]
47
+ }
48
+ }
49
+ },
50
+ {
51
+ "id": "row2-with-sidebar",
52
+ "speaker": "Presenter",
53
+ "text": "サイドバー付きrow-2レイアウトのテストです。",
54
+ "image": {
55
+ "type": "markdown",
56
+ "markdown": {
57
+ "sidebar-left": ["目次", "1. はじめに", "2. 本文", "3. まとめ"],
58
+ "row-2": ["# メインコンテンツ\n\n詳細な説明がここに入ります。", "# サブコンテンツ\n\n補足情報です。"]
59
+ }
60
+ }
61
+ },
62
+ {
63
+ "id": "row2-full",
64
+ "speaker": "Presenter",
65
+ "text": "フルレイアウト(ヘッダー+サイドバー+row-2)のテストです。",
66
+ "image": {
67
+ "type": "markdown",
68
+ "markdown": {
69
+ "header": "# GraphAI入門",
70
+ "sidebar-left": "**目次**\n\n1. 概要\n2. 特徴\n3. 使い方",
71
+ "row-2": [
72
+ "## 概要\n\nGraphAIはワークフローエンジンです。\n\n- 宣言的\n- 柔軟\n- 拡張可能",
73
+ "## コード例\n\n```typescript\nconst graph = new GraphAI({\n nodes: {...}\n});\nawait graph.run();\n```"
74
+ ]
75
+ }
76
+ }
77
+ },
78
+ {
79
+ "id": "2x2-with-header",
80
+ "speaker": "Presenter",
81
+ "text": "ヘッダー付き2x2グリッドのテストです。",
82
+ "image": {
83
+ "type": "markdown",
84
+ "markdown": {
85
+ "header": ["# 年間レポート", "2024年度"],
86
+ "2x2": ["### Q1\n売上: 100万", "### Q2\n売上: 120万", "### Q3\n売上: 150万", "### Q4\n売上: 180万"]
87
+ }
88
+ }
89
+ },
90
+ {
91
+ "id": "2x2-with-sidebar",
92
+ "speaker": "Presenter",
93
+ "text": "サイドバー付き2x2グリッドのテストです。",
94
+ "image": {
95
+ "type": "markdown",
96
+ "markdown": {
97
+ "sidebar-left": ["📊 レポート", "---", "Q1", "Q2", "Q3", "Q4"],
98
+ "2x2": ["**北海道**\n人口: 500万", "**東京**\n人口: 1400万", "**大阪**\n人口: 880万", "**福岡**\n人口: 510万"]
99
+ }
100
+ }
101
+ },
102
+ {
103
+ "id": "2x2-full",
104
+ "speaker": "Presenter",
105
+ "text": "フルレイアウト(ヘッダー+サイドバー+2x2)のテストです。",
106
+ "image": {
107
+ "type": "markdown",
108
+ "markdown": {
109
+ "header": "# 地域別売上",
110
+ "sidebar-left": "**凡例**\n\n🟢 好調\n🟡 普通\n🔴 不調",
111
+ "2x2": ["## 北海道 🟢\n\n前年比 +15%", "## 東北 🟡\n\n前年比 +3%", "## 関東 🟢\n\n前年比 +20%", "## 関西 🔴\n\n前年比 -5%"]
112
+ }
113
+ }
114
+ },
115
+ {
116
+ "id": "content-only",
117
+ "speaker": "Presenter",
118
+ "text": "シンプルなコンテンツのみのレイアウトです。",
119
+ "image": {
120
+ "type": "markdown",
121
+ "markdown": {
122
+ "content": "# シンプルなスライド\n\nこれは単一カラムのコンテンツです。\n\n- ポイント1\n- ポイント2\n- ポイント3\n\n```typescript\nconsole.log('Hello');\n```"
123
+ }
124
+ }
125
+ },
126
+ {
127
+ "id": "content-with-header",
128
+ "speaker": "Presenter",
129
+ "text": "ヘッダー付きコンテンツのテストです。",
130
+ "image": {
131
+ "type": "markdown",
132
+ "markdown": {
133
+ "header": "# ドキュメントタイトル",
134
+ "content": "## 本文\n\nここに詳細な説明文が入ります。\n\n1. 手順1\n2. 手順2\n3. 手順3"
135
+ }
136
+ }
137
+ },
138
+ {
139
+ "id": "content-full",
140
+ "speaker": "Presenter",
141
+ "text": "フルレイアウト(ヘッダー+サイドバー+content)のテストです。",
142
+ "image": {
143
+ "type": "markdown",
144
+ "markdown": {
145
+ "header": "# ユーザーガイド",
146
+ "sidebar-left": "**目次**\n\n1. 概要\n2. インストール\n3. 使い方",
147
+ "content": "## 概要\n\nこのアプリケーションは...\n\n### 特徴\n\n- 簡単操作\n- 高速処理\n- 拡張可能"
148
+ }
149
+ }
150
+ }
151
+ ]
152
+ }
@@ -0,0 +1,58 @@
1
+ {
2
+ "$mulmocast": {
3
+ "version": "1.0",
4
+ "credit": "closing"
5
+ },
6
+ "title": "Markdown with Mermaid Test",
7
+ "speechParams": {
8
+ "speakers": {
9
+ "Presenter": {
10
+ "voiceId": "shimmer",
11
+ "displayName": {
12
+ "en": "Presenter"
13
+ }
14
+ }
15
+ }
16
+ },
17
+ "beats": [
18
+ {
19
+ "speaker": "Presenter",
20
+ "text": "This slide shows markdown with embedded mermaid using row-2 layout.",
21
+ "image": {
22
+ "type": "markdown",
23
+ "markdown": {
24
+ "row-2": [
25
+ ["# Architecture Overview", "", "- Component A handles requests", "- Component B processes data", "", "Key points about the flow..."],
26
+ ["```mermaid", "graph TD", " A[Component A] --> B[Component B]", " B --> C[Database]", "```"]
27
+ ]
28
+ }
29
+ }
30
+ },
31
+ {
32
+ "speaker": "Presenter",
33
+ "text": "This slide shows a simple markdown with mermaid in content area.",
34
+ "image": {
35
+ "type": "markdown",
36
+ "markdown": {
37
+ "header": "# System Architecture",
38
+ "content": ["## Flow Diagram", "", "```mermaid", "flowchart LR", " User --> API", " API --> Service", " Service --> DB[(Database)]", "```"]
39
+ }
40
+ }
41
+ },
42
+ {
43
+ "speaker": "Presenter",
44
+ "text": "This uses 2x2 layout with mermaid in one cell.",
45
+ "image": {
46
+ "type": "markdown",
47
+ "markdown": {
48
+ "2x2": [
49
+ ["# Input", "User provides data"],
50
+ ["```mermaid", "graph TD", " Input --> Process", "```"],
51
+ ["# Processing", "Data is transformed"],
52
+ ["```mermaid", "graph TD", " Process --> Output", "```"]
53
+ ]
54
+ }
55
+ }
56
+ }
57
+ ]
58
+ }
@@ -0,0 +1,70 @@
1
+ {
2
+ "$mulmocast": {
3
+ "version": "1.0",
4
+ "credit": "closing"
5
+ },
6
+ "title": "Text Slide Style Test",
7
+ "speechParams": {
8
+ "speakers": {
9
+ "Presenter": {
10
+ "voiceId": "shimmer",
11
+ "displayName": {
12
+ "en": "Presenter"
13
+ }
14
+ }
15
+ }
16
+ },
17
+ "beats": [
18
+ {
19
+ "speaker": "Presenter",
20
+ "text": "This is a corporate blue style slide.",
21
+ "image": {
22
+ "type": "textSlide",
23
+ "slide": {
24
+ "title": "Corporate Blue",
25
+ "subtitle": "Business Style",
26
+ "bullets": ["Professional appearance", "Blue gradient background", "Clean typography"]
27
+ },
28
+ "style": "corporate-blue"
29
+ }
30
+ },
31
+ {
32
+ "speaker": "Presenter",
33
+ "text": "This is a cyber neon style slide.",
34
+ "image": {
35
+ "type": "textSlide",
36
+ "slide": {
37
+ "title": "Cyber Neon",
38
+ "subtitle": "Tech Style",
39
+ "bullets": ["Glowing green text", "Dark background", "Futuristic feel"]
40
+ },
41
+ "style": "cyber-neon"
42
+ }
43
+ },
44
+ {
45
+ "speaker": "Presenter",
46
+ "text": "This is a zen garden style slide.",
47
+ "image": {
48
+ "type": "textSlide",
49
+ "slide": {
50
+ "title": "Zen Garden",
51
+ "subtitle": "Japanese Style",
52
+ "bullets": ["Minimalist design", "Calm colors", "Elegant typography"]
53
+ },
54
+ "style": "zen-garden"
55
+ }
56
+ },
57
+ {
58
+ "speaker": "Presenter",
59
+ "text": "This slide uses default style without specifying style property.",
60
+ "image": {
61
+ "type": "textSlide",
62
+ "slide": {
63
+ "title": "Default Style",
64
+ "subtitle": "No Style Specified",
65
+ "bullets": ["Uses textSlideParams", "Fallback behavior"]
66
+ }
67
+ }
68
+ }
69
+ ]
70
+ }
@@ -1,5 +1,8 @@
1
1
  {
2
- "$schema": "../../assets/schema.json",
2
+ "$mulmocast": {
3
+ "version": "1.1",
4
+ "credit": "closing"
5
+ },
3
6
  "title": "Vertex AI Test",
4
7
  "description": "Test Vertex AI integration",
5
8
  "lang": "en",
@@ -0,0 +1,21 @@
1
+ {
2
+ "$schema": "../../assets/schema.json",
3
+ "title": "Vertex AI Test",
4
+ "description": "Test Vertex AI integration",
5
+ "lang": "en",
6
+ "imageParams": {
7
+ "provider": "google",
8
+ "model": "imagen-4.0-generate-001",
9
+ "vertexai_project": "${GOOGLE_PROJECT_ID}",
10
+ "vertexai_location": "us-central1"
11
+ },
12
+ "speechParams": {
13
+ "provider": "mock"
14
+ },
15
+ "beats": [
16
+ {
17
+ "text": "Hello",
18
+ "imagePrompt": "A simple blue circle on white background"
19
+ }
20
+ ]
21
+ }
@@ -0,0 +1,39 @@
1
+ {
2
+ "$mulmocast": { "version": "1.1" },
3
+ "lang": "ja",
4
+ "title": "実践例",
5
+ "speechParams": {
6
+ "speakers": {
7
+ "Presenter": {
8
+ "provider": "openai",
9
+ "voiceId": "shimmer"
10
+ }
11
+ }
12
+ },
13
+ "beats": [
14
+ {
15
+ "speaker": "Presenter",
16
+ "text": "3つの機能を組み合わせた実践例です。",
17
+ "duration": 3,
18
+ "image": {
19
+ "type": "markdown",
20
+ "markdown": {
21
+ "header": "# プロジェクトフロー",
22
+ "row-2": [
23
+ ["```mermaid", "graph TD", " A[企画] --> B[設計]", " B --> C[開発]", " C --> D[テスト]", " D --> E[リリース]", "```"],
24
+ [
25
+ "## フェーズ説明",
26
+ "",
27
+ "1. **企画**: 要件定義",
28
+ "2. **設計**: アーキテクチャ設計",
29
+ "3. **開発**: 実装",
30
+ "4. **テスト**: 品質保証",
31
+ "5. **リリース**: 本番デプロイ"
32
+ ]
33
+ ]
34
+ },
35
+ "style": "corporate-blue"
36
+ }
37
+ }
38
+ ]
39
+ }