@design-twin/dtwin 0.0.0-stage → 0.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/LICENSE +21 -0
- package/README.md +959 -2
- package/figma-plugin/code.js +4207 -0
- package/figma-plugin/manifest.json +28 -0
- package/figma-plugin/ui.html +686 -0
- package/layer/cross-check.js +2746 -0
- package/layer/drift-lint.js +1526 -0
- package/layer/get-component.js +553 -0
- package/layer/map-bootstrap.js +1404 -0
- package/layer/map-validate.js +397 -0
- package/package.json +53 -4
- package/src/abort-reason.ts +12 -0
- package/src/asset-compare.ts +175 -0
- package/src/asset-owners.ts +280 -0
- package/src/atomic-write.ts +79 -0
- package/src/bridge-holder.ts +294 -0
- package/src/cli-commands.ts +427 -0
- package/src/client-presence.ts +146 -0
- package/src/clock.ts +45 -0
- package/src/commands.ts +561 -0
- package/src/daemon-auth.ts +174 -0
- package/src/daemon.ts +788 -0
- package/src/delivery.ts +85 -0
- package/src/design-system-layout.ts +232 -0
- package/src/doc-types.ts +1054 -0
- package/src/doctor.ts +602 -0
- package/src/errmsg.ts +14 -0
- package/src/export-reader.ts +409 -0
- package/src/export-verdict.ts +258 -0
- package/src/figma-mcp.ts +942 -0
- package/src/figma-pull.ts +1265 -0
- package/src/file-lock.ts +268 -0
- package/src/file-memo.ts +62 -0
- package/src/hash.ts +6 -0
- package/src/hex-color.ts +39 -0
- package/src/human-ms.ts +7 -0
- package/src/init.ts +424 -0
- package/src/instance-overrides.ts +7 -0
- package/src/json-file.ts +79 -0
- package/src/json-size.ts +108 -0
- package/src/json-util.ts +51 -0
- package/src/library-layout.ts +193 -0
- package/src/node-id.ts +69 -0
- package/src/pages-layout.ts +483 -0
- package/src/pending-requests.ts +187 -0
- package/src/plugin-frames.ts +32 -0
- package/src/png.ts +196 -0
- package/src/ports.ts +25 -0
- package/src/project-layout.ts +189 -0
- package/src/quick-keys.ts +68 -0
- package/src/read-json.ts +67 -0
- package/src/read-opts.ts +125 -0
- package/src/read-plan.ts +105 -0
- package/src/read-run.ts +119 -0
- package/src/route.ts +243 -0
- package/src/seed-components.ts +229 -0
- package/src/served-session.ts +347 -0
- package/src/server-core.ts +1093 -0
- package/src/settings.ts +156 -0
- package/src/source-stamp.ts +16 -0
- package/src/staleness.ts +63 -0
- package/src/stdio.ts +156 -0
- package/src/svg-normalize.ts +205 -0
- package/src/timeouts.ts +76 -0
- package/src/timings.ts +118 -0
- package/src/token-store.ts +261 -0
- package/src/variables-merge.ts +337 -0
- package/src/verbs.ts +140 -0
- package/src/write-out.ts +1255 -0
package/src/route.ts
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
// route.ts — the one way a front-end reaches the Figma plugin, whoever holds the bridge.
|
|
2
|
+
//
|
|
3
|
+
// There are two ways to reach a plugin, and so two adapters behind one interface (Upstream):
|
|
4
|
+
// - own: this process holds the bridge (server-core's createBridge) — the CLI with no daemon running, or
|
|
5
|
+
// the MCP server that bound the port first;
|
|
6
|
+
// - daemon: another process holds it and serves it on the daemon socket (daemon.ts) — any dtwin command
|
|
7
|
+
// while `dtwin serve` runs, or a second MCP server.
|
|
8
|
+
// The adapters only translate shapes. The RULES sit above them, in the Route, so the CLI and the MCP server
|
|
9
|
+
// cannot drift apart again (they had: a named-client wait done two ways, a stall check only one of them ran,
|
|
10
|
+
// the listClients/connectionInfo fallbacks copied). A route owns:
|
|
11
|
+
// - the connect window: how long a request waits for a plugin (and for a named file, `namedWaitMs`, which is
|
|
12
|
+
// the same window unless the policy says otherwise) before it is resolved against whatever is connected.
|
|
13
|
+
// A one-shot route over its own bridge spends it once, up front (connect()); every other route spends it
|
|
14
|
+
// per request (the daemon waits inside each one);
|
|
15
|
+
// - the never-connected guard: a one-shot route whose window ran out with no plugin EVER connected says so
|
|
16
|
+
// in its own words, rather than resolveClient's "not connected" (which is right for one that left);
|
|
17
|
+
// - the stall check (server-core request `stallMs`): one-shot, own bridge, export-class commands
|
|
18
|
+
// only — the case where a freshly opened bridge can sit on a plugin too busy to answer at all. A daemon's
|
|
19
|
+
// or a long-lived server's connection is warm, so they never pay for it;
|
|
20
|
+
// - progress: only an export-class command hears ticks (a ping or a page list has none worth hearing);
|
|
21
|
+
// - listClients and connectionInfo, from whichever side holds the bridge.
|
|
22
|
+
// MCP's sharing layer (figma-mcp.ts holder) decides per call WHICH upstream to use; a route takes that
|
|
23
|
+
// decision as a function, so it re-resolves exactly when the sharing layer does.
|
|
24
|
+
//
|
|
25
|
+
// Side-effect free, like daemon.ts; only types come from server-core.
|
|
26
|
+
import { TIMEOUTS, daemonGuardMs } from "./timeouts.ts";
|
|
27
|
+
import { ifDefined } from "./json-util.ts";
|
|
28
|
+
import { isExportCmd } from "./commands.ts";
|
|
29
|
+
import type { Cmd, Commands } from "./commands.ts";
|
|
30
|
+
import type { Bridge, ClientRow, ConnectionInfo, ProgressTick } from "./server-core.ts";
|
|
31
|
+
import type { DaemonBridge, DaemonConnection, DaemonStatusView } from "./daemon.ts";
|
|
32
|
+
|
|
33
|
+
/** A connection id as the registry mints it ("c" + a per-bridge sequence number). A target of this form
|
|
34
|
+
* names one socket, so it never depends on identification: waits for a client never hold it for a name. */
|
|
35
|
+
export function isConnId(target: string): boolean {
|
|
36
|
+
return /^c\d+$/.test(target);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* `info` re-described for the connection `connId` — the `clients[]` row with that connId supplies
|
|
41
|
+
* `connId`, `connectedAt` and `connectionUptimeMs` (its `uptimeMs`); every other field (`connected`,
|
|
42
|
+
* `clientsConnected`, `clients`, `connectionsThisRun`, `takeovers`, `lastTakeoverAt`) is kept. A null/absent
|
|
43
|
+
* connId, or one not among `clients`, returns `info` unchanged. Pure — works on a daemon's relayed
|
|
44
|
+
* `status().connection` as well as on the own bridge's connectionInfo().
|
|
45
|
+
*/
|
|
46
|
+
export function connectionFor(info: ConnectionInfo, connId: string | null | undefined): ConnectionInfo {
|
|
47
|
+
if (!connId) return info;
|
|
48
|
+
const row = (info.clients || []).find((c) => c && c.connId === connId);
|
|
49
|
+
if (!row) return info;
|
|
50
|
+
return { ...info, connId: row.connId, connectedAt: row.connectedAt, connectionUptimeMs: row.uptimeMs };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** What a request resolved with, and WHICH connected file answered (null only from a daemon too old to say). */
|
|
54
|
+
export interface Sent<C extends Cmd> { reply: Commands[C]["reply"]; client: ClientRow | null }
|
|
55
|
+
|
|
56
|
+
/** One request as an adapter takes it: no rules applied, every field passed through. */
|
|
57
|
+
export interface UpstreamRequest {
|
|
58
|
+
/** the per-command budget (TIMEOUTS.command when absent) */
|
|
59
|
+
timeoutMs?: number | undefined;
|
|
60
|
+
/** connId / fileKey / file-name substring; absent = the only connected file */
|
|
61
|
+
target?: string | null | undefined;
|
|
62
|
+
/** > 0: wait that long for the target first (server-core awaitClient) */
|
|
63
|
+
waitMs?: number | undefined;
|
|
64
|
+
/** > 0: server-core's stall check (own bridge only; a daemon never runs it) */
|
|
65
|
+
stallMs?: number | undefined;
|
|
66
|
+
onProgress?: ((tick: ProgressTick) => void) | undefined;
|
|
67
|
+
signal?: AbortSignal | undefined;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Who holds the bridge a route reached: this process, or a daemon (its pid; port and pluginConnected kept
|
|
71
|
+
* for a daemon too old to relay its whole connection). */
|
|
72
|
+
export type Owner = { kind: "own" } | { kind: "daemon"; pid: number | null; port: number | null; pluginConnected: boolean | null };
|
|
73
|
+
|
|
74
|
+
/** connectionInfo's answer: the socket half of whoami (null from a daemon too old to relay it), and its holder. */
|
|
75
|
+
export interface RouteConnection { connection: ConnectionInfo | null; owner: Owner }
|
|
76
|
+
|
|
77
|
+
/** The seam. Two adapters: ownUpstream (a bridge this process holds) and daemonUpstream (a daemon's socket). */
|
|
78
|
+
export interface Upstream {
|
|
79
|
+
kind: "own" | "daemon";
|
|
80
|
+
/** Where it is reached: own, the bridge's ws:// URL; daemon, its socket path. For the log line that says so. */
|
|
81
|
+
address: string;
|
|
82
|
+
request<C extends Cmd>(cmd: C, args: Commands[C]["args"], o: UpstreamRequest): Promise<Sent<C>>;
|
|
83
|
+
/** Wait up to `waitMs` for a plugin (and `target`), then say whether any has connected in this bridge's
|
|
84
|
+
* life. A daemon answers null at once: it waits inside each request instead (the frame's waitForConnection). */
|
|
85
|
+
awaitClient(target: string | null | undefined, waitMs: number): Promise<{ everConnected: boolean } | null>;
|
|
86
|
+
listClients(): Promise<ClientRow[]>;
|
|
87
|
+
connectionInfo(connId?: string | null): Promise<RouteConnection>;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** What the own adapter and servedBridge use of a bridge this process holds (server-core's createBridge result) —
|
|
91
|
+
* the narrow face the bridge holder hands them, and what a suite's fake bridge implements. */
|
|
92
|
+
export type HeldBridge = Pick<Bridge, "port" | "isConnected" | "request" | "awaitClient" | "connectionInfo" | "listClients" | "close">;
|
|
93
|
+
|
|
94
|
+
/** The own adapter: the bridge takes the request shape as it is. */
|
|
95
|
+
export function ownUpstream(bridge: HeldBridge): Upstream {
|
|
96
|
+
return {
|
|
97
|
+
kind: "own",
|
|
98
|
+
address: "ws://localhost:" + bridge.port,
|
|
99
|
+
request: (cmd, args, o) => bridge.request(cmd, args, o),
|
|
100
|
+
async awaitClient(target, waitMs) {
|
|
101
|
+
await bridge.awaitClient(target, waitMs);
|
|
102
|
+
return { everConnected: bridge.connectionInfo().connectionsThisRun > 0 };
|
|
103
|
+
},
|
|
104
|
+
listClients: async () => bridge.listClients(),
|
|
105
|
+
connectionInfo: async (connId) => ({ connection: bridge.connectionInfo(connId ?? undefined), owner: { kind: "own" } }),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The daemon adapter: a request becomes a socket frame (the connect window rides on it as waitForConnection,
|
|
110
|
+
* and the outer guard is sized for it — daemonGuardMs); listings come from the daemon's __status. */
|
|
111
|
+
export function daemonUpstream(conn: DaemonConnection, status: () => Promise<DaemonStatusView | null>): Upstream {
|
|
112
|
+
return {
|
|
113
|
+
kind: "daemon",
|
|
114
|
+
address: conn.sock,
|
|
115
|
+
request(cmd, args, o) {
|
|
116
|
+
const timeoutMs = o.timeoutMs ?? TIMEOUTS.command;
|
|
117
|
+
const waitMs = o.waitMs && o.waitMs > 0 ? o.waitMs : 0;
|
|
118
|
+
return conn.request({ cmd, args, timeoutMs, ...ifDefined("client", o.target ?? undefined), ...(waitMs > 0 ? { waitForConnection: waitMs } : {}) },
|
|
119
|
+
{ guardMs: daemonGuardMs(timeoutMs, waitMs), onProgress: o.onProgress, signal: o.signal });
|
|
120
|
+
},
|
|
121
|
+
awaitClient: async () => null,
|
|
122
|
+
listClients: async () => (await status())?.clients || [],
|
|
123
|
+
async connectionInfo(connId) {
|
|
124
|
+
// An older daemon may omit fields (daemon.ts DaemonStatusView) — reported as null, never guessed.
|
|
125
|
+
const st = await status();
|
|
126
|
+
const owner: Owner = { kind: "daemon", pid: st?.pid ?? null, port: st?.port ?? null, pluginConnected: st?.pluginConnected ?? null };
|
|
127
|
+
return { connection: st?.connection ? connectionFor(st.connection, connId) : null, owner };
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** What daemon.serve() drives, made from a bridge this process holds: requests go through the own adapter.
|
|
133
|
+
* `close` replaces the bridge's own (the bridge holder's, which also forgets the bridge). */
|
|
134
|
+
export function servedBridge(bridge: HeldBridge, close: () => void = () => bridge.close()): DaemonBridge {
|
|
135
|
+
return {
|
|
136
|
+
port: bridge.port,
|
|
137
|
+
isConnected: () => bridge.isConnected(),
|
|
138
|
+
request: ownUpstream(bridge).request, // a frame's args as they arrived (absent forwarded as absent)
|
|
139
|
+
close,
|
|
140
|
+
listClients: () => bridge.listClients(),
|
|
141
|
+
connectionInfo: () => bridge.connectionInfo(),
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export interface RoutePolicy {
|
|
146
|
+
/** How long a request waits for a plugin to connect before it is resolved against whatever is there. */
|
|
147
|
+
connectWaitMs: number;
|
|
148
|
+
/** The CLI with no daemon: a bridge opened for one command. Spends the window once, up front, and is the
|
|
149
|
+
* one case the stall check and the never-connected guard are for. */
|
|
150
|
+
oneShot: boolean;
|
|
151
|
+
/** The stall window (one-shot, own bridge, export-class only); FIGMA_BRIDGE_STALL_MS, else 20 s. */
|
|
152
|
+
stallMs?: number;
|
|
153
|
+
/** The connect window for a NAMED target (a file name or fileKey, not a connId); `connectWaitMs` when absent.
|
|
154
|
+
* The CLI passes none: its `--timeout` is the window for every target, named or not (and server-core's
|
|
155
|
+
* awaitClient already caps a name at NAMED_CLIENT_WAIT_MS once a plugin is connected). The MCP server, whose
|
|
156
|
+
* connectWaitMs is 0, passes NAMED_CLIENT_WAIT_MS: every open window redials on its own clock, so the file
|
|
157
|
+
* asked for may land a moment after another. */
|
|
158
|
+
namedWaitMs?: number;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export interface RouteRequest {
|
|
162
|
+
timeoutMs?: number | undefined;
|
|
163
|
+
target?: string | null | undefined;
|
|
164
|
+
/** Heard only by an export-class command. */
|
|
165
|
+
onProgress?: ((tick: ProgressTick) => void) | undefined;
|
|
166
|
+
signal?: AbortSignal | undefined;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export interface Route {
|
|
170
|
+
/** Spend the connect window (a one-shot route over its own bridge; otherwise nothing to wait for here) —
|
|
171
|
+
* once: later calls answer the first one's result. Requests and listClients call it themselves; a caller
|
|
172
|
+
* calls it first only to time the wait. `target` is what that first wait waits for. */
|
|
173
|
+
connect(target?: string | null): Promise<{ waitedMs: number }>;
|
|
174
|
+
request<C extends Cmd>(cmd: C, args: Commands[C]["args"], o?: RouteRequest): Promise<Sent<C>>;
|
|
175
|
+
listClients(): Promise<ClientRow[]>;
|
|
176
|
+
/** The socket half of whoami for `connId` (the connection a request was answered by), and who holds it. */
|
|
177
|
+
connectionInfo(connId?: string | null): Promise<RouteConnection>;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** The window's text for the never-connected guard. */
|
|
181
|
+
const neverConnected = (windowMs: number): Error => new Error(
|
|
182
|
+
`no Figma plugin connected within ${Math.round(windowMs / 1000)}s. In Figma DESKTOP open the file and run Plugins → Development → Design Twin, then run this again (--timeout <seconds> waits longer; \`dtwin doctor\` says what is wrong if it still won't connect).`,
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
const stallFromEnv = (): number => Number(process.env.FIGMA_BRIDGE_STALL_MS) || 20000;
|
|
186
|
+
|
|
187
|
+
/** A route over whatever `upstream` answers (called per use: MCP's sharing layer re-resolves the holder). */
|
|
188
|
+
export function createRoute(upstream: () => Promise<Upstream>, policy: RoutePolicy): Route {
|
|
189
|
+
const namedWaitMs = policy.namedWaitMs ?? policy.connectWaitMs;
|
|
190
|
+
const stallMs = policy.stallMs ?? stallFromEnv();
|
|
191
|
+
let connected: Promise<{ waitedMs: number; everConnected: boolean | null }> | null = null;
|
|
192
|
+
|
|
193
|
+
// The connect window a request carries: namedWaitMs for a named file, the policy's own for anything else.
|
|
194
|
+
const windowFor = (target: string | null | undefined): number => {
|
|
195
|
+
const named = !!target && !isConnId(target);
|
|
196
|
+
return named ? namedWaitMs : policy.connectWaitMs;
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
function spend(up: Upstream, target: string | null | undefined) {
|
|
200
|
+
return (connected ??= (async () => {
|
|
201
|
+
if (!policy.oneShot || up.kind !== "own") return { waitedMs: 0, everConnected: null };
|
|
202
|
+
const t0 = Date.now();
|
|
203
|
+
const r = await up.awaitClient(target, policy.connectWaitMs);
|
|
204
|
+
return { waitedMs: Date.now() - t0, everConnected: r ? r.everConnected : null };
|
|
205
|
+
})());
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
async connect(target) {
|
|
210
|
+
const { waitedMs } = await spend(await upstream(), target);
|
|
211
|
+
return { waitedMs };
|
|
212
|
+
},
|
|
213
|
+
async request(cmd, args, o = {}) {
|
|
214
|
+
const up = await upstream();
|
|
215
|
+
// The `export` run trait (commands.ts): the commands that walk a tree and can run for minutes.
|
|
216
|
+
const exportClass = isExportCmd(cmd);
|
|
217
|
+
// A one-shot route over its own bridge has spent its window up front: a plugin that connected and has
|
|
218
|
+
// gone since is resolveClient's to report, at once — not another window.
|
|
219
|
+
const spentUpFront = policy.oneShot && up.kind === "own";
|
|
220
|
+
if (spentUpFront) {
|
|
221
|
+
const c = await spend(up, o.target);
|
|
222
|
+
if (c.everConnected === false) throw neverConnected(policy.connectWaitMs);
|
|
223
|
+
}
|
|
224
|
+
return up.request(cmd, args, {
|
|
225
|
+
timeoutMs: o.timeoutMs,
|
|
226
|
+
target: o.target,
|
|
227
|
+
waitMs: spentUpFront ? 0 : windowFor(o.target),
|
|
228
|
+
stallMs: spentUpFront && exportClass ? stallMs : undefined,
|
|
229
|
+
onProgress: exportClass ? o.onProgress : undefined,
|
|
230
|
+
signal: o.signal,
|
|
231
|
+
});
|
|
232
|
+
},
|
|
233
|
+
async listClients() {
|
|
234
|
+
const up = await upstream();
|
|
235
|
+
// No plugin within the window is this listing's legitimate "none", not the never-connected error.
|
|
236
|
+
if (policy.oneShot) await spend(up, null);
|
|
237
|
+
return up.listClients();
|
|
238
|
+
},
|
|
239
|
+
async connectionInfo(connId) {
|
|
240
|
+
return (await upstream()).connectionInfo(connId);
|
|
241
|
+
},
|
|
242
|
+
};
|
|
243
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
#!/usr/bin/env -S bun --no-install --no-env-file
|
|
2
|
+
// seed-components — pre-populate design/components.json from Code Connect files in the codebase.
|
|
3
|
+
//
|
|
4
|
+
// Code Connect files ARE the Figma-component -> code-component mapping you'd otherwise hand-author,
|
|
5
|
+
// and they're plain source files, so reading them is FREE (no MCP, no REST, no paid plan). This turns
|
|
6
|
+
// components.json from fully hand-authored into mostly-generated; you only fill prop maps by hand.
|
|
7
|
+
//
|
|
8
|
+
// It recognizes all three shapes Code Connect uses:
|
|
9
|
+
// 1) Template files (.figma.js/.ts) — `// url=` + `// component=` + `// source=` header comments
|
|
10
|
+
// (the format the current Figma CLI/MCP emit; gives component + source DIRECTLY, most reliable).
|
|
11
|
+
// 2) Legacy React — `figma.connect(Component, "https://…node-id=1-2", {…})`.
|
|
12
|
+
// 3) SwiftUI/Compose — `FigmaConnect` with `let component = Button.self` / `component = Button::class`.
|
|
13
|
+
//
|
|
14
|
+
// Usage:
|
|
15
|
+
// dtwin seed [codeRoot=.] [outDir=design] [--dry-run] (routed by figma-pull.ts)
|
|
16
|
+
// bun bridge/src/seed-components.ts [codeRoot=.] [outDir=design] [--dry-run] (the same, run directly)
|
|
17
|
+
//
|
|
18
|
+
// Merges into design/components.json fill-missing-only (never clobbers hand-authored fields) and, for
|
|
19
|
+
// forms that carry only a node-id, resolves it to the Figma component NAME via the export's design-system.json
|
|
20
|
+
// (design/export/, or design/ itself on the older flat layout).
|
|
21
|
+
|
|
22
|
+
import fs from "node:fs";
|
|
23
|
+
import path from "node:path";
|
|
24
|
+
import { ID, parseNodeId } from "./node-id.ts";
|
|
25
|
+
import { ifDefined, isRecord, nullProto } from "./json-util.ts";
|
|
26
|
+
import { exportInDesignDir, readComponentCatalog } from "./export-reader.ts";
|
|
27
|
+
import { writeJsonLike } from "./json-file.ts";
|
|
28
|
+
import { exitCli, runCli } from "./stdio.ts";
|
|
29
|
+
import { SEED_HELP } from "./verbs.ts"; // the one help text, shared with `dtwin seed --help`
|
|
30
|
+
|
|
31
|
+
const SKIP_DIRS = new Set(["node_modules", ".git", "dist", "build", ".next", "out", "vendor", "Pods", ".build"]);
|
|
32
|
+
const CODE_EXT = /\.(tsx?|jsx?|mjs|cjs|swift|kt|kts)$/;
|
|
33
|
+
|
|
34
|
+
// Hoisted: extractMappings runs once per candidate file and `walk` enumerates the whole repo tree, so
|
|
35
|
+
// building these per call recompiled three regexes thousands of times. Safe to share — every one is
|
|
36
|
+
// driven by a `while ((m = re.exec(text)))` loop that runs until exec returns null, which resets
|
|
37
|
+
// lastIndex to 0.
|
|
38
|
+
const URL_COMMENT_RE = new RegExp("\\/\\/\\s*url=(\\S*node-id=" + ID + "\\S*)", "g");
|
|
39
|
+
const JS_CONNECT_RE = new RegExp("figma\\.connect\\(\\s*([A-Za-z0-9_$.]+)?\\s*,?\\s*[\"'`]([^\"'`]*node-id=" + ID + "[^\"'`]*)[\"'`]", "g");
|
|
40
|
+
const NATIVE_URL_RE = new RegExp("[\"'`]([^\"'`]*node-id=" + ID + "[^\"'`]*)[\"'`]", "g");
|
|
41
|
+
const NATIVE_COMP_RE = /component\s*=\s*([A-Za-z0-9_]+)\s*(?:\.self|::class)/g;
|
|
42
|
+
|
|
43
|
+
function walk(dir: string, hits: string[]): void {
|
|
44
|
+
let entries: fs.Dirent[] = [];
|
|
45
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
46
|
+
for (const e of entries) {
|
|
47
|
+
const full = path.join(dir, e.name);
|
|
48
|
+
if (e.isDirectory()) {
|
|
49
|
+
if (!SKIP_DIRS.has(e.name)) walk(full, hits);
|
|
50
|
+
} else if (CODE_EXT.test(e.name)) {
|
|
51
|
+
hits.push(full);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** One Code Connect mapping found in a source file. */
|
|
57
|
+
// `| undefined`: an optional regex capture (JS_CONNECT_RE's identifier) or a `(match || [])[1]` read is
|
|
58
|
+
// assigned straight in; every reader tests these fields for truthiness, so undefined and absent read alike.
|
|
59
|
+
interface Mapping { identifier?: string | undefined; url: string; source?: string | undefined }
|
|
60
|
+
|
|
61
|
+
// Extract { identifier?, url, source? } mappings from one file's text, across all three shapes.
|
|
62
|
+
function extractMappings(text: string): Mapping[] {
|
|
63
|
+
const out: Mapping[] = [];
|
|
64
|
+
const seen = new Set<string>();
|
|
65
|
+
const push = (o: Mapping) => { if (o.url && !seen.has(o.url)) { seen.add(o.url); out.push(o); } };
|
|
66
|
+
|
|
67
|
+
// 1) Template files: header comments. `url=` may be a placeholder (documentUrlSubstitutions),
|
|
68
|
+
// so key off node-id, not scheme. component/source are declared right there — no name lookup needed.
|
|
69
|
+
let m: RegExpExecArray | null;
|
|
70
|
+
while ((m = URL_COMMENT_RE.exec(text))) {
|
|
71
|
+
const win = text.slice(Math.max(0, m.index - 600), m.index + 600); // comments cluster together
|
|
72
|
+
const comp = (win.match(/\/\/\s*component=([^\s]+)/) || [])[1];
|
|
73
|
+
const src = (win.match(/\/\/\s*source=([^\s]+)/) || [])[1];
|
|
74
|
+
const url = m[1];
|
|
75
|
+
if (url === undefined) continue; // cannot happen: group 1 is not optional, so a match always sets it
|
|
76
|
+
push({ identifier: comp, url, source: src });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// 2) Legacy React call form.
|
|
80
|
+
while ((m = JS_CONNECT_RE.exec(text))) {
|
|
81
|
+
const url = m[2];
|
|
82
|
+
if (url === undefined) continue; // cannot happen: group 2 is not optional (group 1, the identifier, is)
|
|
83
|
+
push({ identifier: m[1], url });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// 3) SwiftUI / Compose: the real component is `component = X.self` / `X::class`, NOT the wrapper type.
|
|
87
|
+
if (/FigmaConnect/.test(text)) {
|
|
88
|
+
const comps: Array<{ name: string; index: number }> = [];
|
|
89
|
+
let c: RegExpExecArray | null;
|
|
90
|
+
while ((c = NATIVE_COMP_RE.exec(text))) {
|
|
91
|
+
const name = c[1];
|
|
92
|
+
if (name !== undefined) comps.push({ name, index: c.index }); // always set: group 1 is not optional
|
|
93
|
+
}
|
|
94
|
+
while ((m = NATIVE_URL_RE.exec(text))) {
|
|
95
|
+
// nearest `component = X.self` to this URL; fall back to the enclosing type name.
|
|
96
|
+
let best: string | undefined, bestD = Infinity;
|
|
97
|
+
for (const cc of comps) { const d = Math.abs(cc.index - m.index); if (d < bestD) { bestD = d; best = cc.name; } }
|
|
98
|
+
if (!best) {
|
|
99
|
+
const before = text.slice(Math.max(0, m.index - 500), m.index);
|
|
100
|
+
const t = (before.match(/(?:struct|class|object)\s+([A-Za-z0-9_]+)/g) || []).pop();
|
|
101
|
+
if (t) best = t.split(/\s+/).pop();
|
|
102
|
+
}
|
|
103
|
+
const url = m[1];
|
|
104
|
+
if (url === undefined) continue; // cannot happen: group 1 is not optional
|
|
105
|
+
push({ ...ifDefined("identifier", best), url });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function loadJson(p: string): unknown {
|
|
112
|
+
try { return JSON.parse(fs.readFileSync(p, "utf8")) as unknown; } catch { return undefined; }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// id -> component name, from an existing export (optional; only needed for Code Connect forms that
|
|
116
|
+
// give a node-id URL but no component identifier).
|
|
117
|
+
//
|
|
118
|
+
// The catalog is export-reader.ts readComponentCatalog's — design-system.json's `files.componentsLocal` pointer (inside
|
|
119
|
+
// the export only), else the inline components[] of an export from before the design-system split — so a node-id-only
|
|
120
|
+
// mapping is never silently dropped because the slim manifest has no `components` of its own (the silent-wrong-answer
|
|
121
|
+
// failure design-to-code/catalog-input.ts exists to prevent). The export is the one the design dir holds (design/export,
|
|
122
|
+
// or design/ itself on the older flat layout). `problem` is why an export that IS there could not be read (none when
|
|
123
|
+
// nothing is exported); only a row whose `id` AND `name` are strings can be a lookup entry.
|
|
124
|
+
function loadIdToName(designDir: string): { idToName: Map<string, string>; problem: string | null } {
|
|
125
|
+
const idToName = new Map<string, string>();
|
|
126
|
+
const catalog = readComponentCatalog(exportInDesignDir(designDir));
|
|
127
|
+
if ("error" in catalog) return { idToName, problem: catalog.missing ? null : catalog.error };
|
|
128
|
+
for (const c of catalog.components) {
|
|
129
|
+
if (!isRecord(c)) continue;
|
|
130
|
+
const { id, name } = c;
|
|
131
|
+
if (typeof id === "string" && id && typeof name === "string") idToName.set(id, name);
|
|
132
|
+
}
|
|
133
|
+
return { idToName, problem: null };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** One components.json entry (hand-authored or seeded). */
|
|
137
|
+
export interface ComponentsEntry { component?: unknown; source?: unknown; nodeId?: unknown; import?: unknown; props?: unknown }
|
|
138
|
+
|
|
139
|
+
interface SeedOptions { codeRoot: string; outDir: string; dryRun: boolean }
|
|
140
|
+
|
|
141
|
+
// argv is everything AFTER the command: `process.argv.slice(2)` when run directly, `argv.slice(1)` of
|
|
142
|
+
// `dtwin seed …`. Positions are unchanged from the original script: [codeRoot=.] [outDir=design], with
|
|
143
|
+
// an empty string falling back to the default exactly as `argv[n] || default` always did. A help probe
|
|
144
|
+
// wins over everything else and has no side effects; an unknown flag is refused instead of being taken
|
|
145
|
+
// as a codeRoot spelled `--something`.
|
|
146
|
+
export function main(argv: string[]): void {
|
|
147
|
+
if (argv.some((a) => a === "--help" || a === "-h")) {
|
|
148
|
+
console.log(SEED_HELP);
|
|
149
|
+
exitCli(0);
|
|
150
|
+
}
|
|
151
|
+
let dryRun = false;
|
|
152
|
+
const positional: string[] = [];
|
|
153
|
+
for (const a of argv) {
|
|
154
|
+
if (a === "--dry-run") dryRun = true;
|
|
155
|
+
else if (a.startsWith("-") && a !== "-") {
|
|
156
|
+
console.error(`[seed-components] error: unknown flag ${a} — use: dtwin seed [codeRoot] [outDir] [--dry-run]`);
|
|
157
|
+
exitCli(2);
|
|
158
|
+
} else positional.push(a);
|
|
159
|
+
}
|
|
160
|
+
run({ codeRoot: positional[0] || ".", outDir: positional[1] || "design", dryRun });
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function run({ codeRoot, outDir, dryRun }: SeedOptions): void {
|
|
164
|
+
const { idToName, problem } = loadIdToName(outDir);
|
|
165
|
+
if (problem) console.error(`[seed-components] warning: ${problem} — node-id-only mappings cannot be named`);
|
|
166
|
+
|
|
167
|
+
const files: string[] = [];
|
|
168
|
+
walk(codeRoot, files);
|
|
169
|
+
|
|
170
|
+
const found: Array<{ name: string; identifier: string | undefined; nodeId: string | undefined; source: string }> = [];
|
|
171
|
+
for (const f of files) {
|
|
172
|
+
let text = "";
|
|
173
|
+
try { text = fs.readFileSync(f, "utf8"); } catch { continue; }
|
|
174
|
+
// Gate: a Code Connect signal of any kind — a node-id URL, the call form, or the native marker.
|
|
175
|
+
if (text.indexOf("node-id=") === -1 && text.indexOf("figma.connect") === -1 && text.indexOf("FigmaConnect") === -1) continue;
|
|
176
|
+
for (const mp of extractMappings(text)) {
|
|
177
|
+
const nodeId = parseNodeId(mp.url);
|
|
178
|
+
const name = (nodeId && idToName.get(nodeId)) || mp.identifier;
|
|
179
|
+
if (!name) continue;
|
|
180
|
+
found.push({ name, identifier: mp.identifier, nodeId, source: mp.source || path.relative(codeRoot, f) });
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (!found.length) {
|
|
185
|
+
console.error("[seed-components] no Code Connect mappings found under " + path.resolve(codeRoot) + ".");
|
|
186
|
+
console.error("[seed-components] (looked for `// url=`+`// component=` templates, `figma.connect(...)`, and `@FigmaConnect`.)");
|
|
187
|
+
exitCli(0);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const outPath = path.join(outDir, "components.json");
|
|
191
|
+
// Null-prototype: this map is keyed by UNTRUSTED component names. On a plain object a component
|
|
192
|
+
// named "__proto__" (or "constructor"/"toString") resolves to an inherited Object.prototype member,
|
|
193
|
+
// so the entry looks like it already exists — it is silently dropped from components.json AND the
|
|
194
|
+
// `cur.x = ...` backfills below land on Object.prototype, after which every OTHER entry's
|
|
195
|
+
// `=== undefined` check is false and its source/nodeId are never filled either. Object.assign onto
|
|
196
|
+
// a null-prototype target turns those names back into ordinary own keys. JSON.stringify is unaffected.
|
|
197
|
+
const existing: Record<string, ComponentsEntry | undefined> = Object.assign(nullProto<ComponentsEntry | undefined>(), loadJson(outPath) || {});
|
|
198
|
+
let added = 0, filled = 0;
|
|
199
|
+
for (const m of found) {
|
|
200
|
+
const cur = existing[m.name];
|
|
201
|
+
if (!cur) {
|
|
202
|
+
existing[m.name] = {
|
|
203
|
+
component: m.identifier || m.name,
|
|
204
|
+
source: m.source,
|
|
205
|
+
nodeId: m.nodeId,
|
|
206
|
+
import: "", // yours to fill — codegen reuses the component once you do
|
|
207
|
+
props: {},
|
|
208
|
+
};
|
|
209
|
+
added++;
|
|
210
|
+
} else {
|
|
211
|
+
if (cur.source === undefined) { cur.source = m.source; filled++; }
|
|
212
|
+
if (cur.nodeId === undefined && m.nodeId) { cur.nodeId = m.nodeId; filled++; }
|
|
213
|
+
if (cur.component === undefined) cur.component = m.identifier || m.name;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// --dry-run: everything above only READ; the merge result is computed in memory and dropped here.
|
|
218
|
+
const dry = dryRun ? "(dry run, nothing written) " : "";
|
|
219
|
+
if (!dryRun) {
|
|
220
|
+
writeJsonLike(outPath, existing); // a person's hand-edited map: atomic, in its own format
|
|
221
|
+
}
|
|
222
|
+
console.error(`[seed-components] ${dry}${found.length} mapping(s) from ${files.length} scanned file(s) → ${outPath}`);
|
|
223
|
+
console.error(`[seed-components] ${dry}${added} new entr(ies), ${filled} field(s) filled. Fill in each entry's "import" and "props".`);
|
|
224
|
+
if (!idToName.size && !problem) console.error("[seed-components] tip: export design-system.json first so node-id-only mappings resolve to real component names.");
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Only when RUN directly (it is a CLI, driven by the tests as a subprocess). `dtwin seed` imports this
|
|
228
|
+
// module and calls main() itself, so the guard is what keeps that import from also scanning the tree.
|
|
229
|
+
if (import.meta.main) runCli((argv) => { main(argv); return Number(process.exitCode ?? 0); }, { name: "[seed-components] error" });
|