draply-dev 1.3.9 → 1.4.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 +218 -293
- package/package.json +1 -1
- package/src/overlay.js +105 -149
- package/src/draply-features.js +0 -625
package/bin/cli.js
CHANGED
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
4
|
const http = require('http');
|
|
5
|
-
const https = require('https');
|
|
6
5
|
const fs = require('fs');
|
|
7
6
|
const path = require('path');
|
|
8
7
|
const zlib = require('zlib');
|
|
@@ -12,15 +11,16 @@ const targetPort = parseInt(args[0]) || 3000;
|
|
|
12
11
|
const proxyPort = parseInt(args[1]) || 4000;
|
|
13
12
|
const targetHost = args[2] || 'localhost';
|
|
14
13
|
|
|
14
|
+
const pkg = require('../package.json');
|
|
15
15
|
const OVERLAY_JS = fs.readFileSync(path.join(__dirname, '../src/overlay.js'), 'utf8');
|
|
16
|
-
|
|
16
|
+
// Unique marker that does NOT appear inside overlay.js itself
|
|
17
17
|
const MARKER = 'data-ps-done="1"';
|
|
18
|
-
const INJECT_TAG = `\n<link rel="stylesheet" href="/draply.css">\n<script ${MARKER}>\n${OVERLAY_JS}\n</script
|
|
18
|
+
const INJECT_TAG = `\n<link rel="stylesheet" href="/draply.css">\n<script ${MARKER}>\n${OVERLAY_JS}\n</script>`;
|
|
19
19
|
|
|
20
20
|
function injectOverlay(html) {
|
|
21
21
|
if (html.includes(MARKER)) return html;
|
|
22
|
-
if (html.includes('</body>')) return html.replace(/<\/body>/i, INJECT_TAG + '\n</body>');
|
|
23
|
-
if (html.includes('</html>')) return html.replace(/<\/html>/i, INJECT_TAG + '\n</html>');
|
|
22
|
+
if (html.includes('</body>')) return html.replace(/<\/body>/i, () => INJECT_TAG + '\n</body>');
|
|
23
|
+
if (html.includes('</html>')) return html.replace(/<\/html>/i, () => INJECT_TAG + '\n</html>');
|
|
24
24
|
return html + INJECT_TAG;
|
|
25
25
|
}
|
|
26
26
|
|
|
@@ -35,169 +35,62 @@ function decode(headers, chunks) {
|
|
|
35
35
|
});
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
//
|
|
38
|
+
// Создаём пустой draply.css при старте если его нет
|
|
39
39
|
const projectRoot = process.cwd();
|
|
40
|
-
const
|
|
41
|
-
if (!fs.existsSync(
|
|
42
|
-
fs.
|
|
43
|
-
const gitignorePath = path.join(projectRoot, '.gitignore');
|
|
44
|
-
if (fs.existsSync(gitignorePath)) {
|
|
45
|
-
const gi = fs.readFileSync(gitignorePath, 'utf8');
|
|
46
|
-
if (!gi.includes('.draply')) {
|
|
47
|
-
fs.appendFileSync(gitignorePath, '\n# Draply temp files\n.draply/\n', 'utf8');
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
const overridesPath = path.join(draplyDir, 'overrides.css');
|
|
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;
|
|
74
|
-
}
|
|
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;
|
|
40
|
+
const overridesPath = path.join(projectRoot, 'draply.css');
|
|
41
|
+
if (!fs.existsSync(overridesPath)) {
|
|
42
|
+
fs.writeFileSync(overridesPath, '/* draply */\n', 'utf8');
|
|
98
43
|
}
|
|
99
44
|
|
|
100
|
-
// ── Call AI API (Gemini or Groq) ──────────────────────────────────────────────
|
|
101
|
-
function callAI(apiKey, prompt, provider) {
|
|
102
|
-
if (provider === 'groq') return callGroq(apiKey, prompt);
|
|
103
|
-
return callGemini(apiKey, prompt);
|
|
104
|
-
}
|
|
105
45
|
|
|
106
|
-
function callGroq(apiKey, prompt) {
|
|
107
|
-
return new Promise((resolve, reject) => {
|
|
108
|
-
const body = JSON.stringify({
|
|
109
|
-
model: 'llama-3.3-70b-versatile',
|
|
110
|
-
messages: [{ role: 'user', content: prompt }],
|
|
111
|
-
temperature: 0.1,
|
|
112
|
-
max_tokens: 8192
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
const options = {
|
|
116
|
-
hostname: 'api.groq.com',
|
|
117
|
-
path: '/openai/v1/chat/completions',
|
|
118
|
-
method: 'POST',
|
|
119
|
-
headers: {
|
|
120
|
-
'Content-Type': 'application/json',
|
|
121
|
-
'Authorization': `Bearer ${apiKey}`,
|
|
122
|
-
'Content-Length': Buffer.byteLength(body)
|
|
123
|
-
}
|
|
124
|
-
};
|
|
125
|
-
|
|
126
|
-
const req = https.request(options, res => {
|
|
127
|
-
const chunks = [];
|
|
128
|
-
res.on('data', c => chunks.push(c));
|
|
129
|
-
res.on('end', () => {
|
|
130
|
-
try {
|
|
131
|
-
const data = JSON.parse(Buffer.concat(chunks).toString());
|
|
132
|
-
if (data.error) {
|
|
133
|
-
reject(new Error(data.error.message || 'Groq API error'));
|
|
134
|
-
return;
|
|
135
|
-
}
|
|
136
|
-
const text = data.choices?.[0]?.message?.content || '';
|
|
137
|
-
resolve(text);
|
|
138
|
-
} catch (e) { reject(e); }
|
|
139
|
-
});
|
|
140
|
-
});
|
|
141
|
-
req.on('error', reject);
|
|
142
|
-
req.write(body);
|
|
143
|
-
req.end();
|
|
144
|
-
});
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
function callGemini(apiKey, prompt) {
|
|
148
|
-
return new Promise((resolve, reject) => {
|
|
149
|
-
const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash-lite:generateContent?key=${apiKey}`;
|
|
150
|
-
const body = JSON.stringify({
|
|
151
|
-
contents: [{ parts: [{ text: prompt }] }],
|
|
152
|
-
generationConfig: { temperature: 0.1, maxOutputTokens: 8192 }
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
const parsed = new URL(url);
|
|
156
|
-
const options = {
|
|
157
|
-
hostname: parsed.hostname,
|
|
158
|
-
path: parsed.pathname + parsed.search,
|
|
159
|
-
method: 'POST',
|
|
160
|
-
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }
|
|
161
|
-
};
|
|
162
|
-
|
|
163
|
-
const req = https.request(options, res => {
|
|
164
|
-
const chunks = [];
|
|
165
|
-
res.on('data', c => chunks.push(c));
|
|
166
|
-
res.on('end', () => {
|
|
167
|
-
try {
|
|
168
|
-
const data = JSON.parse(Buffer.concat(chunks).toString());
|
|
169
|
-
if (data.error) {
|
|
170
|
-
reject(new Error(data.error.message || 'Gemini API error'));
|
|
171
|
-
return;
|
|
172
|
-
}
|
|
173
|
-
const text = data.candidates?.[0]?.content?.parts?.[0]?.text || '';
|
|
174
|
-
resolve(text);
|
|
175
|
-
} catch (e) { reject(e); }
|
|
176
|
-
});
|
|
177
|
-
});
|
|
178
|
-
req.on('error', reject);
|
|
179
|
-
req.write(body);
|
|
180
|
-
req.end();
|
|
181
|
-
});
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
185
|
-
// HTTP SERVER
|
|
186
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
187
46
|
const server = http.createServer((req, res) => {
|
|
188
47
|
|
|
48
|
+
// CORS preflight
|
|
189
49
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
190
50
|
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,OPTIONS');
|
|
191
51
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
192
52
|
if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; }
|
|
193
53
|
|
|
54
|
+
// ── Upload endpoint ─────────────────────────────────────────────────────────
|
|
55
|
+
if (req.url === '/draply-upload' && req.method === 'POST') {
|
|
56
|
+
let body = '';
|
|
57
|
+
req.on('data', c => body += c);
|
|
58
|
+
req.on('end', () => {
|
|
59
|
+
try {
|
|
60
|
+
const { name, base64 } = JSON.parse(body);
|
|
61
|
+
const assetsDir = path.join(process.cwd(), 'assets_draply');
|
|
62
|
+
if (!fs.existsSync(assetsDir)) fs.mkdirSync(assetsDir);
|
|
63
|
+
|
|
64
|
+
const ext = path.extname(name) || '.png';
|
|
65
|
+
const base = path.basename(name, ext);
|
|
66
|
+
let finalName = name;
|
|
67
|
+
let counter = 1;
|
|
68
|
+
while (fs.existsSync(path.join(assetsDir, finalName))) {
|
|
69
|
+
finalName = `${base}-${counter}${ext}`;
|
|
70
|
+
counter++;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const data = base64.replace(/^data:image\/\w+;base64,/, '');
|
|
74
|
+
fs.writeFileSync(path.join(assetsDir, finalName), data, 'base64');
|
|
75
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
76
|
+
res.end(JSON.stringify({ ok: true, url: `./assets_draply/${finalName}` }));
|
|
77
|
+
} catch (e) {
|
|
78
|
+
res.writeHead(500); res.end(JSON.stringify({ ok: false, error: e.message }));
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
194
84
|
// ── Config endpoint (get/set API key) ───────────────────────────────────────
|
|
85
|
+
const configPath = path.join(process.cwd(), 'draply.config.json');
|
|
86
|
+
function loadConfig() { try { return JSON.parse(fs.readFileSync(configPath, 'utf8')); } catch { return {}; } }
|
|
87
|
+
function saveConfig(cfg) { fs.writeFileSync(configPath, JSON.stringify(cfg, null, 2), 'utf8'); }
|
|
88
|
+
|
|
195
89
|
if (req.url === '/draply-config') {
|
|
196
90
|
if (req.method === 'GET') {
|
|
197
91
|
const cfg = loadConfig();
|
|
198
92
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
199
|
-
|
|
200
|
-
res.end(JSON.stringify({ hasKey: !!cfg.apiKey, provider: cfg.provider || 'gemini' }));
|
|
93
|
+
res.end(JSON.stringify({ hasKey: !!cfg.apiKey, provider: cfg.provider || 'groq' }));
|
|
201
94
|
return;
|
|
202
95
|
}
|
|
203
96
|
if (req.method === 'POST') {
|
|
@@ -205,17 +98,12 @@ const server = http.createServer((req, res) => {
|
|
|
205
98
|
req.on('data', c => body += c);
|
|
206
99
|
req.on('end', () => {
|
|
207
100
|
try {
|
|
208
|
-
const {
|
|
209
|
-
const cfg = loadConfig();
|
|
210
|
-
if (apiKey !== undefined) cfg.apiKey = apiKey;
|
|
211
|
-
if (provider) cfg.provider = provider;
|
|
101
|
+
const cfg = { ...loadConfig(), ...JSON.parse(body) };
|
|
212
102
|
saveConfig(cfg);
|
|
213
|
-
console.log(` \x1b[32m✓\x1b[0m API key saved (${cfg.provider || 'gemini'})`);
|
|
214
103
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
215
104
|
res.end(JSON.stringify({ ok: true }));
|
|
216
105
|
} catch (e) {
|
|
217
|
-
res.writeHead(500
|
|
218
|
-
res.end(JSON.stringify({ ok: false, error: e.message }));
|
|
106
|
+
res.writeHead(500); res.end(JSON.stringify({ ok: false, error: e.message }));
|
|
219
107
|
}
|
|
220
108
|
});
|
|
221
109
|
return;
|
|
@@ -223,148 +111,200 @@ const server = http.createServer((req, res) => {
|
|
|
223
111
|
}
|
|
224
112
|
|
|
225
113
|
// ── AI Apply endpoint ───────────────────────────────────────────────────────
|
|
226
|
-
if (req.url === '/draply-ai-apply' && req.method === 'POST') {
|
|
114
|
+
if ((req.url === '/draply-ai-apply' || req.url === '/draply-save') && req.method === 'POST') {
|
|
227
115
|
let body = '';
|
|
228
116
|
req.on('data', c => body += c);
|
|
229
117
|
req.on('end', async () => {
|
|
230
118
|
try {
|
|
231
119
|
const { changes } = JSON.parse(body);
|
|
232
120
|
const cfg = loadConfig();
|
|
121
|
+
|
|
122
|
+
// If no config, fallback to saving standard CSS
|
|
233
123
|
if (!cfg.apiKey) {
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
124
|
+
const lines = [];
|
|
125
|
+
for (const ch of (changes || [])) {
|
|
126
|
+
if (!ch.selector) continue;
|
|
127
|
+
const props = Object.entries(ch.props).map(([k, v]) => ` ${k}: ${v};`).join('\n');
|
|
128
|
+
const label = ch.selector.split('>').pop().trim();
|
|
129
|
+
lines.push(`/* ${label} */\n${ch.selector} {\n${props}\n}`);
|
|
130
|
+
}
|
|
131
|
+
const css = '/* draply */\n\n' + lines.join('\n\n') + '\n';
|
|
132
|
+
fs.writeFileSync(overridesPath, css, 'utf8');
|
|
133
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
134
|
+
res.end(JSON.stringify({ ok: true, fallback: true }));
|
|
135
|
+
return;
|
|
237
136
|
}
|
|
238
137
|
|
|
239
|
-
// Group changes by file
|
|
240
138
|
const results = [];
|
|
241
|
-
const
|
|
139
|
+
const fileMap = new Map();
|
|
242
140
|
|
|
141
|
+
// Check for google fonts
|
|
142
|
+
const reqGoogleFonts = new Set();
|
|
243
143
|
for (const ch of (changes || [])) {
|
|
244
|
-
if (
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
results.push({ selector: ch.selector, ok: false, reason: 'No class name found' });
|
|
248
|
-
continue;
|
|
249
|
-
}
|
|
250
|
-
const found = findFileForClass(projectRoot, className);
|
|
251
|
-
if (!found) {
|
|
252
|
-
results.push({ selector: ch.selector, ok: false, reason: `"${className}" not found in source` });
|
|
253
|
-
continue;
|
|
144
|
+
if (ch.props && ch.props.googleFont) {
|
|
145
|
+
reqGoogleFonts.add(ch.props.googleFont);
|
|
146
|
+
delete ch.props.googleFont;
|
|
254
147
|
}
|
|
255
|
-
|
|
256
|
-
|
|
148
|
+
}
|
|
149
|
+
if (reqGoogleFonts.size > 0) {
|
|
150
|
+
const targetHTML = path.join(process.cwd(), 'index.html');
|
|
151
|
+
if (fs.existsSync(targetHTML)) {
|
|
152
|
+
let html = fs.readFileSync(targetHTML, 'utf8');
|
|
153
|
+
reqGoogleFonts.forEach(font => {
|
|
154
|
+
const link = `<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=${encodeURIComponent(font)}:wght@400;700&display=swap">`;
|
|
155
|
+
if (!html.includes(link)) {
|
|
156
|
+
html = html.replace('</head>', ` ${link}\n</head>`);
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
fs.writeFileSync(targetHTML, html, 'utf8');
|
|
257
160
|
}
|
|
258
|
-
fileChanges.get(found.file).items.push({ selector: ch.selector, className, props: ch.props });
|
|
259
161
|
}
|
|
260
162
|
|
|
261
|
-
//
|
|
262
|
-
for (const
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
}
|
|
163
|
+
// 1. Group by exact file (thanks to React Fiber, fallback to index.html)
|
|
164
|
+
for (const ch of (changes || [])) {
|
|
165
|
+
let targetFile = ch.exactFile;
|
|
166
|
+
if (!targetFile || !fs.existsSync(targetFile)) {
|
|
167
|
+
const fallbackHTML = path.join(process.cwd(), 'index.html');
|
|
168
|
+
if (fs.existsSync(fallbackHTML)) {
|
|
169
|
+
targetFile = fallbackHTML;
|
|
170
|
+
} else {
|
|
171
|
+
results.push({ selector: ch.selector, ok: false, reason: 'Source file not linked' });
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
if (!fileMap.has(targetFile)) {
|
|
176
|
+
fileMap.set(targetFile, { content: fs.readFileSync(targetFile, 'utf8'), items: [] });
|
|
275
177
|
}
|
|
276
|
-
|
|
178
|
+
fileMap.get(targetFile).items.push(ch);
|
|
179
|
+
}
|
|
277
180
|
|
|
278
|
-
|
|
181
|
+
// 2. Call AI per file using XML Patches
|
|
182
|
+
for (const [filePath, { content: rawContent, items }] of fileMap) {
|
|
183
|
+
|
|
279
184
|
let changesBlock = '';
|
|
185
|
+
const content = rawContent.replace(/\r\n/g, '\n');
|
|
186
|
+
const lines = content.split('\n');
|
|
187
|
+
let classNamesToFind = items.map(c => {
|
|
188
|
+
const parts = c.selector.split('.');
|
|
189
|
+
return parts.length > 1 ? parts.pop() : '';
|
|
190
|
+
}).filter(Boolean);
|
|
191
|
+
|
|
192
|
+
let ctxStart = -1, ctxEnd = -1;
|
|
193
|
+
for (let i = 0; i < lines.length; i++) {
|
|
194
|
+
if (classNamesToFind.some(cls => lines[i].includes(cls)) || (items.length === 1 && items[0].selector && lines[i].includes(items[0].selector.split('#').pop()))) {
|
|
195
|
+
const s = Math.max(0, i - 40);
|
|
196
|
+
const e = Math.min(lines.length - 1, i + 40);
|
|
197
|
+
if (ctxStart === -1 || s < ctxStart) ctxStart = s;
|
|
198
|
+
if (ctxEnd === -1 || e > ctxEnd) ctxEnd = e;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (ctxStart === -1) { ctxStart = 0; ctxEnd = Math.min(lines.length - 1, 150); }
|
|
202
|
+
|
|
203
|
+
const snippet = lines.slice(ctxStart, ctxEnd + 1).join('\n');
|
|
204
|
+
|
|
280
205
|
items.forEach(item => {
|
|
281
206
|
const propsStr = Object.entries(item.props).map(([k, v]) => ` ${k}: ${v}`).join('\n');
|
|
282
|
-
changesBlock += `\
|
|
207
|
+
changesBlock += `\nTarget Selector: ${item.selector}\nChanges:\n${propsStr}\n`;
|
|
283
208
|
});
|
|
284
209
|
|
|
285
210
|
const prompt = `You are a strict code editor applying style changes to a file.
|
|
286
211
|
|
|
287
|
-
FILE: ${
|
|
212
|
+
FILE: ${path.basename(filePath)} (lines ${ctxStart + 1}-${ctxEnd + 1})
|
|
288
213
|
\`\`\`
|
|
289
214
|
${snippet}
|
|
290
215
|
\`\`\`
|
|
291
216
|
|
|
292
|
-
CHANGES:
|
|
217
|
+
CHANGES (Apply to HTML/React elements matching selectors):
|
|
293
218
|
${changesBlock}
|
|
219
|
+
|
|
294
220
|
IMPORTANT: Return your changes wrapped in <patch> tags containing <search> and <replace> blocks. DO NOT use JSON.
|
|
295
221
|
|
|
296
222
|
Rules:
|
|
297
|
-
- The content inside <search> must be an EXACT substring from the file snippet above.
|
|
298
|
-
-
|
|
299
|
-
-
|
|
223
|
+
- The content inside <search> must be an EXACT substring from the file snippet above (including indentation).
|
|
224
|
+
- Update HTML/JSX inline styles or Tailwind classes appropriately.
|
|
225
|
+
- REPLACE old style values if they exist, DO NOT duplicate keys!
|
|
226
|
+
- If 'innerText' is provided in Changes, update the text content inside the target HTML element!
|
|
227
|
+
- If 'src' is provided in Changes, update the src attribute of the target HTML element!
|
|
300
228
|
|
|
301
229
|
Example response:
|
|
302
230
|
<patch>
|
|
303
231
|
<search>
|
|
304
|
-
<
|
|
232
|
+
<button className="btn" style={{ padding: '10px' }}>
|
|
305
233
|
</search>
|
|
306
234
|
<replace>
|
|
307
|
-
<
|
|
235
|
+
<button className="btn" style={{ padding: '10px', color: '#ff0000' }}>
|
|
308
236
|
</replace>
|
|
309
237
|
</patch>
|
|
310
238
|
|
|
311
239
|
Return ONLY the patch blocks.`;
|
|
312
240
|
|
|
241
|
+
let apiResult = '';
|
|
313
242
|
try {
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
243
|
+
// Groq call
|
|
244
|
+
const groqBody = JSON.stringify({
|
|
245
|
+
model: 'llama-3.3-70b-versatile',
|
|
246
|
+
messages: [{ role: 'user', content: prompt }],
|
|
247
|
+
temperature: 0.1, max_tokens: 8192
|
|
248
|
+
});
|
|
249
|
+
apiResult = await new Promise((resolve, reject) => {
|
|
250
|
+
const https = require('https');
|
|
251
|
+
const req = https.request({
|
|
252
|
+
hostname: 'api.groq.com', path: '/openai/v1/chat/completions',
|
|
253
|
+
method: 'POST',
|
|
254
|
+
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${cfg.apiKey}`, 'Content-Length': Buffer.byteLength(groqBody) }
|
|
255
|
+
}, res => {
|
|
256
|
+
const chunks = [];
|
|
257
|
+
res.on('data', c => chunks.push(c));
|
|
258
|
+
res.on('end', () => {
|
|
259
|
+
const data = JSON.parse(Buffer.concat(chunks).toString());
|
|
260
|
+
if (data.error) reject(new Error(data.error.message));
|
|
261
|
+
else resolve(data.choices?.[0]?.message?.content || '');
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
req.on('error', reject); req.write(groqBody); req.end();
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
const patches = [];
|
|
268
|
+
const patchRegex = /<search>([\s\S]*?)<\/search>[\s\S]*?<replace>([\s\S]*?)<\/replace>/g;
|
|
269
|
+
let match;
|
|
270
|
+
while ((match = patchRegex.exec(apiResult)) !== null) {
|
|
271
|
+
let s = match[1], r = match[2];
|
|
272
|
+
s = s.replace(/^\n+/, '').replace(/\n+$/, '').replace(/\r/g, '');
|
|
273
|
+
r = r.replace(/^\n+/, '').replace(/\n+$/, '').replace(/\r/g, '');
|
|
274
|
+
if (s.trim()) patches.push({ search: s, replace: r });
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
let newContent = content;
|
|
278
|
+
let applied = 0;
|
|
279
|
+
for (const patch of patches) {
|
|
280
|
+
if (newContent.includes(patch.search)) {
|
|
281
|
+
newContent = newContent.replace(patch.search, patch.replace);
|
|
282
|
+
applied++;
|
|
283
|
+
} else {
|
|
284
|
+
// Fallback to whitespace-agnostic matching
|
|
285
|
+
const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
286
|
+
const looseRegexStr = escapeRegExp(patch.search.trim()).replace(/\s+/g, '\\s+');
|
|
287
|
+
const looseRegex = new RegExp(looseRegexStr);
|
|
288
|
+
if (looseRegex.test(newContent)) {
|
|
289
|
+
newContent = newContent.replace(looseRegex, patch.replace);
|
|
290
|
+
applied++;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (applied > 0) {
|
|
296
|
+
// Restore original line endings if they were \r\n
|
|
297
|
+
const finalContent = rawContent.includes('\r\n') ? newContent.replace(/\n/g, '\r\n') : newContent;
|
|
298
|
+
fs.writeFileSync(filePath, finalContent, 'utf8');
|
|
299
|
+
console.log(` \x1b[32m✓\x1b[0m Modified ${path.basename(filePath)} (${applied} patch(es))`);
|
|
300
|
+
items.forEach(item => results.push({ selector: item.selector, ok: true }));
|
|
301
|
+
} else {
|
|
302
|
+
console.warn(` \x1b[33m⚠\x1b[0m AI matched 0 patches in ${path.basename(filePath)}`);
|
|
303
|
+
items.forEach(item => results.push({ selector: item.selector, ok: false, reason: 'AI patch failed to match' }));
|
|
304
|
+
}
|
|
305
|
+
} catch (e) {
|
|
306
|
+
console.error(` \x1b[31m✖\x1b[0m AI Error for ${path.basename(filePath)}:`, e.message);
|
|
307
|
+
items.forEach(item => results.push({ selector: item.selector, ok: false, reason: e.message }));
|
|
368
308
|
}
|
|
369
309
|
}
|
|
370
310
|
|
|
@@ -380,34 +320,7 @@ Return ONLY the patch blocks.`;
|
|
|
380
320
|
return;
|
|
381
321
|
}
|
|
382
322
|
|
|
383
|
-
// ──
|
|
384
|
-
if (req.url === '/draply-save' && req.method === 'POST') {
|
|
385
|
-
let body = '';
|
|
386
|
-
req.on('data', c => body += c);
|
|
387
|
-
req.on('end', () => {
|
|
388
|
-
try {
|
|
389
|
-
const { changes } = JSON.parse(body);
|
|
390
|
-
const lines = [];
|
|
391
|
-
for (const ch of (changes || [])) {
|
|
392
|
-
if (!ch.selector) continue;
|
|
393
|
-
const props = Object.entries(ch.props).map(([k, v]) => ` ${k}: ${v};`).join('\n');
|
|
394
|
-
const label = ch.selector.split('>').pop().trim();
|
|
395
|
-
lines.push(`/* ${label} */\n${ch.selector} {\n${props}\n}`);
|
|
396
|
-
}
|
|
397
|
-
const css = '/* draply — ' + new Date().toLocaleString('ru-RU') + ' */\n\n' + lines.join('\n\n') + '\n';
|
|
398
|
-
fs.writeFileSync(overridesPath, css, 'utf8');
|
|
399
|
-
console.log(` \x1b[32m✓\x1b[0m Saved ${changes.length} changes to .draply/overrides.css`);
|
|
400
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
401
|
-
res.end(JSON.stringify({ ok: true }));
|
|
402
|
-
} catch (e) {
|
|
403
|
-
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
404
|
-
res.end(JSON.stringify({ ok: false, error: e.message }));
|
|
405
|
-
}
|
|
406
|
-
});
|
|
407
|
-
return;
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
// ── Serve CSS ───────────────────────────────────────────────────────────────
|
|
323
|
+
// ── Draply: Serve CSS ──────────────────────────────────────────────────────
|
|
411
324
|
if (req.url.split('?')[0] === '/draply.css') {
|
|
412
325
|
const isModule = req.headers['sec-fetch-dest'] === 'script' || req.url.includes('import');
|
|
413
326
|
try {
|
|
@@ -419,7 +332,7 @@ Return ONLY the patch blocks.`;
|
|
|
419
332
|
res.writeHead(200, { 'Content-Type': 'text/css', 'cache-control': 'no-store' });
|
|
420
333
|
res.end(css);
|
|
421
334
|
}
|
|
422
|
-
} catch {
|
|
335
|
+
} catch (e) {
|
|
423
336
|
if (isModule) {
|
|
424
337
|
res.writeHead(200, { 'Content-Type': 'application/javascript' });
|
|
425
338
|
res.end('export default {};');
|
|
@@ -431,7 +344,9 @@ Return ONLY the patch blocks.`;
|
|
|
431
344
|
return;
|
|
432
345
|
}
|
|
433
346
|
|
|
434
|
-
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
// ── Proxy to dev server ────────────────────────────────────────────────────
|
|
435
350
|
const opts = {
|
|
436
351
|
hostname: targetHost,
|
|
437
352
|
port: targetPort,
|
|
@@ -468,8 +383,8 @@ Return ONLY the patch blocks.`;
|
|
|
468
383
|
pReq.on('error', () => {
|
|
469
384
|
res.writeHead(502, { 'Content-Type': 'text/html' });
|
|
470
385
|
res.end(`<!DOCTYPE html><html><body style="background:#0a0a0f;color:#e8e8f0;font-family:monospace;padding:60px;text-align:center">
|
|
471
|
-
<h2 style="color:#ff6b6b">⚠
|
|
472
|
-
<p style="color:#555;margin-top:16px"
|
|
386
|
+
<h2 style="color:#ff6b6b">⚠ Не могу достучаться до ${targetHost}:${targetPort}</h2>
|
|
387
|
+
<p style="color:#555;margin-top:16px">Убедись что dev сервер запущен, потом обнови страницу</p>
|
|
473
388
|
<script>setTimeout(()=>location.reload(), 2000)</script>
|
|
474
389
|
</body></html>`);
|
|
475
390
|
});
|
|
@@ -478,10 +393,20 @@ Return ONLY the patch blocks.`;
|
|
|
478
393
|
});
|
|
479
394
|
|
|
480
395
|
server.listen(proxyPort, () => {
|
|
481
|
-
console.log(
|
|
482
|
-
console.log(`
|
|
483
|
-
console.log(`
|
|
484
|
-
console.log(` \x1b[90mCtrl+C
|
|
396
|
+
console.log(`\n \x1b[32m●\x1b[0m Draply v${pkg.version} запущен\n`);
|
|
397
|
+
console.log(` Твой проект → \x1b[36mhttp://${targetHost}:${targetPort}\x1b[0m`);
|
|
398
|
+
console.log(` Открой это → \x1b[33mhttp://localhost:${proxyPort}\x1b[0m \x1b[32m← вот сюда заходи!\x1b[0m\n`);
|
|
399
|
+
console.log(` \x1b[90mCtrl+C чтобы остановить\x1b[0m\n`);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
server.on('error', (e) => {
|
|
403
|
+
if (e.code === 'EADDRINUSE') {
|
|
404
|
+
console.error(`\x1b[31m✖ Ошибка: Порт ${proxyPort} уже занят.\x1b[0m`);
|
|
405
|
+
console.log(`Попробуй другой порт: \x1b[33mnpx draply ${targetPort} ${proxyPort + 1}\x1b[0m`);
|
|
406
|
+
} else {
|
|
407
|
+
console.error(`\x1b[31m✖ Ошибка сервера:\x1b[0m`, e.message);
|
|
408
|
+
}
|
|
409
|
+
process.exit(1);
|
|
485
410
|
});
|
|
486
411
|
|
|
487
|
-
process.on('SIGINT', () => { console.log('\n \x1b[90mDraply
|
|
412
|
+
process.on('SIGINT', () => { console.log('\n \x1b[90mDraply остановлен\x1b[0m\n'); process.exit(0); });
|