@arraypress/covers 0.0.0-stage → 1.0.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/CHANGELOG.md +50 -0
- package/LICENSE +29 -0
- package/README.md +561 -2
- package/fonts/OFL.txt +93 -0
- package/fonts/inter-latin-400.woff +0 -0
- package/fonts/inter-latin-500.woff +0 -0
- package/fonts/inter-latin-600.woff +0 -0
- package/fonts/inter-latin-700.woff +0 -0
- package/package.json +96 -4
- package/recipes/aurora.json +132 -0
- package/recipes/bauhaus.json +113 -0
- package/recipes/bigType.json +103 -0
- package/recipes/contour.json +109 -0
- package/recipes/cutout.json +191 -0
- package/recipes/diagram.json +92 -0
- package/recipes/dither.json +170 -0
- package/recipes/duotone.json +75 -0
- package/recipes/gridTitle.json +136 -0
- package/recipes/iconTile.json +214 -0
- package/recipes/issue.json +221 -0
- package/recipes/mark.json +170 -0
- package/recipes/photo.json +156 -0
- package/recipes/riso.json +387 -0
- package/recipes/stripes.json +206 -0
- package/recipes/waveform.json +154 -0
- package/schema/recipe.schema.json +2292 -0
- package/src/astro.d.ts +31 -0
- package/src/astro.js +86 -0
- package/src/color.js +246 -0
- package/src/diagram.js +248 -0
- package/src/extract.js +90 -0
- package/src/flatten.js +234 -0
- package/src/fonts.js +200 -0
- package/src/geometry.js +187 -0
- package/src/grid.js +41 -0
- package/src/handmade.js +253 -0
- package/src/icons.js +157 -0
- package/src/index.d.ts +897 -0
- package/src/index.js +19 -0
- package/src/json-recipe.js +458 -0
- package/src/layer-options.js +37 -0
- package/src/layers.js +972 -0
- package/src/palette.js +185 -0
- package/src/png.js +77 -0
- package/src/print.js +658 -0
- package/src/recipes.js +78 -0
- package/src/render.js +462 -0
- package/src/rng.js +95 -0
- package/src/svg.js +69 -0
- package/src/text.js +230 -0
package/src/index.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @arraypress/covers — build-time cover images for posts, products and
|
|
3
|
+
* pages: soft mesh gradients, grain, patterns, stripes, shapes, icons and
|
|
4
|
+
* type, from one seed, as SVG or WebP/PNG/AVIF/JPEG.
|
|
5
|
+
*
|
|
6
|
+
* @module @arraypress/covers
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export { cover, renderSvg, renderImage, coverKey, MIME, DEFAULT_QUALITY, VERSION } from './render.js';
|
|
10
|
+
export { recipes } from './recipes.js';
|
|
11
|
+
export { defineRecipe, loadRecipe } from './json-recipe.js';
|
|
12
|
+
export { palettes, resolvePalette, derivePalette } from './palette.js';
|
|
13
|
+
export { toHex, toOklch, fromOklch, mixOklab, mixOklch, onBackground, gradientAt } from './color.js';
|
|
14
|
+
export { registerIcons } from './icons.js';
|
|
15
|
+
export { resolveSize, ASPECTS } from './geometry.js';
|
|
16
|
+
export { createRng, hashString } from './rng.js';
|
|
17
|
+
export { LAYERS as layers } from './layers.js';
|
|
18
|
+
export { extractPalette } from './extract.js';
|
|
19
|
+
export { RISO_INKS } from './print.js';
|
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Declarative recipes: a cover style written as JSON.
|
|
3
|
+
*
|
|
4
|
+
* ```json
|
|
5
|
+
* {
|
|
6
|
+
* "$schema": "https://unpkg.com/@arraypress/covers/schema/recipe.schema.json",
|
|
7
|
+
* "name": "poster",
|
|
8
|
+
* "params": { "background": "solid" },
|
|
9
|
+
* "vars": { "ink": { "readable": "@accent" } },
|
|
10
|
+
* "layers": [
|
|
11
|
+
* { "id": "bg", "type": "solid", "color": "@accent", "when": { "background": "solid" } },
|
|
12
|
+
* { "id": "bg", "type": "linear", "colors": ["@accent", "@colors.1"], "when": { "background": "gradient" } },
|
|
13
|
+
* { "id": "title", "type": "text", "content": "{title}", "color": "$ink", "when": "title" }
|
|
14
|
+
* ]
|
|
15
|
+
* }
|
|
16
|
+
* ```
|
|
17
|
+
*
|
|
18
|
+
* Values are resolved when the cover is built:
|
|
19
|
+
*
|
|
20
|
+
* | Form | Means |
|
|
21
|
+
* |---|---|
|
|
22
|
+
* | `"@bg"` … | palette fields (`bg`, `text`, `accent`, `onAccent`, `muted`, `surface`, `mode`, `colors`, `colors.N`) and canvas numbers (`width`, `height`, `u`) |
|
|
23
|
+
* | `"$name"`, `"$name.0"` | a value from `vars` (and an item or field of it) |
|
|
24
|
+
* | `"{title}"` | a param or content option (`title`, `label`, `icon`, `image`); `"{opt.x}"` any cover option. A whole-string token keeps the value's type; inside a longer string it's interpolated. |
|
|
25
|
+
* | `{ "mix": [a, b, t, space?] }` | two colours mixed in OKLab (or `"oklch"`: hue the short way, stays vivid) |
|
|
26
|
+
* | `{ "readable": c }` | white, or near-black on a light colour (`[c, light, dark]` to choose) |
|
|
27
|
+
* | `{ "readable": [bgs, ratio, prefer] }` | `prefer` (`"white"`, `"black"`, a colour) if it clears `ratio` on every background, else the better of white / near-black |
|
|
28
|
+
* | `{ "tone": [color, { l: [min, max], c: [min, max] }] }` | the colour pulled into an OKLCH band (its hue kept) |
|
|
29
|
+
* | `{ "gradientUnder": [colors, angle, size] }` | the colours a centred square of `size` covers in a linear gradient |
|
|
30
|
+
* | `{ "pick": [...] }`, `{ "range": [min, max] }`, `{ "int": [min, max] }` | seeded choices |
|
|
31
|
+
* | `{ "add" \| "sub" \| "mul" \| "div" \| "min" \| "max": [a, b, …] }` | arithmetic; lengths like `"54vw"` become pixels |
|
|
32
|
+
* | `{ "ramp": [from, to, steps, amount?] }` | `steps` colours from `from` toward `to` |
|
|
33
|
+
* | `{ "taper": [ratio, steps] }` | `[1, ratio, ratio², …]` |
|
|
34
|
+
* | `{ "length": value }` | characters in a string (or a number's digits), items in an array |
|
|
35
|
+
* | `{ "reverse": [...] }` | the array backwards |
|
|
36
|
+
* | `{ "if": cond, "then": a, "else": b }` | a choice (only the chosen side is evaluated) |
|
|
37
|
+
*
|
|
38
|
+
* Conditions (`when` on a layer, `if`): `"title"` (the option is set),
|
|
39
|
+
* `{ "background": "gradient" }` (a param or option equals a value; `mode`
|
|
40
|
+
* is the palette's, `aspect` the canvas's: `wide`, `square` or `tall`),
|
|
41
|
+
* `{ "not": c }`, `{ "all": [...] }`, `{ "any": [...] }`.
|
|
42
|
+
*
|
|
43
|
+
* Seeding: `vars` draw from the cover's recipe stream in order; draws inside
|
|
44
|
+
* a layer use that layer's own stream, so adding a layer never changes
|
|
45
|
+
* another's choices.
|
|
46
|
+
*
|
|
47
|
+
* @module json-recipe
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
import { readFileSync } from 'node:fs';
|
|
51
|
+
import { readableText } from '@arraypress/color-utils';
|
|
52
|
+
import { createRng } from './rng.js';
|
|
53
|
+
import { len } from './geometry.js';
|
|
54
|
+
import { toHex, mixOklab, mixOklch, onBackground, gradientAt } from './color.js';
|
|
55
|
+
import { LAYERS, toneColor } from './layers.js';
|
|
56
|
+
import { LAYER_OPTIONS, COMMON } from './layer-options.js';
|
|
57
|
+
|
|
58
|
+
/** Options every recipe can read without declaring them. */
|
|
59
|
+
const CONTENT = ['title', 'label', 'icon', 'image'];
|
|
60
|
+
|
|
61
|
+
/** `@` tokens. */
|
|
62
|
+
const PALETTE = ['bg', 'text', 'accent', 'onAccent', 'muted', 'surface', 'mode', 'colors'];
|
|
63
|
+
const CANVAS = ['width', 'height', 'u'];
|
|
64
|
+
|
|
65
|
+
/** Value operators, by name, with their argument arity. */
|
|
66
|
+
const OPS = /** @type {Record<string, [number, number]>} */ ({
|
|
67
|
+
mix: [3, 4], readable: [1, 3], gradientUnder: [3, 3], tone: [2, 2], length: [1, 1], pick: [1, Infinity], range: [2, 2], int: [2, 2],
|
|
68
|
+
add: [1, Infinity], sub: [2, Infinity], mul: [1, Infinity], div: [2, 2], min: [1, Infinity], max: [1, Infinity],
|
|
69
|
+
ramp: [3, 4], taper: [2, 2], reverse: [1, 1],
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
/** Keys of a layer that aren't layer options. */
|
|
73
|
+
const LAYER_META = new Set(['id', 'type', 'when']);
|
|
74
|
+
|
|
75
|
+
/** Top-level keys of a recipe document. */
|
|
76
|
+
const TOP = new Set(['$schema', 'name', 'description', 'params', 'requires', 'vars', 'output', 'layers']);
|
|
77
|
+
|
|
78
|
+
/** Recipes defined from the same JSON object are reused. */
|
|
79
|
+
const defined = new WeakMap();
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The value of a content-like option for conditions: a string, number or
|
|
83
|
+
* bytes as-is; an override object's `content`, `name`, `svg` or `src`.
|
|
84
|
+
*
|
|
85
|
+
* @param {unknown} v
|
|
86
|
+
* @returns {unknown}
|
|
87
|
+
*/
|
|
88
|
+
function valueOf(v) {
|
|
89
|
+
if (v && typeof v === 'object' && !Array.isArray(v) && !(v instanceof Uint8Array)) {
|
|
90
|
+
const o = /** @type {any} */ (v);
|
|
91
|
+
return o.content ?? o.name ?? o.svg ?? o.src;
|
|
92
|
+
}
|
|
93
|
+
return v;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** @param {unknown} v @returns {boolean} */
|
|
97
|
+
const truthy = (v) => {
|
|
98
|
+
const x = valueOf(v);
|
|
99
|
+
return x !== undefined && x !== null && x !== false && x !== '' && !(Array.isArray(x) && x.length === 0);
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/** @param {unknown} v @returns {v is Record<string, any>} */
|
|
103
|
+
const isPlain = (v) => Boolean(v) && typeof v === 'object' && !Array.isArray(v) && !(v instanceof Uint8Array);
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Turn a JSON recipe into a recipe function. The document is validated
|
|
107
|
+
* first; any problem throws with the recipe's name and the JSON path.
|
|
108
|
+
*
|
|
109
|
+
* @param {import('./index.js').RecipeJson} json - The recipe document.
|
|
110
|
+
* @returns {import('./index.js').Recipe} The recipe (with `.recipeName` and `.json`).
|
|
111
|
+
* @throws {TypeError} On an invalid document.
|
|
112
|
+
*
|
|
113
|
+
* @example
|
|
114
|
+
* const poster = defineRecipe({ name: 'poster', layers: [{ type: 'solid', color: '@accent' }] });
|
|
115
|
+
* await renderSvg({ recipe: poster, seed: 'x' });
|
|
116
|
+
*/
|
|
117
|
+
export function defineRecipe(json) {
|
|
118
|
+
if (defined.has(json)) return defined.get(json);
|
|
119
|
+
validate(json);
|
|
120
|
+
const name = json.name ?? 'custom';
|
|
121
|
+
/** @type {any} */
|
|
122
|
+
const recipe = (/** @type {Record<string, unknown>} */ options, /** @type {any} */ c) => build(json, name, options ?? {}, c);
|
|
123
|
+
recipe.recipeName = name;
|
|
124
|
+
recipe.json = json;
|
|
125
|
+
defined.set(json, recipe);
|
|
126
|
+
return recipe;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Read and define a JSON recipe file (synchronous, for use in configs).
|
|
131
|
+
*
|
|
132
|
+
* @param {string | URL} path - A `.json` file.
|
|
133
|
+
* @returns {import('./index.js').Recipe} The recipe.
|
|
134
|
+
* @throws {TypeError} On invalid JSON or an invalid recipe (the message names the file).
|
|
135
|
+
*
|
|
136
|
+
* @example
|
|
137
|
+
* const house = loadRecipe('./src/covers/house.json');
|
|
138
|
+
*/
|
|
139
|
+
export function loadRecipe(path) {
|
|
140
|
+
let json;
|
|
141
|
+
try {
|
|
142
|
+
json = JSON.parse(readFileSync(path, 'utf8'));
|
|
143
|
+
} catch (err) {
|
|
144
|
+
throw new TypeError(`Couldn't read recipe ${String(path)}: ${/** @type {Error} */ (err).message}`);
|
|
145
|
+
}
|
|
146
|
+
if (isPlain(json) && !json.name) json.name = String(path).split('/').pop()?.replace(/\.json$/, '');
|
|
147
|
+
return defineRecipe(json);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
// Building
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* @typedef {object} Scope
|
|
156
|
+
* @property {string} name
|
|
157
|
+
* @property {Record<string, unknown>} options
|
|
158
|
+
* @property {Record<string, unknown>} params
|
|
159
|
+
* @property {Record<string, unknown>} vars
|
|
160
|
+
* @property {any} c - The recipe context.
|
|
161
|
+
* @property {() => import('./rng.js').Rng} rng - The current stream.
|
|
162
|
+
*/
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Expand a recipe document into layers.
|
|
166
|
+
*
|
|
167
|
+
* @param {import('./index.js').RecipeJson} json
|
|
168
|
+
* @param {string} name
|
|
169
|
+
* @param {Record<string, unknown>} options
|
|
170
|
+
* @param {any} c
|
|
171
|
+
* @returns {import('./index.js').Layer[]}
|
|
172
|
+
*/
|
|
173
|
+
function build(json, name, options, c) {
|
|
174
|
+
const params = { ...(json.params ?? {}) };
|
|
175
|
+
for (const k of Object.keys(params)) if (options[k] !== undefined) params[k] = options[k];
|
|
176
|
+
/** @type {Scope} */
|
|
177
|
+
const scope = { name, options, params, vars: {}, c, rng: () => c.rng };
|
|
178
|
+
|
|
179
|
+
for (const req of json.requires ?? []) {
|
|
180
|
+
if (!truthy(lookup(scope, req))) throw new TypeError(`Recipe "${name}" needs the "${req}" option`);
|
|
181
|
+
}
|
|
182
|
+
for (const [k, v] of Object.entries(json.vars ?? {})) scope.vars[k] = resolve(scope, v);
|
|
183
|
+
|
|
184
|
+
/** @type {Record<string, number>} */
|
|
185
|
+
const seen = {};
|
|
186
|
+
const layers = [];
|
|
187
|
+
for (const layer of json.layers) {
|
|
188
|
+
const n = (seen[layer.type] = (seen[layer.type] ?? -1) + 1);
|
|
189
|
+
let stream;
|
|
190
|
+
const own = { ...scope, rng: () => (stream ??= createRng(`${c.seed ?? ''}:recipe:${layer.id ?? `${layer.type}#${n}`}`)) };
|
|
191
|
+
if (layer.when !== undefined && !test(own, layer.when)) continue;
|
|
192
|
+
/** @type {Record<string, unknown>} */
|
|
193
|
+
const out = { ...(layer.id ? { id: layer.id } : {}), type: layer.type };
|
|
194
|
+
for (const [k, v] of Object.entries(layer)) {
|
|
195
|
+
if (LAYER_META.has(k)) continue;
|
|
196
|
+
const r = resolve(own, v);
|
|
197
|
+
if (r !== undefined) out[k] = r;
|
|
198
|
+
}
|
|
199
|
+
layers.push(/** @type {any} */ (out));
|
|
200
|
+
}
|
|
201
|
+
return layers;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Look up a template name: a param, a content option, or `opt.<key>`.
|
|
206
|
+
*
|
|
207
|
+
* @param {Scope} s
|
|
208
|
+
* @param {string} key
|
|
209
|
+
* @returns {unknown}
|
|
210
|
+
*/
|
|
211
|
+
function lookup(s, key) {
|
|
212
|
+
if (key.startsWith('opt.')) return s.options[key.slice(4)];
|
|
213
|
+
if (Object.hasOwn(s.params, key)) return s.params[key];
|
|
214
|
+
return s.options[key];
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Resolve one value.
|
|
219
|
+
*
|
|
220
|
+
* @param {Scope} s
|
|
221
|
+
* @param {unknown} v
|
|
222
|
+
* @returns {unknown}
|
|
223
|
+
*/
|
|
224
|
+
function resolve(s, v) {
|
|
225
|
+
if (typeof v === 'string') return resolveString(s, v);
|
|
226
|
+
if (Array.isArray(v)) return v.map((x) => resolve(s, x));
|
|
227
|
+
if (!isPlain(v)) return v;
|
|
228
|
+
if ('if' in v) return test(s, v.if) ? resolve(s, v.then) : resolve(s, v.else);
|
|
229
|
+
const keys = Object.keys(v);
|
|
230
|
+
if (keys.length === 1 && Object.hasOwn(OPS, keys[0])) return op(s, keys[0], v[keys[0]]);
|
|
231
|
+
/** @type {Record<string, unknown>} */
|
|
232
|
+
const out = {};
|
|
233
|
+
for (const [k, x] of Object.entries(v)) {
|
|
234
|
+
const r = resolve(s, x);
|
|
235
|
+
if (r !== undefined) out[k] = r;
|
|
236
|
+
}
|
|
237
|
+
return out;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* @param {Scope} s
|
|
242
|
+
* @param {string} str
|
|
243
|
+
* @returns {unknown}
|
|
244
|
+
*/
|
|
245
|
+
function resolveString(s, str) {
|
|
246
|
+
if (str.startsWith('@@') || str.startsWith('$$')) return str.slice(1);
|
|
247
|
+
if (str.startsWith('@')) {
|
|
248
|
+
const [head, idx] = str.slice(1).split('.');
|
|
249
|
+
if (CANVAS.includes(head)) return s.c[head];
|
|
250
|
+
const p = s.c.palette;
|
|
251
|
+
if (head === 'colors' && idx !== undefined) return p.colors[Number(idx) % p.colors.length];
|
|
252
|
+
return head === 'colors' ? p.colors.slice() : p[head];
|
|
253
|
+
}
|
|
254
|
+
if (str.startsWith('$')) {
|
|
255
|
+
const [head, ...path] = str.slice(1).split('.');
|
|
256
|
+
return path.reduce((/** @type {any} */ acc, k) => acc?.[k], s.vars[head]);
|
|
257
|
+
}
|
|
258
|
+
const whole = str.match(/^\{([\w.]+)\}$/);
|
|
259
|
+
if (whole) return lookup(s, whole[1]);
|
|
260
|
+
return str.replace(/\{([\w.]+)\}/g, (_, k) => {
|
|
261
|
+
const x = valueOf(lookup(s, k));
|
|
262
|
+
return x == null ? '' : String(x);
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Evaluate an operator.
|
|
268
|
+
*
|
|
269
|
+
* @param {Scope} s
|
|
270
|
+
* @param {string} name
|
|
271
|
+
* @param {any} raw - Its arguments, unresolved.
|
|
272
|
+
* @returns {unknown}
|
|
273
|
+
*/
|
|
274
|
+
function op(s, name, raw) {
|
|
275
|
+
if (name === 'reverse') {
|
|
276
|
+
const arr = resolve(s, raw);
|
|
277
|
+
if (!Array.isArray(arr)) throw new TypeError(`Recipe "${s.name}": "reverse" needs an array`);
|
|
278
|
+
return arr.slice().reverse();
|
|
279
|
+
}
|
|
280
|
+
if (name === 'pick') {
|
|
281
|
+
const items = Array.isArray(raw) ? raw : resolve(s, raw);
|
|
282
|
+
return resolve(s, s.rng().pick(/** @type {unknown[]} */ (items)));
|
|
283
|
+
}
|
|
284
|
+
const args = /** @type {any[]} */ ([].concat(resolve(s, raw)));
|
|
285
|
+
const nums = () => args.map((a) => (typeof a === 'number' ? a : len(a, s.c)));
|
|
286
|
+
switch (name) {
|
|
287
|
+
case 'readable': {
|
|
288
|
+
// [backgrounds, ratio, prefer]: prefer a colour that clears a ratio (3 for icons).
|
|
289
|
+
if (typeof args[1] === 'number') return onBackground(args[0], args[1], args[2] ?? 'white');
|
|
290
|
+
return readableText(toHex(args[0]), args[1] ? toHex(args[1]) : '#ffffff', args[2] ? toHex(args[2]) : '#111111');
|
|
291
|
+
}
|
|
292
|
+
case 'length': return Array.isArray(resolve(s, raw)) ? /** @type {unknown[]} */ (resolve(s, raw)).length : String(args[0] ?? '').length;
|
|
293
|
+
case 'tone': return toneColor(toHex(args[0]), args[1] ?? {});
|
|
294
|
+
case 'gradientUnder': {
|
|
295
|
+
// The colours a centred square of `size` sits on in a linear gradient at `angle`.
|
|
296
|
+
const [colors, angle, size] = args;
|
|
297
|
+
const box = typeof size === 'number' ? size : len(size, s.c);
|
|
298
|
+
const a = (Number(angle) * Math.PI) / 180;
|
|
299
|
+
const line = Math.abs(s.c.width * Math.sin(a)) + Math.abs(s.c.height * Math.cos(a));
|
|
300
|
+
const half = ((box / 2) * (Math.abs(Math.sin(a)) + Math.abs(Math.cos(a)))) / line;
|
|
301
|
+
return [-1, -0.5, 0, 0.5, 1].map((k) => gradientAt(colors, 0.5 + k * half));
|
|
302
|
+
}
|
|
303
|
+
case 'mix': {
|
|
304
|
+
const mixer = args[3] === 'oklch' ? mixOklch : mixOklab;
|
|
305
|
+
if (args[3] !== undefined && args[3] !== 'oklch' && args[3] !== 'oklab') throw new TypeError(`Recipe "${s.name}": mix space must be "oklab" or "oklch"`);
|
|
306
|
+
return mixer(toHex(args[0]), toHex(args[1]), Number(args[2]));
|
|
307
|
+
}
|
|
308
|
+
case 'range': { const [a, b] = nums(); return s.rng().range(a, b); }
|
|
309
|
+
case 'int': { const [a, b] = nums(); return s.rng().int(a, b); }
|
|
310
|
+
case 'add': return nums().reduce((a, b) => a + b);
|
|
311
|
+
case 'sub': return nums().reduce((a, b) => a - b);
|
|
312
|
+
case 'mul': return nums().reduce((a, b) => a * b);
|
|
313
|
+
case 'div': { const [a, b] = nums(); return a / b; }
|
|
314
|
+
case 'min': return Math.min(...nums());
|
|
315
|
+
case 'max': return Math.max(...nums());
|
|
316
|
+
case 'ramp': {
|
|
317
|
+
const [from, to, steps, amount = 1] = args;
|
|
318
|
+
return Array.from({ length: steps }, (_, i) => mixOklab(toHex(from), toHex(to), (i / steps) * amount));
|
|
319
|
+
}
|
|
320
|
+
case 'taper': { const [ratio, steps] = nums(); return Array.from({ length: steps }, (_, i) => ratio ** i); }
|
|
321
|
+
default: throw new TypeError(`Unknown operator "${name}"`);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Evaluate a condition.
|
|
327
|
+
*
|
|
328
|
+
* @param {Scope} s
|
|
329
|
+
* @param {unknown} cond
|
|
330
|
+
* @returns {boolean}
|
|
331
|
+
*/
|
|
332
|
+
function test(s, cond) {
|
|
333
|
+
if (typeof cond === 'string') return truthy(lookup(s, cond));
|
|
334
|
+
if (typeof cond === 'boolean') return cond;
|
|
335
|
+
if (!isPlain(cond)) return false;
|
|
336
|
+
if ('not' in cond) return !test(s, cond.not);
|
|
337
|
+
if ('all' in cond) return cond.all.every((/** @type {unknown} */ c) => test(s, c));
|
|
338
|
+
if ('any' in cond) return cond.any.some((/** @type {unknown} */ c) => test(s, c));
|
|
339
|
+
return Object.entries(cond).every(([k, want]) => {
|
|
340
|
+
const ratio = s.c.width / s.c.height;
|
|
341
|
+
const have = k === 'mode' ? s.c.palette.mode
|
|
342
|
+
: k === 'aspect' ? (ratio > 1.15 ? 'wide' : ratio < 0.87 ? 'tall' : 'square')
|
|
343
|
+
: valueOf(lookup(s, k));
|
|
344
|
+
return Array.isArray(want) ? want.includes(have) : have === want;
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// ---------------------------------------------------------------------------
|
|
349
|
+
// Validation
|
|
350
|
+
// ---------------------------------------------------------------------------
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Check a recipe document, throwing on the first problem.
|
|
354
|
+
*
|
|
355
|
+
* @param {any} json
|
|
356
|
+
* @returns {void}
|
|
357
|
+
* @throws {TypeError}
|
|
358
|
+
*/
|
|
359
|
+
export function validate(json) {
|
|
360
|
+
if (!isPlain(json)) throw new TypeError('A JSON recipe must be an object');
|
|
361
|
+
const name = typeof json.name === 'string' ? json.name : 'custom';
|
|
362
|
+
const fail = (/** @type {string} */ path, /** @type {string} */ msg) => {
|
|
363
|
+
throw new TypeError(`Recipe "${name}": ${path ? `${path}: ` : ''}${msg}`);
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
for (const k of Object.keys(json)) if (!TOP.has(k)) fail(k, `unknown key (expected ${[...TOP].join(', ')})`);
|
|
367
|
+
if (json.params !== undefined && !isPlain(json.params)) fail('params', 'must be an object of defaults');
|
|
368
|
+
if (json.output !== undefined && !(isPlain(json.output) && Object.keys(json.output).every((k) => k === 'lossless'))) fail('output', 'is { "lossless": true | false }');
|
|
369
|
+
if (json.vars !== undefined && !isPlain(json.vars)) fail('vars', 'must be an object');
|
|
370
|
+
if (json.requires !== undefined && !(Array.isArray(json.requires) && json.requires.every((/** @type {unknown} */ r) => typeof r === 'string'))) fail('requires', 'must be an array of option names');
|
|
371
|
+
if (!Array.isArray(json.layers) || json.layers.length === 0) fail('layers', 'must be a non-empty array');
|
|
372
|
+
|
|
373
|
+
const names = new Set([...Object.keys(json.params ?? {}), ...CONTENT]);
|
|
374
|
+
const vars = new Set();
|
|
375
|
+
const checkName = (/** @type {string} */ path, /** @type {string} */ key) => {
|
|
376
|
+
if (!key.startsWith('opt.') && !names.has(key)) {
|
|
377
|
+
fail(path, `unknown param "${key}" (declare it in "params", or use "{opt.${key}}" for any cover option)`);
|
|
378
|
+
}
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
const checkCond = (/** @type {string} */ path, /** @type {any} */ c) => {
|
|
382
|
+
if (typeof c === 'string') return checkName(path, c);
|
|
383
|
+
if (typeof c === 'boolean') return;
|
|
384
|
+
if (!isPlain(c) || Object.keys(c).length === 0) return fail(path, 'a condition is an option name, { "not": … }, { "all": [...] }, { "any": [...] } or { "option": value }');
|
|
385
|
+
if ('not' in c) return checkCond(`${path}.not`, c.not);
|
|
386
|
+
for (const k of ['all', 'any']) {
|
|
387
|
+
if (k in c) {
|
|
388
|
+
if (!Array.isArray(c[k])) return fail(`${path}.${k}`, 'must be an array of conditions');
|
|
389
|
+
return c[k].forEach((/** @type {unknown} */ x, /** @type {number} */ i) => checkCond(`${path}.${k}[${i}]`, x));
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
for (const k of Object.keys(c)) if (k !== 'mode' && k !== 'aspect') checkName(`${path}.${k}`, k);
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
const checkValue = (/** @type {string} */ path, /** @type {any} */ v) => {
|
|
396
|
+
if (typeof v === 'string') {
|
|
397
|
+
if (v.startsWith('@@') || v.startsWith('$$')) return;
|
|
398
|
+
if (v.startsWith('@')) {
|
|
399
|
+
const [head, idx, extra] = v.slice(1).split('.');
|
|
400
|
+
const ok = extra === undefined && (CANVAS.includes(head) ? idx === undefined : PALETTE.includes(head) && (idx === undefined || (head === 'colors' && /^\d+$/.test(idx))));
|
|
401
|
+
if (!ok) fail(path, `unknown token "${v}" (use @${PALETTE.join(', @')}, @colors.N, @${CANVAS.join(', @')})`);
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
if (v.startsWith('$')) {
|
|
405
|
+
const head = v.slice(1).split('.')[0];
|
|
406
|
+
if (!vars.has(head)) fail(path, `unknown var "${v}" (declare it in "vars" first)`);
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
for (const m of v.matchAll(/\{([^{}]*)\}/g)) {
|
|
410
|
+
if (!/^[\w.]+$/.test(m[1])) fail(path, `bad template "{${m[1]}}"`);
|
|
411
|
+
checkName(path, m[1]);
|
|
412
|
+
}
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
if (Array.isArray(v)) return v.forEach((x, i) => checkValue(`${path}[${i}]`, x));
|
|
416
|
+
if (!isPlain(v)) return;
|
|
417
|
+
if ('if' in v) {
|
|
418
|
+
for (const k of Object.keys(v)) if (!['if', 'then', 'else'].includes(k)) fail(path, `unexpected "${k}" in an "if" (use if, then, else)`);
|
|
419
|
+
if (!('then' in v)) fail(path, '"if" needs a "then"');
|
|
420
|
+
checkCond(`${path}.if`, v.if);
|
|
421
|
+
checkValue(`${path}.then`, v.then);
|
|
422
|
+
if ('else' in v) checkValue(`${path}.else`, v.else);
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
const keys = Object.keys(v);
|
|
426
|
+
if (keys.length === 1 && Object.hasOwn(OPS, keys[0])) {
|
|
427
|
+
const [lo, hi] = OPS[keys[0]];
|
|
428
|
+
const arg = v[keys[0]];
|
|
429
|
+
const count = Array.isArray(arg) ? arg.length : 1;
|
|
430
|
+
// `reverse` takes one array (literal or computed); the rest take argument lists.
|
|
431
|
+
if (keys[0] !== 'reverse' && (count < lo || count > hi)) fail(`${path}.${keys[0]}`, `takes ${lo === hi ? lo : `${lo}${hi === Infinity ? '+' : `–${hi}`}`} argument${hi === 1 ? '' : 's'}, got ${count}`);
|
|
432
|
+
if (keys[0] === 'mix' && typeof arg[3] === 'string' && !['oklab', 'oklch'].includes(arg[3])) fail(`${path}.mix`, 'the space is "oklab" or "oklch"');
|
|
433
|
+
if (keys[0] === 'pick' && Array.isArray(arg) && arg.length === 0) fail(`${path}.pick`, 'needs at least one item');
|
|
434
|
+
return checkValue(`${path}.${keys[0]}`, arg);
|
|
435
|
+
}
|
|
436
|
+
for (const [k, x] of Object.entries(v)) checkValue(`${path}.${k}`, x);
|
|
437
|
+
};
|
|
438
|
+
|
|
439
|
+
for (const [k, v] of Object.entries(json.params ?? {})) checkValue(`params.${k}`, v);
|
|
440
|
+
for (const r of json.requires ?? []) checkName('requires', r);
|
|
441
|
+
for (const [k, v] of Object.entries(json.vars ?? {})) {
|
|
442
|
+
checkValue(`vars.${k}`, v);
|
|
443
|
+
vars.add(k);
|
|
444
|
+
}
|
|
445
|
+
json.layers.forEach((/** @type {any} */ layer, /** @type {number} */ i) => {
|
|
446
|
+
const path = `layers[${i}]${layer?.id ? ` (${layer.id})` : ''}`;
|
|
447
|
+
if (!isPlain(layer)) return fail(path, 'must be an object');
|
|
448
|
+
if (!Object.hasOwn(LAYERS, layer.type)) fail(`${path}.type`, `unknown layer type "${layer.type}" (${Object.keys(LAYERS).join(', ')})`);
|
|
449
|
+
if (layer.id !== undefined && typeof layer.id !== 'string') fail(`${path}.id`, 'must be a string');
|
|
450
|
+
if (layer.when !== undefined) checkCond(`${path}.when`, layer.when);
|
|
451
|
+
const known = { ...LAYER_OPTIONS[layer.type], ...COMMON };
|
|
452
|
+
for (const [k, v] of Object.entries(layer)) {
|
|
453
|
+
if (LAYER_META.has(k)) continue;
|
|
454
|
+
if (!Object.hasOwn(known, k)) fail(`${path}.${k}`, `"${layer.type}" layers have no option "${k}" (${Object.keys(known).join(', ')})`);
|
|
455
|
+
checkValue(`${path}.${k}`, v);
|
|
456
|
+
}
|
|
457
|
+
});
|
|
458
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every layer type's option names, with one-line descriptions. The JSON
|
|
3
|
+
* recipe validator rejects names not listed here, and
|
|
4
|
+
* `scripts/schema.mjs` builds the JSON Schema's autocomplete from it.
|
|
5
|
+
* Add an option to a layer, add it here.
|
|
6
|
+
*
|
|
7
|
+
* @module layer-options
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** @type {Record<string, Record<string, string>>} Layer options, by type: name → description. */
|
|
11
|
+
export const LAYER_OPTIONS = {
|
|
12
|
+
solid: { color: 'Fill. Default @bg.' },
|
|
13
|
+
linear: { colors: 'Two or more colours.', opacities: 'Per-stop opacity.', angle: 'CSS degrees (0 = up). Default seeded.', ease: 'Smoothstep stops. Default true.' },
|
|
14
|
+
radial: { colors: 'Centre → edge.', x: 'Centre x, 0–1.', y: 'Centre y, 0–1.', radius: 'Length. Default "80vmax".', ease: 'Default true.' },
|
|
15
|
+
mesh: { colors: 'Blob colours.', hues: 'Distinct colours to use. Default 3.', hueSpan: 'Keep the colours within this many degrees of hue (OKLCH): neighbouring hues only.', count: 'Blobs. Default 4.', blur: 'Length. Default "11vmax".', spread: '0–1. Default 1 (sweep), 0.7 (ring).', size: '[min, max] radius, fractions of the longer side.', base: 'Fill under the fields: "auto" (a gradient across the sweep between the first two colours; default), "bg", a colour, or null.', arrangement: 'sweep (default: long fields along one direction) | ring', angle: 'Sweep direction in degrees. Default seeded.', streak: 'How much more the sweep blurs along than across. Default 1.5.', tone: '{ l: [min, max], c: [min, max] }: pull colours into an OKLCH band.', fold: 'true or { color, to, position, bend, softness, sheen }: a second field meeting the first along a soft curve.', streaks: 'Thin light motion streaks along the sweep. Default 0.', from: 'A photo (path or bytes) to take the colours from (needs sharp).', blobs: 'Explicit [{ x, y, r, squash, rotate, color, opacity }].' },
|
|
16
|
+
stripes: { direction: 'vertical | horizontal | diagonal', angle: 'For diagonal.', count: 'Stripes. Default 8.', widths: 'even | random | taper | [weights]', taper: 'Ratio for widths: taper.', colors: 'Cycled.', gap: 'Length.', radius: 'Length.', region: '{ x, y, width, height } fractions.', regionRadius: 'Length.' },
|
|
17
|
+
image: { src: 'A local path, data: URI or bytes ("{image}").', fit: 'cover | contain', focus: 'An anchor, attention or entropy.', region: 'Fractions.', duotone: 'true or [shadow, highlight].', grayscale: 'boolean', normalize: 'Stretch tones first.', tint: 'A colour or { color, amount, blend }.', blur: 'Length.' },
|
|
18
|
+
grain: { amount: '0–1. Default 0.08.', size: 'Grain px. Default 1.', monochrome: 'Default true.', vary: '"edges": heavier toward the edges (spray paint).' },
|
|
19
|
+
pattern: { kind: 'grid | dots | lines | rings | waves', size: 'Cell. Length.', stroke: 'Length.', color: 'Default @text.', angle: 'For lines.', center: '{ x, y } fractions.', wavelength: 'Length.', amplitude: 'Length.', fade: 'none | radial | edges | top | bottom | left | right', dash: 'dotted | dashed | [on, off] lengths.', divisions: 'Grid/lines: N equal cells (inner lines only) instead of size.', gaps: '[{ x, y, width, height }] fractions the lines break around.', region: 'Fractions.' },
|
|
20
|
+
shapes: { kind: 'circle | square | pill | mixed', count: 'Default 6.', size: '[min, max] lengths.', colors: 'Cycled.', placement: 'scatter | row | column', items: '[{ kind, x, y, size, color, rotate, opacity }]', region: 'Fractions.', rotate: 'Tilt squares and pills.', radius: 'Square corner, fraction of size.', outline: 'true or a width.' },
|
|
21
|
+
icon: { name: 'Iconify name ("lucide:feather") or raw SVG ("{icon}").', svg: 'Raw SVG.', path: 'A path d.', viewBox: '[x, y, w, h] for path.', size: 'Length. Default "24vmin".', color: 'Default the accent on a tile, else @text.', strokeWidth: 'Stroke icons.', anchor: 'One of nine anchors.', position: '{ x, y } fractions.', offset: '{ x, y } lengths.', margin: 'Length.', tile: 'true or { size, radius, color, shadow, border }.' },
|
|
22
|
+
text: { content: 'The text ("{title}").', font: 'Family. Default Inter.', weight: 'Default 600.', style: 'normal | italic', size: 'Length.', minSize: 'Shrink toward this before truncating.', lineHeight: 'Default 1.12.', letterSpacing: 'Em. Default -0.02.', color: 'Default @text.', align: 'left | center | right', anchor: 'Default bottom-left.', position: '{ x, y } fractions.', offset: '{ x, y } lengths.', margin: 'Length.', maxWidth: 'Length.', maxLines: 'Default 3.', ellipsis: "Default '…'.", balance: 'Default true.', transform: 'none | uppercase | lowercase', label: 'A string or { content, size, weight, color, letterSpacing, transform, gap }.', background: 'A colour or { color, radius, padding, opacity }.' },
|
|
23
|
+
diagram: { icon: 'One icon (Iconify name or raw SVG), drawn as white line-art.', nodes: '[{ icon, shape: square | circle | none, size, brackets }]', layout: 'single | row | triangle | square | stack | hub', arrows: 'true | false | loop | both | cycle', shape: 'For a single icon: none (default) | square | circle.', size: 'Node size. Length.', gap: 'Space between nodes. Length.', stroke: 'Line width. Default "0.3vmin".', color: 'Line colour. Default white.', radius: 'Tile corner radius.', iconScale: 'Icon size inside a tile, 0–1. Default 0.44.', position: '{ x, y } centre, fractions.', grid: 'true or { divisions, opacity, stroke, color, pad }: a dotted grid that breaks around the drawing.' },
|
|
24
|
+
paper: { count: 'Shapes in the collage. Default 4.', kind: 'mixed | rect | square | circle | blob | arch | pill', color: 'Default #faf9f5 (paper).', region: 'Where the collage sits, fractions.', items: '[{ kind, x, y, width, height, size, rotate, color }] placed in the region.', wobble: 'How rough the cut edges are, 0–1. Default 0.5.' },
|
|
25
|
+
scribble: { icon: 'An icon to re-draw by hand (Iconify name or raw SVG).', graph: 'true or { nodes, dot }: a hand-drawn node graph instead.', color: 'Default #faf9f5.', stroke: 'Line width. Default "1.3vmin".', size: 'Length. Default "46vmin".', position: '{ x, y } centre, fractions.', wobble: '0–1. Default 0.5.' },
|
|
26
|
+
ink: { color: 'The ink: a colour or riso:pink | blue | yellow | teal | red | green | orange | purple | navy | black.', shapes: '[{ kind: circle | ring | half | arch | band | rect | text, x, y, r, size, width, height, thickness, rotate, content, … }]', offset: '{ x, y }: misregistration. Default seeded, 0.3–1% of the short side.', texture: 'grain (default) | halftone | none', speckle: 'Grain: share of paper showing through. Default 0.09.', cell: 'Halftone cell. Length.', dot: 'Halftone dot radius, fraction of the cell.', angle: 'Halftone screen angle: 0 or 45 (default seeded).', density: '[min, max]: uneven ink across the sheet. Default [0.78, 1].' },
|
|
27
|
+
dither: { colors: '[ground, ink] or [c0, c1, c2] (two dithered bands).', source: 'field (default, noise) | linear | radial', shape: 'none | circle | sphere | arch', size: 'Shape size. Length.', center: '{ x, y } fractions.', matrix: 'bayer4 (default) | bayer8 | noise', cell: 'Pixel size. Length. Default "0.9vmin".', angle: 'For linear.', scale: 'Noise feature size.', gamma: 'Tone curve.', range: '[lo, hi] of the field.' },
|
|
28
|
+
contour: { levels: 'Isolines. Default 20.', color: 'Default @text.', stroke: 'Default "0.14vmin".', highlight: 'Level to draw in the accent (default seeded), or false.', highlightColor: 'Default @accent.', index: 'Every Nth line thicker. Default 5 (0: none).', indexWeight: 'Index line weight multiple.', scale: 'Noise feature size.', octaves: 'Noise detail.', resolution: 'Grid cells across the short side.' },
|
|
29
|
+
waveform: { peaks: 'Peak values (any scale).', audio: 'An audio file to decode at build (needs @arraypress/waveform-gen).', bars: 'Bars. Default 88.', style: 'mirrored (default) | bars | line | dots', color: 'Default @accent.', region: 'Fractions.', gap: 'Gap between bars, fraction. Default 0.4.', round: 'Rounded bars. Default true.', stroke: 'Line style width.', caption: 'true (the duration) or text under the wave.', duration: 'Seconds (for the caption).', captionSize: 'Length.', captionColor: 'Default @text.' },
|
|
30
|
+
tiles: { cols: 'Columns. Default from the series.', rows: 'Rows. Default to keep cells near square.', series: 'Seeds the grid rhythm shared by a series.', colors: 'Shape and cell colours.', ground: 'The page colour among them.', kinds: 'Subset of quarter, half, circle, square, triangle.', merge: 'A 2×2 merged cell. Default true.', region: 'Fractions.' },
|
|
31
|
+
rule: { y: 'Height, fraction. Default 0.15.', from: '{ x, y } fractions.', to: '{ x, y } fractions.', inset: 'Side inset. Length.', color: 'Default @text.', stroke: 'Default "0.12vmin".' },
|
|
32
|
+
frame: { inset: 'Length.', radius: 'Length.', stroke: 'Length.', color: 'Default @text.', fill: 'A colour (a card).' },
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** @type {Record<string, string>} Options every layer takes (besides type, id and when). */
|
|
36
|
+
export const COMMON = { opacity: '0–1.', blend: 'A CSS mix-blend-mode.', seed: "This layer's own random stream." };
|
|
37
|
+
|