makeables 0.10.0 → 0.11.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 +2 -1
- package/dist/badge-package-CMFI3BIR.js +53 -0
- package/dist/brand-NMENEWIZ.js +37 -0
- package/dist/brand-preview-W4FTJJM3.js +48 -0
- package/dist/chunk-4WLCMPRH.js +74 -0
- package/dist/{chunk-TRIRNJSG.js → chunk-6RBOBRL4.js} +13 -11
- package/dist/chunk-BYXBJQAS.js +0 -0
- package/dist/chunk-DLTVENEP.js +3295 -0
- package/dist/{chunk-G6OTYITI.js → chunk-YB2YJCBS.js} +386 -3463
- package/dist/chunk-ZZA6CR6X.js +700 -0
- package/dist/cli.js +2317 -224
- package/dist/{kit-YXOYCN6F.js → kit-IEWHMBS2.js} +3 -2
- package/dist/{mcp-VS5XYXVM.js → mcp-YBRUVIWG.js} +11 -18
- package/dist/submission-ATVS5YTK.js +76 -0
- package/package.json +3 -2
- package/dist/chunk-SZTWKHNA.js +0 -95
|
@@ -0,0 +1,700 @@
|
|
|
1
|
+
import {
|
|
2
|
+
readKit
|
|
3
|
+
} from "./chunk-6RBOBRL4.js";
|
|
4
|
+
import {
|
|
5
|
+
InputError,
|
|
6
|
+
readSubmission,
|
|
7
|
+
submitDesign
|
|
8
|
+
} from "./chunk-YB2YJCBS.js";
|
|
9
|
+
import {
|
|
10
|
+
assemblePackage,
|
|
11
|
+
brandDocumentSchema,
|
|
12
|
+
brandPackageDocumentSchema,
|
|
13
|
+
jsonBytes
|
|
14
|
+
} from "./chunk-DLTVENEP.js";
|
|
15
|
+
|
|
16
|
+
// src/brand.ts
|
|
17
|
+
import { spawnSync } from "child_process";
|
|
18
|
+
import { createHash } from "crypto";
|
|
19
|
+
import { mkdir, readFile, writeFile } from "fs/promises";
|
|
20
|
+
import { dirname, extname, join, relative, resolve } from "path";
|
|
21
|
+
import { createCanvas, loadImage } from "@napi-rs/canvas";
|
|
22
|
+
import sharp from "sharp";
|
|
23
|
+
var imageFile = /\.(svg|png|webp|jpe?g)$/i;
|
|
24
|
+
var REF_IMAGE_LIMIT = 2e6;
|
|
25
|
+
async function readBrand(file) {
|
|
26
|
+
return brandDocumentSchema.parse(JSON.parse(await readFile(resolve(file), "utf8")));
|
|
27
|
+
}
|
|
28
|
+
async function writeBrand(file, brand) {
|
|
29
|
+
await writeFile(resolve(file), `${JSON.stringify(brandDocumentSchema.parse(brand), null, 2)}
|
|
30
|
+
`);
|
|
31
|
+
}
|
|
32
|
+
function slugify(value) {
|
|
33
|
+
return value.normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40) || "brand";
|
|
34
|
+
}
|
|
35
|
+
function newBrand(name, slug = slugify(name)) {
|
|
36
|
+
return brandDocumentSchema.parse({
|
|
37
|
+
format: "makeables-brand",
|
|
38
|
+
version: 1,
|
|
39
|
+
slug,
|
|
40
|
+
name,
|
|
41
|
+
description: `${name} on Makeables.`
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
async function addBrandKit(file, ref, id, family) {
|
|
45
|
+
const brand = await readBrand(file);
|
|
46
|
+
const path = resolve(ref);
|
|
47
|
+
const pkg = await readSubmission(path);
|
|
48
|
+
if (pkg.manifest.product !== "kit") throw new InputError("Add a kit document or kit package.");
|
|
49
|
+
const families = family && !brand.families.some((entry) => entry.id === family);
|
|
50
|
+
return brandDocumentSchema.parse({
|
|
51
|
+
...brand,
|
|
52
|
+
families: families ? [...brand.families, { id: family, name: pkg.manifest.title.slice(0, 40) }] : brand.families,
|
|
53
|
+
kits: [
|
|
54
|
+
...brand.kits.filter((kit) => kit.id !== id),
|
|
55
|
+
{ id, ...family ? { family } : {}, file: relative(dirname(resolve(file)), path) }
|
|
56
|
+
]
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
async function artTone(bytes) {
|
|
60
|
+
const { data } = await sharp(bytes, { density: 72 }).resize(32, 32, { fit: "inside" }).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
|
|
61
|
+
let sum = 0, count = 0;
|
|
62
|
+
for (let i = 0; i < data.length; i += 4)
|
|
63
|
+
if (data[i + 3] > 128) {
|
|
64
|
+
sum += 0.2126 * data[i] + 0.7152 * data[i + 1] + 0.0722 * data[i + 2];
|
|
65
|
+
count++;
|
|
66
|
+
}
|
|
67
|
+
return count && sum / count > 150 ? "light" : "dark";
|
|
68
|
+
}
|
|
69
|
+
async function addBrandLogo(file, ref, id, name = id) {
|
|
70
|
+
const brand = await readBrand(file);
|
|
71
|
+
const bytes = await readFile(resolve(ref));
|
|
72
|
+
const info = await imageInfo(bytes);
|
|
73
|
+
if (!info) throw new InputError("Choose an SVG, PNG, WebP or JPEG logo.");
|
|
74
|
+
const svg = info.format === "svg";
|
|
75
|
+
const base = dirname(resolve(file));
|
|
76
|
+
await mkdir(join(base, "refs"), { recursive: true });
|
|
77
|
+
const path = join("refs", `${id}.${svg ? "svg" : "png"}`);
|
|
78
|
+
await writeFile(join(base, path), svg ? bytes : await fitImage(bytes));
|
|
79
|
+
return brandDocumentSchema.parse({
|
|
80
|
+
...brand,
|
|
81
|
+
logos: [
|
|
82
|
+
...brand.logos.filter((logo) => logo.id !== id),
|
|
83
|
+
{ id, name, file: path, tone: await artTone(bytes) }
|
|
84
|
+
]
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
async function fitImage(bytes, maxSide = 1600) {
|
|
88
|
+
let side = maxSide;
|
|
89
|
+
for (; ; ) {
|
|
90
|
+
const out = await sharp(bytes).resize({ width: side, height: side, fit: "inside", withoutEnlargement: true }).png().toBuffer();
|
|
91
|
+
if (out.length <= REF_IMAGE_LIMIT || side <= 400) return new Uint8Array(out);
|
|
92
|
+
side = Math.round(side * 0.8);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
function hexOf(r, g, b) {
|
|
96
|
+
return `#${[r, g, b].map((v) => Math.round(v).toString(16).padStart(2, "0")).join("")}`;
|
|
97
|
+
}
|
|
98
|
+
function luminance(hex) {
|
|
99
|
+
const [r, g, b] = [1, 3, 5].map((i) => Number.parseInt(hex.slice(i, i + 2), 16) / 255);
|
|
100
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
101
|
+
}
|
|
102
|
+
async function paletteOf(bytes) {
|
|
103
|
+
const { data } = await sharp(bytes).resize(64, 64, { fit: "inside" }).removeAlpha().raw().toBuffer({ resolveWithObject: true });
|
|
104
|
+
const buckets = /* @__PURE__ */ new Map();
|
|
105
|
+
for (let i = 0; i < data.length; i += 3) {
|
|
106
|
+
const key = [data[i] >> 5, data[i + 1] >> 5, data[i + 2] >> 5].join();
|
|
107
|
+
const entry = buckets.get(key) ?? { n: 0, r: 0, g: 0, b: 0 };
|
|
108
|
+
entry.n++;
|
|
109
|
+
entry.r += data[i];
|
|
110
|
+
entry.g += data[i + 1];
|
|
111
|
+
entry.b += data[i + 2];
|
|
112
|
+
buckets.set(key, entry);
|
|
113
|
+
}
|
|
114
|
+
const colors = [...buckets.values()].sort((a, b) => b.n - a.n).map((c) => hexOf(c.r / c.n, c.g / c.n, c.b / c.n));
|
|
115
|
+
const chroma = (hex) => {
|
|
116
|
+
const [r, g, b] = [1, 3, 5].map((i) => Number.parseInt(hex.slice(i, i + 2), 16));
|
|
117
|
+
return Math.max(r, g, b) - Math.min(r, g, b);
|
|
118
|
+
};
|
|
119
|
+
const dark = colors.find((c) => luminance(c) < 0.2) ?? "#141414";
|
|
120
|
+
const light = colors.find((c) => luminance(c) > 0.8) ?? "#f4f1eb";
|
|
121
|
+
const accents = colors.filter((c) => chroma(c) > 70);
|
|
122
|
+
const base = luminance(colors[0] ?? light) < 0.5 ? { paper: dark, ink: light } : { paper: light, ink: dark };
|
|
123
|
+
return {
|
|
124
|
+
...base,
|
|
125
|
+
accent: accents[0] ?? "#ff5a1f",
|
|
126
|
+
...accents[1] ? { accent2: accents[1] } : {}
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
function attribute(tag, name) {
|
|
130
|
+
return tag.match(new RegExp(`\\b${name}\\s*=\\s*("([^"]*)"|'([^']*)')`, "i"))?.slice(2).find(Boolean);
|
|
131
|
+
}
|
|
132
|
+
function decode(text) {
|
|
133
|
+
return text.replace(/&/g, "&").replace(/"/g, '"').replace(/'|'/g, "'").replace(/</g, "<").replace(/>/g, ">");
|
|
134
|
+
}
|
|
135
|
+
function pageFacts(html, base) {
|
|
136
|
+
const facts = { icons: [], logos: [] };
|
|
137
|
+
const absolute = (value) => {
|
|
138
|
+
if (!value) return void 0;
|
|
139
|
+
try {
|
|
140
|
+
const url = new URL(decode(value), base);
|
|
141
|
+
return url.protocol === "https:" || url.protocol === "http:" ? url.href : void 0;
|
|
142
|
+
} catch {
|
|
143
|
+
return void 0;
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
for (const tag of html.match(/<meta\b[^>]*>/gi) ?? []) {
|
|
147
|
+
const key = (attribute(tag, "property") ?? attribute(tag, "name") ?? "").toLowerCase();
|
|
148
|
+
const content = attribute(tag, "content");
|
|
149
|
+
if (!content) continue;
|
|
150
|
+
if (key === "og:title" || key === "twitter:title" && !facts.title)
|
|
151
|
+
facts.title = decode(content);
|
|
152
|
+
if (key === "og:description" || key === "description" && !facts.description)
|
|
153
|
+
facts.description = decode(content);
|
|
154
|
+
if (key === "og:image" || key === "twitter:image" && !facts.image)
|
|
155
|
+
facts.image = absolute(content);
|
|
156
|
+
}
|
|
157
|
+
if (!facts.title)
|
|
158
|
+
facts.title = decode(html.match(/<title[^>]*>([^<]*)<\/title>/i)?.[1]?.trim() ?? "");
|
|
159
|
+
for (const tag of html.match(/<link\b[^>]*>/gi) ?? []) {
|
|
160
|
+
const rel = (attribute(tag, "rel") ?? "").toLowerCase();
|
|
161
|
+
const href = absolute(attribute(tag, "href"));
|
|
162
|
+
if (href && /icon|apple-touch-icon|mask-icon/.test(rel)) facts.icons.push(href);
|
|
163
|
+
}
|
|
164
|
+
for (const tag of html.match(/<img\b[^>]*>/gi) ?? []) {
|
|
165
|
+
const text = `${attribute(tag, "alt") ?? ""} ${attribute(tag, "class") ?? ""} ${attribute(tag, "src") ?? ""}`;
|
|
166
|
+
const src = absolute(attribute(tag, "src"));
|
|
167
|
+
if (src && /logo|wordmark|lockup|brand/i.test(text)) facts.logos.push(src);
|
|
168
|
+
}
|
|
169
|
+
for (const block of html.match(/<script[^>]+application\/ld\+json[^>]*>[\s\S]*?<\/script>/gi) ?? []) {
|
|
170
|
+
try {
|
|
171
|
+
const value = JSON.parse(block.replace(/^<script[^>]*>|<\/script>$/gi, ""));
|
|
172
|
+
for (const entry of [
|
|
173
|
+
value,
|
|
174
|
+
...Array.isArray(value["@graph"]) ? value["@graph"] : []
|
|
175
|
+
].flat()) {
|
|
176
|
+
if (!entry || typeof entry !== "object" || !/Event/.test(String(entry["@type"]))) continue;
|
|
177
|
+
facts.event ??= entry.name;
|
|
178
|
+
facts.date ??= entry.startDate;
|
|
179
|
+
const location = Array.isArray(entry.location) ? entry.location[0] : entry.location;
|
|
180
|
+
facts.place ??= location?.name ?? location?.address?.addressLocality;
|
|
181
|
+
const image = absolute(Array.isArray(entry.image) ? entry.image[0] : entry.image);
|
|
182
|
+
if (image) facts.image = image;
|
|
183
|
+
}
|
|
184
|
+
} catch {
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
facts.icons = [...new Set(facts.icons)];
|
|
188
|
+
facts.logos = [...new Set(facts.logos)];
|
|
189
|
+
return facts;
|
|
190
|
+
}
|
|
191
|
+
function renderedHtml(url) {
|
|
192
|
+
const run = (args) => spawnSync("agent-browser", args, { encoding: "utf8", shell: false, timeout: 6e4 });
|
|
193
|
+
if (run(["--version"]).status !== 0) return null;
|
|
194
|
+
if (run(["open", url]).status !== 0) return null;
|
|
195
|
+
run(["wait", "2000"]);
|
|
196
|
+
const result = run(["eval", "document.documentElement.outerHTML"]);
|
|
197
|
+
if (result.status !== 0) return null;
|
|
198
|
+
try {
|
|
199
|
+
const value = JSON.parse(result.stdout.trim());
|
|
200
|
+
return typeof value === "string" ? value : null;
|
|
201
|
+
} catch {
|
|
202
|
+
return result.stdout || null;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
async function fetchBytes(url, limit = 12e6) {
|
|
206
|
+
const response = await fetch(url, {
|
|
207
|
+
headers: { "User-Agent": "Mozilla/5.0 (compatible; Makeables/1.0; +https://makeables.dev)" },
|
|
208
|
+
redirect: "follow",
|
|
209
|
+
signal: AbortSignal.timeout(2e4)
|
|
210
|
+
});
|
|
211
|
+
if (!response.ok) throw new InputError(`Could not fetch ${url}: HTTP ${response.status}.`);
|
|
212
|
+
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
213
|
+
if (bytes.length > limit) throw new InputError(`${url} exceeds ${limit / 1e6} MB.`);
|
|
214
|
+
return { bytes, type: response.headers.get("content-type") ?? "" };
|
|
215
|
+
}
|
|
216
|
+
async function imageInfo(bytes) {
|
|
217
|
+
try {
|
|
218
|
+
const meta = await sharp(bytes).metadata();
|
|
219
|
+
return meta.width && meta.height ? meta : null;
|
|
220
|
+
} catch {
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
async function refsProof(brand, files, facts) {
|
|
225
|
+
const width = 1600;
|
|
226
|
+
const cell = 340;
|
|
227
|
+
const columns = 4;
|
|
228
|
+
const rows = Math.max(1, Math.ceil(files.length / columns));
|
|
229
|
+
const height = 260 + rows * (cell + 60) + 180;
|
|
230
|
+
const canvas = createCanvas(width, height);
|
|
231
|
+
const context = canvas.getContext("2d");
|
|
232
|
+
context.fillStyle = "#f4f1eb";
|
|
233
|
+
context.fillRect(0, 0, width, height);
|
|
234
|
+
context.fillStyle = "#141414";
|
|
235
|
+
context.font = "bold 52px sans-serif";
|
|
236
|
+
context.fillText(`${brand.name} \xB7 brand refs`, 60, 90, width - 120);
|
|
237
|
+
context.font = "22px sans-serif";
|
|
238
|
+
facts.slice(0, 3).forEach((line, i) => {
|
|
239
|
+
context.fillText(line, 60, 135 + i * 30, width - 120);
|
|
240
|
+
});
|
|
241
|
+
for (const [index, file] of files.entries()) {
|
|
242
|
+
const x = 60 + index % columns * (cell + 30);
|
|
243
|
+
const y = 240 + Math.floor(index / columns) * (cell + 60);
|
|
244
|
+
try {
|
|
245
|
+
const png = await sharp(await readFile(file.path)).png().toBuffer();
|
|
246
|
+
context.fillStyle = await artTone(png) === "light" ? "#2a2a2a" : "#e6e2d9";
|
|
247
|
+
context.fillRect(x, y, cell, cell);
|
|
248
|
+
const image = await loadImage(png);
|
|
249
|
+
const scale = Math.min((cell - 40) / image.width, (cell - 40) / image.height, 4);
|
|
250
|
+
const w = image.width * scale, h = image.height * scale;
|
|
251
|
+
context.drawImage(image, x + (cell - w) / 2, y + (cell - h) / 2, w, h);
|
|
252
|
+
} catch {
|
|
253
|
+
context.fillStyle = "#ff3b3b";
|
|
254
|
+
context.fillText("unreadable", x + 20, y + 40);
|
|
255
|
+
}
|
|
256
|
+
context.fillStyle = "#141414";
|
|
257
|
+
context.font = "20px sans-serif";
|
|
258
|
+
context.fillText(file.name, x, y + cell + 30, cell);
|
|
259
|
+
}
|
|
260
|
+
const swatches = Object.entries(brand.palette);
|
|
261
|
+
const sy = height - 140;
|
|
262
|
+
swatches.forEach(([name, color], i) => {
|
|
263
|
+
context.fillStyle = color;
|
|
264
|
+
context.fillRect(60 + i * 200, sy, 180, 70);
|
|
265
|
+
context.fillStyle = "#141414";
|
|
266
|
+
context.font = "18px sans-serif";
|
|
267
|
+
context.fillText(`${name} ${color}`, 60 + i * 200, sy + 100);
|
|
268
|
+
});
|
|
269
|
+
return new Uint8Array(canvas.toBuffer("image/png"));
|
|
270
|
+
}
|
|
271
|
+
var eventPlatforms = /(^|\.)(luma\.com|lu\.ma|eventbrite\.[a-z.]+|meetup\.com|partiful\.com)$/;
|
|
272
|
+
async function brandFromUrl(url, out, options = {}) {
|
|
273
|
+
let page;
|
|
274
|
+
try {
|
|
275
|
+
page = new URL(url);
|
|
276
|
+
} catch {
|
|
277
|
+
throw new InputError("Supply a complete https URL.");
|
|
278
|
+
}
|
|
279
|
+
if (page.protocol !== "https:") throw new InputError("Supply an https URL.");
|
|
280
|
+
const existing = options.into ? await readBrand(options.into) : void 0;
|
|
281
|
+
const target = options.into ? dirname(resolve(options.into)) : resolve(out);
|
|
282
|
+
const refs = join(target, "refs");
|
|
283
|
+
if (!existing) {
|
|
284
|
+
await mkdir(target);
|
|
285
|
+
await mkdir(refs);
|
|
286
|
+
} else await mkdir(refs, { recursive: true });
|
|
287
|
+
const platform = eventPlatforms.test(page.hostname);
|
|
288
|
+
let html = "";
|
|
289
|
+
let staticError;
|
|
290
|
+
try {
|
|
291
|
+
html = new TextDecoder().decode((await fetchBytes(page.href, 4e6)).bytes);
|
|
292
|
+
} catch (error) {
|
|
293
|
+
staticError = error;
|
|
294
|
+
}
|
|
295
|
+
let facts = pageFacts(html, page.href);
|
|
296
|
+
if (staticError || !facts.logos.length) {
|
|
297
|
+
const rendered = renderedHtml(page.href);
|
|
298
|
+
if (rendered) {
|
|
299
|
+
html = rendered;
|
|
300
|
+
facts = pageFacts(html, page.href);
|
|
301
|
+
} else if (staticError) throw staticError;
|
|
302
|
+
}
|
|
303
|
+
const saved = [];
|
|
304
|
+
const warnings = [];
|
|
305
|
+
const seen = /* @__PURE__ */ new Set();
|
|
306
|
+
const save = async (source, name2, role) => {
|
|
307
|
+
try {
|
|
308
|
+
const { bytes } = await fetchBytes(source);
|
|
309
|
+
const digest = createHash("sha256").update(bytes).digest("hex");
|
|
310
|
+
if (seen.has(digest)) return;
|
|
311
|
+
seen.add(digest);
|
|
312
|
+
const info = await imageInfo(bytes);
|
|
313
|
+
if (!info) return warnings.push(`Skipped ${source}: not a readable image.`);
|
|
314
|
+
const isSvg = info.format === "svg";
|
|
315
|
+
if (role === "logo" && !isSvg && Math.max(info.width ?? 0, info.height ?? 0) < 96)
|
|
316
|
+
return warnings.push(
|
|
317
|
+
`Skipped ${source}: ${info.width}\xD7${info.height} is too small for print.`
|
|
318
|
+
);
|
|
319
|
+
const data = isSvg ? bytes : await fitImage(bytes);
|
|
320
|
+
const path = join(refs, `${name2}.${isSvg ? "svg" : "png"}`);
|
|
321
|
+
await writeFile(path, data);
|
|
322
|
+
const fitted = isSvg ? info : await imageInfo(data);
|
|
323
|
+
saved.push({ name: name2, path, role, width: fitted?.width ?? 0, height: fitted?.height ?? 0 });
|
|
324
|
+
if (!isSvg && data.length < bytes.length)
|
|
325
|
+
warnings.push(
|
|
326
|
+
`${name2} was reduced to ${fitted?.width}\xD7${fitted?.height} to stay under 2 MB; keep the original master for print.`
|
|
327
|
+
);
|
|
328
|
+
} catch (error) {
|
|
329
|
+
warnings.push(`Skipped ${source}: ${error.message}`);
|
|
330
|
+
}
|
|
331
|
+
};
|
|
332
|
+
const prefix = existing ? slugify(page.hostname.replace(/^www\./, "").split(".")[0]) : "logo";
|
|
333
|
+
if (facts.image && !existing?.cover) await save(facts.image, "cover", "cover");
|
|
334
|
+
const logoSources = platform ? facts.logos.filter((src) => !eventPlatforms.test(new URL(src).hostname)) : [...facts.logos, ...facts.icons];
|
|
335
|
+
for (const [index, source] of logoSources.slice(0, 8).entries())
|
|
336
|
+
await save(source, `${prefix}-${index + 1}`, "logo");
|
|
337
|
+
if (platform)
|
|
338
|
+
warnings.push(
|
|
339
|
+
`${page.hostname} is an event platform; its icons are not the brand's. Add the brand's own logos with: makeables brand from-url --url <brand-site> --file ${join(target, "brand.json")}`
|
|
340
|
+
);
|
|
341
|
+
const cover = saved.find((file2) => file2.role === "cover");
|
|
342
|
+
const name = (options.name ?? facts.event ?? facts.title?.split(/\s[|·–-]\s/)[0] ?? page.hostname).trim().slice(0, 60);
|
|
343
|
+
const logos = saved.filter((file2) => file2.role === "logo").map((file2) => ({
|
|
344
|
+
id: file2.name,
|
|
345
|
+
name: file2.name.replace(/-/g, " "),
|
|
346
|
+
file: relative(target, file2.path)
|
|
347
|
+
}));
|
|
348
|
+
for (const logo of logos)
|
|
349
|
+
Object.assign(logo, { tone: await artTone(await readFile(resolve(target, logo.file))) });
|
|
350
|
+
const brand = existing ? brandDocumentSchema.parse({
|
|
351
|
+
...existing,
|
|
352
|
+
logos: [
|
|
353
|
+
...existing.logos.filter((logo) => !logos.some((entry) => entry.id === logo.id)),
|
|
354
|
+
...logos
|
|
355
|
+
].slice(0, 8)
|
|
356
|
+
}) : brandDocumentSchema.parse({
|
|
357
|
+
...newBrand(name, options.slug ?? slugify(name)),
|
|
358
|
+
description: (facts.description ?? `${name} on Makeables.`).slice(0, 400),
|
|
359
|
+
website: `${page.origin}${page.pathname}`,
|
|
360
|
+
...cover ? {
|
|
361
|
+
cover: relative(target, cover.path),
|
|
362
|
+
palette: await paletteOf(await readFile(cover.path))
|
|
363
|
+
} : {},
|
|
364
|
+
logos
|
|
365
|
+
});
|
|
366
|
+
const lines = [
|
|
367
|
+
page.href,
|
|
368
|
+
[facts.date && `Date: ${facts.date}`, facts.place && `Place: ${facts.place}`].filter(Boolean).join(" \xB7 "),
|
|
369
|
+
facts.description ?? ""
|
|
370
|
+
].filter(Boolean);
|
|
371
|
+
const proof = join(refs, "refs-proof.png");
|
|
372
|
+
const shown = [
|
|
373
|
+
...brand.cover ? [{ name: "cover", path: resolve(target, brand.cover) }] : [],
|
|
374
|
+
...brand.logos.map((logo) => ({ name: logo.id, path: resolve(target, logo.file) }))
|
|
375
|
+
];
|
|
376
|
+
await writeFile(proof, await refsProof(brand, shown, lines));
|
|
377
|
+
const file = join(target, "brand.json");
|
|
378
|
+
await writeBrand(file, brand);
|
|
379
|
+
if (!brand.cover)
|
|
380
|
+
warnings.push("No cover image found. Add one to refs/ and set brand.json cover.");
|
|
381
|
+
if (!brand.logos.length)
|
|
382
|
+
warnings.push(
|
|
383
|
+
"No logo found. Ask the user for official logo files or a brand kit page and add them to refs/."
|
|
384
|
+
);
|
|
385
|
+
return {
|
|
386
|
+
path: file,
|
|
387
|
+
proof,
|
|
388
|
+
event: { title: facts.title, date: facts.date, place: facts.place },
|
|
389
|
+
files: saved.map(({ name: name2, path, role, width, height }) => ({
|
|
390
|
+
name: name2,
|
|
391
|
+
role,
|
|
392
|
+
path,
|
|
393
|
+
width,
|
|
394
|
+
height
|
|
395
|
+
})),
|
|
396
|
+
palette: brand.palette,
|
|
397
|
+
warnings
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
function referencedImages(value, found = []) {
|
|
401
|
+
if (typeof value === "string") {
|
|
402
|
+
const match = value.match(/^data:image\/(?:png|jpeg|jpg|webp|gif|svg\+xml);base64,(.+)$/);
|
|
403
|
+
if (match) found.push(Uint8Array.from(Buffer.from(match[1], "base64")));
|
|
404
|
+
} else if (Array.isArray(value)) for (const entry of value) referencedImages(entry, found);
|
|
405
|
+
else if (value && typeof value === "object")
|
|
406
|
+
for (const entry of Object.values(value)) referencedImages(entry, found);
|
|
407
|
+
return found;
|
|
408
|
+
}
|
|
409
|
+
async function imageHash(bytes) {
|
|
410
|
+
const { data } = await sharp(bytes).flatten({ background: "#ffffff" }).resize(8, 8, { fit: "fill" }).greyscale().raw().toBuffer({ resolveWithObject: true });
|
|
411
|
+
const mean = data.reduce((sum, value) => sum + value, 0) / data.length;
|
|
412
|
+
return Array.from(data, (value) => value > mean ? "1" : "0").join("");
|
|
413
|
+
}
|
|
414
|
+
function distance(a, b) {
|
|
415
|
+
let n = 0;
|
|
416
|
+
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) n++;
|
|
417
|
+
return n;
|
|
418
|
+
}
|
|
419
|
+
async function checkKitAgainstBrand(kitFile, brandFile) {
|
|
420
|
+
const brand = await readBrand(brandFile);
|
|
421
|
+
const brandDir = dirname(resolve(brandFile));
|
|
422
|
+
const allowed = [];
|
|
423
|
+
const sources = [
|
|
424
|
+
...brand.cover ? [brand.cover] : [],
|
|
425
|
+
...brand.logos.map((logo) => logo.file),
|
|
426
|
+
...brand.plan?.generated ?? []
|
|
427
|
+
];
|
|
428
|
+
const refsDir = join(brandDir, "refs");
|
|
429
|
+
try {
|
|
430
|
+
const { readdir } = await import("fs/promises");
|
|
431
|
+
for (const entry of await readdir(refsDir))
|
|
432
|
+
if (imageFile.test(entry) && entry !== "refs-proof.png") sources.push(join("refs", entry));
|
|
433
|
+
} catch {
|
|
434
|
+
}
|
|
435
|
+
for (const source of new Set(sources)) {
|
|
436
|
+
try {
|
|
437
|
+
allowed.push({
|
|
438
|
+
file: source,
|
|
439
|
+
hash: await imageHash(await readFile(resolve(brandDir, source)))
|
|
440
|
+
});
|
|
441
|
+
} catch {
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
const kit = await readKit(kitFile);
|
|
445
|
+
const kitDir = dirname(resolve(kitFile));
|
|
446
|
+
const pieces = [];
|
|
447
|
+
for (const item of kit.items) {
|
|
448
|
+
const path = resolve(kitDir, item.file);
|
|
449
|
+
const raw = path.endsWith(".json") ? JSON.parse(await readFile(path, "utf8")) : null;
|
|
450
|
+
const images = raw ? referencedImages(raw) : [];
|
|
451
|
+
const pkg = await readSubmission(path, false);
|
|
452
|
+
const previews = /* @__PURE__ */ new Set([pkg.manifest.front, pkg.manifest.back]);
|
|
453
|
+
for (const entry of pkg.manifest.files)
|
|
454
|
+
if (entry.mime.startsWith("image/") && !previews.has(entry.path))
|
|
455
|
+
images.push(pkg.files[entry.path]);
|
|
456
|
+
const matches = [];
|
|
457
|
+
let foreign = 0;
|
|
458
|
+
for (const bytes of images) {
|
|
459
|
+
let hash;
|
|
460
|
+
try {
|
|
461
|
+
hash = await imageHash(bytes);
|
|
462
|
+
} catch {
|
|
463
|
+
continue;
|
|
464
|
+
}
|
|
465
|
+
const best = allowed.map((entry) => ({ ...entry, d: distance(entry.hash, hash) })).sort((a, b) => a.d - b.d)[0];
|
|
466
|
+
if (best && best.d <= 10) matches.push(best.file);
|
|
467
|
+
else foreign++;
|
|
468
|
+
}
|
|
469
|
+
const document = pkg.manifest.document ? new TextDecoder().decode(pkg.files[pkg.manifest.document]) : "";
|
|
470
|
+
if (/"source":"(gallery|sticker|petdex)"/.test(document)) foreign++;
|
|
471
|
+
pieces.push({ id: item.id, images: images.length, foreign, matches: [...new Set(matches)] });
|
|
472
|
+
}
|
|
473
|
+
const stickers = kit.items.length;
|
|
474
|
+
const issues = [
|
|
475
|
+
...pieces.filter((piece) => piece.foreign).map(
|
|
476
|
+
(piece) => `${piece.id}: ${piece.foreign} image(s) not from the brand refs or generated art.`
|
|
477
|
+
),
|
|
478
|
+
...brand.plan?.stickers && stickers < brand.plan.stickers ? [`The plan asks for ${brand.plan.stickers} pieces; the kit has ${stickers}.`] : [],
|
|
479
|
+
...brand.cover && !pieces.some((piece) => piece.matches.includes(brand.cover)) ? ["No piece uses the event cover."] : [],
|
|
480
|
+
...brand.logos.length && !pieces.some((piece) => piece.matches.some((m) => brand.logos.some((logo) => logo.file === m))) ? ["No piece uses a brand logo."] : []
|
|
481
|
+
];
|
|
482
|
+
return { ok: issues.length === 0, brand: brand.slug, pieces, issues };
|
|
483
|
+
}
|
|
484
|
+
async function rasterIdentity(bytes) {
|
|
485
|
+
return new Uint8Array(
|
|
486
|
+
await sharp(bytes, { density: 300 }).resize({ width: 1200, height: 1200, fit: "inside", withoutEnlargement: false }).png().toBuffer()
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
async function brandCover(brand, logo, kitCovers) {
|
|
490
|
+
const width = 1600, height = 900;
|
|
491
|
+
const canvas = createCanvas(width, height);
|
|
492
|
+
const context = canvas.getContext("2d");
|
|
493
|
+
context.fillStyle = brand.palette.paper;
|
|
494
|
+
context.fillRect(0, 0, width, height);
|
|
495
|
+
context.fillStyle = brand.palette.accent;
|
|
496
|
+
context.fillRect(0, height - 24, width, 24);
|
|
497
|
+
if (logo) {
|
|
498
|
+
const image = await loadImage(Buffer.from(logo));
|
|
499
|
+
const scale = Math.min(520 / image.width, 220 / image.height);
|
|
500
|
+
context.drawImage(image, 80, 80, image.width * scale, image.height * scale);
|
|
501
|
+
} else {
|
|
502
|
+
context.fillStyle = brand.palette.ink;
|
|
503
|
+
context.font = "bold 96px sans-serif";
|
|
504
|
+
context.fillText(brand.name, 80, 200, width - 160);
|
|
505
|
+
}
|
|
506
|
+
for (const [index, bytes] of kitCovers.slice(0, 3).entries()) {
|
|
507
|
+
const image = await loadImage(Buffer.from(bytes));
|
|
508
|
+
const box = 440;
|
|
509
|
+
const scale = Math.min(box / image.width, box / image.height);
|
|
510
|
+
context.drawImage(
|
|
511
|
+
image,
|
|
512
|
+
80 + index * (box + 40),
|
|
513
|
+
360,
|
|
514
|
+
image.width * scale,
|
|
515
|
+
image.height * scale
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
return new Uint8Array(canvas.toBuffer("image/png"));
|
|
519
|
+
}
|
|
520
|
+
async function prepareBrand(file) {
|
|
521
|
+
const brand = await readBrand(file);
|
|
522
|
+
const pending = brand.kits.filter((kit) => !kit.submission).map((kit) => kit.id);
|
|
523
|
+
if (!brand.kits.length) throw new InputError("Add a kit with makeables brand add-kit first.");
|
|
524
|
+
if (pending.length)
|
|
525
|
+
throw new InputError(
|
|
526
|
+
`Kits not submitted yet: ${pending.join(", ")}. Run makeables submit --file brand.json; it submits each kit, then the brand.`
|
|
527
|
+
);
|
|
528
|
+
const base = dirname(resolve(file));
|
|
529
|
+
const files = {};
|
|
530
|
+
const mime = {};
|
|
531
|
+
const logos = [];
|
|
532
|
+
for (const logo of brand.logos) {
|
|
533
|
+
const path = `identity/${logo.id}.png`;
|
|
534
|
+
files[path] = await rasterIdentity(await readFile(resolve(base, logo.file)));
|
|
535
|
+
mime[path] = "image/png";
|
|
536
|
+
logos.push({ ...logo, file: path, tone: logo.tone ?? await artTone(files[path]) });
|
|
537
|
+
}
|
|
538
|
+
let cover;
|
|
539
|
+
if (brand.cover) {
|
|
540
|
+
cover = "identity/cover.png";
|
|
541
|
+
files[cover] = await fitImage(await readFile(resolve(base, brand.cover)));
|
|
542
|
+
mime[cover] = "image/png";
|
|
543
|
+
}
|
|
544
|
+
const kitCovers = [];
|
|
545
|
+
for (const kit of brand.kits)
|
|
546
|
+
if (kit.file) {
|
|
547
|
+
const pkg = await readSubmission(resolve(base, kit.file));
|
|
548
|
+
kitCovers.push(pkg.files[pkg.manifest.front]);
|
|
549
|
+
}
|
|
550
|
+
const published = brandPackageDocumentSchema.parse({
|
|
551
|
+
...brand,
|
|
552
|
+
plan: void 0,
|
|
553
|
+
cover,
|
|
554
|
+
logos,
|
|
555
|
+
kits: brand.kits.map(({ file: _file, ...kit }) => kit)
|
|
556
|
+
});
|
|
557
|
+
files["brand.json"] = jsonBytes(published);
|
|
558
|
+
mime["brand.json"] = "application/json";
|
|
559
|
+
files["cover.png"] = await brandCover(brand, logos[0] && files[logos[0].file], kitCovers);
|
|
560
|
+
mime["cover.png"] = "image/png";
|
|
561
|
+
return assemblePackage(
|
|
562
|
+
{
|
|
563
|
+
product: "brand",
|
|
564
|
+
slug: brand.slug,
|
|
565
|
+
title: brand.name,
|
|
566
|
+
document: "brand.json",
|
|
567
|
+
front: "cover.png",
|
|
568
|
+
assets: {},
|
|
569
|
+
kits: published.kits.map((kit) => kit.submission)
|
|
570
|
+
},
|
|
571
|
+
files,
|
|
572
|
+
mime
|
|
573
|
+
);
|
|
574
|
+
}
|
|
575
|
+
var mimeOf = (path) => ({
|
|
576
|
+
".svg": "image/svg+xml",
|
|
577
|
+
".png": "image/png",
|
|
578
|
+
".webp": "image/webp",
|
|
579
|
+
".jpg": "image/jpeg",
|
|
580
|
+
".jpeg": "image/jpeg"
|
|
581
|
+
})[extname(path).toLowerCase()] ?? "application/octet-stream";
|
|
582
|
+
var dataUrl = (bytes, type) => `data:${type};base64,${Buffer.from(bytes).toString("base64")}`;
|
|
583
|
+
async function brandView(file) {
|
|
584
|
+
const brand = await readBrand(file);
|
|
585
|
+
const base = dirname(resolve(file));
|
|
586
|
+
const pieces = [];
|
|
587
|
+
const kits = [];
|
|
588
|
+
for (const kit of brand.kits) {
|
|
589
|
+
if (!kit.file) continue;
|
|
590
|
+
const pkg = await readSubmission(resolve(base, kit.file));
|
|
591
|
+
if (pkg.manifest.product !== "kit") continue;
|
|
592
|
+
kits.push({ id: kit.id, name: pkg.manifest.title, href: "#" });
|
|
593
|
+
for (const item of pkg.manifest.items) {
|
|
594
|
+
const front = pkg.files[`items/${item.id}/${item.manifest.front}`];
|
|
595
|
+
const back = item.manifest.back ? pkg.files[`items/${item.id}/${item.manifest.back}`] : void 0;
|
|
596
|
+
pieces.push({
|
|
597
|
+
id: `${kit.id}-${item.id}`,
|
|
598
|
+
name: item.manifest.title,
|
|
599
|
+
family: kit.family ?? kit.id,
|
|
600
|
+
product: item.manifest.product,
|
|
601
|
+
preview: dataUrl(front, "image/png"),
|
|
602
|
+
...back ? { back: dataUrl(back, "image/png") } : {}
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
const read = async (path) => path ? dataUrl(await readFile(resolve(base, path)), mimeOf(path)) : void 0;
|
|
607
|
+
return {
|
|
608
|
+
slug: brand.slug,
|
|
609
|
+
name: brand.name,
|
|
610
|
+
language: brand.language,
|
|
611
|
+
tagline: brand.tagline,
|
|
612
|
+
description: brand.description,
|
|
613
|
+
website: brand.website,
|
|
614
|
+
palette: brand.palette,
|
|
615
|
+
layout: brand.layout,
|
|
616
|
+
cover: await read(brand.cover),
|
|
617
|
+
logos: await Promise.all(
|
|
618
|
+
brand.logos.map(async (logo) => ({
|
|
619
|
+
name: logo.name,
|
|
620
|
+
src: await read(logo.file),
|
|
621
|
+
tone: logo.tone ?? await artTone(await readFile(resolve(base, logo.file)))
|
|
622
|
+
}))
|
|
623
|
+
),
|
|
624
|
+
families: brand.families.length ? brand.families : brand.kits.map((kit) => ({
|
|
625
|
+
id: kit.family ?? kit.id,
|
|
626
|
+
name: kit.id,
|
|
627
|
+
headline: "",
|
|
628
|
+
description: ""
|
|
629
|
+
})),
|
|
630
|
+
kits,
|
|
631
|
+
pieces
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
async function submitBrand(options) {
|
|
635
|
+
const brand = await readBrand(options.file);
|
|
636
|
+
const base = dirname(resolve(options.file));
|
|
637
|
+
const pending = brand.kits.filter((kit) => !kit.submission && kit.file);
|
|
638
|
+
if (options.dryRun || options.status) {
|
|
639
|
+
const kits2 = [];
|
|
640
|
+
for (const kit of brand.kits) {
|
|
641
|
+
if (kit.submission) {
|
|
642
|
+
kits2.push({ id: kit.id, submission: kit.submission });
|
|
643
|
+
continue;
|
|
644
|
+
}
|
|
645
|
+
const result2 = await submitDesign({
|
|
646
|
+
...options,
|
|
647
|
+
file: resolve(base, kit.file),
|
|
648
|
+
title: void 0
|
|
649
|
+
});
|
|
650
|
+
kits2.push({ id: kit.id, ...result2 });
|
|
651
|
+
}
|
|
652
|
+
return {
|
|
653
|
+
product: "brand",
|
|
654
|
+
slug: brand.slug,
|
|
655
|
+
title: brand.name,
|
|
656
|
+
dryRun: Boolean(options.dryRun),
|
|
657
|
+
kits: kits2,
|
|
658
|
+
shares: [
|
|
659
|
+
"brand page copy, palette and layout",
|
|
660
|
+
`${brand.logos.length} logos`,
|
|
661
|
+
...brand.cover ? ["cover"] : []
|
|
662
|
+
],
|
|
663
|
+
review: "Each kit and the brand are reviewed by a Makeables admin before /brands/<slug> is public."
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
const kits = [];
|
|
667
|
+
for (const kit of pending) {
|
|
668
|
+
const result2 = await submitDesign({
|
|
669
|
+
...options,
|
|
670
|
+
file: resolve(base, kit.file),
|
|
671
|
+
title: void 0
|
|
672
|
+
});
|
|
673
|
+
const id = "receipt" in result2 && result2.receipt ? result2.receipt.id : void 0;
|
|
674
|
+
if (!id) throw new InputError(`Kit ${kit.id} did not return a submission.`);
|
|
675
|
+
kit.submission = id;
|
|
676
|
+
await writeBrand(options.file, brand);
|
|
677
|
+
kits.push({ id: kit.id, submission: id, state: result2.state });
|
|
678
|
+
}
|
|
679
|
+
const pkg = await prepareBrand(options.file);
|
|
680
|
+
const result = await submitDesign({ ...options, pkg, title: options.title ?? brand.name });
|
|
681
|
+
return { ...result, slug: brand.slug, kits };
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
export {
|
|
685
|
+
readBrand,
|
|
686
|
+
writeBrand,
|
|
687
|
+
slugify,
|
|
688
|
+
newBrand,
|
|
689
|
+
addBrandKit,
|
|
690
|
+
artTone,
|
|
691
|
+
addBrandLogo,
|
|
692
|
+
paletteOf,
|
|
693
|
+
pageFacts,
|
|
694
|
+
refsProof,
|
|
695
|
+
brandFromUrl,
|
|
696
|
+
checkKitAgainstBrand,
|
|
697
|
+
prepareBrand,
|
|
698
|
+
brandView,
|
|
699
|
+
submitBrand
|
|
700
|
+
};
|