designlang 11.2.0 → 11.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/design-extract.js +15 -0
- package/marketplace/SUBMISSION-PLAYBOOK.md +95 -0
- package/marketplace/chrome-listing.md +45 -0
- package/marketplace/claude-code-skill.md +52 -0
- package/marketplace/cursor-listing.md +55 -0
- package/marketplace/figma-listing.md +50 -0
- package/marketplace/raycast-listing.md +38 -0
- package/marketplace/vscode-listing.md +46 -0
- package/package.json +1 -1
- package/src/chat.js +356 -0
- package/src/clone.js +6 -2
- package/src/crawler.js +18 -8
- package/src/extractors/background-patterns.js +15 -4
- package/src/extractors/material-language.js +4 -2
- package/src/extractors/motion.js +14 -7
- package/src/formatters/markdown.js +1 -1
- package/src/formatters/prompt-pack.js +1 -1
- package/src/history.js +2 -4
- package/src/studio.js +15 -5
- package/src/sync.js +14 -18
package/bin/design-extract.js
CHANGED
|
@@ -1085,6 +1085,21 @@ program
|
|
|
1085
1085
|
}
|
|
1086
1086
|
});
|
|
1087
1087
|
|
|
1088
|
+
// ── Chat — REPL over a live extraction (v12) ──────────────
|
|
1089
|
+
program
|
|
1090
|
+
.command('chat <target>')
|
|
1091
|
+
.description('Interactive REPL over an extraction. <target> is either a URL or a path to an existing *-design-tokens.json file.')
|
|
1092
|
+
.option('-o, --out <dir>', 'output directory for `save`', './chat-output')
|
|
1093
|
+
.action(async (target, opts) => {
|
|
1094
|
+
try {
|
|
1095
|
+
const { runChat } = await import('../src/chat.js');
|
|
1096
|
+
await runChat(target, opts);
|
|
1097
|
+
} catch (err) {
|
|
1098
|
+
console.error(chalk.red(`\n ${err.message}\n`));
|
|
1099
|
+
process.exit(1);
|
|
1100
|
+
}
|
|
1101
|
+
});
|
|
1102
|
+
|
|
1088
1103
|
// ── Replay — record a short WebM of motion from a URL ─────
|
|
1089
1104
|
program
|
|
1090
1105
|
.command('replay <url>')
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# designlang — Distribution Submission Playbook
|
|
2
|
+
|
|
3
|
+
Submit each marketplace below. All listing copy + manifests are pre-written.
|
|
4
|
+
Review queues take 1–7 days; this playbook is everything you need to click "submit".
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## 1. Figma Community (`figma-plugin/`)
|
|
9
|
+
|
|
10
|
+
**Goal:** publicly listed in the Figma Community plugin store.
|
|
11
|
+
|
|
12
|
+
**Steps:**
|
|
13
|
+
1. Open Figma Desktop → Community → Publish a plugin.
|
|
14
|
+
2. Upload from local: `figma-plugin/`.
|
|
15
|
+
3. Use the title, tagline, description, tags, and cover-image brief from `marketplace/figma-listing.md`.
|
|
16
|
+
4. Screenshots: take 4 from the live extraction studio.
|
|
17
|
+
5. Submit. Review queue: 3–7 days.
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## 2. Cursor — Custom Tool listing
|
|
22
|
+
|
|
23
|
+
**Goal:** Cursor users can `Cmd+Shift+P → designlang: Extract` from any URL.
|
|
24
|
+
|
|
25
|
+
**Steps:**
|
|
26
|
+
1. Read `marketplace/cursor-listing.md`.
|
|
27
|
+
2. The provided `mcp.json` snippet is what users paste into `~/.cursor/mcp.json`.
|
|
28
|
+
3. We don't submit to Cursor's marketplace directly (no formal review process yet);
|
|
29
|
+
the MCP integration is documented and self-served. Promotion = blog post + tweet.
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## 3. VS Code Marketplace (`vscode-extension/`)
|
|
34
|
+
|
|
35
|
+
**Goal:** publicly listed at marketplace.visualstudio.com/items?itemName=designlang.
|
|
36
|
+
|
|
37
|
+
**Steps:**
|
|
38
|
+
1. Install `vsce` once: `npm install -g @vscode/vsce`.
|
|
39
|
+
2. From `vscode-extension/`: `vsce package` → produces `.vsix`.
|
|
40
|
+
3. Create publisher account: <https://marketplace.visualstudio.com/manage>.
|
|
41
|
+
4. `vsce publish` (or upload the `.vsix` via the dashboard).
|
|
42
|
+
5. Use the listing copy from `marketplace/vscode-listing.md`.
|
|
43
|
+
6. Review queue: ~24h.
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## 4. Claude Code Skill registry
|
|
48
|
+
|
|
49
|
+
**Goal:** designlang appears in the official Claude Code skills list.
|
|
50
|
+
|
|
51
|
+
**Steps:**
|
|
52
|
+
1. Read `marketplace/claude-code-skill.md` — it contains the SKILL.md the registry expects.
|
|
53
|
+
2. The SKILL.md is already emitted by `designlang <url> --emit-agent-rules` at
|
|
54
|
+
`.claude/skills/designlang/SKILL.md`. We submit a copy at the project root for discovery.
|
|
55
|
+
3. Open a PR against <https://github.com/anthropics/claude-code-skills> (or the
|
|
56
|
+
then-current registry repo) — title: "Add: designlang skill".
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
## 5. Raycast Store (`raycast-extension/`)
|
|
61
|
+
|
|
62
|
+
**Goal:** designlang as a one-keystroke Raycast command.
|
|
63
|
+
|
|
64
|
+
**Steps:**
|
|
65
|
+
1. Read `marketplace/raycast-listing.md`.
|
|
66
|
+
2. From `raycast-extension/`: follow Raycast's contribution guide
|
|
67
|
+
<https://developers.raycast.com/basics/publish-an-extension>.
|
|
68
|
+
3. PR to <https://github.com/raycast/extensions>.
|
|
69
|
+
4. Review queue: 5–14 days.
|
|
70
|
+
|
|
71
|
+
---
|
|
72
|
+
|
|
73
|
+
## 6. Chrome Web Store (`chrome-extension/`)
|
|
74
|
+
|
|
75
|
+
**Goal:** one-click extract from any tab via the Chrome toolbar.
|
|
76
|
+
|
|
77
|
+
**Steps:**
|
|
78
|
+
1. Read `marketplace/chrome-listing.md`.
|
|
79
|
+
2. Zip `chrome-extension/`: `cd chrome-extension && zip -r ../designlang-chrome.zip .`.
|
|
80
|
+
3. Upload at <https://chrome.google.com/webstore/devconsole>. ($5 one-time dev fee.)
|
|
81
|
+
4. Review queue: 1–3 days.
|
|
82
|
+
|
|
83
|
+
---
|
|
84
|
+
|
|
85
|
+
## Sequencing
|
|
86
|
+
|
|
87
|
+
Do them in this order — easiest payoff first, most setup last:
|
|
88
|
+
1. **Cursor MCP** (no review, just docs + tweet — ship today).
|
|
89
|
+
2. **Chrome Web Store** ($5 fee, 1–3 day review — submit today).
|
|
90
|
+
3. **VS Code Marketplace** (free, ~24h review — submit today).
|
|
91
|
+
4. **Figma Community** (free, 3–7 day review — submit today).
|
|
92
|
+
5. **Claude Code Skills registry** (PR-based, async — submit this week).
|
|
93
|
+
6. **Raycast Store** (PR-based, 5–14 day review — submit this week).
|
|
94
|
+
|
|
95
|
+
Total click-time across all six: ~90 minutes. The wall-clock for everything to be live: ~2 weeks.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# Chrome Web Store — designlang extension
|
|
2
|
+
|
|
3
|
+
## Name
|
|
4
|
+
|
|
5
|
+
designlang — Extract design system from this tab
|
|
6
|
+
|
|
7
|
+
## Summary (max 132 chars)
|
|
8
|
+
|
|
9
|
+
One click to extract any website's design system: tokens, typography, spacing, components, voice. $0, no account, MIT.
|
|
10
|
+
|
|
11
|
+
## Description (long)
|
|
12
|
+
|
|
13
|
+
designlang lives in your Chrome toolbar. Click the icon on any tab; the active URL is sent to designlang.app, which runs a Playwright extraction and opens the result inline as tabs (DESIGN.md, DTCG tokens, Tailwind, CSS variables, Figma variables, and 7 more).
|
|
14
|
+
|
|
15
|
+
Permissions:
|
|
16
|
+
• `activeTab` — read the URL of the current tab. Nothing else.
|
|
17
|
+
|
|
18
|
+
Use cases:
|
|
19
|
+
• Read a competitor's design tokens in 5 seconds
|
|
20
|
+
• Pull a Tailwind config off any production site
|
|
21
|
+
• Generate a DESIGN.md for AI coding agents (Claude Code, Cursor, Windsurf)
|
|
22
|
+
• One-click copy hex / radius / shadow values
|
|
23
|
+
• Import a Figma Variable collection from any URL
|
|
24
|
+
|
|
25
|
+
Open source: https://github.com/Manavarya09/design-extract
|
|
26
|
+
|
|
27
|
+
## Category
|
|
28
|
+
|
|
29
|
+
Developer Tools
|
|
30
|
+
|
|
31
|
+
## Languages
|
|
32
|
+
|
|
33
|
+
English
|
|
34
|
+
|
|
35
|
+
## Screenshots (4 — 1280x800)
|
|
36
|
+
|
|
37
|
+
1. Browser tab on a real site (e.g. Stripe), with the designlang toolbar icon highlighted.
|
|
38
|
+
2. The same tab after clicking — designlang.app opens with extraction in progress (token paint streaming).
|
|
39
|
+
3. The result viewer with the DESIGN.md tab active, source code visible, copy/download buttons.
|
|
40
|
+
4. The result viewer on the Tailwind config tab, showing extracted palette + scale.
|
|
41
|
+
|
|
42
|
+
## Submission
|
|
43
|
+
|
|
44
|
+
ZIP the `chrome-extension/` directory, upload at <https://chrome.google.com/webstore/devconsole>.
|
|
45
|
+
$5 one-time developer fee.
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# Claude Code Skills registry — designlang
|
|
2
|
+
|
|
3
|
+
## Skill name
|
|
4
|
+
|
|
5
|
+
designlang
|
|
6
|
+
|
|
7
|
+
## SKILL.md (the file the registry expects)
|
|
8
|
+
|
|
9
|
+
```markdown
|
|
10
|
+
---
|
|
11
|
+
name: designlang
|
|
12
|
+
description: Reverse-engineer any website into a complete design system. Outputs DTCG W3C tokens, motion, anatomy, voice, page intent, material language, plus a single agent-native DESIGN.md and a one-command Next.js clone. Use when the user wants to extract, mirror, or compare design tokens against a live URL.
|
|
13
|
+
when_to_use: |
|
|
14
|
+
Use this skill when the user wants to:
|
|
15
|
+
- Extract design tokens, palette, typography, or shadows from a public website
|
|
16
|
+
- Generate a DESIGN.md for an existing site (agent-native single-file artifact)
|
|
17
|
+
- Clone a website's design as a runnable Next.js starter
|
|
18
|
+
- Compare local tokens against a deployed site (drift detection)
|
|
19
|
+
- Import a website's tokens into Figma, Tailwind, iOS, Android, Flutter, or WordPress
|
|
20
|
+
arguments:
|
|
21
|
+
- name: url
|
|
22
|
+
description: Public URL of the site to extract from.
|
|
23
|
+
required: true
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
# designlang skill
|
|
27
|
+
|
|
28
|
+
`npx designlang <url>` runs a Playwright extraction and writes ~25 files
|
|
29
|
+
(DTCG tokens, Tailwind, CSS vars, Figma vars, motion, anatomy, voice,
|
|
30
|
+
intent, material, library detection, prompt pack for v0/Lovable/Cursor,
|
|
31
|
+
plus the single-file `DESIGN.md`).
|
|
32
|
+
|
|
33
|
+
Sub-commands:
|
|
34
|
+
- `designlang clone <url>` — generates a working Next.js repo from the extraction
|
|
35
|
+
- `designlang ci <url> --tokens ./tokens.json` — drift bot, writes a PR-comment markdown
|
|
36
|
+
- `designlang studio` — local web studio over the latest extraction
|
|
37
|
+
- `designlang chat <url>` — REPL with mutations (sharpen / soften / dark / brutalist / glass / swap-color / swap-font)
|
|
38
|
+
- `designlang mcp` — stdio MCP server
|
|
39
|
+
|
|
40
|
+
For agent rules and ready-to-paste prompts, run with `--emit-agent-rules` or read
|
|
41
|
+
the `*-prompts/` directory.
|
|
42
|
+
|
|
43
|
+
The full spec for `DESIGN.md` is at <https://designlang.app/spec>.
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Submission flow
|
|
47
|
+
|
|
48
|
+
1. Fork <https://github.com/anthropics/claude-code> (or the then-current registry repo).
|
|
49
|
+
2. Add `skills/designlang/SKILL.md` with the content above.
|
|
50
|
+
3. PR title: `Add: designlang skill`.
|
|
51
|
+
4. PR body: link to <https://designlang.app>, describe the surface, mention 1.6k stars + 5K npm downloads.
|
|
52
|
+
5. Tag the PR with `skill` and `community`.
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# Cursor — designlang MCP integration
|
|
2
|
+
|
|
3
|
+
## What it gives the user
|
|
4
|
+
|
|
5
|
+
Inside Cursor (and Windsurf, and Claude Desktop), `designlang` exposes the **last extraction** as a live MCP resource. Cursor can read tokens, regions, components, and CSS health without re-extracting, and call MCP tools to refine the design (`designlang chat` ops are wired here).
|
|
6
|
+
|
|
7
|
+
## Install (paste into `~/.cursor/mcp.json`)
|
|
8
|
+
|
|
9
|
+
```json
|
|
10
|
+
{
|
|
11
|
+
"mcpServers": {
|
|
12
|
+
"designlang": {
|
|
13
|
+
"command": "npx",
|
|
14
|
+
"args": ["-y", "designlang", "mcp", "--output-dir", "./design-extract-output"]
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## What Cursor sees
|
|
21
|
+
|
|
22
|
+
Resources:
|
|
23
|
+
- `designlang://latest/tokens` — DTCG W3C token tree of the most recent extraction
|
|
24
|
+
- `designlang://latest/regions` — semantic region map (nav, hero, pricing, …)
|
|
25
|
+
- `designlang://latest/components` — component clusters with variant/size/state
|
|
26
|
+
- `designlang://latest/css-health` — specificity graph + unused-CSS report
|
|
27
|
+
- `designlang://latest/design-md` — the agent-native single-file artifact
|
|
28
|
+
|
|
29
|
+
Tools:
|
|
30
|
+
- `designlang_extract` — paste a URL, get tokens
|
|
31
|
+
- `designlang_chat` — apply mutations (sharpen / soften / dark / brutalist / glass / swap-color / swap-font)
|
|
32
|
+
- `designlang_clone` — generate a working Next.js repo from a URL
|
|
33
|
+
- `designlang_drift` — compare local tokens against a live URL
|
|
34
|
+
|
|
35
|
+
## Tweet thread for launch
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
1/ Cursor + designlang. Paste any URL inside Cursor and it reads the live design system as MCP resources.
|
|
39
|
+
|
|
40
|
+
`~/.cursor/mcp.json`:
|
|
41
|
+
{
|
|
42
|
+
"mcpServers": {
|
|
43
|
+
"designlang": {
|
|
44
|
+
"command": "npx",
|
|
45
|
+
"args": ["-y", "designlang", "mcp"]
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
2/ Then ask: "Refactor my <Button> to match stripe.com's design language" — Cursor reads designlang://latest/tokens and rewrites against the real palette, radii, shadows, voice.
|
|
51
|
+
|
|
52
|
+
3/ It also exposes `designlang_chat` as a tool. "Make it brutalist" → tokens regenerate. "Swap primary to #ff4800" → updated. Then ask Cursor to apply the new tokens to your repo.
|
|
53
|
+
|
|
54
|
+
4/ $0. MIT. https://designlang.app
|
|
55
|
+
```
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Figma Community — designlang plugin listing
|
|
2
|
+
|
|
3
|
+
## Title
|
|
4
|
+
|
|
5
|
+
designlang — Import any website's design system
|
|
6
|
+
|
|
7
|
+
## Tagline (max 100 chars)
|
|
8
|
+
|
|
9
|
+
Paste a URL, get a Figma Variable collection — colors, typography, spacing — pulled live from any site.
|
|
10
|
+
|
|
11
|
+
## Description (max 1000 chars)
|
|
12
|
+
|
|
13
|
+
designlang reverse-engineers any website into a complete design system and imports it directly into Figma as a Variable collection.
|
|
14
|
+
|
|
15
|
+
Paste a URL or paste an exported `*-figma-variables.json` from the [designlang CLI](https://www.npmjs.com/package/designlang). The plugin creates a fresh VariableCollection (or updates an existing one), maps every primitive, semantic, and composite token, and supports multi-mode payloads (light/dark) automatically.
|
|
16
|
+
|
|
17
|
+
What you get:
|
|
18
|
+
• Colors with proper roles (primary, secondary, accent, background, foreground, neutrals)
|
|
19
|
+
• Typography variables (sans, mono, body size, heading scale)
|
|
20
|
+
• Spacing scale (with detected base unit)
|
|
21
|
+
• Radii (xs/sm/md/lg/xl/full)
|
|
22
|
+
• Shadows (kept as raw strings — Figma doesn't natively type box-shadow)
|
|
23
|
+
|
|
24
|
+
Free, MIT-licensed, no account. The whole system also runs as a CLI: `npx designlang <url>`.
|
|
25
|
+
|
|
26
|
+
## Tags (pick up to 12)
|
|
27
|
+
|
|
28
|
+
design-system, design-tokens, design-extractor, dtcg, figma-variables, color-palette, typography, tokens, brand, design-system-import, w3c, ai-coding-agents
|
|
29
|
+
|
|
30
|
+
## Cover-image brief
|
|
31
|
+
|
|
32
|
+
Layout: paper background (#f3f1ea), single orange accent (#ff4800).
|
|
33
|
+
Hero: "designlang" in Fraunces, 96px, with the `d` mark to the left.
|
|
34
|
+
Below: a 5-swatch palette strip showing primary / secondary / accent / fg / bg.
|
|
35
|
+
Right side: a tiny mock Figma Variable panel showing 6 imported variables.
|
|
36
|
+
|
|
37
|
+
## Screenshots (4)
|
|
38
|
+
|
|
39
|
+
1. Plugin UI on plugin run — "Paste URL or upload .json" state.
|
|
40
|
+
2. Mid-import — token list streaming in.
|
|
41
|
+
3. Figma side panel showing the imported VariableCollection with 24 colors expanded.
|
|
42
|
+
4. After import — a frame using the imported variables (button + card mock).
|
|
43
|
+
|
|
44
|
+
## Support email
|
|
45
|
+
|
|
46
|
+
[your email]
|
|
47
|
+
|
|
48
|
+
## Source code link
|
|
49
|
+
|
|
50
|
+
https://github.com/Manavarya09/design-extract/tree/main/figma-plugin
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# Raycast Store — designlang extension
|
|
2
|
+
|
|
3
|
+
## Title
|
|
4
|
+
|
|
5
|
+
designlang — Extract a website's design system
|
|
6
|
+
|
|
7
|
+
## Subtitle
|
|
8
|
+
|
|
9
|
+
Paste any URL, get DTCG tokens / DESIGN.md / Tailwind config in one keystroke.
|
|
10
|
+
|
|
11
|
+
## Description
|
|
12
|
+
|
|
13
|
+
Designlang reverse-engineers any website into a complete design system, all from inside Raycast.
|
|
14
|
+
|
|
15
|
+
Triggers a Playwright extraction (or pulls from cache), then opens the result in your default browser as a shareable permalink at `designlang.app/x/<hash>`. Paste the URL, hit ⌘ Enter, see the design system within 5 seconds.
|
|
16
|
+
|
|
17
|
+
What you get every time:
|
|
18
|
+
• A shareable permalink (works on every device, no account)
|
|
19
|
+
• A single-file DESIGN.md for AI coding agents
|
|
20
|
+
• 12 file formats: DTCG tokens, Tailwind config, CSS variables, Figma variables, shadcn theme, React/Vue/Svelte themes, iOS SwiftUI, Android Compose, Flutter, WordPress block theme
|
|
21
|
+
|
|
22
|
+
Free, MIT, no signup.
|
|
23
|
+
|
|
24
|
+
## Categories
|
|
25
|
+
|
|
26
|
+
Developer Tools, Productivity
|
|
27
|
+
|
|
28
|
+
## Keywords
|
|
29
|
+
|
|
30
|
+
design, tokens, design-system, css, tailwind, figma, dtcg, ai
|
|
31
|
+
|
|
32
|
+
## Author
|
|
33
|
+
|
|
34
|
+
Manav Arya Singh (@manavarya09)
|
|
35
|
+
|
|
36
|
+
## Submission
|
|
37
|
+
|
|
38
|
+
PR to https://github.com/raycast/extensions with the contents of `raycast-extension/`.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# VS Code Marketplace — designlang listing
|
|
2
|
+
|
|
3
|
+
## Display name
|
|
4
|
+
|
|
5
|
+
designlang — Extract any website's design system
|
|
6
|
+
|
|
7
|
+
## Description (short, max 200 chars)
|
|
8
|
+
|
|
9
|
+
CLI + MCP + VS Code commands that reverse-engineer any URL into DTCG tokens, Tailwind, CSS vars, Figma variables, and an agent-native DESIGN.md. $0, MIT, no account.
|
|
10
|
+
|
|
11
|
+
## README (long description)
|
|
12
|
+
|
|
13
|
+
The README at the root of `vscode-extension/` is what the marketplace renders. Make sure it covers:
|
|
14
|
+
|
|
15
|
+
1. **What it does** — paste a URL, extract a design system, import into the active workspace.
|
|
16
|
+
2. **Commands** —
|
|
17
|
+
- `Designlang: Extract from URL` (Cmd+Shift+P)
|
|
18
|
+
- `Designlang: Apply to Workspace` (writes Tailwind + CSS vars next to the user's existing files)
|
|
19
|
+
- `Designlang: Open DESIGN.md`
|
|
20
|
+
- `Designlang: Compare with Production` (drift bot inside the editor)
|
|
21
|
+
3. **MCP integration** — point at the same `npx designlang mcp` server, gets the live extraction.
|
|
22
|
+
4. **Screenshots** — the streaming token paint inside the VS Code panel.
|
|
23
|
+
|
|
24
|
+
## Categories
|
|
25
|
+
|
|
26
|
+
Programming Languages, Other, Visualization, AI
|
|
27
|
+
|
|
28
|
+
## Tags
|
|
29
|
+
|
|
30
|
+
design-system, design-tokens, dtcg, w3c, tailwind, figma, css, ai, mcp, claude, cursor
|
|
31
|
+
|
|
32
|
+
## Pricing
|
|
33
|
+
|
|
34
|
+
Free.
|
|
35
|
+
|
|
36
|
+
## License
|
|
37
|
+
|
|
38
|
+
MIT — link to the LICENSE file at the repo root.
|
|
39
|
+
|
|
40
|
+
## Repo
|
|
41
|
+
|
|
42
|
+
https://github.com/Manavarya09/design-extract
|
|
43
|
+
|
|
44
|
+
## Publisher
|
|
45
|
+
|
|
46
|
+
`designlang` (matches package.json `name`)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "designlang",
|
|
3
|
-
"version": "11.
|
|
3
|
+
"version": "11.3.0",
|
|
4
4
|
"description": "Extract the complete design language from any website and ship it — clone to a working Next.js starter, guard tokens with a CI drift bot, or browse everything in a local studio. Outputs W3C DTCG tokens, motion tokens, typed anatomy stubs, Tailwind config, and ready-to-paste v0 / Lovable / Cursor / Claude-Artifacts prompts.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/chat.js
ADDED
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
// designlang chat — REPL over a live extraction.
|
|
2
|
+
//
|
|
3
|
+
// Heuristic-only in v12.0: the operations below cover the cases real users
|
|
4
|
+
// reach for first. LLM fallback ships in v12.1 (--smart). The router parses
|
|
5
|
+
// natural-ish English ("sharpen radii", "make it brutalist", "swap primary
|
|
6
|
+
// to #ff4800") into structured operations on the design object, re-derives
|
|
7
|
+
// tokens, and prints a tight diff.
|
|
8
|
+
|
|
9
|
+
import { createInterface } from 'readline';
|
|
10
|
+
import { stdin as input, stdout as output } from 'process';
|
|
11
|
+
import { writeFileSync, mkdirSync, readFileSync, existsSync } from 'fs';
|
|
12
|
+
import { join, resolve } from 'path';
|
|
13
|
+
import chalk from 'chalk';
|
|
14
|
+
import { extractDesignLanguage } from './index.js';
|
|
15
|
+
import { formatDtcgTokens } from './formatters/dtcg-tokens.js';
|
|
16
|
+
import { formatDesignMd } from './formatters/design-md.js';
|
|
17
|
+
import { formatTailwind } from './formatters/tailwind.js';
|
|
18
|
+
import { formatCssVars } from './formatters/css-vars.js';
|
|
19
|
+
import { nameFromUrl } from './utils.js';
|
|
20
|
+
|
|
21
|
+
function isHex(s) {
|
|
22
|
+
return typeof s === 'string' && /^#[0-9a-f]{3,8}$/i.test(s.trim());
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function hexToRgb(hex) {
|
|
26
|
+
const m = String(hex).trim().toLowerCase().replace(/^#/, '');
|
|
27
|
+
const full = m.length === 3 ? m.split('').map((c) => c + c).join('') : m.slice(0, 6);
|
|
28
|
+
return {
|
|
29
|
+
r: parseInt(full.slice(0, 2), 16) || 0,
|
|
30
|
+
g: parseInt(full.slice(2, 4), 16) || 0,
|
|
31
|
+
b: parseInt(full.slice(4, 6), 16) || 0,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function rgbToHex({ r, g, b }) {
|
|
36
|
+
return '#' + [r, g, b].map((v) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0')).join('');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function opSharpenRadii(design, factor = 0.5) {
|
|
40
|
+
const radii = design.borders?.radii || [];
|
|
41
|
+
const next = radii.map((r) => ({ ...r, value: Math.max(0, Math.round((r.value || 0) * factor)) }));
|
|
42
|
+
const changes = next.map((r, i) => `${r.label || 'r' + i}: ${radii[i].value}px → ${r.value}px`);
|
|
43
|
+
return {
|
|
44
|
+
design: { ...design, borders: { ...(design.borders || {}), radii: next } },
|
|
45
|
+
changes: ['radii sharpened', ...changes],
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function opSoftenRadii(design, factor = 2) {
|
|
50
|
+
const radii = design.borders?.radii || [];
|
|
51
|
+
const next = radii.map((r) => ({ ...r, value: Math.min(64, Math.round((r.value || 0) * factor) || 4) }));
|
|
52
|
+
const changes = next.map((r, i) => `${r.label || 'r' + i}: ${radii[i].value}px → ${r.value}px`);
|
|
53
|
+
return {
|
|
54
|
+
design: { ...design, borders: { ...(design.borders || {}), radii: next } },
|
|
55
|
+
changes: ['radii softened', ...changes],
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function opDarkMode(design) {
|
|
60
|
+
const colors = design.colors || {};
|
|
61
|
+
const bgs = colors.backgrounds || ['#ffffff'];
|
|
62
|
+
const txt = colors.text || ['#171717'];
|
|
63
|
+
const swapped = { ...colors, backgrounds: txt.slice(), text: bgs.slice() };
|
|
64
|
+
return {
|
|
65
|
+
design: { ...design, colors: swapped },
|
|
66
|
+
changes: [
|
|
67
|
+
`background: ${bgs[0]} → ${txt[0]}`,
|
|
68
|
+
`foreground: ${txt[0]} → ${bgs[0]}`,
|
|
69
|
+
],
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function opMakeBrutalist(design) {
|
|
74
|
+
const radii = (design.borders?.radii || []).map((r) => ({ ...r, value: 0 }));
|
|
75
|
+
const shadows = (design.shadows?.values || []).map((s) => ({
|
|
76
|
+
...s,
|
|
77
|
+
raw: '4px 4px 0 0 currentColor',
|
|
78
|
+
value: '4px 4px 0 0 currentColor',
|
|
79
|
+
}));
|
|
80
|
+
const families = (design.typography?.families || []).slice();
|
|
81
|
+
const monoFam = families.find((f) => /mono|consol|courier|jet|sf-mono|geist mono/i.test(f.name)) || { name: 'JetBrains Mono', count: 1, weights: [400] };
|
|
82
|
+
return {
|
|
83
|
+
design: {
|
|
84
|
+
...design,
|
|
85
|
+
borders: { ...(design.borders || {}), radii },
|
|
86
|
+
shadows: { ...(design.shadows || {}), values: shadows },
|
|
87
|
+
typography: {
|
|
88
|
+
...(design.typography || {}),
|
|
89
|
+
families: [monoFam, ...families.filter((f) => f !== monoFam)].slice(0, 3),
|
|
90
|
+
},
|
|
91
|
+
materialLanguage: { ...(design.materialLanguage || {}), label: 'brutalist', confidence: 1.0 },
|
|
92
|
+
},
|
|
93
|
+
changes: [
|
|
94
|
+
'radii → 0 (sharp corners)',
|
|
95
|
+
'shadows → hard offset (4px 4px 0 0)',
|
|
96
|
+
'primary font → mono',
|
|
97
|
+
'material → brutalist',
|
|
98
|
+
],
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function opMakeGlass(design) {
|
|
103
|
+
const radii = (design.borders?.radii || []).map((r) => ({
|
|
104
|
+
...r,
|
|
105
|
+
value: Math.max(r.value || 8, 16),
|
|
106
|
+
}));
|
|
107
|
+
const shadows = (design.shadows?.values || []).map((s, i) => ({
|
|
108
|
+
...s,
|
|
109
|
+
raw: `0 ${8 + i * 4}px ${24 + i * 8}px rgba(0,0,0,0.08)`,
|
|
110
|
+
value: `0 ${8 + i * 4}px ${24 + i * 8}px rgba(0,0,0,0.08)`,
|
|
111
|
+
}));
|
|
112
|
+
return {
|
|
113
|
+
design: {
|
|
114
|
+
...design,
|
|
115
|
+
borders: { ...(design.borders || {}), radii },
|
|
116
|
+
shadows: { ...(design.shadows || {}), values: shadows },
|
|
117
|
+
materialLanguage: { ...(design.materialLanguage || {}), label: 'glass', confidence: 1.0 },
|
|
118
|
+
},
|
|
119
|
+
changes: [
|
|
120
|
+
'radii ≥ 16px (rounded)',
|
|
121
|
+
'shadows → soft, depth-stacked',
|
|
122
|
+
'material → glass',
|
|
123
|
+
],
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function opSwapColor(design, role, hex) {
|
|
128
|
+
if (!isHex(hex)) return { design, changes: [`error: ${hex} is not a hex color`] };
|
|
129
|
+
const colors = { ...(design.colors || {}) };
|
|
130
|
+
const before = colors[role]?.hex;
|
|
131
|
+
if (!before) {
|
|
132
|
+
return { design, changes: [`error: no ${role} color in this extraction (try primary, secondary, accent)`] };
|
|
133
|
+
}
|
|
134
|
+
const next = { ...colors[role], hex };
|
|
135
|
+
return {
|
|
136
|
+
design: { ...design, colors: { ...colors, [role]: next } },
|
|
137
|
+
changes: [`${role}: ${before} → ${hex}`],
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function opSwapFont(design, name) {
|
|
142
|
+
const families = (design.typography?.families || []).slice();
|
|
143
|
+
const before = families[0]?.name || '—';
|
|
144
|
+
const replaced = [{ name, count: families[0]?.count || 0, weights: families[0]?.weights || [400, 600] }, ...families.slice(1)];
|
|
145
|
+
return {
|
|
146
|
+
design: { ...design, typography: { ...(design.typography || {}), families: replaced } },
|
|
147
|
+
changes: [`primary font: ${before} → ${name}`],
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function opReset(_design, original) {
|
|
152
|
+
return { design: structuredClone(original), changes: ['reset to original extraction'] };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function parseCommand(line) {
|
|
156
|
+
const s = String(line).trim().toLowerCase();
|
|
157
|
+
if (!s) return null;
|
|
158
|
+
|
|
159
|
+
if (s === 'help' || s === '?') return { kind: 'help' };
|
|
160
|
+
if (s === 'quit' || s === 'exit' || s === ':q') return { kind: 'quit' };
|
|
161
|
+
if (s === 'reset' || s === 'undo all') return { kind: 'reset' };
|
|
162
|
+
if (s === 'save' || s === 'export' || s === 'write') return { kind: 'save' };
|
|
163
|
+
if (s === 'show' || s === 'print' || s === 'state') return { kind: 'state' };
|
|
164
|
+
if (s.startsWith('show ') || s.startsWith('print ')) {
|
|
165
|
+
return { kind: 'show', what: s.split(/\s+/)[1] };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (/(make it |make this |go )?brutalist/.test(s)) return { kind: 'op', op: 'brutalist' };
|
|
169
|
+
if (/(make it |make this |go )?glass(morph)?/.test(s)) return { kind: 'op', op: 'glass' };
|
|
170
|
+
if (/(dark mode|dark theme|invert|go dark)/.test(s)) return { kind: 'op', op: 'dark' };
|
|
171
|
+
if (/sharp(en)?( radii| corners)?/.test(s)) return { kind: 'op', op: 'sharpen' };
|
|
172
|
+
if (/(soft|round)(en)?( radii| corners)?/.test(s)) return { kind: 'op', op: 'soften' };
|
|
173
|
+
|
|
174
|
+
const colorRe = /(primary|secondary|accent)\s*(?:to|=|:)?\s*(#[0-9a-f]{3,8})/i;
|
|
175
|
+
const cm = colorRe.exec(line);
|
|
176
|
+
if (cm) return { kind: 'op', op: 'swap-color', role: cm[1].toLowerCase(), hex: cm[2] };
|
|
177
|
+
|
|
178
|
+
const fontRe = /(?:font|typeface)\s*(?:to|=|:)?\s*([A-Za-z][\w\s-]{1,40})/i;
|
|
179
|
+
const fm = fontRe.exec(line);
|
|
180
|
+
if (fm) return { kind: 'op', op: 'swap-font', name: fm[1].trim() };
|
|
181
|
+
|
|
182
|
+
return { kind: 'unknown', input: line };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function printHelp() {
|
|
186
|
+
console.log('');
|
|
187
|
+
console.log(chalk.bold(' Commands:'));
|
|
188
|
+
const rows = [
|
|
189
|
+
['sharpen / soften', 'halve / double every radius'],
|
|
190
|
+
['dark mode', 'swap background ↔ foreground'],
|
|
191
|
+
['brutalist', 'radii → 0, hard shadows, mono font'],
|
|
192
|
+
['glass', 'rounded radii, soft layered shadows'],
|
|
193
|
+
['primary #ff4800', 'swap a role color (primary | secondary | accent)'],
|
|
194
|
+
['font Inter', 'swap the primary font family'],
|
|
195
|
+
['show / state', 'print current palette + tokens'],
|
|
196
|
+
['reset', 'restore the original extraction'],
|
|
197
|
+
['save', 'write DTCG, Tailwind, CSS vars, DESIGN.md to ./chat-output'],
|
|
198
|
+
['quit', 'exit'],
|
|
199
|
+
];
|
|
200
|
+
for (const [cmd, desc] of rows) {
|
|
201
|
+
console.log(' ' + chalk.cyan(cmd.padEnd(28)) + chalk.gray(desc));
|
|
202
|
+
}
|
|
203
|
+
console.log('');
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function printState(design) {
|
|
207
|
+
const c = design.colors || {};
|
|
208
|
+
const t = design.typography || {};
|
|
209
|
+
const r = design.borders?.radii || [];
|
|
210
|
+
console.log('');
|
|
211
|
+
console.log(chalk.bold(' Current state'));
|
|
212
|
+
console.log(' ' + chalk.gray('palette:'.padEnd(14)) + [c.primary?.hex, c.secondary?.hex, c.accent?.hex, c.backgrounds?.[0], c.text?.[0]].filter(Boolean).join(' · '));
|
|
213
|
+
console.log(' ' + chalk.gray('font:'.padEnd(14)) + (t.families?.[0]?.name || '—'));
|
|
214
|
+
console.log(' ' + chalk.gray('radii:'.padEnd(14)) + (r.map((x) => `${x.label || '?'}=${x.value}`).join(' · ') || '—'));
|
|
215
|
+
console.log(' ' + chalk.gray('material:'.padEnd(14)) + (design.materialLanguage?.label || 'flat'));
|
|
216
|
+
console.log('');
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function applyOp(parsed, current) {
|
|
220
|
+
switch (parsed.op) {
|
|
221
|
+
case 'sharpen': return opSharpenRadii(current);
|
|
222
|
+
case 'soften': return opSoftenRadii(current);
|
|
223
|
+
case 'dark': return opDarkMode(current);
|
|
224
|
+
case 'brutalist': return opMakeBrutalist(current);
|
|
225
|
+
case 'glass': return opMakeGlass(current);
|
|
226
|
+
case 'swap-color': return opSwapColor(current, parsed.role, parsed.hex);
|
|
227
|
+
case 'swap-font': return opSwapFont(current, parsed.name);
|
|
228
|
+
default: return { design: current, changes: ['no-op'] };
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function saveDesign(design, outDir) {
|
|
233
|
+
mkdirSync(outDir, { recursive: true });
|
|
234
|
+
const url = design.meta?.url || 'extraction';
|
|
235
|
+
const prefix = nameFromUrl(url);
|
|
236
|
+
const dtcg = formatDtcgTokens(design);
|
|
237
|
+
const written = [];
|
|
238
|
+
const write = (name, content) => {
|
|
239
|
+
const p = join(outDir, name);
|
|
240
|
+
writeFileSync(p, content, 'utf-8');
|
|
241
|
+
written.push(p);
|
|
242
|
+
};
|
|
243
|
+
write(`${prefix}-design-tokens.json`, JSON.stringify(dtcg, null, 2));
|
|
244
|
+
write(`${prefix}-tailwind.config.js`, formatTailwind(design));
|
|
245
|
+
write(`${prefix}-variables.css`, formatCssVars(design));
|
|
246
|
+
write(`${prefix}-DESIGN.md`, formatDesignMd(design));
|
|
247
|
+
return written;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function synthesizeDesignFromTokens(tokens, sourcePath) {
|
|
251
|
+
const findHex = (...paths) => {
|
|
252
|
+
for (const p of paths) {
|
|
253
|
+
const parts = p.split('.');
|
|
254
|
+
let v = tokens;
|
|
255
|
+
for (const k of parts) {
|
|
256
|
+
v = v?.[k];
|
|
257
|
+
if (!v) break;
|
|
258
|
+
}
|
|
259
|
+
if (v && typeof v.$value === 'string') return v.$value;
|
|
260
|
+
}
|
|
261
|
+
return null;
|
|
262
|
+
};
|
|
263
|
+
const primary = findHex('color.primary', 'primitive.color.brand.primary', 'primitive.color.primary');
|
|
264
|
+
const secondary = findHex('color.secondary');
|
|
265
|
+
const accent = findHex('color.accent', 'primitive.color.brand.accent');
|
|
266
|
+
const bg = findHex('color.background', 'primitive.color.background.bg0', 'primitive.color.neutral.n100');
|
|
267
|
+
const fg = findHex('color.foreground', 'primitive.color.text.text0', 'primitive.color.foreground');
|
|
268
|
+
return {
|
|
269
|
+
meta: { url: `file://${sourcePath}`, title: 'imported tokens' },
|
|
270
|
+
colors: {
|
|
271
|
+
primary: primary ? { hex: primary, count: 1 } : null,
|
|
272
|
+
secondary: secondary ? { hex: secondary, count: 1 } : null,
|
|
273
|
+
accent: accent ? { hex: accent, count: 1 } : null,
|
|
274
|
+
backgrounds: bg ? [bg] : ['#ffffff'],
|
|
275
|
+
text: fg ? [fg] : ['#171717'],
|
|
276
|
+
neutrals: [],
|
|
277
|
+
all: [],
|
|
278
|
+
},
|
|
279
|
+
typography: { families: [{ name: 'system-ui', count: 1, weights: [400, 600] }], headings: [], body: { size: 16 } },
|
|
280
|
+
spacing: { base: 4, scale: [4, 8, 12, 16, 24, 32, 48, 64] },
|
|
281
|
+
shadows: { values: [{ label: 'md', raw: '0 4px 6px rgba(0,0,0,0.1)', value: '0 4px 6px rgba(0,0,0,0.1)' }] },
|
|
282
|
+
borders: { radii: [{ label: 'md', value: 8 }] },
|
|
283
|
+
breakpoints: [],
|
|
284
|
+
components: {},
|
|
285
|
+
variables: {},
|
|
286
|
+
materialLanguage: { label: 'flat', confidence: 0.5 },
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export async function runChat(target, opts = {}) {
|
|
291
|
+
const outDir = resolve(opts.out || './chat-output');
|
|
292
|
+
|
|
293
|
+
let design;
|
|
294
|
+
if (target && /\.json$/.test(target) && existsSync(target)) {
|
|
295
|
+
console.log(chalk.gray(` Loading tokens from ${target}…`));
|
|
296
|
+
const tokens = JSON.parse(readFileSync(target, 'utf-8'));
|
|
297
|
+
design = synthesizeDesignFromTokens(tokens, target);
|
|
298
|
+
} else {
|
|
299
|
+
let url = String(target);
|
|
300
|
+
if (!url.startsWith('http')) url = `https://${url}`;
|
|
301
|
+
console.log(chalk.gray(` Extracting ${url}… (this takes a few seconds)`));
|
|
302
|
+
design = await extractDesignLanguage(url);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const original = structuredClone(design);
|
|
306
|
+
|
|
307
|
+
console.log('');
|
|
308
|
+
console.log(chalk.bold(' designlang chat'));
|
|
309
|
+
console.log(chalk.gray(' type "help" for commands · Ctrl+D to quit'));
|
|
310
|
+
printState(design);
|
|
311
|
+
|
|
312
|
+
const rl = createInterface({ input, output, prompt: chalk.gray('> ') });
|
|
313
|
+
rl.prompt();
|
|
314
|
+
|
|
315
|
+
for await (const line of rl) {
|
|
316
|
+
const parsed = parseCommand(line);
|
|
317
|
+
if (!parsed) { rl.prompt(); continue; }
|
|
318
|
+
|
|
319
|
+
if (parsed.kind === 'help') { printHelp(); rl.prompt(); continue; }
|
|
320
|
+
if (parsed.kind === 'quit') { rl.close(); break; }
|
|
321
|
+
if (parsed.kind === 'state' || parsed.kind === 'show') { printState(design); rl.prompt(); continue; }
|
|
322
|
+
if (parsed.kind === 'reset') {
|
|
323
|
+
const r = opReset(design, original);
|
|
324
|
+
design = r.design;
|
|
325
|
+
r.changes.forEach((c) => console.log(' ' + chalk.gray('•') + ' ' + c));
|
|
326
|
+
printState(design);
|
|
327
|
+
rl.prompt();
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
if (parsed.kind === 'save') {
|
|
331
|
+
const files = saveDesign(design, outDir);
|
|
332
|
+
console.log('');
|
|
333
|
+
for (const f of files) console.log(' ' + chalk.green('✓') + ' ' + f);
|
|
334
|
+
console.log('');
|
|
335
|
+
rl.prompt();
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
if (parsed.kind === 'unknown') {
|
|
339
|
+
console.log(chalk.yellow(` Didn't catch that. Try "help" for commands.`));
|
|
340
|
+
rl.prompt();
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (parsed.kind === 'op') {
|
|
345
|
+
const r = applyOp(parsed, design);
|
|
346
|
+
design = r.design;
|
|
347
|
+
console.log('');
|
|
348
|
+
r.changes.forEach((c) => console.log(' ' + chalk.green('•') + ' ' + c));
|
|
349
|
+
console.log('');
|
|
350
|
+
rl.prompt();
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
console.log('');
|
|
355
|
+
console.log(chalk.gray(' bye'));
|
|
356
|
+
}
|
package/src/clone.js
CHANGED
|
@@ -21,7 +21,11 @@ function dedupeConsecutive(order) {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
function sanitize(str, fallback = '') {
|
|
24
|
-
|
|
24
|
+
// Escape backslash FIRST so the subsequent escapes don't get re-escaped.
|
|
25
|
+
return String(str ?? fallback)
|
|
26
|
+
.replace(/\\/g, '\\\\')
|
|
27
|
+
.replace(/`/g, '\\`')
|
|
28
|
+
.replace(/\$\{/g, '\\${');
|
|
25
29
|
}
|
|
26
30
|
|
|
27
31
|
function titleFromUrl(url = '') {
|
|
@@ -352,7 +356,7 @@ button { font-family: inherit; }
|
|
|
352
356
|
|
|
353
357
|
// layout.js
|
|
354
358
|
writeFileSync(join(projectDir, 'src/app/layout.js'), `export const metadata = {
|
|
355
|
-
title: '${(design.meta.title || 'Cloned Design').replace(/'/g, "\\'")} · cloned',
|
|
359
|
+
title: '${(design.meta.title || 'Cloned Design').replace(/\\/g, '\\\\').replace(/'/g, "\\'")} · cloned',
|
|
356
360
|
description: 'Design cloned from ${url} with designlang.',
|
|
357
361
|
};
|
|
358
362
|
|
package/src/crawler.js
CHANGED
|
@@ -26,6 +26,7 @@ export async function crawlPage(url, options = {}) {
|
|
|
26
26
|
deepInteract = false,
|
|
27
27
|
selector,
|
|
28
28
|
channel,
|
|
29
|
+
wsEndpoint, // Remote browser (e.g. Browserless). When set, skips local launch.
|
|
29
30
|
} = options;
|
|
30
31
|
|
|
31
32
|
const launchArgs = [
|
|
@@ -38,14 +39,23 @@ export async function crawlPage(url, options = {}) {
|
|
|
38
39
|
launchArgs.push('--ignore-certificate-errors', '--ignore-ssl-errors');
|
|
39
40
|
}
|
|
40
41
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
42
|
+
// Prefer remote browser when wsEndpoint is provided (Browserless v2 / any
|
|
43
|
+
// Playwright-protocol WSS). Skips the @sparticuz/chromium 150MB cold-start
|
|
44
|
+
// tax on Vercel Functions entirely.
|
|
45
|
+
const usingRemote = !!wsEndpoint;
|
|
46
|
+
// Browserless v2 speaks CDP at the root endpoint — connectOverCDP works
|
|
47
|
+
// across Browserless and any other CDP-compatible service. connect() would
|
|
48
|
+
// require Playwright's protocol on a path like /playwright/chromium.
|
|
49
|
+
const browser = usingRemote
|
|
50
|
+
? await chromium.connectOverCDP(wsEndpoint, { timeout: 30000 })
|
|
51
|
+
: await chromium.launch({
|
|
52
|
+
headless: true,
|
|
53
|
+
...(executablePath && { executablePath }),
|
|
54
|
+
// channel: 'chrome' forces Playwright to use the system Chrome install
|
|
55
|
+
// instead of the 150MB bundled Chromium — see --system-chrome.
|
|
56
|
+
...(channel && { channel }),
|
|
57
|
+
args: launchArgs,
|
|
58
|
+
});
|
|
49
59
|
try {
|
|
50
60
|
const context = await browser.newContext({
|
|
51
61
|
viewport: { width, height },
|
|
@@ -7,18 +7,29 @@
|
|
|
7
7
|
// Pure function — reads `rawData.light.computedStyles`, which every extractor
|
|
8
8
|
// already has access to, plus the `modernColors` and any collected svgs.
|
|
9
9
|
|
|
10
|
+
// All pattern detectors operate on a length-capped string. Adversarial CSS
|
|
11
|
+
// background-image values (data URIs in particular) can run several KB; cap
|
|
12
|
+
// to 4KB so the regexes can never run quadratic over megabyte payloads.
|
|
13
|
+
const MAX_BG_LEN = 4096;
|
|
14
|
+
function cap(s) {
|
|
15
|
+
return typeof s === 'string' ? s.slice(0, MAX_BG_LEN) : '';
|
|
16
|
+
}
|
|
17
|
+
|
|
10
18
|
function looksLikeDotGrid(image) {
|
|
11
|
-
|
|
19
|
+
const s = cap(image);
|
|
20
|
+
// Bounded inner content (.{0,256}) instead of unbounded .* — no nested quantifier risk.
|
|
21
|
+
return /radial-gradient\([^)]{0,256}\)/i.test(s) && /repeat/i.test(s) && /\d{1,4}px\s{0,4}\d{1,4}px/.test(s);
|
|
12
22
|
}
|
|
13
23
|
|
|
14
24
|
function looksLikeLineGrid(image) {
|
|
15
25
|
// repeating-linear-gradient with a narrow colored band.
|
|
16
|
-
return /repeating-linear-gradient/i.test(image);
|
|
26
|
+
return /repeating-linear-gradient/i.test(cap(image));
|
|
17
27
|
}
|
|
18
28
|
|
|
19
29
|
function looksLikeNoise(image) {
|
|
20
|
-
|
|
21
|
-
|
|
30
|
+
const s = cap(image);
|
|
31
|
+
// Bounded character class instead of .+ — `.+` could backtrack on long data URIs.
|
|
32
|
+
return /feTurbulence|data:image\/svg[^"']{0,2048}fractalNoise/i.test(s) || /noise\.(png|svg|webp)/i.test(s);
|
|
22
33
|
}
|
|
23
34
|
|
|
24
35
|
function countRadialGradients(image) {
|
|
@@ -51,11 +51,13 @@ function shadowComplexity(shadowValues) {
|
|
|
51
51
|
if (!shadowValues.length) return { profile: 'none', avgBlur: 0, maxBlur: 0, insetCount: 0, hardShadowCount: 0, hasPair: false };
|
|
52
52
|
let insetCount = 0, hardShadowCount = 0, totalBlur = 0, maxBlur = 0, pairCount = 0;
|
|
53
53
|
for (const v of shadowValues) {
|
|
54
|
-
|
|
54
|
+
// Cap to defang ReDoS on adversarial CSS shadow values. Real values are <500 chars.
|
|
55
|
+
const raw = (typeof v === 'string' ? v : (v.value || '')).slice(0, 2000);
|
|
55
56
|
if (/inset/i.test(raw)) insetCount++;
|
|
56
57
|
// Blur is the third length in `offset-x offset-y blur [spread] color`. The
|
|
57
58
|
// `px` unit is common but optional — `0 0` is a valid zero-blur shadow.
|
|
58
|
-
|
|
59
|
+
// Bounded digit counts prevent polynomial backtracking on long digit runs.
|
|
60
|
+
const blurs = [...raw.matchAll(/(-?\d{1,8}(?:\.\d{1,4})?)(?:px)?\s+(-?\d{1,8}(?:\.\d{1,4})?)(?:px)?\s+(\d{1,8}(?:\.\d{1,4})?)(?:px)?/g)];
|
|
59
61
|
for (const m of blurs) {
|
|
60
62
|
const blur = parseFloat(m[3]);
|
|
61
63
|
totalBlur += blur;
|
package/src/extractors/motion.js
CHANGED
|
@@ -92,18 +92,25 @@ export function extractMotion(computedStyles, keyframes = []) {
|
|
|
92
92
|
|
|
93
93
|
for (const el of computedStyles) {
|
|
94
94
|
let isAnimating = false;
|
|
95
|
-
|
|
96
|
-
|
|
95
|
+
// Cap inputs to defang any pathological CSS that could trigger
|
|
96
|
+
// polynomial-time regex backtracking. Real values are <200 chars.
|
|
97
|
+
const transition = (el.transition || '').slice(0, 2000);
|
|
98
|
+
if (transition && transition !== 'all 0s ease 0s' && transition !== 'none') {
|
|
99
|
+
transitions.add(transition);
|
|
97
100
|
isAnimating = true;
|
|
98
|
-
|
|
99
|
-
for (const m of
|
|
100
|
-
|
|
101
|
+
// Tightened: bounded \d{1,8} and bounded fractional part — no nested quantifiers.
|
|
102
|
+
for (const m of transition.matchAll(/(?<![(\d])(\d{1,8}(?:\.\d{1,4})?m?s)(?![)\w])/g)) durations.push(MS(m[1]));
|
|
103
|
+
// Tightened: limit cubic-bezier/steps inner content to 64 chars.
|
|
104
|
+
for (const m of transition.matchAll(/(ease|ease-in|ease-out|ease-in-out|linear|cubic-bezier\([^)]{1,64}\)|steps\([^)]{1,64}\))/g)) easingRaw.add(m[1]);
|
|
105
|
+
for (const part of transition.split(',')) {
|
|
101
106
|
const prop = part.trim().split(/\s+/)[0];
|
|
102
107
|
if (prop && prop !== 'all') transitionedProps[prop] = (transitionedProps[prop] || 0) + 1;
|
|
103
108
|
}
|
|
104
109
|
}
|
|
105
|
-
|
|
106
|
-
|
|
110
|
+
const animation = (el.animation || '').slice(0, 2000);
|
|
111
|
+
if (animation && animation !== 'none 0s ease 0s 1 normal none running' && animation !== 'none') {
|
|
112
|
+
// Tightened: bound the identifier length so backtracking is linear.
|
|
113
|
+
const nameMatch = animation.match(/([a-zA-Z_][\w-]{0,127})$/) || animation.match(/^([a-zA-Z_][\w-]{0,127})/);
|
|
107
114
|
if (nameMatch) {
|
|
108
115
|
const name = nameMatch[1];
|
|
109
116
|
if (name !== 'none' && name !== 'running' && name !== 'paused') {
|
|
@@ -733,7 +733,7 @@ export function formatMarkdown(design) {
|
|
|
733
733
|
lines.push('| # | Role | Heading | Confidence |');
|
|
734
734
|
lines.push('|---|------|---------|------------|');
|
|
735
735
|
for (const s of design.sectionRoles.sections.slice(0, 20)) {
|
|
736
|
-
const h = (s.heading || '').replace(/\|/g, '\\|').slice(0, 80);
|
|
736
|
+
const h = (s.heading || '').replace(/\\/g, '\\\\').replace(/\|/g, '\\|').slice(0, 80);
|
|
737
737
|
lines.push(`| ${s.index} | ${s.role}${s.subrole ? ` · ${s.subrole}` : ''} | ${h || '—'} | ${s.confidence} |`);
|
|
738
738
|
}
|
|
739
739
|
lines.push('');
|
|
@@ -139,7 +139,7 @@ export function formatCursorPrompt(design) {
|
|
|
139
139
|
` colors: [${b.colors.map(c => `'${c}'`).join(', ')}],`,
|
|
140
140
|
` fonts: [${b.fonts.map(f => `'${f}'`).join(', ')}],`,
|
|
141
141
|
` radii: [${(design.borders?.radii || []).slice(0, 6).map(r => `'${String(r.value ?? r)}'`).join(', ')}],`,
|
|
142
|
-
` shadows: [${(design.shadows?.values || []).slice(0, 3).map(s => `'${String(s.raw ?? s.value ?? s).replace(/'/g, "\\'")}'`).join(', ')}],`,
|
|
142
|
+
` shadows: [${(design.shadows?.values || []).slice(0, 3).map(s => `'${String(s.raw ?? s.value ?? s).replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`).join(', ')}],`,
|
|
143
143
|
'};',
|
|
144
144
|
'```',
|
|
145
145
|
'',
|
package/src/history.js
CHANGED
|
@@ -19,10 +19,9 @@ export function saveSnapshot(design) {
|
|
|
19
19
|
const hostname = new URL(design.meta.url).hostname.replace(/^www\./, '');
|
|
20
20
|
const file = historyFile(hostname);
|
|
21
21
|
|
|
22
|
+
// Read directly inside try/catch — no existsSync race.
|
|
22
23
|
let history = [];
|
|
23
|
-
|
|
24
|
-
try { history = JSON.parse(readFileSync(file, 'utf-8')); } catch { history = []; }
|
|
25
|
-
}
|
|
24
|
+
try { history = JSON.parse(readFileSync(file, 'utf-8')); } catch { history = []; }
|
|
26
25
|
|
|
27
26
|
// Compact snapshot — only store key metrics, not full data
|
|
28
27
|
const snapshot = {
|
|
@@ -65,7 +64,6 @@ export function getHistory(url) {
|
|
|
65
64
|
ensureDir();
|
|
66
65
|
const hostname = new URL(url).hostname.replace(/^www\./, '');
|
|
67
66
|
const file = historyFile(hostname);
|
|
68
|
-
if (!existsSync(file)) return [];
|
|
69
67
|
try { return JSON.parse(readFileSync(file, 'utf-8')); } catch { return []; }
|
|
70
68
|
}
|
|
71
69
|
|
package/src/studio.js
CHANGED
|
@@ -296,15 +296,25 @@ export async function runStudio(opts) {
|
|
|
296
296
|
// Static passthrough — screenshots, preview.html, etc.
|
|
297
297
|
const safe = pathname.replace(/\.\./g, '').replace(/^\//, '');
|
|
298
298
|
const filePath = join(dir, safe);
|
|
299
|
-
|
|
299
|
+
// Path-traversal guard: must stay inside dir.
|
|
300
|
+
if (!filePath.startsWith(dir)) {
|
|
301
|
+
res.writeHead(404, { 'content-type': 'text/plain' });
|
|
302
|
+
res.end('not found');
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
// Race-safe read — single try/catch instead of exists→stat→read chain.
|
|
306
|
+
try {
|
|
307
|
+
if (!statSync(filePath).isFile()) throw new Error('not a file');
|
|
308
|
+
const body = readFileSync(filePath);
|
|
300
309
|
const ext = extname(filePath).toLowerCase();
|
|
301
310
|
res.writeHead(200, { 'content-type': MIME[ext] || 'application/octet-stream' });
|
|
302
|
-
res.end(
|
|
311
|
+
res.end(body);
|
|
312
|
+
return;
|
|
313
|
+
} catch {
|
|
314
|
+
res.writeHead(404, { 'content-type': 'text/plain' });
|
|
315
|
+
res.end('not found');
|
|
303
316
|
return;
|
|
304
317
|
}
|
|
305
|
-
|
|
306
|
-
res.writeHead(404, { 'content-type': 'text/plain' });
|
|
307
|
-
res.end('not found');
|
|
308
318
|
} catch (e) {
|
|
309
319
|
res.writeHead(500, { 'content-type': 'text/plain' });
|
|
310
320
|
res.end(`error: ${e.message}`);
|
package/src/sync.js
CHANGED
|
@@ -5,9 +5,19 @@ import { formatTokens } from './formatters/tokens.js';
|
|
|
5
5
|
import { formatTailwind } from './formatters/tailwind.js';
|
|
6
6
|
import { formatCssVars } from './formatters/css-vars.js';
|
|
7
7
|
import { saveSnapshot, getHistory } from './history.js';
|
|
8
|
-
import { writeFileSync, readFileSync,
|
|
8
|
+
import { writeFileSync, readFileSync, statSync } from 'fs';
|
|
9
9
|
import { join } from 'path';
|
|
10
10
|
|
|
11
|
+
// Race-safe "update only if file exists" — statSync inside try/catch
|
|
12
|
+
// closes the toctou window vs. existsSync→writeFileSync.
|
|
13
|
+
function updateIfExists(path, content) {
|
|
14
|
+
try {
|
|
15
|
+
if (!statSync(path).isFile()) return false;
|
|
16
|
+
writeFileSync(path, content, 'utf-8');
|
|
17
|
+
return true;
|
|
18
|
+
} catch { return false; }
|
|
19
|
+
}
|
|
20
|
+
|
|
11
21
|
export async function syncDesign(url, options = {}) {
|
|
12
22
|
const { out = '.', interval = 3600000 } = options; // default 1 hour
|
|
13
23
|
|
|
@@ -42,23 +52,9 @@ export async function syncDesign(url, options = {}) {
|
|
|
42
52
|
// Update local files
|
|
43
53
|
const updates = [];
|
|
44
54
|
|
|
45
|
-
|
|
46
|
-
if (
|
|
47
|
-
|
|
48
|
-
updates.push('design-tokens.json');
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
const tailwindPath = join(out, 'tailwind.config.js');
|
|
52
|
-
if (existsSync(tailwindPath)) {
|
|
53
|
-
writeFileSync(tailwindPath, formatTailwind(current), 'utf-8');
|
|
54
|
-
updates.push('tailwind.config.js');
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
const cssPath = join(out, 'variables.css');
|
|
58
|
-
if (existsSync(cssPath)) {
|
|
59
|
-
writeFileSync(cssPath, formatCssVars(current), 'utf-8');
|
|
60
|
-
updates.push('variables.css');
|
|
61
|
-
}
|
|
55
|
+
if (updateIfExists(join(out, 'design-tokens.json'), formatTokens(current))) updates.push('design-tokens.json');
|
|
56
|
+
if (updateIfExists(join(out, 'tailwind.config.js'), formatTailwind(current))) updates.push('tailwind.config.js');
|
|
57
|
+
if (updateIfExists(join(out, 'variables.css'), formatCssVars(current))) updates.push('variables.css');
|
|
62
58
|
|
|
63
59
|
return {
|
|
64
60
|
changes,
|