draply-dev 1.0.1 → 1.0.4

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.
Files changed (3) hide show
  1. package/bin/cli.js +220 -147
  2. package/package.json +1 -1
  3. package/src/overlay.js +944 -903
package/bin/cli.js CHANGED
@@ -2,24 +2,24 @@
2
2
  'use strict';
3
3
 
4
4
  const http = require('http');
5
- const fs = require('fs');
5
+ const fs = require('fs');
6
6
  const path = require('path');
7
7
  const zlib = require('zlib');
8
8
 
9
- const args = process.argv.slice(2);
9
+ const args = process.argv.slice(2);
10
10
  const targetPort = parseInt(args[0]) || 3000;
11
- const proxyPort = parseInt(args[1]) || 4000;
11
+ const proxyPort = parseInt(args[1]) || 4000;
12
12
  const targetHost = args[2] || 'localhost';
13
13
 
14
- const OVERLAY_JS = fs.readFileSync(path.join(__dirname, '../src/overlay.js'), 'utf8');
14
+ const OVERLAY_JS = fs.readFileSync(path.join(__dirname, '../src/overlay.js'), 'utf8');
15
15
  // Unique marker that does NOT appear inside overlay.js itself
16
- const MARKER = 'data-ps-done="1"';
17
- const INJECT_TAG = `\n<link rel="stylesheet" href="/draply.css">\n<script ${MARKER}>\n${OVERLAY_JS}\n</script>`;
16
+ const MARKER = 'data-ps-done="1"';
17
+ const INJECT_TAG = `\n<link rel="stylesheet" href="/draply.css">\n<script ${MARKER}>\n${OVERLAY_JS}\n</script>`;
18
18
 
19
19
  function injectOverlay(html) {
20
20
  if (html.includes(MARKER)) return html;
21
- if (html.includes('</body>')) return html.replace(/<\/body>/i, INJECT_TAG + '\n</body>');
22
- if (html.includes('</html>')) return html.replace(/<\/html>/i, INJECT_TAG + '\n</html>');
21
+ if (html.includes('</body>')) return html.replace(/<\/body>/i, INJECT_TAG + '\n</body>');
22
+ if (html.includes('</html>')) return html.replace(/<\/html>/i, INJECT_TAG + '\n</html>');
23
23
  return html + INJECT_TAG;
24
24
  }
25
25
 
@@ -27,158 +27,220 @@ function decode(headers, chunks) {
27
27
  const enc = headers['content-encoding'] || '';
28
28
  const buf = Buffer.concat(chunks);
29
29
  return new Promise((res, rej) => {
30
- if (enc === 'gzip') return zlib.gunzip(buf, (e,d) => e ? rej(e) : res(d.toString('utf8')));
31
- if (enc === 'deflate') return zlib.inflate(buf, (e,d) => e ? rej(e) : res(d.toString('utf8')));
32
- if (enc === 'br') return zlib.brotliDecompress(buf, (e,d) => e ? rej(e) : res(d.toString('utf8')));
30
+ if (enc === 'gzip') return zlib.gunzip(buf, (e, d) => e ? rej(e) : res(d.toString('utf8')));
31
+ if (enc === 'deflate') return zlib.inflate(buf, (e, d) => e ? rej(e) : res(d.toString('utf8')));
32
+ if (enc === 'br') return zlib.brotliDecompress(buf, (e, d) => e ? rej(e) : res(d.toString('utf8')));
33
33
  res(buf.toString('utf8'));
34
34
  });
35
35
  }
36
36
 
37
37
  // Создаём пустой draply.css при старте если его нет
38
- const projectRoot = process.cwd();
38
+ const projectRoot = process.cwd();
39
39
  const overridesPath = path.join(projectRoot, 'draply.css');
40
40
  if (!fs.existsSync(overridesPath)) {
41
41
  fs.writeFileSync(overridesPath, '/* draply */\n', 'utf8');
42
42
  }
