makecc 0.2.13 → 0.2.15
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/dist/server/services/claudeCliService.js +210 -0
- package/dist/server/services/nodeSyncService.js +106 -19
- package/dist/server/services/workflowExecutionService.js +79 -97
- package/package.json +1 -1
- package/server/services/claudeCliService.ts +258 -0
- package/server/services/nodeSyncService.ts +126 -20
- package/server/services/workflowExecutionService.ts +77 -110
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { spawn } from 'child_process';
|
|
2
|
+
import { join, basename } from 'path';
|
|
3
|
+
import { existsSync, readdirSync, statSync } from 'fs';
|
|
4
|
+
import { mkdir, copyFile, readdir, stat } from 'fs/promises';
|
|
5
|
+
|
|
6
|
+
export interface ClaudeCliResult {
|
|
7
|
+
success: boolean;
|
|
8
|
+
stdout: string;
|
|
9
|
+
stderr: string;
|
|
10
|
+
exitCode: number | null;
|
|
11
|
+
generatedFiles: Array<{ name: string; path: string; type: string }>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ClaudeCliOptions {
|
|
15
|
+
prompt: string;
|
|
16
|
+
workingDirectory: string;
|
|
17
|
+
outputDirectory: string;
|
|
18
|
+
timeoutMs?: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* claude -c 명령어를 백그라운드로 실행하고 결과를 캡처합니다.
|
|
23
|
+
*/
|
|
24
|
+
export async function executeClaudeCli(options: ClaudeCliOptions): Promise<ClaudeCliResult> {
|
|
25
|
+
const { prompt, workingDirectory, outputDirectory, timeoutMs = 300000 } = options; // 기본 5분 타임아웃
|
|
26
|
+
|
|
27
|
+
// 출력 디렉토리 생성
|
|
28
|
+
if (!existsSync(outputDirectory)) {
|
|
29
|
+
await mkdir(outputDirectory, { recursive: true });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// 실행 전 working directory 파일 목록 스냅샷
|
|
33
|
+
const beforeFiles = await getDirectorySnapshot(workingDirectory);
|
|
34
|
+
|
|
35
|
+
return new Promise((resolve) => {
|
|
36
|
+
// claude -c --print 옵션으로 결과만 출력 (인터랙티브 모드 없이)
|
|
37
|
+
const proc = spawn('claude', ['-c', '--print', prompt], {
|
|
38
|
+
cwd: workingDirectory,
|
|
39
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
40
|
+
env: {
|
|
41
|
+
...process.env,
|
|
42
|
+
// TERM 설정으로 색상 코드 방지
|
|
43
|
+
TERM: 'dumb',
|
|
44
|
+
NO_COLOR: '1',
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
let stdout = '';
|
|
49
|
+
let stderr = '';
|
|
50
|
+
let timedOut = false;
|
|
51
|
+
|
|
52
|
+
// 타임아웃 설정
|
|
53
|
+
const timer = setTimeout(() => {
|
|
54
|
+
timedOut = true;
|
|
55
|
+
proc.kill('SIGTERM');
|
|
56
|
+
}, timeoutMs);
|
|
57
|
+
|
|
58
|
+
proc.stdout?.on('data', (data) => {
|
|
59
|
+
stdout += data.toString();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
proc.stderr?.on('data', (data) => {
|
|
63
|
+
stderr += data.toString();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
proc.on('close', async (code) => {
|
|
67
|
+
clearTimeout(timer);
|
|
68
|
+
|
|
69
|
+
// 실행 후 새로 생성된 파일 찾기
|
|
70
|
+
const afterFiles = await getDirectorySnapshot(workingDirectory);
|
|
71
|
+
const newFiles = findNewFiles(beforeFiles, afterFiles);
|
|
72
|
+
|
|
73
|
+
// 새 파일들을 output 디렉토리로 복사
|
|
74
|
+
const generatedFiles: Array<{ name: string; path: string; type: string }> = [];
|
|
75
|
+
|
|
76
|
+
for (const filePath of newFiles) {
|
|
77
|
+
const fileName = basename(filePath);
|
|
78
|
+
const destPath = join(outputDirectory, fileName);
|
|
79
|
+
const fileType = getFileType(fileName);
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
await copyFile(filePath, destPath);
|
|
83
|
+
generatedFiles.push({ name: fileName, path: destPath, type: fileType });
|
|
84
|
+
console.log(`Copied generated file: ${filePath} -> ${destPath}`);
|
|
85
|
+
} catch (err) {
|
|
86
|
+
console.error(`Failed to copy file ${filePath}:`, err);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// stdout 결과도 파일로 저장 (결과가 있으면)
|
|
91
|
+
if (stdout.trim()) {
|
|
92
|
+
const { writeFile } = await import('fs/promises');
|
|
93
|
+
const resultPath = join(outputDirectory, 'claude-output.md');
|
|
94
|
+
await writeFile(resultPath, stdout, 'utf-8');
|
|
95
|
+
generatedFiles.push({ name: 'claude-output.md', path: resultPath, type: 'markdown' });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
resolve({
|
|
99
|
+
success: code === 0 && !timedOut,
|
|
100
|
+
stdout: timedOut ? stdout + '\n[Execution timed out]' : stdout,
|
|
101
|
+
stderr,
|
|
102
|
+
exitCode: code,
|
|
103
|
+
generatedFiles,
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
proc.on('error', (err) => {
|
|
108
|
+
clearTimeout(timer);
|
|
109
|
+
resolve({
|
|
110
|
+
success: false,
|
|
111
|
+
stdout: '',
|
|
112
|
+
stderr: err.message,
|
|
113
|
+
exitCode: null,
|
|
114
|
+
generatedFiles: [],
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* 디렉토리의 파일 스냅샷 (재귀적으로 1단계까지만)
|
|
122
|
+
*/
|
|
123
|
+
async function getDirectorySnapshot(dir: string): Promise<Map<string, number>> {
|
|
124
|
+
const snapshot = new Map<string, number>();
|
|
125
|
+
|
|
126
|
+
if (!existsSync(dir)) {
|
|
127
|
+
return snapshot;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
132
|
+
|
|
133
|
+
for (const entry of entries) {
|
|
134
|
+
// 숨김 파일 및 특정 폴더 제외
|
|
135
|
+
if (entry.name.startsWith('.') || entry.name === 'node_modules' || entry.name === '__pycache__') {
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const fullPath = join(dir, entry.name);
|
|
140
|
+
|
|
141
|
+
if (entry.isFile()) {
|
|
142
|
+
const stats = await stat(fullPath);
|
|
143
|
+
snapshot.set(fullPath, stats.mtimeMs);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
} catch (err) {
|
|
147
|
+
console.error('Error reading directory:', err);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return snapshot;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* 파일 확장자로 타입 결정
|
|
155
|
+
*/
|
|
156
|
+
function getFileType(fileName: string): string {
|
|
157
|
+
const ext = fileName.split('.').pop()?.toLowerCase() || '';
|
|
158
|
+
const typeMap: Record<string, string> = {
|
|
159
|
+
md: 'markdown',
|
|
160
|
+
txt: 'text',
|
|
161
|
+
json: 'json',
|
|
162
|
+
js: 'javascript',
|
|
163
|
+
ts: 'typescript',
|
|
164
|
+
py: 'python',
|
|
165
|
+
html: 'html',
|
|
166
|
+
css: 'css',
|
|
167
|
+
png: 'image',
|
|
168
|
+
jpg: 'image',
|
|
169
|
+
jpeg: 'image',
|
|
170
|
+
gif: 'image',
|
|
171
|
+
svg: 'image',
|
|
172
|
+
pdf: 'pdf',
|
|
173
|
+
xlsx: 'excel',
|
|
174
|
+
xls: 'excel',
|
|
175
|
+
pptx: 'powerpoint',
|
|
176
|
+
ppt: 'powerpoint',
|
|
177
|
+
docx: 'word',
|
|
178
|
+
doc: 'word',
|
|
179
|
+
};
|
|
180
|
+
return typeMap[ext] || 'file';
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* 새로 생성되거나 수정된 파일 찾기
|
|
185
|
+
*/
|
|
186
|
+
function findNewFiles(before: Map<string, number>, after: Map<string, number>): string[] {
|
|
187
|
+
const newFiles: string[] = [];
|
|
188
|
+
|
|
189
|
+
for (const [path, mtime] of after) {
|
|
190
|
+
const beforeMtime = before.get(path);
|
|
191
|
+
|
|
192
|
+
// 새 파일이거나 수정된 파일
|
|
193
|
+
if (beforeMtime === undefined || mtime > beforeMtime) {
|
|
194
|
+
newFiles.push(path);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return newFiles;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* 노드별 프롬프트 생성
|
|
203
|
+
*/
|
|
204
|
+
export function buildNodePrompt(
|
|
205
|
+
nodeType: string,
|
|
206
|
+
nodeData: Record<string, unknown>,
|
|
207
|
+
previousResults: string
|
|
208
|
+
): string {
|
|
209
|
+
const lines: string[] = [];
|
|
210
|
+
|
|
211
|
+
if (nodeType === 'subagent') {
|
|
212
|
+
const role = nodeData.role as string || 'assistant';
|
|
213
|
+
const description = nodeData.description as string || '';
|
|
214
|
+
const systemPrompt = nodeData.systemPrompt as string || '';
|
|
215
|
+
|
|
216
|
+
lines.push(`You are a ${role}.`);
|
|
217
|
+
if (systemPrompt) {
|
|
218
|
+
lines.push(systemPrompt);
|
|
219
|
+
}
|
|
220
|
+
lines.push('');
|
|
221
|
+
lines.push('## Task');
|
|
222
|
+
lines.push(description || 'Complete the following task based on the input.');
|
|
223
|
+
lines.push('');
|
|
224
|
+
if (previousResults) {
|
|
225
|
+
lines.push('## Input from previous steps');
|
|
226
|
+
lines.push(previousResults);
|
|
227
|
+
lines.push('');
|
|
228
|
+
}
|
|
229
|
+
lines.push('Please complete this task and provide the result.');
|
|
230
|
+
} else if (nodeType === 'skill') {
|
|
231
|
+
const skillId = nodeData.skillId as string || '';
|
|
232
|
+
const description = nodeData.description as string || '';
|
|
233
|
+
|
|
234
|
+
if (skillId) {
|
|
235
|
+
lines.push(`Execute the skill: /${skillId}`);
|
|
236
|
+
}
|
|
237
|
+
lines.push('');
|
|
238
|
+
lines.push('## Task');
|
|
239
|
+
lines.push(description || 'Execute this skill with the provided input.');
|
|
240
|
+
lines.push('');
|
|
241
|
+
if (previousResults) {
|
|
242
|
+
lines.push('## Input');
|
|
243
|
+
lines.push(previousResults);
|
|
244
|
+
lines.push('');
|
|
245
|
+
}
|
|
246
|
+
} else {
|
|
247
|
+
// Generic
|
|
248
|
+
lines.push('## Task');
|
|
249
|
+
lines.push(nodeData.description as string || 'Process the following input.');
|
|
250
|
+
lines.push('');
|
|
251
|
+
if (previousResults) {
|
|
252
|
+
lines.push('## Input');
|
|
253
|
+
lines.push(previousResults);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return lines.join('\n');
|
|
258
|
+
}
|
|
@@ -9,10 +9,12 @@ interface NodeData {
|
|
|
9
9
|
// Skill specific
|
|
10
10
|
skillId?: string;
|
|
11
11
|
skillPath?: string;
|
|
12
|
+
upstream?: string[]; // Connected upstream agents/skills
|
|
13
|
+
downstream?: string[]; // Connected downstream agents/skills
|
|
12
14
|
// Subagent specific
|
|
13
15
|
tools?: string[];
|
|
14
16
|
model?: string;
|
|
15
|
-
skills?: string[]; // Connected skills
|
|
17
|
+
skills?: string[]; // Connected downstream skills
|
|
16
18
|
systemPrompt?: string;
|
|
17
19
|
// Command specific
|
|
18
20
|
commandName?: string;
|
|
@@ -99,6 +101,8 @@ export class NodeSyncService {
|
|
|
99
101
|
|
|
100
102
|
/**
|
|
101
103
|
* 엣지 연결 시 관계 업데이트
|
|
104
|
+
* - source의 downstream에 target 추가
|
|
105
|
+
* - target의 upstream에 source 추가
|
|
102
106
|
*/
|
|
103
107
|
async syncEdge(edge: EdgeData, nodes: NodeData[]): Promise<{ success: boolean; error?: string }> {
|
|
104
108
|
try {
|
|
@@ -109,26 +113,75 @@ export class NodeSyncService {
|
|
|
109
113
|
return { success: false, error: 'Source or target node not found' };
|
|
110
114
|
}
|
|
111
115
|
|
|
112
|
-
|
|
116
|
+
const sourceId = this.getNodeIdentifier(sourceNode);
|
|
117
|
+
const targetId = this.getNodeIdentifier(targetNode);
|
|
118
|
+
|
|
119
|
+
// 서브에이전트 → 스킬 연결
|
|
113
120
|
if (sourceNode.type === 'subagent' && targetNode.type === 'skill') {
|
|
121
|
+
// 에이전트의 skills 필드 업데이트
|
|
114
122
|
const skills = sourceNode.skills || [];
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
skills.push(skillId);
|
|
123
|
+
if (!skills.includes(targetId)) {
|
|
124
|
+
skills.push(targetId);
|
|
118
125
|
sourceNode.skills = skills;
|
|
119
126
|
await this.syncSubagentNode(sourceNode);
|
|
120
127
|
}
|
|
128
|
+
|
|
129
|
+
// 스킬의 upstream 필드 업데이트
|
|
130
|
+
const upstream = targetNode.upstream || [];
|
|
131
|
+
if (!upstream.includes(sourceId)) {
|
|
132
|
+
upstream.push(sourceId);
|
|
133
|
+
targetNode.upstream = upstream;
|
|
134
|
+
await this.syncSkillNode(targetNode);
|
|
135
|
+
}
|
|
121
136
|
}
|
|
122
137
|
|
|
123
|
-
// 스킬 →
|
|
138
|
+
// 스킬 → 스킬 연결
|
|
139
|
+
if (sourceNode.type === 'skill' && targetNode.type === 'skill') {
|
|
140
|
+
// source의 downstream 업데이트
|
|
141
|
+
const downstream = sourceNode.downstream || [];
|
|
142
|
+
if (!downstream.includes(targetId)) {
|
|
143
|
+
downstream.push(targetId);
|
|
144
|
+
sourceNode.downstream = downstream;
|
|
145
|
+
await this.syncSkillNode(sourceNode);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// target의 upstream 업데이트
|
|
149
|
+
const upstream = targetNode.upstream || [];
|
|
150
|
+
if (!upstream.includes(sourceId)) {
|
|
151
|
+
upstream.push(sourceId);
|
|
152
|
+
targetNode.upstream = upstream;
|
|
153
|
+
await this.syncSkillNode(targetNode);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// 스킬 → 서브에이전트 연결
|
|
124
158
|
if (sourceNode.type === 'skill' && targetNode.type === 'subagent') {
|
|
159
|
+
// 에이전트의 upstream skills 필드 업데이트
|
|
125
160
|
const skills = targetNode.skills || [];
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
skills.push(skillId);
|
|
161
|
+
if (!skills.includes(sourceId)) {
|
|
162
|
+
skills.push(sourceId);
|
|
129
163
|
targetNode.skills = skills;
|
|
130
164
|
await this.syncSubagentNode(targetNode);
|
|
131
165
|
}
|
|
166
|
+
|
|
167
|
+
// 스킬의 downstream 필드 업데이트
|
|
168
|
+
const downstream = sourceNode.downstream || [];
|
|
169
|
+
if (!downstream.includes(targetId)) {
|
|
170
|
+
downstream.push(targetId);
|
|
171
|
+
sourceNode.downstream = downstream;
|
|
172
|
+
await this.syncSkillNode(sourceNode);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// 서브에이전트 → 서브에이전트 연결
|
|
177
|
+
if (sourceNode.type === 'subagent' && targetNode.type === 'subagent') {
|
|
178
|
+
// source의 downstream agents
|
|
179
|
+
const sourceDownstream = sourceNode.skills || [];
|
|
180
|
+
if (!sourceDownstream.includes(targetId)) {
|
|
181
|
+
sourceDownstream.push(targetId);
|
|
182
|
+
sourceNode.skills = sourceDownstream;
|
|
183
|
+
await this.syncSubagentNode(sourceNode);
|
|
184
|
+
}
|
|
132
185
|
}
|
|
133
186
|
|
|
134
187
|
return { success: true };
|
|
@@ -138,6 +191,16 @@ export class NodeSyncService {
|
|
|
138
191
|
}
|
|
139
192
|
}
|
|
140
193
|
|
|
194
|
+
/**
|
|
195
|
+
* 노드의 식별자 반환 (skillId 또는 kebab-case label)
|
|
196
|
+
*/
|
|
197
|
+
private getNodeIdentifier(node: NodeData): string {
|
|
198
|
+
if (node.type === 'skill') {
|
|
199
|
+
return node.skillId || this.toKebabCase(node.label);
|
|
200
|
+
}
|
|
201
|
+
return this.toKebabCase(node.label);
|
|
202
|
+
}
|
|
203
|
+
|
|
141
204
|
/**
|
|
142
205
|
* 엣지 삭제 시 관계 업데이트
|
|
143
206
|
*/
|
|
@@ -150,17 +213,42 @@ export class NodeSyncService {
|
|
|
150
213
|
return { success: true }; // 노드가 없으면 무시
|
|
151
214
|
}
|
|
152
215
|
|
|
153
|
-
|
|
216
|
+
const sourceId = this.getNodeIdentifier(sourceNode);
|
|
217
|
+
const targetId = this.getNodeIdentifier(targetNode);
|
|
218
|
+
|
|
219
|
+
// 서브에이전트 → 스킬 연결 해제
|
|
154
220
|
if (sourceNode.type === 'subagent' && targetNode.type === 'skill') {
|
|
155
|
-
|
|
156
|
-
sourceNode.skills = (sourceNode.skills || []).filter(s => s !==
|
|
221
|
+
// 에이전트에서 스킬 제거
|
|
222
|
+
sourceNode.skills = (sourceNode.skills || []).filter(s => s !== targetId);
|
|
157
223
|
await this.syncSubagentNode(sourceNode);
|
|
224
|
+
|
|
225
|
+
// 스킬에서 upstream 제거
|
|
226
|
+
targetNode.upstream = (targetNode.upstream || []).filter(s => s !== sourceId);
|
|
227
|
+
await this.syncSkillNode(targetNode);
|
|
158
228
|
}
|
|
159
229
|
|
|
230
|
+
// 스킬 → 스킬 연결 해제
|
|
231
|
+
if (sourceNode.type === 'skill' && targetNode.type === 'skill') {
|
|
232
|
+
sourceNode.downstream = (sourceNode.downstream || []).filter(s => s !== targetId);
|
|
233
|
+
await this.syncSkillNode(sourceNode);
|
|
234
|
+
|
|
235
|
+
targetNode.upstream = (targetNode.upstream || []).filter(s => s !== sourceId);
|
|
236
|
+
await this.syncSkillNode(targetNode);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// 스킬 → 서브에이전트 연결 해제
|
|
160
240
|
if (sourceNode.type === 'skill' && targetNode.type === 'subagent') {
|
|
161
|
-
|
|
162
|
-
targetNode.skills = (targetNode.skills || []).filter(s => s !== skillId);
|
|
241
|
+
targetNode.skills = (targetNode.skills || []).filter(s => s !== sourceId);
|
|
163
242
|
await this.syncSubagentNode(targetNode);
|
|
243
|
+
|
|
244
|
+
sourceNode.downstream = (sourceNode.downstream || []).filter(s => s !== targetId);
|
|
245
|
+
await this.syncSkillNode(sourceNode);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// 서브에이전트 → 서브에이전트 연결 해제
|
|
249
|
+
if (sourceNode.type === 'subagent' && targetNode.type === 'subagent') {
|
|
250
|
+
sourceNode.skills = (sourceNode.skills || []).filter(s => s !== targetId);
|
|
251
|
+
await this.syncSubagentNode(sourceNode);
|
|
164
252
|
}
|
|
165
253
|
|
|
166
254
|
return { success: true };
|
|
@@ -179,20 +267,38 @@ export class NodeSyncService {
|
|
|
179
267
|
|
|
180
268
|
await fs.mkdir(skillPath, { recursive: true });
|
|
181
269
|
|
|
270
|
+
// Frontmatter 구성
|
|
271
|
+
const frontmatter: Record<string, string> = {
|
|
272
|
+
name: skillId,
|
|
273
|
+
description: node.description || node.label,
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
// upstream 연결 추가
|
|
277
|
+
if (node.upstream && node.upstream.length > 0) {
|
|
278
|
+
frontmatter.upstream = node.upstream.join(', ');
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// downstream 연결 추가
|
|
282
|
+
if (node.downstream && node.downstream.length > 0) {
|
|
283
|
+
frontmatter.downstream = node.downstream.join(', ');
|
|
284
|
+
}
|
|
285
|
+
|
|
182
286
|
// 기존 파일이 있으면 읽어서 업데이트, 없으면 새로 생성
|
|
183
287
|
let content = '';
|
|
184
288
|
try {
|
|
185
289
|
content = await fs.readFile(skillMdPath, 'utf-8');
|
|
186
290
|
// frontmatter 업데이트
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
});
|
|
291
|
+
for (const [key, value] of Object.entries(frontmatter)) {
|
|
292
|
+
content = this.updateFrontmatter(content, { [key]: value });
|
|
293
|
+
}
|
|
191
294
|
} catch {
|
|
192
295
|
// 새로 생성
|
|
296
|
+
const frontmatterStr = Object.entries(frontmatter)
|
|
297
|
+
.map(([key, value]) => `${key}: ${value}`)
|
|
298
|
+
.join('\n');
|
|
299
|
+
|
|
193
300
|
content = `---
|
|
194
|
-
|
|
195
|
-
description: ${node.description || node.label}
|
|
301
|
+
${frontmatterStr}
|
|
196
302
|
---
|
|
197
303
|
|
|
198
304
|
# ${node.label}
|