@ideare/kit 0.0.0-stage → 0.1.0-next.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.
Files changed (83) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +148 -2
  3. package/THIRD_PARTY.md +52 -0
  4. package/dist/brand/schema.d.ts +265 -0
  5. package/dist/brand/schema.js +271 -0
  6. package/dist/check/copy.d.ts +27 -0
  7. package/dist/check/copy.js +139 -0
  8. package/dist/check/index.d.ts +54 -0
  9. package/dist/check/index.js +492 -0
  10. package/dist/color/contrast.d.ts +41 -0
  11. package/dist/color/contrast.js +84 -0
  12. package/dist/color/palette.d.ts +27 -0
  13. package/dist/color/palette.js +114 -0
  14. package/dist/color/wcag.d.ts +22 -0
  15. package/dist/color/wcag.js +50 -0
  16. package/dist/draft/draft.d.ts +61 -0
  17. package/dist/draft/draft.js +383 -0
  18. package/dist/editor/history.d.ts +78 -0
  19. package/dist/editor/history.js +221 -0
  20. package/dist/editor/operations.d.ts +112 -0
  21. package/dist/editor/operations.js +621 -0
  22. package/dist/files/docs.d.ts +49 -0
  23. package/dist/files/docs.js +392 -0
  24. package/dist/files/generate.d.ts +47 -0
  25. package/dist/files/generate.js +254 -0
  26. package/dist/files/png.d.ts +40 -0
  27. package/dist/files/png.js +168 -0
  28. package/dist/files/raster.d.ts +14 -0
  29. package/dist/files/raster.js +0 -0
  30. package/dist/files/templates.d.ts +162 -0
  31. package/dist/files/templates.js +390 -0
  32. package/dist/files/tokens.d.ts +22 -0
  33. package/dist/files/tokens.js +204 -0
  34. package/dist/fonts/catalog.d.ts +3 -0
  35. package/dist/fonts/catalog.js +345 -0
  36. package/dist/fonts/ofl.d.ts +5 -0
  37. package/dist/fonts/ofl.js +5 -0
  38. package/dist/fonts/pick.d.ts +33 -0
  39. package/dist/fonts/pick.js +73 -0
  40. package/dist/imports/folder.d.ts +80 -0
  41. package/dist/imports/folder.js +124 -0
  42. package/dist/imports/unzip.d.ts +32 -0
  43. package/dist/imports/unzip.js +128 -0
  44. package/dist/index.d.ts +59 -0
  45. package/dist/index.js +34 -0
  46. package/dist/marks/draw.d.ts +48 -0
  47. package/dist/marks/draw.js +149 -0
  48. package/dist/marks/icons.data.d.ts +5 -0
  49. package/dist/marks/icons.data.js +600 -0
  50. package/dist/marks/svg.d.ts +46 -0
  51. package/dist/marks/svg.js +341 -0
  52. package/dist/node/index.d.ts +13 -0
  53. package/dist/node/index.js +41 -0
  54. package/dist/posts/pix.d.ts +19 -0
  55. package/dist/posts/pix.js +49 -0
  56. package/dist/posts/templates.d.ts +45 -0
  57. package/dist/posts/templates.js +868 -0
  58. package/dist/resvg/index.d.ts +2 -0
  59. package/dist/resvg/index.js +22 -0
  60. package/dist/scene/brand-art.d.ts +49 -0
  61. package/dist/scene/brand-art.js +154 -0
  62. package/dist/scene/frame.d.ts +89 -0
  63. package/dist/scene/frame.js +213 -0
  64. package/dist/scene/grain.d.ts +7 -0
  65. package/dist/scene/grain.js +7 -0
  66. package/dist/scene/qr.d.ts +10 -0
  67. package/dist/scene/qr.js +359 -0
  68. package/dist/scene/render.d.ts +76 -0
  69. package/dist/scene/render.js +557 -0
  70. package/dist/scene/schema.d.ts +271 -0
  71. package/dist/scene/schema.js +257 -0
  72. package/dist/sign.d.ts +47 -0
  73. package/dist/sign.js +88 -0
  74. package/dist/text/engine.d.ts +128 -0
  75. package/dist/text/engine.js +245 -0
  76. package/dist/text/fonts.d.ts +56 -0
  77. package/dist/text/fonts.js +65 -0
  78. package/dist/version.d.ts +2 -0
  79. package/dist/version.js +2 -0
  80. package/dist/wasm/index.d.ts +15 -0
  81. package/dist/wasm/index.js +46 -0
  82. package/package.json +78 -5
  83. package/schema/brand.v1.json +442 -0
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Marks as data. A mark master is SVG, but the scene engine never passes through SVG it did not
3
+ * write (docs/spikes/render.md, rule 1). So a mark is read into a small, fixed subset (shapes,
4
+ * groups, solid fills and strokes), then written back out by the kit, recolored as each version
5
+ * needs. Anything outside the subset is listed, so the studio can say what to fix.
6
+ */
7
+ export type MarkElement = {
8
+ tag: "g" | "path" | "circle" | "ellipse" | "rect" | "line" | "polyline" | "polygon";
9
+ attrs: Record<string, string>;
10
+ children: MarkElement[];
11
+ };
12
+ export type ParsedMark = {
13
+ viewBox: {
14
+ x: number;
15
+ y: number;
16
+ w: number;
17
+ h: number;
18
+ };
19
+ /** The root's own paint (Lucide-style fill="none" stroke="currentColor"), kept as a group. */
20
+ root: MarkElement;
21
+ /** Every hex color used, lowercase. */
22
+ colors: string[];
23
+ /** The thinnest stroke, in viewBox units (Infinity when nothing is stroked). */
24
+ thinnestStroke: number;
25
+ /** What was left out, in plain words. Empty for a clean mark. */
26
+ unsupported: string[];
27
+ };
28
+ /** Read a mark master into the kit's subset. Throws only when there is no usable <svg> root. */
29
+ export declare function parseMark(svg: string): ParsedMark;
30
+ export type MarkPaint = {
31
+ /** Map each hex the mark uses to the hex to draw. Default: as it is. */
32
+ color?: (hex: string) => string;
33
+ /** What currentColor and an unset fill mean here. Default: #000000, as in SVG. */
34
+ current?: string;
35
+ };
36
+ /** Write the mark's elements back out (no <svg> wrapper), recolored. */
37
+ export declare function markBody(mark: ParsedMark, paint?: MarkPaint): string;
38
+ /** A standalone SVG of the mark (recolored), with its own viewBox. */
39
+ export declare function markSvg(mark: ParsedMark, paint?: MarkPaint): string;
40
+ /**
41
+ * A mark master as the kit would draw it, written by the kit: only the subset parseMark reads, so
42
+ * no script, event handler, link or foreign content survives. currentColor stays. Running it on
43
+ * its own output gives the same bytes, so saving a clean mark again changes nothing. Throws like
44
+ * parseMark when there is no usable <svg> root.
45
+ */
46
+ export declare function cleanMarkSvg(svg: string): string;
@@ -0,0 +1,341 @@
1
+ /**
2
+ * Marks as data. A mark master is SVG, but the scene engine never passes through SVG it did not
3
+ * write (docs/spikes/render.md, rule 1). So a mark is read into a small, fixed subset (shapes,
4
+ * groups, solid fills and strokes), then written back out by the kit, recolored as each version
5
+ * needs. Anything outside the subset is listed, so the studio can say what to fix.
6
+ */
7
+ const SHAPES = new Set(["path", "circle", "ellipse", "rect", "line", "polyline", "polygon"]);
8
+ const SKIPPED = new Set(["title", "desc", "metadata"]);
9
+ const PAINT = new Set([
10
+ "fill",
11
+ "fill-rule",
12
+ "fill-opacity",
13
+ "stroke",
14
+ "stroke-width",
15
+ "stroke-linecap",
16
+ "stroke-linejoin",
17
+ "stroke-miterlimit",
18
+ "stroke-opacity",
19
+ "stroke-dasharray",
20
+ "opacity",
21
+ "clip-rule",
22
+ ]);
23
+ const GEOMETRY = {
24
+ g: ["transform"],
25
+ path: ["d", "transform"],
26
+ circle: ["cx", "cy", "r", "transform"],
27
+ ellipse: ["cx", "cy", "rx", "ry", "transform"],
28
+ rect: ["x", "y", "width", "height", "rx", "ry", "transform"],
29
+ line: ["x1", "y1", "x2", "y2", "transform"],
30
+ polyline: ["points", "transform"],
31
+ polygon: ["points", "transform"],
32
+ };
33
+ // Attributes that carry no drawing and are dropped without a word.
34
+ const IGNORED = /^(id|class|version|xml:space|xmlns(:.*)?|data-.*|aria-.*|role|focusable|style|width|height|viewBox|preserveAspectRatio|x|y|key)$/;
35
+ // The names svgo writes when a name is shorter than the hex (convertColors), plus the basics.
36
+ const NAMED = {
37
+ black: "#000000",
38
+ white: "#ffffff",
39
+ red: "#ff0000",
40
+ tan: "#d2b48c",
41
+ aqua: "#00ffff",
42
+ blue: "#0000ff",
43
+ cyan: "#00ffff",
44
+ gold: "#ffd700",
45
+ gray: "#808080",
46
+ grey: "#808080",
47
+ lime: "#00ff00",
48
+ navy: "#000080",
49
+ peru: "#cd853f",
50
+ pink: "#ffc0cb",
51
+ plum: "#dda0dd",
52
+ snow: "#fffafa",
53
+ teal: "#008080",
54
+ azure: "#f0ffff",
55
+ beige: "#f5f5dc",
56
+ brown: "#a52a2a",
57
+ coral: "#ff7f50",
58
+ green: "#008000",
59
+ ivory: "#fffff0",
60
+ khaki: "#f0e68c",
61
+ linen: "#faf0e6",
62
+ olive: "#808000",
63
+ wheat: "#f5deb3",
64
+ bisque: "#ffe4c4",
65
+ indigo: "#4b0082",
66
+ maroon: "#800000",
67
+ orange: "#ffa500",
68
+ orchid: "#da70d6",
69
+ purple: "#800080",
70
+ salmon: "#fa8072",
71
+ sienna: "#a0522d",
72
+ silver: "#c0c0c0",
73
+ tomato: "#ff6347",
74
+ violet: "#ee82ee",
75
+ yellow: "#ffff00",
76
+ crimson: "#dc143c",
77
+ fuchsia: "#ff00ff",
78
+ magenta: "#ff00ff",
79
+ };
80
+ const NUMBER = /^-?(\d+\.?\d*|\.\d+)(e-?\d+)?$/i;
81
+ const TRANSFORM = /^(\s*(matrix|translate|scale|rotate|skewX|skewY)\s*\([-\d.,\se]*\)\s*)+$/i;
82
+ /** A paint value as the kit stores it: lowercase hex, "none" or "currentColor". */
83
+ function normalizePaint(value) {
84
+ const trimmed = value.trim().toLowerCase();
85
+ if (trimmed === "none" || trimmed === "transparent")
86
+ return "none";
87
+ if (trimmed === "currentcolor")
88
+ return "currentColor";
89
+ const short = /^#([0-9a-f])([0-9a-f])([0-9a-f])$/.exec(trimmed);
90
+ if (short)
91
+ return `#${short[1]}${short[1]}${short[2]}${short[2]}${short[3]}${short[3]}`;
92
+ if (/^#[0-9a-f]{6}$/.test(trimmed))
93
+ return trimmed;
94
+ const rgb = /^rgb\(\s*(\d+)[\s,]+(\d+)[\s,]+(\d+)\s*\)$/.exec(trimmed);
95
+ if (rgb) {
96
+ return `#${[rgb[1], rgb[2], rgb[3]].map((part) => Math.min(255, Number(part)).toString(16).padStart(2, "0")).join("")}`;
97
+ }
98
+ return NAMED[trimmed];
99
+ }
100
+ function readAttributes(source) {
101
+ const attrs = {};
102
+ for (const match of source.matchAll(/([A-Za-z_:][\w:.-]*)\s*=\s*(?:"([^"]*)"|'([^']*)')/g)) {
103
+ attrs[match[1] ?? ""] = match[2] ?? match[3] ?? "";
104
+ }
105
+ return attrs;
106
+ }
107
+ function decode(value) {
108
+ return value
109
+ .replaceAll("&quot;", '"')
110
+ .replaceAll("&apos;", "'")
111
+ .replaceAll("&lt;", "<")
112
+ .replaceAll("&gt;", ">")
113
+ .replaceAll("&amp;", "&");
114
+ }
115
+ /** Read a mark master into the kit's subset. Throws only when there is no usable <svg> root. */
116
+ export function parseMark(svg) {
117
+ const unsupported = new Set();
118
+ const colors = new Set();
119
+ let thinnestStroke = Number.POSITIVE_INFINITY;
120
+ const source = svg
121
+ .replace(/<!--[\s\S]*?-->/g, "")
122
+ .replace(/<\?[\s\S]*?\?>/g, "")
123
+ .replace(/<!DOCTYPE[^>]*>/gi, "");
124
+ const stack = [];
125
+ let root;
126
+ let viewBox;
127
+ let skipDepth = 0;
128
+ let rootPaint = {};
129
+ const clean = (tag, raw) => {
130
+ const attrs = {};
131
+ const style = {};
132
+ for (const part of (raw.style ?? "").split(";")) {
133
+ const [name, ...rest] = part.split(":");
134
+ if (name?.trim() && rest.length)
135
+ style[name.trim()] = rest.join(":").trim();
136
+ }
137
+ const all = { ...raw, ...style };
138
+ for (const [name, rawValue] of Object.entries(all)) {
139
+ const value = decode(rawValue).trim();
140
+ if (PAINT.has(name)) {
141
+ if (name === "fill" || name === "stroke") {
142
+ const paint = normalizePaint(value);
143
+ if (!paint) {
144
+ unsupported.add(`the color "${value}" (use a hex color)`);
145
+ continue;
146
+ }
147
+ if (paint.startsWith("#"))
148
+ colors.add(paint);
149
+ attrs[name] = paint;
150
+ }
151
+ else if (name === "fill-rule" || name === "clip-rule") {
152
+ if (value === "evenodd" || value === "nonzero")
153
+ attrs[name] = value;
154
+ }
155
+ else if (name === "stroke-linecap") {
156
+ if (["butt", "round", "square"].includes(value))
157
+ attrs[name] = value;
158
+ }
159
+ else if (name === "stroke-linejoin") {
160
+ if (["miter", "round", "bevel"].includes(value))
161
+ attrs[name] = value;
162
+ }
163
+ else if (name === "stroke-dasharray") {
164
+ if (/^[\d.\s,]+$/.test(value) || value === "none")
165
+ attrs[name] = value;
166
+ }
167
+ else if (NUMBER.test(value.replace(/px$/, ""))) {
168
+ attrs[name] = value.replace(/px$/, "");
169
+ }
170
+ continue;
171
+ }
172
+ if (GEOMETRY[tag]?.includes(name)) {
173
+ if (name === "transform" && !TRANSFORM.test(value)) {
174
+ unsupported.add("a transform the kit cannot read");
175
+ continue;
176
+ }
177
+ if (name === "d" && /[^MmLlHhVvCcSsQqTtAaZz\d\s,.eE+-]/.test(value)) {
178
+ unsupported.add("path data with unknown commands");
179
+ continue;
180
+ }
181
+ if (name === "points" && !/^[\d\s,.eE+-]*$/.test(value)) {
182
+ unsupported.add("points that are not numbers");
183
+ continue;
184
+ }
185
+ const isList = name === "d" || name === "points" || name === "transform";
186
+ const number = value.replace(/px$/, "");
187
+ if (!isList && !NUMBER.test(number)) {
188
+ unsupported.add(`a ${name} that is not a number`);
189
+ continue;
190
+ }
191
+ attrs[name] = isList ? value : number;
192
+ continue;
193
+ }
194
+ if (!IGNORED.test(name) && !(name in style))
195
+ unsupported.add(`the ${name} attribute`);
196
+ else if (name in style && !IGNORED.test(name))
197
+ unsupported.add(`the ${name} style`);
198
+ }
199
+ return attrs;
200
+ };
201
+ for (const match of source.matchAll(/<(\/?)([A-Za-z][\w:-]*)([^>]*?)(\/?)>/g)) {
202
+ const [, closing, tagName = "", rawAttrs = "", selfClosing] = match;
203
+ const tag = tagName.toLowerCase();
204
+ if (skipDepth > 0) {
205
+ if (!selfClosing)
206
+ skipDepth += closing ? -1 : 1;
207
+ continue;
208
+ }
209
+ if (closing) {
210
+ if (tag === "g" || tag === "svg")
211
+ stack.pop();
212
+ continue;
213
+ }
214
+ if (tag === "svg") {
215
+ const attrs = readAttributes(rawAttrs);
216
+ if (root) {
217
+ unsupported.add("a nested <svg>");
218
+ if (!selfClosing)
219
+ skipDepth = 1;
220
+ continue;
221
+ }
222
+ const box = (attrs.viewBox ?? attrs.viewbox ?? "")
223
+ .trim()
224
+ .split(/[\s,]+/)
225
+ .map(Number);
226
+ if (box.length === 4 &&
227
+ box.every(Number.isFinite) &&
228
+ (box[2] ?? 0) > 0 &&
229
+ (box[3] ?? 0) > 0) {
230
+ viewBox = { x: box[0] ?? 0, y: box[1] ?? 0, w: box[2] ?? 1, h: box[3] ?? 1 };
231
+ }
232
+ else {
233
+ const w = Number.parseFloat(attrs.width ?? "");
234
+ const h = Number.parseFloat(attrs.height ?? "");
235
+ if (w > 0 && h > 0)
236
+ viewBox = { x: 0, y: 0, w, h };
237
+ }
238
+ rootPaint = clean("g", attrs);
239
+ delete rootPaint.transform;
240
+ root = { tag: "g", attrs: rootPaint, children: [] };
241
+ stack.push(root);
242
+ continue;
243
+ }
244
+ const parent = stack[stack.length - 1];
245
+ if (!parent)
246
+ continue;
247
+ if (SKIPPED.has(tag)) {
248
+ if (!selfClosing)
249
+ skipDepth = 1;
250
+ continue;
251
+ }
252
+ if (tag !== "g" && !SHAPES.has(tag)) {
253
+ unsupported.add(`the <${tag}> element`);
254
+ if (!selfClosing)
255
+ skipDepth = 1;
256
+ continue;
257
+ }
258
+ const element = {
259
+ tag: tag,
260
+ attrs: clean(tag, readAttributes(rawAttrs)),
261
+ children: [],
262
+ };
263
+ parent.children.push(element);
264
+ if (tag === "g" && !selfClosing)
265
+ stack.push(element);
266
+ }
267
+ if (!root || !viewBox)
268
+ throw new TypeError("The mark has no <svg> root with a viewBox or a size.");
269
+ // Stroke widths, inherited down the tree: the legibility check needs the thinnest one.
270
+ const walk = (element, stroke, width) => {
271
+ const nextStroke = element.attrs.stroke ?? stroke;
272
+ const nextWidth = element.attrs["stroke-width"]
273
+ ? Number(element.attrs["stroke-width"])
274
+ : width;
275
+ if (element.tag !== "g" && nextStroke && nextStroke !== "none") {
276
+ thinnestStroke = Math.min(thinnestStroke, nextWidth);
277
+ }
278
+ for (const child of element.children)
279
+ walk(child, nextStroke, nextWidth);
280
+ };
281
+ walk(root, undefined, 1);
282
+ return {
283
+ viewBox,
284
+ root,
285
+ colors: [...colors].sort(),
286
+ thinnestStroke,
287
+ unsupported: [...unsupported],
288
+ };
289
+ }
290
+ function escapeAttr(value) {
291
+ return value.replaceAll("&", "&amp;").replaceAll('"', "&quot;").replaceAll("<", "&lt;");
292
+ }
293
+ /** Write the mark's elements back out (no <svg> wrapper), recolored. */
294
+ export function markBody(mark, paint = {}) {
295
+ const current = paint.current ?? "#000000";
296
+ const color = paint.color ?? ((hex) => hex);
297
+ const write = (element, isRoot) => {
298
+ const attrs = { ...element.attrs };
299
+ // SVG paints an unset root fill black; say it, so recoloring reaches it too.
300
+ if (isRoot && attrs.fill === undefined)
301
+ attrs.fill = "currentColor";
302
+ const parts = Object.entries(attrs).map(([name, value]) => {
303
+ let out = value;
304
+ if (name === "fill" || name === "stroke") {
305
+ out =
306
+ value === "currentColor"
307
+ ? color(current)
308
+ : value === "none"
309
+ ? "none"
310
+ : color(value);
311
+ }
312
+ return ` ${name}="${escapeAttr(out)}"`;
313
+ });
314
+ const inner = element.children.map((child) => write(child, false)).join("");
315
+ return element.tag === "g" || inner
316
+ ? `<${element.tag}${parts.join("")}>${inner}</${element.tag}>`
317
+ : `<${element.tag}${parts.join("")}/>`;
318
+ };
319
+ return write(mark.root, true);
320
+ }
321
+ /** A standalone SVG of the mark (recolored), with its own viewBox. */
322
+ export function markSvg(mark, paint = {}) {
323
+ const { x, y, w, h } = mark.viewBox;
324
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${x} ${y} ${w} ${h}">${markBody(mark, paint)}</svg>`;
325
+ }
326
+ /**
327
+ * A mark master as the kit would draw it, written by the kit: only the subset parseMark reads, so
328
+ * no script, event handler, link or foreign content survives. currentColor stays. Running it on
329
+ * its own output gives the same bytes, so saving a clean mark again changes nothing. Throws like
330
+ * parseMark when there is no usable <svg> root.
331
+ */
332
+ export function cleanMarkSvg(svg) {
333
+ const mark = parseMark(svg);
334
+ // markSvg writes the root's paint on a <g>, which reads back as a bare root around one group.
335
+ // Lifting that group back up keeps a second pass from nesting another one.
336
+ const [only, ...rest] = mark.root.children;
337
+ const root = Object.keys(mark.root.attrs).length === 0 && only?.tag === "g" && rest.length === 0
338
+ ? only
339
+ : mark.root;
340
+ return markSvg({ ...mark, root }, { current: "currentColor" });
341
+ }
@@ -0,0 +1,13 @@
1
+ import { type FontResolver } from "../text/fonts.ts";
2
+ /** Where fonts are kept by default: ~/.cache/ideare/fonts (XDG_CACHE_HOME when set). */
3
+ export declare function defaultFontCacheDir(): string;
4
+ /**
5
+ * Downloads static TTFs (or WOFF2) from Fontsource once and keeps them on disk, keyed by the
6
+ * Fontsource version, so a pinned family never changes under you. Catalog families are pinned
7
+ * to the catalog's version; any other id follows "latest".
8
+ */
9
+ export declare function diskFontResolver(options?: {
10
+ dir?: string;
11
+ fetch?: typeof fetch;
12
+ versions?: Record<string, string>;
13
+ }): FontResolver;
@@ -0,0 +1,41 @@
1
+ /**
2
+ * The Node and Bun side of the kit: a font resolver with a disk cache. The core never touches a
3
+ * file system, so this lives behind its own entry, `@ideare/kit/node`.
4
+ */
5
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
6
+ import { homedir } from "node:os";
7
+ import { join } from "node:path";
8
+ import { catalogVersions } from "../fonts/pick.js";
9
+ import { fetchFontResolver, fontFileName } from "../text/fonts.js";
10
+ /** Where fonts are kept by default: ~/.cache/ideare/fonts (XDG_CACHE_HOME when set). */
11
+ export function defaultFontCacheDir() {
12
+ const base = process.env.XDG_CACHE_HOME || join(homedir(), ".cache");
13
+ return join(base, "ideare", "fonts");
14
+ }
15
+ /**
16
+ * Downloads static TTFs (or WOFF2) from Fontsource once and keeps them on disk, keyed by the
17
+ * Fontsource version, so a pinned family never changes under you. Catalog families are pinned
18
+ * to the catalog's version; any other id follows "latest".
19
+ */
20
+ export function diskFontResolver(options = {}) {
21
+ const dir = options.dir ?? defaultFontCacheDir();
22
+ const versions = { ...catalogVersions(), ...options.versions };
23
+ const remote = fetchFontResolver({ fetch: options.fetch, versions });
24
+ return {
25
+ async load(font) {
26
+ const file = join(dir, `${versions[font.id] ?? "latest"}`, fontFileName(font));
27
+ try {
28
+ return new Uint8Array(await readFile(file));
29
+ }
30
+ catch {
31
+ const bytes = await remote.load(font);
32
+ await mkdir(join(file, ".."), { recursive: true });
33
+ // Write then rename, so a crash never leaves half a font in the cache.
34
+ const partial = `${file}.${process.pid}.partial`;
35
+ await writeFile(partial, bytes);
36
+ await rename(partial, file);
37
+ return bytes;
38
+ }
39
+ },
40
+ };
41
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * The Pix "copia e cola" code: what a static Pix QR code holds (BR Code, the EMV QR merchant
3
+ * format as Banco Central do Brasil's Pix manual sets it). A bank app reads the key, the name,
4
+ * the city and an optional amount from it, then asks the payer to confirm.
5
+ */
6
+ export type PixDetails = {
7
+ /** The Pix key: a phone number with +55, an email, a CPF or CNPJ, or a random key. */
8
+ key: string;
9
+ /** Who receives, as the bank shows it. Accents are dropped; 25 characters at most. */
10
+ name: string;
11
+ /** The receiver's city. Accents are dropped; 15 characters at most. */
12
+ city: string;
13
+ /** A fixed amount in reais, like 25.5. Left out, the payer types it. */
14
+ amount?: number;
15
+ /** A reference for the payment, up to 25 letters and digits. Default `***`, for none. */
16
+ txid?: string;
17
+ };
18
+ /** The code for a static Pix QR code. Throws when the key is empty or too long to fit. */
19
+ export declare function pixCode(details: PixDetails): string;
@@ -0,0 +1,49 @@
1
+ /**
2
+ * The Pix "copia e cola" code: what a static Pix QR code holds (BR Code, the EMV QR merchant
3
+ * format as Banco Central do Brasil's Pix manual sets it). A bank app reads the key, the name,
4
+ * the city and an optional amount from it, then asks the payer to confirm.
5
+ */
6
+ function field(id, value) {
7
+ return `${id}${String(value.length).padStart(2, "0")}${value}`;
8
+ }
9
+ /** Plain ASCII, as the BR Code's name and city fields take them. */
10
+ function plain(text, max) {
11
+ return text
12
+ .normalize("NFD")
13
+ .replace(/[\u0300-\u036f]/g, "")
14
+ .replace(/[^\x20-\x7e]/g, "")
15
+ .trim()
16
+ .slice(0, max)
17
+ .trimEnd();
18
+ }
19
+ /** CRC-16/CCITT-FALSE (polynomial 0x1021, start 0xFFFF), as four uppercase hex digits. */
20
+ function crc16(text) {
21
+ let crc = 0xffff;
22
+ for (const byte of new TextEncoder().encode(text)) {
23
+ crc ^= byte << 8;
24
+ for (let bit = 0; bit < 8; bit++)
25
+ crc = crc & 0x8000 ? ((crc << 1) ^ 0x1021) & 0xffff : (crc << 1) & 0xffff;
26
+ }
27
+ return crc.toString(16).toUpperCase().padStart(4, "0");
28
+ }
29
+ /** The code for a static Pix QR code. Throws when the key is empty or too long to fit. */
30
+ export function pixCode(details) {
31
+ const key = details.key.trim();
32
+ if (!key || key.length > 77)
33
+ throw new RangeError("A Pix key has 1 to 77 characters.");
34
+ const txid = (details.txid ?? "***").replace(/[^A-Za-z0-9*]/g, "").slice(0, 25) || "***";
35
+ const amount = details.amount !== undefined && details.amount > 0
36
+ ? field("54", details.amount.toFixed(2))
37
+ : "";
38
+ const body = field("00", "01") +
39
+ field("26", field("00", "br.gov.bcb.pix") + field("01", key)) +
40
+ field("52", "0000") +
41
+ field("53", "986") +
42
+ amount +
43
+ field("58", "BR") +
44
+ field("59", plain(details.name, 25) || "Pix") +
45
+ field("60", plain(details.city, 15) || "BRASIL") +
46
+ field("62", field("05", txid)) +
47
+ "6304";
48
+ return body + crc16(body);
49
+ }
@@ -0,0 +1,45 @@
1
+ import type { Brand, Locale } from "../brand/schema.ts";
2
+ import { type Scene } from "../scene/schema.ts";
3
+ /**
4
+ * The launch templates: a starting design for each job an owner posts for, in every size they
5
+ * post at. Each is plain scene data the editor opens and the API draws. Paints are brand tokens
6
+ * only, in the pairs every brand's palette keeps readable (ink and muted on ground and surface,
7
+ * onPrimary on primary, primary as large type on ground), and type uses the brand's two font
8
+ * roles, so a template looks like the brand it is made for. Photo spots are art layers named
9
+ * `photo`, which the editor fills with an upload or AI art; text the owner changes is a slot,
10
+ * and slots marked live can be filled by a live image link (`t.<slot>`).
11
+ */
12
+ export declare const POST_TEMPLATES: readonly ["promotion", "hours", "menu", "pix", "new-product", "status-offer", "carousel"];
13
+ export type PostTemplateId = (typeof POST_TEMPLATES)[number];
14
+ export declare const POST_FORMATS: readonly ["ig-post", "ig-square", "ig-story", "wa-status", "share-card"];
15
+ export type PostFormat = (typeof POST_FORMATS)[number];
16
+ export type PostTemplateOptions = {
17
+ /** The size. Default: the template's own (`wa-status` for the status offer, else `ig-post`). */
18
+ format?: PostFormat;
19
+ /** The language of the words. Default: the brand's. */
20
+ locale?: Locale;
21
+ /** For the Pix card: the key its QR code pays. The name comes from the brand. */
22
+ pix?: {
23
+ key: string;
24
+ city?: string;
25
+ amount?: number;
26
+ };
27
+ };
28
+ type Info = {
29
+ name: string;
30
+ description: string;
31
+ };
32
+ /** What the template picker shows, and the size each template starts at. */
33
+ export declare const POST_TEMPLATE_INFO: Record<PostTemplateId, {
34
+ format: PostFormat;
35
+ text: Record<Locale, Info>;
36
+ }>;
37
+ /**
38
+ * A starting design for one of the owner's jobs, in one size and language, for one brand.
39
+ *
40
+ * ```ts
41
+ * const scene = postTemplate("promotion", brand); // an Instagram post, in the brand's language
42
+ * ```
43
+ */
44
+ export declare function postTemplate(id: PostTemplateId, brand: Brand, options?: PostTemplateOptions): Scene;
45
+ export {};