truecanvas 0.1.0 → 0.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/README.md +54 -181
- package/dist/catalog-worker.js +1 -1
- package/dist/chunk-C4SATRZT.js +196 -0
- package/dist/chunk-PBKX3SBC.js +97 -0
- package/dist/chunk-TSHD2S36.js +28 -0
- package/dist/{chunk-753N4QRO.js → chunk-WU7NBTJH.js} +39 -20
- package/dist/{chunk-WB35FXGP.js → chunk-XWQ5MS24.js} +1071 -489
- package/dist/cli.js +676 -429
- package/dist/core/index.d.ts +179 -11
- package/dist/core/index.js +40 -25
- package/dist/editor/assets/Menu-BFifVcgz.js +9 -0
- package/dist/editor/assets/Menu-DXPm0EXo.css +1 -0
- package/dist/editor/assets/hub-UhU8yp5F.js +2 -0
- package/dist/editor/assets/main-UtSaCfnK.js +26 -0
- package/dist/editor/hub.html +3 -3
- package/dist/editor/index.html +3 -3
- package/dist/icons-worker.js +7 -0
- package/dist/next/index.d.ts +6 -1
- package/dist/next/index.js +16 -3
- package/dist/runtime/host.js +3 -1
- package/dist/vite/index.d.ts +33 -0
- package/dist/vite/index.js +81 -0
- package/package.json +15 -6
- package/dist/editor/assets/controls-BQYSgX53.css +0 -1
- package/dist/editor/assets/controls-BYFjuGCq.js +0 -9
- package/dist/editor/assets/hub-C4_700Hv.js +0 -2
- package/dist/editor/assets/main-CfAJbDf1.js +0 -25
package/README.md
CHANGED
|
@@ -4,197 +4,98 @@
|
|
|
4
4
|
|
|
5
5
|
**A Figma-like canvas for your real React components. The code is the document.**
|
|
6
6
|
|
|
7
|
-
[truecanvas
|
|
7
|
+
[](https://www.npmjs.com/package/truecanvas)
|
|
8
|
+
[](https://github.com/Nathandona/truecanvas/actions/workflows/ci.yml)
|
|
9
|
+
[](https://github.com/Nathandona/truecanvas/blob/main/LICENSE)
|
|
10
|
+
|
|
11
|
+
Truecanvas renders your actual `.tsx` components on an infinite canvas and writes every change back to plain TSX in your repo. It has no built-in AI: it is agent-first through **MCP**, so Claude Code, Cursor, Codex or any MCP client can read your design system, edit frames and take screenshots while you watch and steer.
|
|
8
12
|
|
|
9
|
-
|
|
13
|
+
[truecanvas.dev](https://truecanvas.dev)
|
|
10
14
|
|
|
11
15
|

|
|
12
16
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
- **
|
|
16
|
-
- **
|
|
17
|
-
- **
|
|
18
|
-
- **
|
|
19
|
-
- **
|
|
20
|
-
- **
|
|
21
|
-
- **
|
|
22
|
-
- **
|
|
23
|
-
- **Light, dark, system.** Per-frame theme (Inherit / Light / Dark) and a canvas-wide theme, applied live without reloading frames.
|
|
24
|
-
- **Lightweight.** No second dev server. Frames render through your existing `next dev`, the editor is a static bundle, and headless Chromium only starts when an agent asks for a screenshot.
|
|
25
|
-
|
|
26
|
-
> Status: early (v0.1). Next.js App Router + Tailwind first. Linux-first, tested on Fedora 43.
|
|
17
|
+
## Features
|
|
18
|
+
|
|
19
|
+
- **Real components.** Layers are instances of your components, with a props panel generated from their TypeScript types.
|
|
20
|
+
- **Code is the document.** A canvas is a `.canvas.tsx` file. Edits are minimal patches that keep your formatting, so diffs stay reviewable.
|
|
21
|
+
- **Agent-first.** 44 MCP tools to read the tree, insert JSX, set props and classes, create frames and variants, and take screenshots.
|
|
22
|
+
- **Design on real pages.** Link any Next.js page as a frame: editing its layers edits `page.tsx` and its layouts.
|
|
23
|
+
- **Components and libraries.** Turn a selection into a component, edit main components, install icon sets (Lucide, Tabler, Phosphor, Heroicons, Radix) and add shadcn/ui components.
|
|
24
|
+
- **Git built in.** Per-frame change summaries, branches, commits, pull requests with before/after images, visual compare and comments that travel with branches.
|
|
25
|
+
- **Motion, devices, themes.** Scroll reveals and text animations, iPhone/Pixel/iPad presets with device chrome, and per-frame light and dark themes.
|
|
26
|
+
- **Lightweight.** Frames render through your existing dev server. Headless Chromium only starts when an agent asks for a screenshot.
|
|
27
27
|
|
|
28
28
|
## Quick start
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
In a **Next.js** (App Router, 15.3+) or **Vite + React** (5+) app:
|
|
31
31
|
|
|
32
32
|
```bash
|
|
33
33
|
npx truecanvas
|
|
34
34
|
```
|
|
35
35
|
|
|
36
|
-
|
|
36
|
+
The first run sets the project up: the dev dependency, the plugin in `next.config` or `vite.config`, a `canvas` script and a first canvas. It then starts your app and opens the editor. After that, use `npm run canvas`.
|
|
37
37
|
|
|
38
|
-
|
|
38
|
+
Start a new app instead:
|
|
39
39
|
|
|
40
40
|
```bash
|
|
41
|
-
npm create truecanvas@latest my-app
|
|
42
|
-
|
|
41
|
+
npm create truecanvas@latest my-app # asks Next.js or Vite
|
|
42
|
+
npm create truecanvas@latest my-app -- --vite
|
|
43
43
|
```
|
|
44
44
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
**Something off?** `npx truecanvas doctor` checks Node, Next, React, Tailwind, the config, agents, screenshots and git, with a one-line fix for each problem.
|
|
45
|
+
Run `npx truecanvas doctor` if anything looks off: it checks every requirement and prints a fix for each problem.
|
|
48
46
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
### Connect your agent
|
|
47
|
+
## Connect your agent
|
|
52
48
|
|
|
53
49
|
```bash
|
|
54
50
|
npx truecanvas connect
|
|
55
51
|
```
|
|
56
52
|
|
|
57
|
-
This
|
|
53
|
+
This adds Truecanvas to the project's `.mcp.json`. Commit it and every teammate's agent is connected.
|
|
58
54
|
|
|
59
55
|
| Client | Config |
|
|
60
56
|
| --- | --- |
|
|
61
|
-
| Claude Code | `.mcp.json
|
|
62
|
-
| Cursor | `.cursor/mcp.json
|
|
63
|
-
| VS Code | `.vscode/mcp.json
|
|
64
|
-
| Codex | `~/.codex/config.toml
|
|
65
|
-
| stdio
|
|
66
|
-
|
|
67
|
-
Then select something on the canvas and ask: *"Make my selection denser"*, *"Create a dark copy of the Chat frame and check it with a screenshot"*, *"Show every status of SessionRow"*.
|
|
68
|
-
|
|
69
|
-
## The Truecanvas window
|
|
70
|
-
|
|
71
|
-
```bash
|
|
72
|
-
npx truecanvas desktop # adds Truecanvas to your app launcher (GNOME, KDE…)
|
|
73
|
-
npx truecanvas hub # or start it from a terminal
|
|
74
|
-
```
|
|
75
|
-
|
|
76
|
-
One window for all your projects, with tabs like a browser. The **Dashboard** lists your recent projects (with their favicons) and the Next.js apps it finds on your computer. You can open a folder, set up Truecanvas in an existing app, or create a new project. Each tab is a project's editor. Opening a tab starts that project's app on free ports, and closing it stops the app and frees the memory (the tab bar shows how much is in use).
|
|
77
|
-
|
|
78
|
-
The hub serves MCP at the same `http://localhost:4800/mcp`, and your agent works on the project in the active tab. It can also call `list_projects` and `open_project` to switch itself.
|
|
79
|
-
|
|
80
|
-
## Collaborate
|
|
81
|
-
|
|
82
|
-
Everything Truecanvas makes is a file in your repo: canvases, comments, linked pages, the motion components they use, and `.mcp.json`. So git is the collaboration layer, and a team works on design the way it works on code.
|
|
83
|
-
|
|
84
|
-
### The workflow
|
|
85
|
-
|
|
86
|
-
1. **Set up once, commit it.** `npx truecanvas` in the repo, then commit `package.json`, `canvas/`, `next.config` and `.mcp.json`. Teammates run their usual install and `npm run canvas`; their agents are already connected.
|
|
87
|
-
2. **Design on real pages.** Linked frames are your production pages: editing them edits `page.tsx`. For bigger ideas, right-click → **Explore a copy**, iterate freely (you or your agent), then **Apply to page**. Delete explorations once applied.
|
|
88
|
-
3. **One branch per change.** On `main`, Truecanvas offers to start a branch and brings your edits along.
|
|
89
|
-
4. **Commit from the Git panel.** It lists what changed per frame in plain words and drafts the message.
|
|
90
|
-
5. **Open a pull request** from the panel: the description shows each changed frame, before and after.
|
|
91
|
-
6. **Review in Truecanvas too.** In the Truecanvas window, a project's menu → **Pull requests…** checks out any PR and shows it on the canvas, live, not just as pictures. Leave comments on frames; they're saved in the branch, and agents can read and resolve them.
|
|
92
|
-
7. **Merge.** There's no handoff: the design is already the code.
|
|
93
|
-
|
|
94
|
-
Designers who don't use a terminal can do all of it from the Truecanvas window: **Clone from GitHub** (clones, installs, sets up), open the project, branch, commit, open and review PRs.
|
|
95
|
-
|
|
96
|
-
### What's in the Git panel
|
|
97
|
-
|
|
57
|
+
| Claude Code | `.mcp.json`, or `claude mcp add --scope user --transport http truecanvas http://localhost:4800/mcp` |
|
|
58
|
+
| Cursor | `.cursor/mcp.json`: `{ "mcpServers": { "truecanvas": { "url": "http://localhost:4800/mcp" } } }` |
|
|
59
|
+
| VS Code | `.vscode/mcp.json`: `{ "servers": { "truecanvas": { "type": "http", "url": "http://localhost:4800/mcp" } } }` |
|
|
60
|
+
| Codex | `~/.codex/config.toml`: `[mcp_servers.truecanvas]` with `url = "http://localhost:4800/mcp"` |
|
|
61
|
+
| stdio clients | `{ "command": "npx", "args": ["truecanvas", "mcp"] }` |
|
|
98
62
|
|
|
99
|
-
|
|
100
|
-
- **Review changes**: a full-screen before/after of each changed frame against the last commit (rendered by your app), plus the code diff.
|
|
101
|
-
- **Branch first**: on `main` with changes, Truecanvas offers to start a branch (named from the page and frames) and carries your edits over.
|
|
102
|
-
- **Pull requests** (GitHub, through the `gh` CLI): push and open a PR in one step. Its description lists the changes per frame, optionally with before/after images stored on a `truecanvas-previews` branch of the same repo. The branch pill then shows the PR's state: open, checks running or failing, approved, merged. No GitHub remote yet? "Publish" creates the repo (private by default).
|
|
103
|
-
- **Compare**: render this page as it was on another branch or commit, side by side or as an overlay with an opacity slider. Changed and new frames are flagged. This works because Truecanvas renders any version of the code.
|
|
104
|
-
- **Comments** (`C`): pin a comment on a frame or layer. Threads are saved in `canvas/<page>.comments.json`, so they travel with branches and show up in pull requests. Agents read them with `list_comments` (including the current source of the commented layer), fix things, and `resolve_comment` with a note.
|
|
105
|
-
|
|
106
|
-
### Good to know
|
|
107
|
-
|
|
108
|
-
- **Async, like code.** Collaboration happens through branches and pull requests. Live multiplayer on one canvas isn't supported; the live cursors you see are agents working on your machine.
|
|
109
|
-
- **Merges.** Canvas files are TSX with one block per frame and minimal edits, so two people changing different frames merge cleanly. When both change the same frame, resolve it like any code conflict and Truecanvas re-renders the result.
|
|
110
|
-
- **One agent config for everyone.** `.mcp.json` points at `http://localhost:4800/mcp`, which works whether each person runs `npm run canvas` or the Truecanvas window.
|
|
111
|
-
|
|
112
|
-
## Linked frames
|
|
113
|
-
|
|
114
|
-
A new project's first canvas links your homepage. Any page can be linked from Pages + or with the `import_page` tool:
|
|
115
|
-
|
|
116
|
-
```tsx
|
|
117
|
-
<Frame name="Home" x={0} y={0} width={1440} page="src/app/(marketing)/page.tsx">
|
|
118
|
-
<MarketingLayout>
|
|
119
|
-
<HomePage />
|
|
120
|
-
</MarketingLayout>
|
|
121
|
-
</Frame>
|
|
122
|
-
```
|
|
123
|
-
|
|
124
|
-
- The frame renders the real page through its layouts. Its layers are the JSX of `page.tsx` and of the layouts around it, and every edit goes to the file that layer lives in, keeping that file's formatting. The frame label shows **Live · page.tsx**.
|
|
125
|
-
- Undo, the Git panel (linked pages count as design files) and Compare (the page is rendered as it was at that commit) all work on the page files.
|
|
126
|
-
- Only plain composition is editable: a page whose component fetches data or has logic before its `return` is shown as one layer, **View only**. Explore a copy to design on it.
|
|
127
|
-
- **Explore a copy** creates `<Frame from="src/app/…/page.tsx">` with the page's JSX inline, in the canvas file. **Apply to page** replaces the page's returned JSX with the exploration's, adds the imports it needs and removes the ones it no longer uses.
|
|
128
|
-
- Page and layout layers get ids like `src/app/page.tsx#12:8`, stamped only in `next dev`. Production builds are untouched.
|
|
63
|
+
Then select something and ask: *"Make my selection denser"* or *"Show every status of SessionRow in a variants frame"*.
|
|
129
64
|
|
|
130
65
|
## A canvas file
|
|
131
66
|
|
|
132
67
|
```tsx
|
|
133
68
|
"use client";
|
|
134
69
|
import { Canvas, Frame } from "truecanvas";
|
|
135
|
-
import { SessionRow } from "@/components/
|
|
70
|
+
import { SessionRow } from "@/components/session-row";
|
|
136
71
|
|
|
137
72
|
export default function ChatCanvas() {
|
|
138
73
|
return (
|
|
139
74
|
<Canvas>
|
|
140
75
|
<Frame name="Sessions" x={0} y={0} width={320}>
|
|
141
|
-
<
|
|
142
|
-
<SessionRow title="Review release readiness" status="working" elapsed="2m" active />
|
|
143
|
-
<SessionRow title="Draft the Q4 changelog" status="review" source="slack" elapsed="3h" />
|
|
144
|
-
</div>
|
|
76
|
+
<SessionRow title="Review release readiness" status="working" />
|
|
145
77
|
</Frame>
|
|
146
78
|
<Frame name="Sessions dark" x={360} y={0} width={320} theme="dark">
|
|
147
|
-
|
|
79
|
+
<SessionRow title="Draft the changelog" status="review" />
|
|
148
80
|
</Frame>
|
|
149
81
|
</Canvas>
|
|
150
82
|
);
|
|
151
83
|
}
|
|
152
84
|
```
|
|
153
85
|
|
|
154
|
-
|
|
155
|
-
- Each frame renders in its own iframe at its own width, so media queries, portals and `position: fixed` behave like the real app.
|
|
156
|
-
- Keep props literal (`status="working"`) to make them editable. Expressions such as `{items.map(…)}` still render, but they are read-only in the inspector.
|
|
157
|
-
|
|
158
|
-
## MCP tools
|
|
159
|
-
|
|
160
|
-
| Read | Write | Look & show |
|
|
161
|
-
| --- | --- | --- |
|
|
162
|
-
| `list_canvases` | `set_props` · `set_text` · `set_class_name` | `screenshot_frame` |
|
|
163
|
-
| `get_canvas` (compact outline with ids) | `insert_jsx` · `replace_node` | `focus` (select + zoom in the user's editor) |
|
|
164
|
-
| `get_node` (exact TSX source) | `duplicate_nodes` · `delete_nodes` · `move_node` | `undo` · `redo` |
|
|
165
|
-
| `list_components` · `get_component` | `wrap_nodes` (auto layout) | |
|
|
166
|
-
| `get_design_tokens` (Tailwind `@theme`) | `create_frame` · `update_frame` (both take `device`) | |
|
|
167
|
-
| | `create_canvas` · `rename_canvas` · `delete_canvas` | |
|
|
168
|
-
| `get_selection` (what the user selected) | `create_variants_frame` · `add_background` · `apply_preset` | |
|
|
169
|
-
| `list_comments` | `reply_comment` · `resolve_comment` | |
|
|
170
|
-
| | `import_page` (linked by default) · `explore_copy` · `apply_to_page` | |
|
|
171
|
-
| | `add_animation` · `remove_animation` | `play_frame` (play, replay, stop) |
|
|
172
|
-
| | `create_component` · `open_component` | |
|
|
173
|
-
|
|
174
|
-
Node ids are `line:col` of the JSX tag (`file#line:col` for layers of a linked page) and change after edits. Every write returns the fresh ids of the affected frame. Catalog components are imported automatically.
|
|
86
|
+
`<Frame>` takes `name`, `x`, `y`, `width`, and optionally `height`, `theme`, `device`, `page` (a linked page) or `component` (a main component). Each frame renders in its own iframe at its own width, so media queries, portals and `position: fixed` behave as in your app. Literal props are editable in the inspector; expressions still render but are read-only.
|
|
175
87
|
|
|
176
88
|
## How it works
|
|
177
89
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
│ editor UI (static bundle) │◀─────────▶│ Workspace: parse → patch → write │◀── MCP ── your agent
|
|
182
|
-
│ ┌──────┐ ┌──────┐ │ │ (Babel + magic-string, undo log) │
|
|
183
|
-
│ │iframe│ │iframe│ frames │ │ Catalog: TS checker → prop types │
|
|
184
|
-
│ └──┬───┘ └──┬───┘ │ └───────────────┬──────────────────┘
|
|
185
|
-
└─────┼────────┼────────────┘ │ writes canvas/*.canvas.tsx
|
|
186
|
-
│ └── postMessage: hit-test, rects, theme ▼
|
|
187
|
-
└──────────── /truecanvas/[canvas]?frame=… ── your `next dev` (HMR)
|
|
188
|
-
```
|
|
90
|
+
1. A dev-only plugin (Turbopack/webpack loader for Next.js, Vite plugin for Vite) stamps each JSX element with its source position. Production builds are untouched.
|
|
91
|
+
2. Each frame loads `/truecanvas/<canvas>` from your dev server, where a small runtime maps what's under the pointer back to that position.
|
|
92
|
+
3. Your gestures and your agent's MCP calls become the same commands. The server patches the source and your dev server hot-reloads the frame.
|
|
189
93
|
|
|
190
|
-
|
|
191
|
-
2. In the iframe, a tiny runtime answers "what's under this point?" by walking React's fiber tree to the nearest stamped element. No wrapper elements, and it works with portals and components that don't forward props.
|
|
192
|
-
3. The editor turns your gestures into commands. The server patches the source text and writes it, and Next's HMR re-renders the frame.
|
|
193
|
-
4. Agents use the same commands over MCP, so whatever you can do, they can do.
|
|
94
|
+
Collaboration goes through git: canvases, comments and linked pages are files, so design changes are branched, reviewed and merged like code. The **Truecanvas window** (`npx truecanvas open`) manages several projects in tabs, clones from GitHub and reviews pull requests on the canvas.
|
|
194
95
|
|
|
195
96
|
## Configuration
|
|
196
97
|
|
|
197
|
-
|
|
98
|
+
Everything is optional. Add a `truecanvas.config.json` at the project root:
|
|
198
99
|
|
|
199
100
|
```json
|
|
200
101
|
{
|
|
@@ -203,63 +104,35 @@ Optional `truecanvas.config.json` at the project root:
|
|
|
203
104
|
"canvasDir": "canvas",
|
|
204
105
|
"components": ["components/**/*.tsx"],
|
|
205
106
|
"darkMode": { "strategy": "class", "value": "dark" },
|
|
206
|
-
"
|
|
107
|
+
"css": ["/src/index.css"],
|
|
207
108
|
"editor": "code"
|
|
208
109
|
}
|
|
209
110
|
```
|
|
210
111
|
|
|
211
|
-
`
|
|
212
|
-
|
|
213
|
-
## Shortcuts
|
|
214
|
-
|
|
215
|
-
| | |
|
|
216
|
-
| --- | --- |
|
|
217
|
-
| Select / Frame / Hand / Interact | `V` `F` `H` `P` |
|
|
218
|
-
| Duplicate · Delete | `⌘D` · `⌫` |
|
|
219
|
-
| Wrap in auto layout | `⇧A` |
|
|
220
|
-
| Reorder in parent | `↑` `↓` or drag on the canvas |
|
|
221
|
-
| Parent / child | `⇧↵` / `↵` (or `Esc`) |
|
|
222
|
-
| Edit text | double-click or `↵` |
|
|
223
|
-
| Rename layer or page | double-click its name |
|
|
224
|
-
| Copy as JSX | `⌘C` |
|
|
225
|
-
| Pause / play animations | `⇧P` |
|
|
226
|
-
| Comment | `C` |
|
|
227
|
-
| Zoom to fit · selection · 100% | `⇧1` · `⇧2` · `⇧0` |
|
|
228
|
-
| Undo · Redo | `⌘Z` · `⇧⌘Z` |
|
|
229
|
-
|
|
230
|
-
On Linux, `⌘` is `Ctrl`. Pan with space-drag, middle mouse or two-finger scroll. Zoom with Ctrl+scroll or pinch.
|
|
112
|
+
`darkMode` describes how your app switches themes (a class, or `{ "strategy": "attribute", "attribute": "data-theme", "value": "dark" }`). `css` (Vite only) lists the stylesheets frames load; by default, the ones your entry module imports.
|
|
231
113
|
|
|
232
114
|
## Requirements
|
|
233
115
|
|
|
234
|
-
- Node
|
|
235
|
-
-
|
|
236
|
-
-
|
|
237
|
-
-
|
|
116
|
+
- Node 22+ (24 LTS recommended) and React 19
|
|
117
|
+
- Next.js 15.3+ (App Router) or Vite 5+. Tailwind is optional; the style controls write Tailwind classes.
|
|
118
|
+
- macOS, Linux or Windows
|
|
119
|
+
- Screenshots: Chrome, Edge or Chromium, or `npx playwright install chromium-headless-shell`
|
|
120
|
+
- Pull requests: the GitHub CLI (`gh auth login`)
|
|
238
121
|
|
|
239
|
-
|
|
122
|
+
Linked page frames need Next.js file-based routes. Everything else works the same in Vite apps.
|
|
240
123
|
|
|
241
|
-
##
|
|
124
|
+
## Development
|
|
242
125
|
|
|
243
126
|
```bash
|
|
244
127
|
pnpm install
|
|
245
|
-
pnpm build
|
|
246
|
-
pnpm --filter truecanvas test
|
|
247
|
-
pnpm dev
|
|
248
|
-
pnpm --filter truecanvas dev:editor # editor with
|
|
249
|
-
pnpm pack:local # an installable build, plus the command to add it to a project
|
|
128
|
+
pnpm build # library and editor
|
|
129
|
+
pnpm --filter truecanvas test # tests
|
|
130
|
+
pnpm dev # playground app and editor
|
|
131
|
+
pnpm --filter truecanvas dev:editor # editor with HMR on :4801
|
|
250
132
|
```
|
|
251
133
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
**Releasing:** `pnpm release 0.2.0` bumps `truecanvas` and `create-truecanvas`, commits and tags; `git push --follow-tags` runs the release workflow, which tests and publishes both to npm with provenance (needs an `NPM_TOKEN` secret in the GitHub repo).
|
|
255
|
-
|
|
256
|
-
## Roadmap
|
|
257
|
-
|
|
258
|
-
- Multi-frame drag and reparenting by dragging on the canvas
|
|
259
|
-
- Vite + React Router support, then CSS Modules
|
|
260
|
-
- Inline prop editing for `ReactNode` slots
|
|
261
|
-
- Desktop shell (AppImage / Flatpak) once the web version settles
|
|
134
|
+
Releases: `pnpm release <version>` bumps both packages and tags; `git push --follow-tags` publishes to npm from GitHub Actions with provenance.
|
|
262
135
|
|
|
263
136
|
## License
|
|
264
137
|
|
|
265
|
-
MIT
|
|
138
|
+
[MIT](https://github.com/Nathandona/truecanvas/blob/main/LICENSE)
|
package/dist/catalog-worker.js
CHANGED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// src/core/icons.ts
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { createRequire } from "module";
|
|
5
|
+
import { fileURLToPath, pathToFileURL } from "url";
|
|
6
|
+
var aliasOf = (name, all, suffix) => name.endsWith(suffix) && all.has(name.slice(0, -suffix.length));
|
|
7
|
+
var ICON_LIBRARIES = [
|
|
8
|
+
{
|
|
9
|
+
id: "lucide",
|
|
10
|
+
label: "Lucide",
|
|
11
|
+
package: "lucide-react",
|
|
12
|
+
from: "lucide-react",
|
|
13
|
+
homepage: "https://lucide.dev/icons",
|
|
14
|
+
pick: (n, all) => !n.startsWith("Lucide") && !aliasOf(n, all, "Icon") && n !== "Icon"
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
id: "tabler",
|
|
18
|
+
label: "Tabler Icons",
|
|
19
|
+
package: "@tabler/icons-react",
|
|
20
|
+
from: "@tabler/icons-react",
|
|
21
|
+
homepage: "https://tabler.io/icons",
|
|
22
|
+
pick: (n) => /^Icon[A-Z0-9]/.test(n)
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
id: "phosphor",
|
|
26
|
+
label: "Phosphor",
|
|
27
|
+
package: "@phosphor-icons/react",
|
|
28
|
+
from: "@phosphor-icons/react",
|
|
29
|
+
homepage: "https://phosphoricons.com",
|
|
30
|
+
pick: (n, all) => !["IconBase", "IconContext", "SSRBase"].includes(n) && !aliasOf(n, all, "Icon")
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
id: "heroicons",
|
|
34
|
+
label: "Heroicons outline",
|
|
35
|
+
package: "@heroicons/react",
|
|
36
|
+
from: "@heroicons/react/24/outline",
|
|
37
|
+
homepage: "https://heroicons.com",
|
|
38
|
+
pick: (n) => n.endsWith("Icon")
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
id: "heroicons-solid",
|
|
42
|
+
label: "Heroicons solid",
|
|
43
|
+
package: "@heroicons/react",
|
|
44
|
+
from: "@heroicons/react/24/solid",
|
|
45
|
+
homepage: "https://heroicons.com",
|
|
46
|
+
pick: (n) => n.endsWith("Icon")
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
id: "radix",
|
|
50
|
+
label: "Radix Icons",
|
|
51
|
+
package: "@radix-ui/react-icons",
|
|
52
|
+
from: "@radix-ui/react-icons",
|
|
53
|
+
homepage: "https://www.radix-ui.com/icons",
|
|
54
|
+
pick: (n) => n.endsWith("Icon")
|
|
55
|
+
}
|
|
56
|
+
];
|
|
57
|
+
function iconLibrary(id) {
|
|
58
|
+
return ICON_LIBRARIES.find((l) => l.id === id || l.package === id || l.from === id);
|
|
59
|
+
}
|
|
60
|
+
function installedVersion(root, pkg) {
|
|
61
|
+
try {
|
|
62
|
+
const req = createRequire(path.join(root, "package.json"));
|
|
63
|
+
let dir = path.dirname(req.resolve(pkg));
|
|
64
|
+
for (let i = 0; i < 6; i++, dir = path.dirname(dir)) {
|
|
65
|
+
const file = path.join(dir, "package.json");
|
|
66
|
+
if (fs.existsSync(file)) {
|
|
67
|
+
const json = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
68
|
+
if (json.name === pkg) return json.version ?? null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
} catch {
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
async function renderIconSet(root, id) {
|
|
76
|
+
const lib = iconLibrary(id);
|
|
77
|
+
if (!lib) throw new Error(`Unknown icon library "${id}".`);
|
|
78
|
+
const req = createRequire(path.join(root, "package.json"));
|
|
79
|
+
const load = async (spec) => import(pathToFileURL(req.resolve(spec)).href);
|
|
80
|
+
const React = await load("react");
|
|
81
|
+
const server = await load("react-dom/server");
|
|
82
|
+
const mod = await load(lib.from);
|
|
83
|
+
const createElement = React.createElement ?? React.default?.createElement;
|
|
84
|
+
const renderToStaticMarkup = server.renderToStaticMarkup ?? server.default?.renderToStaticMarkup;
|
|
85
|
+
const exports = { ...mod.default && typeof mod.default === "object" ? mod.default : {}, ...mod };
|
|
86
|
+
const all = new Set(Object.keys(exports).filter((n) => /^[A-Z]/.test(n)));
|
|
87
|
+
const out = [];
|
|
88
|
+
for (const name of [...all].sort()) {
|
|
89
|
+
if (!lib.pick(name, all)) continue;
|
|
90
|
+
const C = exports[name];
|
|
91
|
+
if (!(typeof C === "function" || typeof C === "object" && C !== null && "$$typeof" in C)) continue;
|
|
92
|
+
try {
|
|
93
|
+
const svg = renderToStaticMarkup(createElement(C, {}));
|
|
94
|
+
if (svg.startsWith("<svg")) out.push({ name, svg: svg.replace(/\s(?:class|data-[\w-]+)="[^"]*"/g, "") });
|
|
95
|
+
} catch {
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
function cacheFile(root, id, version) {
|
|
101
|
+
return path.join(root, "node_modules", ".cache", "truecanvas", "icons", `${id}@${version}.json`);
|
|
102
|
+
}
|
|
103
|
+
var memo = null;
|
|
104
|
+
var inflight = /* @__PURE__ */ new Map();
|
|
105
|
+
async function loadIcons(root, id) {
|
|
106
|
+
const lib = iconLibrary(id);
|
|
107
|
+
if (!lib) throw new Error(`Unknown icon library "${id}". Known: ${ICON_LIBRARIES.map((l) => l.id).join(", ")}.`);
|
|
108
|
+
const version = installedVersion(root, lib.package);
|
|
109
|
+
if (!version) throw new Error(`${lib.package} isn't installed in this project. Install it first (install_library).`);
|
|
110
|
+
const file = cacheFile(root, lib.id, version);
|
|
111
|
+
if (memo?.key === file) return memo.icons;
|
|
112
|
+
const running = inflight.get(file);
|
|
113
|
+
if (running) return running;
|
|
114
|
+
const job = (async () => {
|
|
115
|
+
let icons = null;
|
|
116
|
+
try {
|
|
117
|
+
icons = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
118
|
+
} catch {
|
|
119
|
+
icons = await renderInWorker(root, lib.id);
|
|
120
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
121
|
+
fs.writeFileSync(file, JSON.stringify(icons));
|
|
122
|
+
}
|
|
123
|
+
memo = { key: file, icons };
|
|
124
|
+
return icons;
|
|
125
|
+
})().finally(() => inflight.delete(file));
|
|
126
|
+
inflight.set(file, job);
|
|
127
|
+
return job;
|
|
128
|
+
}
|
|
129
|
+
async function renderInWorker(root, id) {
|
|
130
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
131
|
+
const worker = [path.join(here, "icons-worker.js"), path.join(here, "..", "icons-worker.js")].find((p) => fs.existsSync(p));
|
|
132
|
+
if (!worker) return renderIconSet(root, id);
|
|
133
|
+
const { Worker } = await import("worker_threads");
|
|
134
|
+
return new Promise((resolve, reject) => {
|
|
135
|
+
const w = new Worker(worker, { workerData: { root, id } });
|
|
136
|
+
w.once("message", (m) => m.ok ? resolve(m.icons) : reject(new Error(m.error)));
|
|
137
|
+
w.once("error", reject);
|
|
138
|
+
w.once("exit", (code) => code !== 0 && reject(new Error(`icons worker exited with ${code}`)));
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
function subsequence(q, name) {
|
|
142
|
+
let i = 0;
|
|
143
|
+
let start = -1;
|
|
144
|
+
for (let j = 0; j < name.length && i < q.length; j++) {
|
|
145
|
+
if (name[j] !== q[i]) continue;
|
|
146
|
+
if (i === 0) start = j;
|
|
147
|
+
i++;
|
|
148
|
+
if (i === q.length) return { start, span: j - start + 1 };
|
|
149
|
+
}
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
function distance(a, b, max = 2) {
|
|
153
|
+
if (Math.abs(a.length - b.length) > max) return Infinity;
|
|
154
|
+
let prev = Array.from({ length: b.length + 1 }, (_, j) => j);
|
|
155
|
+
for (let i = 1; i <= a.length; i++) {
|
|
156
|
+
const row = [i];
|
|
157
|
+
for (let j = 1; j <= b.length; j++) row[j] = Math.min(prev[j] + 1, row[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
158
|
+
if (Math.min(...row) > max) return Infinity;
|
|
159
|
+
prev = row;
|
|
160
|
+
}
|
|
161
|
+
return prev[b.length];
|
|
162
|
+
}
|
|
163
|
+
function searchIcons(icons, query, limit = 200) {
|
|
164
|
+
const q = query.trim().toLowerCase().replace(/[\s_-]+/g, "");
|
|
165
|
+
if (!q) return { total: icons.length, icons: icons.slice(0, limit) };
|
|
166
|
+
const direct = [];
|
|
167
|
+
const fuzzy = [];
|
|
168
|
+
for (const icon of icons) {
|
|
169
|
+
const name = icon.name.toLowerCase().replace(/^icon(?=[a-z0-9])/, "").replace(/icon$/, "");
|
|
170
|
+
if (name === q) direct.push({ icon, score: 0 });
|
|
171
|
+
else if (name.startsWith(q)) direct.push({ icon, score: 1 });
|
|
172
|
+
else if (q.length > 3 && distance(q, name) <= 2) direct.push({ icon, score: 1 + distance(q, name) / 4 });
|
|
173
|
+
else if (name.includes(q)) direct.push({ icon, score: 2 });
|
|
174
|
+
else if (q.length > 2) {
|
|
175
|
+
const m = subsequence(q, name);
|
|
176
|
+
if (m && m.span <= q.length * 2) fuzzy.push({ icon, score: (m.start === 0 ? 0 : 1e3) + m.span * 10 + name.length / 100 });
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
const byScore = (a, b) => a.score - b.score || a.icon.name.length - b.icon.name.length;
|
|
180
|
+
direct.sort(byScore);
|
|
181
|
+
fuzzy.sort(byScore);
|
|
182
|
+
const ranked = direct.length >= 5 ? direct : [...direct, ...fuzzy];
|
|
183
|
+
return { total: ranked.length, icons: ranked.slice(0, limit).map((s) => s.icon) };
|
|
184
|
+
}
|
|
185
|
+
function iconLibraries(root) {
|
|
186
|
+
return ICON_LIBRARIES.map((l) => ({ id: l.id, label: l.label, package: l.package, from: l.from, homepage: l.homepage, version: installedVersion(root, l.package) }));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export {
|
|
190
|
+
ICON_LIBRARIES,
|
|
191
|
+
iconLibrary,
|
|
192
|
+
renderIconSet,
|
|
193
|
+
loadIcons,
|
|
194
|
+
searchIcons,
|
|
195
|
+
iconLibraries
|
|
196
|
+
};
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// src/core/config.ts
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import path from "path";
|
|
4
|
+
function loadConfig(root, overrides = {}) {
|
|
5
|
+
let file = {};
|
|
6
|
+
const configPath = path.join(root, "truecanvas.config.json");
|
|
7
|
+
if (fs.existsSync(configPath)) file = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
8
|
+
let deps = {};
|
|
9
|
+
try {
|
|
10
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8"));
|
|
11
|
+
deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
12
|
+
} catch {
|
|
13
|
+
deps = {};
|
|
14
|
+
}
|
|
15
|
+
const framework = file.framework ?? detectFramework(deps);
|
|
16
|
+
const vite = framework === "vite";
|
|
17
|
+
const appDir = file.appDir ?? (vite ? fs.existsSync(path.join(root, "src")) ? "src" : "." : fs.existsSync(path.join(root, "src/app")) ? "src/app" : "app");
|
|
18
|
+
const srcPrefix = appDir === "src" || appDir.startsWith("src/") ? "src/" : "";
|
|
19
|
+
const knownLibraries = ["@paper-design/shaders-react"].filter((l) => l in deps);
|
|
20
|
+
return {
|
|
21
|
+
root,
|
|
22
|
+
framework,
|
|
23
|
+
appUrl: vite ? "http://localhost:5173" : "http://localhost:3000",
|
|
24
|
+
port: 4800,
|
|
25
|
+
canvasDir: `${srcPrefix}canvas`,
|
|
26
|
+
appDir,
|
|
27
|
+
routeDir: vite ? ".truecanvas" : `${appDir}/truecanvas`,
|
|
28
|
+
components: [`${srcPrefix}components/**/*.tsx`],
|
|
29
|
+
darkMode: { strategy: "class", value: "dark" },
|
|
30
|
+
...file,
|
|
31
|
+
libraries: [.../* @__PURE__ */ new Set([...knownLibraries, ...file.libraries ?? []])],
|
|
32
|
+
...stripUndefined(overrides)
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
function stripUndefined(o) {
|
|
36
|
+
return Object.fromEntries(Object.entries(o).filter(([, v]) => v !== void 0));
|
|
37
|
+
}
|
|
38
|
+
function detectFramework(deps) {
|
|
39
|
+
return !("next" in deps) && "vite" in deps ? "vite" : "next";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// src/core/ast.ts
|
|
43
|
+
import { parse } from "@babel/parser";
|
|
44
|
+
function parseModule(source) {
|
|
45
|
+
return parse(source, {
|
|
46
|
+
sourceType: "module",
|
|
47
|
+
plugins: ["typescript", "jsx"],
|
|
48
|
+
errorRecovery: false
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
function idOf(node) {
|
|
52
|
+
const loc = node.loc.start;
|
|
53
|
+
return `${loc.line}:${loc.column}`;
|
|
54
|
+
}
|
|
55
|
+
function jsxName(name) {
|
|
56
|
+
if (name.type === "JSXIdentifier") return name.name;
|
|
57
|
+
if (name.type === "JSXNamespacedName") return `${name.namespace.name}:${name.name.name}`;
|
|
58
|
+
return `${jsxName(name.object)}.${name.property.name}`;
|
|
59
|
+
}
|
|
60
|
+
function walk(node, visit, parent = null) {
|
|
61
|
+
if (!node || typeof node !== "object") return;
|
|
62
|
+
if (Array.isArray(node)) {
|
|
63
|
+
for (const child of node) walk(child, visit, parent);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const n = node;
|
|
67
|
+
if (typeof n.type !== "string") return;
|
|
68
|
+
if (visit(n, parent) === false) return;
|
|
69
|
+
for (const key of Object.keys(n)) {
|
|
70
|
+
if (key === "loc" || key === "leadingComments" || key === "trailingComments" || key === "innerComments" || key === "extra") continue;
|
|
71
|
+
const value = n[key];
|
|
72
|
+
if (value && typeof value === "object") walk(value, visit, n);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
function isComponentName(name) {
|
|
76
|
+
return /^[A-Z]/.test(name) || name.includes(".");
|
|
77
|
+
}
|
|
78
|
+
function rewriteImportSource(statement, from, to) {
|
|
79
|
+
if (from === to) return statement;
|
|
80
|
+
for (const q of ['"', "'"]) {
|
|
81
|
+
const needle = `${q}${from}${q}`;
|
|
82
|
+
const i = statement.indexOf(needle);
|
|
83
|
+
if (i >= 0) return `${statement.slice(0, i)}${q}${to}${q}${statement.slice(i + needle.length)}`;
|
|
84
|
+
}
|
|
85
|
+
return statement;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export {
|
|
89
|
+
loadConfig,
|
|
90
|
+
detectFramework,
|
|
91
|
+
parseModule,
|
|
92
|
+
idOf,
|
|
93
|
+
jsxName,
|
|
94
|
+
walk,
|
|
95
|
+
isComponentName,
|
|
96
|
+
rewriteImportSource
|
|
97
|
+
};
|