figdiff 0.0.0-stage → 0.1.0-alpha.1

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.
Files changed (66) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +308 -2
  3. package/dist/action/changed-files.d.ts +14 -0
  4. package/dist/action/changed-files.js +35 -0
  5. package/dist/action/changed-files.js.map +1 -0
  6. package/dist/action/comment.d.ts +28 -0
  7. package/dist/action/comment.js +70 -0
  8. package/dist/action/comment.js.map +1 -0
  9. package/dist/action/github.d.ts +15 -0
  10. package/dist/action/github.js +62 -0
  11. package/dist/action/github.js.map +1 -0
  12. package/dist/action/glob.d.ts +10 -0
  13. package/dist/action/glob.js +45 -0
  14. package/dist/action/glob.js.map +1 -0
  15. package/dist/action/main.d.ts +20 -0
  16. package/dist/action/main.js +111 -0
  17. package/dist/action/main.js.map +1 -0
  18. package/dist/action.d.ts +2 -0
  19. package/dist/action.js +7 -0
  20. package/dist/action.js.map +1 -0
  21. package/dist/cli.d.ts +2 -0
  22. package/dist/cli.js +21 -0
  23. package/dist/cli.js.map +1 -0
  24. package/dist/diff/diff.d.ts +11 -0
  25. package/dist/diff/diff.js +68 -0
  26. package/dist/diff/diff.js.map +1 -0
  27. package/dist/diff/summary.d.ts +3 -0
  28. package/dist/diff/summary.js +40 -0
  29. package/dist/diff/summary.js.map +1 -0
  30. package/dist/diff/types.d.ts +59 -0
  31. package/dist/diff/types.js +2 -0
  32. package/dist/diff/types.js.map +1 -0
  33. package/dist/git.d.ts +12 -0
  34. package/dist/git.js +32 -0
  35. package/dist/git.js.map +1 -0
  36. package/dist/html.d.ts +25 -0
  37. package/dist/html.js +205 -0
  38. package/dist/html.js.map +1 -0
  39. package/dist/index.d.ts +18 -0
  40. package/dist/index.js +12 -0
  41. package/dist/index.js.map +1 -0
  42. package/dist/parser/document.d.ts +12 -0
  43. package/dist/parser/document.js +71 -0
  44. package/dist/parser/document.js.map +1 -0
  45. package/dist/parser/index.d.ts +10 -0
  46. package/dist/parser/index.js +21 -0
  47. package/dist/parser/index.js.map +1 -0
  48. package/dist/render/compare.d.ts +19 -0
  49. package/dist/render/compare.js +61 -0
  50. package/dist/render/compare.js.map +1 -0
  51. package/dist/render/figcraft-cli.d.ts +21 -0
  52. package/dist/render/figcraft-cli.js +63 -0
  53. package/dist/render/figcraft-cli.js.map +1 -0
  54. package/dist/render/frames.d.ts +19 -0
  55. package/dist/render/frames.js +53 -0
  56. package/dist/render/frames.js.map +1 -0
  57. package/dist/render/png.d.ts +14 -0
  58. package/dist/render/png.js +185 -0
  59. package/dist/render/png.js.map +1 -0
  60. package/dist/report.d.ts +7 -0
  61. package/dist/report.js +109 -0
  62. package/dist/report.js.map +1 -0
  63. package/dist/run.d.ts +23 -0
  64. package/dist/run.js +144 -0
  65. package/dist/run.js.map +1 -0
  66. package/package.json +51 -4
