draply-dev 1.2.0 → 1.3.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/bin/cli.js +226 -485
- package/package.json +1 -1
- package/src/draply-features.js +435 -741
- package/src/overlay.js +3 -15
package/bin/cli.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
4
|
const http = require('http');
|
|
5
|
+
const https = require('https');
|
|
5
6
|
const fs = require('fs');
|
|
6
7
|
const path = require('path');
|
|
7
8
|
const zlib = require('zlib');
|
|
@@ -13,7 +14,6 @@ const targetHost = args[2] || 'localhost';
|
|
|
13
14
|
|
|
14
15
|
const OVERLAY_JS = fs.readFileSync(path.join(__dirname, '../src/overlay.js'), 'utf8');
|
|
15
16
|
const FEATURES_JS = fs.readFileSync(path.join(__dirname, '../src/draply-features.js'), 'utf8');
|
|
16
|
-
// Unique marker that does NOT appear inside overlay.js itself
|
|
17
17
|
const MARKER = 'data-ps-done="1"';
|
|
18
18
|
const INJECT_TAG = `\n<link rel="stylesheet" href="/draply.css">\n<script ${MARKER}>\n${OVERLAY_JS}\n</script>\n<script data-draply-features="1">\n${FEATURES_JS}\n</script>`;
|
|
19
19
|
|
|
@@ -35,12 +35,11 @@ function decode(headers, chunks) {
|
|
|
35
35
|
});
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
//
|
|
38
|
+
// ── Project & Config ──────────────────────────────────────────────────────────
|
|
39
39
|
const projectRoot = process.cwd();
|
|
40
40
|
const draplyDir = path.join(projectRoot, '.draply');
|
|
41
41
|
if (!fs.existsSync(draplyDir)) {
|
|
42
42
|
fs.mkdirSync(draplyDir, { recursive: true });
|
|
43
|
-
// Добавляем .draply в .gitignore если он есть
|
|
44
43
|
const gitignorePath = path.join(projectRoot, '.gitignore');
|
|
45
44
|
if (fs.existsSync(gitignorePath)) {
|
|
46
45
|
const gi = fs.readFileSync(gitignorePath, 'utf8');
|
|
@@ -50,57 +49,250 @@ if (!fs.existsSync(draplyDir)) {
|
|
|
50
49
|
}
|
|
51
50
|
}
|
|
52
51
|
const overridesPath = path.join(draplyDir, 'overrides.css');
|
|
53
|
-
if (!fs.existsSync(overridesPath))
|
|
54
|
-
|
|
52
|
+
if (!fs.existsSync(overridesPath)) fs.writeFileSync(overridesPath, '/* draply */\n', 'utf8');
|
|
53
|
+
|
|
54
|
+
const configPath = path.join(draplyDir, 'config.json');
|
|
55
|
+
function loadConfig() {
|
|
56
|
+
try { return JSON.parse(fs.readFileSync(configPath, 'utf8')); } catch { return {}; }
|
|
57
|
+
}
|
|
58
|
+
function saveConfig(cfg) {
|
|
59
|
+
fs.writeFileSync(configPath, JSON.stringify(cfg, null, 2), 'utf8');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ── File walker ───────────────────────────────────────────────────────────────
|
|
63
|
+
function walkDir(dir, exts, results = []) {
|
|
64
|
+
try {
|
|
65
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
66
|
+
for (const e of entries) {
|
|
67
|
+
const fp = path.join(dir, e.name);
|
|
68
|
+
if (e.name.startsWith('.') || e.name === 'node_modules' || e.name === 'dist' || e.name === '.next' || e.name === 'build') continue;
|
|
69
|
+
if (e.isDirectory()) walkDir(fp, exts, results);
|
|
70
|
+
else if (exts.some(x => e.name.endsWith(x))) results.push(fp);
|
|
71
|
+
}
|
|
72
|
+
} catch { /* skip */ }
|
|
73
|
+
return results;
|
|
55
74
|
}
|
|
56
75
|
|
|
76
|
+
// ── Find best source file for a className ─────────────────────────────────────
|
|
77
|
+
function findFileForClass(root, className) {
|
|
78
|
+
const files = walkDir(root, ['.jsx', '.tsx', '.js', '.ts', '.css', '.scss', '.vue', '.svelte']);
|
|
79
|
+
for (const f of files) {
|
|
80
|
+
try {
|
|
81
|
+
const content = fs.readFileSync(f, 'utf8');
|
|
82
|
+
if (content.includes(className)) {
|
|
83
|
+
return { file: f, content, relativePath: path.relative(root, f) };
|
|
84
|
+
}
|
|
85
|
+
} catch { /* skip */ }
|
|
86
|
+
}
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ── Extract class name from CSS selector ──────────────────────────────────────
|
|
91
|
+
function extractClassName(selector) {
|
|
92
|
+
const parts = selector.split('>').map(s => s.trim());
|
|
93
|
+
for (let i = parts.length - 1; i >= 0; i--) {
|
|
94
|
+
const m = parts[i].match(/\.([a-zA-Z][a-zA-Z0-9_-]*)/);
|
|
95
|
+
if (m) return m[1];
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ── Call Gemini API ───────────────────────────────────────────────────────────
|
|
101
|
+
function callGemini(apiKey, prompt) {
|
|
102
|
+
return new Promise((resolve, reject) => {
|
|
103
|
+
const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${apiKey}`;
|
|
104
|
+
const body = JSON.stringify({
|
|
105
|
+
contents: [{ parts: [{ text: prompt }] }],
|
|
106
|
+
generationConfig: { temperature: 0.1, maxOutputTokens: 8192 }
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const parsed = new URL(url);
|
|
110
|
+
const options = {
|
|
111
|
+
hostname: parsed.hostname,
|
|
112
|
+
path: parsed.pathname + parsed.search,
|
|
113
|
+
method: 'POST',
|
|
114
|
+
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const req = https.request(options, res => {
|
|
118
|
+
const chunks = [];
|
|
119
|
+
res.on('data', c => chunks.push(c));
|
|
120
|
+
res.on('end', () => {
|
|
121
|
+
try {
|
|
122
|
+
const data = JSON.parse(Buffer.concat(chunks).toString());
|
|
123
|
+
if (data.error) {
|
|
124
|
+
reject(new Error(data.error.message || 'Gemini API error'));
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
const text = data.candidates?.[0]?.content?.parts?.[0]?.text || '';
|
|
128
|
+
resolve(text);
|
|
129
|
+
} catch (e) { reject(e); }
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
req.on('error', reject);
|
|
133
|
+
req.write(body);
|
|
134
|
+
req.end();
|
|
135
|
+
});
|
|
136
|
+
}
|
|
57
137
|
|
|
138
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
139
|
+
// HTTP SERVER
|
|
140
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
58
141
|
const server = http.createServer((req, res) => {
|
|
59
142
|
|
|
60
|
-
// CORS preflight
|
|
61
143
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
62
144
|
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,OPTIONS');
|
|
63
145
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
64
146
|
if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; }
|
|
65
147
|
|
|
66
|
-
// ──
|
|
67
|
-
if (req.url === '/draply-
|
|
148
|
+
// ── Config endpoint (get/set API key) ───────────────────────────────────────
|
|
149
|
+
if (req.url === '/draply-config') {
|
|
150
|
+
if (req.method === 'GET') {
|
|
151
|
+
const cfg = loadConfig();
|
|
152
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
153
|
+
// Mask API key for security — only send if it exists
|
|
154
|
+
res.end(JSON.stringify({ hasKey: !!cfg.apiKey, provider: cfg.provider || 'gemini' }));
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (req.method === 'POST') {
|
|
158
|
+
let body = '';
|
|
159
|
+
req.on('data', c => body += c);
|
|
160
|
+
req.on('end', () => {
|
|
161
|
+
try {
|
|
162
|
+
const { apiKey, provider } = JSON.parse(body);
|
|
163
|
+
const cfg = loadConfig();
|
|
164
|
+
if (apiKey !== undefined) cfg.apiKey = apiKey;
|
|
165
|
+
if (provider) cfg.provider = provider;
|
|
166
|
+
saveConfig(cfg);
|
|
167
|
+
console.log(` \x1b[32m✓\x1b[0m API key saved (${cfg.provider || 'gemini'})`);
|
|
168
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
169
|
+
res.end(JSON.stringify({ ok: true }));
|
|
170
|
+
} catch (e) {
|
|
171
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
172
|
+
res.end(JSON.stringify({ ok: false, error: e.message }));
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ── AI Apply endpoint ───────────────────────────────────────────────────────
|
|
180
|
+
if (req.url === '/draply-ai-apply' && req.method === 'POST') {
|
|
68
181
|
let body = '';
|
|
69
182
|
req.on('data', c => body += c);
|
|
70
|
-
req.on('end', () => {
|
|
183
|
+
req.on('end', async () => {
|
|
71
184
|
try {
|
|
72
185
|
const { changes } = JSON.parse(body);
|
|
73
|
-
const
|
|
186
|
+
const cfg = loadConfig();
|
|
187
|
+
if (!cfg.apiKey) {
|
|
188
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
189
|
+
res.end(JSON.stringify({ ok: false, error: 'No API key configured. Click ⚙ to set up.' }));
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
74
193
|
const results = [];
|
|
75
194
|
|
|
195
|
+
// Group changes by class
|
|
76
196
|
for (const ch of (changes || [])) {
|
|
77
197
|
if (!ch.selector || !ch.props) continue;
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
if (
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
198
|
+
|
|
199
|
+
const className = extractClassName(ch.selector);
|
|
200
|
+
if (!className) {
|
|
201
|
+
results.push({ selector: ch.selector, ok: false, reason: 'No class name found' });
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Find the source file
|
|
206
|
+
const found = findFileForClass(projectRoot, className);
|
|
207
|
+
if (!found) {
|
|
208
|
+
results.push({ selector: ch.selector, ok: false, reason: `"${className}" not found in source files` });
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
console.log(` \x1b[36m🤖\x1b[0m AI applying: .${className} → ${found.relativePath}`);
|
|
213
|
+
|
|
214
|
+
// Build the prompt
|
|
215
|
+
const propsStr = Object.entries(ch.props).map(([k, v]) => ` ${k}: ${v}`).join('\n');
|
|
216
|
+
const prompt = `You are a code editor. Modify the following source file to apply CSS style changes.
|
|
217
|
+
|
|
218
|
+
FILE: ${found.relativePath}
|
|
219
|
+
\`\`\`
|
|
220
|
+
${found.content}
|
|
221
|
+
\`\`\`
|
|
222
|
+
|
|
223
|
+
CHANGES TO APPLY:
|
|
224
|
+
The element with class "${className}" should have these CSS properties:
|
|
225
|
+
${propsStr}
|
|
226
|
+
|
|
227
|
+
RULES:
|
|
228
|
+
- If the file is CSS/SCSS: find the .${className} rule and update/add the properties. If the rule doesn't exist, add it.
|
|
229
|
+
- If the file is JSX/TSX: apply changes in the most appropriate way for this file:
|
|
230
|
+
- If the file already uses inline styles for this element, update the style prop
|
|
231
|
+
- If the file imports a CSS file, add the rule to that CSS file instead (tell me the filename in a comment)
|
|
232
|
+
- If the file uses Tailwind classes, update the className with appropriate Tailwind classes
|
|
233
|
+
- Otherwise, add a style prop to the element with className="${className}"
|
|
234
|
+
- Keep ALL existing code intact. Only modify what's needed for the style changes.
|
|
235
|
+
- Do NOT add comments explaining what you changed.
|
|
236
|
+
|
|
237
|
+
Return ONLY the complete modified file content. No markdown fences, no explanations, just the raw code.`;
|
|
238
|
+
|
|
239
|
+
try {
|
|
240
|
+
const result = await callGemini(cfg.apiKey, prompt);
|
|
241
|
+
|
|
242
|
+
// Clean up response — remove markdown fences if AI added them
|
|
243
|
+
let code = result.trim();
|
|
244
|
+
if (code.startsWith('```')) {
|
|
245
|
+
code = code.replace(/^```[a-z]*\n?/, '').replace(/\n?```$/, '');
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Safety: don't write empty or tiny responses
|
|
249
|
+
if (code.length < 20) {
|
|
250
|
+
results.push({ selector: ch.selector, ok: false, reason: 'AI returned empty/invalid response' });
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Write the modified file
|
|
255
|
+
fs.writeFileSync(found.file, code, 'utf8');
|
|
256
|
+
console.log(` \x1b[32m✓\x1b[0m Applied to ${found.relativePath}`);
|
|
257
|
+
results.push({ selector: ch.selector, ok: true, file: found.relativePath });
|
|
258
|
+
|
|
259
|
+
} catch (aiErr) {
|
|
260
|
+
console.log(` \x1b[31m✗\x1b[0m AI error: ${aiErr.message}`);
|
|
261
|
+
results.push({ selector: ch.selector, ok: false, reason: aiErr.message });
|
|
84
262
|
}
|
|
85
263
|
}
|
|
86
264
|
|
|
87
|
-
|
|
265
|
+
const applied = results.filter(r => r.ok).length;
|
|
266
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
267
|
+
res.end(JSON.stringify({ ok: true, applied, total: results.length, results }));
|
|
268
|
+
|
|
269
|
+
} catch (e) {
|
|
270
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
271
|
+
res.end(JSON.stringify({ ok: false, error: e.message }));
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// ── Save changes to CSS ─────────────────────────────────────────────────────
|
|
278
|
+
if (req.url === '/draply-save' && req.method === 'POST') {
|
|
279
|
+
let body = '';
|
|
280
|
+
req.on('data', c => body += c);
|
|
281
|
+
req.on('end', () => {
|
|
282
|
+
try {
|
|
283
|
+
const { changes } = JSON.parse(body);
|
|
88
284
|
const lines = [];
|
|
89
285
|
for (const ch of (changes || [])) {
|
|
90
286
|
if (!ch.selector) continue;
|
|
91
|
-
const props = Object.entries(ch.props)
|
|
92
|
-
.map(([k, v]) => ` ${k}: ${v};`)
|
|
93
|
-
.join('\n');
|
|
287
|
+
const props = Object.entries(ch.props).map(([k, v]) => ` ${k}: ${v};`).join('\n');
|
|
94
288
|
const label = ch.selector.split('>').pop().trim();
|
|
95
289
|
lines.push(`/* ${label} */\n${ch.selector} {\n${props}\n}`);
|
|
96
290
|
}
|
|
97
291
|
const css = '/* draply — ' + new Date().toLocaleString('ru-RU') + ' */\n\n' + lines.join('\n\n') + '\n';
|
|
98
292
|
fs.writeFileSync(overridesPath, css, 'utf8');
|
|
99
|
-
|
|
100
|
-
const applied = results.filter(r => r.applied).length;
|
|
101
|
-
const total = results.length;
|
|
293
|
+
console.log(` \x1b[32m✓\x1b[0m Saved ${changes.length} changes to .draply/overrides.css`);
|
|
102
294
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
103
|
-
res.end(JSON.stringify({ ok: true
|
|
295
|
+
res.end(JSON.stringify({ ok: true }));
|
|
104
296
|
} catch (e) {
|
|
105
297
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
106
298
|
res.end(JSON.stringify({ ok: false, error: e.message }));
|
|
@@ -109,7 +301,7 @@ const server = http.createServer((req, res) => {
|
|
|
109
301
|
return;
|
|
110
302
|
}
|
|
111
303
|
|
|
112
|
-
// ──
|
|
304
|
+
// ── Serve CSS ───────────────────────────────────────────────────────────────
|
|
113
305
|
if (req.url.split('?')[0] === '/draply.css') {
|
|
114
306
|
const isModule = req.headers['sec-fetch-dest'] === 'script' || req.url.includes('import');
|
|
115
307
|
try {
|
|
@@ -121,7 +313,7 @@ const server = http.createServer((req, res) => {
|
|
|
121
313
|
res.writeHead(200, { 'Content-Type': 'text/css', 'cache-control': 'no-store' });
|
|
122
314
|
res.end(css);
|
|
123
315
|
}
|
|
124
|
-
} catch
|
|
316
|
+
} catch {
|
|
125
317
|
if (isModule) {
|
|
126
318
|
res.writeHead(200, { 'Content-Type': 'application/javascript' });
|
|
127
319
|
res.end('export default {};');
|
|
@@ -133,33 +325,7 @@ const server = http.createServer((req, res) => {
|
|
|
133
325
|
return;
|
|
134
326
|
}
|
|
135
327
|
|
|
136
|
-
// ──
|
|
137
|
-
if (req.url === '/draply-project-info' && req.method === 'GET') {
|
|
138
|
-
const info = detectProject(projectRoot);
|
|
139
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
140
|
-
res.end(JSON.stringify(info));
|
|
141
|
-
return;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
// ── Draply: Find Source endpoint ──────────────────────────────────────────
|
|
145
|
-
if (req.url === '/draply-find-source' && req.method === 'POST') {
|
|
146
|
-
let body = '';
|
|
147
|
-
req.on('data', c => body += c);
|
|
148
|
-
req.on('end', () => {
|
|
149
|
-
try {
|
|
150
|
-
const { selector, tagName, className } = JSON.parse(body);
|
|
151
|
-
const result = findSourceFile(projectRoot, { selector, tagName, className });
|
|
152
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
153
|
-
res.end(JSON.stringify(result));
|
|
154
|
-
} catch (e) {
|
|
155
|
-
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
156
|
-
res.end(JSON.stringify({ found: false, error: e.message }));
|
|
157
|
-
}
|
|
158
|
-
});
|
|
159
|
-
return;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
// ── Proxy to dev server ────────────────────────────────────────────────────
|
|
328
|
+
// ── Proxy to dev server ─────────────────────────────────────────────────────
|
|
163
329
|
const opts = {
|
|
164
330
|
hostname: targetHost,
|
|
165
331
|
port: targetPort,
|
|
@@ -196,8 +362,8 @@ const server = http.createServer((req, res) => {
|
|
|
196
362
|
pReq.on('error', () => {
|
|
197
363
|
res.writeHead(502, { 'Content-Type': 'text/html' });
|
|
198
364
|
res.end(`<!DOCTYPE html><html><body style="background:#0a0a0f;color:#e8e8f0;font-family:monospace;padding:60px;text-align:center">
|
|
199
|
-
<h2 style="color:#ff6b6b">⚠
|
|
200
|
-
<p style="color:#555;margin-top:16px"
|
|
365
|
+
<h2 style="color:#ff6b6b">⚠ Can't reach ${targetHost}:${targetPort}</h2>
|
|
366
|
+
<p style="color:#555;margin-top:16px">Make sure your dev server is running, then refresh</p>
|
|
201
367
|
<script>setTimeout(()=>location.reload(), 2000)</script>
|
|
202
368
|
</body></html>`);
|
|
203
369
|
});
|
|
@@ -206,435 +372,10 @@ const server = http.createServer((req, res) => {
|
|
|
206
372
|
});
|
|
207
373
|
|
|
208
374
|
server.listen(proxyPort, () => {
|
|
209
|
-
console.log('\n \x1b[32m●\x1b[0m Draply
|
|
210
|
-
console.log(`
|
|
211
|
-
console.log(`
|
|
212
|
-
console.log(` \x1b[90mCtrl+C
|
|
375
|
+
console.log('\n \x1b[32m●\x1b[0m Draply running\n');
|
|
376
|
+
console.log(` Your project → \x1b[36mhttp://${targetHost}:${targetPort}\x1b[0m`);
|
|
377
|
+
console.log(` Open this → \x1b[33mhttp://localhost:${proxyPort}\x1b[0m \x1b[32m← go here!\x1b[0m\n`);
|
|
378
|
+
console.log(` \x1b[90mCtrl+C to stop\x1b[0m\n`);
|
|
213
379
|
});
|
|
214
380
|
|
|
215
|
-
process.on('SIGINT', () => { console.log('\n \x1b[90mDraply
|
|
216
|
-
|
|
217
|
-
// ══════════════════════════════════════════════════════════════════════════
|
|
218
|
-
// PROJECT DETECTION
|
|
219
|
-
// ══════════════════════════════════════════════════════════════════════════
|
|
220
|
-
function detectProject(root) {
|
|
221
|
-
const result = { framework: 'unknown', cssStrategy: 'unknown', root };
|
|
222
|
-
|
|
223
|
-
try {
|
|
224
|
-
const pkgPath = path.join(root, 'package.json');
|
|
225
|
-
if (!fs.existsSync(pkgPath)) return result;
|
|
226
|
-
|
|
227
|
-
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
228
|
-
const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
229
|
-
|
|
230
|
-
// Detect framework
|
|
231
|
-
if (allDeps['next']) result.framework = 'next';
|
|
232
|
-
else if (allDeps['react']) result.framework = 'react';
|
|
233
|
-
else if (allDeps['nuxt']) result.framework = 'nuxt';
|
|
234
|
-
else if (allDeps['vue']) result.framework = 'vue';
|
|
235
|
-
else if (allDeps['@angular/core']) result.framework = 'angular';
|
|
236
|
-
else if (allDeps['svelte']) result.framework = 'svelte';
|
|
237
|
-
else if (allDeps['vite']) result.framework = 'vite';
|
|
238
|
-
|
|
239
|
-
// Detect CSS strategy
|
|
240
|
-
if (allDeps['tailwindcss']) result.cssStrategy = 'tailwind';
|
|
241
|
-
else if (allDeps['styled-components']) result.cssStrategy = 'styled-components';
|
|
242
|
-
else if (allDeps['@emotion/react'] || allDeps['@emotion/styled']) result.cssStrategy = 'emotion';
|
|
243
|
-
else if (allDeps['sass'] || allDeps['node-sass']) result.cssStrategy = 'sass';
|
|
244
|
-
else {
|
|
245
|
-
// Check for CSS modules (usually enabled by default in React/Next)
|
|
246
|
-
if (['react', 'next'].includes(result.framework)) {
|
|
247
|
-
result.cssStrategy = 'css-modules';
|
|
248
|
-
} else {
|
|
249
|
-
result.cssStrategy = 'external';
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
} catch { /* ignore */ }
|
|
253
|
-
|
|
254
|
-
return result;
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
// ══════════════════════════════════════════════════════════════════════════
|
|
258
|
-
// SOURCE FILE FINDER
|
|
259
|
-
// ══════════════════════════════════════════════════════════════════════════
|
|
260
|
-
function findSourceFile(root, { selector, tagName, className }) {
|
|
261
|
-
const result = { found: false, file: null, line: null, hint: '' };
|
|
262
|
-
|
|
263
|
-
try {
|
|
264
|
-
const srcDirs = ['src', 'app', 'pages', 'components', 'lib'];
|
|
265
|
-
const extensions = ['.tsx', '.jsx', '.vue', '.svelte', '.js', '.ts'];
|
|
266
|
-
const searchTerm = className || tagName || '';
|
|
267
|
-
|
|
268
|
-
if (!searchTerm) {
|
|
269
|
-
result.hint = 'Select an element with a class name for better results.';
|
|
270
|
-
return result;
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
for (const dir of srcDirs) {
|
|
274
|
-
const dirPath = path.join(root, dir);
|
|
275
|
-
if (!fs.existsSync(dirPath)) continue;
|
|
276
|
-
|
|
277
|
-
const files = walkDir(dirPath, extensions);
|
|
278
|
-
for (const file of files) {
|
|
279
|
-
try {
|
|
280
|
-
const content = fs.readFileSync(file, 'utf8');
|
|
281
|
-
const lines = content.split('\n');
|
|
282
|
-
for (let i = 0; i < lines.length; i++) {
|
|
283
|
-
if (lines[i].includes(searchTerm)) {
|
|
284
|
-
result.found = true;
|
|
285
|
-
result.file = path.relative(root, file);
|
|
286
|
-
result.line = i + 1;
|
|
287
|
-
result.hint = `Found "${searchTerm}" at ${result.file}:${result.line}`;
|
|
288
|
-
return result;
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
} catch { /* skip unreadable files */ }
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
result.hint = `Could not find "${searchTerm}" in source files.`;
|
|
296
|
-
} catch (e) {
|
|
297
|
-
result.hint = 'Error searching source files: ' + e.message;
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
return result;
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
function walkDir(dir, extensions, results = []) {
|
|
304
|
-
try {
|
|
305
|
-
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
306
|
-
for (const entry of entries) {
|
|
307
|
-
const fullPath = path.join(dir, entry.name);
|
|
308
|
-
if (entry.name.startsWith('.') || entry.name === 'node_modules' || entry.name === '.next' || entry.name === 'dist') continue;
|
|
309
|
-
if (entry.isDirectory()) {
|
|
310
|
-
walkDir(fullPath, extensions, results);
|
|
311
|
-
} else if (extensions.some(ext => entry.name.endsWith(ext))) {
|
|
312
|
-
results.push(fullPath);
|
|
313
|
-
}
|
|
314
|
-
}
|
|
315
|
-
} catch { /* ignore */ }
|
|
316
|
-
return results;
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
// ══════════════════════════════════════════════════════════════════════════
|
|
320
|
-
// AUTO-APPLY CHANGES TO SOURCE FILES
|
|
321
|
-
// ══════════════════════════════════════════════════════════════════════════
|
|
322
|
-
|
|
323
|
-
function autoApplyChange(root, selector, props, projectInfo) {
|
|
324
|
-
const result = { selector, applied: false, file: null, strategy: null, reason: '' };
|
|
325
|
-
|
|
326
|
-
// 1. Extract class name from selector
|
|
327
|
-
const className = extractClassName(selector);
|
|
328
|
-
if (!className) {
|
|
329
|
-
result.reason = 'No class name in selector';
|
|
330
|
-
return result;
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
// 2. Try: find existing CSS rule and modify it
|
|
334
|
-
const cssResult = findAndModifyCSSRule(root, className, props);
|
|
335
|
-
if (cssResult.applied) {
|
|
336
|
-
result.applied = true;
|
|
337
|
-
result.file = path.relative(root, cssResult.file);
|
|
338
|
-
result.strategy = 'css-modify';
|
|
339
|
-
return result;
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
// 3. Try: find JSX component using this class → find its CSS import → add rule there
|
|
343
|
-
const importResult = findCSSImportAndAppend(root, className, props);
|
|
344
|
-
if (importResult.applied) {
|
|
345
|
-
result.applied = true;
|
|
346
|
-
result.file = path.relative(root, importResult.file);
|
|
347
|
-
result.strategy = 'css-append';
|
|
348
|
-
return result;
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
// 4. Try: modify inline style in JSX
|
|
352
|
-
const inlineResult = modifyInlineStyle(root, className, props);
|
|
353
|
-
if (inlineResult.applied) {
|
|
354
|
-
result.applied = true;
|
|
355
|
-
result.file = path.relative(root, inlineResult.file);
|
|
356
|
-
result.strategy = 'inline-style';
|
|
357
|
-
return result;
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
// 5. Fallback: find any CSS file in project and append
|
|
361
|
-
const fallbackResult = appendToAnyCSSFile(root, className, props);
|
|
362
|
-
if (fallbackResult.applied) {
|
|
363
|
-
result.applied = true;
|
|
364
|
-
result.file = path.relative(root, fallbackResult.file);
|
|
365
|
-
result.strategy = 'css-fallback';
|
|
366
|
-
return result;
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
result.reason = 'Could not find target file for ' + className;
|
|
370
|
-
return result;
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
// Extract the most specific class name from a CSS selector
|
|
374
|
-
function extractClassName(selector) {
|
|
375
|
-
// Selector like: #root > div > section.nexus-hero > h1.title
|
|
376
|
-
const parts = selector.split('>').map(s => s.trim());
|
|
377
|
-
// Try from the end — most specific first
|
|
378
|
-
for (let i = parts.length - 1; i >= 0; i--) {
|
|
379
|
-
const classMatch = parts[i].match(/\.([a-zA-Z][a-zA-Z0-9_-]*)/);
|
|
380
|
-
if (classMatch) return classMatch[1];
|
|
381
|
-
}
|
|
382
|
-
return null;
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
// Strategy 1: Find existing CSS rule in .css/.scss files and modify it
|
|
386
|
-
function findAndModifyCSSRule(root, className, props) {
|
|
387
|
-
const cssFiles = walkDir(root, ['.css', '.scss', '.sass', '.less']);
|
|
388
|
-
const ruleRegex = new RegExp(
|
|
389
|
-
`(\\.${escapeRegex(className)}\\s*\\{)([^}]*)(\\})`, 's'
|
|
390
|
-
);
|
|
391
|
-
|
|
392
|
-
for (const file of cssFiles) {
|
|
393
|
-
// Skip node_modules, .draply, etc
|
|
394
|
-
const rel = path.relative(root, file);
|
|
395
|
-
if (rel.includes('node_modules') || rel.includes('.draply')) continue;
|
|
396
|
-
|
|
397
|
-
try {
|
|
398
|
-
let content = fs.readFileSync(file, 'utf8');
|
|
399
|
-
const match = content.match(ruleRegex);
|
|
400
|
-
if (!match) continue;
|
|
401
|
-
|
|
402
|
-
// Found the rule — modify it
|
|
403
|
-
const existingBlock = match[2];
|
|
404
|
-
let newBlock = existingBlock;
|
|
405
|
-
|
|
406
|
-
for (const [prop, val] of Object.entries(props)) {
|
|
407
|
-
const propRegex = new RegExp(`(${escapeRegex(prop)}\\s*:\\s*)([^;]+)(;)`, 'g');
|
|
408
|
-
if (propRegex.test(newBlock)) {
|
|
409
|
-
// Property exists — update value
|
|
410
|
-
newBlock = newBlock.replace(
|
|
411
|
-
new RegExp(`(${escapeRegex(prop)}\\s*:\\s*)([^;]+)(;)`, 'g'),
|
|
412
|
-
`$1${val}$3`
|
|
413
|
-
);
|
|
414
|
-
} else {
|
|
415
|
-
// Property doesn't exist — add it
|
|
416
|
-
newBlock = newBlock.trimEnd() + `\n ${prop}: ${val};\n`;
|
|
417
|
-
}
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
content = content.replace(ruleRegex, `$1${newBlock}$3`);
|
|
421
|
-
fs.writeFileSync(file, content, 'utf8');
|
|
422
|
-
|
|
423
|
-
return { applied: true, file };
|
|
424
|
-
} catch { /* skip */ }
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
return { applied: false };
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
// Strategy 2: Find the JSX component using className, find its CSS import, append rule
|
|
431
|
-
function findCSSImportAndAppend(root, className, props) {
|
|
432
|
-
const jsxFiles = walkDir(root, ['.jsx', '.tsx', '.js', '.ts']);
|
|
433
|
-
|
|
434
|
-
for (const jsxFile of jsxFiles) {
|
|
435
|
-
const rel = path.relative(root, jsxFile);
|
|
436
|
-
if (rel.includes('node_modules')) continue;
|
|
437
|
-
|
|
438
|
-
try {
|
|
439
|
-
const content = fs.readFileSync(jsxFile, 'utf8');
|
|
440
|
-
|
|
441
|
-
// Check if this component uses the className
|
|
442
|
-
if (!content.includes(className)) continue;
|
|
443
|
-
|
|
444
|
-
// Find CSS imports in this file
|
|
445
|
-
// Matches: import './styles.css' or import styles from './styles.module.css' or require('./styles.css')
|
|
446
|
-
const importRegex = /(?:import\s+(?:\w+\s+from\s+)?['"]([^'"]+\.(?:css|scss|sass|less))['"]|require\(['"]([^'"]+\.(?:css|scss|sass|less))['"]\))/g;
|
|
447
|
-
let importMatch;
|
|
448
|
-
const cssImports = [];
|
|
449
|
-
|
|
450
|
-
while ((importMatch = importRegex.exec(content)) !== null) {
|
|
451
|
-
const importPath = importMatch[1] || importMatch[2];
|
|
452
|
-
const fullCSSPath = path.resolve(path.dirname(jsxFile), importPath);
|
|
453
|
-
if (fs.existsSync(fullCSSPath)) {
|
|
454
|
-
cssImports.push(fullCSSPath);
|
|
455
|
-
}
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
if (cssImports.length === 0) continue;
|
|
459
|
-
|
|
460
|
-
// Append rule to the first CSS import
|
|
461
|
-
const targetCSS = cssImports[0];
|
|
462
|
-
const newRule = `\n/* Draply: .${className} */\n.${className} {\n${formatProps(props)}\n}\n`;
|
|
463
|
-
|
|
464
|
-
let cssContent = fs.readFileSync(targetCSS, 'utf8');
|
|
465
|
-
|
|
466
|
-
// Check if rule already exists (might have been added before)
|
|
467
|
-
if (cssContent.includes(`.${className}`)) {
|
|
468
|
-
// Modify existing rule instead
|
|
469
|
-
return findAndModifyCSSRuleSingle(targetCSS, className, props);
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
cssContent += newRule;
|
|
473
|
-
fs.writeFileSync(targetCSS, cssContent, 'utf8');
|
|
474
|
-
|
|
475
|
-
return { applied: true, file: targetCSS };
|
|
476
|
-
} catch { /* skip */ }
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
return { applied: false };
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
// Helper: modify rule in a specific file
|
|
483
|
-
function findAndModifyCSSRuleSingle(file, className, props) {
|
|
484
|
-
const ruleRegex = new RegExp(
|
|
485
|
-
`(\\.${escapeRegex(className)}\\s*\\{)([^}]*)(\\})`, 's'
|
|
486
|
-
);
|
|
487
|
-
try {
|
|
488
|
-
let content = fs.readFileSync(file, 'utf8');
|
|
489
|
-
const match = content.match(ruleRegex);
|
|
490
|
-
if (!match) return { applied: false };
|
|
491
|
-
|
|
492
|
-
let newBlock = match[2];
|
|
493
|
-
for (const [prop, val] of Object.entries(props)) {
|
|
494
|
-
const propRegex = new RegExp(`(${escapeRegex(prop)}\\s*:\\s*)([^;]+)(;)`, 'g');
|
|
495
|
-
if (propRegex.test(newBlock)) {
|
|
496
|
-
newBlock = newBlock.replace(
|
|
497
|
-
new RegExp(`(${escapeRegex(prop)}\\s*:\\s*)([^;]+)(;)`, 'g'),
|
|
498
|
-
`$1${val}$3`
|
|
499
|
-
);
|
|
500
|
-
} else {
|
|
501
|
-
newBlock = newBlock.trimEnd() + `\n ${prop}: ${val};\n`;
|
|
502
|
-
}
|
|
503
|
-
}
|
|
504
|
-
content = content.replace(ruleRegex, `$1${newBlock}$3`);
|
|
505
|
-
fs.writeFileSync(file, content, 'utf8');
|
|
506
|
-
return { applied: true, file };
|
|
507
|
-
} catch { return { applied: false }; }
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
// Strategy 3: Add inline style to JSX element
|
|
511
|
-
function modifyInlineStyle(root, className, props) {
|
|
512
|
-
const jsxFiles = walkDir(root, ['.jsx', '.tsx']);
|
|
513
|
-
|
|
514
|
-
for (const file of jsxFiles) {
|
|
515
|
-
const rel = path.relative(root, file);
|
|
516
|
-
if (rel.includes('node_modules')) continue;
|
|
517
|
-
|
|
518
|
-
try {
|
|
519
|
-
let content = fs.readFileSync(file, 'utf8');
|
|
520
|
-
if (!content.includes(className)) continue;
|
|
521
|
-
|
|
522
|
-
// Find element with this className
|
|
523
|
-
// Pattern: className="...nexus-hero..." or className={'...nexus-hero...'}
|
|
524
|
-
const elementRegex = new RegExp(
|
|
525
|
-
`(<[a-zA-Z][a-zA-Z0-9]*[^>]*className=["'{][^"'}]*${escapeRegex(className)}[^"'}]*["'}])([^>]*>|\\s*/>)`,
|
|
526
|
-
's'
|
|
527
|
-
);
|
|
528
|
-
|
|
529
|
-
const match = content.match(elementRegex);
|
|
530
|
-
if (!match) continue;
|
|
531
|
-
|
|
532
|
-
const fullTag = match[0];
|
|
533
|
-
const jsxProps = propsToJSXStyle(props);
|
|
534
|
-
|
|
535
|
-
// Check if element already has a style prop
|
|
536
|
-
if (fullTag.includes('style=')) {
|
|
537
|
-
// Modify existing style prop — add/update properties
|
|
538
|
-
const styleRegex = /style=\{\{([^}]*)\}\}/;
|
|
539
|
-
const styleMatch = fullTag.match(styleRegex);
|
|
540
|
-
if (styleMatch) {
|
|
541
|
-
let existingStyles = styleMatch[1];
|
|
542
|
-
for (const [camelProp, val] of Object.entries(jsxProps)) {
|
|
543
|
-
const propRegex = new RegExp(`${camelProp}\\s*:\\s*['"][^'"]*['"]`, 'g');
|
|
544
|
-
if (propRegex.test(existingStyles)) {
|
|
545
|
-
existingStyles = existingStyles.replace(propRegex, `${camelProp}: '${val}'`);
|
|
546
|
-
} else {
|
|
547
|
-
existingStyles = existingStyles.trim();
|
|
548
|
-
if (existingStyles && !existingStyles.endsWith(',')) existingStyles += ',';
|
|
549
|
-
existingStyles += ` ${camelProp}: '${val}'`;
|
|
550
|
-
}
|
|
551
|
-
}
|
|
552
|
-
const newTag = fullTag.replace(styleRegex, `style={{${existingStyles}}}`);
|
|
553
|
-
content = content.replace(fullTag, newTag);
|
|
554
|
-
}
|
|
555
|
-
} else {
|
|
556
|
-
// Add new style prop
|
|
557
|
-
const styleStr = Object.entries(jsxProps)
|
|
558
|
-
.map(([k, v]) => `${k}: '${v}'`)
|
|
559
|
-
.join(', ');
|
|
560
|
-
const insertion = ` style={{${styleStr}}}`;
|
|
561
|
-
// Insert before the closing > or />
|
|
562
|
-
const newTag = fullTag.replace(match[2], insertion + match[2]);
|
|
563
|
-
content = content.replace(fullTag, newTag);
|
|
564
|
-
}
|
|
565
|
-
|
|
566
|
-
fs.writeFileSync(file, content, 'utf8');
|
|
567
|
-
return { applied: true, file };
|
|
568
|
-
} catch { /* skip */ }
|
|
569
|
-
}
|
|
570
|
-
|
|
571
|
-
return { applied: false };
|
|
572
|
-
}
|
|
573
|
-
|
|
574
|
-
// Strategy 4: Find any CSS file in the project and append the rule
|
|
575
|
-
function appendToAnyCSSFile(root, className, props) {
|
|
576
|
-
// Priority: index.css, App.css, globals.css, main.css, styles.css
|
|
577
|
-
const candidates = [
|
|
578
|
-
'src/index.css', 'src/App.css', 'src/app/globals.css', 'src/globals.css',
|
|
579
|
-
'src/main.css', 'src/styles.css', 'styles/globals.css', 'app/globals.css',
|
|
580
|
-
'src/styles/globals.css'
|
|
581
|
-
];
|
|
582
|
-
|
|
583
|
-
for (const candidate of candidates) {
|
|
584
|
-
const fullPath = path.join(root, candidate);
|
|
585
|
-
if (fs.existsSync(fullPath)) {
|
|
586
|
-
try {
|
|
587
|
-
let content = fs.readFileSync(fullPath, 'utf8');
|
|
588
|
-
// Check if rule already exists
|
|
589
|
-
if (content.includes(`.${className}`)) {
|
|
590
|
-
return findAndModifyCSSRuleSingle(fullPath, className, props);
|
|
591
|
-
}
|
|
592
|
-
const newRule = `\n/* Draply: .${className} */\n.${className} {\n${formatProps(props)}\n}\n`;
|
|
593
|
-
content += newRule;
|
|
594
|
-
fs.writeFileSync(fullPath, content, 'utf8');
|
|
595
|
-
return { applied: true, file: fullPath };
|
|
596
|
-
} catch { /* skip */ }
|
|
597
|
-
}
|
|
598
|
-
}
|
|
599
|
-
|
|
600
|
-
// Last resort: find ANY css file
|
|
601
|
-
const allCSS = walkDir(root, ['.css']);
|
|
602
|
-
for (const file of allCSS) {
|
|
603
|
-
const rel = path.relative(root, file);
|
|
604
|
-
if (rel.includes('node_modules') || rel.includes('.draply') || rel.includes('.next')) continue;
|
|
605
|
-
try {
|
|
606
|
-
let content = fs.readFileSync(file, 'utf8');
|
|
607
|
-
if (content.includes(`.${className}`)) {
|
|
608
|
-
return findAndModifyCSSRuleSingle(file, className, props);
|
|
609
|
-
}
|
|
610
|
-
const newRule = `\n/* Draply: .${className} */\n.${className} {\n${formatProps(props)}\n}\n`;
|
|
611
|
-
content += newRule;
|
|
612
|
-
fs.writeFileSync(file, content, 'utf8');
|
|
613
|
-
return { applied: true, file };
|
|
614
|
-
} catch { /* skip */ }
|
|
615
|
-
}
|
|
616
|
-
|
|
617
|
-
return { applied: false };
|
|
618
|
-
}
|
|
619
|
-
|
|
620
|
-
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
621
|
-
|
|
622
|
-
function escapeRegex(str) {
|
|
623
|
-
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
624
|
-
}
|
|
625
|
-
|
|
626
|
-
function formatProps(props) {
|
|
627
|
-
return Object.entries(props).map(([k, v]) => ` ${k}: ${v};`).join('\n');
|
|
628
|
-
}
|
|
629
|
-
|
|
630
|
-
function camelCase(str) {
|
|
631
|
-
return str.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
632
|
-
}
|
|
633
|
-
|
|
634
|
-
function propsToJSXStyle(props) {
|
|
635
|
-
const result = {};
|
|
636
|
-
for (const [prop, val] of Object.entries(props)) {
|
|
637
|
-
result[camelCase(prop)] = val;
|
|
638
|
-
}
|
|
639
|
-
return result;
|
|
640
|
-
}
|
|
381
|
+
process.on('SIGINT', () => { console.log('\n \x1b[90mDraply stopped\x1b[0m\n'); process.exit(0); });
|