draply-dev 1.2.0 → 1.3.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,6 +2,7 @@
2
2
  'use strict';
3
3
 
4
4
  const http = require('http');
5
+ const https = require('https');
5
6
  const fs = require('fs');
6
7
  const path = require('path');
7
8
  const zlib = require('zlib');
@@ -13,7 +14,6 @@ const targetHost = args[2] || 'localhost';
13
14
 
14
15
  const OVERLAY_JS = fs.readFileSync(path.join(__dirname, '../src/overlay.js'), 'utf8');
15
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
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>`;
19
19
 
@@ -35,12 +35,11 @@ function decode(headers, chunks) {
35
35
  });
36
36
  }
37
37
 
38
- // Храним CSS в скрытой папке .draply/ чтобы не засорять проект юзера
38
+ // ── Project & Config ──────────────────────────────────────────────────────────
39
39
  const projectRoot = process.cwd();
40
40
  const draplyDir = path.join(projectRoot, '.draply');
41
41
  if (!fs.existsSync(draplyDir)) {
42
42
  fs.mkdirSync(draplyDir, { recursive: true });
43
- // Добавляем .draply в .gitignore если он есть
44
43
  const gitignorePath = path.join(projectRoot, '.gitignore');
45
44
  if (fs.existsSync(gitignorePath)) {
46
45
  const gi = fs.readFileSync(gitignorePath, 'utf8');
@@ -50,57 +49,250 @@ if (!fs.existsSync(draplyDir)) {
50
49
  }
51
50
  }
52
51
  const overridesPath = path.join(draplyDir, 'overrides.css');
53
- if (!fs.existsSync(overridesPath)) {
54
- fs.writeFileSync(overridesPath, '/* draply */\n', 'utf8');
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;
55
74
  }
56
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 Gemini API ───────────────────────────────────────────────────────────
101
+ function callGemini(apiKey, prompt) {
102
+ return new Promise((resolve, reject) => {
103
+ const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${apiKey}`;
104
+ const body = JSON.stringify({
105
+ contents: [{ parts: [{ text: prompt }] }],
106
+ generationConfig: { temperature: 0.1, maxOutputTokens: 8192 }
107
+ });
108
+
109
+ const parsed = new URL(url);
110
+ const options = {
111
+ hostname: parsed.hostname,
112
+ path: parsed.pathname + parsed.search,
113
+ method: 'POST',
114
+ headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }
115
+ };
116
+
117
+ const req = https.request(options, res => {
118
+ const chunks = [];
119
+ res.on('data', c => chunks.push(c));
120
+ res.on('end', () => {
121
+ try {
122
+ const data = JSON.parse(Buffer.concat(chunks).toString());
123
+ if (data.error) {
124
+ reject(new Error(data.error.message || 'Gemini API error'));
125
+ return;
126
+ }
127
+ const text = data.candidates?.[0]?.content?.parts?.[0]?.text || '';
128
+ resolve(text);
129
+ } catch (e) { reject(e); }
130
+ });
131
+ });
132
+ req.on('error', reject);
133
+ req.write(body);
134
+ req.end();
135
+ });
136
+ }
57
137
 