package/dist/html.js ADDED
@@ -0,0 +1,205 @@
1
+ import { summarize } from "./diff/summary.js";
2
+ import { formatSummary, formatValue } from "./report.js";
3
+ /** Key used in `HtmlOptions.images` for a top-level frame. */
4
+ export const frameKey = (page, frame) => `${page}\u0000${frame}`;
5
+ const SEP = " › ";
6
+ const KIND_SYMBOL = { added: "+", removed: "−", moved: ">", modified: "~", reordered: "↕" };
7
+ const KIND_LABEL = { added: "Added", removed: "Removed", moved: "Moved", modified: "Changed", reordered: "Reordered" };
8
+ export function escapeHtml(text) {
9
+ return text
10
+ .replace(/&/g, "&")
11
+ .replace(/</g, "&lt;")
12
+ .replace(/>/g, "&gt;")
13
+ .replace(/"/g, "&quot;")
14
+ .replace(/'/g, "&#39;");
15
+ }
16
+ /** Only embedded images and web URLs are accepted, never script-capable schemes. */
17
+ function safeImageSrc(src) {
18
+ return /^(data:image\/(png|jpeg|webp|gif|svg\+xml);|https?:\/\/)/i.test(src) ? src : undefined;
19
+ }
20
+ /** Escape a value for display and add a small color swatch next to every hex color. */
21
+ function valueHtml(value) {
22
+ let text = formatValue(value);
23
+ if (text.length > 240)
24
+ text = text.slice(0, 240) + "…";
25
+ return escapeHtml(text).replace(/#[0-9A-F]{6}(?:[0-9A-F]{2})?\b/g, (hex) => `<span class="sw" style="background:${hex}"></span><code>${hex}</code>`);
26
+ }
27
+ function rowBody(c) {
28
+ switch (c.kind) {
29
+ case "added":
30
+ case "removed":
31
+ return `<span class="type">${escapeHtml(c.type)}</span>`;
32
+ case "moved":
33
+ return `<span class="note">moved from ${escapeHtml(c.fromTrail.join(SEP) || "(root)")}</span>`;
34
+ case "reordered":
35
+ return `<span class="note">layer order changed</span>`;
36
+ case "modified":
37
+ return (`<ul class="props">` +
38
+ c.props
39
+ .map((p) => `<li><span class="prop">${escapeHtml(p.prop)}</span>` +
40
+ `<span class="from">${valueHtml(p.from)}</span><span class="arrow">→</span>` +
41
+ `<span class="to">${valueHtml(p.to)}</span></li>`)
42
+ .join("") +
43
+ `</ul>`);
44
+ }
45
+ }
46
+ function row(c, label) {
47
+ return (`<li class="chg ${c.kind}" data-kind="${c.kind}">` +
48
+ `<span class="badge" title="${KIND_LABEL[c.kind]}">${KIND_SYMBOL[c.kind]}</span>` +
49
+ `<div class="what"><span class="label">${escapeHtml(label)}</span>${rowBody(c)}</div></li>`);
50
+ }
51
+ function percent(ratio) {
52
+ const p = ratio * 100;
53
+ if (p === 0)
54
+ return "0%";
55
+ return p < 0.1 ? "<0.1%" : `${p < 10 ? p.toFixed(1) : Math.round(p)}%`;
56
+ }
57
+ function compare(images) {
58
+ if (!images)
59
+ return "";
60
+ const before = images.before && safeImageSrc(images.before);
61
+ const after = images.after && safeImageSrc(images.after);
62
+ const diff = images.diff && safeImageSrc(images.diff);
63
+ if (before && after) {
64
+ const tabs = `<div class="tabs"><button type="button" data-view="swipe" aria-pressed="true">Swipe</button>` +
65
+ `<button type="button" data-view="side" aria-pressed="false">Side by side</button>` +
66
+ (diff ? `<button type="button" data-view="diff" aria-pressed="false">Difference</button>` : "") +
67
+ (diff && images.changedRatio !== undefined
68
+ ? `<span class="ratio">${escapeHtml(percent(images.changedRatio))} of pixels differ</span>`
69
+ : "") +
70
+ `</div>`;
71
+ return (`<div class="cmp" data-view="swipe" style="--pos:50%">${tabs}` +
72
+ `<div class="stack"><img class="b" alt="Before" src="${escapeHtml(before)}">` +
73
+ `<img class="a" alt="After" src="${escapeHtml(after)}"></div>` +
74
+ (diff ? `<img class="diffimg" alt="Differences highlighted" src="${escapeHtml(diff)}" hidden>` : "") +
75
+ `<input type="range" min="0" max="100" value="50" aria-label="Slide between before and after">` +
76
+ `<div class="cap"><span>Before</span><span>After</span></div></div>`);
77
+ }
78
+ const only = after ?? before;
79
+ if (!only)
80
+ return "";
81
+ return (`<figure class="single"><img alt="${after ? "After" : "Before"}" src="${escapeHtml(only)}">` +
82
+ `<figcaption>${after ? "Added: only in the new version" : "Removed: only in the old version"}</figcaption></figure>`);
83
+ }
84
+ const CSS = `
85
+ :root{--bg:#fff;--fg:#1c1c1e;--muted:#6b6b70;--line:#e3e3e8;--card:#f7f7f9;--add:#1a7f37;--rem:#c62828;--mod:#8a5a00;--mov:#5b3fc0;--reo:#00796b}
86
+ @media (prefers-color-scheme:dark){:root{--bg:#161618;--fg:#ececf0;--muted:#9a9aa3;--line:#2e2e33;--card:#1f1f23;--add:#56d364;--rem:#ff7b72;--mod:#e3b341;--mov:#a99bff;--reo:#4fd1c5}}
87
+ *{box-sizing:border-box}
88
+ body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif}
89
+ main{max-width:920px;margin:0 auto;padding:32px 16px 64px}
90
+ h1{font-size:22px;margin:0 0 4px}
91
+ .files{color:var(--muted);margin:0 0 16px;overflow-wrap:anywhere}
92
+ .files code{color:var(--fg)}
93
+ .summary{font-size:16px;margin:0 0 16px}
94
+ .filters{display:flex;flex-wrap:wrap;gap:8px;margin:0 0 24px}
95
+ .filters button{font:inherit;font-size:13px;border:1px solid var(--line);background:var(--card);color:var(--fg);border-radius:999px;padding:4px 12px;cursor:pointer}
96
+ .filters button[aria-pressed=false]{opacity:.45;text-decoration:line-through}
97
+ h2{font-size:17px;margin:32px 0 8px;padding-bottom:6px;border-bottom:1px solid var(--line)}
98
+ details{border:1px solid var(--line);border-radius:10px;margin:12px 0;background:var(--card)}
99
+ summary{cursor:pointer;padding:10px 14px;font-weight:600;display:flex;justify-content:space-between;gap:12px}
100
+ summary .count{color:var(--muted);font-weight:400;white-space:nowrap}
101
+ .body{padding:0 14px 12px}
102
+ ul.list{list-style:none;margin:0;padding:0}
103
+ .chg{display:flex;gap:10px;padding:8px 0;border-top:1px solid var(--line)}
104
+ .chg[hidden]{display:none}
105
+ .badge{flex:none;width:22px;height:22px;border-radius:6px;display:grid;place-items:center;font:600 13px/1 ui-monospace,monospace;color:var(--bg)}
106
+ .added .badge{background:var(--add)}.removed .badge{background:var(--rem)}.modified .badge{background:var(--mod)}.moved .badge{background:var(--mov)}.reordered .badge{background:var(--reo)}
107
+ .what{min-width:0}
108
+ .label{font-weight:600;overflow-wrap:anywhere}
109
+ .type,.note{color:var(--muted);margin-left:8px;font-size:13px}
110
+ ul.props{list-style:none;margin:4px 0 0;padding:0}
111
+ ul.props li{display:flex;flex-wrap:wrap;align-items:center;gap:6px;font-size:13px;padding:1px 0}
112
+ .prop{color:var(--muted);min-width:84px}
113
+ .arrow{color:var(--muted)}
114
+ code{font:12.5px ui-monospace,SFMono-Regular,Menlo,monospace}
115
+ .sw{display:inline-block;width:12px;height:12px;border-radius:3px;border:1px solid var(--line);vertical-align:-1px;margin-right:4px}
116
+ .cmp{margin:4px 0 12px}
117
+ .stack{position:relative;border:1px solid var(--line);border-radius:8px;overflow:hidden;background:var(--bg)}
118
+ .stack img{display:block;width:100%;height:auto}
119
+ .stack img.a{position:absolute;inset:0;clip-path:inset(0 0 0 var(--pos))}
120
+ .cmp input{width:100%;margin:6px 0 0}
121
+ .cap{display:flex;justify-content:space-between;color:var(--muted);font-size:12px}
122
+ .tabs{display:flex;align-items:center;gap:6px;margin:0 0 6px}
123
+ .tabs button{font:inherit;font-size:12px;border:1px solid var(--line);background:var(--bg);color:var(--fg);border-radius:6px;padding:2px 10px;cursor:pointer}
124
+ .tabs button[aria-pressed=true]{background:var(--fg);color:var(--bg);border-color:var(--fg)}
125
+ .tabs .ratio{margin-left:auto;color:var(--muted);font-size:12px}
126
+ .diffimg{display:block;width:100%;height:auto;border:1px solid var(--line);border-radius:8px}
127
+ .diffimg[hidden]{display:none}
128
+ .cmp[data-view=diff] input,.cmp[data-view=diff] .cap,.cmp[data-view=diff] .stack,.cmp[data-view=side] input{display:none}
129
+ .cmp[data-view=side] .stack{display:grid;grid-template-columns:1fr 1fr;gap:8px;border:0;border-radius:0;overflow:visible;background:none}
130
+ .cmp[data-view=side] .stack img{border:1px solid var(--line);border-radius:8px;background:var(--bg)}
131
+ .cmp[data-view=side] .stack img.a{position:static;clip-path:none}
132
+ figure.single{margin:4px 0 12px}
133
+ figure.single img{display:block;width:100%;height:auto;border:1px solid var(--line);border-radius:8px;background:var(--bg)}
134
+ figure.single figcaption{color:var(--muted);font-size:12px;margin-top:4px}
135
+ footer{margin-top:40px;color:var(--muted);font-size:12px}
136
+ `;
137
+ const SCRIPT = `
138
+ document.querySelectorAll('.filters button').forEach(function(btn){
139
+ btn.addEventListener('click',function(){
140
+ var on=btn.getAttribute('aria-pressed')!=='true';
141
+ btn.setAttribute('aria-pressed',String(on));
142
+ var kind=btn.getAttribute('data-kind');
143
+ document.querySelectorAll('.chg[data-kind="'+kind+'"]').forEach(function(el){el.hidden=!on;});
144
+ });
145
+ });
146
+ document.querySelectorAll('.cmp').forEach(function(c){
147
+ var r=c.querySelector('input');
148
+ r.addEventListener('input',function(){c.style.setProperty('--pos',r.value+'%');});
149
+
150
+ c.querySelectorAll('.tabs button').forEach(function(t){
151
+ t.addEventListener('click',function(){
152
+ var view=t.getAttribute('data-view');
153
+ c.setAttribute('data-view',view);
154
+ var d=c.querySelector('.diffimg');
155
+ if(d)d.hidden=view!=='diff';
156
+ c.querySelectorAll('.tabs button').forEach(function(o){o.setAttribute('aria-pressed',String(o===t));});
157
+ });
158
+ });
159
+ });
160
+ `;
161
+ /** A single self-contained HTML page describing the changes. No network access needed to view it. */
162
+ export function formatHtml(changes, options = {}) {
163
+ const title = options.title ?? "figdiff report";
164
+ const summary = summarize(changes);
165
+ const pages = new Map();
166
+ for (const c of changes) {
167
+ const page = c.trail[0] ?? "(document)";
168
+ const frame = c.trail[1] ?? "";
169
+ const frames = pages.get(page) ?? new Map();
170
+ frames.set(frame, [...(frames.get(frame) ?? []), c]);
171
+ pages.set(page, frames);
172
+ }
173
+ const kinds = ["added", "removed", "modified", "moved", "reordered"].filter((k) => changes.some((c) => c.kind === k));
174
+ const filters = kinds
175
+ .map((k) => `<button type="button" data-kind="${k}" aria-pressed="true">${KIND_SYMBOL[k]} ${KIND_LABEL[k]}</button>`)
176
+ .join("");
177
+ let sections = "";
178
+ for (const [page, frames] of pages) {
179
+ sections += `<h2>Page “${escapeHtml(page)}”</h2>`;
180
+ for (const [frame, group] of frames) {
181
+ const heading = frame === "" ? "Page itself" : `Frame “${escapeHtml(frame)}”`;
182
+ const images = frame === "" ? undefined : options.images?.[frameKey(page, frame)];
183
+ const nodeCount = new Set(group.map((c) => c.id)).size;
184
+ const items = group
185
+ .map((c) => row(c, c.trail.length > 2 ? c.trail.slice(2).join(SEP) : frame === "" ? "(page)" : "(frame itself)"))
186
+ .join("");
187
+ sections +=
188
+ `<details open><summary><span>${heading}</span><span class="count">${nodeCount} ${nodeCount === 1 ? "node" : "nodes"}</span></summary>` +
189
+ `<div class="body">${compare(images)}<ul class="list">${items}</ul></div></details>`;
190
+ }
191
+ }
192
+ const files = options.beforeName || options.afterName
193
+ ? `<p class="files"><code>${escapeHtml(options.beforeName ?? "before")}</code> → <code>${escapeHtml(options.afterName ?? "after")}</code></p>`
194
+ : "";
195
+ const body = changes.length === 0
196
+ ? `<p class="summary">No changes.</p>`
197
+ : `<p class="summary">${escapeHtml(formatSummary(summary))}</p><div class="filters">${filters}</div>${sections}`;
198
+ return (`<!doctype html><html lang="en"><head><meta charset="utf-8">` +
199
+ `<meta name="viewport" content="width=device-width,initial-scale=1">` +
200
+ `<title>${escapeHtml(title)}</title><style>${CSS}</style></head><body><main>` +
201
+ `<h1>${escapeHtml(title)}</h1>${files}${body}` +
202
+ `<footer>Generated by figdiff. Changes are matched by node id.</footer>` +
203
+ `</main><script>${SCRIPT}</script></body></html>\n`);
204
+ }
205
+ //# sourceMappingURL=html.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"html.js","sourceRoot":"","sources":["../src/html.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,18 @@
1
+ export { diffTrees } from "./diff/diff.js";
2
+ export { summarize } from "./diff/summary.js";
3
+ export { parseFigcraft, readDocumentJson, documentToTree } from "./parser/index.js";
4
+ export { formatText, formatSummary, formatValue } from "./report.js";
5
+ export { run } from "./run.js";
6
+ export type { Change, DesignNode, PropChange, Summary } from "./diff/types.js";
7
+ export { formatHtml, frameKey, escapeHtml } from "./html.js";
8
+ export type { FrameImages, HtmlOptions } from "./html.js";
9
+ export { decodePng, encodePng } from "./render/png.js";
10
+ export type { Image } from "./render/png.js";
11
+ export { compareImages } from "./render/compare.js";
12
+ export type { Comparison } from "./render/compare.js";
13
+ export { createCliRenderer, findFigcraftCli, RendererUnavailableError } from "./render/figcraft-cli.js";
14
+ export type { Renderer, Exec } from "./render/figcraft-cli.js";
15
+ export { renderChangedFrames } from "./render/frames.js";
16
+ export type { RenderOptions, RenderedFrames } from "./render/frames.js";
17
+ export { extractFromGit } from "./git.js";
18
+ export type { GitCopy, GitRun } from "./git.js";
package/dist/index.js ADDED
@@ -0,0 +1,12 @@
1
+ export { diffTrees } from "./diff/diff.js";
2
+ export { summarize } from "./diff/summary.js";
3
+ export { parseFigcraft, readDocumentJson, documentToTree } from "./parser/index.js";
4
+ export { formatText, formatSummary, formatValue } from "./report.js";
5
+ export { run } from "./run.js";
6
+ export { formatHtml, frameKey, escapeHtml } from "./html.js";
7
+ export { decodePng, encodePng } from "./render/png.js";
8
+ export { compareImages } from "./render/compare.js";
9
+ export { createCliRenderer, findFigcraftCli, RendererUnavailableError } from "./render/figcraft-cli.js";
10
+ export { renderChangedFrames } from "./render/frames.js";
11
+ export { extractFromGit } from "./git.js";
12
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAC;AAC3C,OAAO,EAAE,SAAS,EAAE,MAAM,mBAAmB,CAAC;AAC9C,OAAO,EAAE,aAAa,EAAE,gBAAgB,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AACpF,OAAO,EAAE,UAAU,EAAE,aAAa,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AACrE,OAAO,EAAE,GAAG,EAAE,MAAM,UAAU,CAAC;AAE/B,OAAO,EAAE,UAAU,EAAE,QAAQ,EAAE,UAAU,EAAE,MAAM,WAAW,CAAC;AAE7D,OAAO,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAEvD,OAAO,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAEpD,OAAO,EAAE,iBAAiB,EAAE,eAAe,EAAE,wBAAwB,EAAE,MAAM,0BAA0B,CAAC;AAExG,OAAO,EAAE,mBAAmB,EAAE,MAAM,oBAAoB,CAAC;AAEzD,OAAO,EAAE,cAAc,EAAE,MAAM,UAAU,CAAC"}
@@ -0,0 +1,12 @@
1
+ import type { DesignNode } from "../diff/types.js";
2
+ /**
3
+ * Convert the contents of a `.figcraft` file's `document.json` into figdiff's tree.
4
+ *
5
+ * Format (observed in FigCraft's example files, document version 1):
6
+ * - `nodes` is a flat map from node id (a string key) to the node
7
+ * - each node lists child ids in `children`; the root of the tree is each page in `pages`
8
+ * - numeric ids; `next_id` is the id counter
9
+ *
10
+ * See docs/format-notes.md.
11
+ */
12
+ export declare function documentToTree(doc: unknown): DesignNode;
@@ -0,0 +1,71 @@
1
+ /** Keys that describe structure rather than appearance; they are not diffed as props. */
2
+ const STRUCTURAL_KEYS = new Set(["id", "name", "type", "children", "parent"]);
3
+ /** Number of decimals kept when comparing numbers (absorbs float noise like 8.000000000000002). */
4
+ const PRECISION = 10_000;
5
+ function normalize(value) {
6
+ if (typeof value === "number")
7
+ return Math.round(value * PRECISION) / PRECISION;
8
+ if (Array.isArray(value))
9
+ return value.map(normalize);
10
+ if (value !== null && typeof value === "object") {
11
+ const out = {};
12
+ for (const [k, v] of Object.entries(value))
13
+ out[k] = normalize(v);
14
+ return out;
15
+ }
16
+ return value;
17
+ }
18
+ /**
19
+ * Convert the contents of a `.figcraft` file's `document.json` into figdiff's tree.
20
+ *
21
+ * Format (observed in FigCraft's example files, document version 1):
22
+ * - `nodes` is a flat map from node id (a string key) to the node
23
+ * - each node lists child ids in `children`; the root of the tree is each page in `pages`
24
+ * - numeric ids; `next_id` is the id counter
25
+ *
26
+ * See docs/format-notes.md.
27
+ */
28
+ export function documentToTree(doc) {
29
+ if (doc === null || typeof doc !== "object")
30
+ throw new Error("document.json is not an object");
31
+ const d = doc;
32
+ if (d.format !== "figcraft") {
33
+ throw new Error(`Not a FigCraft document (format: ${JSON.stringify(d.format)})`);
34
+ }
35
+ const nodes = d.nodes;
36
+ const pages = d.pages;
37
+ if (!nodes || typeof nodes !== "object" || !Array.isArray(pages)) {
38
+ throw new Error("document.json has no `nodes` map or `pages` list");
39
+ }
40
+ const seen = new Set();
41
+ const build = (id) => {
42
+ const key = String(id);
43
+ const raw = nodes[key];
44
+ if (!raw)
45
+ throw new Error(`Node ${key} is referenced but missing from document.json`);
46
+ if (seen.has(key))
47
+ throw new Error(`Node ${key} appears more than once in the tree`);
48
+ seen.add(key);
49
+ const props = {};
50
+ for (const [k, v] of Object.entries(raw)) {
51
+ if (!STRUCTURAL_KEYS.has(k))
52
+ props[k] = normalize(v);
53
+ }
54
+ const children = Array.isArray(raw.children) ? raw.children.map(build) : [];
55
+ return {
56
+ id: key,
57
+ type: String(raw.type ?? "unknown"),
58
+ name: String(raw.name ?? ""),
59
+ props,
60
+ children,
61
+ };
62
+ };
63
+ return {
64
+ id: "document",
65
+ type: "document",
66
+ name: String(d.name ?? "Untitled"),
67
+ props: {},
68
+ children: pages.map(build),
69
+ };
70
+ }
71
+ //# sourceMappingURL=document.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"document.js","sourceRoot":"","sources":["../../src/parser/document.ts"],"names":[],"mappings":"AAIA,yFAAyF;AACzF,MAAM,eAAe,GAAG,IAAI,GAAG,CAAC,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,UAAU,EAAE,QAAQ,CAAC,CAAC,CAAC;AAE9E,mGAAmG;AACnG,MAAM,SAAS,GAAG,MAAM,CAAC;AAEzB,SAAS,SAAS,CAAC,KAAc;IAC/B,IAAI,OAAO,KAAK,KAAK,QAAQ;QAAE,OAAO,IAAI,CAAC,KAAK,CAAC,KAAK,GAAG,SAAS,CAAC,GAAG,SAAS,CAAC;IAChF,IAAI,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC;QAAE,OAAO,KAAK,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC;IACtD,IAAI,KAAK,KAAK,IAAI,IAAI,OAAO,KAAK,KAAK,QAAQ,EAAE,CAAC;QAChD,MAAM,GAAG,GAAQ,EAAE,CAAC;QACpB,KAAK,MAAM,CAAC,CAAC,EAAE,CAAC,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,KAAY,CAAC;YAAE,GAAG,CAAC,CAAC,CAAC,GAAG,SAAS,CAAC,CAAC,CAAC,CAAC;QACzE,OAAO,GAAG,CAAC;IACb,CAAC;IACD,OAAO,KAAK,CAAC;AACf,CAAC;AAED;;;;;;;;;GASG;AACH,MAAM,UAAU,cAAc,CAAC,GAAY;IACzC,IAAI,GAAG,KAAK,IAAI,IAAI,OAAO,GAAG,KAAK,QAAQ;QAAE,MAAM,IAAI,KAAK,CAAC,gCAAgC,CAAC,CAAC;IAC/F,MAAM,CAAC,GAAG,GAAU,CAAC;IACrB,IAAI,CAAC,CAAC,MAAM,KAAK,UAAU,EAAE,CAAC;QAC5B,MAAM,IAAI,KAAK,CAAC,oCAAoC,IAAI,CAAC,SAAS,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;IACnF,CAAC;IACD,MAAM,KAAK,GAAG,CAAC,CAAC,KAAwC,CAAC;IACzD,MAAM,KAAK,GAAG,CAAC,CAAC,KAA8B,CAAC;IAC/C,IAAI,CAAC,KAAK,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,EAAE,CAAC;QACjE,MAAM,IAAI,KAAK,CAAC,kDAAkD,CAAC,CAAC;IACtE,CAAC;IAED,MAAM,IAAI,GAAG,IAAI,GAAG,EAAU,CAAC;IAC/B,MAAM,KAAK,GAAG,CAAC,EAAW,EAAc,EAAE;QACxC,MAAM,GAAG,GAAG,MAAM,CAAC,EAAE,CAAC,CAAC;QACvB,MAAM,GAAG,GAAG,KAAK,CAAC,GAAG,CAAC,CAAC;QACvB,IAAI,CAAC,GAAG;YAAE,MAAM,IAAI,KAAK,CAAC,QAAQ,GAAG,+CAA+C,CAAC,CAAC;QACtF,IAAI,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC;YAAE,MAAM,IAAI,KAAK,CAAC,QAAQ,GAAG,qCAAqC,CAAC,CAAC;QACrF,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;QAEd,MAAM,KAAK,GAAQ,EAAE,CAAC;QACtB,KAAK,MAAM,CAAC,CAAC,EAAE,CAAC,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,GAAG,CAAC,EAAE,CAAC;YACzC,IAAI,CAAC,eAAe,CAAC,GAAG,CAAC,CAAC,CAAC;gBAAE,KAAK,CAAC,CAAC,CAAC,GAAG,SAAS,CAAC,CAAC,CAAC,CAAC;QACvD,CAAC;QACD,MAAM,QAAQ,GAAG,KAAK,CAAC,OAAO,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,QAAQ,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;QAC5E,OAAO;YACL,EAAE,EAAE,GAAG;YACP,IAAI,EAAE,MAAM,CAAC,GAAG,CAAC,IAAI,IAAI,SAAS,CAAC;YACnC,IAAI,EAAE,MAAM,CAAC,GAAG,CAAC,IAAI,IAAI,EAAE,CAAC;YAC5B,KAAK;YACL,QAAQ;SACT,CAAC;IACJ,CAAC,CAAC;IAEF,OAAO;QACL,EAAE,EAAE,UAAU;QACd,IAAI,EAAE,UAAU;QAChB,IAAI,EAAE,MAAM,CAAC,CAAC,CAAC,IAAI,IAAI,UAAU,CAAC;QAClC,KAAK,EAAE,EAAE;QACT,QAAQ,EAAE,KAAK,CAAC,GAAG,CAAC,KAAK,CAAC;KAC3B,CAAC;AACJ,CAAC"}
@@ -0,0 +1,10 @@
1
+ import type { DesignNode } from "../diff/types.js";
2
+ export { documentToTree } from "./document.js";
3
+ /**
4
+ * Read a .figcraft archive and return its parsed `document.json`.
5
+ *
6
+ * The archive is a zip containing `document.json`, and optionally
7
+ * `thumbnail.png`, `images/`, `versions/`, `branches/` and `library/`.
8
+ */
9
+ export declare function readDocumentJson(path: string): unknown;
10
+ export declare function parseFigcraft(path: string): DesignNode;
@@ -0,0 +1,21 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { unzipSync, strFromU8 } from "fflate";
3
+ import { documentToTree } from "./document.js";
4
+ export { documentToTree } from "./document.js";
5
+ /**
6
+ * Read a .figcraft archive and return its parsed `document.json`.
7
+ *
8
+ * The archive is a zip containing `document.json`, and optionally
9
+ * `thumbnail.png`, `images/`, `versions/`, `branches/` and `library/`.
10
+ */
11
+ export function readDocumentJson(path) {
12
+ const files = unzipSync(new Uint8Array(readFileSync(path)));
13
+ const entry = files["document.json"];
14
+ if (!entry)
15
+ throw new Error(`${path}: no document.json found. Is this a .figcraft file?`);
16
+ return JSON.parse(strFromU8(entry));
17
+ }
18
+ export function parseFigcraft(path) {
19
+ return documentToTree(readDocumentJson(path));
20
+ }
21
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/parser/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AACvC,OAAO,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,QAAQ,CAAC;AAE9C,OAAO,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAE/C,OAAO,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAE/C;;;;;GAKG;AACH,MAAM,UAAU,gBAAgB,CAAC,IAAY;IAC3C,MAAM,KAAK,GAAG,SAAS,CAAC,IAAI,UAAU,CAAC,YAAY,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IAC5D,MAAM,KAAK,GAAG,KAAK,CAAC,eAAe,CAAC,CAAC;IACrC,IAAI,CAAC,KAAK;QAAE,MAAM,IAAI,KAAK,CAAC,GAAG,IAAI,qDAAqD,CAAC,CAAC;IAC1F,OAAO,IAAI,CAAC,KAAK,CAAC,SAAS,CAAC,KAAK,CAAC,CAAC,CAAC;AACtC,CAAC;AAED,MAAM,UAAU,aAAa,CAAC,IAAY;IACxC,OAAO,cAAc,CAAC,gBAAgB,CAAC,IAAI,CAAC,CAAC,CAAC;AAChD,CAAC"}
@@ -0,0 +1,19 @@
1
+ import type { Image } from "./png.js";
2
+ export interface Comparison {
3
+ width: number;
4
+ height: number;
5
+ changedPixels: number;
6
+ totalPixels: number;
7
+ /** changedPixels / totalPixels, from 0 to 1. */
8
+ ratio: number;
9
+ /** Faded copy of the new image with changed pixels in solid red. */
10
+ diff: Image;
11
+ }
12
+ /**
13
+ * Compare two images pixel by pixel. If sizes differ, the comparison canvas is the larger of
14
+ * the two, and any area covered by only one image counts as changed.
15
+ *
16
+ * `tolerance` is the largest per-channel difference (0 to 255) still treated as unchanged,
17
+ * which absorbs anti-aliasing noise.
18
+ */
19
+ export declare function compareImages(before: Image, after: Image, tolerance?: number): Comparison;
@@ -0,0 +1,61 @@
1
+ const HIGHLIGHT = [255, 45, 85, 255];
2
+ /**
3
+ * Compare two images pixel by pixel. If sizes differ, the comparison canvas is the larger of
4
+ * the two, and any area covered by only one image counts as changed.
5
+ *
6
+ * `tolerance` is the largest per-channel difference (0 to 255) still treated as unchanged,
7
+ * which absorbs anti-aliasing noise.
8
+ */
9
+ export function compareImages(before, after, tolerance = 8) {
10
+ const width = Math.max(before.width, after.width);
11
+ const height = Math.max(before.height, after.height);
12
+ const out = new Uint8Array(width * height * 4);
13
+ let changed = 0;
14
+ const read = (img, x, y) => x < img.width && y < img.height ? (y * img.width + x) * 4 : undefined;
15
+ for (let y = 0; y < height; y++) {
16
+ for (let x = 0; x < width; x++) {
17
+ const a = read(before, x, y);
18
+ const b = read(after, x, y);
19
+ const o = (y * width + x) * 4;
20
+ let isChanged;
21
+ if (a === undefined || b === undefined) {
22
+ isChanged = a !== b;
23
+ }
24
+ else {
25
+ isChanged = false;
26
+ for (let c = 0; c < 4; c++) {
27
+ if (Math.abs(before.data[a + c] - after.data[b + c]) > tolerance) {
28
+ isChanged = true;
29
+ break;
30
+ }
31
+ }
32
+ }
33
+ if (isChanged) {
34
+ changed++;
35
+ out.set(HIGHLIGHT, o);
36
+ }
37
+ else if (b !== undefined) {
38
+ // Unchanged: show the pixel faded to 25% over white so the changes stand out.
39
+ const alpha = after.data[b + 3] / 255;
40
+ for (let c = 0; c < 3; c++) {
41
+ const over = after.data[b + c] * alpha + 255 * (1 - alpha);
42
+ out[o + c] = Math.round(255 - (255 - over) * 0.25);
43
+ }
44
+ out[o + 3] = 255;
45
+ }
46
+ else {
47
+ out.set([255, 255, 255, 255], o);
48
+ }
49
+ }
50
+ }
51
+ const totalPixels = width * height;
52
+ return {
53
+ width,
54
+ height,
55
+ changedPixels: changed,
56
+ totalPixels,
57
+ ratio: totalPixels === 0 ? 0 : changed / totalPixels,
58
+ diff: { width, height, data: out },
59
+ };
60
+ }
61
+ //# sourceMappingURL=compare.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"compare.js","sourceRoot":"","sources":["../../src/render/compare.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,21 @@
1
+ /** Runs a program; must throw if it cannot be started or exits with an error. */
2
+ export type Exec = (command: string, args: string[]) => void;
3
+ export declare const defaultExec: Exec;
4
+ /** Renders one top-level frame of a design file to a PNG. */
5
+ export interface Renderer {
6
+ /** Returns the PNG bytes, or undefined when the frame does not exist in that file. */
7
+ renderFrame(file: string, page: string, frame: string, scale: number): Uint8Array | undefined;
8
+ }
9
+ /** Thrown when figcraft-cli itself cannot be run, as opposed to a frame that is missing. */
10
+ export declare class RendererUnavailableError extends Error {
11
+ }
12
+ /**
13
+ * Locate figcraft-cli. An explicit path wins; otherwise the FIGCRAFT_CLI environment
14
+ * variable, then `figcraft-cli` on the PATH. Returns undefined when it cannot be started.
15
+ */
16
+ export declare function findFigcraftCli(explicit?: string, exec?: Exec, env?: Record<string, string | undefined>): string | undefined;
17
+ /**
18
+ * Renderer backed by `figcraft-cli export`. The frame is selected by name; when the page
19
+ * is also passed and the CLI rejects that combination, it retries with the name alone.
20
+ */
21
+ export declare function createCliRenderer(bin: string, exec?: Exec): Renderer;
@@ -0,0 +1,63 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { mkdtempSync, readFileSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ export const defaultExec = (command, args) => {
6
+ execFileSync(command, args, { stdio: "ignore", timeout: 120_000 });
7
+ };
8
+ /** Thrown when figcraft-cli itself cannot be run, as opposed to a frame that is missing. */
9
+ export class RendererUnavailableError extends Error {
10
+ }
11
+ const isNotFound = (err) => err?.code === "ENOENT";
12
+ /**
13
+ * Locate figcraft-cli. An explicit path wins; otherwise the FIGCRAFT_CLI environment
14
+ * variable, then `figcraft-cli` on the PATH. Returns undefined when it cannot be started.
15
+ */
16
+ export function findFigcraftCli(explicit, exec = defaultExec, env = process.env) {
17
+ const candidates = explicit ? [explicit] : [env.FIGCRAFT_CLI, "figcraft-cli"];
18
+ for (const candidate of candidates) {
19
+ if (!candidate)
20
+ continue;
21
+ try {
22
+ exec(candidate, ["--help"]);
23
+ return candidate;
24
+ }
25
+ catch (err) {
26
+ // Exists but did not like --help: still usable. Only "not found" rules it out.
27
+ if (!isNotFound(err))
28
+ return candidate;
29
+ }
30
+ }
31
+ return undefined;
32
+ }
33
+ /**
34
+ * Renderer backed by `figcraft-cli export`. The frame is selected by name; when the page
35
+ * is also passed and the CLI rejects that combination, it retries with the name alone.
36
+ */
37
+ export function createCliRenderer(bin, exec = defaultExec) {
38
+ return {
39
+ renderFrame(file, page, frame, scale) {
40
+ const dir = mkdtempSync(join(tmpdir(), "figdiff-render-"));
41
+ const out = join(dir, "frame.png");
42
+ try {
43
+ const base = ["export", file, "-o", out, "--format", "png", "--scale", String(scale)];
44
+ for (const selector of [["--page", page, "--node", frame], ["--node", frame]]) {
45
+ try {
46
+ exec(bin, [...base, ...selector]);
47
+ return new Uint8Array(readFileSync(out));
48
+ }
49
+ catch (err) {
50
+ if (isNotFound(err))
51
+ throw new RendererUnavailableError(`Cannot run ${bin}`);
52
+ // Otherwise: this selector did not work, or the frame is not in this file.
53
+ }
54
+ }
55
+ return undefined;
56
+ }
57
+ finally {
58
+ rmSync(dir, { recursive: true, force: true });
59
+ }
60
+ },
61
+ };
62
+ }
63
+ //# sourceMappingURL=figcraft-cli.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"figcraft-cli.js","sourceRoot":"","sources":["../../src/render/figcraft-cli.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAClD,OAAO,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,EAAE,MAAM,SAAS,CAAC;AAC5D,OAAO,EAAE,MAAM,EAAE,MAAM,SAAS,CAAC;AACjC,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AAKjC,MAAM,CAAC,MAAM,WAAW,GAAS,CAAC,OAAO,EAAE,IAAI,EAAE,EAAE;IACjD,YAAY,CAAC,OAAO,EAAE,IAAI,EAAE,EAAE,KAAK,EAAE,QAAQ,EAAE,OAAO,EAAE,OAAO,EAAE,CAAC,CAAC;AACrE,CAAC,CAAC;AAQF,4FAA4F;AAC5F,MAAM,OAAO,wBAAyB,SAAQ,KAAK;CAAG;AAEtD,MAAM,UAAU,GAAG,CAAC,GAAY,EAAW,EAAE,CAAE,GAAgC,EAAE,IAAI,KAAK,QAAQ,CAAC;AAEnG;;;GAGG;AACH,MAAM,UAAU,eAAe,CAC7B,QAAiB,EACjB,OAAa,WAAW,EACxB,MAA0C,OAAO,CAAC,GAAG;IAErD,MAAM,UAAU,GAAG,QAAQ,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,YAAY,EAAE,cAAc,CAAC,CAAC;IAC9E,KAAK,MAAM,SAAS,IAAI,UAAU,EAAE,CAAC;QACnC,IAAI,CAAC,SAAS;YAAE,SAAS;QACzB,IAAI,CAAC;YACH,IAAI,CAAC,SAAS,EAAE,CAAC,QAAQ,CAAC,CAAC,CAAC;YAC5B,OAAO,SAAS,CAAC;QACnB,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,+EAA+E;YAC/E,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC;gBAAE,OAAO,SAAS,CAAC;QACzC,CAAC;IACH,CAAC;IACD,OAAO,SAAS,CAAC;AACnB,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,iBAAiB,CAAC,GAAW,EAAE,OAAa,WAAW;IACrE,OAAO;QACL,WAAW,CAAC,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE,KAAK;YAClC,MAAM,GAAG,GAAG,WAAW,CAAC,IAAI,CAAC,MAAM,EAAE,EAAE,iBAAiB,CAAC,CAAC,CAAC;YAC3D,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,EAAE,WAAW,CAAC,CAAC;YACnC,IAAI,CAAC;gBACH,MAAM,IAAI,GAAG,CAAC,QAAQ,EAAE,IAAI,EAAE,IAAI,EAAE,GAAG,EAAE,UAAU,EAAE,KAAK,EAAE,SAAS,EAAE,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC;gBACtF,KAAK,MAAM,QAAQ,IAAI,CAAC,CAAC,QAAQ,EAAE,IAAI,EAAE,QAAQ,EAAE,KAAK,CAAC,EAAE,CAAC,QAAQ,EAAE,KAAK,CAAC,CAAC,EAAE,CAAC;oBAC9E,IAAI,CAAC;wBACH,IAAI,CAAC,GAAG,EAAE,CAAC,GAAG,IAAI,EAAE,GAAG,QAAQ,CAAC,CAAC,CAAC;wBAClC,OAAO,IAAI,UAAU,CAAC,YAAY,CAAC,GAAG,CAAC,CAAC,CAAC;oBAC3C,CAAC;oBAAC,OAAO,GAAG,EAAE,CAAC;wBACb,IAAI,UAAU,CAAC,GAAG,CAAC;4BAAE,MAAM,IAAI,wBAAwB,CAAC,cAAc,GAAG,EAAE,CAAC,CAAC;wBAC7E,2EAA2E;oBAC7E,CAAC;gBACH,CAAC;gBACD,OAAO,SAAS,CAAC;YACnB,CAAC;oBAAS,CAAC;gBACT,MAAM,CAAC,GAAG,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC;YAChD,CAAC;QACH,CAAC;KACF,CAAC;AACJ,CAAC"}
@@ -0,0 +1,19 @@
1
+ import type { Change } from "../diff/types.js";
2
+ import type { FrameImages } from "../html.js";
3
+ import type { Renderer } from "./figcraft-cli.js";
4
+ export interface RenderOptions {
5
+ /** Render scale passed to figcraft-cli. Default 1. */
6
+ scale?: number;
7
+ /** Most frames to render, to keep reports small and fast. Default 30. */
8
+ maxFrames?: number;
9
+ }
10
+ export interface RenderedFrames {
11
+ images: Record<string, FrameImages>;
12
+ warnings: string[];
13
+ }
14
+ /**
15
+ * Render every top-level frame that has changes, in both versions, and compare them.
16
+ * A frame missing from one version (added or removed) gets only the image that exists.
17
+ * Throws RendererUnavailableError if figcraft-cli cannot be run at all.
18
+ */
19
+ export declare function renderChangedFrames(changes: Change[], beforePath: string, afterPath: string, renderer: Renderer, options?: RenderOptions): RenderedFrames;