visualproof 0.2.0 → 0.4.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/README.md +88 -33
- package/dist/assets/tips/phone-fingers.light.mp4 +0 -0
- package/dist/assets/tips/phone-fingers.light.webp +0 -0
- package/dist/assets/tips/tour-agent.webp +0 -0
- package/dist/assets/tips/tour-arrange.webp +0 -0
- package/dist/assets/tips/tour-artwork.webp +0 -0
- package/dist/assets/tips/tour-checks.webp +0 -0
- package/dist/assets/tips/tour-export.webp +0 -0
- package/dist/assets/tips/tour-keys.webp +0 -0
- package/dist/assets/tips/tour-library.webp +0 -0
- package/dist/assets/tips/tour-project.webp +0 -0
- package/dist/assets/tips/tour-review.webp +0 -0
- package/dist/assets/tips/tour-share.webp +0 -0
- package/dist/assets/tips/tour-source.webp +0 -0
- package/dist/assets/tips/tour-target.webp +0 -0
- package/dist/assets/tips/tour-view.webp +0 -0
- package/dist/assets/tips/tour.json +289 -0
- package/dist/assets/tips/tour.webp +0 -0
- package/dist/assets/webfonts/LICENSE-Noto.txt +95 -0
- package/dist/assets/webfonts/metrics.json +1 -0
- package/dist/assets/webfonts/ttf/jost-400-normal.ttf +0 -0
- package/dist/assets/webfonts/ttf/jost-500-normal.ttf +0 -0
- package/dist/assets/webfonts/ttf/noto-sans-math.ttf +0 -0
- package/dist/assets/webfonts/ttf/noto-sans.ttf +0 -0
- package/dist/assets/webfonts/ttf/source-sans-3-400-italic.ttf +0 -0
- package/dist/assets/webfonts/ttf/source-sans-3-400-normal.ttf +0 -0
- package/dist/assets/webfonts/ttf/source-sans-3-500-normal.ttf +0 -0
- package/dist/assets/webfonts/ttf/source-sans-3-600-normal.ttf +0 -0
- package/dist/assets/webfonts/ttf/source-sans-3-700-normal.ttf +0 -0
- package/dist/assets/webfonts/ttf/source-serif-4-400-italic.ttf +0 -0
- package/dist/assets/webfonts/ttf/source-serif-4-600-italic.ttf +0 -0
- package/dist/mcp/visualproof-mcp.mjs +3 -1
- package/dist/server/agent.js +94 -91
- package/dist/server/agentdocs.js +1 -1
- package/dist/server/artcmd.js +335 -0
- package/dist/server/children.js +85 -0
- package/dist/server/doctor.js +105 -42
- package/dist/server/events.js +10 -0
- package/dist/server/export.js +11 -4
- package/dist/server/extract.js +3 -2
- package/dist/server/figures.js +3 -2
- package/dist/server/film.js +380 -0
- package/dist/server/history.js +11 -1
- package/dist/server/hover.js +8 -5
- package/dist/server/live.js +72 -9
- package/dist/server/main.js +352 -23
- package/dist/server/mcptools.js +4 -1
- package/dist/server/media.js +114 -0
- package/dist/server/pipeline.js +21 -10
- package/dist/server/poppler.js +3 -2
- package/dist/server/readonlygate.js +4 -4
- package/dist/server/review.js +4 -1
- package/dist/server/reviewapi.js +35 -1
- package/dist/server/snapshot.js +10 -1
- package/dist/server/sourcestore.js +3 -2
- package/dist/server/status.js +3 -2
- package/dist/server/supervise.js +219 -0
- package/dist/server/updates.js +951 -0
- package/dist/server/usage.js +3 -1
- package/dist/server/version.js +183 -0
- package/dist/server/view.js +0 -7
- package/dist/server/views.js +24 -0
- package/dist/shared/artfonts.js +81 -0
- package/dist/shared/artrecipe.js +316 -0
- package/dist/shared/collab.js +7 -2
- package/dist/shared/declfind.js +95 -0
- package/dist/shared/lostfound.js +1 -0
- package/dist/shared/pastgraph.js +6 -6
- package/dist/shared/review.js +62 -5
- package/dist/shared/timeline.js +5 -0
- package/dist/web/bundle/app.js +3006 -413
- package/dist/web/bundle/edges.js +1 -1
- package/dist/web/index.html +13 -1
- package/dist/web/src/agentmark.js +14 -0
- package/dist/web/src/artwork.js +1000 -0
- package/dist/web/src/bundles.js +151 -0
- package/dist/web/src/canvas.js +3716 -0
- package/dist/web/src/drag.js +61 -0
- package/dist/web/src/edgequeue.js +204 -0
- package/dist/web/src/gl/gl.js +123 -0
- package/dist/web/src/gl/renderer.js +451 -0
- package/dist/web/src/halo.js +95 -0
- package/dist/web/src/highlight.js +108 -0
- package/dist/web/src/overview.js +393 -0
- package/dist/web/src/reviewqueue.js +250 -0
- package/dist/web/src/selection.js +272 -0
- package/dist/web/src/status.js +80 -0
- package/dist/web/src/visitors.js +318 -0
- package/dist/web/src/visits.js +174 -0
- package/dist/web/style.css +196 -1
- package/docs/agent-api/README.md +1 -1
- package/docs/agent-api/export.md +41 -2
- package/docs/agent-api/history.md +5 -3
- package/docs/agent-api/mcp.md +1 -1
- package/docs/agent-api/reading.md +2 -1
- package/package.json +5 -1
package/dist/server/usage.js
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
// again. A module that cannot be elaborated is reported and keeps the text-based context and no
|
|
19
19
|
// notation edges; the others are not held back by it.
|
|
20
20
|
import { spawn } from "node:child_process";
|
|
21
|
+
import { childEnv, DETACH, grouped } from "./children.js";
|
|
21
22
|
import { createHash } from "node:crypto";
|
|
22
23
|
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
23
24
|
import { join } from "node:path";
|
|
@@ -62,7 +63,8 @@ export function traceOptions(p, module) {
|
|
|
62
63
|
* Asked once per pass, and every worker started with it, rather than through `lake env` each. */
|
|
63
64
|
function lakeEnv(root) {
|
|
64
65
|
return new Promise((resolveP, reject) => {
|
|
65
|
-
|
|
66
|
+
// the server's own variables are not the workers': they are given this environment
|
|
67
|
+
const child = grouped(spawn("lake", ["env", "env", "-0"], { cwd: root, stdio: ["ignore", "pipe", "pipe"], detached: DETACH, env: childEnv() }));
|
|
66
68
|
const out = [];
|
|
67
69
|
let err = "";
|
|
68
70
|
child.stdout.on("data", (b) => out.push(b));
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// Versions: this package's own, and the ranges package.json's `engines` writes, as `visualproof
|
|
2
|
+
// doctor` checks Node against them and a server checks a release it would take (server/updates.ts).
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
let own = null;
|
|
6
|
+
/** The version of this package: read once, as the process started it, so that a package installed
|
|
7
|
+
* over it meanwhile does not change what it says it runs (UpdateSpec decision 5). */
|
|
8
|
+
export function packageVersion() {
|
|
9
|
+
return (own ??= JSON.parse(readFileSync(join(import.meta.dirname, "..", "..", "package.json"), "utf8")).version);
|
|
10
|
+
}
|
|
11
|
+
export function parseVersion(v) {
|
|
12
|
+
const m = /(\d+)\.(\d+)\.(\d+)/.exec(v);
|
|
13
|
+
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
|
|
14
|
+
}
|
|
15
|
+
export function cmp(a, b) {
|
|
16
|
+
for (let i = 0; i < 3; i++)
|
|
17
|
+
if (a[i] !== b[i])
|
|
18
|
+
return a[i] - b[i];
|
|
19
|
+
return 0;
|
|
20
|
+
}
|
|
21
|
+
/** npm's order: by the numbers, then a prerelease before its release, then the prerelease
|
|
22
|
+
* identifiers one by one (numbers before words, numbers by value, words as text), fewer first. */
|
|
23
|
+
function order(a, b) {
|
|
24
|
+
const c = cmp(a.v, b.v);
|
|
25
|
+
if (c)
|
|
26
|
+
return c;
|
|
27
|
+
if (!a.pre.length || !b.pre.length)
|
|
28
|
+
return (b.pre.length ? 1 : 0) - (a.pre.length ? 1 : 0);
|
|
29
|
+
for (let i = 0; i < Math.max(a.pre.length, b.pre.length); i++) {
|
|
30
|
+
const x = a.pre[i], y = b.pre[i];
|
|
31
|
+
if (x === undefined)
|
|
32
|
+
return -1;
|
|
33
|
+
if (y === undefined)
|
|
34
|
+
return 1;
|
|
35
|
+
if (x === y)
|
|
36
|
+
continue;
|
|
37
|
+
if (typeof x === "number" && typeof y === "number")
|
|
38
|
+
return x - y;
|
|
39
|
+
if (typeof x === "number")
|
|
40
|
+
return -1;
|
|
41
|
+
if (typeof y === "number")
|
|
42
|
+
return 1;
|
|
43
|
+
return x < y ? -1 : 1;
|
|
44
|
+
}
|
|
45
|
+
return 0;
|
|
46
|
+
}
|
|
47
|
+
/** Prerelease identifiers, as npm takes them: none empty, a number without a leading zero. */
|
|
48
|
+
function prerelease(text) {
|
|
49
|
+
if (text === undefined)
|
|
50
|
+
return [];
|
|
51
|
+
const ids = text.split(".");
|
|
52
|
+
if (ids.some((id) => !/^[0-9A-Za-z-]+$/.test(id) || /^0\d+$/.test(id)))
|
|
53
|
+
return null;
|
|
54
|
+
return ids.map((id) => (/^\d+$/.test(id) ? Number(id) : id));
|
|
55
|
+
}
|
|
56
|
+
const NUM = "0|[1-9]\\d*";
|
|
57
|
+
const PART = `(${NUM}|[xX*])`;
|
|
58
|
+
const PARTIAL = new RegExp(`^v?${PART}(?:\\.${PART})?(?:\\.${PART})?(?:-([^+]+))?(?:\\+[0-9A-Za-z.-]+)?$`);
|
|
59
|
+
function partial(text) {
|
|
60
|
+
const m = PARTIAL.exec(text);
|
|
61
|
+
if (!m)
|
|
62
|
+
return null;
|
|
63
|
+
const part = (x) => (x === undefined || /^[xX*]$/.test(x) ? null : Number(x));
|
|
64
|
+
const p = [part(m[1]), part(m[2]), part(m[3])];
|
|
65
|
+
// a wildcard leaves the parts after it wild (`1.x.3` is `1.x`)
|
|
66
|
+
for (let i = 1; i < 3; i++)
|
|
67
|
+
if (p[i - 1] === null)
|
|
68
|
+
p[i] = null;
|
|
69
|
+
const pre = prerelease(m[4]);
|
|
70
|
+
if (pre === null || (pre.length && p[2] === null))
|
|
71
|
+
return null;
|
|
72
|
+
return { p, pre };
|
|
73
|
+
}
|
|
74
|
+
/** the least version a partial one covers: a version written whole is itself; one with a wild part
|
|
75
|
+
* (`22`, `22.18.x`) starts at its first prerelease (`22.0.0-0`), as npm's includePrerelease has it */
|
|
76
|
+
const lower = (x) => ({ v: [x.p[0] ?? 0, x.p[1] ?? 0, x.p[2] ?? 0], pre: x.pre.length ? x.pre : x.p[2] === null ? [0] : [] });
|
|
77
|
+
/** the first version past what a partial version covers (`1.2` covers 1.2.x: up to 1.3.0's first
|
|
78
|
+
* prerelease, 1.3.0-0); null for `*` */
|
|
79
|
+
function past(x) {
|
|
80
|
+
const [a, b, c] = x.p;
|
|
81
|
+
if (a === null)
|
|
82
|
+
return null;
|
|
83
|
+
if (b === null)
|
|
84
|
+
return { v: [a + 1, 0, 0], pre: [0] };
|
|
85
|
+
if (c === null)
|
|
86
|
+
return { v: [a, b + 1, 0], pre: [0] };
|
|
87
|
+
return { v: [a, b, c + 1], pre: [0] };
|
|
88
|
+
}
|
|
89
|
+
/** One comparator as npm reads it (node-semver's desugaring), as bounds; null where it is no comparator. */
|
|
90
|
+
function bounds(op, text) {
|
|
91
|
+
const x = partial(text);
|
|
92
|
+
if (!x)
|
|
93
|
+
return null;
|
|
94
|
+
const lo = lower(x);
|
|
95
|
+
const hi = past(x);
|
|
96
|
+
const wild = x.p[2] === null;
|
|
97
|
+
switch (op) {
|
|
98
|
+
case "":
|
|
99
|
+
case "=":
|
|
100
|
+
// one version, or an x-range
|
|
101
|
+
return !wild ? [["=", lo]] : hi ? [[">=", lo], ["<", hi]] : [];
|
|
102
|
+
case ">=":
|
|
103
|
+
return [[">=", lo]];
|
|
104
|
+
case ">":
|
|
105
|
+
// past what it covers: `>1.2` is `>=1.3.0`; `>*`, nothing
|
|
106
|
+
return !wild ? [[">", lo]] : hi ? [[">=", hi]] : [["<", { v: [0, 0, 0], pre: [0] }]];
|
|
107
|
+
case "<":
|
|
108
|
+
return [["<", lo]];
|
|
109
|
+
case "<=":
|
|
110
|
+
return !wild ? [["<=", lo]] : hi ? [["<", hi]] : [];
|
|
111
|
+
case "~":
|
|
112
|
+
case "~>": {
|
|
113
|
+
// patches of the minor given (of the major, where only it is)
|
|
114
|
+
const [a, b] = x.p;
|
|
115
|
+
if (a === null)
|
|
116
|
+
return [];
|
|
117
|
+
return [[">=", lo], ["<", { v: b === null ? [a + 1, 0, 0] : [a, b + 1, 0], pre: [0] }]];
|
|
118
|
+
}
|
|
119
|
+
case "^": {
|
|
120
|
+
// the same leftmost part that is not zero
|
|
121
|
+
const [a, b, c] = x.p;
|
|
122
|
+
if (a === null)
|
|
123
|
+
return [];
|
|
124
|
+
const top = a > 0 || b === null ? [a + 1, 0, 0] : b > 0 || c === null ? [0, b + 1, 0] : [0, 0, c + 1];
|
|
125
|
+
return [[">=", lo], ["<", { v: top, pre: [0] }]];
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
/** an operator, spaces, a version: `>= 22.0.0` as `>=22.0.0`; never `> = 22` */
|
|
131
|
+
const COMPARATOR = /^(<=|>=|<|>|=|\^|~>|~)?\s*([^\s<>=^~]+)\s*/;
|
|
132
|
+
/** One alternative of a range (no `||`): a hyphen range, or comparators joined by spaces; null where
|
|
133
|
+
* npm could not read it. */
|
|
134
|
+
function alternative(text) {
|
|
135
|
+
const t = text.trim();
|
|
136
|
+
if (!t)
|
|
137
|
+
return [];
|
|
138
|
+
const hyphen = /^(\S+)\s+-\s+(\S+)$/.exec(t);
|
|
139
|
+
if (hyphen) {
|
|
140
|
+
const a = partial(hyphen[1]);
|
|
141
|
+
const b = partial(hyphen[2]);
|
|
142
|
+
if (!a || !b)
|
|
143
|
+
return null;
|
|
144
|
+
const top = b.p[2] === null ? past(b) : null;
|
|
145
|
+
return [[">=", lower(a)], ...(b.p[0] === null ? [] : top ? [["<", top]] : [["<=", lower(b)]])];
|
|
146
|
+
}
|
|
147
|
+
const out = [];
|
|
148
|
+
for (let rest = t; rest;) {
|
|
149
|
+
const m = COMPARATOR.exec(rest);
|
|
150
|
+
if (!m)
|
|
151
|
+
return null;
|
|
152
|
+
const b = bounds(m[1] ?? "", m[2]);
|
|
153
|
+
if (!b)
|
|
154
|
+
return null;
|
|
155
|
+
out.push(...b);
|
|
156
|
+
rest = rest.slice(m[0].length);
|
|
157
|
+
}
|
|
158
|
+
return out;
|
|
159
|
+
}
|
|
160
|
+
/** Whether `version` meets a range as package.json's `engines` writes one, as npm checks a Node
|
|
161
|
+
* against engines (npm-install-checks: semver with includePrerelease): alternatives joined by
|
|
162
|
+
* `||`, each a hyphen range (`22.18.0 - 24`) or comparators joined by spaces — `>=`, `>`, `<=`,
|
|
163
|
+
* `<`, `=`, `^`, `~`, a version, an x-range (`22`, `22.x`, `22.18.*`, `*`) — an operator apart from
|
|
164
|
+
* its version or not. An empty range, or one of spaces alone, is any version; one npm could not
|
|
165
|
+
* read, in any of its alternatives, none. Prereleases in npm's order — 22.18.0 is past
|
|
166
|
+
* 22.18.0-rc.1, so `<=22.18.0-rc.1` excludes it — and a prerelease Node is taken as any version
|
|
167
|
+
* (`24.0.0-rc.1` meets `>=22`; `22.0.0-rc.1` meets `22`, which starts at `22.0.0-0`). */
|
|
168
|
+
export function satisfies(version, range) {
|
|
169
|
+
const m = /^v?(\d+)\.(\d+)\.(\d+)(?:-([^+]+))?(?:\+.*)?$/.exec(version.trim());
|
|
170
|
+
const pre = m ? prerelease(m[4]) : null;
|
|
171
|
+
if (!m || pre === null)
|
|
172
|
+
return false;
|
|
173
|
+
const v = { v: [Number(m[1]), Number(m[2]), Number(m[3])], pre };
|
|
174
|
+
if (range === undefined || range === null || !range.trim())
|
|
175
|
+
return true;
|
|
176
|
+
const alts = range.split("||").map(alternative);
|
|
177
|
+
if (alts.some((a) => a === null))
|
|
178
|
+
return false;
|
|
179
|
+
return alts.some((bs) => bs.every(([op, w]) => {
|
|
180
|
+
const c = order(v, w);
|
|
181
|
+
return op === ">=" ? c >= 0 : op === ">" ? c > 0 : op === "<=" ? c <= 0 : op === "<" ? c < 0 : c === 0;
|
|
182
|
+
}));
|
|
183
|
+
}
|
package/dist/server/view.js
CHANGED
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
import { readFileSync } from "node:fs";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { LAYOUT_VERSION } from "../shared/graph.js";
|
|
6
|
-
import { gitSource } from "./history.js";
|
|
7
6
|
import { layOutHere, layOutOffThread } from "./layoutthread.js";
|
|
8
7
|
import { compiledTextHash } from "./status.js";
|
|
9
8
|
import { versionBase, versions } from "./versions.js";
|
|
@@ -75,11 +74,6 @@ function viewParts(p, ex) {
|
|
|
75
74
|
});
|
|
76
75
|
return { nodes, edges };
|
|
77
76
|
}
|
|
78
|
-
/** The project's git source and commit as it is read, where it is a git repository. */
|
|
79
|
-
function gitOf(root) {
|
|
80
|
-
const git = gitSource(root);
|
|
81
|
-
return git ? { git } : {};
|
|
82
|
-
}
|
|
83
77
|
function assemble(p, ex, nodes, edges, layouts) {
|
|
84
78
|
const g = ex.graph;
|
|
85
79
|
const meta = {
|
|
@@ -93,7 +87,6 @@ function assemble(p, ex, nodes, edges, layouts) {
|
|
|
93
87
|
toolchain: p.toolchain,
|
|
94
88
|
extractedAt: ex.at,
|
|
95
89
|
extractMs: g.stats.ms,
|
|
96
|
-
...gitOf(p.root),
|
|
97
90
|
},
|
|
98
91
|
modules: g.modules.map((m) => {
|
|
99
92
|
const file = p.files.get(m.name) ?? null;
|
package/dist/server/views.js
CHANGED
|
@@ -161,6 +161,30 @@ function cleanMemory(x) {
|
|
|
161
161
|
return null;
|
|
162
162
|
return { set: m.set, mem: { nodes, clusters } };
|
|
163
163
|
}
|
|
164
|
+
/** The Project view as a site keeps it — its pins, folds, hidden levels and clustering — read and
|
|
165
|
+
* nothing written (the artwork command, ExportSpec decision 21; the Views class sets aside a file
|
|
166
|
+
* it cannot read): null where the site keeps none, or `why` where its views cannot be read. Pins
|
|
167
|
+
* dropped under another way of laying out mean nothing in this one, as Views reads them. */
|
|
168
|
+
export function readProjectView(siteDir, layoutVersion) {
|
|
169
|
+
const file = join(siteDir, "views.json");
|
|
170
|
+
if (!existsSync(file))
|
|
171
|
+
return { view: null };
|
|
172
|
+
try {
|
|
173
|
+
const raw = JSON.parse(readFileSync(file, "utf8"));
|
|
174
|
+
for (const r of Array.isArray(raw.views) ? raw.views : []) {
|
|
175
|
+
const v = readView(r);
|
|
176
|
+
if (typeof v === "string" || v.id !== PROJECT)
|
|
177
|
+
continue;
|
|
178
|
+
if (raw.layoutVersion !== layoutVersion)
|
|
179
|
+
v.pins = emptyLayouts();
|
|
180
|
+
return { view: v };
|
|
181
|
+
}
|
|
182
|
+
return { view: null };
|
|
183
|
+
}
|
|
184
|
+
catch (e) {
|
|
185
|
+
return { view: null, why: e instanceof Error ? e.message : String(e) };
|
|
186
|
+
}
|
|
187
|
+
}
|
|
164
188
|
export class Views {
|
|
165
189
|
book;
|
|
166
190
|
file;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// Text measured as an artwork's fonts draw it, without a browser (ExportSpec decision 21): the
|
|
2
|
+
// advance widths of the faces the package ships (assets/webfonts/metrics.json, made by
|
|
3
|
+
// scripts/artfonts.py), for a CSS font as a canvas takes it. A face stands for its family at the
|
|
4
|
+
// nearest weight it is made at; a character it has not is measured in the faces that stand in for
|
|
5
|
+
// it (Noto Sans, Noto Sans Math), as the renderer draws it from them. No kerning: the renderer the
|
|
6
|
+
// command draws with is given none either.
|
|
7
|
+
/** the families an artwork's fonts name that the package has not, and the face drawn for them: the
|
|
8
|
+
* system's own for the board's names (web/src/canvas.ts FONT), and the generic ones */
|
|
9
|
+
const STANDS_FOR = {
|
|
10
|
+
"-apple-system": "Source Sans 3",
|
|
11
|
+
"blinkmacsystemfont": "Source Sans 3",
|
|
12
|
+
"sf pro text": "Source Sans 3",
|
|
13
|
+
"inter": "Source Sans 3",
|
|
14
|
+
"segoe ui": "Source Sans 3",
|
|
15
|
+
"sans-serif": "Source Sans 3",
|
|
16
|
+
"system-ui": "Source Sans 3",
|
|
17
|
+
"georgia": "Source Serif 4",
|
|
18
|
+
"times new roman": "Source Serif 4",
|
|
19
|
+
"serif": "Source Serif 4",
|
|
20
|
+
};
|
|
21
|
+
/** A CSS font as a 2D context takes it ("italic 600 12px \"Jost\", sans-serif"): its slant, weight,
|
|
22
|
+
* size in pixels, and families in order. */
|
|
23
|
+
export function parseFont(font) {
|
|
24
|
+
const m = /^\s*((?:(?:italic|oblique|normal|bold|bolder|lighter|\d{3})\s+)*)([\d.]+)px(?:\/[^\s]+)?\s+(.+)$/i.exec(font);
|
|
25
|
+
if (!m)
|
|
26
|
+
return { style: "normal", weight: 400, px: 10, families: ["sans-serif"] };
|
|
27
|
+
const words = m[1].trim().toLowerCase().split(/\s+/).filter(Boolean);
|
|
28
|
+
const style = words.includes("italic") || words.includes("oblique") ? "italic" : "normal";
|
|
29
|
+
const num = words.find((w) => /^\d{3}$/.test(w));
|
|
30
|
+
const weight = num ? Number(num) : words.includes("bold") ? 700 : 400;
|
|
31
|
+
const families = m[3].split(",").map((f) => f.trim().replace(/^["']|["']$/g, "")).filter(Boolean);
|
|
32
|
+
return { style, weight, px: Number(m[2]), families };
|
|
33
|
+
}
|
|
34
|
+
/** How wide `text` is in `font`, in pixels, as the package's faces draw it. */
|
|
35
|
+
export function textMeasurer(metrics) {
|
|
36
|
+
const families = new Map();
|
|
37
|
+
for (const f of metrics.faces)
|
|
38
|
+
families.set(f.family.toLowerCase(), [...(families.get(f.family.toLowerCase()) ?? []), f]);
|
|
39
|
+
const chosen = new Map();
|
|
40
|
+
/** the face a font is drawn in: its first family the package has (or stands one in for), at the
|
|
41
|
+
* slant asked where it is made in it, at the nearest weight */
|
|
42
|
+
const faceOf = (font) => {
|
|
43
|
+
const hit = chosen.get(font);
|
|
44
|
+
if (hit)
|
|
45
|
+
return hit;
|
|
46
|
+
const f = parseFont(font);
|
|
47
|
+
let set;
|
|
48
|
+
for (const fam of f.families) {
|
|
49
|
+
const k = fam.toLowerCase();
|
|
50
|
+
set = families.get(k) ?? families.get(STANDS_FOR[k]?.toLowerCase() ?? "");
|
|
51
|
+
if (set)
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
54
|
+
set ??= families.get("source sans 3") ?? metrics.faces;
|
|
55
|
+
const slanted = set.filter((x) => x.style === f.style);
|
|
56
|
+
const pool = slanted.length ? slanted : set;
|
|
57
|
+
const face = pool.reduce((a, b) => (Math.abs(b.weight - f.weight) < Math.abs(a.weight - f.weight) ? b : a));
|
|
58
|
+
const out = { face, px: f.px };
|
|
59
|
+
chosen.set(font, out);
|
|
60
|
+
return out;
|
|
61
|
+
};
|
|
62
|
+
return (text, font) => {
|
|
63
|
+
const { face, px } = faceOf(font);
|
|
64
|
+
let w = 0;
|
|
65
|
+
for (const ch of text) {
|
|
66
|
+
const cp = String(ch.codePointAt(0));
|
|
67
|
+
let units = face.widths[cp];
|
|
68
|
+
let upm = face.unitsPerEm;
|
|
69
|
+
if (units === undefined)
|
|
70
|
+
for (const fb of metrics.fallback)
|
|
71
|
+
if (fb.widths[cp] !== undefined) {
|
|
72
|
+
units = fb.widths[cp];
|
|
73
|
+
upm = fb.unitsPerEm;
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
// a character no face has: about an en, as a renderer's missing glyph is drawn
|
|
77
|
+
w += ((units ?? face.unitsPerEm * 0.55) / upm) * px;
|
|
78
|
+
}
|
|
79
|
+
return w;
|
|
80
|
+
};
|
|
81
|
+
}
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
// An artwork recipe (ExportSpec decision 20, drafted 2026-10-15 for the owner's review): what a
|
|
2
|
+
// picture of the board is, in words that hold on any project — a size by its name, names by a rule
|
|
3
|
+
// ("the clusters to depth 1, and the targets"), targets and the selection by full name — so that
|
|
4
|
+
// one recipe makes the same kind of picture of every codebase. An agent sends one
|
|
5
|
+
// (POST /api/agent/artwork, server/agent.ts); the link it gets back carries it after `#`, and the
|
|
6
|
+
// page that opens it resolves it against the board it opens on (web/src/main.ts, openRecipe) and
|
|
7
|
+
// opens the artwork mode with what it says, saving the files where the link asks.
|
|
8
|
+
import { ART_MAX_SIDE, ART_NAMES_BYTES, ART_RULED_BYTES, ART_STYLES, ART_TARGETS_BYTES, artNameId, cleanNames, FOOT_SIZE, SCHEMES, TITLE_CHARS, TITLE_SIZE } from "./collab.js";
|
|
9
|
+
/** The sizes offered (web/src/artwork.ts, PRESETS), each in either orientation: given landscape;
|
|
10
|
+
* `key`, a recipe's name for it. */
|
|
11
|
+
export const ART_PRESETS = [
|
|
12
|
+
{ key: "hd", name: "HD", w: 1920, h: 1080 },
|
|
13
|
+
{ key: "4k", name: "4K", w: 3840, h: 2160 },
|
|
14
|
+
{ key: "square", name: "Square", w: 4096, h: 4096 },
|
|
15
|
+
{ key: "a3", name: "A3, 300 dpi", w: 4961, h: 3508 },
|
|
16
|
+
{ key: "a2", name: "A2, 300 dpi", w: 7016, h: 4961 },
|
|
17
|
+
];
|
|
18
|
+
/** How a click selects, as the address names it (web/src/selection.ts, TOOLS): Selection; Rests on's
|
|
19
|
+
* Uses, TCB, Keys and Direct; Used by. */
|
|
20
|
+
export const RECIPE_MODES = ["plain", "deps", "tcb", "keys", "refs", "apps"];
|
|
21
|
+
/** The most a recipe takes, in bytes of its JSON: the link carries it. */
|
|
22
|
+
export const RECIPE_BYTES = 6000;
|
|
23
|
+
const FIELDS = ["size", "orientation", "style", "theme", "transparent", "colouring", "border", "title", "subtitle", "titleSize", "footSize", "source", "names", "targets", "keys", "notes", "edges", "selection", "lit", "view"];
|
|
24
|
+
const NAME_FIELDS = ["from", "clusters", "minShare", "targets", "decls", "omit"];
|
|
25
|
+
/** A recipe as given, checked: each field as it says, nothing unknown (a field misspelt would
|
|
26
|
+
* otherwise be a picture silently unlike the one meant). */
|
|
27
|
+
export function readRecipe(v) {
|
|
28
|
+
const bad = (error, hint) => ({ error, hint });
|
|
29
|
+
if (!v || typeof v !== "object" || Array.isArray(v))
|
|
30
|
+
return bad("recipe is an object.", 'E.g. {"size": "a2", "orientation": "portrait", "names": {"clusters": 1}}.');
|
|
31
|
+
if (new TextEncoder().encode(JSON.stringify(v)).length > RECIPE_BYTES)
|
|
32
|
+
return bad(`A recipe is at most ${RECIPE_BYTES} bytes.`, "Name clusters by a depth rather than one by one.");
|
|
33
|
+
const o = v;
|
|
34
|
+
const unknown = Object.keys(o).filter((k) => !FIELDS.includes(k));
|
|
35
|
+
if (unknown.length)
|
|
36
|
+
return bad(`recipe has no field ${unknown.join(", ")}.`, `Its fields: ${FIELDS.join(", ")}.`);
|
|
37
|
+
const r = {};
|
|
38
|
+
const bool = (k) => {
|
|
39
|
+
if (o[k] === undefined)
|
|
40
|
+
return null;
|
|
41
|
+
if (typeof o[k] !== "boolean")
|
|
42
|
+
return `${k} is true or false.`;
|
|
43
|
+
r[k] = o[k];
|
|
44
|
+
return null;
|
|
45
|
+
};
|
|
46
|
+
const oneOf = (k, of) => {
|
|
47
|
+
if (o[k] === undefined)
|
|
48
|
+
return null;
|
|
49
|
+
if (!of.includes(o[k]))
|
|
50
|
+
return `${k} is ${of.join(", ")}.`;
|
|
51
|
+
r[k] = o[k];
|
|
52
|
+
return null;
|
|
53
|
+
};
|
|
54
|
+
const num = (k, least, most) => {
|
|
55
|
+
if (o[k] === undefined)
|
|
56
|
+
return null;
|
|
57
|
+
if (typeof o[k] !== "number" || !Number.isFinite(o[k]) || o[k] < least || o[k] > most)
|
|
58
|
+
return `${k} is a number from ${least} to ${most} (hundredths of the picture's height).`;
|
|
59
|
+
r[k] = o[k];
|
|
60
|
+
return null;
|
|
61
|
+
};
|
|
62
|
+
const text = (k) => {
|
|
63
|
+
if (o[k] === undefined)
|
|
64
|
+
return null;
|
|
65
|
+
if (o[k] !== null && (typeof o[k] !== "string" || o[k].length > TITLE_CHARS))
|
|
66
|
+
return `${k} is text of at most ${TITLE_CHARS} characters, "" for none, or null for the project's own.`;
|
|
67
|
+
r[k] = o[k];
|
|
68
|
+
return null;
|
|
69
|
+
};
|
|
70
|
+
const names = (x) => Array.isArray(x) && x.length <= 2000 && x.every((n) => typeof n === "string" && n.length > 0 && n.length <= 1000);
|
|
71
|
+
const fault = bool("transparent") ?? bool("border") ?? bool("source") ?? bool("keys") ?? bool("notes") ?? bool("lit") ??
|
|
72
|
+
oneOf("orientation", ["landscape", "portrait"]) ?? oneOf("style", ART_STYLES) ?? oneOf("theme", ["light", "dark"]) ??
|
|
73
|
+
oneOf("colouring", SCHEMES) ?? oneOf("edges", ["all", "selection", "none"]) ??
|
|
74
|
+
num("titleSize", TITLE_SIZE.least, TITLE_SIZE.most) ?? num("footSize", FOOT_SIZE.least, FOOT_SIZE.most) ?? text("title") ?? text("subtitle");
|
|
75
|
+
if (fault)
|
|
76
|
+
return bad(fault, "See GET /api/agent/docs/export, \"Artwork\".");
|
|
77
|
+
if (o.size !== undefined) {
|
|
78
|
+
const s = o.size;
|
|
79
|
+
if (typeof s === "string") {
|
|
80
|
+
if (!ART_PRESETS.some((p) => p.key === s))
|
|
81
|
+
return bad(`size ${s} is no size.`, `A size by name (${ART_PRESETS.map((p) => p.key).join(", ")}) or {w, h} in pixels.`);
|
|
82
|
+
r.size = s;
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
const sw = s?.w, sh = s?.h;
|
|
86
|
+
const side = (x) => Number.isInteger(x) && x >= 16 && x <= ART_MAX_SIDE;
|
|
87
|
+
if (!side(sw) || !side(sh))
|
|
88
|
+
return bad(`size is a name or {w, h}: whole pixels from 16 to ${ART_MAX_SIDE}.`, `Names: ${ART_PRESETS.map((p) => p.key).join(", ")}.`);
|
|
89
|
+
r.size = { w: sw, h: sh };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (o.names !== undefined) {
|
|
93
|
+
const n = o.names;
|
|
94
|
+
if (n === "board" || n === "clusters" || n === "none")
|
|
95
|
+
r.names = n;
|
|
96
|
+
else if (n && typeof n === "object" && !Array.isArray(n)) {
|
|
97
|
+
const m = n;
|
|
98
|
+
const extra = Object.keys(m).filter((k) => !NAME_FIELDS.includes(k));
|
|
99
|
+
if (extra.length)
|
|
100
|
+
return bad(`names has no field ${extra.join(", ")}.`, `Its fields: ${NAME_FIELDS.join(", ")}.`);
|
|
101
|
+
const rule = {};
|
|
102
|
+
if (m.from !== undefined) {
|
|
103
|
+
if (m.from !== "none" && m.from !== "board")
|
|
104
|
+
return bad("names.from is none or board.", "From none: only what the rule names; from board: what the board writes, the rule's besides.");
|
|
105
|
+
rule.from = m.from;
|
|
106
|
+
}
|
|
107
|
+
if (m.clusters !== undefined) {
|
|
108
|
+
if (Number.isInteger(m.clusters) && m.clusters >= 0 && m.clusters <= 20)
|
|
109
|
+
rule.clusters = m.clusters;
|
|
110
|
+
else if (names(m.clusters))
|
|
111
|
+
rule.clusters = m.clusters;
|
|
112
|
+
else
|
|
113
|
+
return bad("names.clusters is a depth (0: the top level) or a list of clusters' keys.", "E.g. 1: the top level and the clusters right under it.");
|
|
114
|
+
}
|
|
115
|
+
if (m.minShare !== undefined) {
|
|
116
|
+
if (typeof m.minShare !== "number" || !(m.minShare >= 0 && m.minShare <= 1))
|
|
117
|
+
return bad("names.minShare is a share from 0 to 1.", "0.01: a cluster below the top named where it holds at least 1% of the project's declarations.");
|
|
118
|
+
rule.minShare = m.minShare;
|
|
119
|
+
}
|
|
120
|
+
if (m.targets !== undefined) {
|
|
121
|
+
if (typeof m.targets !== "boolean")
|
|
122
|
+
return bad("names.targets is true or false.", "The shown targets' names.");
|
|
123
|
+
rule.targets = m.targets;
|
|
124
|
+
}
|
|
125
|
+
for (const k of ["decls", "omit"]) {
|
|
126
|
+
if (m[k] === undefined)
|
|
127
|
+
continue;
|
|
128
|
+
if (!names(m[k]))
|
|
129
|
+
return bad(`names.${k} is a list of names.`, k === "decls" ? "Declarations by full name." : "Clusters by key, declarations by full name.");
|
|
130
|
+
rule[k] = m[k];
|
|
131
|
+
}
|
|
132
|
+
r.names = rule;
|
|
133
|
+
}
|
|
134
|
+
else
|
|
135
|
+
return bad('names is "board", "clusters", "none" or a rule {from, clusters, minShare, targets, decls, omit}.', 'E.g. {"clusters": 1}: the top level and the clusters right under it, and the targets.');
|
|
136
|
+
}
|
|
137
|
+
if (o.targets !== undefined) {
|
|
138
|
+
const t = o.targets;
|
|
139
|
+
if (t === "all" || t === "none" || t === "faded")
|
|
140
|
+
r.targets = t;
|
|
141
|
+
else if (names(t))
|
|
142
|
+
r.targets = t;
|
|
143
|
+
else
|
|
144
|
+
return bad('targets is "all", "none", "faded" or a list of targets\' full names.', "Those named are marked, the others drawn as any declaration.");
|
|
145
|
+
}
|
|
146
|
+
if (o.selection !== undefined && o.selection !== null) {
|
|
147
|
+
const s = o.selection;
|
|
148
|
+
if (!s || typeof s !== "object" || typeof s.name !== "string" || !s.name || (s.mode !== undefined && !RECIPE_MODES.includes(s.mode)) || Object.keys(s).some((k) => k !== "name" && k !== "mode"))
|
|
149
|
+
return bad("selection is {name, mode?}: a declaration's full name, and how it is selected.", `mode: ${RECIPE_MODES.join(", ")} (default tcb).`);
|
|
150
|
+
r.selection = { name: s.name, ...(s.mode !== undefined ? { mode: s.mode } : {}) };
|
|
151
|
+
}
|
|
152
|
+
else if (o.selection === null)
|
|
153
|
+
r.selection = null;
|
|
154
|
+
if (o.view !== undefined && o.view !== null) {
|
|
155
|
+
if (typeof o.view !== "string" || !o.view || o.view.length > 200)
|
|
156
|
+
return bad("view is a view's id.", "GET /api/agent/views lists them; left out, the Project.");
|
|
157
|
+
r.view = o.view;
|
|
158
|
+
}
|
|
159
|
+
return { recipe: r };
|
|
160
|
+
}
|
|
161
|
+
/** the outside world's cluster, as a reader may call it */
|
|
162
|
+
const EXTERNAL = "⟨external⟩";
|
|
163
|
+
const keyOf = (k) => (k === "External" ? EXTERNAL : k.startsWith("External.") ? EXTERNAL + k.slice(8) : k);
|
|
164
|
+
/** A recipe resolved against a board: everything it leaves out as a new artwork has it (4K
|
|
165
|
+
* landscape, the board's look, light, names as the board writes them, every target marked). Names
|
|
166
|
+
* are written from none: the clusters named by key, the declarations and the targets' names
|
|
167
|
+
* wherever they fall (ArtworkLook `shownNames`), as names ticked by hand are; the clusters a depth
|
|
168
|
+
* rule names — the outside world's own, and the project's down to the depth that hold `minShare`
|
|
169
|
+
* of the project — only where they cover no name already written (`ruledNames`; the owner's
|
|
170
|
+
* decision of 2026-10-15: names chosen by a rule never overlap). */
|
|
171
|
+
export function resolveRecipe(r, b) {
|
|
172
|
+
const problems = [];
|
|
173
|
+
const preset = typeof r.size === "string" ? ART_PRESETS.find((p) => p.key === r.size) : ART_PRESETS[1];
|
|
174
|
+
const given = typeof r.size === "object" ? r.size : { w: preset.w, h: preset.h };
|
|
175
|
+
const portrait = r.orientation ? r.orientation === "portrait" : given.h > given.w;
|
|
176
|
+
const [long, short] = given.w >= given.h ? [given.w, given.h] : [given.h, given.w];
|
|
177
|
+
const { w, h } = portrait ? { w: short, h: long } : { w: long, h: short };
|
|
178
|
+
// what the recipe names that the board has not is said (`problems`); what is left out for room,
|
|
179
|
+
// counted (`left`); a target of the review the board has not — a target outlives its declaration's
|
|
180
|
+
// rename or removal — is left out unsaid, as the board leaves it
|
|
181
|
+
let left = 0;
|
|
182
|
+
const targetSet = new Set(b.targets);
|
|
183
|
+
const held = (n) => b.clusterOf(n) !== undefined;
|
|
184
|
+
let targets = "marked";
|
|
185
|
+
let shownTargets = [];
|
|
186
|
+
let marked = b.targets.filter(held);
|
|
187
|
+
if (r.targets === "none")
|
|
188
|
+
[targets, marked] = ["plain", []];
|
|
189
|
+
else if (r.targets === "faded")
|
|
190
|
+
targets = "highlight";
|
|
191
|
+
else if (Array.isArray(r.targets)) {
|
|
192
|
+
const unknown = r.targets.filter((t) => !targetSet.has(t));
|
|
193
|
+
if (unknown.length)
|
|
194
|
+
problems.push(`not targets of the review: ${unknown.join(", ")}`);
|
|
195
|
+
const off = r.targets.filter((t) => targetSet.has(t) && !held(t));
|
|
196
|
+
if (off.length)
|
|
197
|
+
problems.push(`targets not on the board: ${off.join(", ")}`);
|
|
198
|
+
const on = r.targets.filter((t) => targetSet.has(t) && held(t));
|
|
199
|
+
shownTargets = cleanNames(on, ART_TARGETS_BYTES);
|
|
200
|
+
left += new Set(on).size - shownTargets.length;
|
|
201
|
+
targets = "chosen";
|
|
202
|
+
marked = shownTargets;
|
|
203
|
+
}
|
|
204
|
+
// the names
|
|
205
|
+
let labels = "board";
|
|
206
|
+
let namesFrom = "board";
|
|
207
|
+
let shown = [];
|
|
208
|
+
let hidden = [];
|
|
209
|
+
let ruledIds = [];
|
|
210
|
+
const named = { clusters: [], ruled: [], decls: [] };
|
|
211
|
+
const n = r.names;
|
|
212
|
+
if (n === "board" || n === "clusters" || n === "none")
|
|
213
|
+
labels = n;
|
|
214
|
+
else if (n) {
|
|
215
|
+
labels = "chosen";
|
|
216
|
+
namesFrom = n.from ?? "none";
|
|
217
|
+
const omit = new Set((n.omit ?? []).map(keyOf));
|
|
218
|
+
const byKey = new Map(b.clusters.map((c, i) => [c.key, i]));
|
|
219
|
+
if (typeof n.clusters === "number") {
|
|
220
|
+
const least = Math.ceil((n.minShare ?? 0.01) * b.projectCount);
|
|
221
|
+
b.clusters.forEach((c) => {
|
|
222
|
+
if ((c.ext ? c.depth === 0 : c.depth <= n.clusters && c.count >= least) && !omit.has(c.key))
|
|
223
|
+
named.ruled.push(c.key);
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
else if (Array.isArray(n.clusters)) {
|
|
227
|
+
for (const k of n.clusters) {
|
|
228
|
+
const key = keyOf(k);
|
|
229
|
+
if (!byKey.has(key))
|
|
230
|
+
problems.push(`no cluster ${k} on the board`);
|
|
231
|
+
else if (!omit.has(key) && !named.clusters.includes(key))
|
|
232
|
+
named.clusters.push(key);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
// the targets' names (all on the board), and the declarations named
|
|
236
|
+
const decls = n.targets === false ? [] : [...marked];
|
|
237
|
+
for (const d of n.decls ?? []) {
|
|
238
|
+
if (!held(d))
|
|
239
|
+
problems.push(`no declaration ${d} on the board`);
|
|
240
|
+
else
|
|
241
|
+
decls.push(d);
|
|
242
|
+
}
|
|
243
|
+
for (const d of decls)
|
|
244
|
+
if (!omit.has(d) && !named.decls.includes(d))
|
|
245
|
+
named.decls.push(d);
|
|
246
|
+
const ids = [...named.clusters.map((k) => artNameId("cluster", k)), ...named.decls.map((d) => artNameId("decl", d))];
|
|
247
|
+
shown = cleanNames(ids, ART_NAMES_BYTES);
|
|
248
|
+
const ruled = named.ruled.map((k) => artNameId("cluster", k));
|
|
249
|
+
ruledIds = cleanNames(ruled, ART_RULED_BYTES);
|
|
250
|
+
left += ids.length + ruled.length - shown.length - ruledIds.length;
|
|
251
|
+
// what is named is what the artwork carries
|
|
252
|
+
const kept = new Set([...shown, ...ruledIds]);
|
|
253
|
+
named.clusters = named.clusters.filter((k) => kept.has(artNameId("cluster", k)));
|
|
254
|
+
named.ruled = named.ruled.filter((k) => kept.has(artNameId("cluster", k)));
|
|
255
|
+
named.decls = named.decls.filter((d) => kept.has(artNameId("decl", d)));
|
|
256
|
+
// from the board, what to leave out is said as hidden
|
|
257
|
+
if (namesFrom === "board")
|
|
258
|
+
hidden = cleanNames([...omit].map((k) => (byKey.has(k) ? artNameId("cluster", k) : artNameId("decl", k))), ART_NAMES_BYTES);
|
|
259
|
+
}
|
|
260
|
+
const look = {
|
|
261
|
+
w,
|
|
262
|
+
h,
|
|
263
|
+
scheme: r.colouring ?? "kind",
|
|
264
|
+
theme: r.theme ?? "light",
|
|
265
|
+
background: r.transparent ? "transparent" : "theme",
|
|
266
|
+
labels,
|
|
267
|
+
namesFrom,
|
|
268
|
+
hiddenNames: hidden,
|
|
269
|
+
shownNames: shown,
|
|
270
|
+
ruledNames: ruledIds,
|
|
271
|
+
edges: r.edges ?? "all",
|
|
272
|
+
notes: r.notes ?? true,
|
|
273
|
+
targets,
|
|
274
|
+
shownTargets,
|
|
275
|
+
keys: r.keys ?? true,
|
|
276
|
+
selection: r.lit ?? true,
|
|
277
|
+
source: r.source ?? false,
|
|
278
|
+
style: r.style ?? "board",
|
|
279
|
+
title: r.title ?? null,
|
|
280
|
+
subtitle: r.subtitle ?? null,
|
|
281
|
+
titleSize: r.titleSize ?? TITLE_SIZE.first,
|
|
282
|
+
footSize: r.footSize ?? FOOT_SIZE.first,
|
|
283
|
+
border: r.border ?? false,
|
|
284
|
+
};
|
|
285
|
+
return { look, named, left, problems };
|
|
286
|
+
}
|
|
287
|
+
/** A recipe as the link carries it after `#`: its JSON, base64url. */
|
|
288
|
+
export function encodeRecipe(r) {
|
|
289
|
+
const bytes = new TextEncoder().encode(JSON.stringify(r));
|
|
290
|
+
let s = "";
|
|
291
|
+
for (const x of bytes)
|
|
292
|
+
s += String.fromCharCode(x);
|
|
293
|
+
return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
294
|
+
}
|
|
295
|
+
/** A link's recipe, read back; null where it is none. */
|
|
296
|
+
export function decodeRecipe(text) {
|
|
297
|
+
try {
|
|
298
|
+
const s = atob(text.replace(/-/g, "+").replace(/_/g, "/"));
|
|
299
|
+
return JSON.parse(new TextDecoder().decode(Uint8Array.from(s, (c) => c.charCodeAt(0))));
|
|
300
|
+
}
|
|
301
|
+
catch {
|
|
302
|
+
return null;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
/** the address's word for the recipe, and for the files to save */
|
|
306
|
+
export const RECIPE_PARAM = "art";
|
|
307
|
+
export const SAVE_PARAM = "save";
|
|
308
|
+
/** A fragment's recipe and the files it asks to save; null where it carries none. */
|
|
309
|
+
export function recipeIn(hash) {
|
|
310
|
+
const p = new URLSearchParams(hash.startsWith("#") ? hash.slice(1) : hash);
|
|
311
|
+
const text = p.get(RECIPE_PARAM);
|
|
312
|
+
if (!text)
|
|
313
|
+
return null;
|
|
314
|
+
const save = (p.get(SAVE_PARAM) ?? "").split(",").filter((k) => k === "png" || k === "svg");
|
|
315
|
+
return { text, save: [...new Set(save)] };
|
|
316
|
+
}
|