@penpair/marwes-ui 0.1.3 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,222 @@
1
+ import { createHash } from "node:crypto";
2
+ import { mkdir, open, readFile, unlink } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { atomicWriteFile, ensureGitignoreEntry, resolveProjectInput, resolveProjectOutput, } from "@penpair/core";
5
+ import { canonical, normalizeValue } from "./tokens.js";
6
+ const tokenMap = (sets) => new Map(sets.map((set) => [
7
+ set.name,
8
+ new Map(set.tokens.map((token) => [
9
+ token.name,
10
+ token,
11
+ ])),
12
+ ]));
13
+ const values = (token) => token
14
+ ? {
15
+ type: token.type,
16
+ value: normalizeValue(token.type, token.value),
17
+ description: token.description || "",
18
+ }
19
+ : {
20
+ type: null,
21
+ value: null,
22
+ description: null,
23
+ };
24
+ /** Report each changed property against a verified ancestor; never guess a rename. */
25
+ export function ownershipReport(desired, live, ancestor) {
26
+ const wanted = tokenMap(desired.sets);
27
+ const actual = tokenMap(live.sets);
28
+ const old = tokenMap(ancestor?.sets ?? []);
29
+ const changes = [];
30
+ const unmanagedSets = live.sets
31
+ .filter((set) => !wanted.has(set.name) && !old.has(set.name))
32
+ .map((set) => set.name);
33
+ const unmanagedTokens = [];
34
+ for (const setName of new Set([
35
+ ...wanted.keys(),
36
+ ...old.keys(),
37
+ ])) {
38
+ for (const name of new Set([
39
+ ...(wanted.get(setName)?.keys() ?? []),
40
+ ...(old.get(setName)?.keys() ?? []),
41
+ ])) {
42
+ const a = values(old.get(setName)?.get(name));
43
+ const d = values(wanted.get(setName)?.get(name));
44
+ const l = values(actual.get(setName)?.get(name));
45
+ for (const property of [
46
+ "type",
47
+ "value",
48
+ "description",
49
+ ]) {
50
+ const same = (x, y) => canonical(x) === canonical(y);
51
+ if (same(d[property], l[property])) {
52
+ continue;
53
+ }
54
+ let kind;
55
+ if (!ancestor || !old.get(setName)?.has(name)) {
56
+ kind = !actual.get(setName)?.has(name) ? "upstream" : "untracked";
57
+ }
58
+ else {
59
+ const upstream = !same(d[property], a[property]);
60
+ const designer = !same(l[property], a[property]);
61
+ kind = upstream && designer ? "conflict" : designer ? "designer" : "upstream";
62
+ }
63
+ changes.push({
64
+ set: setName,
65
+ name,
66
+ property,
67
+ kind,
68
+ ancestor: a[property],
69
+ desired: d[property],
70
+ live: l[property],
71
+ });
72
+ }
73
+ }
74
+ for (const name of actual.get(setName)?.keys() ?? []) {
75
+ if (!wanted.get(setName)?.has(name) && !old.get(setName)?.has(name)) {
76
+ unmanagedTokens.push({
77
+ set: setName,
78
+ name,
79
+ });
80
+ }
81
+ }
82
+ }
83
+ const key = (theme) => canonical([
84
+ theme.group,
85
+ theme.name,
86
+ ]);
87
+ const previousThemes = new Map((ancestor?.themes ?? []).map((theme) => [
88
+ key(theme),
89
+ theme,
90
+ ]));
91
+ for (const theme of desired.themes) {
92
+ const current = live.themes.find((t) => key(t) === key(theme));
93
+ const previous = previousThemes.get(key(theme));
94
+ const managed = new Set([
95
+ ...desired.sets,
96
+ ...(ancestor?.sets ?? []),
97
+ ].map((set) => set.name));
98
+ const orderedMembership = (sets) => sets === undefined ? null : sets.filter((name) => managed.has(name)).sort();
99
+ const a = orderedMembership(previous?.sets);
100
+ const d = orderedMembership(theme.sets);
101
+ const l = orderedMembership(current?.sets);
102
+ if (canonical(d) === canonical(l)) {
103
+ continue;
104
+ }
105
+ const kind = !previous
106
+ ? !current
107
+ ? "upstream"
108
+ : "untracked"
109
+ : canonical(l) === canonical(a)
110
+ ? "upstream"
111
+ : canonical(d) === canonical(a)
112
+ ? "designer"
113
+ : "conflict";
114
+ changes.push({
115
+ set: "@themes",
116
+ name: key(theme),
117
+ property: "membership",
118
+ kind,
119
+ ancestor: a,
120
+ desired: d,
121
+ live: l,
122
+ });
123
+ }
124
+ return {
125
+ changes,
126
+ blockers: changes.filter((change) => change.kind !== "upstream"),
127
+ unmanagedSets,
128
+ unmanagedTokens,
129
+ };
130
+ }
131
+ /** Destruction is conservative across all sets, including those outside adapter ownership. */
132
+ export function assertSafeDestruction(actions, live, bindingCounts = {}) {
133
+ for (const action of actions) {
134
+ const names = action.kind === "removeTokens"
135
+ ? action.names
136
+ : action.kind === "replaceTokens"
137
+ ? action.tokens
138
+ .filter((token) => live.sets
139
+ .find((set) => set.name === action.set)
140
+ ?.tokens.find((old) => old.name === token.name)?.type !== token.type)
141
+ .map((token) => token.name)
142
+ : [];
143
+ for (const name of names) {
144
+ if ((bindingCounts[name] ?? 0) > 0) {
145
+ throw new Error(`Cannot remove or change type of ${name}: bound to ${bindingCounts[name]} retained shape(s)`);
146
+ }
147
+ for (const set of live.sets) {
148
+ for (const token of set.tokens) {
149
+ if (set.name ===
150
+ action.set &&
151
+ token.name === name) {
152
+ continue;
153
+ }
154
+ if (JSON.stringify(token.value).includes("{" + name + "}")) {
155
+ throw new Error(`Cannot remove or change type of ${name}: referenced by ${set.name}/${token.name}`);
156
+ }
157
+ }
158
+ }
159
+ }
160
+ }
161
+ }
162
+ const ledgerPath = (fileId) => `.penpair/token-sync/${createHash("sha256").update(fileId).digest("hex")}.json`;
163
+ export async function readSyncAncestor(root, fileId) {
164
+ try {
165
+ const record = JSON.parse(await readFile(await resolveProjectInput(root, ledgerPath(fileId)), "utf8"));
166
+ if (record.schemaVersion !== 1 ||
167
+ record.fileId !== fileId ||
168
+ record.sha256 !== createHash("sha256").update(canonical(record.snapshot)).digest("hex") ||
169
+ ![
170
+ 1,
171
+ 2,
172
+ ].includes(record.snapshot?.schemaVersion) ||
173
+ !Array.isArray(record.snapshot?.sets) ||
174
+ !Array.isArray(record.snapshot?.themes)) {
175
+ throw new Error("Invalid token-sync ancestor; inspect it before replacing it");
176
+ }
177
+ return record.snapshot;
178
+ }
179
+ catch (error) {
180
+ if (error.code === "ENOENT") {
181
+ return undefined;
182
+ }
183
+ throw error;
184
+ }
185
+ }
186
+ export async function saveSyncAncestor(root, fileId, snapshot, expectedAncestor) {
187
+ await ensureGitignoreEntry(root, ".penpair", {
188
+ directory: true,
189
+ comment: "penpair: private token synchronization evidence",
190
+ });
191
+ const destination = await resolveProjectOutput(root, ledgerPath(fileId));
192
+ await mkdir(path.dirname(destination), {
193
+ recursive: true,
194
+ mode: 0o700,
195
+ });
196
+ const lockPath = destination + ".lock";
197
+ const lock = await open(lockPath, "wx", 0o600).catch(() => {
198
+ throw new Error("Token ancestor is locked; inspect any interrupted local sync before retrying");
199
+ });
200
+ try {
201
+ const current = await readSyncAncestor(root, fileId);
202
+ if (current &&
203
+ canonical(current) !== canonical(snapshot) &&
204
+ canonical(current) !== canonical(expectedAncestor)) {
205
+ throw new Error("Token ancestor advanced concurrently; newer sync evidence was preserved");
206
+ }
207
+ await atomicWriteFile(destination, JSON.stringify({
208
+ schemaVersion: 1,
209
+ fileId,
210
+ savedAt: new Date().toISOString(),
211
+ sha256: createHash("sha256").update(canonical(snapshot)).digest("hex"),
212
+ snapshot,
213
+ }, null, 2) + "\n", {
214
+ replace: true,
215
+ mode: 0o600,
216
+ });
217
+ }
218
+ finally {
219
+ await lock.close();
220
+ await unlink(lockPath);
221
+ }
222
+ }
package/dist/penpot.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { type PenpotToolClient, type Snippet } from "@penpair/core";
1
+ import { type LayoutOptions, type LayoutResult, type JournalOptions, type PenpotToolClient, type Snippet } from "@penpair/core";
2
2
  import { canonical, type PenpotToken, type TokenSnapshot } from "./tokens.js";
