@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
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
// export-verdict.ts — ONE verdict on the export on disk, the answer `dtwin doctor` formats and figma_status returns.
|
|
2
|
+
//
|
|
3
|
+
// export-reader.ts reads an export's freshness per source (readExportFreshness: the design system's stamp, the page
|
|
4
|
+
// walk's, every screen grouped by the Figma file it came from). This judges that reading, once:
|
|
5
|
+
//
|
|
6
|
+
// - sources: what the export draws on — the screens of each Figma file (and those pulled before rows were stamped,
|
|
7
|
+
// a group of their own, never another source's), the design system, a page walk's own index when it lists no
|
|
8
|
+
// screen, and (only when nothing above carries a stamp) a top-level screen doc of the older flat layout. Each
|
|
9
|
+
// with its newest stamp, its age, and why it has none ("missing" / "unparseable").
|
|
10
|
+
// - newest: the newest parseable stamp of them all, and which source and file it came from. An export mixing a
|
|
11
|
+
// design system from one Figma file with screens from another is dated, and attributed, by what was pulled last
|
|
12
|
+
// — never by whichever single document happened to be read first.
|
|
13
|
+
// - stale: newest older than STALE_MS (the threshold drift-lint's stale-snapshot warning uses too).
|
|
14
|
+
// - unknownFreshness: why there is no age at all, when no source carries a usable stamp.
|
|
15
|
+
// - problems: every manifest, part or index that is there but unusable.
|
|
16
|
+
// - lastScreen: the newest screen row (figma_status's `lastScreenExport`).
|
|
17
|
+
//
|
|
18
|
+
// Wording is the caller's: doctor turns this into its export lines, figma_status hands it to an agent as data.
|
|
19
|
+
// Never throws: a missing or unreadable file adds nothing, and the reason is in `problems`.
|
|
20
|
+
import fs from "node:fs";
|
|
21
|
+
import path from "node:path";
|
|
22
|
+
import { DESIGN_SYSTEM_FILES } from "./design-system-layout.ts";
|
|
23
|
+
import { exportFileOf, readExportFreshness } from "./export-reader.ts";
|
|
24
|
+
import type { ExportFreshness, ExportProblem } from "./export-reader.ts";
|
|
25
|
+
|
|
26
|
+
/** How old the newest stamp may be before the export is stale — drift-lint's default max-age too. */
|
|
27
|
+
export const STALE_MS = 24 * 60 * 60 * 1000;
|
|
28
|
+
|
|
29
|
+
/** Why a source (or the whole export) has no usable age: no stamp at all, or one that is not a date. */
|
|
30
|
+
export type StampProblem = "missing" | "unparseable";
|
|
31
|
+
|
|
32
|
+
/** What snapshotAge reports. `problem` is set only when there is no usable age. */
|
|
33
|
+
export interface SnapshotAge {
|
|
34
|
+
exportedAt: string | undefined;
|
|
35
|
+
ageMs: number | undefined;
|
|
36
|
+
problem?: StampProblem;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// A parsed JSON document's own field, read the way plain JS reads `doc && doc.x`: undefined for a
|
|
40
|
+
// falsy doc and for anything that is not an object carrying the key.
|
|
41
|
+
function field(doc: unknown, key: string): unknown {
|
|
42
|
+
return doc && typeof doc === "object" && key in doc ? (doc as Record<string, unknown>)[key] : undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// A stamp is a string in every export this repo writes; a truthy non-string (hand-edited JSON) is
|
|
46
|
+
// stringified rather than passed through, so the declared `string` holds.
|
|
47
|
+
const asText = (v: unknown): string | undefined => (v ? (typeof v === "string" ? v : String(v)) : undefined);
|
|
48
|
+
|
|
49
|
+
// The ONE freshness rule, shared with design-to-code/drift-lint.ts: given a parsed document, what is its
|
|
50
|
+
// export timestamp and how old is it? Callers own the POLICY (max-age threshold, warning codes,
|
|
51
|
+
// wording); this owns only "which field, and is it parseable". Two copies of the parse rule meant
|
|
52
|
+
// figma_status and drift-lint could disagree about the same file on disk.
|
|
53
|
+
export function snapshotAge(doc: unknown, now?: number): SnapshotAge {
|
|
54
|
+
const exportedAt = asText(field(doc, "exportedAt"));
|
|
55
|
+
if (!exportedAt) return { exportedAt: undefined, ageMs: undefined, problem: "missing" };
|
|
56
|
+
const t = Date.parse(exportedAt);
|
|
57
|
+
if (Number.isNaN(t)) return { exportedAt, ageMs: undefined, problem: "unparseable" };
|
|
58
|
+
return { exportedAt, ageMs: (now || Date.now()) - t };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** One thing the export draws on. */
|
|
62
|
+
export interface ExportSource {
|
|
63
|
+
/** `screens`: the indexed screens of one Figma file; `design-system`; `page-walk`: pages/index.json's own stamp, for
|
|
64
|
+
* an index listing no screen; `screen-doc`: a top-level screen doc of the older flat layout */
|
|
65
|
+
readonly kind: "screens" | "design-system" | "page-walk" | "screen-doc";
|
|
66
|
+
/** the Figma file it came from (a screen doc's `screen` label stands in); null when not recorded */
|
|
67
|
+
readonly sourceFile: string | null;
|
|
68
|
+
/** how many screens (`screens` only) */
|
|
69
|
+
readonly count?: number;
|
|
70
|
+
/** its newest stamp; for `screens`, the newest parseable one among its rows */
|
|
71
|
+
readonly exportedAt: string | undefined;
|
|
72
|
+
readonly ageMs: number | undefined;
|
|
73
|
+
/** why there is no age */
|
|
74
|
+
readonly problem?: StampProblem;
|
|
75
|
+
/** the file (absolute) that stamp was read from; for `screens`, the newest one's screen file */
|
|
76
|
+
readonly file?: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The newest usable stamp of the export, and where it came from. */
|
|
80
|
+
export interface ExportNewest {
|
|
81
|
+
readonly exportedAt: string;
|
|
82
|
+
readonly ageMs: number;
|
|
83
|
+
readonly kind: ExportSource["kind"];
|
|
84
|
+
readonly sourceFile: string | null;
|
|
85
|
+
readonly file: string | undefined;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The newest screen row of the export's index. `file` is the screen JSON's path relative to the export dir, as the
|
|
89
|
+
* index records it. */
|
|
90
|
+
export interface LastScreenExport {
|
|
91
|
+
name: string | undefined;
|
|
92
|
+
id: string;
|
|
93
|
+
file: string | undefined;
|
|
94
|
+
exportedAt: string;
|
|
95
|
+
ageMs: number;
|
|
96
|
+
sourceFile?: string;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The verdict on the export at `root`. */
|
|
100
|
+
export interface ExportVerdict {
|
|
101
|
+
readonly root: string;
|
|
102
|
+
/** some export is there — a source, or at least a file that is there but unusable */
|
|
103
|
+
readonly exported: boolean;
|
|
104
|
+
/** screens per Figma file (named ones first, then the unrecorded group), the design system, then the rest */
|
|
105
|
+
readonly sources: readonly ExportSource[];
|
|
106
|
+
readonly newest: ExportNewest | null;
|
|
107
|
+
readonly staleAfterMs: number;
|
|
108
|
+
/** newest is older than staleAfterMs */
|
|
109
|
+
readonly stale: boolean;
|
|
110
|
+
/** why freshness is unknown: set when there are sources and none carries a usable stamp */
|
|
111
|
+
readonly unknownFreshness: string | undefined;
|
|
112
|
+
/** the source unknownFreshness names (set with it) */
|
|
113
|
+
readonly undated: ExportSource | undefined;
|
|
114
|
+
/** the sources name more than one Figma file */
|
|
115
|
+
readonly mixed: boolean;
|
|
116
|
+
readonly problems: readonly ExportProblem[];
|
|
117
|
+
readonly lastScreen: LastScreenExport | null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// A top-level design/<Screen>.json (the older flat layout's single-screen pull) is identified by SHAPE: design/ also
|
|
121
|
+
// holds files this tool GENERATES from an export — tokens.dtcg.json, tokens.resolver.json, target.json, an audit
|
|
122
|
+
// report — which carry no export stamp and must never be mistaken for the export. A real one carries `nodes[]` (a
|
|
123
|
+
// single-screen pull) or `layers[]`/`pageDirs[]` (a page walk); a blacklist would need updating with every new output.
|
|
124
|
+
function looksLikeExport(doc: unknown): boolean {
|
|
125
|
+
if (!doc || typeof doc !== "object") return false;
|
|
126
|
+
const files = field(doc, "files");
|
|
127
|
+
return Array.isArray(field(doc, "nodes")) || Array.isArray(field(doc, "layers")) || Array.isArray(field(doc, "pageDirs")) ||
|
|
128
|
+
Array.isArray(field(doc, "components")) || Array.isArray(field(doc, "variables")) || (!!files && typeof files === "object");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** One top-level export-shaped file: its path, its stamp, and its label. */
|
|
132
|
+
interface ScreenDoc { file: string; exportedAt: unknown; label: string | undefined }
|
|
133
|
+
|
|
134
|
+
// Every top-level .json of the export but the manifest, newest first, that looks like an export. Top level only —
|
|
135
|
+
// never a recursive walk, since this runs on every figma_status/doctor call and design/assets/ can hold thousands of
|
|
136
|
+
// files. An unreadable or unparseable one is passed over: only the named files' problems are reported.
|
|
137
|
+
function topLevelDocs(dir: string): ScreenDoc[] {
|
|
138
|
+
let entries: fs.Dirent[] = [];
|
|
139
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return []; }
|
|
140
|
+
const rest: Array<{ f: string; mtime: number }> = [];
|
|
141
|
+
for (const e of entries) {
|
|
142
|
+
if (!e.isFile() || !e.name.endsWith(".json") || e.name === DESIGN_SYSTEM_FILES.MANIFEST) continue;
|
|
143
|
+
const f = path.join(dir, e.name);
|
|
144
|
+
try { rest.push({ f, mtime: fs.statSync(f).mtimeMs }); } catch { /* vanished mid-read */ }
|
|
145
|
+
}
|
|
146
|
+
rest.sort((a, b) => b.mtime - a.mtime);
|
|
147
|
+
const out: ScreenDoc[] = [];
|
|
148
|
+
for (const { f } of rest) {
|
|
149
|
+
let doc: unknown;
|
|
150
|
+
try { doc = JSON.parse(fs.readFileSync(f, "utf8")) as unknown; } catch { continue; }
|
|
151
|
+
if (!looksLikeExport(doc)) continue;
|
|
152
|
+
// A screen doc has no `file` (that is a design-system field); its `screen` is the human label.
|
|
153
|
+
const screen = field(doc, "screen");
|
|
154
|
+
out.push({ file: f, exportedAt: field(doc, "exportedAt"), label: asText(field(doc, "file")) || (typeof screen === "string" ? screen : undefined) });
|
|
155
|
+
}
|
|
156
|
+
return out;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** The verdict on one freshness reading (export-reader.ts readExportFreshness), as of `now`. */
|
|
160
|
+
function verdictOf(fresh: ExportFreshness, now: number): ExportVerdict {
|
|
161
|
+
const aged = (at: unknown): SnapshotAge => snapshotAge({ exportedAt: at }, now);
|
|
162
|
+
const sources: ExportSource[] = [];
|
|
163
|
+
const add = (kind: ExportSource["kind"], sourceFile: string | null | undefined, at: unknown, file: string | undefined, count?: number): void => {
|
|
164
|
+
const { exportedAt, ageMs, problem } = aged(at);
|
|
165
|
+
sources.push({ kind, sourceFile: sourceFile || null, ...(count === undefined ? {} : { count }), exportedAt, ageMs,
|
|
166
|
+
...(problem ? { problem } : {}), ...(file ? { file } : {}) });
|
|
167
|
+
};
|
|
168
|
+
const screenFile = (rel: unknown): string | undefined => exportFileOf(fresh.root, rel) ?? undefined;
|
|
169
|
+
const named = fresh.screens.filter((g) => g.sourceFile !== null);
|
|
170
|
+
for (const g of [...named, ...fresh.screens.filter((g) => g.sourceFile === null)]) add("screens", g.sourceFile, g.newestAt, screenFile(g.newestRow?.file), g.count);
|
|
171
|
+
if (fresh.designSystem) add("design-system", fresh.designSystem.stamp.file, fresh.designSystem.stamp.exportedAt, fresh.designSystem.from);
|
|
172
|
+
// The page walk's own index: when it lists no screen, or when it names the Figma file that rows of an older bridge
|
|
173
|
+
// do not (those rows stay unattributed — some may be an earlier walk's — and the walk says what it was).
|
|
174
|
+
const unrecorded = fresh.screens.some((g) => g.sourceFile === null);
|
|
175
|
+
if (fresh.index && (!fresh.screens.length || (unrecorded && fresh.index.sourceFile))) add("page-walk", fresh.index.sourceFile, fresh.index.exportedAt, fresh.index.from);
|
|
176
|
+
// The older flat layout's single-screen pull wrote only design/<Screen>.json: an export too, consulted only when
|
|
177
|
+
// nothing above is stamped. A stamped one wins over an unstamped one (design/variables.json — export-shaped, written
|
|
178
|
+
// beside the screen doc, carrying no exportedAt of its own — is the case that forces it); an unstamped one is still
|
|
179
|
+
// reported, with its warning, rather than "nothing exported".
|
|
180
|
+
if (!sources.some((s) => s.exportedAt)) {
|
|
181
|
+
const docs = topLevelDocs(fresh.root);
|
|
182
|
+
const doc = docs.find((d) => !!d.exportedAt) ?? (sources.length ? undefined : docs[0]);
|
|
183
|
+
if (doc) add("screen-doc", doc.label, doc.exportedAt, doc.file);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
let newest: ExportNewest | null = null;
|
|
187
|
+
for (const s of sources) {
|
|
188
|
+
if (s.exportedAt === undefined || s.ageMs === undefined) continue;
|
|
189
|
+
// a tie goes to a source that names its Figma file (a page walk's index beside the rows it stamped)
|
|
190
|
+
if (!newest || s.ageMs < newest.ageMs || (s.ageMs === newest.ageMs && newest.sourceFile === null && s.sourceFile !== null)) newest = { exportedAt: s.exportedAt, ageMs: s.ageMs, kind: s.kind, sourceFile: s.sourceFile, file: s.file };
|
|
191
|
+
}
|
|
192
|
+
// why there is no age, said of one source: a stamp that is not a date says more than one that is absent, and a
|
|
193
|
+
// source read from a file (and naming its Figma file) more than screen rows that name neither
|
|
194
|
+
const undated = newest ? undefined
|
|
195
|
+
: sources.find((s) => s.problem === "unparseable") ?? sources.find((s) => s.file && s.sourceFile !== null) ?? sources.find((s) => s.file) ?? sources[0];
|
|
196
|
+
const label = (s: ExportSource): string => (s.file ? path.basename(s.file) : s.kind === "screens" ? "the screen index rows" : s.kind);
|
|
197
|
+
const unknownFreshness = !undated ? undefined
|
|
198
|
+
: undated.problem === "unparseable" ? `exportedAt ('${undated.exportedAt}') in ${label(undated)} is not a parseable timestamp`
|
|
199
|
+
: `no exportedAt stamp in ${label(undated)} — freshness unknown (re-export with the current plugin)`;
|
|
200
|
+
|
|
201
|
+
const best = fresh.newestScreen;
|
|
202
|
+
const lastScreen: LastScreenExport | null = best && {
|
|
203
|
+
name: typeof best.row.name === "string" ? best.row.name : undefined,
|
|
204
|
+
id: best.row.id,
|
|
205
|
+
file: typeof best.row.file === "string" ? best.row.file : undefined,
|
|
206
|
+
exportedAt: best.exportedAt,
|
|
207
|
+
ageMs: now - best.t,
|
|
208
|
+
...(typeof best.row.sourceFile === "string" && best.row.sourceFile ? { sourceFile: best.row.sourceFile } : {}),
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
return {
|
|
212
|
+
root: fresh.root,
|
|
213
|
+
exported: sources.length > 0 || fresh.problems.length > 0,
|
|
214
|
+
sources,
|
|
215
|
+
newest,
|
|
216
|
+
staleAfterMs: STALE_MS,
|
|
217
|
+
stale: newest !== null && newest.ageMs > STALE_MS,
|
|
218
|
+
unknownFreshness,
|
|
219
|
+
undated,
|
|
220
|
+
// a screen doc's label is a screen's name, not a Figma file's
|
|
221
|
+
mixed: new Set(sources.filter((s) => s.kind !== "screen-doc" && s.sourceFile !== null).map((s) => s.sourceFile)).size > 1,
|
|
222
|
+
problems: fresh.problems,
|
|
223
|
+
lastScreen,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** The verdict on the export at `root` (export-reader.ts exportRoot resolves which), as of `now`. Never throws. */
|
|
228
|
+
export function readExportVerdict(root: string, now: number = Date.now()): ExportVerdict {
|
|
229
|
+
return verdictOf(readExportFreshness(root), now);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** figma_status's `snapshot`: the verdict in the shape that field has always had — the newest stamp's file, age and
|
|
233
|
+
* source (`warning` when there is none), or `{ file, error }` when the only export file there is unusable — with the
|
|
234
|
+
* verdict's attribution and staleness alongside. null when nothing is exported. */
|
|
235
|
+
export type StatusSnapshot =
|
|
236
|
+
| { file: string; exportedAt: string | undefined; ageMs: number | undefined; sourceFile: string | undefined; warning: string | undefined;
|
|
237
|
+
stale: boolean; staleAfterMs: number; sources: readonly ExportSource[]; problems: readonly ExportProblem[] }
|
|
238
|
+
| { file: string; error: string; problems: readonly ExportProblem[] };
|
|
239
|
+
|
|
240
|
+
export function statusSnapshot(v: ExportVerdict): StatusSnapshot | null {
|
|
241
|
+
const first = v.undated ?? v.sources[0];
|
|
242
|
+
if (!first) {
|
|
243
|
+
const p = v.problems[0];
|
|
244
|
+
return p ? { file: path.join(v.root, ...p.file.split("/")), error: `${p.file} ${p.error}`, problems: v.problems } : null;
|
|
245
|
+
}
|
|
246
|
+
const at = v.newest ?? first;
|
|
247
|
+
return {
|
|
248
|
+
file: at.file ?? v.root,
|
|
249
|
+
exportedAt: at.exportedAt,
|
|
250
|
+
ageMs: at.ageMs,
|
|
251
|
+
sourceFile: at.sourceFile ?? undefined,
|
|
252
|
+
warning: v.unknownFreshness,
|
|
253
|
+
stale: v.stale,
|
|
254
|
+
staleAfterMs: v.staleAfterMs,
|
|
255
|
+
sources: v.sources,
|
|
256
|
+
problems: v.problems,
|
|
257
|
+
};
|
|
258
|
+
}
|