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 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;
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
- // 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,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
- 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;
144
+ if (ch.props && ch.props.googleFont) {
145
+ reqGoogleFonts.add(ch.props.googleFont);
146
+ delete ch.props.googleFont;
249
147
  }
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;
254
- }
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
+ }
275
174
  }
276
- const snippet = lines.slice(contextStart, contextEnd + 1).join('\n');
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
- // 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}
294
- IMPORTANT: Return ONLY a JSON array of search-and-replace pairs. Each pair format:
295
- {"search": "exact code to find", "replace": "modified code to replace it with"}
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
- - "search" must be an EXACT substring from the file snippet above (copy it precisely).
299
- - Ensure all double quotes inside JSON string values are properly escaped (\\").
300
- - Do NOT include markdown fences, preambles, or explanations. Only the JSON array.
301
- - 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.
302
- - If the file is JSX, update the style object/prop. Do not duplicate properties inside object literals.
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
- "search": "style={{ color: 'blue', opacity: 0.5 }}",
308
- "replace": "style={{ color: '#ff0000', opacity: 0.5 }}"
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
- const result = await callAI(cfg.apiKey, prompt, cfg.provider || 'groq');
314
-
315
- // Extract JSON array using regex to ignore any surrounding text/markdown
316
- let jsonStr = result.trim();
317
- const match = jsonStr.match(/\[[\s\S]*\]/);
318
- if (match) {
319
- jsonStr = match[0];
320
- }
321
-
322
- let patches;
323
- try {
324
- patches = JSON.parse(jsonStr);
325
- } catch (err) {
326
- console.log(` \x1b[31m✗\x1b[0m AI returned invalid JSON`);
327
- console.log(` Response: ${jsonStr.substring(0, 300)}`);
328
- items.forEach(item => results.push({ selector: item.selector, ok: false, reason: 'AI returned invalid JSON: ' + err.message }));
329
- continue;
330
- }
331
-
332
- if (!Array.isArray(patches) || patches.length === 0) {
333
- items.forEach(item => results.push({ selector: item.selector, ok: false, reason: 'AI returned no changes' }));
334
- continue;
335
- }
336
-
337
- // Apply patches
338
- let content = found.content;
339
- let applied = 0;
340
- for (const patch of patches) {
341
- if (!patch.search || patch.replace === undefined) continue;
342
- if (content.includes(patch.search)) {
343
- content = content.replace(patch.search, patch.replace);
344
- applied++;
345
- console.log(` \x1b[32m ✓\x1b[0m Patch applied`);
346
- } else {
347
- console.log(` \x1b[33m ⚠\x1b[0m Search string not found: "${patch.search.substring(0, 60)}..."`);
348
- }
349
- }
350
-
351
- if (applied > 0) {
352
- fs.writeFileSync(found.file, content, 'utf8');
353
- console.log(` \x1b[32m✓\x1b[0m Applied ${applied} patches to ${found.relativePath}`);
354
- items.forEach(item => results.push({ selector: item.selector, ok: true, file: found.relativePath }));
355
- } else {
356
- items.forEach(item => results.push({ selector: item.selector, ok: false, reason: 'No patches matched' }));
357
- }
358
-
359
- } catch (aiErr) {
360
- console.log(` \x1b[31m✗\x1b[0m AI error: ${aiErr.message}`);
361
- 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 }));
362
308
  }
363
309
  }
364
310
 
@@ -374,34 +320,7 @@ Example response:
374
320
  return;
375
321
  }
376
322
 
377
- // ── Save changes to CSS ─────────────────────────────────────────────────────
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
- // ── Proxy to dev server ─────────────────────────────────────────────────────
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">⚠ Can't reach ${targetHost}:${targetPort}</h2>
466
- <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>
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('\n \x1b[32m●\x1b[0m Draply running\n');
476
- console.log(` Your project → \x1b[36mhttp://${targetHost}:${targetPort}\x1b[0m`);
477
- console.log(` Open this → \x1b[33mhttp://localhost:${proxyPort}\x1b[0m \x1b[32m← go here!\x1b[0m\n`);
478
- 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);
479
410
  });
480
411
 
481
- 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); });