3
3
  /** What the Penpot file holds today. */
4
4
  export interface LiveTokens {
@@ -66,7 +66,9 @@ export interface Drift {
66
66
  unexpectedSets: string[];
67
67
  }
68
68
  /** Compares the committed expectation with what the file holds. Only the sets the expectation names are judged; other sets are the project's own. */
69
- export declare function compareToPenpot(expected: TokenSnapshot, live: LiveTokens): Drift;
69
+ export declare function compareToPenpot(expected: TokenSnapshot, live: LiveTokens, options?: {
70
+ ancestor?: TokenSnapshot;
71
+ }): Drift;
70
72
  export declare function formatDrift(drift: Drift, impact?: Record<string, number>, limit?: number): string[];
71
73
  export type Action = {
72
74
  kind: "createSet";
@@ -106,12 +108,21 @@ export type Action = {
106
108
  export interface PlanOptions {
107
109
  /** Also remove what Penpot has but the expectation does not (tokens added by hand, sets no longer expected, extra sets in a theme). */
108
110
  prune?: boolean;
111
+ /** Only tokens in a verified ancestor can be pruned. */
112
+ ancestor?: TokenSnapshot;
109
113
  }
110
114
  /** The writes that bring the file to the expectation. Nothing in a set the expectation does not name is ever touched. */
111
115
  export declare function planPush(expected: TokenSnapshot, live: LiveTokens, options?: PlanOptions): Action[];
112
116
  /** Splits a long token list into batches that stay well under the request limit. */
113
117
  export declare function inBatches(tokens: PenpotToken[]): PenpotToken[][];
114
118
  export interface ApplyReport {
119
+ redacted?: boolean;
120
+ complete?: boolean;
121
+ layout?: LayoutResult;
122
+ journal?: {
123
+ runId: string;
124
+ state: string;
125
+ };
115
126
  steps: string[];
116
127
  structureBefore: {
117
128
  count: number;
@@ -131,7 +142,14 @@ export declare function readLive(client: PenpotToolClient): Promise<LiveTokens>;
131
142
  * Carries out the plan one write at a time, stopping at the first anomaly, then reads everything back: the file must now match the
132
143
  * expectation, and no shape may have moved or been rebound.
133
144
  */
134
- export declare function applyPlan(client: PenpotToolClient, expected: TokenSnapshot, actions: Action[], onStep?: (message: string) => void): Promise<ApplyReport>;
145
+ export declare function applyPlan(client: PenpotToolClient, expected: TokenSnapshot, actions: Action[], onStep?: (message: string) => void, options?: {
146
+ layout?: false | LayoutOptions;
147
+ expectedFileId?: string;
148
+ ancestor?: TokenSnapshot;
149
+ prune?: boolean;
150
+ expectedLive?: LiveTokens;
151
+ journal?: Omit<JournalOptions, "input" | "operation" | "payload" | "target">;
152
+ }): Promise<ApplyReport>;
135
153
  /** The use count of every token a drift would change, so a bump can be judged by what it reaches. */
136
154
  export declare function impactOf(client: PenpotToolClient): Promise<Record<string, number>>;
137
155
  export { canonical };