138
+ // ══════════════════════════════════════════════════════════════════════════════
139
+ // HTTP SERVER
140
+ // ══════════════════════════════════════════════════════════════════════════════
58
141
  const server = http.createServer((req, res) => {
59
142
 
60
- // CORS preflight
61
143
  res.setHeader('Access-Control-Allow-Origin', '*');
62
144
  res.setHeader('Access-Control-Allow-Methods', 'GET,POST,OPTIONS');
63
145
  res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
64
146
  if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; }
65
147
 
66
- // ── Draply: Save endpoint AUTO-APPLY to source files ─────────────────────
67
- if (req.url === '/draply-save' && req.method === 'POST') {
148
+ // ── Config endpoint (get/set API key) ───────────────────────────────────────
149
+ if (req.url === '/draply-config') {
150
+ if (req.method === 'GET') {
151
+ const cfg = loadConfig();
152
+ res.writeHead(200, { 'Content-Type': 'application/json' });
153
+ // Mask API key for security — only send if it exists
154
+ res.end(JSON.stringify({ hasKey: !!cfg.apiKey, provider: cfg.provider || 'gemini' }));
155
+ return;
156
+ }
157
+ if (req.method === 'POST') {
158
+ let body = '';
159
+ req.on('data', c => body += c);
160
+ req.on('end', () => {
161
+ try {
162
+ const { apiKey, provider } = JSON.parse(body);
163
+ const cfg = loadConfig();
164
+ if (apiKey !== undefined) cfg.apiKey = apiKey;
165
+ if (provider) cfg.provider = provider;
166
+ saveConfig(cfg);
167
+ console.log(` \x1b[32m✓\x1b[0m API key saved (${cfg.provider || 'gemini'})`);
168
+ res.writeHead(200, { 'Content-Type': 'application/json' });
169
+ res.end(JSON.stringify({ ok: true }));
170
+ } catch (e) {
171
+ res.writeHead(500, { 'Content-Type': 'application/json' });
172
+ res.end(JSON.stringify({ ok: false, error: e.message }));
173
+ }
174
+ });
175
+ return;
176
+ }
177
+ }
178
+
179
+ // ── AI Apply endpoint ───────────────────────────────────────────────────────
180
+ if (req.url === '/draply-ai-apply' && req.method === 'POST') {
68
181
  let body = '';
69
182
  req.on('data', c => body += c);
70
- req.on('end', () => {
183
+ req.on('end', async () => {
71
184
  try {
72
185
  const { changes } = JSON.parse(body);
73
- const projectInfo = detectProject(projectRoot);
186
+ const cfg = loadConfig();
187
+ if (!cfg.apiKey) {
188
+ res.writeHead(400, { 'Content-Type': 'application/json' });
189
+ res.end(JSON.stringify({ ok: false, error: 'No API key configured. Click ⚙ to set up.' }));
190
+ return;
191
+ }
192
+
74
193
  const results = [];
75
194
 
195
+ // Group changes by class
76
196
  for (const ch of (changes || [])) {
77
197
  if (!ch.selector || !ch.props) continue;
78
- const result = autoApplyChange(projectRoot, ch.selector, ch.props, projectInfo);
79
- results.push(result);
80
- if (result.applied) {
81
- console.log(` \x1b[32m✓\x1b[0m Applied: ${result.file} (${result.strategy})`);
82
- } else {
83
- console.log(` \x1b[33m⚠\x1b[0m Fallback CSS: ${ch.selector.split('>').pop().trim()}`);
198
+
199
+ const className = extractClassName(ch.selector);
200
+ if (!className) {
201
+ results.push({ selector: ch.selector, ok: false, reason: 'No class name found' });
202
+ continue;
203
+ }
204
+
205
+ // Find the source file
206
+ const found = findFileForClass(projectRoot, className);
207
+ if (!found) {
208
+ results.push({ selector: ch.selector, ok: false, reason: `"${className}" not found in source files` });
209
+ continue;
210
+ }
211
+
212
+ console.log(` \x1b[36m🤖\x1b[0m AI applying: .${className} → ${found.relativePath}`);
213
+
214
+ // Build the prompt
215
+ const propsStr = Object.entries(ch.props).map(([k, v]) => ` ${k}: ${v}`).join('\n');
216
+ const prompt = `You are a code editor. Modify the following source file to apply CSS style changes.
217
+
218
+ FILE: ${found.relativePath}
219
+ \`\`\`
220
+ ${found.content}
221
+ \`\`\`
222
+
223
+ CHANGES TO APPLY:
224
+ The element with class "${className}" should have these CSS properties:
225
+ ${propsStr}
226
+
227
+ RULES:
228
+ - If the file is CSS/SCSS: find the .${className} rule and update/add the properties. If the rule doesn't exist, add it.
229
+ - If the file is JSX/TSX: apply changes in the most appropriate way for this file:
230
+ - If the file already uses inline styles for this element, update the style prop
231
+ - If the file imports a CSS file, add the rule to that CSS file instead (tell me the filename in a comment)
232
+ - If the file uses Tailwind classes, update the className with appropriate Tailwind classes
233
+ - Otherwise, add a style prop to the element with className="${className}"
234
+ - Keep ALL existing code intact. Only modify what's needed for the style changes.
235
+ - Do NOT add comments explaining what you changed.
236
+
237
+ Return ONLY the complete modified file content. No markdown fences, no explanations, just the raw code.`;
238
+
239
+ try {
240
+ const result = await callGemini(cfg.apiKey, prompt);
241
+
242
+ // Clean up response — remove markdown fences if AI added them
243
+ let code = result.trim();
244
+ if (code.startsWith('```')) {
245
+ code = code.replace(/^```[a-z]*\n?/, '').replace(/\n?```$/, '');
246
+ }
247
+
248
+ // Safety: don't write empty or tiny responses
249
+ if (code.length < 20) {
250
+ results.push({ selector: ch.selector, ok: false, reason: 'AI returned empty/invalid response' });
251
+ continue;
252
+ }
253
+
254
+ // Write the modified file
255
+ fs.writeFileSync(found.file, code, 'utf8');
256
+ console.log(` \x1b[32m✓\x1b[0m Applied to ${found.relativePath}`);
257
+ results.push({ selector: ch.selector, ok: true, file: found.relativePath });
258
+
259
+ } catch (aiErr) {
260
+ console.log(` \x1b[31m✗\x1b[0m AI error: ${aiErr.message}`);
261
+ results.push({ selector: ch.selector, ok: false, reason: aiErr.message });
84
262
  }
85
263
  }
86
264
 
87
- // Also save CSS override as backup
265
+ const applied = results.filter(r => r.ok).length;
266
+ res.writeHead(200, { 'Content-Type': 'application/json' });
267
+ res.end(JSON.stringify({ ok: true, applied, total: results.length, results }));
268
+
269
+ } catch (e) {
270
+ res.writeHead(500, { 'Content-Type': 'application/json' });
271
+ res.end(JSON.stringify({ ok: false, error: e.message }));
272
+ }
273
+ });
274
+ return;
275
+ }
276
+
277
+ // ── Save changes to CSS ─────────────────────────────────────────────────────
278
+ if (req.url === '/draply-save' && req.method === 'POST') {
279
+ let body = '';
280
+ req.on('data', c => body += c);
281
+ req.on('end', () => {
282
+ try {
283
+ const { changes } = JSON.parse(body);
88
284
  const lines = [];
89
285
  for (const ch of (changes || [])) {
90
286
  if (!ch.selector) continue;
91
- const props = Object.entries(ch.props)
92
- .map(([k, v]) => ` ${k}: ${v};`)
93
- .join('\n');
287
+ const props = Object.entries(ch.props).map(([k, v]) => ` ${k}: ${v};`).join('\n');
94
288
  const label = ch.selector.split('>').pop().trim();
95
289
  lines.push(`/* ${label} */\n${ch.selector} {\n${props}\n}`);
96
290
  }
97
291
  const css = '/* draply — ' + new Date().toLocaleString('ru-RU') + ' */\n\n' + lines.join('\n\n') + '\n';
98
292
  fs.writeFileSync(overridesPath, css, 'utf8');
99
-
100
- const applied = results.filter(r => r.applied).length;
101
- const total = results.length;
293
+ console.log(` \x1b[32m✓\x1b[0m Saved ${changes.length} changes to .draply/overrides.css`);
102
294
  res.writeHead(200, { 'Content-Type': 'application/json' });
103
- res.end(JSON.stringify({ ok: true, applied, total, results }));
295
+ res.end(JSON.stringify({ ok: true }));
104
296
  } catch (e) {
105
297
  res.writeHead(500, { 'Content-Type': 'application/json' });
106
298
  res.end(JSON.stringify({ ok: false, error: e.message }));
@@ -109,7 +301,7 @@ const server = http.createServer((req, res) => {
109
301
  return;
110
302
  }
111
303
 
112
- // ── Draply: Serve CSS ──────────────────────────────────────────────────────
304
+ // ── Serve CSS ───────────────────────────────────────────────────────────────
113
305
  if (req.url.split('?')[0] === '/draply.css') {
114
306
  const isModule = req.headers['sec-fetch-dest'] === 'script' || req.url.includes('import');
115
307
  try {
@@ -121,7 +313,7 @@ const server = http.createServer((req, res) => {
121
313
  res.writeHead(200, { 'Content-Type': 'text/css', 'cache-control': 'no-store' });
122
314
  res.end(css);
123
315
  }
124
- } catch (e) {
316
+ } catch {
125
317
  if (isModule) {
126
318
  res.writeHead(200, { 'Content-Type': 'application/javascript' });
127
319
  res.end('export default {};');
@@ -133,33 +325,7 @@ const server = http.createServer((req, res) => {
133
325
  return;
134
326
  }
135
327
 
136
- // ── Draply: Project Info endpoint ──────────────────────────────────────────
137
- if (req.url === '/draply-project-info' && req.method === 'GET') {
138
- const info = detectProject(projectRoot);
139
- res.writeHead(200, { 'Content-Type': 'application/json' });
140
- res.end(JSON.stringify(info));
141
- return;
142
- }
143
-
144
- // ── Draply: Find Source endpoint ──────────────────────────────────────────
145
- if (req.url === '/draply-find-source' && req.method === 'POST') {
146
- let body = '';
147
- req.on('data', c => body += c);
148
- req.on('end', () => {
149
- try {
150
- const { selector, tagName, className } = JSON.parse(body);
151
- const result = findSourceFile(projectRoot, { selector, tagName, className });
152
- res.writeHead(200, { 'Content-Type': 'application/json' });
153
- res.end(JSON.stringify(result));
154
- } catch (e) {
155
- res.writeHead(500, { 'Content-Type': 'application/json' });
156
- res.end(JSON.stringify({ found: false, error: e.message }));
157
- }
158
- });
159
- return;
160
- }
161
-
162
- // ── Proxy to dev server ────────────────────────────────────────────────────
328
+ // ── Proxy to dev server ─────────────────────────────────────────────────────
163
329
  const opts = {
164
330
  hostname: targetHost,
165
331
  port: targetPort,
@@ -196,8 +362,8 @@ const server = http.createServer((req, res) => {
196
362
  pReq.on('error', () => {
197
363
  res.writeHead(502, { 'Content-Type': 'text/html' });
198
364
  res.end(`<!DOCTYPE html><html><body style="background:#0a0a0f;color:#e8e8f0;font-family:monospace;padding:60px;text-align:center">
199
- <h2 style="color:#ff6b6b">⚠ Не могу достучаться до ${targetHost}:${targetPort}</h2>
200
- <p style="color:#555;margin-top:16px">Убедись что dev сервер запущен, потом обнови страницу</p>
365
+ <h2 style="color:#ff6b6b">⚠ Can't reach ${targetHost}:${targetPort}</h2>
366
+ <p style="color:#555;margin-top:16px">Make sure your dev server is running, then refresh</p>
201
367
  <script>setTimeout(()=>location.reload(), 2000)</script>
202
368
  </body></html>`);
203
369
  });
@@ -206,435 +372,10 @@ const server = http.createServer((req, res) => {
206
372
  });
207
373
 
208
374
  server.listen(proxyPort, () => {
209
- console.log('\n \x1b[32m●\x1b[0m Draply запущен\n');
210
- console.log(` Твой проект → \x1b[36mhttp://${targetHost}:${targetPort}\x1b[0m`);
211
- console.log(` Открой это → \x1b[33mhttp://localhost:${proxyPort}\x1b[0m \x1b[32m← вот сюда заходи!\x1b[0m\n`);
212
- console.log(` \x1b[90mCtrl+C чтобы остановить\x1b[0m\n`);
375
+ console.log('\n \x1b[32m●\x1b[0m Draply running\n');
376
+ console.log(` Your project → \x1b[36mhttp://${targetHost}:${targetPort}\x1b[0m`);
377
+ console.log(` Open this → \x1b[33mhttp://localhost:${proxyPort}\x1b[0m \x1b[32m← go here!\x1b[0m\n`);
378
+ console.log(` \x1b[90mCtrl+C to stop\x1b[0m\n`);
213
379
  });
214
380
 
215
- process.on('SIGINT', () => { console.log('\n \x1b[90mDraply остановлен\x1b[0m\n'); process.exit(0); });
216
-
217
- // ══════════════════════════════════════════════════════════════════════════
218
- // PROJECT DETECTION
219
- // ══════════════════════════════════════════════════════════════════════════
220
- function detectProject(root) {
221
- const result = { framework: 'unknown', cssStrategy: 'unknown', root };
222
-
223
- try {
224
- const pkgPath = path.join(root, 'package.json');
225
- if (!fs.existsSync(pkgPath)) return result;
226
-
227
- const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
228
- const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
229
-
230
- // Detect framework
231
- if (allDeps['next']) result.framework = 'next';
232
- else if (allDeps['react']) result.framework = 'react';
233
- else if (allDeps['nuxt']) result.framework = 'nuxt';
234
- else if (allDeps['vue']) result.framework = 'vue';
235
- else if (allDeps['@angular/core']) result.framework = 'angular';
236
- else if (allDeps['svelte']) result.framework = 'svelte';
237
- else if (allDeps['vite']) result.framework = 'vite';
238
-
239
- // Detect CSS strategy
240
- if (allDeps['tailwindcss']) result.cssStrategy = 'tailwind';
241
- else if (allDeps['styled-components']) result.cssStrategy = 'styled-components';
242
- else if (allDeps['@emotion/react'] || allDeps['@emotion/styled']) result.cssStrategy = 'emotion';
243
- else if (allDeps['sass'] || allDeps['node-sass']) result.cssStrategy = 'sass';
244
- else {
245
- // Check for CSS modules (usually enabled by default in React/Next)
246
- if (['react', 'next'].includes(result.framework)) {
247
- result.cssStrategy = 'css-modules';
248
- } else {
249
- result.cssStrategy = 'external';
250
- }
251
- }
252
- } catch { /* ignore */ }
253
-
254
- return result;
255
- }
256
-
257
- // ══════════════════════════════════════════════════════════════════════════
258
- // SOURCE FILE FINDER
259
- // ══════════════════════════════════════════════════════════════════════════
260
- function findSourceFile(root, { selector, tagName, className }) {
261
- const result = { found: false, file: null, line: null, hint: '' };
262
-
263
- try {
264
- const srcDirs = ['src', 'app', 'pages', 'components', 'lib'];
265
- const extensions = ['.tsx', '.jsx', '.vue', '.svelte', '.js', '.ts'];
266
- const searchTerm = className || tagName || '';
267
-
268
- if (!searchTerm) {
269
- result.hint = 'Select an element with a class name for better results.';
270
- return result;
271
- }
272
-
273
- for (const dir of srcDirs) {
274
- const dirPath = path.join(root, dir);
275
- if (!fs.existsSync(dirPath)) continue;
276
-
277
- const files = walkDir(dirPath, extensions);
278
- for (const file of files) {
279
- try {
280
- const content = fs.readFileSync(file, 'utf8');
281
- const lines = content.split('\n');
282
- for (let i = 0; i < lines.length; i++) {
283
- if (lines[i].includes(searchTerm)) {
284
- result.found = true;
285
- result.file = path.relative(root, file);
286
- result.line = i + 1;
287
- result.hint = `Found "${searchTerm}" at ${result.file}:${result.line}`;
288
- return result;
289
- }
290
- }
291
- } catch { /* skip unreadable files */ }
292
- }
293
- }
294
-
295
- result.hint = `Could not find "${searchTerm}" in source files.`;
296
- } catch (e) {
297
- result.hint = 'Error searching source files: ' + e.message;
298
- }
299
-
300
- return result;
301
- }
302
-
303
- function walkDir(dir, extensions, results = []) {
304
- try {
305
- const entries = fs.readdirSync(dir, { withFileTypes: true });
306
- for (const entry of entries) {
307
- const fullPath = path.join(dir, entry.name);
308
- if (entry.name.startsWith('.') || entry.name === 'node_modules' || entry.name === '.next' || entry.name === 'dist') continue;
309
- if (entry.isDirectory()) {
310
- walkDir(fullPath, extensions, results);
311
- } else if (extensions.some(ext => entry.name.endsWith(ext))) {
312
- results.push(fullPath);
313
- }
314
- }
315
- } catch { /* ignore */ }
316
- return results;
317
- }
318
-
319
- // ══════════════════════════════════════════════════════════════════════════
320
- // AUTO-APPLY CHANGES TO SOURCE FILES
321
- // ══════════════════════════════════════════════════════════════════════════
322
-
323
- function autoApplyChange(root, selector, props, projectInfo) {
324
- const result = { selector, applied: false, file: null, strategy: null, reason: '' };
325
-
326
- // 1. Extract class name from selector
327
- const className = extractClassName(selector);
328
- if (!className) {
329
- result.reason = 'No class name in selector';
330
- return result;
331
- }
332
-
333
- // 2. Try: find existing CSS rule and modify it
334
- const cssResult = findAndModifyCSSRule(root, className, props);
335
- if (cssResult.applied) {
336
- result.applied = true;
337
- result.file = path.relative(root, cssResult.file);
338
- result.strategy = 'css-modify';
339
- return result;
340
- }
341
-
342
- // 3. Try: find JSX component using this class → find its CSS import → add rule there
343
- const importResult = findCSSImportAndAppend(root, className, props);
344
- if (importResult.applied) {
345
- result.applied = true;
346
- result.file = path.relative(root, importResult.file);
347
- result.strategy = 'css-append';
348
- return result;
349
- }
350
-
351
- // 4. Try: modify inline style in JSX
352
- const inlineResult = modifyInlineStyle(root, className, props);
353
- if (inlineResult.applied) {
354
- result.applied = true;
355
- result.file = path.relative(root, inlineResult.file);
356
- result.strategy = 'inline-style';
357
- return result;
358
- }
359
-
360
- // 5. Fallback: find any CSS file in project and append
361
- const fallbackResult = appendToAnyCSSFile(root, className, props);
362
- if (fallbackResult.applied) {
363
- result.applied = true;
364
- result.file = path.relative(root, fallbackResult.file);
365
- result.strategy = 'css-fallback';
366
- return result;
367
- }
368
-
369
- result.reason = 'Could not find target file for ' + className;
370
- return result;
371
- }
372
-
373
- // Extract the most specific class name from a CSS selector
374
- function extractClassName(selector) {
375
- // Selector like: #root > div > section.nexus-hero > h1.title
376
- const parts = selector.split('>').map(s => s.trim());
377
- // Try from the end — most specific first
378
- for (let i = parts.length - 1; i >= 0; i--) {
379
- const classMatch = parts[i].match(/\.([a-zA-Z][a-zA-Z0-9_-]*)/);
380
- if (classMatch) return classMatch[1];
381
- }
382
- return null;
383
- }
384
-
385
- // Strategy 1: Find existing CSS rule in .css/.scss files and modify it
386
- function findAndModifyCSSRule(root, className, props) {
387
- const cssFiles = walkDir(root, ['.css', '.scss', '.sass', '.less']);
388
- const ruleRegex = new RegExp(
389
- `(\\.${escapeRegex(className)}\\s*\\{)([^}]*)(\\})`, 's'
390
- );
391
-
392
- for (const file of cssFiles) {
393
- // Skip node_modules, .draply, etc
394
- const rel = path.relative(root, file);
395
- if (rel.includes('node_modules') || rel.includes('.draply')) continue;
396
-
397
- try {
398
- let content = fs.readFileSync(file, 'utf8');
399
- const match = content.match(ruleRegex);
400
- if (!match) continue;
401
-
402
- // Found the rule — modify it
403
- const existingBlock = match[2];
404
- let newBlock = existingBlock;
405
-
406
- for (const [prop, val] of Object.entries(props)) {
407
- const propRegex = new RegExp(`(${escapeRegex(prop)}\\s*:\\s*)([^;]+)(;)`, 'g');
408
- if (propRegex.test(newBlock)) {
409
- // Property exists — update value
410
- newBlock = newBlock.replace(
411
- new RegExp(`(${escapeRegex(prop)}\\s*:\\s*)([^;]+)(;)`, 'g'),
412
- `$1${val}$3`
413
- );
414
- } else {
415
- // Property doesn't exist — add it
416
- newBlock = newBlock.trimEnd() + `\n ${prop}: ${val};\n`;
417
- }
418
- }
419
-
420
- content = content.replace(ruleRegex, `$1${newBlock}$3`);
421
- fs.writeFileSync(file, content, 'utf8');
422
-
423
- return { applied: true, file };
424
- } catch { /* skip */ }
425
- }
426
-
427
- return { applied: false };
428
- }
429
-
430
- // Strategy 2: Find the JSX component using className, find its CSS import, append rule
431
- function findCSSImportAndAppend(root, className, props) {
432
- const jsxFiles = walkDir(root, ['.jsx', '.tsx', '.js', '.ts']);
433
-
434
- for (const jsxFile of jsxFiles) {
435
- const rel = path.relative(root, jsxFile);
436
- if (rel.includes('node_modules')) continue;
437
-
438
- try {
439
- const content = fs.readFileSync(jsxFile, 'utf8');
440
-
441
- // Check if this component uses the className
442
- if (!content.includes(className)) continue;
443
-
444
- // Find CSS imports in this file
445
- // Matches: import './styles.css' or import styles from './styles.module.css' or require('./styles.css')
446
- const importRegex = /(?:import\s+(?:\w+\s+from\s+)?['"]([^'"]+\.(?:css|scss|sass|less))['"]|require\(['"]([^'"]+\.(?:css|scss|sass|less))['"]\))/g;
447
- let importMatch;
448
- const cssImports = [];
449
-
450
- while ((importMatch = importRegex.exec(content)) !== null) {
451
- const importPath = importMatch[1] || importMatch[2];
452
- const fullCSSPath = path.resolve(path.dirname(jsxFile), importPath);
453
- if (fs.existsSync(fullCSSPath)) {
454
- cssImports.push(fullCSSPath);
455
- }
456
- }
457
-
458
- if (cssImports.length === 0) continue;
459
-
460
- // Append rule to the first CSS import
461
- const targetCSS = cssImports[0];
462
- const newRule = `\n/* Draply: .${className} */\n.${className} {\n${formatProps(props)}\n}\n`;
463
-
464
- let cssContent = fs.readFileSync(targetCSS, 'utf8');
465
-
466
- // Check if rule already exists (might have been added before)
467
- if (cssContent.includes(`.${className}`)) {
468
- // Modify existing rule instead
469
- return findAndModifyCSSRuleSingle(targetCSS, className, props);
470
- }
471
-
472
- cssContent += newRule;
473
- fs.writeFileSync(targetCSS, cssContent, 'utf8');
474
-
475
- return { applied: true, file: targetCSS };
476
- } catch { /* skip */ }
477
- }
478
-
479
- return { applied: false };
480
- }
481
-
482
- // Helper: modify rule in a specific file
483
- function findAndModifyCSSRuleSingle(file, className, props) {
484
- const ruleRegex = new RegExp(
485
- `(\\.${escapeRegex(className)}\\s*\\{)([^}]*)(\\})`, 's'
486
- );
487
- try {
488
- let content = fs.readFileSync(file, 'utf8');
489
- const match = content.match(ruleRegex);
490
- if (!match) return { applied: false };
491
-
492
- let newBlock = match[2];
493
- for (const [prop, val] of Object.entries(props)) {
494
- const propRegex = new RegExp(`(${escapeRegex(prop)}\\s*:\\s*)([^;]+)(;)`, 'g');
495
- if (propRegex.test(newBlock)) {
496
- newBlock = newBlock.replace(
497
- new RegExp(`(${escapeRegex(prop)}\\s*:\\s*)([^;]+)(;)`, 'g'),
498
- `$1${val}$3`
499
- );
500
- } else {
501
- newBlock = newBlock.trimEnd() + `\n ${prop}: ${val};\n`;
502
- }
503
- }
504
- content = content.replace(ruleRegex, `$1${newBlock}$3`);
505
- fs.writeFileSync(file, content, 'utf8');
506
- return { applied: true, file };
507
- } catch { return { applied: false }; }
508
- }
509
-
510
- // Strategy 3: Add inline style to JSX element
511
- function modifyInlineStyle(root, className, props) {
512
- const jsxFiles = walkDir(root, ['.jsx', '.tsx']);
513
-
514
- for (const file of jsxFiles) {
515
- const rel = path.relative(root, file);
516
- if (rel.includes('node_modules')) continue;
517
-
518
- try {
519
- let content = fs.readFileSync(file, 'utf8');
520
- if (!content.includes(className)) continue;
521
-
522
- // Find element with this className
523
- // Pattern: className="...nexus-hero..." or className={'...nexus-hero...'}
524
- const elementRegex = new RegExp(
525
- `(<[a-zA-Z][a-zA-Z0-9]*[^>]*className=["'{][^"'}]*${escapeRegex(className)}[^"'}]*["'}])([^>]*>|\\s*/>)`,
526
- 's'
527
- );
528
-
529
- const match = content.match(elementRegex);
530
- if (!match) continue;
531
-
532
- const fullTag = match[0];
533
- const jsxProps = propsToJSXStyle(props);
534
-
535
- // Check if element already has a style prop
536
- if (fullTag.includes('style=')) {
537
- // Modify existing style prop — add/update properties
538
- const styleRegex = /style=\{\{([^}]*)\}\}/;
539
- const styleMatch = fullTag.match(styleRegex);
540
- if (styleMatch) {
541
- let existingStyles = styleMatch[1];
542
- for (const [camelProp, val] of Object.entries(jsxProps)) {
543
- const propRegex = new RegExp(`${camelProp}\\s*:\\s*['"][^'"]*['"]`, 'g');
544
- if (propRegex.test(existingStyles)) {
545
- existingStyles = existingStyles.replace(propRegex, `${camelProp}: '${val}'`);
546
- } else {
547
- existingStyles = existingStyles.trim();
548
- if (existingStyles && !existingStyles.endsWith(',')) existingStyles += ',';
549
- existingStyles += ` ${camelProp}: '${val}'`;
550
- }
551
- }
552
- const newTag = fullTag.replace(styleRegex, `style={{${existingStyles}}}`);
553
- content = content.replace(fullTag, newTag);
554
- }
555
- } else {
556
- // Add new style prop
557
- const styleStr = Object.entries(jsxProps)
558
- .map(([k, v]) => `${k}: '${v}'`)
559
- .join(', ');
560
- const insertion = ` style={{${styleStr}}}`;
561
- // Insert before the closing > or />
562
- const newTag = fullTag.replace(match[2], insertion + match[2]);
563
- content = content.replace(fullTag, newTag);
564
- }
565
-
566
- fs.writeFileSync(file, content, 'utf8');
567
- return { applied: true, file };
568
- } catch { /* skip */ }
569
- }
570
-
571
- return { applied: false };
572
- }
573
-
574
- // Strategy 4: Find any CSS file in the project and append the rule
575
- function appendToAnyCSSFile(root, className, props) {
576
- // Priority: index.css, App.css, globals.css, main.css, styles.css
577
- const candidates = [
578
- 'src/index.css', 'src/App.css', 'src/app/globals.css', 'src/globals.css',
579
- 'src/main.css', 'src/styles.css', 'styles/globals.css', 'app/globals.css',
580
- 'src/styles/globals.css'
581
- ];
582
-
583
- for (const candidate of candidates) {
584
- const fullPath = path.join(root, candidate);
585
- if (fs.existsSync(fullPath)) {
586
- try {
587
- let content = fs.readFileSync(fullPath, 'utf8');
588
- // Check if rule already exists
589
- if (content.includes(`.${className}`)) {
590
- return findAndModifyCSSRuleSingle(fullPath, className, props);
591
- }
592
- const newRule = `\n/* Draply: .${className} */\n.${className} {\n${formatProps(props)}\n}\n`;
593
- content += newRule;
594
- fs.writeFileSync(fullPath, content, 'utf8');
595
- return { applied: true, file: fullPath };
596
- } catch { /* skip */ }
597
- }
598
- }
599
-
600
- // Last resort: find ANY css file
601
- const allCSS = walkDir(root, ['.css']);
602
- for (const file of allCSS) {
603
- const rel = path.relative(root, file);
604
- if (rel.includes('node_modules') || rel.includes('.draply') || rel.includes('.next')) continue;
605
- try {
606
- let content = fs.readFileSync(file, 'utf8');
607
- if (content.includes(`.${className}`)) {
608
- return findAndModifyCSSRuleSingle(file, className, props);
609
- }
610
- const newRule = `\n/* Draply: .${className} */\n.${className} {\n${formatProps(props)}\n}\n`;
611
- content += newRule;
612
- fs.writeFileSync(file, content, 'utf8');
613
- return { applied: true, file };
614
- } catch { /* skip */ }
615
- }
616
-
617
- return { applied: false };
618
- }
619
-
620
- // ── Helpers ──────────────────────────────────────────────────────────────────
621
-
622
- function escapeRegex(str) {
623
- return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
624
- }
625
-
626
- function formatProps(props) {
627
- return Object.entries(props).map(([k, v]) => ` ${k}: ${v};`).join('\n');
628
- }
629
-
630
- function camelCase(str) {
631
- return str.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
632
- }
633
-
634
- function propsToJSXStyle(props) {
635
- const result = {};
636
- for (const [prop, val] of Object.entries(props)) {
637
- result[camelCase(prop)] = val;
638
- }
639
- return result;
640
- }
381
+ process.on('SIGINT', () => { console.log('\n \x1b[90mDraply stopped\x1b[0m\n'); process.exit(0); });