draply-dev 1.3.9 → 1.4.1

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 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
- 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
- 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>`;
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
- // ── Project & Config ──────────────────────────────────────────────────────────
38
+ // Создаём пустой draply.css при старте если его нет
39
39
  const projectRoot = process.cwd();
40
- const draplyDir = path.join(projectRoot, '.draply');
41
- if (!fs.existsSync(draplyDir)) {
42
- fs.mkdirSync(draplyDir, { recursive: true });
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
- // Mask API key for security only send if it exists
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 { apiKey, provider } = JSON.parse(body);
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, { 'Content-Type': 'application/json' });
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
- res.writeHead(400, { 'Content-Type': 'application/json' });
235
- res.end(JSON.stringify({ ok: false, error: 'No API key configured. Click ⚙ to set up.' }));
236
- return;
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 fileChanges = new Map(); // file -> { found, allProps }
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 (!ch.selector || !ch.props) continue;
245
- const className = extractClassName(ch.selector);
246
- if (!className) {
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
- if (!fileChanges.has(found.file)) {
256
- fileChanges.set(found.file, { found, items: [] });
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
- // One AI call per file (saves tokens & rate limit)
262
- for (const [filePath, { found, items }] of fileChanges) {
263
- console.log(` \x1b[36m🤖\x1b[0m AI applying ${items.length} changes → ${found.relativePath}`);
264
-
265
- // Find the relevant context snippet (~50 lines around the class usage)
266
- const lines = found.content.split('\n');
267
- let contextStart = 0, contextEnd = lines.length - 1;
268
- for (const item of items) {
269
- for (let i = 0; i < lines.length; i++) {
270
- if (lines[i].includes(item.className)) {
271
- contextStart = Math.min(contextStart || i, Math.max(0, i - 25));
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
+ }
174
+ }
175
+ if (!fileMap.has(targetFile)) {
176
+ fileMap.set(targetFile, { content: fs.readFileSync(targetFile, 'utf8'), items: [] });
275
177
  }
276
- const snippet = lines.slice(contextStart, contextEnd + 1).join('\n');
178
+ fileMap.get(targetFile).items.push(ch);
179
+ }
277
180
 
278
- // Build changes description
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 += `\nElement .${item.className}:\n${propsStr}\n`;
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: ${found.relativePath} (lines ${contextStart + 1}-${contextEnd + 1})
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
- - If adding properties to an existing CSS rule or JS object (like a style object), you MUST REPLACE the old values for those properties if they already exist. DO NOT CREATE DUPLICATE KEYS.
299
- - If the file is JSX, update the style object/prop without duplicating properties.
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! Use ONLY the exact text provided in the request. DO NOT add signatures, names, attributions, or "complete" the text based on context.
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
- <section className="hero" style={{ padding: '10px' }}>
232
+ <button className="btn" style={{ padding: '10px' }}>
305
233
  </search>
306
234
  <replace>
307
- <section className="hero" style={{ padding: '10px', color: '#ff0000' }}>
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
- const result = await callAI(cfg.apiKey, prompt, cfg.provider || 'groq');
315
-
316
- const patches = [];
317
- const patchRegex = /<search>([\s\S]*?)<\/search>[\s\S]*?<replace>([\s\S]*?)<\/replace>/g;
318
- let match;
319
- while ((match = patchRegex.exec(result)) !== null) {
320
- let s = match[1];
321
- let r = match[2];
322
- // Remove exactly one leading/trailing newline if AI added them around the tags
323
- if (s.startsWith('\n')) s = s.substring(1);
324
- if (s.endsWith('\n')) s = s.substring(0, s.length - 1);
325
- if (r.startsWith('\n')) r = r.substring(1);
326
- if (r.endsWith('\n')) r = r.substring(0, r.length - 1);
327
-
328
- if (s.trim()) patches.push({ search: s, replace: r });
329
- }
330
-
331
- if (patches.length === 0) {
332
- console.log(` \x1b[31m✗\x1b[0m AI returned no readable patches`);
333
- console.log(` Response: ${result.substring(0, 300)}`);
334
- items.forEach(item => results.push({ selector: item.selector, ok: false, reason: 'AI returned no format matches' }));
335
- continue;
336
- }
337
-
338
- if (!Array.isArray(patches) || patches.length === 0) {
339
- items.forEach(item => results.push({ selector: item.selector, ok: false, reason: 'AI returned no changes' }));
340
- continue;
341
- }
342
-
343
- // Apply patches
344
- let content = found.content;
345
- let applied = 0;
346
- for (const patch of patches) {
347
- if (!patch.search || patch.replace === undefined) continue;
348
- if (content.includes(patch.search)) {
349
- content = content.replace(patch.search, patch.replace);
350
- applied++;
351
- console.log(` \x1b[32m ✓\x1b[0m Patch applied`);
352
- } else {
353
- console.log(` \x1b[33m ⚠\x1b[0m Search string not found: "${patch.search.substring(0, 60)}..."`);
354
- }
355
- }
356
-
357
- if (applied > 0) {
358
- fs.writeFileSync(found.file, content, 'utf8');
359
- console.log(` \x1b[32m✓\x1b[0m Applied ${applied} patches to ${found.relativePath}`);
360
- items.forEach(item => results.push({ selector: item.selector, ok: true, file: found.relativePath }));
361
- } else {
362
- items.forEach(item => results.push({ selector: item.selector, ok: false, reason: 'No patches matched' }));
363
- }
364
-
365
- } catch (aiErr) {
366
- console.log(` \x1b[31m✗\x1b[0m AI error: ${aiErr.message}`);
367
- items.forEach(item => results.push({ selector: item.selector, ok: false, reason: aiErr.message }));
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
- // ── Save changes to CSS ─────────────────────────────────────────────────────
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
- // ── Proxy to dev server ─────────────────────────────────────────────────────
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">⚠ Can't reach ${targetHost}:${targetPort}</h2>
472
- <p style="color:#555;margin-top:16px">Make sure your dev server is running, then refresh</p>
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('\n \x1b[32m●\x1b[0m Draply running\n');
482
- console.log(` Your project → \x1b[36mhttp://${targetHost}:${targetPort}\x1b[0m`);
483
- console.log(` Open this → \x1b[33mhttp://localhost:${proxyPort}\x1b[0m \x1b[32m← go here!\x1b[0m\n`);
484
- console.log(` \x1b[90mCtrl+C to stop\x1b[0m\n`);
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 stopped\x1b[0m\n'); process.exit(0); });
412
+ process.on('SIGINT', () => { console.log('\n \x1b[90mDraply остановлен\x1b[0m\n'); process.exit(0); });