@penpair/marwes-ui 0.0.0-stage → 0.1.3
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 +29 -2
- package/dist/commands.d.ts +28 -0
- package/dist/commands.js +232 -0
- package/dist/config.d.ts +19 -0
- package/dist/config.js +34 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +11 -0
- package/dist/marwes.d.ts +17 -0
- package/dist/marwes.js +72 -0
- package/dist/penpot.d.ts +137 -0
- package/dist/penpot.js +295 -0
- package/dist/snapshot-file.d.ts +43 -0
- package/dist/snapshot-file.js +87 -0
- package/dist/theme-input.d.ts +11 -0
- package/dist/theme-input.js +21 -0
- package/dist/tokens.d.ts +75 -0
- package/dist/tokens.js +186 -0
- package/package.json +51 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Niklas Westman
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,30 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @penpair/marwes-ui
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**The [Marwes UI](https://marwes.io) adapter for [penpair](https://github.com/niklas-westman/penpair): your Marwes design tokens, and your own theme on top, in your Penpot file, with drift checks against the Marwes your app actually uses.**
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npm i -D @penpair/marwes-ui
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
List it in `penpair.config.json` and say where your theme lives:
|
|
10
|
+
|
|
11
|
+
```json
|
|
12
|
+
{
|
|
13
|
+
"adapters": ["@penpair/marwes-ui"],
|
|
14
|
+
"marwes": {
|
|
15
|
+
"project": "packages/app",
|
|
16
|
+
"themes": { "light": { "module": "packages/app/src/theme.ts", "export": "myLight" } }
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
penpair marwes tokens snapshot --write # the expected tokens, from the installed Marwes and your theme: commit the file
|
|
23
|
+
penpair marwes tokens check # offline, for CI: is the snapshot current? (fails after a Marwes bump)
|
|
24
|
+
penpair marwes tokens diff # Penpot against the snapshot, and how many shapes each change reaches
|
|
25
|
+
penpair marwes tokens push --apply # make Penpot match, then read everything back to verify
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
The installed Marwes and your theme file are the truth; Penpot is made to match, and anything that stops matching is found. The full guide, including what drifts, what catches it and how to put it in CI: [Marwes tokens in Penpot, and keeping them true](https://github.com/niklas-westman/penpair/blob/main/docs/marwes-tokens.md).
|
|
29
|
+
|
|
30
|
+
Needs `@penpair/web` or `@penpair/docker` (the `penpair` command) and Marwes installed in the project. Components are not covered yet.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { type AdapterCommand } from "@penpair/core";
|
|
2
|
+
import { type MarwesSettings } from "./config.js";
|
|
3
|
+
import { type InstalledMarwes } from "./marwes.js";
|
|
4
|
+
import { type SnapshotDiff } from "./snapshot-file.js";
|
|
5
|
+
import { type TokenSnapshot } from "./tokens.js";
|
|
6
|
+
/** What the project's configuration, the installed Marwes and the project's themes say the Penpot tokens should be. */
|
|
7
|
+
export interface Expectation {
|
|
8
|
+
settings: MarwesSettings;
|
|
9
|
+
installed: InstalledMarwes;
|
|
10
|
+
fresh: TokenSnapshot;
|
|
11
|
+
problems: string[];
|
|
12
|
+
}
|
|
13
|
+
export declare function computeExpectation(root: string, rawConfig: Record<string, unknown>, instance: string): Promise<Expectation>;
|
|
14
|
+
export interface SnapshotCheck {
|
|
15
|
+
/** The committed snapshot equals what the installed Marwes and the themes give now. */
|
|
16
|
+
current: boolean;
|
|
17
|
+
committed?: TokenSnapshot;
|
|
18
|
+
diff: SnapshotDiff;
|
|
19
|
+
problems: string[];
|
|
20
|
+
expectation: Expectation;
|
|
21
|
+
}
|
|
22
|
+
export declare function checkSnapshotFor(root: string, rawConfig: Record<string, unknown>, instance: string): Promise<SnapshotCheck>;
|
|
23
|
+
/**
|
|
24
|
+
* For a project's own test suite: resolves the project's penpair.config.json and checks that the committed Marwes token snapshot
|
|
25
|
+
* is current, so that bumping Marwes or editing the theme without regenerating the snapshot fails a test.
|
|
26
|
+
*/
|
|
27
|
+
export declare function checkTokenSnapshot(root: string): Promise<SnapshotCheck>;
|
|
28
|
+
export declare const marwesCommand: AdapterCommand;
|
package/dist/commands.js
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { parseArgs } from "node:util";
|
|
4
|
+
import { loadConfig, readRawConfig } from "@penpair/core";
|
|
5
|
+
import { readMarwesSettings } from "./config.js";
|
|
6
|
+
import { familyProblems, loadInstalledMarwes } from "./marwes.js";
|
|
7
|
+
import { applyPlan, compareToPenpot, describePlan, formatDrift, impactOf, planPush, readLive } from "./penpot.js";
|
|
8
|
+
import { diffSnapshots, formatDiff, readSnapshot, writeSnapshot } from "./snapshot-file.js";
|
|
9
|
+
import { loadThemeInput } from "./theme-input.js";
|
|
10
|
+
import { buildSnapshot } from "./tokens.js";
|
|
11
|
+
const adapterVersion = createRequire(import.meta.url)("../package.json").version;
|
|
12
|
+
export async function computeExpectation(root, rawConfig, instance) {
|
|
13
|
+
const settings = readMarwesSettings(rawConfig, instance);
|
|
14
|
+
const installed = await loadInstalledMarwes(path.resolve(root, settings.project));
|
|
15
|
+
const [light, dark] = await Promise.all([settings.themes.light, settings.themes.dark].map((reference) => (reference ? loadThemeInput(root, reference) : undefined)));
|
|
16
|
+
const fresh = buildSnapshot({
|
|
17
|
+
core: installed.core,
|
|
18
|
+
version: installed.version,
|
|
19
|
+
name: settings.name,
|
|
20
|
+
project: { ...(light ? { light: light.input } : {}), ...(dark ? { dark: dark.input } : {}) },
|
|
21
|
+
generatedBy: `@penpair/marwes-ui@${adapterVersion}`,
|
|
22
|
+
});
|
|
23
|
+
return { settings, installed, fresh, problems: familyProblems(installed) };
|
|
24
|
+
}
|
|
25
|
+
export async function checkSnapshotFor(root, rawConfig, instance) {
|
|
26
|
+
const expectation = await computeExpectation(root, rawConfig, instance);
|
|
27
|
+
const committed = await readSnapshot(root, expectation.settings.snapshot);
|
|
28
|
+
const diff = diffSnapshots(committed, expectation.fresh);
|
|
29
|
+
const problems = [...expectation.problems];
|
|
30
|
+
if (!committed)
|
|
31
|
+
problems.push(`there is no snapshot yet at ${expectation.settings.snapshot}; run \`penpair marwes tokens snapshot --write\` and commit it`);
|
|
32
|
+
return { current: !!committed && diff.identical && committed.hash === expectation.fresh.hash && expectation.problems.length === 0, committed, diff, problems, expectation };
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* For a project's own test suite: resolves the project's penpair.config.json and checks that the committed Marwes token snapshot
|
|
36
|
+
* is current, so that bumping Marwes or editing the theme without regenerating the snapshot fails a test.
|
|
37
|
+
*/
|
|
38
|
+
export async function checkTokenSnapshot(root) {
|
|
39
|
+
const config = await loadConfig(root);
|
|
40
|
+
return checkSnapshotFor(root, await readRawConfig(root), config.instance);
|
|
41
|
+
}
|
|
42
|
+
const summary = (snapshot) => `Marwes ${snapshot.marwesVersion}, ${snapshot.sets.length} sets, ${snapshot.sets.reduce((n, s) => n + s.tokens.length, 0)} tokens, ${snapshot.hash.slice(0, 10)}`;
|
|
43
|
+
/** What the snapshot says it does not cover, so a reader is never left thinking everything Marwes provides is in Penpot. */
|
|
44
|
+
const coverage = (snapshot) => ((snapshot.unmapped ?? []).length > 0 ? `${snapshot.unmapped.length} Marwes variable(s) are not tokens: ${snapshot.unmapped.slice(0, 6).join(", ")}${snapshot.unmapped.length > 6 ? ", ..." : ""}` : undefined);
|
|
45
|
+
const flags = (args) => parseArgs({ args, allowPositionals: true, options: { write: { type: "boolean" }, apply: { type: "boolean" }, prune: { type: "boolean" }, json: { type: "boolean" } } });
|
|
46
|
+
const staleAdvice = "Run `penpair marwes tokens snapshot --write`, review the change in git, and commit it.";
|
|
47
|
+
async function snapshotCommand(context, args) {
|
|
48
|
+
const { values } = flags(args);
|
|
49
|
+
const check = await checkSnapshotFor(context.root, context.rawConfig, context.config.instance);
|
|
50
|
+
const fresh = check.expectation.fresh;
|
|
51
|
+
if (values.json)
|
|
52
|
+
console.log(JSON.stringify({ current: check.current, summary: summary(fresh), diff: check.diff, problems: check.problems }, null, 2));
|
|
53
|
+
else {
|
|
54
|
+
console.log(`Expected from the installed ${summary(fresh)}`);
|
|
55
|
+
for (const line of formatDiff(check.diff))
|
|
56
|
+
console.log(` ${line}`);
|
|
57
|
+
if (coverage(fresh))
|
|
58
|
+
console.log(`note ${coverage(fresh)}`);
|
|
59
|
+
for (const problem of check.problems.filter((p) => !p.startsWith("there is no snapshot")))
|
|
60
|
+
console.log(`FAIL ${problem}`);
|
|
61
|
+
}
|
|
62
|
+
if (check.expectation.problems.length > 0)
|
|
63
|
+
return 1;
|
|
64
|
+
if (check.current) {
|
|
65
|
+
if (!values.json)
|
|
66
|
+
console.log(`ok ${check.expectation.settings.snapshot} is current`);
|
|
67
|
+
return 0;
|
|
68
|
+
}
|
|
69
|
+
if (!values.write) {
|
|
70
|
+
if (!values.json)
|
|
71
|
+
console.log(`${check.committed ? "The committed snapshot is stale." : "There is no snapshot yet."} Run again with --write to save ${check.expectation.settings.snapshot}.`);
|
|
72
|
+
return 2;
|
|
73
|
+
}
|
|
74
|
+
await writeSnapshot(context.root, check.expectation.settings.snapshot, fresh);
|
|
75
|
+
if (!values.json)
|
|
76
|
+
console.log(`wrote ${check.expectation.settings.snapshot}. Review the change in git and commit it; then \`penpair marwes tokens diff\` shows what Penpot needs.`);
|
|
77
|
+
return 0;
|
|
78
|
+
}
|
|
79
|
+
async function checkCommand(context, args) {
|
|
80
|
+
const { values } = flags(args);
|
|
81
|
+
const check = await checkSnapshotFor(context.root, context.rawConfig, context.config.instance);
|
|
82
|
+
if (values.json) {
|
|
83
|
+
console.log(JSON.stringify({ current: check.current, installed: check.expectation.installed.versions, diff: check.diff, problems: check.problems }, null, 2));
|
|
84
|
+
return check.current ? 0 : check.expectation.problems.length > 0 ? 1 : 2;
|
|
85
|
+
}
|
|
86
|
+
if (check.current) {
|
|
87
|
+
console.log(`ok ${check.expectation.settings.snapshot} is current (${summary(check.expectation.fresh)})`);
|
|
88
|
+
return 0;
|
|
89
|
+
}
|
|
90
|
+
for (const line of formatDiff(check.diff))
|
|
91
|
+
console.log(` ${line}`);
|
|
92
|
+
for (const problem of check.problems)
|
|
93
|
+
console.log(`FAIL ${problem}`);
|
|
94
|
+
if (check.committed && check.expectation.problems.length === 0) {
|
|
95
|
+
const cause = check.committed.marwesVersion !== check.expectation.installed.version ? `it was made from Marwes ${check.committed.marwesVersion} and the installed Marwes is ${check.expectation.installed.version}` : "the installed Marwes or the project's theme has changed since it was made";
|
|
96
|
+
console.log(`FAIL the committed snapshot is stale: ${cause}. ${staleAdvice}`);
|
|
97
|
+
}
|
|
98
|
+
return check.expectation.problems.length > 0 ? 1 : 2;
|
|
99
|
+
}
|
|
100
|
+
async function readPenpot(context, committed, wantImpact) {
|
|
101
|
+
return context.withPenpot(async (client) => {
|
|
102
|
+
const live = await readLive(client);
|
|
103
|
+
const drift = compareToPenpot(committed, live);
|
|
104
|
+
return { live, drift, ...(wantImpact && !drift.inSync ? { impact: await impactOf(client).catch(() => undefined) } : {}) };
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
async function diffCommand(context, args) {
|
|
108
|
+
const { values } = flags(args);
|
|
109
|
+
const check = await checkSnapshotFor(context.root, context.rawConfig, context.config.instance);
|
|
110
|
+
if (!check.committed) {
|
|
111
|
+
console.error(`FAIL ${check.problems.join("; ")}`);
|
|
112
|
+
return 1;
|
|
113
|
+
}
|
|
114
|
+
const { drift, impact } = await readPenpot(context, check.committed, true);
|
|
115
|
+
if (values.json) {
|
|
116
|
+
console.log(JSON.stringify({ snapshotCurrent: check.current, drift, impact }, null, 2));
|
|
117
|
+
return drift.inSync ? 0 : 2;
|
|
118
|
+
}
|
|
119
|
+
if (!check.current)
|
|
120
|
+
console.log(`note: the committed snapshot is stale against the installed Marwes (${staleAdvice}); the comparison below is against the committed one.`);
|
|
121
|
+
if (drift.inSync) {
|
|
122
|
+
console.log(`ok Penpot matches ${summary(check.committed)}`);
|
|
123
|
+
return 0;
|
|
124
|
+
}
|
|
125
|
+
console.log(`Penpot differs from ${summary(check.committed)}:`);
|
|
126
|
+
for (const line of formatDrift(drift, impact))
|
|
127
|
+
console.log(` ${line}`);
|
|
128
|
+
console.log("`penpair marwes tokens push --apply` brings Penpot to the snapshot (Marwes is authoritative: a value changed by hand in Penpot is put back).");
|
|
129
|
+
return 2;
|
|
130
|
+
}
|
|
131
|
+
async function pushCommand(context, args) {
|
|
132
|
+
const { values } = flags(args);
|
|
133
|
+
const check = await checkSnapshotFor(context.root, context.rawConfig, context.config.instance);
|
|
134
|
+
if (!check.committed) {
|
|
135
|
+
console.error(`FAIL ${check.problems.join("; ")}`);
|
|
136
|
+
return 1;
|
|
137
|
+
}
|
|
138
|
+
if (!check.current) {
|
|
139
|
+
for (const line of formatDiff(check.diff))
|
|
140
|
+
console.log(` ${line}`);
|
|
141
|
+
console.error(`STOP the committed snapshot is stale against the installed Marwes or the theme, and the repository's record must lead. ${staleAdvice} Then push.`);
|
|
142
|
+
return 2;
|
|
143
|
+
}
|
|
144
|
+
const { drift, impact, live } = await readPenpot(context, check.committed, true);
|
|
145
|
+
const plan = planPush(check.committed, live, { prune: values.prune === true });
|
|
146
|
+
if (drift.inSync && plan.length === 0) {
|
|
147
|
+
console.log(`ok Penpot already matches ${summary(check.committed)}; nothing to write`);
|
|
148
|
+
return 0;
|
|
149
|
+
}
|
|
150
|
+
console.log(`Penpot differs from ${summary(check.committed)}:`);
|
|
151
|
+
for (const line of formatDrift(drift, impact))
|
|
152
|
+
console.log(` ${line}`);
|
|
153
|
+
console.log("Plan:");
|
|
154
|
+
for (const line of describePlan(plan))
|
|
155
|
+
console.log(` ${line}`);
|
|
156
|
+
if (!values.apply) {
|
|
157
|
+
console.log("Nothing was written. Run again with --apply to do it.");
|
|
158
|
+
if (!values.prune && drift.sets.some((s) => s.tokens.some((t) => t.kind === "extra")))
|
|
159
|
+
console.log("Tokens added by hand are kept unless you add --prune.");
|
|
160
|
+
return 0;
|
|
161
|
+
}
|
|
162
|
+
const report = await context.withPenpot((client) => applyPlan(client, check.committed, plan, (message) => context.log(message)));
|
|
163
|
+
for (const step of report.steps)
|
|
164
|
+
console.log(`done ${step}`);
|
|
165
|
+
if (report.verified) {
|
|
166
|
+
console.log(`ok verified: Penpot now matches the snapshot and no shape moved or was rebound (${report.structureAfter.count} shapes unchanged)`);
|
|
167
|
+
return 0;
|
|
168
|
+
}
|
|
169
|
+
if (!report.structureUnchanged)
|
|
170
|
+
console.error(`FAIL the shapes changed while pushing tokens (${report.structureBefore.hash} -> ${report.structureAfter.hash}); look at the file before going on`);
|
|
171
|
+
for (const line of formatDrift(report.drift))
|
|
172
|
+
console.error(`FAIL still different: ${line}`);
|
|
173
|
+
return 1;
|
|
174
|
+
}
|
|
175
|
+
async function statusCommand(context, args) {
|
|
176
|
+
const { values } = flags(args);
|
|
177
|
+
const check = await checkSnapshotFor(context.root, context.rawConfig, context.config.instance);
|
|
178
|
+
const lines = [];
|
|
179
|
+
let code = 0;
|
|
180
|
+
lines.push(`Marwes installed: ${Object.entries(check.expectation.installed.versions).map(([n, v]) => `${n} ${v}`).join(", ")}`);
|
|
181
|
+
for (const problem of check.expectation.problems) {
|
|
182
|
+
lines.push(`FAIL ${problem}`);
|
|
183
|
+
code = 1;
|
|
184
|
+
}
|
|
185
|
+
lines.push(check.committed ? `Snapshot (${check.expectation.settings.snapshot}): ${summary(check.committed)}` : `Snapshot: none yet (${check.expectation.settings.snapshot})`);
|
|
186
|
+
lines.push(check.current ? "ok the snapshot is current against the installed Marwes and the theme" : `FAIL the snapshot is stale or missing. ${staleAdvice}`);
|
|
187
|
+
if (!check.current && code === 0)
|
|
188
|
+
code = 2;
|
|
189
|
+
let drift;
|
|
190
|
+
if (check.committed) {
|
|
191
|
+
try {
|
|
192
|
+
drift = (await readPenpot(context, check.committed, false)).drift;
|
|
193
|
+
lines.push(drift.inSync ? "ok Penpot matches the snapshot" : `FAIL Penpot differs from the snapshot (${drift.sets.reduce((n, s) => n + s.tokens.length, 0)} token(s), ${drift.themes.length} theme(s)); see \`penpair marwes tokens diff\``);
|
|
194
|
+
if (!drift.inSync && code === 0)
|
|
195
|
+
code = 2;
|
|
196
|
+
}
|
|
197
|
+
catch (error) {
|
|
198
|
+
lines.push(`skip Penpot: not checked (${String(error.message).split("\n")[0]})`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (values.json)
|
|
202
|
+
console.log(JSON.stringify({ installed: check.expectation.installed.versions, snapshotCurrent: check.current, penpotInSync: drift?.inSync ?? null, exit: code }, null, 2));
|
|
203
|
+
else
|
|
204
|
+
for (const line of lines)
|
|
205
|
+
console.log(line);
|
|
206
|
+
return code;
|
|
207
|
+
}
|
|
208
|
+
export const marwesCommand = {
|
|
209
|
+
name: "marwes",
|
|
210
|
+
summary: "Marwes tokens in Penpot, and drift from the installed Marwes",
|
|
211
|
+
usage: [
|
|
212
|
+
"penpair marwes tokens snapshot [--write] build the expected Penpot tokens from the installed Marwes and your theme; --write saves the snapshot",
|
|
213
|
+
"penpair marwes tokens check [--json] offline, for CI: is the committed snapshot current? (exit 2 when stale)",
|
|
214
|
+
"penpair marwes tokens diff [--json] Penpot against the snapshot: missing, changed, added by hand, and what each change reaches",
|
|
215
|
+
"penpair marwes tokens push [--apply] [--prune] bring Penpot to the snapshot (looks first; --apply writes; --prune also removes what is not expected)",
|
|
216
|
+
"penpair marwes tokens status one screen: installed Marwes, snapshot, Penpot",
|
|
217
|
+
],
|
|
218
|
+
async run(context, args) {
|
|
219
|
+
const [area, sub, ...rest] = args;
|
|
220
|
+
if (area !== "tokens" || !sub) {
|
|
221
|
+
console.error(`usage:\n ${marwesCommand.usage.join("\n ")}`);
|
|
222
|
+
return 1;
|
|
223
|
+
}
|
|
224
|
+
const commands = { snapshot: snapshotCommand, check: checkCommand, diff: diffCommand, push: pushCommand, status: statusCommand };
|
|
225
|
+
const run = commands[sub];
|
|
226
|
+
if (!run) {
|
|
227
|
+
console.error(`unknown: penpair marwes tokens ${sub}\nusage:\n ${marwesCommand.usage.join("\n ")}`);
|
|
228
|
+
return 1;
|
|
229
|
+
}
|
|
230
|
+
return run(context, rest);
|
|
231
|
+
},
|
|
232
|
+
};
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { ThemeReference } from "./theme-input.js";
|
|
2
|
+
/** The `marwes` block of penpair.config.json. */
|
|
3
|
+
export interface MarwesSettings {
|
|
4
|
+
/** The project's name; it prefixes its overlay sets ("Acme · Light"). Defaults to the instance name, capitalised. */
|
|
5
|
+
name: string;
|
|
6
|
+
/** The folder of the package that depends on Marwes (a workspace package in a monorepo). Relative to the project. */
|
|
7
|
+
project: string;
|
|
8
|
+
/** The project's own themes, so the project's choices go on top of Marwes's defaults. Optional. */
|
|
9
|
+
themes: {
|
|
10
|
+
light?: ThemeReference;
|
|
11
|
+
dark?: ThemeReference;
|
|
12
|
+
};
|
|
13
|
+
/** Where the committed snapshot lives, relative to the project. */
|
|
14
|
+
snapshot: string;
|
|
15
|
+
}
|
|
16
|
+
export declare const defaultSnapshotPath = "design/marwes-tokens.snapshot.json";
|
|
17
|
+
export declare class MarwesConfigError extends Error {
|
|
18
|
+
}
|
|
19
|
+
export declare function readMarwesSettings(rawConfig: Record<string, unknown>, instance: string): MarwesSettings;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
export const defaultSnapshotPath = "design/marwes-tokens.snapshot.json";
|
|
3
|
+
export class MarwesConfigError extends Error {
|
|
4
|
+
}
|
|
5
|
+
const relative = (value, label) => {
|
|
6
|
+
if (typeof value !== "string" || value.length === 0 || path.isAbsolute(value) || value.split(/[\\/]/).includes(".."))
|
|
7
|
+
throw new MarwesConfigError(`penpair.config.json: marwes.${label} must be a relative path that stays inside the project`);
|
|
8
|
+
return value;
|
|
9
|
+
};
|
|
10
|
+
function themeReference(value, label) {
|
|
11
|
+
if (value === undefined)
|
|
12
|
+
return undefined;
|
|
13
|
+
const v = value;
|
|
14
|
+
if (typeof value !== "object" || value === null || typeof v.export !== "string" || v.export.length === 0)
|
|
15
|
+
throw new MarwesConfigError(`penpair.config.json: marwes.themes.${label} must be {"module": "<file>", "export": "<name>"}`);
|
|
16
|
+
return { module: relative(v.module, `themes.${label}.module`), export: v.export };
|
|
17
|
+
}
|
|
18
|
+
const capitalise = (text) => text.charAt(0).toUpperCase() + text.slice(1);
|
|
19
|
+
export function readMarwesSettings(rawConfig, instance) {
|
|
20
|
+
const block = rawConfig.marwes;
|
|
21
|
+
const v = (typeof block === "object" && block !== null ? block : {});
|
|
22
|
+
if (block !== undefined && (typeof block !== "object" || block === null))
|
|
23
|
+
throw new MarwesConfigError('penpair.config.json: "marwes" must be an object');
|
|
24
|
+
const themes = (typeof v.themes === "object" && v.themes !== null ? v.themes : {});
|
|
25
|
+
const name = v.name === undefined ? capitalise(instance) : String(v.name);
|
|
26
|
+
if (!/^[A-Za-z][A-Za-z0-9 _-]{0,40}$/.test(name))
|
|
27
|
+
throw new MarwesConfigError("penpair.config.json: marwes.name must be a short name (letters, digits, spaces, hyphens)");
|
|
28
|
+
return {
|
|
29
|
+
name,
|
|
30
|
+
project: v.project === undefined ? "." : relative(v.project, "project"),
|
|
31
|
+
themes: { light: themeReference(themes.light, "light"), dark: themeReference(themes.dark, "dark") },
|
|
32
|
+
snapshot: v.snapshot === undefined ? defaultSnapshotPath : relative(v.snapshot, "snapshot"),
|
|
33
|
+
};
|
|
34
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** The adapter penpair loads when `@penpair/marwes-ui` is listed under "adapters" in penpair.config.json. */
|
|
2
|
+
declare const _default: {
|
|
3
|
+
name: string;
|
|
4
|
+
commands: import("@penpair/core").AdapterCommand[];
|
|
5
|
+
};
|
|
6
|
+
export default _default;
|
|
7
|
+
export * from "./commands.js";
|
|
8
|
+
export * from "./config.js";
|
|
9
|
+
export * from "./marwes.js";
|
|
10
|
+
export * from "./penpot.js";
|
|
11
|
+
export * from "./snapshot-file.js";
|
|
12
|
+
export * from "./theme-input.js";
|
|
13
|
+
export * from "./tokens.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { defineAdapter } from "@penpair/core";
|
|
2
|
+
import { marwesCommand } from "./commands.js";
|
|
3
|
+
/** The adapter penpair loads when `@penpair/marwes-ui` is listed under "adapters" in penpair.config.json. */
|
|
4
|
+
export default defineAdapter({ name: "@penpair/marwes-ui", commands: [marwesCommand] });
|
|
5
|
+
export * from "./commands.js";
|
|
6
|
+
export * from "./config.js";
|
|
7
|
+
export * from "./marwes.js";
|
|
8
|
+
export * from "./penpot.js";
|
|
9
|
+
export * from "./snapshot-file.js";
|
|
10
|
+
export * from "./theme-input.js";
|
|
11
|
+
export * from "./tokens.js";
|
package/dist/marwes.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { MarwesCore } from "./tokens.js";
|
|
2
|
+
export declare class MarwesError extends Error {
|
|
3
|
+
}
|
|
4
|
+
export interface InstalledMarwes {
|
|
5
|
+
core: MarwesCore;
|
|
6
|
+
/** The version of @marwes-ui/core, which is the version the tokens come from. */
|
|
7
|
+
version: string;
|
|
8
|
+
/** Every @marwes-ui package found next to it, with its version. Marwes releases them together. */
|
|
9
|
+
versions: Record<string, string>;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Loads the Marwes core that the app itself resolves, so the tokens always describe the installed version. `projectDirectory` is
|
|
13
|
+
* the folder of the package that depends on Marwes (a workspace package in a monorepo).
|
|
14
|
+
*/
|
|
15
|
+
export declare function loadInstalledMarwes(projectDirectory: string): Promise<InstalledMarwes>;
|
|
16
|
+
/** Marwes releases core, presets and the framework packages together; a mix means a half-finished upgrade. */
|
|
17
|
+
export declare function familyProblems(installed: InstalledMarwes): string[];
|
package/dist/marwes.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { pathToFileURL } from "node:url";
|
|
5
|
+
export class MarwesError extends Error {
|
|
6
|
+
}
|
|
7
|
+
const familyNames = ["react", "vue", "svelte", "core", "presets"];
|
|
8
|
+
function packageDirectory(entry) {
|
|
9
|
+
let directory = path.dirname(entry);
|
|
10
|
+
while (!existsSync(path.join(directory, "package.json"))) {
|
|
11
|
+
const parent = path.dirname(directory);
|
|
12
|
+
if (parent === directory)
|
|
13
|
+
throw new MarwesError(`no package.json above ${entry}`);
|
|
14
|
+
directory = parent;
|
|
15
|
+
}
|
|
16
|
+
return directory;
|
|
17
|
+
}
|
|
18
|
+
const versionOf = (directory) => String(JSON.parse(readFileSync(path.join(directory, "package.json"), "utf8")).version ?? "?");
|
|
19
|
+
/**
|
|
20
|
+
* Loads the Marwes core that the app itself resolves, so the tokens always describe the installed version. `projectDirectory` is
|
|
21
|
+
* the folder of the package that depends on Marwes (a workspace package in a monorepo).
|
|
22
|
+
*/
|
|
23
|
+
export async function loadInstalledMarwes(projectDirectory) {
|
|
24
|
+
const fromProject = createRequire(path.join(projectDirectory, "package.json"));
|
|
25
|
+
let componentEntry;
|
|
26
|
+
let componentPackage = "";
|
|
27
|
+
for (const candidate of ["react", "vue", "svelte"]) {
|
|
28
|
+
try {
|
|
29
|
+
componentEntry = fromProject.resolve(`@marwes-ui/${candidate}`);
|
|
30
|
+
componentPackage = candidate;
|
|
31
|
+
break;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
// try the next framework
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (!componentEntry)
|
|
38
|
+
throw new MarwesError(`No @marwes-ui/react, vue or svelte is installed for ${projectDirectory}. Set "marwes.project" in penpair.config.json to the folder of the package that uses Marwes.`);
|
|
39
|
+
const fromComponent = createRequire(componentEntry);
|
|
40
|
+
let coreEntry;
|
|
41
|
+
try {
|
|
42
|
+
coreEntry = fromComponent.resolve("@marwes-ui/core");
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
throw new MarwesError(`@marwes-ui/${componentPackage} is installed but its @marwes-ui/core cannot be found; reinstall the dependencies.`);
|
|
46
|
+
}
|
|
47
|
+
const versions = { [componentPackage]: versionOf(packageDirectory(componentEntry)) };
|
|
48
|
+
versions.core = versionOf(packageDirectory(coreEntry));
|
|
49
|
+
for (const name of familyNames) {
|
|
50
|
+
if (versions[name])
|
|
51
|
+
continue;
|
|
52
|
+
try {
|
|
53
|
+
versions[name] = versionOf(packageDirectory(fromComponent.resolve(`@marwes-ui/${name}`)));
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
// not installed, which is normal for the frameworks the app does not use
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const namespace = (await import(pathToFileURL(coreEntry).href));
|
|
60
|
+
const core = typeof namespace.resolveThemeInput === "function" ? namespace : namespace.default;
|
|
61
|
+
for (const name of ["resolveThemeInput", "themeToCSSVars"])
|
|
62
|
+
if (typeof core?.[name] !== "function")
|
|
63
|
+
throw new MarwesError(`@marwes-ui/core ${versions.core} does not export ${name}; this adapter needs a Marwes that does. Update @penpair/marwes-ui or check the Marwes version.`);
|
|
64
|
+
if (!core.mwThemeVars?.spacing)
|
|
65
|
+
throw new MarwesError(`@marwes-ui/core ${versions.core} does not export mwThemeVars.spacing; this adapter needs a Marwes that does.`);
|
|
66
|
+
return { core, version: versions.core, versions };
|
|
67
|
+
}
|
|
68
|
+
/** Marwes releases core, presets and the framework packages together; a mix means a half-finished upgrade. */
|
|
69
|
+
export function familyProblems(installed) {
|
|
70
|
+
const distinct = new Set(Object.values(installed.versions));
|
|
71
|
+
return distinct.size > 1 ? [`the installed Marwes packages are at different versions (${Object.entries(installed.versions).map(([name, version]) => `${name} ${version}`).join(", ")}); Marwes releases them together, so finish the upgrade`] : [];
|
|
72
|
+
}
|
package/dist/penpot.d.ts
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { type PenpotToolClient, type Snippet } from "@penpair/core";
|
|
2
|
+
import { canonical, type PenpotToken, type TokenSnapshot } from "./tokens.js";
|
|
3
|
+
/** What the Penpot file holds today. */
|
|
4
|
+
export interface LiveTokens {
|
|
5
|
+
sets: {
|
|
6
|
+
order: number;
|
|
7
|
+
name: string;
|
|
8
|
+
active: boolean;
|
|
9
|
+
tokens: {
|
|
10
|
+
name: string;
|
|
11
|
+
type: string;
|
|
12
|
+
value: unknown;
|
|
13
|
+
description: string;
|
|
14
|
+
}[];
|
|
15
|
+
}[];
|
|
16
|
+
themes: {
|
|
17
|
+
group: string;
|
|
18
|
+
name: string;
|
|
19
|
+
active: boolean;
|
|
20
|
+
sets: string[];
|
|
21
|
+
}[];
|
|
22
|
+
}
|
|
23
|
+
export declare function readTokens(): Snippet;
|
|
24
|
+
/** How many shapes use each token, over every page: what a change of a token would reach. */
|
|
25
|
+
export declare function bindingCounts(): Snippet;
|
|
26
|
+
/** A hash of everything structural on every page (ids, kinds, geometry, which tokens are bound), not of colours: pushing tokens must not move or rebind a shape. */
|
|
27
|
+
export declare function structureFingerprint(): Snippet;
|
|
28
|
+
export type TokenDrift = {
|
|
29
|
+
name: string;
|
|
30
|
+
kind: "missing";
|
|
31
|
+
} | {
|
|
32
|
+
name: string;
|
|
33
|
+
kind: "extra";
|
|
34
|
+
} | {
|
|
35
|
+
name: string;
|
|
36
|
+
kind: "value";
|
|
37
|
+
expected: string;
|
|
38
|
+
actual: string;
|
|
39
|
+
} | {
|
|
40
|
+
name: string;
|
|
41
|
+
kind: "type";
|
|
42
|
+
expected: string;
|
|
43
|
+
actual: string;
|
|
44
|
+
} | {
|
|
45
|
+
name: string;
|
|
46
|
+
kind: "description";
|
|
47
|
+
expected: string;
|
|
48
|
+
actual: string;
|
|
49
|
+
};
|
|
50
|
+
export interface SetDrift {
|
|
51
|
+
set: string;
|
|
52
|
+
missing?: true;
|
|
53
|
+
tokens: TokenDrift[];
|
|
54
|
+
}
|
|
55
|
+
export interface ThemeDrift {
|
|
56
|
+
theme: string;
|
|
57
|
+
missing?: true;
|
|
58
|
+
missingSets: string[];
|
|
59
|
+
extraSets: string[];
|
|
60
|
+
}
|
|
61
|
+
export interface Drift {
|
|
62
|
+
inSync: boolean;
|
|
63
|
+
sets: SetDrift[];
|
|
64
|
+
themes: ThemeDrift[];
|
|
65
|
+
/** Sets in the file that look like ones this adapter manages but are not in the expectation. */
|
|
66
|
+
unexpectedSets: string[];
|
|
67
|
+
}
|
|
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;
|
|
70
|
+
export declare function formatDrift(drift: Drift, impact?: Record<string, number>, limit?: number): string[];
|
|
71
|
+
export type Action = {
|
|
72
|
+
kind: "createSet";
|
|
73
|
+
set: string;
|
|
74
|
+
} | {
|
|
75
|
+
kind: "addTokens";
|
|
76
|
+
set: string;
|
|
77
|
+
tokens: PenpotToken[];
|
|
78
|
+
} | {
|
|
79
|
+
kind: "updateTokens";
|
|
80
|
+
set: string;
|
|
81
|
+
tokens: PenpotToken[];
|
|
82
|
+
} | {
|
|
83
|
+
kind: "replaceTokens";
|
|
84
|
+
set: string;
|
|
85
|
+
tokens: PenpotToken[];
|
|
86
|
+
} | {
|
|
87
|
+
kind: "removeTokens";
|
|
88
|
+
set: string;
|
|
89
|
+
names: string[];
|
|
90
|
+
} | {
|
|
91
|
+
kind: "createTheme";
|
|
92
|
+
group: string;
|
|
93
|
+
name: string;
|
|
94
|
+
sets: string[];
|
|
95
|
+
} | {
|
|
96
|
+
kind: "syncThemeSets";
|
|
97
|
+
group: string;
|
|
98
|
+
name: string;
|
|
99
|
+
add: string[];
|
|
100
|
+
remove: string[];
|
|
101
|
+
} | {
|
|
102
|
+
kind: "activateTheme";
|
|
103
|
+
group: string;
|
|
104
|
+
name: string;
|
|
105
|
+
};
|
|
106
|
+
export interface PlanOptions {
|
|
107
|
+
/** Also remove what Penpot has but the expectation does not (tokens added by hand, sets no longer expected, extra sets in a theme). */
|
|
108
|
+
prune?: boolean;
|
|
109
|
+
}
|
|
110
|
+
/** The writes that bring the file to the expectation. Nothing in a set the expectation does not name is ever touched. */
|
|
111
|
+
export declare function planPush(expected: TokenSnapshot, live: LiveTokens, options?: PlanOptions): Action[];
|
|
112
|
+
/** Splits a long token list into batches that stay well under the request limit. */
|
|
113
|
+
export declare function inBatches(tokens: PenpotToken[]): PenpotToken[][];
|
|
114
|
+
export interface ApplyReport {
|
|
115
|
+
steps: string[];
|
|
116
|
+
structureBefore: {
|
|
117
|
+
count: number;
|
|
118
|
+
hash: string;
|
|
119
|
+
};
|
|
120
|
+
structureAfter: {
|
|
121
|
+
count: number;
|
|
122
|
+
hash: string;
|
|
123
|
+
};
|
|
124
|
+
structureUnchanged: boolean;
|
|
125
|
+
drift: Drift;
|
|
126
|
+
verified: boolean;
|
|
127
|
+
}
|
|
128
|
+
export declare const describePlan: (actions: Action[]) => string[];
|
|
129
|
+
export declare function readLive(client: PenpotToolClient): Promise<LiveTokens>;
|
|
130
|
+
/**
|
|
131
|
+
* 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
|
+
* expectation, and no shape may have moved or been rebound.
|
|
133
|
+
*/
|
|
134
|
+
export declare function applyPlan(client: PenpotToolClient, expected: TokenSnapshot, actions: Action[], onStep?: (message: string) => void): Promise<ApplyReport>;
|
|
135
|
+
/** The use count of every token a drift would change, so a bump can be judged by what it reaches. */
|
|
136
|
+
export declare function impactOf(client: PenpotToolClient): Promise<Record<string, number>>;
|
|
137
|
+
export { canonical };
|