@su-record/vibe 2.9.1 → 2.9.3
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/CLAUDE.md +31 -10
- package/README.ko.md +90 -25
- package/README.md +139 -25
- package/agents/teams/debug-team.md +70 -0
- package/agents/teams/dev-team.md +88 -0
- package/agents/teams/docs-team.md +80 -0
- package/agents/teams/figma/figma-analyst.md +52 -0
- package/agents/teams/figma/figma-architect.md +112 -0
- package/agents/teams/figma/figma-auditor.md +82 -0
- package/agents/teams/figma/figma-builder.md +100 -0
- package/agents/teams/figma-team.md +85 -0
- package/agents/teams/fullstack-team.md +83 -0
- package/agents/teams/lite-team.md +69 -0
- package/agents/teams/migration-team.md +78 -0
- package/agents/teams/refactor-team.md +94 -0
- package/agents/teams/research-team.md +86 -0
- package/agents/teams/review-debate-team.md +125 -0
- package/agents/teams/security-team.md +81 -0
- package/commands/vibe.analyze.md +324 -170
- package/commands/vibe.figma.md +549 -34
- package/commands/vibe.harness.md +177 -0
- package/commands/vibe.review.md +1 -63
- package/commands/vibe.run.md +52 -403
- package/commands/vibe.scaffold.md +195 -0
- package/commands/vibe.spec.md +373 -1003
- package/commands/vibe.trace.md +17 -0
- package/commands/vibe.verify.md +19 -10
- package/dist/cli/commands/init.d.ts.map +1 -1
- package/dist/cli/commands/init.js +29 -1
- package/dist/cli/commands/init.js.map +1 -1
- package/dist/cli/commands/update.d.ts.map +1 -1
- package/dist/cli/commands/update.js +4 -2
- package/dist/cli/commands/update.js.map +1 -1
- package/dist/cli/postinstall/constants.d.ts +1 -1
- package/dist/cli/postinstall/constants.d.ts.map +1 -1
- package/dist/cli/postinstall/constants.js +6 -1
- package/dist/cli/postinstall/constants.js.map +1 -1
- package/dist/cli/setup/ProjectSetup.d.ts +12 -1
- package/dist/cli/setup/ProjectSetup.d.ts.map +1 -1
- package/dist/cli/setup/ProjectSetup.js +259 -72
- package/dist/cli/setup/ProjectSetup.js.map +1 -1
- package/dist/cli/setup.d.ts +1 -1
- package/dist/cli/setup.d.ts.map +1 -1
- package/dist/cli/setup.js +1 -1
- package/dist/cli/setup.js.map +1 -1
- package/hooks/scripts/figma-guard.js +220 -0
- package/hooks/scripts/figma-refine.js +315 -0
- package/hooks/scripts/figma-to-scss.js +394 -0
- package/hooks/scripts/figma-validate.js +353 -0
- package/package.json +1 -1
- package/skills/arch-guard/SKILL.md +1 -1
- package/skills/capability-loop/SKILL.md +106 -2
- package/skills/chub-usage/SKILL.md +43 -43
- package/skills/claude-md-guide/SKILL.md +175 -175
- package/skills/design-teach/SKILL.md +33 -33
- package/skills/devlog/SKILL.md +38 -38
- package/skills/event-comms/SKILL.md +23 -13
- package/skills/event-ops/SKILL.md +28 -19
- package/skills/event-planning/SKILL.md +13 -1
- package/skills/priority-todos/SKILL.md +1 -1
- package/skills/vibe.figma/SKILL.md +263 -115
- package/skills/vibe.figma/templates/component-spec.md +168 -0
- package/skills/vibe.figma.convert/SKILL.md +131 -84
- package/skills/vibe.figma.convert/rubrics/conversion-rules.md +12 -0
- package/skills/vibe.figma.extract/SKILL.md +148 -108
- package/skills/vibe.figma.extract/rubrics/image-rules.md +15 -3
- package/skills/vibe.interview/SKILL.md +358 -0
- package/skills/vibe.interview/checklists/api.md +101 -0
- package/skills/vibe.interview/checklists/feature.md +88 -0
- package/skills/vibe.interview/checklists/library.md +95 -0
- package/skills/vibe.interview/checklists/mobile.md +89 -0
- package/skills/vibe.interview/checklists/webapp.md +97 -0
- package/skills/vibe.interview/checklists/website.md +99 -0
- package/skills/vibe.plan/SKILL.md +216 -0
- package/skills/vibe.spec/SKILL.md +1155 -0
- package/{commands/vibe.spec.review.md → skills/vibe.spec.review/SKILL.md} +272 -155
- package/vibe/templates/claudemd-template.md +74 -0
- package/vibe/templates/constitution-template.md +15 -0
- package/vibe/templates/plan-template.md +194 -0
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Figma Guard — vibe.figma 작업 시 스킬 우회 차단
|
|
4
|
+
*
|
|
5
|
+
* 차단 대상:
|
|
6
|
+
* 1. /tmp/{feature}/ 하위에 자체 정제/생성 스크립트(.mjs/.js) 작성
|
|
7
|
+
* → figma-refine.js / figma-to-scss.js 호출하라고 차단
|
|
8
|
+
* 2. SCSS 파일을 직접 작성 (figma-to-scss.js 호출 흔적 없이)
|
|
9
|
+
* 3. Vue/React <style> 블록에 figma 관련 SCSS 클래스 직접 작성
|
|
10
|
+
*
|
|
11
|
+
* 작동 조건:
|
|
12
|
+
* - tool: Write 또는 Edit
|
|
13
|
+
* - file_path가 figma 작업 컨텍스트에 해당
|
|
14
|
+
*
|
|
15
|
+
* Exit codes:
|
|
16
|
+
* 0 — 통과
|
|
17
|
+
* 2 — 차단
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import fs from 'fs';
|
|
21
|
+
import path from 'path';
|
|
22
|
+
import os from 'os';
|
|
23
|
+
|
|
24
|
+
// ─── stdin 읽기 ─────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
function readStdinSync() {
|
|
27
|
+
try {
|
|
28
|
+
if (process.stdin.isTTY) return null;
|
|
29
|
+
const fd = fs.openSync('/dev/stdin', 'r');
|
|
30
|
+
const buf = Buffer.alloc(1024 * 1024); // 1MB
|
|
31
|
+
const bytesRead = fs.readSync(fd, buf, 0, buf.length, null);
|
|
32
|
+
fs.closeSync(fd);
|
|
33
|
+
if (bytesRead > 0) {
|
|
34
|
+
return JSON.parse(buf.toString('utf-8', 0, bytesRead));
|
|
35
|
+
}
|
|
36
|
+
} catch { /* fallback */ }
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ─── 검사 1: /tmp/{feature}/ 자체 작성 스크립트 ─────────────────────
|
|
41
|
+
|
|
42
|
+
const FORBIDDEN_TMP_SCRIPT_PATTERNS = [
|
|
43
|
+
/\/tmp\/[^/]+\/refine[\w-]*\.(mjs|js)$/i,
|
|
44
|
+
/\/tmp\/[^/]+\/.*sections.*\.(mjs|js)$/i,
|
|
45
|
+
/\/tmp\/[^/]+\/.*to-scss.*\.(mjs|js)$/i,
|
|
46
|
+
/\/tmp\/[^/]+\/.*generate-scss.*\.(mjs|js)$/i,
|
|
47
|
+
/\/tmp\/[^/]+\/analyze-tree\.(mjs|js)$/i,
|
|
48
|
+
/\/tmp\/[^/]+\/analyze-section\.(mjs|js)$/i,
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
function checkForbiddenTmpScript(filePath) {
|
|
52
|
+
if (!filePath) return null;
|
|
53
|
+
for (const pattern of FORBIDDEN_TMP_SCRIPT_PATTERNS) {
|
|
54
|
+
if (pattern.test(filePath)) {
|
|
55
|
+
return {
|
|
56
|
+
block: true,
|
|
57
|
+
reason: `자체 작성 figma 스크립트 금지: ${path.basename(filePath)}`,
|
|
58
|
+
suggestion: 'figma-refine.js / figma-to-scss.js / figma-validate.js 사용. ' +
|
|
59
|
+
'결과가 마음에 안 들면 ~/.vibe/hooks/scripts/figma-*.js 자체를 수정하세요.'
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ─── 검사 2: SCSS 직접 작성 ─────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Figma 작업 컨텍스트인지 판단:
|
|
70
|
+
* - 현재 작업 디렉토리 또는 file_path에 figma 관련 흔적이 있어야 함
|
|
71
|
+
* - /tmp/ 하위에 sections.json이 존재 → 활성 figma 작업으로 판단
|
|
72
|
+
*/
|
|
73
|
+
function isFigmaContext() {
|
|
74
|
+
try {
|
|
75
|
+
const tmpDirs = fs.readdirSync('/tmp', { withFileTypes: true });
|
|
76
|
+
for (const entry of tmpDirs) {
|
|
77
|
+
if (!entry.isDirectory()) continue;
|
|
78
|
+
const moSections = path.join('/tmp', entry.name, 'mo-main', 'sections.json');
|
|
79
|
+
const pcSections = path.join('/tmp', entry.name, 'pc-main', 'sections.json');
|
|
80
|
+
if (fs.existsSync(moSections) || fs.existsSync(pcSections)) {
|
|
81
|
+
return { active: true, feature: entry.name };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
} catch { /* ignore */ }
|
|
85
|
+
return { active: false };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* SCSS 파일 작성 시도 검사
|
|
90
|
+
*/
|
|
91
|
+
function checkScssWrite(filePath, content) {
|
|
92
|
+
if (!filePath) return null;
|
|
93
|
+
if (!filePath.endsWith('.scss')) return null;
|
|
94
|
+
|
|
95
|
+
// figma 컨텍스트가 아니면 통과
|
|
96
|
+
const ctx = isFigmaContext();
|
|
97
|
+
if (!ctx.active) return null;
|
|
98
|
+
|
|
99
|
+
// 자동 생성 표시 코멘트 있으면 통과
|
|
100
|
+
if (typeof content === 'string' && content.includes('Auto-generated from sections.json')) {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// 빈 파일 또는 @import만 있는 경우 통과
|
|
105
|
+
if (typeof content === 'string') {
|
|
106
|
+
const meaningfulLines = content
|
|
107
|
+
.split('\n')
|
|
108
|
+
.map(l => l.trim())
|
|
109
|
+
.filter(l => l && !l.startsWith('//') && !l.startsWith('/*'));
|
|
110
|
+
const hasOnlyImports = meaningfulLines.every(l =>
|
|
111
|
+
l.startsWith('@import') || l.startsWith('@use') || l.startsWith('@forward')
|
|
112
|
+
);
|
|
113
|
+
if (hasOnlyImports) return null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
block: true,
|
|
118
|
+
reason: `SCSS 직접 작성 금지: ${filePath}`,
|
|
119
|
+
suggestion: `figma-to-scss.js로 생성하세요:\n` +
|
|
120
|
+
` node ~/.vibe/hooks/scripts/figma-to-scss.js \\\n` +
|
|
121
|
+
` /tmp/${ctx.feature}/{bp}-main/sections.json \\\n` +
|
|
122
|
+
` --out=$(dirname ${filePath})`
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ─── 검사 3: Vue/React <style> 블록에 CSS 값 작성 ───────────────────
|
|
127
|
+
|
|
128
|
+
function checkVueStyleBlock(filePath, content) {
|
|
129
|
+
if (!filePath) return null;
|
|
130
|
+
if (!/\.(vue|jsx|tsx)$/.test(filePath)) return null;
|
|
131
|
+
|
|
132
|
+
const ctx = isFigmaContext();
|
|
133
|
+
if (!ctx.active) return null;
|
|
134
|
+
|
|
135
|
+
// 파일 경로에 feature 이름이 있는지 확인
|
|
136
|
+
if (!filePath.includes(ctx.feature)) return null;
|
|
137
|
+
|
|
138
|
+
if (typeof content !== 'string') return null;
|
|
139
|
+
|
|
140
|
+
// <style ...> ... </style> 블록 추출
|
|
141
|
+
const styleBlocks = content.match(/<style[^>]*>([\s\S]*?)<\/style>/g);
|
|
142
|
+
if (!styleBlocks || styleBlocks.length === 0) return null;
|
|
143
|
+
|
|
144
|
+
for (const block of styleBlocks) {
|
|
145
|
+
const inner = block.replace(/<style[^>]*>/, '').replace(/<\/style>/, '');
|
|
146
|
+
const meaningfulLines = inner
|
|
147
|
+
.split('\n')
|
|
148
|
+
.map(l => l.trim())
|
|
149
|
+
.filter(l => l && !l.startsWith('//') && !l.startsWith('/*'));
|
|
150
|
+
|
|
151
|
+
// @import / @use 만 있으면 통과
|
|
152
|
+
const hasOnlyImports = meaningfulLines.every(l =>
|
|
153
|
+
l.startsWith('@import') || l.startsWith('@use') || l.startsWith('@forward')
|
|
154
|
+
);
|
|
155
|
+
if (hasOnlyImports) continue;
|
|
156
|
+
|
|
157
|
+
// CSS 값으로 보이는 라인 검출 (px, rem, color, flex 등)
|
|
158
|
+
const hasCssValue = meaningfulLines.some(l =>
|
|
159
|
+
/:\s*[^;]*(?:px|rem|em|%|vh|vw|#[0-9a-fA-F]|rgba?\(|flex|grid|absolute|relative|inherit)/.test(l)
|
|
160
|
+
);
|
|
161
|
+
if (hasCssValue) {
|
|
162
|
+
return {
|
|
163
|
+
block: true,
|
|
164
|
+
reason: `Vue/React <style> 블록에 CSS 직접 작성 금지: ${path.basename(filePath)}`,
|
|
165
|
+
suggestion: `<style> 블록은 @import만 허용:\n` +
|
|
166
|
+
` <style lang="scss" scoped>\n` +
|
|
167
|
+
` @import '~/assets/scss/${ctx.feature}/index.scss';\n` +
|
|
168
|
+
` </style>`
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ─── 메인 ───────────────────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
const stdinPayload = readStdinSync();
|
|
179
|
+
if (!stdinPayload) {
|
|
180
|
+
process.exit(0);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const toolName = stdinPayload.tool_name || '';
|
|
184
|
+
const toolInput = stdinPayload.tool_input || {};
|
|
185
|
+
|
|
186
|
+
// Write 또는 Edit만 검사
|
|
187
|
+
if (toolName !== 'Write' && toolName !== 'Edit') {
|
|
188
|
+
process.exit(0);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const filePath = toolInput.file_path || '';
|
|
192
|
+
const content = toolInput.content || toolInput.new_string || '';
|
|
193
|
+
|
|
194
|
+
// 3단계 검사
|
|
195
|
+
const checks = [
|
|
196
|
+
checkForbiddenTmpScript(filePath),
|
|
197
|
+
checkScssWrite(filePath, content),
|
|
198
|
+
checkVueStyleBlock(filePath, content),
|
|
199
|
+
];
|
|
200
|
+
|
|
201
|
+
const violations = checks.filter(c => c !== null);
|
|
202
|
+
|
|
203
|
+
if (violations.length === 0) {
|
|
204
|
+
process.exit(0);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// 차단
|
|
208
|
+
const messages = ['🚫 FIGMA GUARD: 스킬 규칙 위반'];
|
|
209
|
+
for (const v of violations) {
|
|
210
|
+
messages.push('');
|
|
211
|
+
messages.push(` ${v.reason}`);
|
|
212
|
+
messages.push(` 💡 ${v.suggestion}`);
|
|
213
|
+
}
|
|
214
|
+
messages.push('');
|
|
215
|
+
messages.push('⛔ vibe.figma 작업 중에는 스크립트 파이프라인을 우회하지 마세요.');
|
|
216
|
+
messages.push(' ~/.vibe/hooks/scripts/figma-{refine,to-scss,validate}.js만 사용.');
|
|
217
|
+
|
|
218
|
+
// PreToolUse hook은 stderr 출력 + exit 2로 차단
|
|
219
|
+
console.error(messages.join('\n'));
|
|
220
|
+
process.exit(2);
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* figma-refine.js — tree.json → sections.json 정제
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* node figma-refine.js <tree.json> --out=<sections.json> --design-width=<px> [--bp=<breakpoint>]
|
|
8
|
+
*
|
|
9
|
+
* 정제 규칙:
|
|
10
|
+
* 1. 1depth 자식을 섹션으로 분할
|
|
11
|
+
* 2. 크기 0px, 장식선 (≤2px), isMask 노드 제거
|
|
12
|
+
* 3. BG 프레임 → images.bg로 분리
|
|
13
|
+
* 4. 벡터 글자 GROUP → images.content로 분리
|
|
14
|
+
* 5. 디자인 텍스트 (multi-fill, gradient, effects) → images.content로 분리
|
|
15
|
+
* 6. 전체 children 재귀 포함 — 잎 노드까지
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import fs from 'fs';
|
|
19
|
+
import path from 'path';
|
|
20
|
+
|
|
21
|
+
// ─── Helpers ────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
function nameToKebab(name) {
|
|
24
|
+
return name
|
|
25
|
+
.replace(/[^a-zA-Z0-9\s_-]/g, '')
|
|
26
|
+
.replace(/[\s_]+/g, '-')
|
|
27
|
+
.replace(/([a-z])([A-Z])/g, '$1-$2')
|
|
28
|
+
.toLowerCase()
|
|
29
|
+
.replace(/-+/g, '-')
|
|
30
|
+
.replace(/^-|-$/g, '') || 'unnamed';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ─── Node Classification ────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
function isZeroSize(node) {
|
|
36
|
+
const w = node.size?.width || 0;
|
|
37
|
+
const h = node.size?.height || 0;
|
|
38
|
+
return w === 0 && h === 0;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function isDecorationLine(node) {
|
|
42
|
+
// VECTOR 장식선: w ≤ 2 or h ≤ 2
|
|
43
|
+
if (node.type !== 'VECTOR') return false;
|
|
44
|
+
const w = node.size?.width || 0;
|
|
45
|
+
const h = node.size?.height || 0;
|
|
46
|
+
return (w <= 2 || h <= 2) && (w > 0 || h > 0);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function isBGFrame(node) {
|
|
50
|
+
const name = (node.name || '').toLowerCase();
|
|
51
|
+
return name === 'bg' || name.endsWith('-bg') || name.startsWith('bg-') || name.startsWith('bg ');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function isVectorTextGroup(node) {
|
|
55
|
+
// 부모 GROUP/FRAME 아래 VECTOR 3개 이상, 각 <60px
|
|
56
|
+
if (node.type !== 'GROUP' && node.type !== 'FRAME') return false;
|
|
57
|
+
const children = node.children || [];
|
|
58
|
+
const vectors = children.filter(c => c.type === 'VECTOR');
|
|
59
|
+
if (vectors.length < 3) return false;
|
|
60
|
+
return vectors.every(v => {
|
|
61
|
+
const w = v.size?.width || 0;
|
|
62
|
+
const h = v.size?.height || 0;
|
|
63
|
+
return w < 60 && h < 60;
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function isDesignText(node) {
|
|
68
|
+
if (node.type !== 'TEXT') return false;
|
|
69
|
+
const fills = node.fills || [];
|
|
70
|
+
const css = node.css || {};
|
|
71
|
+
|
|
72
|
+
// Multi-fill (2개 이상)
|
|
73
|
+
if (fills.length > 1) return true;
|
|
74
|
+
|
|
75
|
+
// Gradient fill
|
|
76
|
+
if (fills.some(f => (f.type || '').includes('GRADIENT'))) return true;
|
|
77
|
+
|
|
78
|
+
// Effects (box-shadow from effects indicates design text with effects)
|
|
79
|
+
// Check if has complex visual effects beyond simple color
|
|
80
|
+
if (css.boxShadow || css.filter || css.backdropFilter) return true;
|
|
81
|
+
|
|
82
|
+
// outline on text = stroke effect
|
|
83
|
+
if (css.outline) return true;
|
|
84
|
+
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ─── Node Refinement ────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
function refineNode(node, sectionName, images) {
|
|
91
|
+
// 필터: 제거 대상
|
|
92
|
+
if (isZeroSize(node)) return null;
|
|
93
|
+
if (isDecorationLine(node)) return null;
|
|
94
|
+
if (node.isMask) return null;
|
|
95
|
+
|
|
96
|
+
// BG 프레임 → images로 분리
|
|
97
|
+
if (isBGFrame(node)) {
|
|
98
|
+
const bgName = `${nameToKebab(sectionName)}-bg`;
|
|
99
|
+
images.bg = `bg/${bgName}.webp`;
|
|
100
|
+
return null; // children에서 제거
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// 벡터 글자 GROUP → images로 분리
|
|
104
|
+
if (isVectorTextGroup(node)) {
|
|
105
|
+
const contentName = `${nameToKebab(sectionName)}-${nameToKebab(node.name || 'text')}`;
|
|
106
|
+
images.content.push({
|
|
107
|
+
name: contentName,
|
|
108
|
+
path: `content/${contentName}.webp`,
|
|
109
|
+
nodeId: node.nodeId,
|
|
110
|
+
originalName: node.name,
|
|
111
|
+
type: 'vector-text'
|
|
112
|
+
});
|
|
113
|
+
// 렌더링 이미지로 대체: type을 IMAGE_PLACEHOLDER로 마킹
|
|
114
|
+
return {
|
|
115
|
+
nodeId: node.nodeId,
|
|
116
|
+
name: node.name,
|
|
117
|
+
type: 'RENDERED_IMAGE',
|
|
118
|
+
size: node.size,
|
|
119
|
+
css: node.css || {},
|
|
120
|
+
imagePath: `content/${contentName}.webp`,
|
|
121
|
+
children: []
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// 디자인 텍스트 → images로 분리
|
|
126
|
+
if (isDesignText(node)) {
|
|
127
|
+
const contentName = `${nameToKebab(sectionName)}-${nameToKebab(node.name || 'design-text')}`;
|
|
128
|
+
images.content.push({
|
|
129
|
+
name: contentName,
|
|
130
|
+
path: `content/${contentName}.webp`,
|
|
131
|
+
nodeId: node.nodeId,
|
|
132
|
+
originalName: node.name,
|
|
133
|
+
text: node.text,
|
|
134
|
+
type: 'design-text'
|
|
135
|
+
});
|
|
136
|
+
return {
|
|
137
|
+
nodeId: node.nodeId,
|
|
138
|
+
name: node.name,
|
|
139
|
+
type: 'RENDERED_IMAGE',
|
|
140
|
+
size: node.size,
|
|
141
|
+
css: node.css || {},
|
|
142
|
+
imagePath: `content/${contentName}.webp`,
|
|
143
|
+
text: node.text,
|
|
144
|
+
children: []
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// 일반 노드 → 재귀 정제
|
|
149
|
+
const refined = {
|
|
150
|
+
nodeId: node.nodeId,
|
|
151
|
+
name: node.name,
|
|
152
|
+
type: node.type,
|
|
153
|
+
size: node.size,
|
|
154
|
+
css: node.css || {}
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
// 메타데이터 보존
|
|
158
|
+
if (node.text) refined.text = node.text;
|
|
159
|
+
if (node.imageRef) refined.imageRef = node.imageRef;
|
|
160
|
+
if (node.imageScaleMode) refined.imageScaleMode = node.imageScaleMode;
|
|
161
|
+
if (node.fills) refined.fills = node.fills;
|
|
162
|
+
if (node.layoutSizingH) refined.layoutSizingH = node.layoutSizingH;
|
|
163
|
+
if (node.layoutSizingV) refined.layoutSizingV = node.layoutSizingV;
|
|
164
|
+
|
|
165
|
+
// children 재귀 정제
|
|
166
|
+
refined.children = [];
|
|
167
|
+
for (const child of (node.children || [])) {
|
|
168
|
+
const refinedChild = refineNode(child, sectionName, images);
|
|
169
|
+
if (refinedChild) refined.children.push(refinedChild);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return refined;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ─── Section Splitting ──────────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
function findMainFrame(tree) {
|
|
178
|
+
// 루트가 직접 섹션을 가지고 있으면 그대로 반환
|
|
179
|
+
const children = tree.children || [];
|
|
180
|
+
|
|
181
|
+
// 1depth에서 섹션 후보 찾기 (GNB/Footer 제외)
|
|
182
|
+
const sectionCandidates = children.filter(c => {
|
|
183
|
+
const name = (c.name || '').toLowerCase();
|
|
184
|
+
const isNav = name.includes('gnb') || name.includes('footer') || name.includes('nav');
|
|
185
|
+
return !isNav;
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// 1depth 자식이 1개고 FRAME이면 → 래퍼 프레임, 그 안의 children이 진짜 섹션
|
|
189
|
+
if (sectionCandidates.length === 1 && sectionCandidates[0].type === 'FRAME') {
|
|
190
|
+
const inner = sectionCandidates[0];
|
|
191
|
+
const innerChildren = inner.children || [];
|
|
192
|
+
// 내부에 2개 이상 자식이 있으면 래퍼로 판단
|
|
193
|
+
if (innerChildren.length >= 2) {
|
|
194
|
+
return inner;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return tree;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function splitIntoSections(tree, designWidth) {
|
|
202
|
+
const mainFrame = findMainFrame(tree);
|
|
203
|
+
const children = mainFrame.children || [];
|
|
204
|
+
const sections = [];
|
|
205
|
+
|
|
206
|
+
for (const child of children) {
|
|
207
|
+
const name = child.name || `Section_${sections.length}`;
|
|
208
|
+
|
|
209
|
+
// GNB/Footer 스킵
|
|
210
|
+
const nameLower = name.toLowerCase();
|
|
211
|
+
if (nameLower.includes('gnb') || nameLower.includes('footer')) continue;
|
|
212
|
+
|
|
213
|
+
// 래퍼 프레임 (이름이 "Frame NNN" 패턴이고 children이 있으면 풀어서 처리)
|
|
214
|
+
const isWrapper = /^Frame\s+\d+$/.test(name) && (child.children || []).length > 0;
|
|
215
|
+
if (isWrapper) {
|
|
216
|
+
// 래퍼 안의 자식을 섹션으로
|
|
217
|
+
for (const inner of (child.children || [])) {
|
|
218
|
+
const innerName = inner.name || `Section_${sections.length}`;
|
|
219
|
+
const images = { bg: null, content: [] };
|
|
220
|
+
const refined = refineNode(inner, innerName, images);
|
|
221
|
+
if (refined) {
|
|
222
|
+
refined.images = images;
|
|
223
|
+
sections.push(refined);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
} else {
|
|
227
|
+
const images = { bg: null, content: [] };
|
|
228
|
+
const refined = refineNode(child, name, images);
|
|
229
|
+
if (refined) {
|
|
230
|
+
refined.images = images;
|
|
231
|
+
sections.push(refined);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return sections;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ─── Stats ──────────────────────────────────────────────────────────
|
|
240
|
+
|
|
241
|
+
function countNodes(node) {
|
|
242
|
+
let count = 1;
|
|
243
|
+
for (const c of (node.children || [])) {
|
|
244
|
+
count += countNodes(c);
|
|
245
|
+
}
|
|
246
|
+
return count;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ─── Main ───────────────────────────────────────────────────────────
|
|
250
|
+
|
|
251
|
+
function main() {
|
|
252
|
+
const args = process.argv.slice(2);
|
|
253
|
+
if (args.length < 1) {
|
|
254
|
+
console.error('Usage: node figma-refine.js <tree.json> --out=<sections.json> --design-width=<px>');
|
|
255
|
+
process.exit(1);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const inputFile = args[0];
|
|
259
|
+
let outFile = '';
|
|
260
|
+
let designWidth = 720;
|
|
261
|
+
let breakpoint = '';
|
|
262
|
+
|
|
263
|
+
for (const arg of args.slice(1)) {
|
|
264
|
+
if (arg.startsWith('--out=')) outFile = arg.slice(6);
|
|
265
|
+
if (arg.startsWith('--design-width=')) designWidth = parseInt(arg.slice(15));
|
|
266
|
+
if (arg.startsWith('--bp=')) breakpoint = arg.slice(5);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (!outFile) {
|
|
270
|
+
console.error('--out=<sections.json> required');
|
|
271
|
+
process.exit(1);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// 입력 읽기
|
|
275
|
+
const tree = JSON.parse(fs.readFileSync(inputFile, 'utf-8'));
|
|
276
|
+
|
|
277
|
+
// 섹션 분할 + 정제
|
|
278
|
+
const sections = splitIntoSections(tree, designWidth);
|
|
279
|
+
|
|
280
|
+
// 결과
|
|
281
|
+
const result = {
|
|
282
|
+
meta: {
|
|
283
|
+
feature: nameToKebab(tree.name || 'feature'),
|
|
284
|
+
designWidth,
|
|
285
|
+
...(breakpoint ? { breakpoint } : {})
|
|
286
|
+
},
|
|
287
|
+
sections
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
// 출력
|
|
291
|
+
const outDir = path.dirname(outFile);
|
|
292
|
+
if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
|
|
293
|
+
fs.writeFileSync(outFile, JSON.stringify(result, null, 2));
|
|
294
|
+
|
|
295
|
+
// 통계 출력
|
|
296
|
+
const totalNodes = sections.reduce((sum, s) => sum + countNodes(s), 0);
|
|
297
|
+
const totalImages = sections.reduce((sum, s) => {
|
|
298
|
+
const imgs = s.images || {};
|
|
299
|
+
return sum + (imgs.bg ? 1 : 0) + (imgs.content || []).length;
|
|
300
|
+
}, 0);
|
|
301
|
+
|
|
302
|
+
const stats = {
|
|
303
|
+
sections: sections.map(s => ({
|
|
304
|
+
name: s.name,
|
|
305
|
+
nodes: countNodes(s),
|
|
306
|
+
bg: s.images?.bg || null,
|
|
307
|
+
contentImages: (s.images?.content || []).length
|
|
308
|
+
})),
|
|
309
|
+
total: { sections: sections.length, nodes: totalNodes, images: totalImages }
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
console.log(JSON.stringify(stats, null, 2));
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
main();
|