@design-twin/dtwin 0.0.0-stage → 0.1.0-alpha.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 +949 -2
- package/package.json +49 -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 +940 -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/package.json
CHANGED
|
@@ -1,6 +1,51 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@design-twin/dtwin",
|
|
3
|
-
"version": "0.0.0
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0-alpha.0",
|
|
4
|
+
"description": "Design Twin bridge: local CLI + MCP server between Claude Code and the Design Twin Figma plugin. Reads a design on the free Figma plan, fully offline.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Mohamed Magdy Omar",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/its-magdy/design-twin.git",
|
|
10
|
+
"directory": "bridge"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/its-magdy/design-twin/tree/main/bridge#readme",
|
|
13
|
+
"bugs": "https://github.com/its-magdy/design-twin/issues",
|
|
14
|
+
"keywords": [
|
|
15
|
+
"figma",
|
|
16
|
+
"mcp",
|
|
17
|
+
"model-context-protocol",
|
|
18
|
+
"design-to-code",
|
|
19
|
+
"design-system",
|
|
20
|
+
"design-tokens",
|
|
21
|
+
"claude-code",
|
|
22
|
+
"figma-plugin",
|
|
23
|
+
"cli"
|
|
24
|
+
],
|
|
25
|
+
"type": "module",
|
|
26
|
+
"engines": {
|
|
27
|
+
"bun": ">=1.4.2"
|
|
28
|
+
},
|
|
29
|
+
"bin": {
|
|
30
|
+
"dtwin": "src/figma-pull.ts"
|
|
31
|
+
},
|
|
32
|
+
"files": [
|
|
33
|
+
"src",
|
|
34
|
+
"README.md",
|
|
35
|
+
"LICENSE"
|
|
36
|
+
],
|
|
37
|
+
"scripts": {
|
|
38
|
+
"typecheck": "tsc --noEmit",
|
|
39
|
+
"pull": "bun src/figma-pull.ts",
|
|
40
|
+
"pull:selection": "bun src/figma-pull.ts design --selection",
|
|
41
|
+
"mcp": "bun src/figma-mcp.ts"
|
|
42
|
+
},
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"@modelcontextprotocol/sdk": "^1.32.1",
|
|
45
|
+
"zod": "^4.6.7"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@types/bun": "^1.4.2",
|
|
49
|
+
"typescript": "^7.0.2"
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// abort-reason.ts — bridge-side only (AbortSignal, DOMException): json-util.ts is also type-checked by the
|
|
2
|
+
// plugin, which has no DOM lib.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Why a request the caller gave up on is rejected: the signal's reason when that is an Error of the
|
|
6
|
+
* caller's own, else `fallback`. A bare abort() carries a DOMException "AbortError" (itself an Error in
|
|
7
|
+
* Node), which names nothing, so it gets the fixed text. The one rule for the bridge and the daemon client.
|
|
8
|
+
*/
|
|
9
|
+
export function abortReason(signal: AbortSignal, fallback: string): Error {
|
|
10
|
+
const r: unknown = signal.reason;
|
|
11
|
+
return r instanceof Error && !(r instanceof DOMException) ? r : new Error(fallback);
|
|
12
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// asset-compare.ts — bridge-side byte comparison built on svg-normalize.ts's two definitions: the v1
|
|
2
|
+
// normalised hash (normalizeForCompare/sameAsset — design-diff's, unchanged) and the fingerprint index
|
|
3
|
+
// the shared assets/ directory dedups with (ContentIndex, below).
|
|
4
|
+
//
|
|
5
|
+
// Split out of svg-normalize.ts (rather than living there) because this needs `crypto`, which the
|
|
6
|
+
// Figma plugin sandbox does not have — and svg-normalize.ts must type-check under the plugin's
|
|
7
|
+
// tsconfig (no Node types) and bundle into the plugin, so an `import … from "node:crypto"` anywhere
|
|
8
|
+
// in a file the plugin bundle imports breaks that build, even for a function the bundle never calls.
|
|
9
|
+
// svg-normalize.ts stays crypto-free so figma-plugin/src/util.ts can safely re-export it; this file
|
|
10
|
+
// is imported only from the Bun side (bridge/src/write-out.ts, design-to-code/design-diff.ts — the latter
|
|
11
|
+
// bundled by claude-plugin/build-scripts.ts the same way it already bundles bridge/src/export-verdict.ts).
|
|
12
|
+
import crypto from "node:crypto";
|
|
13
|
+
import fs from "node:fs";
|
|
14
|
+
import { normalizeSvgText, isSvgName, svgFingerprint, numsWithin, relWithin, SVG_TOL } from "./svg-normalize.ts";
|
|
15
|
+
import { FileMemo } from "./file-memo.ts";
|
|
16
|
+
|
|
17
|
+
// `content` may be a Buffer (bytes on disk / about to be written) or a string (an SVG the plugin just
|
|
18
|
+
// produced, before it's ever touched a filesystem) — normalised either way. Always returns a Buffer.
|
|
19
|
+
export function normalizeForCompare(fileName: unknown, content: Buffer | string): Buffer {
|
|
20
|
+
const buf = Buffer.isBuffer(content) ? content : Buffer.from(String(content), "utf8");
|
|
21
|
+
if (!isSvgName(fileName)) return buf;
|
|
22
|
+
return Buffer.from(normalizeSvgText(buf.toString("utf8")), "utf8");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function sha1Hex(buf: Buffer | string): string {
|
|
26
|
+
return crypto.createHash("sha1").update(buf).digest("hex");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// The comparison every "is this the same asset" decision should use: true if the two byte strings are
|
|
30
|
+
// the same asset MODULO Figma's own export noise (SVG coordinate rounding only; PNG/other bytes are
|
|
31
|
+
// compared exactly — a changed pixel there is real signal, not noise).
|
|
32
|
+
export function sameAsset(fileName: unknown, a: Buffer | string, b: Buffer | string): boolean {
|
|
33
|
+
return Buffer.compare(normalizeForCompare(fileName, a), normalizeForCompare(fileName, b)) === 0;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ---- ContentIndex: "is this content already in assets/, under ANY name"
|
|
37
|
+
//
|
|
38
|
+
// A hash cannot express "within a tolerance", so the index is two-level: the KEY is exact — sha1 of the
|
|
39
|
+
// SVG's fingerprint skeleton (ids canonical, numbers out), or sha1 of the bytes for anything that is not an
|
|
40
|
+
// SVG (a changed pixel is real signal) — and inside one key's bucket the members are compared on their
|
|
41
|
+
// numbers, every one within SVG_TOL (a `<use>`/`<image>` transform's relatively — see svg-normalize.ts). Members are kept in
|
|
42
|
+
// REPRESENTATIVE order — the plain name before a `-<6 hex>[_N]` suffixed copy, then the shortest, then
|
|
43
|
+
// alphabetical (ownCopyWithContent's order in write-out.ts) — so `find` names the same file whichever order
|
|
44
|
+
// the directory listed them in.
|
|
45
|
+
|
|
46
|
+
/** The exact half of an asset's content identity, plus the numbers its tolerance applies to (`rel`: the
|
|
47
|
+
* relatively compared ones). */
|
|
48
|
+
export interface ContentKey { key: string; nums: Float64Array; rel: Float64Array }
|
|
49
|
+
interface Member { name: string; nums: Float64Array; rel: Float64Array }
|
|
50
|
+
/** One cluster of files the index treats as the same asset; `files[0]` is its representative. */
|
|
51
|
+
export interface ContentGroup { hash: string; files: string[] }
|
|
52
|
+
|
|
53
|
+
const EMPTY_NUMS = new Float64Array(0);
|
|
54
|
+
const SUFFIXED_RE = /-[0-9a-z]{6}(_\d+)?\.[^.]+$/i;
|
|
55
|
+
|
|
56
|
+
/** Representative order: plain name first, then shortest, then alphabetical. */
|
|
57
|
+
export function representativeOrder(x: string, y: string): number {
|
|
58
|
+
const sx = SUFFIXED_RE.test(x) ? 1 : 0, sy = SUFFIXED_RE.test(y) ? 1 : 0;
|
|
59
|
+
return sx - sy || x.length - y.length || (x < y ? -1 : x > y ? 1 : 0);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function contentKey(fileName: unknown, content: Buffer | string): ContentKey {
|
|
63
|
+
const buf = Buffer.isBuffer(content) ? content : Buffer.from(String(content), "utf8");
|
|
64
|
+
if (!isSvgName(fileName)) return { key: "b:" + sha1Hex(buf), nums: EMPTY_NUMS, rel: EMPTY_NUMS };
|
|
65
|
+
const fp = svgFingerprint(buf.toString("utf8"));
|
|
66
|
+
return { key: "s:" + sha1Hex(fp.skeleton), nums: fp.nums, rel: fp.rel };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// A stable id for a cluster: its key plus its representative's numbers rounded to 2 places (`-0.00` folded
|
|
70
|
+
// into `0.00`), and its `rel` numbers to 3 significant digits (two clusters of one key may differ only
|
|
71
|
+
// there). Reported as `duplicates[].hash` — it names the group, it is not a hash of any one file.
|
|
72
|
+
function groupHash(key: string, m: Member): string {
|
|
73
|
+
let text = key + "|";
|
|
74
|
+
for (const n of m.nums) { const f = n.toFixed(2); text += (f === "-0.00" ? "0.00" : f) + ","; }
|
|
75
|
+
if (m.rel.length) { text += "|"; for (const n of m.rel) text += (n === 0 ? "0" : n.toPrecision(3)) + ","; }
|
|
76
|
+
return sha1Hex(text);
|
|
77
|
+
}
|
|
78
|
+
const within = (a: Member | ContentKey, b: Member | ContentKey, tol: number): boolean => numsWithin(a.nums, b.nums, tol) && relWithin(a.rel, b.rel, tol);
|
|
79
|
+
|
|
80
|
+
export class ContentIndex {
|
|
81
|
+
private readonly buckets = new Map<string, Member[]>();
|
|
82
|
+
// name -> the key of the bucket holding it, so a replace touches one bucket instead of scanning them all.
|
|
83
|
+
private readonly bucketOf = new Map<string, string>();
|
|
84
|
+
// Buckets added to since they were last sorted. Sorting is deferred to the next read of the bucket, so
|
|
85
|
+
// building the index over a directory sorts each bucket once instead of once per add. The order is the
|
|
86
|
+
// same either way: representativeOrder never ties two different names.
|
|
87
|
+
private readonly unsorted = new Set<string>();
|
|
88
|
+
private readonly tol: number;
|
|
89
|
+
constructor(tol: number = SVG_TOL) { this.tol = tol; }
|
|
90
|
+
|
|
91
|
+
private sortedBucket(key: string): Member[] | undefined {
|
|
92
|
+
const bucket = this.buckets.get(key);
|
|
93
|
+
if (bucket && this.unsorted.delete(key)) bucket.sort((x, y) => representativeOrder(x.name, y.name));
|
|
94
|
+
return bucket;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The first member (representative order) the same as `k`, or undefined. */
|
|
98
|
+
findKey(k: ContentKey): string | undefined {
|
|
99
|
+
const bucket = this.sortedBucket(k.key);
|
|
100
|
+
if (!bucket) return undefined;
|
|
101
|
+
for (const m of bucket) if (within(m, k, this.tol)) return m.name;
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
find(fileName: string, content: Buffer | string): string | undefined { return this.findKey(contentKey(fileName, content)); }
|
|
105
|
+
|
|
106
|
+
/** Add a file under `name` (a name already present is replaced, so a rewrite never leaves a stale member). */
|
|
107
|
+
addKey(name: string, k: ContentKey): void {
|
|
108
|
+
const prevKey = this.bucketOf.get(name);
|
|
109
|
+
const prev = prevKey === undefined ? undefined : this.buckets.get(prevKey);
|
|
110
|
+
if (prevKey !== undefined && prev) {
|
|
111
|
+
const i = prev.findIndex((m) => m.name === name);
|
|
112
|
+
if (i >= 0) prev.splice(i, 1);
|
|
113
|
+
if (!prev.length) { this.buckets.delete(prevKey); this.unsorted.delete(prevKey); }
|
|
114
|
+
}
|
|
115
|
+
let bucket = this.buckets.get(k.key);
|
|
116
|
+
if (!bucket) { bucket = []; this.buckets.set(k.key, bucket); }
|
|
117
|
+
bucket.push({ name, nums: k.nums, rel: k.rel });
|
|
118
|
+
this.bucketOf.set(name, k.key);
|
|
119
|
+
if (bucket.length > 1) this.unsorted.add(k.key);
|
|
120
|
+
}
|
|
121
|
+
add(name: string, content: Buffer | string): void { this.addKey(name, contentKey(name, content)); }
|
|
122
|
+
|
|
123
|
+
/** Every cluster, singletons included. Each member joins the first cluster (representative order) whose
|
|
124
|
+
* representative is within tolerance of it. `find` answers "the first member within tolerance", which is
|
|
125
|
+
* that same representative unless members chain apart by more than the tolerance (never seen: the real
|
|
126
|
+
* drift is 0.004, the tolerance 0.01). */
|
|
127
|
+
groups(): ContentGroup[] { return this.clustersOf(null); }
|
|
128
|
+
|
|
129
|
+
/** groups(), limited to the buckets holding `names` — the same clusters, in the same order, as groups() gives for
|
|
130
|
+
* them. Clustering is quadratic in a bucket's size, so a pull asking about its own few files does not pay for
|
|
131
|
+
* every bucket of a directory every screen ever pulled shares. A name not in the index adds nothing. */
|
|
132
|
+
groupsOf(names: Iterable<string>): ContentGroup[] {
|
|
133
|
+
const keys = new Set<string>();
|
|
134
|
+
for (const n of names) { const k = this.bucketOf.get(n); if (k !== undefined) keys.add(k); }
|
|
135
|
+
return keys.size ? this.clustersOf(keys) : [];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private clustersOf(only: ReadonlySet<string> | null): ContentGroup[] {
|
|
139
|
+
const out: ContentGroup[] = [];
|
|
140
|
+
for (const key of this.buckets.keys()) {
|
|
141
|
+
if (only && !only.has(key)) continue;
|
|
142
|
+
const members = this.sortedBucket(key) ?? [];
|
|
143
|
+
const clusters: Member[][] = [];
|
|
144
|
+
for (const m of members) {
|
|
145
|
+
const c = clusters.find((cl) => { const rep = cl[0]; return rep !== undefined && within(rep, m, this.tol); });
|
|
146
|
+
if (c) c.push(m); else clusters.push([m]);
|
|
147
|
+
}
|
|
148
|
+
for (const cl of clusters) {
|
|
149
|
+
const rep = cl[0];
|
|
150
|
+
if (rep) out.push({ hash: groupHash(key, rep), files: cl.map((m) => m.name) });
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return out;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ---- FileKeyCache: a file's ContentKey, remembered across pulls in one process
|
|
158
|
+
//
|
|
159
|
+
// Every screen pull builds the ContentIndex over the whole shared assets/ directory, so a process that pulls
|
|
160
|
+
// many screens would otherwise re-read and re-fingerprint every file in it on every pull. file-memo.ts says
|
|
161
|
+
// when a remembered key may be used.
|
|
162
|
+
|
|
163
|
+
export class FileKeyCache extends FileMemo<ContentKey> {
|
|
164
|
+
/** contentKey(name, <the bytes of `file`>), or undefined when the file cannot be read. */
|
|
165
|
+
keyOf(file: string, name: string): ContentKey | undefined {
|
|
166
|
+
return this.get(file, () => {
|
|
167
|
+
let bytes: Buffer;
|
|
168
|
+
try { bytes = fs.readFileSync(file); } catch { return undefined; }
|
|
169
|
+
return contentKey(name, bytes);
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** The process-wide cache buildContentIndex (write-out.ts) reads assets/ through. */
|
|
175
|
+
export const fileKeyCache = new FileKeyCache();
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
// asset-owners.ts — who owns an exported graphic, and which hidden graphic can reuse a visible twin's file.
|
|
2
|
+
//
|
|
3
|
+
// Pure: plain IR trees in, plain answers out (no fs) — write-out.ts does the reading and writing, so every
|
|
4
|
+
// rule here is testable offline on hand-built trees.
|
|
5
|
+
//
|
|
6
|
+
// 1. Owners. An asset file is named after its layer, and a layer is very often called
|
|
7
|
+
// `Vector`, `Icon` or `Group_N`. What it IS lives on the nearest component instance above it (or on the
|
|
8
|
+
// node itself): `Checkbox / Status=Checked`. One walk gives every `asset` pointer its uses — node id,
|
|
9
|
+
// layer name, hidden or not, owner, the instance above the owner (`context`, which names a self-owned
|
|
10
|
+
// icon component such as `Glyph 12` inside `Nav item / Type=Settings`) — and the `.assets.json`
|
|
11
|
+
// row carries them, so a builder searches by component instead of by file name. File names never change.
|
|
12
|
+
//
|
|
13
|
+
// 2. Hidden reuse. The plugin does not export a hidden graphic (`assetSkipped:"hidden"`): its
|
|
14
|
+
// exporter has nothing to render. When a VISIBLE node in the same pull (or in another screen already
|
|
15
|
+
// pulled) is the same graphic — the same main component, the same source node inside it, the same
|
|
16
|
+
// chain of components and variants above the owner, the same non-boolean props, the same effective
|
|
17
|
+
// variable modes, the same size and transform, and no paint override (nor a shape/visibility override
|
|
18
|
+
// on a sublayer of the graphic) on either side — the hidden
|
|
19
|
+
// node points at that file instead (`asset` + `assetFrom`). An owner's booleans are left out of the key
|
|
20
|
+
// because they are the visibility toggles that hid the node; a SELF-owned icon's own booleans are its
|
|
21
|
+
// internal toggles (`Show badge`), so they stay in. Variant props are covered by the main
|
|
22
|
+
// component id. The modes are in because a bound colour renders per mode.
|
|
23
|
+
// The chain is in the key because a parent variant may recolour a nested icon in its own DEFINITION
|
|
24
|
+
// (`Button / Type=Danger` drawing its icon red), and Figma's `overrides` list only what is overridden
|
|
25
|
+
// directly on an instance, never what it inherits — so no override would block that twin.
|
|
26
|
+
import type { InstanceOverride, IrNode } from "./doc-types.ts";
|
|
27
|
+
import { OVERRIDE_CAP } from "./instance-overrides.ts";
|
|
28
|
+
|
|
29
|
+
/** The nearest INSTANCE ancestor-or-self that names its main component. */
|
|
30
|
+
export interface AssetOwner {
|
|
31
|
+
/** the component set's name, else the component's own */
|
|
32
|
+
component: string;
|
|
33
|
+
variant?: string;
|
|
34
|
+
componentId?: string;
|
|
35
|
+
componentKey?: string;
|
|
36
|
+
/** the owning instance's node id */
|
|
37
|
+
instance: string;
|
|
38
|
+
/** the node IS the owning instance (a self-owned icon component) */
|
|
39
|
+
self: boolean;
|
|
40
|
+
/** a paint override, a shape/visibility override on a sublayer (or a truncated override list) on this
|
|
41
|
+
* use — never reused across, either way */
|
|
42
|
+
paintOverrides?: true;
|
|
43
|
+
}
|
|
44
|
+
/** The next INSTANCE above the owner. */
|
|
45
|
+
export interface AssetContext { component: string; variant?: string }
|
|
46
|
+
|
|
47
|
+
/** One node in a tree that holds an exported graphic (`asset`) or a hidden one that was not (`assetSkipped:"hidden"`). */
|
|
48
|
+
export interface Graphic {
|
|
49
|
+
node: IrNode;
|
|
50
|
+
/** the node or an ancestor is hidden */
|
|
51
|
+
hidden: boolean;
|
|
52
|
+
owner?: AssetOwner;
|
|
53
|
+
context?: AssetContext;
|
|
54
|
+
/** see reuseKeyOf; absent without an owner that names a component id or key */
|
|
55
|
+
reuseKey?: string;
|
|
56
|
+
/** a paint override, a sublayer shape/visibility override or a truncated override list blocks reuse */
|
|
57
|
+
blocked: boolean;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Figma's overriddenFields that change how a graphic LOOKS. Anything else (a text, a swapped instance's
|
|
61
|
+
// visibility, a name) leaves the exported pixels of an icon alone.
|
|
62
|
+
const PAINT_FIELD_RE = /^(?:fills|strokes|strokeWeight|sto?ke\w*Weight|opacity|effects|effectStyleId|blendMode|fillStyleId|strokeStyleId|boundVariables|explicitVariableModes|vectorNetwork)$/;
|
|
63
|
+
// On a SUBLAYER of the graphic (inside the exported file), any field but these changes the drawing: a
|
|
64
|
+
// `visible` override drops a layer, a size/corner/transform one reshapes it.
|
|
65
|
+
const INERT_SUBLAYER_FIELD_RE = /^(?:name|locked|pluginData|reactions|description|exportSettings|expanded|autoRename|hyperlink|characters|styledTextSegments|textStyleId|fontName)$/;
|
|
66
|
+
|
|
67
|
+
const lastSegment = (id: string): string => { const i = id.lastIndexOf(";"); return i >= 0 ? id.slice(i + 1) : id; };
|
|
68
|
+
const isOwnerInstance = (n: IrNode): boolean => n.type === "INSTANCE" && !!n.mainComponent;
|
|
69
|
+
const overridesOf = (n: IrNode): readonly InstanceOverride[] => (Array.isArray(n.overrides) ? n.overrides : []);
|
|
70
|
+
// The plugin cuts the list at OVERRIDE_CAP (and warns): at that length, what was cut is unknown.
|
|
71
|
+
const truncated = (n: IrNode): boolean => overridesOf(n).length >= OVERRIDE_CAP;
|
|
72
|
+
const paintEntry = (o: InstanceOverride): boolean => !!o && Array.isArray(o.fields) && o.fields.some((f) => typeof f === "string" && PAINT_FIELD_RE.test(f));
|
|
73
|
+
|
|
74
|
+
// (iii) of the reuse guard. On every instance from the root down to the graphic, a paint entry ABOUT the
|
|
75
|
+
// graphic or one of its sublayers counts (a sublayer id is `<id>;…`, or `I<id>;…` under a top-level
|
|
76
|
+
// instance): an outer instance records a recolour of a nested vector on ITS own `overrides`, not on the
|
|
77
|
+
// vector's owner. For a graphic that is not itself an instance, each paint entry on its owner counts
|
|
78
|
+
// as well — the override's target cannot always be tied to the node more precisely than that from the tree.
|
|
79
|
+
// On a STRICT sublayer (`<id>;…`, inside the exported file) any field but an inert one blocks: a `visible`
|
|
80
|
+
// override drops a layer, a size/corner one reshapes it. The graphic's own `visible` is what hid it.
|
|
81
|
+
// A truncated list on any of those instances blocks too: what was cut is unknown.
|
|
82
|
+
function hasPaintOverride(node: IrNode, instances: readonly IrNode[], owner: IrNode | undefined): boolean {
|
|
83
|
+
const id = typeof node.id === "string" ? node.id : "";
|
|
84
|
+
const prefixes = [id + ";", "I" + id + ";"];
|
|
85
|
+
const about = (o: InstanceOverride): boolean => !!o && typeof o.id === "string" && (o.id === id || prefixes.some((p) => o.id.startsWith(p)));
|
|
86
|
+
const sub = (o: InstanceOverride): boolean => !!o && typeof o.id === "string" && o.id !== id && prefixes.some((p) => o.id.startsWith(p));
|
|
87
|
+
const reshapes = (o: InstanceOverride): boolean => Array.isArray(o.fields) && o.fields.some((f) => typeof f === "string" && !INERT_SUBLAYER_FIELD_RE.test(f));
|
|
88
|
+
if (instances.some((inst) => truncated(inst) || overridesOf(inst).some((o) => (about(o) && paintEntry(o)) || (sub(o) && reshapes(o))))) return true;
|
|
89
|
+
if (node.type === "INSTANCE" || !owner) return false;
|
|
90
|
+
return overridesOf(owner).some(paintEntry);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const sortedProps = (n: IrNode, booleans: boolean): Array<[string, unknown]> => {
|
|
94
|
+
const props = n.props && typeof n.props === "object" ? Object.entries(n.props) : [];
|
|
95
|
+
return props.filter(([, v]) => booleans || typeof v !== "boolean").sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/** `<componentKey||componentId>|<self|last id segment>|<sorted props JSON>|<chain JSON>[|<modes JSON>]`, or
|
|
99
|
+
* undefined. The props drop booleans unless the node IS the owner; `modes` is the effective
|
|
100
|
+
* `[collection, mode]` list, sorted (collectGraphics), left off when empty. `chain` is the main component (key, else id — which names the variant; `?` when unnamed) of
|
|
101
|
+
* every INSTANCE above the owner, outermost first: `instances` is the root-down list of instances above
|
|
102
|
+
* `node` (the owner included, when it is an ancestor). The whole chain, not only the next instance up: an
|
|
103
|
+
* outer component's definition can recolour the nested icon just as the parent variant's can.
|
|
104
|
+
* Not in the key: WHERE in the parent the owner sits. The full id path would keep a leading and a trailing
|
|
105
|
+
* icon of one button variant apart, but on the real exports it refused every same-screen reuse — list
|
|
106
|
+
* rows inside an instance carry document ids in that path, different per row for the very same icon. */
|
|
107
|
+
export function reuseKeyOf(node: IrNode, owner: IrNode | undefined, instances: readonly IrNode[] = [], modes = ""): string | undefined {
|
|
108
|
+
const mc = owner && owner.mainComponent;
|
|
109
|
+
const comp = mc && (mc.key || mc.id);
|
|
110
|
+
if (!owner || !comp || typeof node.id !== "string") return undefined;
|
|
111
|
+
const at = instances.indexOf(owner);
|
|
112
|
+
const above = at >= 0 ? instances.slice(0, at) : instances.filter((i) => i !== owner);
|
|
113
|
+
const chain = above.map((i) => (i.mainComponent && (i.mainComponent.key || i.mainComponent.id)) || "?");
|
|
114
|
+
return comp + "|" + (node === owner ? "self" : lastSegment(node.id)) + "|" + JSON.stringify(sortedProps(owner, node === owner)) + "|" + JSON.stringify(chain) + (modes ? "|" + modes : "");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function ownerRef(inst: IrNode, node: IrNode, blocked: boolean): AssetOwner | undefined {
|
|
118
|
+
const mc = inst.mainComponent;
|
|
119
|
+
if (!mc) return undefined;
|
|
120
|
+
const o: AssetOwner = { component: mc.setName ?? mc.name, instance: inst.id, self: inst === node };
|
|
121
|
+
if (mc.variant) o.variant = mc.variant;
|
|
122
|
+
if (mc.id) o.componentId = mc.id;
|
|
123
|
+
if (mc.key) o.componentKey = mc.key;
|
|
124
|
+
if (blocked) o.paintOverrides = true;
|
|
125
|
+
return o;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Every graphic node (`asset` string, or `assetSkipped:"hidden"`) in tree order, with its owner and reuse key. */
|
|
129
|
+
export function collectGraphics(roots: readonly IrNode[] | null | undefined): Graphic[] {
|
|
130
|
+
const out: Graphic[] = [];
|
|
131
|
+
// instances: every INSTANCE from the root down (paint-override scope); owners: the ones naming a main component.
|
|
132
|
+
// modes: the effective variable mode per collection (the root's resolvedModes, then every variableModes
|
|
133
|
+
// pinned on the way down) — a bound colour renders per mode, so a Dark section never reuses a Light file.
|
|
134
|
+
const visit = (n: IrNode, instances: readonly IrNode[], owners: readonly IrNode[], hiddenAbove: boolean, modesAbove: ReadonlyMap<string, string>): void => {
|
|
135
|
+
if (!n || typeof n !== "object") return;
|
|
136
|
+
let modes = modesAbove;
|
|
137
|
+
for (const pinned of [n.resolvedModes, n.variableModes]) {
|
|
138
|
+
if (!pinned || typeof pinned !== "object") continue;
|
|
139
|
+
const next = new Map(modes);
|
|
140
|
+
for (const [c, m] of Object.entries(pinned)) if (typeof m === "string") next.set(c, m);
|
|
141
|
+
modes = next;
|
|
142
|
+
}
|
|
143
|
+
const insts = n.type === "INSTANCE" ? [...instances, n] : instances;
|
|
144
|
+
const owns = isOwnerInstance(n) ? [...owners, n] : owners;
|
|
145
|
+
const hidden = hiddenAbove || n.hidden === true;
|
|
146
|
+
if (typeof n.asset === "string" || n.assetSkipped === "hidden") {
|
|
147
|
+
const owner = owns[owns.length - 1];
|
|
148
|
+
const above = owns[owns.length - 2];
|
|
149
|
+
const blocked = hasPaintOverride(n, insts, owner);
|
|
150
|
+
const g: Graphic = { node: n, hidden, blocked };
|
|
151
|
+
const o = owner && ownerRef(owner, n, blocked);
|
|
152
|
+
if (o) g.owner = o;
|
|
153
|
+
const ac = above && above.mainComponent;
|
|
154
|
+
if (ac) g.context = ac.variant ? { component: ac.setName ?? ac.name, variant: ac.variant } : { component: ac.setName ?? ac.name };
|
|
155
|
+
const mk = [...modes].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
156
|
+
const key = reuseKeyOf(n, owner, insts, mk.length ? JSON.stringify(mk) : "");
|
|
157
|
+
if (key !== undefined) g.reuseKey = key;
|
|
158
|
+
out.push(g);
|
|
159
|
+
}
|
|
160
|
+
// An unrelated screen's JSON on disk may be malformed (`children: {}`): skipped, never a throw.
|
|
161
|
+
if (Array.isArray(n.children)) for (const c of n.children) visit(c, insts, owns, hidden, modes);
|
|
162
|
+
};
|
|
163
|
+
for (const r of roots || []) visit(r, [], [], false, new Map());
|
|
164
|
+
return out;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** pointer (`assets/<name>`) → its uses in tree order. */
|
|
168
|
+
export function usesByPointer(graphics: readonly Graphic[]): Map<string, Graphic[]> {
|
|
169
|
+
const map = new Map<string, Graphic[]>();
|
|
170
|
+
for (const g of graphics) {
|
|
171
|
+
const p = g.node.asset;
|
|
172
|
+
if (typeof p !== "string") continue;
|
|
173
|
+
let list = map.get(p);
|
|
174
|
+
if (!list) { list = []; map.set(p, list); }
|
|
175
|
+
list.push(g);
|
|
176
|
+
}
|
|
177
|
+
return map;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// (ii): the transform Figma drew into the file. A visible asset leaf carries it as `sourceTransform`; an older
|
|
181
|
+
// export, and the hidden node before reuse, carry it bare.
|
|
182
|
+
function transformOf(n: IrNode): { rotation: number; flipped: boolean; skew: number } {
|
|
183
|
+
const t = n.sourceTransform;
|
|
184
|
+
return t
|
|
185
|
+
? { rotation: t.rotation ?? 0, flipped: t.flipped === true, skew: t.skew ?? 0 }
|
|
186
|
+
: { rotation: n.rotation ?? 0, flipped: n.flipped === true, skew: n.skew ?? 0 };
|
|
187
|
+
}
|
|
188
|
+
const BOX_TOL = 0.5;
|
|
189
|
+
const ANGLE_TOL = 0.01;
|
|
190
|
+
function sameLook(hidden: IrNode, twin: IrNode): boolean {
|
|
191
|
+
const a = hidden.box, b = twin.box;
|
|
192
|
+
// `!(d <= tol)`, not `d > tol`: a non-number size (a malformed tree on disk) is NaN and must refuse.
|
|
193
|
+
if (!a || !b || !(Math.abs(a.w - b.w) <= BOX_TOL) || !(Math.abs(a.h - b.h) <= BOX_TOL)) return false;
|
|
194
|
+
const x = transformOf(hidden), y = transformOf(twin);
|
|
195
|
+
return x.flipped === y.flipped && Math.abs(x.rotation - y.rotation) <= ANGLE_TOL && Math.abs(x.skew - y.skew) <= ANGLE_TOL;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// A twin's pointer must be the plugin's own shape, `assets/<name>`: a hand-edited or foreign sibling JSON
|
|
199
|
+
// naming `../x.svg` or another dir would hand that path on, and the reusing screen's `.assets.json` would
|
|
200
|
+
// read the file's bytes.
|
|
201
|
+
const ASSET_POINTER_RE = /^assets\/(?!\.\.?$)[^/\\]+$/;
|
|
202
|
+
const twinOk = (g: Graphic): boolean => !g.hidden && !g.blocked && typeof g.node.asset === "string" && ASSET_POINTER_RE.test(g.node.asset) && g.node.assetFrom === undefined && g.reuseKey !== undefined;
|
|
203
|
+
|
|
204
|
+
export interface ReuseOptions {
|
|
205
|
+
/** Trees of OTHER screens that may hold a twin for one of `keys` (writeScreen: from their `.assets.json`
|
|
206
|
+
* `reuseKey`). Called at most once, and only when a candidate is left unmatched in its own tree. */
|
|
207
|
+
otherTrees?: (keys: ReadonlySet<string>) => Iterable<readonly IrNode[]>;
|
|
208
|
+
/** The twin's pointer names a file on disk — a reuse must never create a dangling pointer. */
|
|
209
|
+
pointerExists?: (pointer: string) => boolean;
|
|
210
|
+
}
|
|
211
|
+
export interface ReuseResult {
|
|
212
|
+
/** `assetSkipped:"hidden"` nodes seen */
|
|
213
|
+
hidden: number;
|
|
214
|
+
/** of which now point at a twin's file */
|
|
215
|
+
reused: number;
|
|
216
|
+
/** of which the twin was in another screen */
|
|
217
|
+
crossScreen: number;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// The transform move: the reused file already has the twin's transform drawn in, so the bare fields go under
|
|
221
|
+
// `sourceTransform` — a builder that applies `rotation` would otherwise rotate it twice.
|
|
222
|
+
function moveTransform(n: IrNode): void {
|
|
223
|
+
const t: NonNullable<IrNode["sourceTransform"]> = {};
|
|
224
|
+
if (typeof n.rotation === "number") t.rotation = n.rotation;
|
|
225
|
+
if (n.flipped === true) t.flipped = true;
|
|
226
|
+
if (typeof n.skew === "number") t.skew = n.skew;
|
|
227
|
+
delete n.rotation; delete n.flipped; delete n.skew;
|
|
228
|
+
if (Object.keys(t).length) n.sourceTransform = t;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Point every hidden graphic that has a visible twin at the twin's file. Mutates `roots` in place. */
|
|
232
|
+
export function reuseHiddenAssets(roots: readonly IrNode[] | null | undefined, opts: ReuseOptions = {}): ReuseResult {
|
|
233
|
+
const graphics = collectGraphics(roots);
|
|
234
|
+
const candidates = graphics.filter((g) => g.node.assetSkipped === "hidden");
|
|
235
|
+
const result: ReuseResult = { hidden: candidates.length, reused: 0, crossScreen: 0 };
|
|
236
|
+
if (!candidates.length) return result;
|
|
237
|
+
const exists = opts.pointerExists || (() => true);
|
|
238
|
+
const pool = (list: readonly Graphic[]): Map<string, Graphic[]> => {
|
|
239
|
+
const m = new Map<string, Graphic[]>();
|
|
240
|
+
for (const g of list) {
|
|
241
|
+
if (!twinOk(g) || g.reuseKey === undefined) continue;
|
|
242
|
+
let l = m.get(g.reuseKey);
|
|
243
|
+
if (!l) { l = []; m.set(g.reuseKey, l); }
|
|
244
|
+
l.push(g);
|
|
245
|
+
}
|
|
246
|
+
return m;
|
|
247
|
+
};
|
|
248
|
+
const pick = (c: Graphic, twins: Map<string, Graphic[]>): Graphic | undefined =>
|
|
249
|
+
c.reuseKey === undefined ? undefined : (twins.get(c.reuseKey) || []).find((t) => typeof t.node.asset === "string" && exists(t.node.asset) && sameLook(c.node, t.node));
|
|
250
|
+
const apply = (c: Graphic, t: Graphic): void => {
|
|
251
|
+
const n = c.node, file = t.node.asset;
|
|
252
|
+
if (typeof file !== "string") return; // pick() only returns twins with a pointer
|
|
253
|
+
n.asset = file;
|
|
254
|
+
n.assetFrom = t.node.id;
|
|
255
|
+
delete n.assetSkipped;
|
|
256
|
+
moveTransform(n);
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
const local = pool(graphics);
|
|
260
|
+
const left: Graphic[] = [];
|
|
261
|
+
for (const c of candidates) {
|
|
262
|
+
if (c.blocked || c.reuseKey === undefined) continue;
|
|
263
|
+
const t = pick(c, local);
|
|
264
|
+
if (t) { apply(c, t); result.reused++; } else left.push(c);
|
|
265
|
+
}
|
|
266
|
+
if (left.length && opts.otherTrees) {
|
|
267
|
+
const keys = new Set<string>();
|
|
268
|
+
for (const c of left) if (c.reuseKey !== undefined) keys.add(c.reuseKey);
|
|
269
|
+
const other: Graphic[] = [];
|
|
270
|
+
for (const tree of opts.otherTrees(keys)) {
|
|
271
|
+
try { other.push(...collectGraphics(tree)); } catch { /* a malformed other screen holds no twin */ }
|
|
272
|
+
}
|
|
273
|
+
const far = pool(other);
|
|
274
|
+
for (const c of left) {
|
|
275
|
+
const t = pick(c, far);
|
|
276
|
+
if (t) { apply(c, t); result.reused++; result.crossScreen++; }
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return result;
|
|
280
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// The one atomic file write: a tmp file beside the target, then a rename over it. Shared by bridge/ and
|
|
2
|
+
// design-to-code/ (the layer may import bridge/src; bridge cannot import the layer).
|
|
3
|
+
//
|
|
4
|
+
// rename(2) replaces the destination atomically on POSIX, so a run killed mid-write (Ctrl-C, OOM) leaves the
|
|
5
|
+
// PREVIOUS file whole instead of a truncated one every later reader fails on. The tmp file sits in the target's
|
|
6
|
+
// own directory so the rename never crosses a filesystem. There is no fsync by default: these files are
|
|
7
|
+
// re-creatable exports, plans and reports, and the guarantee wanted is "never half a file after a killed process",
|
|
8
|
+
// which rename gives; surviving a power cut would cost a sync per write on hundreds of layer files. A file that has
|
|
9
|
+
// to outlive a power cut as well (a migration's journal) passes `sync`. `sync` is fsyncSync (node:fs). On macOS a
|
|
10
|
+
// plain fsync does not flush the drive's write cache (that takes fcntl F_FULLFSYNC); which one Bun issues is unverified.
|
|
11
|
+
import crypto from "node:crypto";
|
|
12
|
+
import fs from "node:fs";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import { errCode } from "./errmsg.ts";
|
|
15
|
+
|
|
16
|
+
/** A `.tmp-<pid>-<random>` suffix: unique per call, so two writers (two processes, or two writes in one) never
|
|
17
|
+
* share a tmp name, and a stale tmp from a killed run is never reused. */
|
|
18
|
+
export const tmpSuffix = (): string => `.tmp-${process.pid}-${crypto.randomBytes(4).toString("hex")}`;
|
|
19
|
+
|
|
20
|
+
// On Windows a rename over a file that another process has open — a reader polling it (verify-screen --wait reads the live
|
|
21
|
+
// status every --interval while the verifier writes it), an antivirus scan or an indexer — is refused
|
|
22
|
+
// (ERROR_ACCESS_DENIED / ERROR_SHARING_VIOLATION → EPERM, EACCES, EBUSY) until that handle closes, which is a moment: the
|
|
23
|
+
// rename is retried for up to RENAME_RETRY_MS (as graceful-fs does for npm) before the refusal stands. POSIX renames over
|
|
24
|
+
// an open file at once; there the first error is final — as it is for a target that is a directory (a refusal no handle
|
|
25
|
+
// closing will lift).
|
|
26
|
+
const RENAME_RETRY_MS = 2_000;
|
|
27
|
+
const RENAME_TRANSIENT = new Set(["EPERM", "EACCES", "EBUSY"]);
|
|
28
|
+
const sleeper = new Int32Array(new SharedArrayBuffer(4));
|
|
29
|
+
/** fs.renameSync(from, to), retried on Windows while the refusal can be another process's open handle (see above). */
|
|
30
|
+
export function renameOver(from: string, to: string): void {
|
|
31
|
+
const t0 = Date.now();
|
|
32
|
+
for (let delay = 5; ; delay = Math.min(delay * 2, 100)) {
|
|
33
|
+
try { fs.renameSync(from, to); return; } catch (e) {
|
|
34
|
+
const code = errCode(e);
|
|
35
|
+
if (process.platform !== "win32" || code === undefined || !RENAME_TRANSIENT.has(code) || Date.now() - t0 >= RENAME_RETRY_MS) throw e;
|
|
36
|
+
let dir = false;
|
|
37
|
+
try { dir = fs.statSync(to).isDirectory(); } catch { /* gone or unreadable: retried */ }
|
|
38
|
+
if (dir) throw e;
|
|
39
|
+
Atomics.wait(sleeper, 0, 0, delay);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The directory's entries flushed to disk (a rename into it, made durable). Not on Windows, where a directory
|
|
45
|
+
* cannot be opened for it; a filesystem that refuses it keeps the rename as it is. */
|
|
46
|
+
function syncDir(dir: string): void {
|
|
47
|
+
if (process.platform === "win32") return;
|
|
48
|
+
try {
|
|
49
|
+
const fd = fs.openSync(dir, "r");
|
|
50
|
+
try { fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
|
|
51
|
+
} catch { /* no directory fsync here: the rename stands, only its durability is the filesystem's */ }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Writes `data` to `file` atomically: a reader sees the old file or the new one, never half of either. The parent
|
|
55
|
+
* directory is created when missing. On any error the tmp file is removed, the old file is left as it was and the
|
|
56
|
+
* error is rethrown. `mode`: the new file's permission bits (a secret passes 0o600) — applied to the tmp file
|
|
57
|
+
* with open(2)'s mode AND chmod, because the umask masks the first. Without `mode` the umask default applies.
|
|
58
|
+
* `sync`: the bytes are fsynced before the rename and the directory after it, so the new file survives a power
|
|
59
|
+
* cut too. */
|
|
60
|
+
export function writeFileAtomic(file: string, data: string | Uint8Array, opts: { mode?: number; sync?: boolean } = {}): void {
|
|
61
|
+
const dir = path.dirname(path.resolve(file));
|
|
62
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
63
|
+
const tmp = file + tmpSuffix();
|
|
64
|
+
try {
|
|
65
|
+
// "wx": exclusive create — never follows a symlink planted at the tmp name
|
|
66
|
+
if (opts.sync) {
|
|
67
|
+
const fd = fs.openSync(tmp, "wx", opts.mode);
|
|
68
|
+
try { fs.writeFileSync(fd, data); fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
|
|
69
|
+
} else {
|
|
70
|
+
fs.writeFileSync(tmp, data, { flag: "wx", ...(opts.mode === undefined ? {} : { mode: opts.mode }) });
|
|
71
|
+
}
|
|
72
|
+
if (opts.mode !== undefined) fs.chmodSync(tmp, opts.mode);
|
|
73
|
+
renameOver(tmp, file);
|
|
74
|
+
} catch (e) {
|
|
75
|
+
try { fs.rmSync(tmp, { force: true }); } catch { /* best effort */ }
|
|
76
|
+
throw e;
|
|
77
|
+
}
|
|
78
|
+
if (opts.sync) syncDir(dir);
|
|
79
|
+
}
|