draply-dev 1.3.8 → 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 +228 -297
- 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;
|
|
98
|
-
}
|
|
99
|
-
|
|
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
|
-
|
|
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
|
-
});
|
|
40
|
+
const overridesPath = path.join(projectRoot, 'draply.css');
|
|
41
|
+
if (!fs.existsSync(overridesPath)) {
|
|
42
|
+
fs.writeFileSync(overridesPath, '/* draply */\n', 'utf8');
|
|
145
43
|
}
|
|
146
44
|
|
|
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
45
|
|
|
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,142 +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;
|
|
144
|
+
if (ch.props && ch.props.googleFont) {
|
|
145
|
+
reqGoogleFonts.add(ch.props.googleFont);
|
|
146
|
+
delete ch.props.googleFont;
|
|
249
147
|
}
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
contextEnd = Math.min(lines.length - 1, i + 25);
|
|
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
|
+
}
|
|
275
174
|
}
|
|
276
|
-
|
|
175
|
+
if (!fileMap.has(targetFile)) {
|
|
176
|
+
fileMap.set(targetFile, { content: fs.readFileSync(targetFile, 'utf8'), items: [] });
|
|
177
|
+
}
|
|
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}
|
|
294
|
-
|
|
295
|
-
|
|
219
|
+
|
|
220
|
+
IMPORTANT: Return your changes wrapped in <patch> tags containing <search> and <replace> blocks. DO NOT use JSON.
|
|
296
221
|
|
|
297
222
|
Rules:
|
|
298
|
-
-
|
|
299
|
-
-
|
|
300
|
-
-
|
|
301
|
-
- If
|
|
302
|
-
- If
|
|
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!
|
|
303
228
|
|
|
304
229
|
Example response:
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
230
|
+
<patch>
|
|
231
|
+
<search>
|
|
232
|
+
<button className="btn" style={{ padding: '10px' }}>
|
|
233
|
+
</search>
|
|
234
|
+
<replace>
|
|
235
|
+
<button className="btn" style={{ padding: '10px', color: '#ff0000' }}>
|
|
236
|
+
</replace>
|
|
237
|
+
</patch>
|
|
238
|
+
|
|
239
|
+
Return ONLY the patch blocks.`;
|
|
240
|
+
|
|
241
|
+
let apiResult = '';
|
|
312
242
|
try {
|
|
313
|
-
|
|
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
|
-
|
|
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 }));
|
|
362
308
|
}
|
|
363
309
|
}
|
|
364
310
|
|
|
@@ -374,34 +320,7 @@ Example response:
|
|
|
374
320
|
return;
|
|
375
321
|
}
|
|
376
322
|
|
|
377
|
-
// ──
|
|
378
|
-
if (req.url === '/draply-save' && req.method === 'POST') {
|
|
379
|
-
let body = '';
|
|
380
|
-
req.on('data', c => body += c);
|
|
381
|
-
req.on('end', () => {
|
|
382
|
-
try {
|
|
383
|
-
const { changes } = JSON.parse(body);
|
|
384
|
-
const lines = [];
|
|
385
|
-
for (const ch of (changes || [])) {
|
|
386
|
-
if (!ch.selector) continue;
|
|
387
|
-
const props = Object.entries(ch.props).map(([k, v]) => ` ${k}: ${v};`).join('\n');
|
|
388
|
-
const label = ch.selector.split('>').pop().trim();
|
|
389
|
-
lines.push(`/* ${label} */\n${ch.selector} {\n${props}\n}`);
|
|
390
|
-
}
|
|
391
|
-
const css = '/* draply — ' + new Date().toLocaleString('ru-RU') + ' */\n\n' + lines.join('\n\n') + '\n';
|
|
392
|
-
fs.writeFileSync(overridesPath, css, 'utf8');
|
|
393
|
-
console.log(` \x1b[32m✓\x1b[0m Saved ${changes.length} changes to .draply/overrides.css`);
|
|
394
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
395
|
-
res.end(JSON.stringify({ ok: true }));
|
|
396
|
-
} catch (e) {
|
|
397
|
-
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
398
|
-
res.end(JSON.stringify({ ok: false, error: e.message }));
|
|
399
|
-
}
|
|
400
|
-
});
|
|
401
|
-
return;
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
// ── Serve CSS ───────────────────────────────────────────────────────────────
|
|
323
|
+
// ── Draply: Serve CSS ──────────────────────────────────────────────────────
|
|
405
324
|
if (req.url.split('?')[0] === '/draply.css') {
|
|
406
325
|
const isModule = req.headers['sec-fetch-dest'] === 'script' || req.url.includes('import');
|
|
407
326
|
try {
|
|
@@ -413,7 +332,7 @@ Example response:
|
|
|
413
332
|
res.writeHead(200, { 'Content-Type': 'text/css', 'cache-control': 'no-store' });
|
|
414
333
|
res.end(css);
|
|
415
334
|
}
|
|
416
|
-
} catch {
|
|
335
|
+
} catch (e) {
|
|
417
336
|
if (isModule) {
|
|
418
337
|
res.writeHead(200, { 'Content-Type': 'application/javascript' });
|
|
419
338
|
res.end('export default {};');
|
|
@@ -425,7 +344,9 @@ Example response:
|
|
|
425
344
|
return;
|
|
426
345
|
}
|
|
427
346
|
|
|
428
|
-
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
// ── Proxy to dev server ────────────────────────────────────────────────────
|
|
429
350
|
const opts = {
|
|
430
351
|
hostname: targetHost,
|
|
431
352
|
port: targetPort,
|
|
@@ -462,8 +383,8 @@ Example response:
|
|
|
462
383
|
pReq.on('error', () => {
|
|
463
384
|
res.writeHead(502, { 'Content-Type': 'text/html' });
|
|
464
385
|
res.end(`<!DOCTYPE html><html><body style="background:#0a0a0f;color:#e8e8f0;font-family:monospace;padding:60px;text-align:center">
|
|
465
|
-
<h2 style="color:#ff6b6b">⚠
|
|
466
|
-
<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>
|
|
467
388
|
<script>setTimeout(()=>location.reload(), 2000)</script>
|
|
468
389
|
</body></html>`);
|
|
469
390
|
});
|
|
@@ -472,10 +393,20 @@ Example response:
|
|
|
472
393
|
});
|
|
473
394
|
|
|
474
395
|
server.listen(proxyPort, () => {
|
|
475
|
-
console.log(
|
|
476
|
-
console.log(`
|
|
477
|
-
console.log(`
|
|
478
|
-
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);
|
|
479
410
|
});
|
|
480
411
|
|
|
481
|
-
process.on('SIGINT', () => { console.log('\n \x1b[90mDraply
|
|
412
|
+
process.on('SIGINT', () => { console.log('\n \x1b[90mDraply остановлен\x1b[0m\n'); process.exit(0); });
|