drafted 1.0.0 → 1.1.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drafted",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Design workspace for AI agents — MCP server and CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -11,6 +11,7 @@
11
11
  "mcp/",
12
12
  "cli/",
13
13
  "shared/",
14
+ "skills/",
14
15
  "README.md"
15
16
  ],
16
17
  "dependencies": {
@@ -0,0 +1,317 @@
1
+ ---
2
+ description: Import an existing website into Drafted as a design frame. Renders the page in a headless browser to capture the full DOM (including SPA content), downloads all assets, rewrites paths, then uploads via MCP tools.
3
+ argument-hint: "[URL or local path, e.g., 'https://example.com' or './dist']"
4
+ ---
5
+ # /import-website-to-drafted
6
+
7
+ Import a website into Drafted as a fully rendered frame with all its assets.
8
+
9
+ ## How it works
10
+
11
+ Drafted frames render in iframes. The server injects a `<base>` tag so relative paths like `<link href="css/styles.css">` resolve to the frame's assets in R2 storage. You upload the HTML as a frame via `write`, and all referenced files (CSS, JS, images, fonts) as assets via `upload_asset` or `batch`.
12
+
13
+ **Most modern sites are SPAs** (React, Framer, Webflow, Next.js) where the HTML source is just a shell and the real content is rendered by JavaScript. A simple HTTP fetch won't capture the content. This skill uses Puppeteer to render the page fully before capturing.
14
+
15
+ ## Step 1: Create/open a project
16
+
17
+ ```
18
+ create_project({ name: "My Import" })
19
+ open_project({ projectId: "..." })
20
+ ```
21
+
22
+ ## Step 2: Ensure Puppeteer is available
23
+
24
+ ```bash
25
+ npm ls puppeteer 2>/dev/null || npm install puppeteer
26
+ ```
27
+
28
+ ## Step 3: Render, capture, and download assets
29
+
30
+ Run this script locally. It launches a headless browser, renders the page fully (including SPA content), captures the complete DOM, then downloads all external assets and rewrites URLs to relative paths.
31
+
32
+ **Adapt `SITE_URL` as needed:**
33
+
34
+ ```bash
35
+ node -e "
36
+ const puppeteer = require('puppeteer');
37
+ const https = require('https');
38
+ const http = require('http');
39
+ const fs = require('fs');
40
+ const path = require('path');
41
+ const { URL } = require('url');
42
+
43
+ const SITE_URL = 'https://example.com'; // ← CHANGE THIS
44
+ const OUT_DIR = '/tmp/site-import';
45
+
46
+ // ── Helpers ──────────────────────────────────────────────────────
47
+
48
+ function download(url) {
49
+ return new Promise((resolve, reject) => {
50
+ const mod = url.startsWith('https') ? https : http;
51
+ mod.get(url, { headers: { 'User-Agent': 'Mozilla/5.0' } }, res => {
52
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
53
+ return download(res.headers.location).then(resolve, reject);
54
+ }
55
+ if (res.statusCode >= 400) return reject(new Error('HTTP ' + res.statusCode));
56
+ const chunks = [];
57
+ res.on('data', c => chunks.push(c));
58
+ res.on('end', () => resolve(Buffer.concat(chunks)));
59
+ res.on('error', reject);
60
+ }).on('error', reject);
61
+ });
62
+ }
63
+
64
+ function urlToAssetPath(urlStr) {
65
+ try {
66
+ const u = new URL(urlStr);
67
+ const domain = u.hostname.replace(/[^a-z0-9.-]/g, '_');
68
+ let p = u.pathname.replace(/[?#].*/, '');
69
+ if (!p || p === '/') p = '/index';
70
+ if (!path.extname(p)) {
71
+ // Guess extension from common patterns
72
+ if (urlStr.includes('font') || urlStr.includes('woff')) p += '.woff2';
73
+ else if (urlStr.includes('css') || urlStr.includes('stylesheet')) p += '.css';
74
+ else p += '.bin';
75
+ }
76
+ return 'assets/' + domain + p;
77
+ } catch { return null; }
78
+ }
79
+
80
+ const MIME_BY_EXT = {
81
+ '.css': 'text/css', '.js': 'application/javascript', '.mjs': 'application/javascript',
82
+ '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif',
83
+ '.webp': 'image/webp', '.svg': 'image/svg+xml', '.ico': 'image/x-icon',
84
+ '.woff': 'font/woff', '.woff2': 'font/woff2', '.ttf': 'font/ttf', '.otf': 'font/otf',
85
+ '.json': 'application/json', '.pdf': 'application/pdf',
86
+ };
87
+
88
+ // ── Main ─────────────────────────────────────────────────────────
89
+
90
+ (async () => {
91
+ fs.mkdirSync(OUT_DIR, { recursive: true });
92
+
93
+ // 1. Render the page in a real browser
94
+ console.log('Launching browser...');
95
+ const browser = await puppeteer.launch({
96
+ headless: true,
97
+ args: ['--no-sandbox', '--disable-setuid-sandbox'],
98
+ });
99
+ const page = await browser.newPage();
100
+ await page.setViewport({ width: 1440, height: 900 });
101
+
102
+ console.log('Navigating to', SITE_URL);
103
+ await page.goto(SITE_URL, { waitUntil: 'networkidle0', timeout: 30000 });
104
+
105
+ // Scroll to bottom to trigger lazy-loaded content
106
+ await page.evaluate(async () => {
107
+ await new Promise(resolve => {
108
+ let total = 0;
109
+ const timer = setInterval(() => {
110
+ window.scrollBy(0, 400);
111
+ total += 400;
112
+ if (total >= document.body.scrollHeight) { clearInterval(timer); resolve(); }
113
+ }, 100);
114
+ setTimeout(() => { clearInterval(timer); resolve(); }, 5000);
115
+ });
116
+ window.scrollTo(0, 0);
117
+ });
118
+ await new Promise(r => setTimeout(r, 1000)); // let lazy content settle
119
+
120
+ // 2. Capture the fully rendered DOM
121
+ let html = await page.content();
122
+ const pageHeight = await page.evaluate(() => document.documentElement.scrollHeight);
123
+ console.log('Captured rendered DOM (' + (html.length / 1024).toFixed(0) + ' KB, page height: ' + pageHeight + 'px)');
124
+
125
+ // 3. Capture computed styles and inline them (prevents style loss)
126
+ const inlineStyles = await page.evaluate(() => {
127
+ const styles = [];
128
+ for (const sheet of document.styleSheets) {
129
+ try {
130
+ const rules = Array.from(sheet.cssRules || []).map(r => r.cssText).join('\\n');
131
+ if (rules) styles.push(rules);
132
+ } catch { /* cross-origin stylesheet, skip */ }
133
+ }
134
+ return styles.join('\\n');
135
+ });
136
+
137
+ await browser.close();
138
+
139
+ // 4. Find all external URLs in the rendered HTML + inline styles
140
+ const combined = html + '\\n' + inlineStyles;
141
+ const urlPattern = /(?:src|href|srcset)=[\"']?(https?:\\/\\/[^\"'\\s,>]+)[\"']?|url\\([\"']?(https?:\\/\\/[^\"')]+)[\"']?\\)/g;
142
+ const skipDomains = ['google-analytics.com', 'googletagmanager.com', 'segment.com', 'hotjar.com', 'facebook.net', 'events.framer.com'];
143
+ const assetExtensions = /\\.(css|js|mjs|json|png|jpe?g|gif|webp|svg|ico|woff2?|ttf|otf|eot|mp4|webm|pdf)(\\?|$)/i;
144
+
145
+ const assets = new Map();
146
+ let match;
147
+ while ((match = urlPattern.exec(combined)) !== null) {
148
+ const url = (match[1] || match[2] || '').split(/[\\s,]/)[0]; // handle srcset
149
+ if (!url || assets.has(url)) continue;
150
+ try { if (skipDomains.some(d => url.includes(d))) continue; } catch {}
151
+ if (!assetExtensions.test(url) && !url.includes('font') && !url.includes('woff')) continue;
152
+ const assetPath = urlToAssetPath(url);
153
+ if (assetPath) assets.set(url, assetPath);
154
+ }
155
+
156
+ console.log('Found', assets.size, 'assets to download');
157
+
158
+ // 5. Download each asset
159
+ let downloaded = 0, failed = 0;
160
+ for (const [url, relPath] of assets) {
161
+ const outPath = path.join(OUT_DIR, relPath);
162
+ fs.mkdirSync(path.dirname(outPath), { recursive: true });
163
+ try {
164
+ const data = await download(url);
165
+ fs.writeFileSync(outPath, data);
166
+ downloaded++;
167
+ } catch (e) {
168
+ console.error(' FAILED:', url.substring(0, 80), e.message);
169
+ failed++;
170
+ assets.delete(url); // remove so we don't rewrite to a missing file
171
+ }
172
+ }
173
+ console.log('Downloaded:', downloaded, '| Failed:', failed);
174
+
175
+ // 6. Rewrite URLs in HTML AND in downloaded text files (CSS, JS, MJS)
176
+ const rewriteTargets = [{ content: html, path: path.join(OUT_DIR, 'index.html') }];
177
+ for (const [, relPath] of assets) {
178
+ const ext = path.extname(relPath).toLowerCase();
179
+ if (['.css', '.js', '.mjs', '.json', '.svg'].includes(ext)) {
180
+ const filePath = path.join(OUT_DIR, relPath);
181
+ if (fs.existsSync(filePath)) {
182
+ rewriteTargets.push({ content: fs.readFileSync(filePath, 'utf8'), path: filePath });
183
+ }
184
+ }
185
+ }
186
+
187
+ for (const target of rewriteTargets) {
188
+ let content = target.content;
189
+ for (const [url, relPath] of assets) {
190
+ // In the main HTML, use relative paths directly
191
+ // In asset files, compute the relative path from the asset to the referenced asset
192
+ if (target.path.endsWith('index.html')) {
193
+ content = content.split(url).join(relPath);
194
+ } else {
195
+ const fromDir = path.dirname(target.path.replace(OUT_DIR + '/', ''));
196
+ const toPath = relPath;
197
+ const rel = path.relative(fromDir, toPath);
198
+ content = content.split(url).join(rel);
199
+ }
200
+ }
201
+ if (target.path.endsWith('index.html')) html = content;
202
+ fs.writeFileSync(target.path, content);
203
+ }
204
+
205
+ // 7. Inject captured styles into the HTML (ensures styles survive without external sheets)
206
+ if (inlineStyles) {
207
+ const styleTag = '<style id=\"captured-styles\">' + inlineStyles + '</style>';
208
+ if (html.includes('</head>')) {
209
+ html = html.replace('</head>', styleTag + '</head>');
210
+ } else {
211
+ html = styleTag + html;
212
+ }
213
+ }
214
+
215
+ // 8. Clean up HTML
216
+ html = html.replace(/<base[^>]*>/gi, '');
217
+ html = html.replace(/<meta[^>]*Content-Security-Policy[^>]*>/gi, '');
218
+ html = html.replace(/<script[^>]*(?:analytics|gtag|segment|hotjar|events\\.framer)[^>]*>.*?<\\/script>/gis, '');
219
+ html = html.replace(/navigator\\.serviceWorker\\.register\\([^)]*\\)/g, '');
220
+ html = html.replace(/\\/\\/# sourceMappingURL=[^\\n]*/g, '');
221
+
222
+ // 9. Write final HTML
223
+ fs.writeFileSync(path.join(OUT_DIR, 'index.html'), html);
224
+
225
+ // 10. Write manifest
226
+ const manifest = Array.from(assets.entries()).map(([url, relPath]) => ({
227
+ asset_path: relPath,
228
+ file_path: path.join(OUT_DIR, relPath),
229
+ exists: fs.existsSync(path.join(OUT_DIR, relPath)),
230
+ })).filter(a => a.exists);
231
+ fs.writeFileSync(path.join(OUT_DIR, 'manifest.json'), JSON.stringify(manifest, null, 2));
232
+
233
+ console.log('\\nOutput:', OUT_DIR);
234
+ console.log('HTML:', path.join(OUT_DIR, 'index.html'), '(' + (html.length / 1024).toFixed(0) + ' KB)');
235
+ console.log('Page height:', pageHeight, 'px (use this for frame height)');
236
+ console.log('Manifest:', manifest.length, 'assets ready to upload');
237
+ })().catch(e => { console.error(e); process.exit(1); });
238
+ "
239
+ ```
240
+
241
+ This outputs:
242
+ - `/tmp/site-import/index.html` — fully rendered HTML with relative paths and captured styles
243
+ - `/tmp/site-import/assets/...` — all downloaded assets (with URLs rewritten inside CSS/JS files too)
244
+ - `/tmp/site-import/manifest.json` — asset list for the upload step
245
+ - The page height in pixels (use this for the frame height)
246
+
247
+ **Key improvements over a plain HTTP fetch:**
248
+ - Renders the page in a real browser — captures SPA content (React, Framer, Webflow, etc.)
249
+ - Scrolls to trigger lazy-loaded images and content
250
+ - Captures computed CSS and inlines it — styles survive even if external sheets fail
251
+ - Rewrites URLs inside CSS and JS files, not just HTML
252
+ - Handles `srcset` attributes
253
+
254
+ ## Step 4: Write the frame
255
+
256
+ Use `file_path` to write the HTML directly from disk — this handles large files that won't fit in a `content` parameter:
257
+
258
+ ```
259
+ write({
260
+ path: "/designs/my-site/homepage.html",
261
+ file_path: "/tmp/site-import/index.html",
262
+ width: 1440,
263
+ height: <page-height-from-script-output>
264
+ })
265
+ ```
266
+
267
+ The `write` tool reads `.html` files as inline content (not binary), so the frame gets proper base tag injection and all relative asset paths will resolve. Note the frame ID in the response — you'll use it in the next step.
268
+
269
+ **Use the page height from the script output** as the frame height so the full page is visible without scrolling within the frame.
270
+
271
+ ## Step 5: Upload assets
272
+
273
+ Read `manifest.json` and batch upload all assets:
274
+
275
+ ```
276
+ batch({
277
+ operations: [
278
+ { tool: "upload_asset", asset_path: "assets/fonts.gstatic.com/s/outfit/v14/abc.woff2", file_path: "/tmp/site-import/assets/fonts.gstatic.com/s/outfit/v14/abc.woff2", frame_id: "<frame-id>" },
279
+ { tool: "upload_asset", asset_path: "assets/cdn.example.com/img/hero.png", file_path: "/tmp/site-import/assets/cdn.example.com/img/hero.png", frame_id: "<frame-id>" },
280
+ // ... one entry per asset from manifest.json
281
+ ]
282
+ })
283
+ ```
284
+
285
+ Associate assets with the frame via `frame_id` so they're cleaned up if the frame is deleted.
286
+
287
+ **For large manifests (30+ assets):** Split into batches of 20-30 operations to avoid timeouts.
288
+
289
+ ## Step 6: Verify
290
+
291
+ ```
292
+ screenshot({ target: "/designs/my-site/homepage.html", fullPage: true })
293
+ ```
294
+
295
+ Check for missing images, broken styles, or layout issues. If assets are missing, check `list_assets({ frame_id: "..." })` against the manifest.
296
+
297
+ ## For local build directories
298
+
299
+ If importing from a local build output (e.g., `./dist` from Vite, `./build` from CRA):
300
+
301
+ 1. Skip the Puppeteer step — read `index.html` directly
302
+ 2. Find all asset references in the HTML
303
+ 3. Copy assets to `/tmp/site-import/assets/` preserving relative paths
304
+ 4. `write` the HTML + `batch upload_asset` the assets
305
+
306
+ The Puppeteer approach is only needed for live URLs where JS rendering matters.
307
+
308
+ ## Common issues
309
+
310
+ | Problem | Cause | Fix |
311
+ |---------|-------|-----|
312
+ | Blank frame | CSP meta tag not stripped | Check the cleanup regex caught it |
313
+ | Missing images | `srcset` or `<picture>` not captured | Add patterns for these to the URL regex |
314
+ | Broken layout | Computed styles not captured | Check the inlined `<style id="captured-styles">` tag |
315
+ | 404 on assets | URL rewriting missed some references | Check downloaded CSS/JS files for un-rewritten absolute URLs |
316
+ | Frame too short | Used default height instead of page height | Use the page height from script output |
317
+ | Puppeteer not found | Not installed | Run `npm install puppeteer` first |