43
- let cssInjected = false; // флаг: CSS уже подключён к проекту?
44
-
45
- // ── Авто-определение фреймворка и подключение draply.css ──────────────────
46
- function autoInjectCSS() {
47
- if (cssInjected) return;
48
- cssInjected = true;
49
-
50
- const has = f => fs.existsSync(path.join(projectRoot, f));
51
- const read = f => fs.readFileSync(path.join(projectRoot, f), 'utf8');
52
- const write = (f, c) => fs.writeFileSync(path.join(projectRoot, f), c, 'utf8');
53
- const already = (content) => content.includes('draply.css');
54
-
55
- try {
56
- // Next.js App Router
57
- for (const f of ['app/layout.tsx', 'app/layout.jsx', 'app/layout.js']) {
58
- if (has(f)) {
59
- let src = read(f);
60
- if (already(src)) return;
61
- src = `import '../draply.css';\n` + src;
62
- write(f, src);
63
- console.log(` \x1b[32m✓\x1b[0m Подключил draply.css → ${f}`);
64
- return;
65
- }
66
- }
67
43
 
68
- // Next.js Pages Router
69
- for (const f of ['pages/_app.tsx', 'pages/_app.jsx', 'pages/_app.js']) {
70
- if (has(f)) {
71
- let src = read(f);
72
- if (already(src)) return;
73
- src = `import '../draply.css';\n` + src;
74
- write(f, src);
75
- console.log(` \x1b[32m✓\x1b[0m Подключил draply.css → ${f}`);
76
- return;
77
- }
78
- }
79
44
 
80
- // Nuxt
81
- for (const f of ['nuxt.config.ts', 'nuxt.config.js']) {
82
- if (has(f)) {
83
- let src = read(f);
84
- if (already(src)) return;
85
- if (src.includes('css:')) {
86
- src = src.replace(/css:\s*\[/, "css: ['./draply.css', ");
87
- } else {
88
- src = src.replace(/export default defineNuxtConfig\(\{/, "export default defineNuxtConfig({\n css: ['./draply.css'],");
89
- }
90
- write(f, src);
91
- console.log(` \x1b[32m✓\x1b[0m Подключил draply.css → ${f}`);
92
- return;
93
- }
94
- }
45
+ const server = http.createServer((req, res) => {
95
46
 
96
- // Vue (Vite)
97
- if (has('src/App.vue')) {
98
- for (const f of ['src/main.ts', 'src/main.js']) {
99
- if (has(f)) {
100
- let src = read(f);
101
- if (already(src)) return;
102
- src = `import '../draply.css'\n` + src;
103
- write(f, src);
104
- console.log(` \x1b[32m✓\x1b[0m Подключил draply.css → ${f}`);
105
- return;
106
- }
107
- }
108
- }
47
+ // CORS preflight
48
+ res.setHeader('Access-Control-Allow-Origin', '*');
49
+ res.setHeader('Access-Control-Allow-Methods', 'GET,POST,OPTIONS');
50
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
51
+ if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; }
109
52
 
110
- // React (Vite) src/main.tsx / src/main.jsx
111
- for (const f of ['src/main.tsx', 'src/main.jsx', 'src/main.js', 'src/main.ts', 'src/index.tsx', 'src/index.jsx', 'src/index.js']) {
112
- if (has(f)) {
113
- let src = read(f);
114
- if (already(src)) return;
115
- src = `import '../draply.css'\n` + src;
116
- write(f, src);
117
- console.log(` \x1b[32m✓\x1b[0m Подключил draply.css → ${f}`);
118
- return;
119
- }
120
- }
53
+ // ── Config endpoint (get/set API key) ───────────────────────────────────────
54
+ const configPath = path.join(process.cwd(), 'draply.config.json');
55
+ function loadConfig() { try { return JSON.parse(fs.readFileSync(configPath, 'utf8')); } catch { return {}; } }
56
+ function saveConfig(cfg) { fs.writeFileSync(configPath, JSON.stringify(cfg, null, 2), 'utf8'); }
121
57
 
122
- // CRA public/index.html
123
- if (has('public/index.html')) {
124
- let src = read('public/index.html');
125
- if (already(src)) return;
126
- src = src.replace(/<\/head>/i, ' <link rel="stylesheet" href="%PUBLIC_URL%/draply.css">\n </head>');
127
- write('public/index.html', src);
128
- // Копируем draply.css в public/
129
- fs.copyFileSync(overridesPath, path.join(projectRoot, 'public/draply.css'));
130
- console.log(` \x1b[32m✓\x1b[0m Подключил draply.css → public/index.html`);
58
+ if (req.url === '/draply-config') {
59
+ if (req.method === 'GET') {
60
+ const cfg = loadConfig();
61
+ res.writeHead(200, { 'Content-Type': 'application/json' });
62
+ res.end(JSON.stringify({ hasKey: !!cfg.apiKey, provider: cfg.provider || 'groq' }));
131
63
  return;
132
64
  }
133
-
134
- // Plain HTML / Vite — index.html в корне
135
- if (has('index.html')) {
136
- let src = read('index.html');
137
- if (already(src)) return;
138
- src = src.replace(/<\/head>/i, ' <link rel="stylesheet" href="./draply.css">\n </head>');
139
- write('index.html', src);
140
- console.log(` \x1b[32m✓\x1b[0m Подключил draply.css index.html`);
65
+ if (req.method === 'POST') {
66
+ let body = '';
67
+ req.on('data', c => body += c);
68
+ req.on('end', () => {
69
+ try {
70
+ const cfg = { ...loadConfig(), ...JSON.parse(body) };
71
+ saveConfig(cfg);
72
+ res.writeHead(200, { 'Content-Type': 'application/json' });
73
+ res.end(JSON.stringify({ ok: true }));
74
+ } catch (e) {
75
+ res.writeHead(500); res.end(JSON.stringify({ ok: false, error: e.message }));
76
+ }
77
+ });
141
78
  return;
142
79
  }
143
-
144
- console.log(` \x1b[33m⚠\x1b[0m Не удалось определить фреймворк — добавь draply.css вручную`);
145
- } catch (err) {
146
- console.log(` \x1b[33m⚠\x1b[0m Ошибка авто-подключения: ${err.message}`);
147
80
  }
148
- }
149
-
150
- const server = http.createServer((req, res) => {
151
-
152
- // CORS preflight
153
- res.setHeader('Access-Control-Allow-Origin', '*');
154
- res.setHeader('Access-Control-Allow-Methods', 'GET,POST,OPTIONS');
155
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
156
- if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; }
157
81
 
158
- // ── Draply: Save endpoint ──────────────────────────────────────────────────
159
- if (req.url === '/draply-save' && req.method === 'POST') {
82
+ // ── AI Apply endpoint ───────────────────────────────────────────────────────
83
+ if ((req.url === '/draply-ai-apply' || req.url === '/draply-save') && req.method === 'POST') {
160
84
  let body = '';
161
85
  req.on('data', c => body += c);
162
- req.on('end', () => {
86
+ req.on('end', async () => {
163
87
  try {
164
88
  const { changes } = JSON.parse(body);
165
- const lines = [];
89
+ const cfg = loadConfig();
90
+
91
+ // If no config, fallback to saving standard CSS
92
+ if (!cfg.apiKey) {
93
+ const lines = [];
94
+ for (const ch of (changes || [])) {
95
+ if (!ch.selector) continue;
96
+ const props = Object.entries(ch.props).map(([k, v]) => ` ${k}: ${v};`).join('\n');
97
+ const label = ch.selector.split('>').pop().trim();
98
+ lines.push(`/* ${label} */\n${ch.selector} {\n${props}\n}`);
99
+ }
100
+ const css = '/* draply */\n\n' + lines.join('\n\n') + '\n';
101
+ fs.writeFileSync(overridesPath, css, 'utf8');
102
+ res.writeHead(200, { 'Content-Type': 'application/json' });
103
+ res.end(JSON.stringify({ ok: true, fallback: true }));
104
+ return;
105
+ }
106
+
107
+ const results = [];
108
+ const fileMap = new Map();
109
+
110
+ // 1. Group by exact file (thanks to React Fiber, fallback to index.html)
166
111
  for (const ch of (changes || [])) {
167
- if (!ch.selector) continue;
168
- const props = Object.entries(ch.props)
169
- .map(([k, v]) => ` ${k}: ${v};`)
170
- .join('\n');
171
- const label = ch.selector.split('>').pop().trim();
172
- lines.push(`/* ${label} */
173
- ${ch.selector} {
174
- ${props}
175
- }`);
112
+ let targetFile = ch.exactFile;
113
+ if (!targetFile || !fs.existsSync(targetFile)) {
114
+ const fallbackHTML = path.join(process.cwd(), 'index.html');
115
+ if (fs.existsSync(fallbackHTML)) {
116
+ targetFile = fallbackHTML;
117
+ } else {
118
+ results.push({ selector: ch.selector, ok: false, reason: 'Source file not linked' });
119
+ continue;
120
+ }
121
+ }
122
+ if (!fileMap.has(targetFile)) {
123
+ fileMap.set(targetFile, { content: fs.readFileSync(targetFile, 'utf8'), items: [] });
124
+ }
125
+ fileMap.get(targetFile).items.push(ch);
176
126
  }
177
- const css = '/* draply — ' + new Date().toLocaleString('ru-RU') + ' */\n\n' + lines.join('\n\n') + '\n';
178
- fs.writeFileSync(overridesPath, css, 'utf8');
179
- autoInjectCSS();
127
+
128
+ // 2. Call AI per file using XML Patches
129
+ for (const [filePath, { content, items }] of fileMap) {
130
+
131
+ let changesBlock = '';
132
+ const lines = content.split('\n');
133
+ let classNamesToFind = items.map(c => {
134
+ const parts = c.selector.split('.');
135
+ return parts.length > 1 ? parts.pop() : '';
136
+ }).filter(Boolean);
137
+
138
+ let ctxStart = 0, ctxEnd = lines.length - 1;
139
+ for (let i = 0; i < lines.length; i++) {
140
+ if (classNamesToFind.some(cls => lines[i].includes(cls))) {
141
+ ctxStart = Math.max(0, i - 25);
142
+ ctxEnd = Math.min(lines.length - 1, i + 25);
143
+ }
144
+ }
145
+ const snippet = lines.slice(ctxStart, ctxEnd + 1).join('\n');
146
+
147
+ items.forEach(item => {
148
+ const propsStr = Object.entries(item.props).map(([k, v]) => ` ${k}: ${v}`).join('\n');
149
+ changesBlock += `\nTarget Selector: ${item.selector}\nChanges:\n${propsStr}\n`;
150
+ });
151
+
152
+ const prompt = `You are a strict code editor applying style changes to a file.
153
+
154
+ FILE: ${path.basename(filePath)} (lines ${ctxStart + 1}-${ctxEnd + 1})
155
+ \`\`\`
156
+ ${snippet}
157
+ \`\`\`
158
+
159
+ CHANGES (Apply to HTML/React elements matching selectors):
160
+ ${changesBlock}
161
+
162
+ IMPORTANT: Return your changes wrapped in <patch> tags containing <search> and <replace> blocks. DO NOT use JSON.
163
+
164
+ Rules:
165
+ - The content inside <search> must be an EXACT substring from the file snippet above.
166
+ - Update HTML/JSX inline styles or Tailwind classes appropriately.
167
+ - REPLACE old style values if they exist, DO NOT duplicate keys!
168
+
169
+ Example response:
170
+ <patch>
171
+ <search>
172
+ <button className="btn" style={{ padding: '10px' }}>
173
+ </search>
174
+ <replace>
175
+ <button className="btn" style={{ padding: '10px', color: '#ff0000' }}>
176
+ </replace>
177
+ </patch>
178
+
179
+ Return ONLY the patch blocks.`;
180
+
181
+ let apiResult = '';
182
+ try {
183
+ // Groq call
184
+ const groqBody = JSON.stringify({
185
+ model: 'llama-3.3-70b-versatile',
186
+ messages: [{ role: 'user', content: prompt }],
187
+ temperature: 0.1, max_tokens: 8192
188
+ });
189
+ apiResult = await new Promise((resolve, reject) => {
190
+ const https = require('https');
191
+ const req = https.request({
192
+ hostname: 'api.groq.com', path: '/openai/v1/chat/completions',
193
+ method: 'POST',
194
+ headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${cfg.apiKey}`, 'Content-Length': Buffer.byteLength(groqBody) }
195
+ }, res => {
196
+ const chunks = [];
197
+ res.on('data', c => chunks.push(c));
198
+ res.on('end', () => {
199
+ const data = JSON.parse(Buffer.concat(chunks).toString());
200
+ if (data.error) reject(new Error(data.error.message));
201
+ else resolve(data.choices?.[0]?.message?.content || '');
202
+ });
203
+ });
204
+ req.on('error', reject); req.write(groqBody); req.end();
205
+ });
206
+
207
+ const patches = [];
208
+ const patchRegex = /<search>([\s\S]*?)<\/search>[\s\S]*?<replace>([\s\S]*?)<\/replace>/g;
209
+ let match;
210
+ while ((match = patchRegex.exec(apiResult)) !== null) {
211
+ let s = match[1], r = match[2];
212
+ if (s.startsWith('\n')) s = s.substring(1);
213
+ if (s.endsWith('\n')) s = s.substring(0, s.length - 1);
214
+ if (r.startsWith('\n')) r = r.substring(1);
215
+ if (r.endsWith('\n')) r = r.substring(0, r.length - 1);
216
+ if (s.trim()) patches.push({ search: s, replace: r });
217
+ }
218
+
219
+ let newContent = content;
220
+ let applied = 0;
221
+ for (const patch of patches) {
222
+ if (newContent.includes(patch.search)) {
223
+ newContent = newContent.replace(patch.search, patch.replace);
224
+ applied++;
225
+ }
226
+ }
227
+
228
+ if (applied > 0) {
229
+ fs.writeFileSync(filePath, newContent, 'utf8');
230
+ console.log(` \x1b[32m✓\x1b[0m Modified ${path.basename(filePath)}`);
231
+ items.forEach(item => results.push({ selector: item.selector, ok: true }));
232
+ } else {
233
+ items.forEach(item => results.push({ selector: item.selector, ok: false, reason: 'AI patch failed to match' }));
234
+ }
235
+ } catch (e) {
236
+ items.forEach(item => results.push({ selector: item.selector, ok: false, reason: e.message }));
237
+ }
238
+ }
239
+
240
+ const applied = results.filter(r => r.ok).length;
180
241
  res.writeHead(200, { 'Content-Type': 'application/json' });
181
- res.end(JSON.stringify({ ok: true }));
242
+ res.end(JSON.stringify({ ok: true, applied, total: results.length, results }));
243
+
182
244
  } catch (e) {
183
245
  res.writeHead(500, { 'Content-Type': 'application/json' });
184
246
  res.end(JSON.stringify({ ok: false, error: e.message }));
@@ -188,14 +250,25 @@ ${props}
188
250
  }
189
251
 
190
252
  // ── Draply: Serve CSS ──────────────────────────────────────────────────────
191
- if (req.url === '/draply.css') {
253
+ if (req.url.split('?')[0] === '/draply.css') {
254
+ const isModule = req.headers['sec-fetch-dest'] === 'script' || req.url.includes('import');
192
255
  try {
193
256
  const css = fs.readFileSync(overridesPath, 'utf8');
194
- res.writeHead(200, { 'Content-Type': 'text/css', 'cache-control': 'no-store' });
195
- res.end(css);
257
+ if (isModule) {
258
+ res.writeHead(200, { 'Content-Type': 'application/javascript', 'cache-control': 'no-store' });
259
+ res.end(`const style = document.createElement('style');\nstyle.setAttribute('data-draply-module', '1');\nstyle.textContent = ${JSON.stringify(css)};\ndocument.head.appendChild(style);\nexport default {};`);
260
+ } else {
261
+ res.writeHead(200, { 'Content-Type': 'text/css', 'cache-control': 'no-store' });
262
+ res.end(css);
263
+ }
196
264
  } catch (e) {
197
- res.writeHead(200, { 'Content-Type': 'text/css' });
198
- res.end('');
265
+ if (isModule) {
266
+ res.writeHead(200, { 'Content-Type': 'application/javascript' });
267
+ res.end('export default {};');
268
+ } else {
269
+ res.writeHead(200, { 'Content-Type': 'text/css' });
270
+ res.end('');
271
+ }
199
272
  }
200
273
  return;
201
274
  }
@@ -205,10 +278,10 @@ ${props}
205
278
  // ── Proxy to dev server ────────────────────────────────────────────────────
206
279
  const opts = {
207
280
  hostname: targetHost,
208
- port: targetPort,
209
- path: req.url,
210
- method: req.method,
211
- headers: { ...req.headers, host: `${targetHost}:${targetPort}`, 'accept-encoding': 'identity' },
281
+ port: targetPort,
282
+ path: req.url,
283
+ method: req.method,
284
+ headers: { ...req.headers, host: `${targetHost}:${targetPort}`, 'accept-encoding': 'identity' },
212
285
  };
213
286
 
214
287
  const pReq = http.request(opts, pRes => {
@@ -218,15 +291,15 @@ ${props}
218
291
  pRes.on('data', c => chunks.push(c));
219
292
  pRes.on('end', async () => {
220
293
  try {
221
- const html = await decode(pRes.headers, chunks);
222
- const out = Buffer.from(injectOverlay(html), 'utf8');
294
+ const html = await decode(pRes.headers, chunks);
295
+ const out = Buffer.from(injectOverlay(html), 'utf8');
223
296
  const headers = { ...pRes.headers };
224
297
  delete headers['content-encoding'];
225
298
  headers['content-length'] = out.length;
226
- headers['cache-control'] = 'no-store';
299
+ headers['cache-control'] = 'no-store';
227
300
  res.writeHead(pRes.statusCode, headers);
228
301
  res.end(out);
229
- } catch(e) {
302
+ } catch (e) {
230
303
  res.writeHead(500); res.end('Draply error: ' + e.message);
231
304
  }
232
305
  });
@@ -237,7 +310,7 @@ ${props}
237
310
  });
238
311
 
239
312
  pReq.on('error', () => {
240
- res.writeHead(502, {'Content-Type':'text/html'});
313
+ res.writeHead(502, { 'Content-Type': 'text/html' });
241
314
  res.end(`<!DOCTYPE html><html><body style="background:#0a0a0f;color:#e8e8f0;font-family:monospace;padding:60px;text-align:center">
242
315
  <h2 style="color:#ff6b6b">⚠ Не могу достучаться до ${targetHost}:${targetPort}</h2>
243
316
  <p style="color:#555;margin-top:16px">Убедись что dev сервер запущен, потом обнови страницу</p>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "draply-dev",
3
- "version": "1.0.1",
3
+ "version": "1.0.4",
4
4
  "description": "Visual overlay for any frontend project — move, resize, restyle live in the browser, save to CSS",
5
5
  "author": "Arman",
6
6
  "type": "commonjs",