@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/settings.ts
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// settings.ts — what a bridge process is configured with, resolved ONCE at its entry (the dtwin CLI, the MCP
|
|
2
|
+
// server, doctor) and passed down: into the bridge holder (bridge-holder.ts), the bridge itself (server-core.ts
|
|
3
|
+
// createBridge), the daemon socket it serves or looks up (daemon.ts), and nothing else reads the environment for
|
|
4
|
+
// these again. No load-time effects, and nothing here exits: a bad value is a result the entry decides about.
|
|
5
|
+
//
|
|
6
|
+
// port FIGMA_BRIDGE_PORT — one of the three the plugin can reach (ports.ts), else a problem.
|
|
7
|
+
// token the effective bridge token and its source, by token-store.ts's precedence (--token-file >
|
|
8
|
+
// FIGMA_BRIDGE_TOKEN > the saved file > mint). Resolved without persisting: the bridge holder commits
|
|
9
|
+
// it (commitToken) once, before it first binds — a minted one is saved then, and the notice printed.
|
|
10
|
+
// A resolution that FAILS (an unreadable --token-file) is kept as that failure, not thrown: commands
|
|
11
|
+
// that never open a bridge (--token-status) still run, which is exactly when you want to diagnose
|
|
12
|
+
// it; the bridge or daemon that needs the token reports it.
|
|
13
|
+
// maxPayload FIGMA_BRIDGE_MAX_PAYLOAD_MB — the per-frame cap (maxPayloadFromEnv).
|
|
14
|
+
// idleMs FIGMA_DAEMON_IDLE_MIN — how long a shared bridge stays up with nothing using it (idleMsFromEnv).
|
|
15
|
+
// ephemeralPort DT_TEST_EPHEMERAL_PORT=1 — the TEST SEAM: bind an ephemeral port instead of `port` (server-core).
|
|
16
|
+
// runtimeDir XDG_RUNTIME_DIR — where the daemon socket lives when private (daemonAddressOf; daemon.ts never reads env).
|
|
17
|
+
//
|
|
18
|
+
// --token-file used to reach server-core by being written into process.env before a dynamic import, because the
|
|
19
|
+
// token was resolved at that module's load. It is now an argument here.
|
|
20
|
+
import * as tokenStore from "./token-store.ts";
|
|
21
|
+
import type { ResolvedToken } from "./token-store.ts";
|
|
22
|
+
import { parsePortEnv, portProblem } from "./ports.ts";
|
|
23
|
+
import os from "node:os";
|
|
24
|
+
import { daemonAddress } from "./daemon.ts";
|
|
25
|
+
import type { DaemonAddress } from "./daemon.ts";
|
|
26
|
+
|
|
27
|
+
/** The effective token, or why none could be resolved. */
|
|
28
|
+
export type TokenSetting = { ok: true; resolved: ResolvedToken } | { ok: false; error: unknown };
|
|
29
|
+
|
|
30
|
+
export interface MaxPayload { mb: number; bytes: number; warning?: string }
|
|
31
|
+
|
|
32
|
+
export interface BridgeSettings {
|
|
33
|
+
readonly port: number;
|
|
34
|
+
readonly token: TokenSetting;
|
|
35
|
+
readonly maxPayload: MaxPayload;
|
|
36
|
+
readonly idleMs: number;
|
|
37
|
+
/** FIGMA_DAEMON_IDLE_MIN as given (undefined when unset), for a message that says why the window is off */
|
|
38
|
+
readonly idleRaw: string | undefined;
|
|
39
|
+
readonly ephemeralPort: boolean;
|
|
40
|
+
/** XDG_RUNTIME_DIR — where the daemon socket lives when it is private to this user (daemon.ts daemonAddress) */
|
|
41
|
+
readonly runtimeDir?: string | undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface SettingsInput {
|
|
45
|
+
/** the environment to read (defaults to process.env). token-store itself reads FIGMA_BRIDGE_TOKEN and the saved
|
|
46
|
+
* file's location from process.env, so only FIGMA_BRIDGE_TOKEN_FILE of the token's inputs comes from here. */
|
|
47
|
+
env?: NodeJS.ProcessEnv;
|
|
48
|
+
/** `--token-file` (wins over FIGMA_BRIDGE_TOKEN_FILE in `env`) */
|
|
49
|
+
tokenFile?: string | null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type SettingsResult = { ok: true; settings: BridgeSettings } | { ok: false; problem: string };
|
|
53
|
+
|
|
54
|
+
/** Every setting, or the one problem that leaves a bridge here nothing to do (a port no plugin can reach). */
|
|
55
|
+
export function readSettings({ env = process.env, tokenFile }: SettingsInput = {}): SettingsResult {
|
|
56
|
+
const p = parsePortEnv(env.FIGMA_BRIDGE_PORT);
|
|
57
|
+
if (p.port === undefined) return { ok: false, problem: portProblem(p.invalid) };
|
|
58
|
+
return {
|
|
59
|
+
ok: true,
|
|
60
|
+
settings: {
|
|
61
|
+
port: p.port,
|
|
62
|
+
token: tokenSetting({ env, ...(tokenFile !== undefined ? { tokenFile } : {}) }),
|
|
63
|
+
maxPayload: maxPayloadFromEnv(env.FIGMA_BRIDGE_MAX_PAYLOAD_MB),
|
|
64
|
+
idleMs: idleMsFromEnv(env),
|
|
65
|
+
idleRaw: env.FIGMA_DAEMON_IDLE_MIN,
|
|
66
|
+
ephemeralPort: env.DT_TEST_EPHEMERAL_PORT === "1",
|
|
67
|
+
runtimeDir: env.XDG_RUNTIME_DIR,
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** THE token resolution every caller goes through: `tokenFile` (the flag) or FIGMA_BRIDGE_TOKEN_FILE, then
|
|
73
|
+
* token-store's precedence. Never persists, never throws. */
|
|
74
|
+
export function tokenSetting({ env = process.env, tokenFile }: SettingsInput = {}): TokenSetting {
|
|
75
|
+
const file = tokenFile ?? (env.FIGMA_BRIDGE_TOKEN_FILE || null);
|
|
76
|
+
try {
|
|
77
|
+
return { ok: true, resolved: tokenStore.resolve({ tokenFile: file, persist: false }) };
|
|
78
|
+
} catch (e) {
|
|
79
|
+
return { ok: false, error: e };
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** What committing the settings' token on first use did (token-store.ts commit): the settings as every later bridge
|
|
84
|
+
* and daemon lookup of this process should see them, and the lines to tell the user. */
|
|
85
|
+
export interface TokenCommit {
|
|
86
|
+
readonly settings: BridgeSettings;
|
|
87
|
+
readonly minted: boolean;
|
|
88
|
+
readonly saveFailed: boolean;
|
|
89
|
+
readonly notice: readonly string[];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Commit the token before a bridge first uses it: save a minted one, and say which token is in play (printing it
|
|
93
|
+
* only on the commit that mints it). A resolution that failed commits nothing — the bridge reports it. */
|
|
94
|
+
export function commitToken(s: BridgeSettings): TokenCommit {
|
|
95
|
+
if (!s.token.ok) return { settings: s, minted: false, saveFailed: false, notice: [] };
|
|
96
|
+
const c = tokenStore.commit(s.token.resolved);
|
|
97
|
+
return { settings: { ...s, token: { ok: true, resolved: c.resolved } }, minted: c.minted, saveFailed: c.saveFailed, notice: c.notice };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The token a daemon's pipe is named from and its handshake keys on: the effective one — except a token nothing
|
|
101
|
+
* saved yet (no daemon anyone could have started under it: a bridge commits the token it mints before it serves) and
|
|
102
|
+
* a resolution that failed, both null. */
|
|
103
|
+
export function daemonTokenOf(t: TokenSetting): string | null {
|
|
104
|
+
return t.ok && t.resolved.source !== "ephemeral" ? t.resolved.token : null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** What the daemon's address takes from the machine rather than the settings: the platform (win32 is a pipe), the
|
|
108
|
+
* uid its socket directory must belong to, and the tmpdir that directory falls back under. */
|
|
109
|
+
export interface Host {
|
|
110
|
+
readonly platform: NodeJS.Platform;
|
|
111
|
+
readonly uid: number | undefined;
|
|
112
|
+
readonly tmpdir: string;
|
|
113
|
+
}
|
|
114
|
+
export const thisHost = (): Host => ({ platform: process.platform, uid: process.getuid?.(), tmpdir: os.tmpdir() });
|
|
115
|
+
|
|
116
|
+
/** Where this process's daemon is looked up or served (daemon.ts daemonAddress): at `port` (the settings' own unless
|
|
117
|
+
* given — `dtwin --rotate-token` looks at all three), keyed on the token as these settings have it. Resolved once
|
|
118
|
+
* per settings value: a token committed since (commitToken) is a new settings value, and a new address. */
|
|
119
|
+
export function daemonAddressOf(s: BridgeSettings, port: number = s.port, host: Host = thisHost()): DaemonAddress {
|
|
120
|
+
return daemonAddress({ port, token: daemonTokenOf(s.token), ...host, runtimeDir: s.runtimeDir });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Does something other than the saved token file decide the token (FIGMA_BRIDGE_TOKEN or a token file)? Then
|
|
124
|
+
* replacing or deleting the saved file changes nothing for a running bridge. */
|
|
125
|
+
export function tokenOverridden(t: TokenSetting): boolean {
|
|
126
|
+
return t.ok ? t.resolved.source === "env" || t.resolved.source === "token-file" : true;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const DEFAULT_MAX_PAYLOAD_MB = 128;
|
|
130
|
+
const MiB = 1024 * 1024;
|
|
131
|
+
/** The bridge's per-frame cap from FIGMA_BRIDGE_MAX_PAYLOAD_MB. It bounds a single frame so a malformed/huge payload can't
|
|
132
|
+
* OOM the process; 128 MB is ~2x a realistic asset-heavy full export, and an unusually large file can raise it. Bun.serve
|
|
133
|
+
* takes it as `maxPayloadLength`, an unsigned 32-bit byte count, so a value past 4096 MB is clamped to just under 4 GiB
|
|
134
|
+
* and `mb` says so (the too-big diagnosis quotes it — the cap in force, not the number typed).
|
|
135
|
+
* A value that is not a finite number above 0 falls back to the default, with a `warning`: `ws` read 0 (and below) as
|
|
136
|
+
* "unlimited", but Bun.serve has no unlimited — 0 there refuses every non-empty frame — and a typo must not become either one. */
|
|
137
|
+
export function maxPayloadFromEnv(raw: string | undefined): MaxPayload {
|
|
138
|
+
const v = (raw ?? "").trim();
|
|
139
|
+
if (v === "") return { mb: DEFAULT_MAX_PAYLOAD_MB, bytes: DEFAULT_MAX_PAYLOAD_MB * MiB };
|
|
140
|
+
const n = Number(v);
|
|
141
|
+
if (!Number.isFinite(n) || n <= 0) {
|
|
142
|
+
return { mb: DEFAULT_MAX_PAYLOAD_MB, bytes: DEFAULT_MAX_PAYLOAD_MB * MiB, warning: `FIGMA_BRIDGE_MAX_PAYLOAD_MB=${JSON.stringify(raw)} is not a number of megabytes above 0 — using the default ${DEFAULT_MAX_PAYLOAD_MB} MB (there is no "unlimited")` };
|
|
143
|
+
}
|
|
144
|
+
const bytes = Math.max(1, Math.min(Math.floor(n * MiB), 0xffffffff));
|
|
145
|
+
return { mb: Math.round((bytes / MiB) * 100) / 100, bytes };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** The idle window a shared bridge uses: FIGMA_DAEMON_IDLE_MIN minutes (default 120), in ms. 0 — and an unparsable
|
|
149
|
+
* value, as it always was — disables the idle shutdown. */
|
|
150
|
+
export function idleMsFromEnv(env: NodeJS.ProcessEnv = process.env): number {
|
|
151
|
+
return minutesToMs(env.FIGMA_DAEMON_IDLE_MIN ?? 120);
|
|
152
|
+
}
|
|
153
|
+
export const minutesToMs = (min: unknown): number => Math.max(0, Number(min)) * 60000 || 0;
|
|
154
|
+
|
|
155
|
+
// The idle window for a log line (human-ms.ts, a leaf the served session imports directly).
|
|
156
|
+
export { humanMs } from "./human-ms.ts";
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// source-stamp.ts — which connected Figma file answered a read, stamped onto its reply.
|
|
2
|
+
//
|
|
3
|
+
// A screen export result has no field of its own naming its source file (unlike design-system.json / a
|
|
4
|
+
// library catalog, which the plugin itself stamps), so the caller that knows WHICH client answered — the
|
|
5
|
+
// bridge's request, or a daemon relaying it — stamps it here before the reply reaches
|
|
6
|
+
// write-out.ts, which persists it onto the screen JSON and its index row. read-run.ts stamps every read the CLI and the MCP
|
|
7
|
+
// server run, so both paths write the same field. Unstamped (never guessed) when no client was handed back
|
|
8
|
+
// (a daemon too old to relay it) or the client has no file name yet.
|
|
9
|
+
import type { Stamped } from "./write-out.ts";
|
|
10
|
+
|
|
11
|
+
export function stampSource<R extends object>(sent: { reply: R; client: { file: string | null; fileKey: string | null } | null }): Stamped<R> {
|
|
12
|
+
const r: Stamped<R> = sent.reply;
|
|
13
|
+
const src = sent.client;
|
|
14
|
+
if (src && src.file) { r.sourceFile = src.file; if (src.fileKey) r.sourceFileKey = src.fileKey; }
|
|
15
|
+
return r;
|
|
16
|
+
}
|
package/src/staleness.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// staleness.ts — the plugin/daemon version-staleness notes, split out of server-core.ts so the CLI's
|
|
2
|
+
// pure, test-driven helpers (figma-pull.ts formatClients) and doctor.ts use them without loading the bridge
|
|
3
|
+
// itself. This module's only load-time effect is reading bridge/package.json for
|
|
4
|
+
// BRIDGE_VERSION. Every caller, server-core.ts included, imports the three public names from here.
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
|
|
7
|
+
// The plugin reports a version so that a stale bundle in Figma (several fixes live only in
|
|
8
|
+
// figma-plugin/code.js — SVG normalisation, case-folded asset names) can be told apart from a fresh
|
|
9
|
+
// one — a plugin instance's startedAt and code.js's own mtime can land in the same minute either way.
|
|
10
|
+
// `BRIDGE_VERSION` is this PACKAGE's version (bridge/package.json — the CLI/MCP the user is actually
|
|
11
|
+
// running); `pluginStalenessNote` compares it against whatever the connected plugin
|
|
12
|
+
// announced in its `hello`. A simple numeric [major,minor,patch] compare, not semver ranges: this is an
|
|
13
|
+
// internal tool with one version number moving in one repo, not a published dependency graph.
|
|
14
|
+
export const BRIDGE_VERSION: string | null = (() => {
|
|
15
|
+
try {
|
|
16
|
+
return (JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { version: string }).version;
|
|
17
|
+
} catch (e) { return null; }
|
|
18
|
+
})();
|
|
19
|
+
type Version = [number, number, number];
|
|
20
|
+
function parseVersion(v: unknown): Version | null {
|
|
21
|
+
if (typeof v !== "string") return null;
|
|
22
|
+
const m = /^(\d+)\.(\d+)\.(\d+)/.exec(v);
|
|
23
|
+
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
|
|
24
|
+
}
|
|
25
|
+
function versionOlder(a: Version, b: Version): boolean {
|
|
26
|
+
const [aMajor, aMinor, aPatch] = a;
|
|
27
|
+
const [bMajor, bMinor, bPatch] = b;
|
|
28
|
+
if (aMajor !== bMajor) return aMajor < bMajor;
|
|
29
|
+
if (aMinor !== bMinor) return aMinor < bMinor;
|
|
30
|
+
if (aPatch !== bPatch) return aPatch < bPatch;
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
// `null` means "nothing to warn about" — the plugin reported a version, and it isn't older than this
|
|
34
|
+
// CLI/MCP. Everything else IS worth a warn, including a MISSING version: a real daemon + a real Figma plugin
|
|
35
|
+
// both running code from before version reporting would say nothing if the logic only compared two
|
|
36
|
+
// REAL version strings (`list clients --json` would print `pluginVersion: null, pluginStale: null` for
|
|
37
|
+
// both files) and treated "didn't report one" as "nothing to check," which is backwards: no version at
|
|
38
|
+
// all is the ONE case we can be certain predates this feature, so it is unconditionally worth a
|
|
39
|
+
// warning, not a silent pass.
|
|
40
|
+
export function pluginStalenessNote(pluginVersion: unknown): string | null {
|
|
41
|
+
if (typeof pluginVersion !== "string" || !pluginVersion) {
|
|
42
|
+
return "plugin bundle predates version reporting (or the daemon was started before this bridge was updated) — " +
|
|
43
|
+
"re-run Plugins → Development → Design Twin in Figma and restart `dtwin serve`";
|
|
44
|
+
}
|
|
45
|
+
const p = parseVersion(pluginVersion), b = parseVersion(BRIDGE_VERSION);
|
|
46
|
+
if (!p || !b || !versionOlder(p, b)) return null;
|
|
47
|
+
return `plugin v${pluginVersion} is older than this ${BRIDGE_VERSION} bridge — reload the plugin in Figma (Plugins → Development → Design Twin) to pick up recent fixes`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Distinct from the above: this fires when the CLIENT ROW ITSELF has no `pluginVersion` KEY at all
|
|
51
|
+
// (`"pluginVersion" in row` is false), which is not the same as the key being present and `null`.
|
|
52
|
+
// `describe()` in server-core.ts always sets the key — to a string or explicitly to `null` — for any bridge
|
|
53
|
+
// running this code, so a row with the key entirely absent can only have come from an OLDER daemon's
|
|
54
|
+
// own older `describe()`, read back over its status socket by a NEWER `dtwin` CLI. That is a
|
|
55
|
+
// second, independent kind of staleness this tool can detect: not just "the Figma plugin is old" but
|
|
56
|
+
// "the long-running `dtwin serve` process itself predates this bridge version and needs restarting" —
|
|
57
|
+
// which reloading the Figma plugin alone will not fix, since the daemon in front of it is what's stale.
|
|
58
|
+
export function daemonRowStalenessNote(row: unknown): string | null {
|
|
59
|
+
if (row && typeof row === "object" && !("pluginVersion" in row)) {
|
|
60
|
+
return "the `dtwin serve` daemon in front of this connection predates plugin-version reporting — restart it (`dtwin serve --stop` then `dtwin serve`) to pick up recent fixes";
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
package/src/stdio.ts
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// The queued console and the exit helpers every CLI entry point ends through — the dtwin CLI, the MCP server and the
|
|
2
|
+
// daemon here, and every design-to-code CLI (design-to-code/proc.ts re-exports this module; bridge/src never imports
|
|
3
|
+
// design-to-code, and the published `designtwin` package ships bridge/src alone, so it lives on this side).
|
|
4
|
+
//
|
|
5
|
+
// Bun (1.4.2, Linux too): console.* writes straight to the fd and DROPS what meets a full non-blocking pipe (a slow
|
|
6
|
+
// reader: CI log shipper, `| less`, Claude Code reading a hook's output), and exit() drops whatever is still queued on a
|
|
7
|
+
// pipe. So: console goes through the streams' queue, and a CLI ends on process.exitCode (the process exits once the queue
|
|
8
|
+
// has drained) — or, when something still holds the loop, through exitFlushed. Never a bare process.exit after output.
|
|
9
|
+
import { format, inspect } from "node:util";
|
|
10
|
+
import type { InspectOptions } from "node:util";
|
|
11
|
+
|
|
12
|
+
/** console.* through process.stdout/stderr.write: Bun's console writes straight to the fd and DROPS what meets a full
|
|
13
|
+
* non-blocking pipe, while stream.write() queues it in order. End with process.exitCode (the process then exits once the
|
|
14
|
+
* queue has drained), or after flushStdio() — never a bare exit, which drops the queue. Also counts the streams' writes
|
|
15
|
+
* in flight, which flushStdio waits on. Idempotent. */
|
|
16
|
+
export function installQueuedConsole(o: { logTo?: "stdout" | "stderr" } = {}): void {
|
|
17
|
+
trackWrites(process.stdout);
|
|
18
|
+
trackWrites(process.stderr);
|
|
19
|
+
// logTo "stderr": everything console prints goes to stderr — the MCP server, whose stdout carries nothing but the
|
|
20
|
+
// JSON-RPC its transport writes (a stray console.log there would corrupt the protocol stream).
|
|
21
|
+
const out = o.logTo === "stderr" ? process.stderr : process.stdout;
|
|
22
|
+
const err = process.stderr;
|
|
23
|
+
// console.group indentation, as the built-in console keeps it (two spaces per open group, on every line printed).
|
|
24
|
+
let indent = "";
|
|
25
|
+
const write = (s: NodeJS.WriteStream, text: string): void => { s.write((indent ? text.replace(/^/gm, indent) : text) + "\n"); };
|
|
26
|
+
const line = (s: NodeJS.WriteStream) => (...a: unknown[]): void => { write(s, format(...a)); };
|
|
27
|
+
console.log = console.info = console.debug = line(out);
|
|
28
|
+
console.warn = console.error = line(err);
|
|
29
|
+
// EVERY other console method that prints, so none of them reaches the fd past the queue — or the MCP server's stdout
|
|
30
|
+
// (test/cli-exit.test.ts holds this list to every method the runtime's console has).
|
|
31
|
+
// The text is close to the built-in's (inspect for dir, `Trace: …` + a stack for trace, `label: n` for count,
|
|
32
|
+
// `label: n.nnnms` for the timers), on the stream the built-in uses (stdout, or stderr for trace / assert / a timer
|
|
33
|
+
// warning); console.table prints its data inspected, not as a grid — nothing here tabulates, and a stray call must
|
|
34
|
+
// still never reach the fd past the queue, or the MCP server's stdout.
|
|
35
|
+
console.dir = (item?: unknown, options?: InspectOptions): void => { write(out, inspect(item, { customInspect: false, ...options })); };
|
|
36
|
+
console.dirxml = line(out);
|
|
37
|
+
console.table = (data?: unknown): void => { write(out, inspect(data)); };
|
|
38
|
+
// Bun's own: console.write(...data) is raw bytes to stdout, no newline, no format — returns the byte count.
|
|
39
|
+
console.write = (...data: Array<string | ArrayBufferView | ArrayBuffer>): number => {
|
|
40
|
+
let n = 0;
|
|
41
|
+
for (const d of data) {
|
|
42
|
+
const b = typeof d === "string" ? Buffer.from(d) : d instanceof ArrayBuffer ? Buffer.from(d) : Buffer.from(d.buffer, d.byteOffset, d.byteLength);
|
|
43
|
+
if (b.length) { out.write(b); n += b.length; }
|
|
44
|
+
}
|
|
45
|
+
return n;
|
|
46
|
+
};
|
|
47
|
+
// Only a terminal is cleared (as the built-in does); never a pipe — least of all the MCP server's.
|
|
48
|
+
console.clear = (): void => { if (out.isTTY) out.write("\u001b[1;1H\u001b[0J"); };
|
|
49
|
+
console.trace = (...a: unknown[]): void => {
|
|
50
|
+
const e = new Error(format(...a));
|
|
51
|
+
e.name = "Trace"; // before the capture: the stack's first line is `Trace: <message>`, as the built-in prints
|
|
52
|
+
Error.captureStackTrace(e, console.trace);
|
|
53
|
+
write(err, e.stack ?? `Trace: ${e.message}`);
|
|
54
|
+
};
|
|
55
|
+
console.assert = (cond?: unknown, ...a: unknown[]): void => { if (!cond) write(err, a.length ? "Assertion failed: " + format(...a) : "Assertion failed"); };
|
|
56
|
+
console.group = console.groupCollapsed = (...a: unknown[]): void => { if (a.length) write(out, format(...a)); indent += " "; };
|
|
57
|
+
console.groupEnd = (): void => { indent = indent.slice(0, -2); };
|
|
58
|
+
const counts = new Map<string, number>();
|
|
59
|
+
console.count = (label = "default"): void => { const n = (counts.get(label) ?? 0) + 1; counts.set(label, n); write(out, `${label}: ${n}`); };
|
|
60
|
+
console.countReset = (label = "default"): void => { counts.delete(label); };
|
|
61
|
+
const timers = new Map<string, number>();
|
|
62
|
+
const elapsed = (label: string): string | null => { const t = timers.get(label); return t === undefined ? null : `${label}: ${(performance.now() - t).toFixed(3)}ms`; };
|
|
63
|
+
console.time = (label = "default"): void => { timers.set(label, performance.now()); };
|
|
64
|
+
console.timeLog = (label = "default", ...a: unknown[]): void => { const e = elapsed(label); if (e === null) write(err, `Warning: No such label '${label}' for console.timeLog()`); else write(out, [e, ...a.map((x) => format(x))].join(" ")); };
|
|
65
|
+
console.timeEnd = (label = "default"): void => { const e = elapsed(label); timers.delete(label); write(e === null ? err : out, e ?? `Warning: No such label '${label}' for console.timeEnd()`); };
|
|
66
|
+
}
|
|
67
|
+
// How many non-empty writes each stream has not yet handed to the OS, and who waits for that to reach 0. Bun (1.4.2) keeps
|
|
68
|
+
// no usable writableLength (0 while 400 KB are queued) and calls back an EMPTY write at once, before what was queued ahead of
|
|
69
|
+
// it is out; a non-empty write's callback does come once its bytes are written, in order. So every write is counted (the
|
|
70
|
+
// stream's write wrapped once) and the count says when the queue is empty.
|
|
71
|
+
const inFlight = new Map<NodeJS.WriteStream, { n: number; idle: Array<() => void> }>();
|
|
72
|
+
function trackWrites(s: NodeJS.WriteStream): void {
|
|
73
|
+
if (inFlight.has(s)) return;
|
|
74
|
+
const st = { n: 0, idle: [] as Array<() => void> };
|
|
75
|
+
inFlight.set(s, st);
|
|
76
|
+
const write = s.write.bind(s) as (chunk: string | Uint8Array, encoding?: BufferEncoding, cb?: (err?: Error | null) => void) => boolean;
|
|
77
|
+
const settle = (): void => { if (--st.n === 0) for (const r of st.idle.splice(0)) r(); };
|
|
78
|
+
s.write = (chunk: string | Uint8Array, encoding?: BufferEncoding | ((err?: Error | null) => void), cb?: (err?: Error | null) => void): boolean => {
|
|
79
|
+
const done = typeof encoding === "function" ? encoding : cb;
|
|
80
|
+
const enc = typeof encoding === "function" ? undefined : encoding;
|
|
81
|
+
if (chunk.length === 0) return write(chunk, enc, done);
|
|
82
|
+
st.n++;
|
|
83
|
+
let called = false;
|
|
84
|
+
return write(chunk, enc, (err?: Error | null) => { if (!called) { called = true; settle(); } done?.(err); });
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
/** Resolves once every write made through installQueuedConsole's streams has been handed to the OS (at once when there is
|
|
88
|
+
* none in flight, or the streams are not tracked). */
|
|
89
|
+
export const flushStdio = (): Promise<void> => Promise.all([process.stdout, process.stderr].map((s) => new Promise<void>((r) => {
|
|
90
|
+
const st = inFlight.get(s);
|
|
91
|
+
if (!st || st.n === 0) r(); else st.idle.push(r);
|
|
92
|
+
}))).then(() => undefined);
|
|
93
|
+
|
|
94
|
+
// ---- ending a CLI without losing its output
|
|
95
|
+
// Every CLI entry point (dtwin, seed-components, each design-to-code CLI) runs its main() through runCli: console goes through the queued streams, and the exit
|
|
96
|
+
// code is process.exitCode — the process ends once the queue has drained, never with a bare exit call that drops it
|
|
97
|
+
// (Bun loses what is still queued on a pipe at exit(): a 300 KB report into a slow reader arrived as its first 64 KB).
|
|
98
|
+
// The few library helpers that must end the CLI mid-call (cli-args.ts cliParse, catalog-input.ts's fail-loud readers)
|
|
99
|
+
// call exitCli(): inside runCli it throws CliExit, which unwinds to runCli and becomes the exit code; anywhere else (a
|
|
100
|
+
// suite calling main() in-process, the MCP server importing a module) it exits the process at once, as before.
|
|
101
|
+
// "Inside" is until main() SETTLES: a long-lived server is not a CLI run. `dtwin mcp` awaits the MCP server's module (so
|
|
102
|
+
// an exitCli while it LOADS is still that CLI's exit code, its line out first), starts it (runServer), then returns; from there the server runs exactly as `bun figma-mcp.ts` does, where an exitCli exits at once.
|
|
103
|
+
// One rule for both launches — the server never depends on how it was started.
|
|
104
|
+
/** What exitCli throws inside runCli. Not an error: runCli turns it into the exit code, silently. */
|
|
105
|
+
export class CliExit extends Error {
|
|
106
|
+
readonly code: number;
|
|
107
|
+
constructor(code: number) { super(`exit ${code}`); this.name = "CliExit"; this.code = code; }
|
|
108
|
+
}
|
|
109
|
+
let cliDepth = 0;
|
|
110
|
+
/** End the CLI with `code` after what it already printed (see above). Never returns. */
|
|
111
|
+
export function exitCli(code: number): never {
|
|
112
|
+
if (cliDepth > 0) throw new CliExit(code);
|
|
113
|
+
process.exit(code);
|
|
114
|
+
}
|
|
115
|
+
export interface RunCliOpts {
|
|
116
|
+
/** The prefix of an uncaught error's line on stderr (`<name>: <stack>`). */
|
|
117
|
+
name: string;
|
|
118
|
+
/** An uncaught error's line on stderr instead (e.g. its message without the stack). */
|
|
119
|
+
errorLine?: (e: unknown) => string;
|
|
120
|
+
/** An uncaught error's exit code, after its line (default 1; verifier-guard fails open: 0). */
|
|
121
|
+
errorCode?: number;
|
|
122
|
+
/** What to do with the exit code (default: process.exitCode = code). */
|
|
123
|
+
done?: (code: number) => void;
|
|
124
|
+
}
|
|
125
|
+
/** Run a CLI's main(argv) as the process entry: queued console, exitCli honoured, an uncaught error one line + exit 1. */
|
|
126
|
+
export function runCli(main: (argv: string[]) => number | Promise<number>, o: RunCliOpts, argv: string[] = process.argv.slice(2)): void {
|
|
127
|
+
installQueuedConsole();
|
|
128
|
+
const done = o.done ?? ((code: number): void => { process.exitCode = code; });
|
|
129
|
+
const fail = (e: unknown): void => {
|
|
130
|
+
if (e instanceof CliExit) return done(e.code);
|
|
131
|
+
console.error(o.errorLine ? o.errorLine(e) : `${o.name}: ${(e instanceof Error && e.stack) || String(e)}`);
|
|
132
|
+
done(o.errorCode ?? 1);
|
|
133
|
+
};
|
|
134
|
+
// Inside main() — and only until it settles — exitCli throws CliExit (see above). Back to 0 once it has, so whatever
|
|
135
|
+
// outlives a CLI's main (a server it started, a timer) sees exitCli as it would outside any CLI: an immediate exit.
|
|
136
|
+
cliDepth++;
|
|
137
|
+
let settled = false;
|
|
138
|
+
const leave = (): void => { if (!settled) { settled = true; cliDepth--; } };
|
|
139
|
+
try {
|
|
140
|
+
const r = main(argv);
|
|
141
|
+
if (typeof r === "number") { leave(); done(r); } else r.then((c) => { leave(); done(c); }, (e: unknown) => { leave(); fail(e); });
|
|
142
|
+
} catch (e) { leave(); fail(e); }
|
|
143
|
+
}
|
|
144
|
+
/** Whether exitCli would throw CliExit here (inside a runCli main that has not settled) rather than exit. For the suites. */
|
|
145
|
+
export const insideCli = (): boolean => cliDepth > 0;
|
|
146
|
+
/** Exit with `code` once everything written to stdout and stderr is out (flushStdio) — or after `capMs` when a reader never
|
|
147
|
+
* drains them. For a process that has to end while something still holds the loop (a hung step's watchdog, a browser that
|
|
148
|
+
* will not go). Needs installQueuedConsole (runCli calls it) to know what is in flight.
|
|
149
|
+
* The cap bounds QUEUED writes (POSIX, where a write to a pipe is queued). On Windows Bun makes stdout/stderr on a pipe
|
|
150
|
+
* synchronous (as Node does there): a write into a full pipe blocks the thread until the reader takes it, so nothing is
|
|
151
|
+
* ever left in flight here — and a reader that never reads holds the process in that write, as it would any program. */
|
|
152
|
+
export function exitFlushed(code: number, capMs = 2_000): void {
|
|
153
|
+
const exit = (): never => process.exit(code);
|
|
154
|
+
setTimeout(exit, capMs).unref(); // a pending write keeps the loop up, so this still fires
|
|
155
|
+
void flushStdio().then(exit);
|
|
156
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// svg-normalize.ts — the definitions of "same SVG, modulo Figma's export noise". There are TWO, on purpose:
|
|
2
|
+
//
|
|
3
|
+
// Figma's own SVG export is not bit-reproducible: re-exporting the SAME icon, with NOTHING changed in
|
|
4
|
+
// the design, comes back with different floating-point path coordinates (measured ≤0.004px drift —
|
|
5
|
+
// d="…8.77734…4.97401…" vs d="…8.7793 …4.97596…" for one icon exported twice), and with def ids that
|
|
6
|
+
// embed the exporting node's id (`clip0_22012_15691` vs `clip0_22012_24907` for one icon reached through
|
|
7
|
+
// two instances).
|
|
8
|
+
//
|
|
9
|
+
// 1. `normalizeSvgText` (v1, below): round every decimal to 1 place, then hash the text. Used by the
|
|
10
|
+
// plugin's per-run content hash (figma-plugin/src/assets.ts register(), re-exported crypto-free
|
|
11
|
+
// from figma-plugin/src/util.ts) and by the change diff (design-to-code/design-diff.ts
|
|
12
|
+
// hashAssetBytes(), via bridge/src/asset-compare.ts). It stays EXACTLY as it is: design-diff
|
|
13
|
+
// persists these hashes in snapshot sidecars, so any change to v1 would report every SVG with
|
|
14
|
+
// defs as "redrawn" against every snapshot already on disk.
|
|
15
|
+
// 2. `svgFingerprint` / `sameSvg` (below v1): a skeleton plus every number, compared within an
|
|
16
|
+
// absolute tolerance. Used by the bridge's shared assets/ directory — the content reuse in
|
|
17
|
+
// bridge/src/write-out.ts writeAssets() and its `duplicates` report, via asset-compare.ts
|
|
18
|
+
// ContentIndex. It closes the three gaps v1 has on real exports: def ids, integer
|
|
19
|
+
// tokens (`12` vs `11.9999`, which v1's decimal-only regex never touches) and the rounding
|
|
20
|
+
// boundary (21.9485 → 21.9 but 21.9504 → 22.0: two exports 0.002 apart hash differently).
|
|
21
|
+
//
|
|
22
|
+
// The plugin bundle inlines THIS file, so it stays dependency-free and crypto-free: the byte/hash
|
|
23
|
+
// helpers live in asset-compare.ts, because `node:crypto` does not exist in the plugin's sandbox and
|
|
24
|
+
// this file must also type-check under the plugin's tsconfig, which has no Node types — an
|
|
25
|
+
// `import … from "node:crypto"` ANYWHERE in this file would break the plugin build even if the plugin
|
|
26
|
+
// bundle never calls that function.
|
|
27
|
+
//
|
|
28
|
+
// ---- v1
|
|
29
|
+
// Rounding to 1 decimal place (~0.1px), not 2 (~0.01px): 2 decimals do NOT reliably collapse the
|
|
30
|
+
// drift, because two legitimately identical re-exports can straddle a rounding boundary — 4.97401
|
|
31
|
+
// rounds to 4.97 but 4.97596 (0.00195 away, well inside the ≤0.002px drift budget) rounds to 4.98, so
|
|
32
|
+
// the "same" icon split into two hashes anyway. Checked against eight real exports of one icon
|
|
33
|
+
// (test/fixtures/livetest3/arrow-down/): 1 decimal place is the coarsest rounding that does NOT itself
|
|
34
|
+
// introduce a boundary split on this data, and it still separates the three genuinely different icons
|
|
35
|
+
// among those eight files — two orders of magnitude below anything a human would call "moved".
|
|
36
|
+
// Matches BOTH plain decimals (`-0.000406265`) and scientific notation (`2.09808e-05`) — Figma emits
|
|
37
|
+
// both for a coordinate near zero, sometimes for the SAME logical value across two exports of one
|
|
38
|
+
// icon. The regex must not stop at the decimal mantissa (`-?\d+\.\d+`): `2.09808e-05` would have only
|
|
39
|
+
// its `2.09808` replaced, leaving the exponent dangling as `2.1e-05` — a corrupted leftover that could
|
|
40
|
+
// never match the plain-decimal form's `0.0`, and the icon would fail to dedup even though every other
|
|
41
|
+
// coordinate matched. A real pull showed this: five files for what should have been two (an up/down pair).
|
|
42
|
+
const NUM_RE = /-?\d+\.\d+(?:[eE][+-]?\d+)?/g;
|
|
43
|
+
export function normalizeSvgText(svg: string): string {
|
|
44
|
+
return svg.replace(NUM_RE, (m) => {
|
|
45
|
+
const n = Number(m);
|
|
46
|
+
if (!Number.isFinite(n)) return m;
|
|
47
|
+
// toFixed can print "-0.0" for a tiny negative value that rounds to zero — a plain "0.0" and a
|
|
48
|
+
// "-0.0" are the same design value and must normalise identically, or they poison dedup the same
|
|
49
|
+
// way the scientific-notation bug above did.
|
|
50
|
+
const fixed = n.toFixed(1);
|
|
51
|
+
return fixed === "-0.0" ? "0.0" : fixed;
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// `fileName` decides whether normalisation even applies: only a textual SVG has a coordinate space to
|
|
56
|
+
// round. PNG/other bytes are never touched — a changed pixel there is real signal, not noise.
|
|
57
|
+
export function isSvgName(fileName: unknown): boolean {
|
|
58
|
+
return /\.svg$/i.test(String(fileName || ""));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ---- the fingerprint (bridge only — see the header for why v1 is not changed instead)
|
|
62
|
+
//
|
|
63
|
+
// The skeleton is the SVG text with (1) every id DEFINED in the document renamed `idN` in order of first
|
|
64
|
+
// definition, together with every `#X` reference to it (`url(#X)`, `href="#X"`, `xlink:href="#X"`) —
|
|
65
|
+
// generic, so it covers any id shape Figma emits, not only the clip/pattern/image ids seen so far; (2)
|
|
66
|
+
// hex colours and `base64,` payloads kept verbatim (a colour is a token, not a coordinate, and a changed
|
|
67
|
+
// pixel in an embedded image is real signal); and (3) every other numeric token, integers included,
|
|
68
|
+
// replaced by `#`, its value pushed to `nums`. Two exports of one icon have equal skeletons and nums
|
|
69
|
+
// within SVG_TOL of each other.
|
|
70
|
+
//
|
|
71
|
+
// SVG_TOL = 0.01, absolute. On the three real export directories every tolerance from 0.005 to 0.05
|
|
72
|
+
// merges the same files; the largest real drift inside a merged group is 0.004 and the closest real
|
|
73
|
+
// pair that is genuinely different is 0.3 apart (a stroke width, 1.5 vs 1.2). 0.01 is 2.5x the drift,
|
|
74
|
+
// far below any visible change, and does not merge opacity steps (0.5 vs 0.54) the way 0.05 would.
|
|
75
|
+
// "Within" is inclusive up to float noise (TOL_EPS): 0.11 vs 0.12 and 0.5 vs 0.51 are both exactly one
|
|
76
|
+
// step apart, but their float differences land on either side of 0.01.
|
|
77
|
+
//
|
|
78
|
+
// One exception: a number inside the `transform` of a `<use>` or `<image>` is in the space of the
|
|
79
|
+
// element it fills — for Figma's raster shells, `<pattern patternContentUnits="objectBoundingBox"><use
|
|
80
|
+
// transform="matrix(0.00195312 0 0 0.00195312 0 0)">`, where the whole box is 1 unit. 0.01 there is a
|
|
81
|
+
// 4.5x zoom of the same photo (scale 1/512 vs 1/113). The `<pattern>`'s own x/y/width/height (and
|
|
82
|
+
// patternTransform) are box fractions too, unless patternUnits="userSpaceOnUse" (a tile size).
|
|
83
|
+
// Those numbers go to `rel` (a `%` in the skeleton)
|
|
84
|
+
// and compare RELATIVELY: within SVG_TOL of the larger magnitude, never looser than the absolute rule.
|
|
85
|
+
// Path and box coordinates keep the absolute tolerance — their near-zero drift (2.09808e-05 vs
|
|
86
|
+
// -0.000406265, one real chevron) is noise, not a different drawing.
|
|
87
|
+
export const SVG_TOL = 0.01;
|
|
88
|
+
const TOL_EPS = 1e-9;
|
|
89
|
+
|
|
90
|
+
export interface SvgFingerprint {
|
|
91
|
+
/** the text with ids canonicalised and every number replaced by `#` */
|
|
92
|
+
skeleton: string;
|
|
93
|
+
/** every number the skeleton replaced with `#`, in document order */
|
|
94
|
+
nums: Float64Array;
|
|
95
|
+
/** every number inside a `<use>`/`<image>` `transform` or a `<pattern>`'s own x/y/width/height/patternTransform
|
|
96
|
+
* (a `%` in the skeleton), compared relatively */
|
|
97
|
+
rel: Float64Array;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const ID_DEF_RE = /(?<![\w:-])id\s*=\s*(["'])(.*?)\1/g;
|
|
101
|
+
// One pass over the text with the protected spans first in the alternation, so a protected span is
|
|
102
|
+
// consumed whole and its digits never reach the number branch: a canonical id marker, a hex colour of
|
|
103
|
+
// 3/4/6/8 digits not followed by another name character (so `#clip0` or `#12345` are not colours), a
|
|
104
|
+
// base64 payload, then the number itself — `-?(\d+\.?\d*|\.\d+)` with an optional exponent.
|
|
105
|
+
const ID_MARK = "\u0000";
|
|
106
|
+
const TOKEN_RE = /(\u0000id\d+\u0000)|(#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{3,4})(?![\w-]))|(base64,[A-Za-z0-9+/=]+)|(-?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?)/g;
|
|
107
|
+
const escapeRe = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
108
|
+
|
|
109
|
+
function canonicalIds(svg: string): string {
|
|
110
|
+
const order = new Map<string, string>(); // defined id -> its canonical marker, in order of first definition
|
|
111
|
+
// exec, not matchAll: the plugin's tsconfig is es2019 (no String.prototype.matchAll).
|
|
112
|
+
ID_DEF_RE.lastIndex = 0;
|
|
113
|
+
for (let m = ID_DEF_RE.exec(svg); m; m = ID_DEF_RE.exec(svg)) {
|
|
114
|
+
const id = m[2];
|
|
115
|
+
if (id && !order.has(id)) order.set(id, ID_MARK + "id" + order.size + ID_MARK);
|
|
116
|
+
}
|
|
117
|
+
if (!order.size) return svg;
|
|
118
|
+
// Longest first, so an id that is a prefix of another (`a` / `a1`) never claims the longer one's text.
|
|
119
|
+
const alts = [...order.keys()].sort((x, y) => y.length - x.length).map(escapeRe).join("|");
|
|
120
|
+
const re = new RegExp("(?<![\\w:-])(id\\s*=\\s*[\"'])(" + alts + ")(?=[\"'])|#(" + alts + ")(?![\\w.:-])", "g");
|
|
121
|
+
return svg.replace(re, (m: string, attr: string | undefined, defined: string | undefined, ref: string | undefined) => {
|
|
122
|
+
if (attr !== undefined && defined !== undefined) return attr + (order.get(defined) ?? defined);
|
|
123
|
+
if (ref !== undefined) return "#" + (order.get(ref) ?? ref);
|
|
124
|
+
return m;
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// The value spans compared relatively: `transform` on `<use>`/`<image>` tags, and a `<pattern>`'s own
|
|
129
|
+
// x/y/width/height/patternTransform. A tag's attributes never contain `>` (a base64 payload has none),
|
|
130
|
+
// so `[^>]*` stays inside the tag.
|
|
131
|
+
const REL_TAG_RE = /<(?:use|image|pattern)\b[^>]*>/g;
|
|
132
|
+
const TRANSFORM_ATTR_RE = /(?<![\w:-])transform\s*=\s*(["'])([^"']*)\1/g;
|
|
133
|
+
// A <pattern>'s own box is a fraction of the filled shape (patternUnits defaults to objectBoundingBox): a
|
|
134
|
+
// TILE fill of 0.05 vs 0.0588 is 20 vs 17 tiles across. With patternUnits="userSpaceOnUse" they are user
|
|
135
|
+
// units, compared absolutely like any other number.
|
|
136
|
+
const PATTERN_ATTR_RE = /(?<![\w:-])(?:x|y|width|height|patternTransform)\s*=\s*(["'])([^"']*)\1/g;
|
|
137
|
+
function relSpans(svg: string): Array<[number, number]> {
|
|
138
|
+
const spans: Array<[number, number]> = [];
|
|
139
|
+
REL_TAG_RE.lastIndex = 0;
|
|
140
|
+
for (let t = REL_TAG_RE.exec(svg); t; t = REL_TAG_RE.exec(svg)) {
|
|
141
|
+
const re = t[0].startsWith("<pattern") ? (/patternUnits\s*=\s*["']userSpaceOnUse/.test(t[0]) ? null : PATTERN_ATTR_RE) : TRANSFORM_ATTR_RE;
|
|
142
|
+
if (!re) continue;
|
|
143
|
+
re.lastIndex = 0;
|
|
144
|
+
for (let a = re.exec(t[0]); a; a = re.exec(t[0])) {
|
|
145
|
+
const start = t.index + a.index + a[0].length - 1 - (a[2] || "").length;
|
|
146
|
+
spans.push([start, start + (a[2] || "").length]);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return spans;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function svgFingerprint(svg: string): SvgFingerprint {
|
|
153
|
+
const nums: number[] = [];
|
|
154
|
+
const rel: number[] = [];
|
|
155
|
+
const text = canonicalIds(svg);
|
|
156
|
+
const spans = relSpans(text);
|
|
157
|
+
const inRel = (at: number): boolean => spans.some(([s, e]) => at >= s && at < e);
|
|
158
|
+
const skeleton = text.replace(TOKEN_RE, (m: string, _id: string | undefined, _hex: string | undefined, _b64: string | undefined, num: string | undefined, at: number) => {
|
|
159
|
+
if (num === undefined) return m;
|
|
160
|
+
if (spans.length && inRel(at)) { rel.push(Number(num)); return "%"; }
|
|
161
|
+
nums.push(Number(num));
|
|
162
|
+
return "#";
|
|
163
|
+
});
|
|
164
|
+
return { skeleton, nums: Float64Array.from(nums), rel: Float64Array.from(rel) };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Every number within `tol` of its counterpart (and as many of them). Exact equality also counts, so two
|
|
168
|
+
* infinities from an overflowing token compare equal rather than NaN-unequal. */
|
|
169
|
+
export function numsWithin(a: Float64Array, b: Float64Array, tol: number = SVG_TOL): boolean {
|
|
170
|
+
if (a.length !== b.length) return false;
|
|
171
|
+
for (let i = 0; i < a.length; i++) {
|
|
172
|
+
const x = a[i] as number, y = b[i] as number;
|
|
173
|
+
if (x !== y && !(Math.abs(x - y) <= tol + TOL_EPS)) return false;
|
|
174
|
+
}
|
|
175
|
+
return true;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** The `rel` half: every number within `tol` × the larger magnitude of the pair, and never more than
|
|
179
|
+
* `tol` apart — so a big number keeps the absolute rule and a tiny one is compared to its own scale. */
|
|
180
|
+
export function relWithin(a: Float64Array, b: Float64Array, tol: number = SVG_TOL): boolean {
|
|
181
|
+
if (a.length !== b.length) return false;
|
|
182
|
+
for (let i = 0; i < a.length; i++) {
|
|
183
|
+
const x = a[i] as number, y = b[i] as number;
|
|
184
|
+
if (x !== y && !(Math.abs(x - y) <= Math.min(tol, tol * Math.max(Math.abs(x), Math.abs(y))) + TOL_EPS)) return false;
|
|
185
|
+
}
|
|
186
|
+
return true;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** The bridge's "same SVG": equal skeletons, every number within `tol`, every `rel` number relatively so. */
|
|
190
|
+
export function sameSvg(a: string, b: string, tol: number = SVG_TOL): boolean {
|
|
191
|
+
const fa = svgFingerprint(a), fb = svgFingerprint(b);
|
|
192
|
+
return fa.skeleton === fb.skeleton && numsWithin(fa.nums, fb.nums, tol) && relWithin(fa.rel, fb.rel, tol);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ---- a raster image in an SVG shell
|
|
196
|
+
//
|
|
197
|
+
// An SVG with an embedded `<image>` and almost no paths is a PHOTO in an SVG shell (an avatar exported as an
|
|
198
|
+
// icon-like container: 2 paths and a 1.98 MB base64 JPEG). The "thousands of vector paths" advice is wrong
|
|
199
|
+
// for it: there is nothing to simplify, and a PNG/JPG export of the layer is the fix. ONE rule for the
|
|
200
|
+
// bridge's pull warning (write-out.ts) and the audit's heavy-asset finding (design-to-code/audit.ts):
|
|
201
|
+
// an illustration with 2000 paths and one `<image>` is still "too many paths", in both.
|
|
202
|
+
export const RASTER_SHELL_MAX_PATHS = 50;
|
|
203
|
+
export function isRasterShell(rasters: number | undefined, paths: number | undefined): boolean {
|
|
204
|
+
return (rasters ?? 0) > 0 && (paths ?? 0) < RASTER_SHELL_MAX_PATHS;
|
|
205
|
+
}
|