feat(webhook): restore Themelings canvas webhook (raw/semantic) + diffs + canvas

This commit is contained in:
lvwmwm 2026-08-28 14:06:06 -03:00
parent ab91206542
commit c140a8bbae
24 changed files with 860 additions and 45 deletions

View file

@ -5,12 +5,14 @@
"": { "": {
"name": "themelings", "name": "themelings",
"dependencies": { "dependencies": {
"color": "^5.0.2", "@napi-rs/canvas": "^0.1.97",
"color": "^5.0.3",
"discord-api-types": "^0.38.46", "discord-api-types": "^0.38.46",
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^2.4.11", "@biomejs/biome": "^2.4.11",
"@types/bun": "^1.3.12", "@types/bun": "^1.3.12",
"@types/color": "^4.2.1",
}, },
"peerDependencies": { "peerDependencies": {
"typescript": "^6.0.2", "typescript": "^6.0.2",
@ -39,8 +41,38 @@
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.11", "", { "os": "win32", "cpu": "x64" }, "sha512-A8D3JM/00C2KQgUV3oj8Ba15EHEYwebAGCy5Sf9GAjr5Y3+kJIYOiESoqRDeuRZueuMdCsbLZIUqmPhpYXJE9A=="], "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.11", "", { "os": "win32", "cpu": "x64" }, "sha512-A8D3JM/00C2KQgUV3oj8Ba15EHEYwebAGCy5Sf9GAjr5Y3+kJIYOiESoqRDeuRZueuMdCsbLZIUqmPhpYXJE9A=="],
"@napi-rs/canvas": ["@napi-rs/canvas@0.1.100", "", { "optionalDependencies": { "@napi-rs/canvas-android-arm64": "0.1.100", "@napi-rs/canvas-darwin-arm64": "0.1.100", "@napi-rs/canvas-darwin-x64": "0.1.100", "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.100", "@napi-rs/canvas-linux-arm64-gnu": "0.1.100", "@napi-rs/canvas-linux-arm64-musl": "0.1.100", "@napi-rs/canvas-linux-riscv64-gnu": "0.1.100", "@napi-rs/canvas-linux-x64-gnu": "0.1.100", "@napi-rs/canvas-linux-x64-musl": "0.1.100", "@napi-rs/canvas-win32-arm64-msvc": "0.1.100", "@napi-rs/canvas-win32-x64-msvc": "0.1.100" } }, "sha512-xglYA6q3XO5P3BNJYxVZ1IV7DLVjp1Py6nwag88YntrS+3vKHyYcMqXVS4ZztJmwz2uGvz1FWhI/4LgbR5uQDA=="],
"@napi-rs/canvas-android-arm64": ["@napi-rs/canvas-android-arm64@0.1.100", "", { "os": "android", "cpu": "arm64" }, "sha512-hjhCKhntPv9+t4ckHymdx0phYNcVW+GKQR6Lzw2zE+pOVjOplSmtx9nNNknTjbEDLcuLZqA1y8ufKg1XfgftzQ=="],
"@napi-rs/canvas-darwin-arm64": ["@napi-rs/canvas-darwin-arm64@0.1.100", "", { "os": "darwin", "cpu": "arm64" }, "sha512-2PcswRaC7Ly645DGt88///zuFDhJxJYdKAs1uU3mfk1atYkXufgcgLfBpk6Tm12nCQBaNt1wpybuPZ4qOhTo8A=="],
"@napi-rs/canvas-darwin-x64": ["@napi-rs/canvas-darwin-x64@0.1.100", "", { "os": "darwin", "cpu": "x64" }, "sha512-ePNZtj7pNIva/siZMg+HmbeozkIjqUIYdoymH8HaA3qK7LfzFN4WMBM8G6HQ9ZC+H3+Dnn5pqtiXpgLykaPOhw=="],
"@napi-rs/canvas-linux-arm-gnueabihf": ["@napi-rs/canvas-linux-arm-gnueabihf@0.1.100", "", { "os": "linux", "cpu": "arm" }, "sha512-d5cDB48oWFGU8/XPhUOFAlySgb/VAu7D+s8fi55K1Pcfg8aPplHWqMgibhVLU8ky7Pyg/fuiVLz4Nf3JrSTuUA=="],
"@napi-rs/canvas-linux-arm64-gnu": ["@napi-rs/canvas-linux-arm64-gnu@0.1.100", "", { "os": "linux", "cpu": "arm64" }, "sha512-rDxgxRu69RvDlX/bh9o22DxLsGr8EqsNgotL9+RwQE1S0b0cqeatqsw6aW45mukm0B42DIAaAacKaYQ8cqS1nw=="],
"@napi-rs/canvas-linux-arm64-musl": ["@napi-rs/canvas-linux-arm64-musl@0.1.100", "", { "os": "linux", "cpu": "arm64" }, "sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ=="],
"@napi-rs/canvas-linux-riscv64-gnu": ["@napi-rs/canvas-linux-riscv64-gnu@0.1.100", "", { "os": "linux", "cpu": "none" }, "sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw=="],
"@napi-rs/canvas-linux-x64-gnu": ["@napi-rs/canvas-linux-x64-gnu@0.1.100", "", { "os": "linux", "cpu": "x64" }, "sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg=="],
"@napi-rs/canvas-linux-x64-musl": ["@napi-rs/canvas-linux-x64-musl@0.1.100", "", { "os": "linux", "cpu": "x64" }, "sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA=="],
"@napi-rs/canvas-win32-arm64-msvc": ["@napi-rs/canvas-win32-arm64-msvc@0.1.100", "", { "os": "win32", "cpu": "arm64" }, "sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw=="],
"@napi-rs/canvas-win32-x64-msvc": ["@napi-rs/canvas-win32-x64-msvc@0.1.100", "", { "os": "win32", "cpu": "x64" }, "sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA=="],
"@types/bun": ["@types/bun@1.3.12", "", { "dependencies": { "bun-types": "1.3.12" } }, "sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A=="], "@types/bun": ["@types/bun@1.3.12", "", { "dependencies": { "bun-types": "1.3.12" } }, "sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A=="],
"@types/color": ["@types/color@4.2.1", "", { "dependencies": { "@types/color-convert": "2" } }, "sha512-ResWeDLy1vozIMbD6JLRKuNBbIcIlBkjTIxVHHd5Cqtm77T+ahH3BWE/PWv1OhFd1HAwcn8no4ig2uTaRXpYQQ=="],
"@types/color-convert": ["@types/color-convert@2.0.4", "", { "dependencies": { "@types/color-name": "^1.1.0" } }, "sha512-Ub1MmDdyZ7mX//g25uBAoH/mWGd9swVbt8BseymnaE18SU4po/PjmCrHxqIIRjBo3hV/vh1KGr0eMxUhp+t+dQ=="],
"@types/color-name": ["@types/color-name@1.1.5", "", {}, "sha512-j2K5UJqGTxeesj6oQuGpMgifpT5k9HprgQd8D1Y0lOFqKHl3PJu5GMeS4Y5EgjS55AE6OQxf8mPED9uaGbf4Cg=="],
"@types/node": ["@types/node@20.12.14", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-scnD59RpYD91xngrQQLGkE+6UrHUPzeKZWhhjBSa3HSkwjbQc38+q3RoIVEwxQGRw3M+j5hpNAM+lgV3cVormg=="], "@types/node": ["@types/node@20.12.14", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-scnD59RpYD91xngrQQLGkE+6UrHUPzeKZWhhjBSa3HSkwjbQc38+q3RoIVEwxQGRw3M+j5hpNAM+lgV3cVormg=="],
"bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="], "bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="],

View file

@ -10,12 +10,14 @@
"format": "biome check --fix ." "format": "biome check --fix ."
}, },
"dependencies": { "dependencies": {
"color": "^5.0.2", "@napi-rs/canvas": "^0.1.97",
"color": "^5.0.3",
"discord-api-types": "^0.38.46" "discord-api-types": "^0.38.46"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^2.4.11", "@biomejs/biome": "^2.4.11",
"@types/bun": "^1.3.12" "@types/bun": "^1.3.12",
"@types/color": "^4.2.1"
}, },
"peerDependencies": { "peerDependencies": {
"typescript": "^6.0.2" "typescript": "^6.0.2"

97
src/canvas/factory.ts Normal file
View file

@ -0,0 +1,97 @@
import { join } from "node:path";
import { type Image, loadImage } from "@napi-rs/canvas";
import { type Diff, DiffType } from "../types";
export type Item =
| {
type: "color";
colors: string[][];
}
| {
type: "image";
image: Image;
}
| {
type: "blob";
blob: string;
};
export interface Row {
title?: string;
subtitle?: string;
item: Item;
}
export interface Section {
header: string;
columns: Row[][];
}
function placeholderFile(file: string) {
return file.endsWith(".lottie") ? join(import.meta.dir, "placeholders/lottie.png") : file;
}
const numColumns = 5;
const maxRows = 3;
async function makeSection(diffs: Map<string, Diff>, isFile: boolean, type: DiffType, header: string) {
const filtered = [...diffs.entries()].filter(([, diff]) => diff.type === type);
const section: Section = {
header,
columns: Array.from({ length: numColumns }, () => []),
};
let i = 0;
for (const [name, diff] of filtered) {
const columnInd = i++ % numColumns;
const column = section.columns[columnInd],
nextColumn = section.columns[i % numColumns];
if (!column) throw new Error(`Couldn't find column of index ${columnInd}`);
if (!nextColumn || nextColumn.length >= maxRows) {
column.push({
item: {
type: "blob",
blob: `+${(filtered.length - i + 1).toLocaleString("en-US")} ${header.toLowerCase()}`,
},
});
break;
}
const changed = diff.type === DiffType.Changed;
const item: Item = isFile
? { type: "image", image: await loadImage(placeholderFile(diff.source)) }
: { type: "color", colors: [changed && diff.oldSource.split(","), diff.source.split(",")].filter((x) => !!x) };
column.push(
...[
changed &&
isFile && {
subtitle: name,
item: {
type: "image",
image: await loadImage(placeholderFile(diff.oldSource)),
} as Item,
},
{
title: name,
subtitle: diff.type === DiffType.Renamed ? diff.oldName : undefined,
item,
},
].filter((x) => !!x),
);
}
section.columns = section.columns.filter((x) => x.length);
return section;
}
export async function makeSections(diffs: Map<string, Diff>, isFile = false) {
return [
await makeSection(diffs, isFile, DiffType.Added, "Added"),
await makeSection(diffs, isFile, DiffType.Changed, "Changed"),
await makeSection(diffs, isFile, DiffType.Renamed, "Renamed"),
await makeSection(diffs, isFile, DiffType.Removed, "Removed"),
].filter((x) => x.columns[0]);
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

12
src/canvas/index.test.ts Normal file
View file

@ -0,0 +1,12 @@
import mock from "../mock";
import { drawSections } from ".";
import { makeSections } from "./factory";
await Bun.write(
"temp/files.png",
new Uint8Array(drawSections(await makeSections(mock.icons, true)).toBuffer("image/png")),
);
await Bun.write(
"temp/colors.png",
new Uint8Array(drawSections(await makeSections(mock.semantic)).toBuffer("image/png")),
);

232
src/canvas/index.ts Normal file
View file

@ -0,0 +1,232 @@
import { readdir } from "node:fs/promises";
import { join } from "node:path";
import { createCanvas, GlobalFonts } from "@napi-rs/canvas";
import { file } from "bun";
import type { Row, Section } from "./factory";
import { measureText, transformSvg, wrapText } from "./utils";
async function bulkRegister(family: string, path: string) {
for (const item of await readdir(path)) GlobalFonts.registerFromPath(join(path, item), family);
}
await bulkRegister("GG Sans", join(import.meta.dir, "fonts/ggsans"));
await bulkRegister("GG Mono", join(import.meta.dir, "fonts/ggmono"));
function calcColumnWidth(column: { textWidth: number; itemWidth: number }[]) {
return Math.max(0, ...column.map(({ textWidth, itemWidth }) => Math.max(0, textWidth, itemWidth)));
}
function calcColumnHeight(column: Row[]) {
const texts = column
.map((row) => Number(!!row.title) + Number(!!row.subtitle) + Number(row.item.type === "blob"))
.reduce((a, b) => a + b, 0);
const items = texts + column.length - 1;
return texts * textSizes.title + column.length * itemSize + items * layout.card.gapY;
}
function calcSectionHeight(columns: Row[][]) {
return Math.max(0, ...columns.map((column) => calcColumnHeight(column)));
}
const layout = {
background: {
padding: 24,
gap: 18,
},
section: {
gap: 8,
},
card: {
radius: 24,
padding: 12,
gapX: 16,
gapY: 8,
},
blob: {
radius: 24 - 12,
},
color: {
radius: 24,
},
};
const textSizes = {
header: 24,
title: 18,
blob: 24,
};
const colors = {
background: "#1c1d23", // BACKGROUND_BASE_LOWEST
section: "#26272f", // CARD_BACKGROUND_DEFAULT
blob: "#6c6f7c1f", // BACKGROUND_MOD_MUTED
textNormal: "#c7c8ce", // TEXT_NORMAL
textMuted: "#818491", // TEXT_MUTED
watermark: "#2f3035", // TEXT_OVERLAY_DARK
};
const itemSize = 128;
const blobSize = itemSize + layout.card.gapY + textSizes.title;
const watermark = await transformSvg(await file(join(import.meta.dir, "placeholders/watermark.svg")).text(), {
color: colors.watermark,
height: layout.background.padding,
});
const mctx = createCanvas(1, 1).getContext("2d");
export function drawSections(sections: Section[]) {
const headerFont = `600 ${textSizes.header}px GG Sans`;
const titleFont = `400 ${textSizes.title}px GG Mono`;
const sects = sections.map(({ header, columns }) => ({
headerWidth: measureText(mctx, headerFont, header).width,
header,
columns: columns.map((column) =>
column.map(({ title, subtitle, item }) => ({
title,
subtitle,
textWidth: Math.max(
0,
title ? measureText(mctx, titleFont, title).width : 0,
subtitle ? measureText(mctx, titleFont, subtitle).width : 0,
),
item,
itemWidth:
item.type === "blob"
? blobSize
: item.type === "image"
? (item.image.width / item.image.height) * itemSize
: itemSize,
})),
),
}));
const columnWidths = Array.from({ length: Math.max(0, ...sects.map(({ columns }) => columns.length)) }, (_, i) =>
Math.max(0, ...sects.map(({ columns }) => (columns[i] ? calcColumnWidth(columns[i]) : 0))),
);
const sectionWidth =
columnWidths.reduce((a, b) => a + b, 0) + layout.card.gapX * (columnWidths.length - 1) + layout.card.padding * 2;
const width = Math.max(0, ...sects.map(({ headerWidth }) => headerWidth), sectionWidth);
const height =
sects
.map(
({ columns }) =>
textSizes.header +
layout.section.gap +
layout.card.padding * 2 +
calcSectionHeight(columns) +
layout.background.gap,
)
.reduce((a, b) => a + b, 0) - layout.background.gap;
const canvas = createCanvas(layout.background.padding * 2 + width, layout.background.padding * 2 + height);
const ctx = canvas.getContext("2d");
ctx.textBaseline = "top";
ctx.fillStyle = colors.background;
ctx.fillRect(0, 0, canvas.width, canvas.height);
let x = layout.background.padding,
y = layout.background.padding;
for (const sect of sects) {
ctx.fillStyle = colors.textNormal;
ctx.font = headerFont;
ctx.fillText(sect.header, x, y);
y += textSizes.header + layout.section.gap;
const sectionHeight = calcSectionHeight(sect.columns);
ctx.fillStyle = colors.section;
ctx.beginPath();
ctx.roundRect(x, y, sectionWidth, sectionHeight + layout.card.padding * 2, layout.card.radius);
ctx.fill();
y += layout.card.padding;
x += layout.card.padding;
let wx = x;
for (let i = 0; i < sect.columns.length; i++) {
let wy = y;
const column = sect.columns[i];
const columnWidth = columnWidths[i];
if (!columnWidth) throw new Error(`Failed to get columnWidth for ${i}, ${columnWidth}`);
const centerX = wx + columnWidth / 2;
for (const row of column) {
ctx.font = titleFont;
if (row.subtitle) {
ctx.fillStyle = colors.textMuted;
ctx.fillText(row.subtitle, centerX - row.textWidth / 2, wy);
wy += textSizes.title + layout.card.gapY;
}
if (row.title) {
ctx.fillStyle = colors.textNormal;
ctx.fillText(row.title, centerX - row.textWidth / 2, wy);
wy += textSizes.title + layout.card.gapY;
}
const midX = centerX - row.itemWidth / 2;
if (row.item.type === "blob") {
const blobHeight = sectionHeight - wy + y;
ctx.fillStyle = colors.blob;
ctx.beginPath();
ctx.roundRect(wx, wy, columnWidth, blobHeight, layout.blob.radius);
ctx.fill();
ctx.font = `600 ${textSizes.blob}px GG Sans`;
ctx.fillStyle = colors.textNormal;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
const wrapped = wrapText(ctx, row.item.blob, columnWidth);
const height = textSizes.blob / 2;
for (let i = 0; i < wrapped.length; i++)
ctx.fillText(
wrapped[i],
wx + columnWidth / 2,
wy + blobHeight / 2 - height * (wrapped.length - 1) + textSizes.blob * i,
);
ctx.textAlign = "left";
ctx.textBaseline = "top";
} else if (row.item.type === "image") {
ctx.drawImage(row.item.image, midX, wy, row.itemWidth, itemSize);
wy += itemSize + layout.card.gapY;
} else if (row.item.type === "color") {
for (let i = 0; i < row.item.colors.length; i++) {
const colors = row.item.colors[i];
for (let j = 0; j < colors.length; j++) {
const color = colors[j];
ctx.save();
ctx.beginPath();
ctx.rect(
Math.floor(midX + (itemSize / colors.length) * j),
Math.floor(wy + (itemSize / row.item.colors.length) * i),
Math.ceil(itemSize / colors.length),
Math.ceil(itemSize / row.item.colors.length),
);
ctx.clip();
ctx.beginPath();
ctx.fillStyle = color;
ctx.roundRect(midX, wy, itemSize, itemSize, layout.color.radius);
ctx.fill();
ctx.restore();
}
}
wy += itemSize + layout.card.gapY;
}
}
wx += columnWidth + layout.card.gapX;
}
y += sectionHeight;
x -= layout.card.padding;
y += layout.card.padding;
y += layout.background.gap;
}
// watermark
ctx.drawImage(watermark, canvas.width - watermark.width - 1, 1);
return canvas;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 888 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3 KiB

View file

@ -0,0 +1,19 @@
<svg width="60" height="60" viewBox="0 0 60 60" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_502_7157)">
<path d="M40.5906 40.9053H37.4034C35.5643 40.8801 33.8102 40.1266 32.5258 38.8101C31.2415 37.4935 30.5316 35.7213 30.552 33.8821V30.5995C30.547 30.2361 30.6136 29.8753 30.748 29.5376C30.8824 29.1999 31.082 28.892 31.3355 28.6315C31.5889 28.371 31.8912 28.163 32.225 28.0193C32.5588 27.8756 32.9177 27.7991 33.2811 27.7941H48.3199V23.8817H21.6013C19.1775 29.3399 16.6965 35.0081 15.8759 37.241C15.3658 38.5335 14.4808 39.6439 13.3347 40.4295C12.1886 41.2151 10.8338 41.64 9.44434 41.6496V43.997C11.5705 46.0661 14.4168 47.2293 17.3836 47.2414H41.3921C43.2279 47.2364 44.9871 46.5049 46.2852 45.2067C47.5833 43.9086 48.3148 42.1495 48.3199 40.3136V39.8174C47.2437 40.5272 45.9829 40.9055 44.6938 40.9053H40.5906ZM24.5022 35.5616C24.0309 35.5613 23.5705 35.4204 23.1798 35.1568C22.7892 34.8932 22.4861 34.519 22.3095 34.0822C22.1328 33.6453 22.0906 33.1656 22.1883 32.7046C22.286 32.2436 22.519 31.8222 22.8577 31.4945C23.1963 31.1668 23.6251 30.9476 24.0891 30.8651C24.553 30.7826 25.0311 30.8405 25.4619 31.0314C25.8928 31.2222 26.2569 31.5374 26.5075 31.9365C26.7581 32.3356 26.8839 32.8004 26.8687 33.2714C26.8488 33.8857 26.5908 34.4682 26.1491 34.8956C25.7074 35.3231 25.1168 35.5619 24.5022 35.5616Z" fill="#AABFF2"/>
<path d="M24.5022 30.8281C23.8746 30.8281 23.2727 31.0775 22.8289 31.5213C22.3851 31.9651 22.1357 32.567 22.1357 33.1946C22.1357 33.8223 22.3851 34.4242 22.8289 34.868C23.2727 35.3118 23.8746 35.5611 24.5022 35.5611C25.1299 35.5611 25.7318 35.3118 26.1756 34.868C26.6194 34.4242 26.8687 33.8223 26.8687 33.1946C26.8687 32.567 26.6194 31.9651 26.1756 31.5213C25.7318 31.0775 25.1299 30.8281 24.5022 30.8281Z" fill="black"/>
<path d="M48.7969 27.7939H33.2811C32.9176 27.7989 32.5588 27.8754 32.2249 28.0191C31.8911 28.1628 31.5888 28.3709 31.3354 28.6314C31.082 28.8919 30.8823 29.1998 30.7479 29.5375C30.6135 29.8751 30.5469 30.236 30.5519 30.5994V33.882C30.5316 35.7211 31.2414 37.4933 32.5258 38.8099C33.8101 40.1265 35.5642 40.88 37.4033 40.9051H44.6937C45.9828 40.9053 47.2437 40.5271 48.3198 39.8173C48.3874 39.7863 48.4514 39.7479 48.5106 39.7028C49.4379 39.0611 50.1975 38.2062 50.7255 37.2098C51.2536 36.2135 51.5346 35.105 51.5451 33.9774V30.5994C51.5502 29.8637 51.2641 29.1558 50.7493 28.6302C50.2344 28.1046 49.5326 27.804 48.7969 27.7939ZM42.0028 37.8516H38.9301C38.766 37.8727 38.5993 37.8586 38.4411 37.8103C38.2829 37.7619 38.1368 37.6804 38.0126 37.5712C37.8883 37.4619 37.7888 37.3274 37.7206 37.1767C37.6525 37.0259 37.6172 36.8624 37.6172 36.697C37.6172 36.5315 37.6525 36.368 37.7206 36.2172C37.7888 36.0665 37.8883 35.932 38.0126 35.8228C38.1368 35.7135 38.2829 35.632 38.4411 35.5836C38.5993 35.5353 38.766 35.5212 38.9301 35.5423H42.0028C42.1668 35.5212 42.3336 35.5353 42.4918 35.5836C42.65 35.632 42.7961 35.7135 42.9203 35.8228C43.0445 35.932 43.1441 36.0665 43.2122 36.2172C43.2804 36.368 43.3157 36.5315 43.3157 36.697C43.3157 36.8624 43.2804 37.0259 43.2122 37.1767C43.1441 37.3274 43.0445 37.4619 42.9203 37.5712C42.7961 37.6804 42.65 37.7619 42.4918 37.8103C42.3336 37.8586 42.1668 37.8727 42.0028 37.8516ZM47.4419 37.8516H46.2014C46.0373 37.8727 45.8706 37.8586 45.7124 37.8103C45.5542 37.7619 45.4081 37.6804 45.2838 37.5712C45.1596 37.4619 45.0601 37.3274 44.9919 37.1767C44.9237 37.0259 44.8885 36.8624 44.8885 36.697C44.8885 36.5315 44.9237 36.368 44.9919 36.2172C45.0601 36.0665 45.1596 35.932 45.2838 35.8228C45.4081 35.7135 45.5542 35.632 45.7124 35.5836C45.8706 35.5353 46.0373 35.5212 46.2014 35.5423H47.3656C47.6462 35.5785 47.9041 35.7157 48.091 35.9282C48.2779 36.1407 48.381 36.414 48.381 36.697C48.381 36.98 48.2779 37.2533 48.091 37.4658C47.9041 37.6783 47.6462 37.8154 47.3656 37.8516H47.4419Z" fill="#CBDAF7"/>
<path d="M42.0025 35.5426H38.9299C38.7658 35.5215 38.5991 35.5356 38.4409 35.5839C38.2826 35.6323 38.1365 35.7138 38.0123 35.823C37.8881 35.9323 37.7886 36.0668 37.7204 36.2175C37.6522 36.3683 37.6169 36.5318 37.6169 36.6972C37.6169 36.8627 37.6522 37.0262 37.7204 37.177C37.7886 37.3277 37.8881 37.4622 38.0123 37.5715C38.1365 37.6807 38.2826 37.7622 38.4409 37.8106C38.5991 37.8589 38.7658 37.873 38.9299 37.8519H42.0025C42.1666 37.873 42.3333 37.8589 42.4915 37.8106C42.6497 37.7622 42.7958 37.6807 42.9201 37.5715C43.0443 37.4622 43.1438 37.3277 43.212 37.177C43.2802 37.0262 43.3154 36.8627 43.3154 36.6972C43.3154 36.5318 43.2802 36.3683 43.212 36.2175C43.1438 36.0668 43.0443 35.9323 42.9201 35.823C42.7958 35.7138 42.6497 35.6323 42.4915 35.5839C42.3333 35.5356 42.1666 35.5215 42.0025 35.5426ZM47.3653 35.5426H46.2011C46.0371 35.5215 45.8703 35.5356 45.7121 35.5839C45.5539 35.6323 45.4078 35.7138 45.2836 35.823C45.1594 35.9323 45.0598 36.0668 44.9917 36.2175C44.9235 36.3683 44.8882 36.5318 44.8882 36.6972C44.8882 36.8627 44.9235 37.0262 44.9917 37.177C45.0598 37.3277 45.1594 37.4622 45.2836 37.5715C45.4078 37.6807 45.5539 37.7622 45.7121 37.8106C45.8703 37.8589 46.0371 37.873 46.2011 37.8519H47.3653C47.646 37.8157 47.9039 37.6786 48.0908 37.4661C48.2776 37.2535 48.3807 36.9802 48.3807 36.6972C48.3807 36.4143 48.2776 36.141 48.0908 35.9284C47.9039 35.7159 47.646 35.5788 47.3653 35.5426Z" fill="#AABFF2"/>
<path d="M4.27186 39.703C3.98205 39.5692 3.70151 39.4162 3.43213 39.245V50.276L6.39026 52.3562L9.34839 50.276V41.5924C7.47436 41.65 5.65222 40.9718 4.27186 39.703Z" fill="#EF8843"/>
<path d="M13.9298 35.9623C13.567 36.8635 13.0245 37.6814 12.3354 38.3663C11.6463 39.0511 10.825 39.5884 9.92146 39.9455C9.01795 40.3026 8.05119 40.4721 7.08009 40.4435C6.10899 40.4149 5.15388 40.1889 4.27295 39.7792C5.65032 41.0549 7.4728 41.7401 9.34948 41.6877C10.7389 41.6781 12.0938 41.2532 13.2399 40.4676C14.386 39.682 15.2709 38.5716 15.781 37.2791C16.6398 35.1034 19.1209 29.4353 21.5064 23.9198H19.1018C19.1018 23.9198 15.781 31.2483 13.9298 35.9623Z" fill="#D0D9EA"/>
<path d="M27.6132 10.3699L29.2354 10.5035L27.6132 10.3699ZM21.6206 10.5798C22.8051 10.4405 23.9968 10.3704 25.1895 10.3699C23.9968 10.3704 22.8051 10.4405 21.6206 10.5798ZM41.6787 18.5763C43.9769 18.6256 46.2678 18.8553 48.5301 19.2633C47.0224 17.4885 41.2397 11.6295 29.2354 10.5798C37.919 12.66 41.1252 17.603 41.6787 18.5763ZM13.9295 35.9625C15.7807 31.2485 19.1014 23.8819 19.1014 23.8819C19.1014 23.8819 24.9795 19.4351 37.0602 18.6717C32.8615 12.9463 25.9338 11.0378 21.6397 10.5798C6.90632 12.1638 0.0167358 23.3475 0.0167358 32.2791C-0.0724653 33.6375 0.196029 34.9956 0.795442 36.2179C1.39486 37.4401 2.30424 38.4839 3.4329 39.245C4.31641 39.8074 5.31205 40.1701 6.35026 40.3078C7.38847 40.4455 8.44423 40.3549 9.4438 40.0423C10.4434 39.7297 11.3626 39.2027 12.1374 38.498C12.9122 37.7933 13.5238 36.928 13.9295 35.9625ZM1.48626 30.5424C1.55559 29.1182 2.17949 27.7777 3.2245 26.8076C4.26951 25.8375 5.65266 25.3148 7.07808 25.3514C8.47156 25.6583 9.68862 26.5006 10.4669 27.6965C11.2451 28.8925 11.5223 30.3464 11.2385 31.7448C11.179 33.1555 10.5714 34.4873 9.54484 35.4568C8.51831 36.4263 7.15397 36.9569 5.74215 36.9358C5.04224 36.7968 4.37667 36.5209 3.78364 36.124C3.1906 35.7271 2.68178 35.217 2.28636 34.623C1.89095 34.029 1.61672 33.3627 1.47942 32.6625C1.34211 31.9622 1.34444 31.2418 1.48626 30.5424Z" fill="#EBEFF7"/>
<path d="M37.0222 18.6716C37.9001 18.6716 38.7971 18.6716 39.7322 18.5571H41.6407C41.0873 17.5838 37.8238 12.6408 29.1975 10.5606L27.6134 10.3698H25.1897C23.997 10.3703 22.8054 10.4404 21.6208 10.5797C25.8958 11.095 32.8236 12.8508 37.0222 18.6716Z" fill="#FC964B"/>
<path d="M39.732 18.5568C38.7969 18.5568 37.8236 18.5568 37.022 18.6713C24.9414 19.4347 19.1015 23.8814 19.1015 23.8814H60C56.6153 21.4338 52.6848 19.8485 48.5492 19.2629C46.2869 18.8548 43.996 18.6251 41.6978 18.5758L39.732 18.5568ZM5.74218 36.9353C7.15401 36.9565 8.51835 36.4258 9.54488 35.4563C10.5714 34.4868 11.1791 33.155 11.2386 31.7443C11.5223 30.3459 11.2452 28.892 10.4669 27.6961C9.68865 26.5001 8.47159 25.6579 7.07811 25.3509C5.65269 25.3143 4.26954 25.837 3.22453 26.8071C2.17952 27.7772 1.55562 29.1178 1.48629 30.542C1.34447 31.2413 1.34215 31.9618 1.47945 32.662C1.61675 33.3623 1.89098 34.0285 2.2864 34.6226C2.68182 35.2166 3.19064 35.7267 3.78367 36.1235C4.37671 36.5204 5.04227 36.7963 5.74218 36.9353ZM2.38328 30.7519C2.40525 30.2738 2.52164 29.8049 2.72573 29.372C2.92982 28.9392 3.21757 28.551 3.5724 28.2299C3.92722 27.9087 4.3421 27.661 4.79311 27.501C5.24413 27.341 5.72235 27.2719 6.20022 27.2976C7.10905 27.5245 7.89481 28.0943 8.39288 28.8876C8.89095 29.681 9.06267 30.6363 8.87208 31.5534C8.82746 32.5001 8.41204 33.3912 7.71563 34.0341C7.01922 34.6769 6.09779 35.0198 5.15056 34.9887C4.22557 34.787 3.41698 34.2296 2.89927 33.437C2.38156 32.6444 2.19623 31.68 2.38328 30.7519Z" fill="#647096"/>
<path d="M5.1509 34.9891C6.09813 35.0203 7.01956 34.6774 7.71597 34.0345C8.41237 33.3917 8.82779 32.5006 8.87241 31.5539C9.05991 30.6235 8.87512 29.6566 8.35774 28.8609C7.84035 28.0652 7.03161 27.5041 6.10513 27.298C5.15609 27.272 4.23469 27.6192 3.53884 28.2651C2.843 28.9109 2.42823 29.804 2.38361 30.7523C2.19657 31.6804 2.38189 32.6448 2.89961 33.4374C3.41732 34.2301 4.22591 34.7874 5.1509 34.9891Z" fill="#FC964B"/>
</g>
<defs>
<clipPath id="clip0_502_7157">
<rect width="60" height="60" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 687 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View file

@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="257" height="48" fill="none">
<path fill="#fff" d="M23.8 48q-4.9 0-9.2-1.9A25 25 0 0 1 1.9 33.3Q-.1 29 0 24A23.5 23.5 0 0 1 14.8 1.9q4.4-2 9.5-1.9a25 25 0 0 1 16.6 6.2q3.3 3 5.2 6.8 2 4 1.9 8.6 0 7-4.3 10.7a15 15 0 0 1-10.3 3.6H30q-.7 0-1.2.6t-.5 1.1.6 1.6q.7.8.7 2.4 0 2-1.8 4.3-2 2.1-4 2.1m-13-21.8q1.3 0 2.4-1t1-2.5-1-2.5-2.5-1-2.4 1-1 2.5q0 1.5 1 2.5t2.4 1m7-9.6q1.5 0 2.5-1 1-1.2 1-2.5 0-1.5-1-2.5-1.1-1-2.5-1t-2.4 1-1 2.5 1 2.4 2.4 1m12 0q1.4 0 2.4-1 1-1.2 1-2.5 0-1.5-1-2.5t-2.4-1-2.4 1-1 2.5 1 2.4q.9 1 2.4 1M37 26q1.5 0 2.5-1t1-2.5-1-2.5-2.5-1q-1.4 0-2.4 1t-1 2.5 1 2.5 2.4 1m50.5 9V12h6.4v8.8h.1q.7-1.3 2.2-2.2 1.5-.7 3.2-.7 2.5 0 4.2 1.6a6 6 0 0 1 1.6 4.3V35h-6.6v-9.5q0-1.2-.6-1.8a2 2 0 0 0-1.6-.6q-1 0-1.7.7-.6.8-.6 2V35zm91 0V12h6.6v23zm10.1 0V18.4h6.6V35zm3.3-18.5a3.4 3.4 0 0 1-3.5-3.5q0-1.5 1-2.5t2.5-1a3.4 3.4 0 0 1 3.6 3.5q0 1.5-1 2.5t-2.6 1m56.2 19.1q-2.7 0-4.6-.8-2-.7-3.8-2.5l3.4-3.3q1.2 1 2.4 1.6 1.2.4 2.8.4.9 0 1.3-.2t.5-.7-.5-.7l-3-.8q-3-.8-4.6-2-1.5-1.4-1.4-3.3 0-2.5 2-4 2.2-1.5 5.5-1.5 2.2 0 4 .7 2 .5 3.7 2l-3.3 3.3q-1-.8-2.1-1.1-1-.4-2.3-.4-.8 0-1.2.2t-.4.6.5.6l3 .8q3 .8 4.6 2 1.5 1.3 1.5 3.4 0 2.5-2.2 4a10 10 0 0 1-5.8 1.7M79.2 35h-6.7V18.3h-6v-6.2h18.7v6.2h-6z"/>
<path fill="#fff" fill-rule="evenodd" d="M228 42.4q-3 0-5-1.2-2.1-1-3.6-3.5l4.6-2.2q.7 1.1 1.6 1.7a4 4 0 0 0 2.2.5q1.7 0 2.6-1t.9-2.8v-1h-.1q-.9.9-2.2 1.4a7 7 0 0 1-2.7.6 7 7 0 0 1-5.5-2.3 9 9 0 0 1-2-6.1q0-3.8 2.1-6.2a7 7 0 0 1 5.7-2.4q1.5 0 2.7.5t2 1.5v-1.5h6.3v14q0 5-2.4 7.5-2.4 2.4-7.1 2.5m2.4-13.7q-.9 1-2.2 1a3 3 0 0 1-2.2-1q-.9-.8-.9-2.3t.9-2.4 2.2-.9 2.2 1q.8.8.8 2.3t-.8 2.3m-114 6.9q-4.2 0-6.6-2.5a9 9 0 0 1-2.5-6.3q0-3.9 2.5-6.4a9 9 0 0 1 6.6-2.5q3.7 0 6.1 2.3a8 8 0 0 1 2.5 7.1l-.2 1h-11.1q.2.8.7 1.5 1 1.2 2.8 1.2 1 0 2-.5a6 6 0 0 0 1.9-1.4l3.4 3q-1.6 1.8-3.5 2.6-2 .9-4.6.9m1.9-12.2q.8.6.9 1.7h-5.6q.2-.9.7-1.4.8-1 2.1-1 1.2 0 1.9.7" clip-rule="evenodd"/>
<path fill="#fff" d="M127.6 35V18.4h6.3v2.4q.7-1.4 2.2-2.1 1.4-.9 3.2-.9 1.7 0 3 .8t2 2.1q1-1.5 2.5-2.2 1.5-.6 3.1-.7 3 0 4.6 1.8 1.6 1.7 1.6 4.7V35h-6.6v-9.3q0-1.3-.5-2t-1.6-.6a2 2 0 0 0-1.6.8 3 3 0 0 0-.7 2V35h-6.6v-9.3q0-1.3-.5-2t-1.6-.6a2 2 0 0 0-1.6.8 3 3 0 0 0-.7 2V35z"/>
<path fill="#fff" fill-rule="evenodd" d="M167.4 35.6q-4.2 0-6.6-2.5a9 9 0 0 1-2.5-6.3q0-3.9 2.6-6.4a9 9 0 0 1 6.6-2.5q3.6 0 6 2.3a8 8 0 0 1 2.5 6.3l-.1 1.7h-11.2q.3 1 .8 1.6.9 1.2 2.7 1.2 1.2 0 2.1-.5a6 6 0 0 0 1.8-1.4l3.4 3q-1.5 1.8-3.5 2.6t-4.6.9m2-12.2q.6.6.8 1.7h-5.6l.7-1.4q.9-1 2.2-1 1 0 1.8.7" clip-rule="evenodd"/>
<path fill="#fff" d="M198.8 35V18.4h6.3v2.4h.1q.9-1.5 2.4-2.2t3-.8q2.7 0 4.3 1.7a6 6 0 0 1 1.6 4.4V35h-6.6v-9.5q0-1-.6-1.7t-1.6-.7-1.7.7q-.6.8-.6 2V35z"/>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

45
src/canvas/utils.ts Normal file
View file

@ -0,0 +1,45 @@
import { type CanvasRenderingContext2D, loadImage } from "@napi-rs/canvas";
export function measureText(ctx: CanvasRenderingContext2D, font: string, text: string) {
ctx.font = font;
return ctx.measureText(text);
}
export function wrapText(ctx: CanvasRenderingContext2D, text: string, maxWidth: number) {
// https://stackoverflow.com/a/16599668
const words = text.split(" ");
let lines: string[] = [],
line = words[0];
for (let i = 1; i < words.length; i++) {
const word = words[i];
const newText = `${line} ${word}`;
const width = ctx.measureText(newText).width;
if (width < maxWidth) line = newText;
else {
lines.push(line);
line = word;
}
}
lines.push(line);
return lines;
}
export async function transformSvg(svg: string, config: { color?: string; height?: number; width?: number }) {
const text = config.color ? svg.replace(/fill="#[a-z0-9]{3,6}"/gi, `fill="${config.color}"`) : svg;
const image = await loadImage(Buffer.from(text));
if (config.width && config.height) {
image.width = config.width;
image.height = config.height;
} else if (config.width) {
image.height = (image.height / image.width) * config.width;
image.width = config.width;
} else if (config.height) {
image.width = (image.width / image.height) * config.height;
image.height = config.height;
}
return image;
}

View file

@ -5,6 +5,25 @@ export enum DiffType {
Removed, Removed,
} }
export type Diff =
| {
type: DiffType.Added | DiffType.Removed;
source: string;
label?: string;
}
| {
type: DiffType.Changed;
source: string;
label?: string;
oldSource: string;
oldLabel?: string;
}
| {
type: DiffType.Renamed;
oldName: string;
source: string;
};
export type CodeDiff = export type CodeDiff =
| { | {
type: DiffType.Added | DiffType.Removed; type: DiffType.Added | DiffType.Removed;
@ -16,6 +35,10 @@ export type CodeDiff =
size: number; size: number;
}; };
export type Differs = Record<"semantic" | "raw" | "icons", Map<string, Diff> | undefined> & {
code: Map<string, CodeDiff> | undefined;
};
export type RawColors = Record<string, string>; export type RawColors = Record<string, string>;
export type Icons = Record< export type Icons = Record<
@ -31,7 +54,3 @@ export type Icons = Record<
export type Semantic = Record<string, [string, { raw: string; opacity: number }]>; export type Semantic = Record<string, [string, { raw: string; opacity: number }]>;
export type SemanticColors = Record<string, Semantic>; export type SemanticColors = Record<string, Semantic>;
export type Differs = {
code: Map<string, CodeDiff> | undefined;
};

View file

@ -4,14 +4,16 @@ import { handleShellErr } from "./utils";
export const gitChanged = new Set<string>(); export const gitChanged = new Set<string>();
export async function fetchGitChanged() { export async function fetchGitChanged() {
for (const changed of ( for (const cwd of ["../data", "../canvas"] as const) {
await Bun.$`git status -z -- ':!source'`.cwd("../data").quiet().then(handleShellErr) try {
) const out = await Bun.$`git status -z`.cwd(cwd).quiet().nothrow().then((r) => r.text());
.text() for (const changed of out
.split("\x00") .split("\x00")
.filter((x) => x !== "") .filter((x) => x !== "")
.map((x) => x.slice(3))) .map((x) => x.slice(3)))
gitChanged.add(changed); if (changed) gitChanged.add(changed);
} catch {}
}
} }
const gitQueue: Promise<void>[] = []; const gitQueue: Promise<void>[] = [];

View file

@ -30,6 +30,8 @@ export async function runTasks() {
colors_pushing: "Committing colors", colors_pushing: "Committing colors",
diff: "Diffs", diff: "Diffs",
diff_code: "Diffing code", diff_code: "Diffing code",
diff_raw: "Diffing raw colors",
diff_semantic: "Diffing semantic colors",
}, },
true, true,
); );
@ -53,8 +55,13 @@ export async function runTasks() {
progress.start("preinit_save"); progress.start("preinit_save");
for (const oprev of oprevFiles) { for (const oprev of oprevFiles) {
const file = Bun.file(join("../data", oprev)); for (const base of ["../data", "../canvas"] as const) {
if (await file.exists()) prevFiles.set(oprev, await file.arrayBuffer()); const file = Bun.file(join(base, oprev));
if (await file.exists()) {
prevFiles.set(oprev, await file.arrayBuffer());
break;
}
}
} }
progress.update("preinit_save", true); progress.update("preinit_save", true);
progress.update("preinit", true); progress.update("preinit", true);

View file

@ -10,7 +10,7 @@ export const modulesPath = join(workFolder, "modules");
export const modulePathsDest = join("..", "data", "module-paths.json"); export const modulePathsDest = join("..", "data", "module-paths.json");
export const apkSplits = ["base", "config.hdpi", "config.xxhdpi"] as const; export const apkSplits = ["base", "config.hdpi", "config.xxhdpi"] as const;
export const oprevFiles = ["source.jsonl"] as const; export const oprevFiles = ["source.jsonl", "raw.json", "semantic.json", "semantic_simple.json"] as const;
export const prevFiles = new Map<(typeof oprevFiles)[number], ArrayBuffer>(); export const prevFiles = new Map<(typeof oprevFiles)[number], ArrayBuffer>();
export const version = await Bun.file("../data/version.txt").text(); export const version = await Bun.file("../data/version.txt").text();

View file

@ -1,9 +1,143 @@
import { type CodeDiff, type Differs, DiffType } from "../../types"; import {
type CodeDiff,
type Diff,
type Differs,
DiffType,
type RawColors,
type SemanticColors,
} from "../../types";
import { fetchGitChanged, gitChanged } from "../git"; import { fetchGitChanged, gitChanged } from "../git";
import { type Progress, wrapPromise } from "../progress"; import { type Progress, wrapPromise } from "../progress";
import { diffAnyway, prevFiles } from "../shared"; import { diffAnyway, prevFiles } from "../shared";
import { join, parseJsonl } from "../utils"; import { join, parseJsonl } from "../utils";
async function diffRaw() {
if (!prevFiles.has("raw.json")) {
throw new Error("Missing prevFile: raw.json");
}
const oldRaw = JSON.parse(new TextDecoder().decode(prevFiles.get("raw.json"))) as RawColors;
const newRaw: RawColors = await Bun.file(join("../canvas", "raw.json")).json();
const renamed = new Set<string>();
const changes = new Map<string, Diff>();
for (const [name, source] of Object.entries(newRaw))
if (!oldRaw[name]) {
const oldName = Object.entries(oldRaw).find(([key, val]) => !newRaw[key] && val === source)?.[0];
if (!oldName)
changes.set(name, {
type: DiffType.Added,
source,
});
else {
renamed.add(oldName);
changes.set(name, {
type: DiffType.Renamed,
oldName,
source,
});
}
} else if (oldRaw[name].toLowerCase() !== source.toLowerCase())
changes.set(name, {
type: DiffType.Changed,
source,
oldSource: oldRaw[name],
});
for (const [name, source] of Object.entries(oldRaw))
if (!(newRaw[name] || renamed.has(name)))
changes.set(name, {
type: DiffType.Removed,
source,
});
return changes;
}
function getSemanticLabel(semantic: any) {
const light = semantic.light,
dark = semantic.darker ?? semantic.dark;
if (dark && light) return `☀️ ${light[0]}, 🌙 ${dark[0]}`;
return Object.entries(semantic)
.map(([name, [color]]) => `${name} ${color}`)
.join(", ");
}
async function diffSemantic() {
if (!prevFiles.has("semantic.json")) {
throw new Error("Missing prevFile: semantic.json");
}
const oldSemantic = JSON.parse(new TextDecoder().decode(prevFiles.get("semantic.json"))) as SemanticColors;
const newSemantic: SemanticColors = await Bun.file(join("../canvas", "semantic.json")).json();
const renamed = new Set<string>();
const changes = new Map<string, Diff>();
for (const [name, semantic] of Object.entries(newSemantic)) {
const source = Object.values(semantic)
.map(([color]) => color)
.join(",");
if (!oldSemantic[name]) {
const themes = Object.keys(semantic).length;
const oldName = Object.entries(oldSemantic).find(
([key, val]) =>
!newSemantic[key] &&
Object.keys(val).length === themes &&
Object.entries(val).every(([skey, sval]) => sval[0] === semantic[skey][0]),
)?.[0];
if (!oldName)
changes.set(name, {
type: DiffType.Added,
source,
label: getSemanticLabel(semantic),
});
else {
renamed.add(oldName);
changes.set(name, {
type: DiffType.Renamed,
oldName,
source,
});
}
} else
for (const [theme, [color]] of Object.entries(semantic))
if (!oldSemantic[name][theme])
changes.set(`${name}.${theme}`, {
type: DiffType.Added,
source: color,
});
else if (oldSemantic[name][theme][0].toLowerCase() !== color.toLowerCase())
changes.set(`${name}.${theme}`, {
type: DiffType.Changed,
source: color,
oldSource: oldSemantic[name][theme][0],
});
}
for (const [name, semantic] of Object.entries(oldSemantic)) {
if (renamed.has(name)) continue;
const source = Object.values(semantic)
.map(([color]) => color)
.join(",");
if (!newSemantic[name])
changes.set(name, {
type: DiffType.Removed,
source,
label: getSemanticLabel(semantic),
});
else
for (const [theme, [source]] of Object.entries(semantic))
if (!newSemantic[name][theme])
changes.set(`${name}.${theme}`, {
type: DiffType.Removed,
source,
});
}
return changes;
}
function parseSource(text: string) { function parseSource(text: string) {
const source = parseJsonl(text) as { file: string; size: number }[]; const source = parseJsonl(text) as { file: string; size: number }[];
return Object.fromEntries( return Object.fromEntries(
@ -13,7 +147,7 @@ function parseSource(text: string) {
async function diffCode() { async function diffCode() {
if (!prevFiles.has("source.jsonl")) { if (!prevFiles.has("source.jsonl")) {
return; throw new Error("Missing prevFile: source.jsonl");
} }
const oldCode = parseSource(new TextDecoder().decode(prevFiles.get("source.jsonl"))); const oldCode = parseSource(new TextDecoder().decode(prevFiles.get("source.jsonl")));
@ -55,6 +189,8 @@ export default async function diffs(progress: Progress) {
if (!(gitChanged.has("version.txt") || diffAnyway)) { if (!(gitChanged.has("version.txt") || diffAnyway)) {
progress.update("diff", null); progress.update("diff", null);
progress.update("diff_raw", null);
progress.update("diff_semantic", null);
progress.update("diff_code", null); progress.update("diff_code", null);
return; return;
} }
@ -62,6 +198,20 @@ export default async function diffs(progress: Progress) {
const differs = {} as Differs; const differs = {} as Differs;
await Promise.all([ await Promise.all([
gitChanged.has("raw.json")
? wrapPromise(
diffRaw().then((x) => (differs.raw = x)),
progress,
"diff_raw",
)
: progress.update("diff_raw", null),
gitChanged.has("semantic.json")
? wrapPromise(
diffSemantic().then((x) => (differs.semantic = x)),
progress,
"diff_semantic",
)
: progress.update("diff_semantic", null),
gitChanged.has("source.jsonl") gitChanged.has("source.jsonl")
? wrapPromise( ? wrapPromise(
diffCode().then((x) => (differs.code = x)), diffCode().then((x) => (differs.code = x)),

View file

@ -1,7 +1,18 @@
import { basename } from "node:path"; import { basename } from "node:path";
import { type CodeDiff, type Differs, DiffType } from "../../types"; import type { Canvas } from "@napi-rs/canvas";
import {
type APIMessageTopLevelComponent,
ComponentType,
MessageFlags,
type RESTPostAPIWebhookWithTokenJSONBody,
RouteBases,
Routes,
} from "discord-api-types/v10";
import { drawSections } from "../../canvas";
import { makeSections } from "../../canvas/factory";
import { type CodeDiff, type Diff, type Differs, DiffType } from "../../types";
import { cuteVersion, maxDiffChanges, version } from "../shared"; import { cuteVersion, maxDiffChanges, version } from "../shared";
import { formatBytes, sortEntries } from "../utils"; import { assert, formatBytes, sortEntries } from "../utils";
function fileBase(path: string, other?: string) { function fileBase(path: string, other?: string) {
if (!other) return `... ${basename(path)}`; if (!other) return `... ${basename(path)}`;
@ -37,27 +48,42 @@ interface FormattedDiff {
capped: boolean; capped: boolean;
} }
function formatDiff(diffs: Map<string, CodeDiff>): FormattedDiff { function formatDiff(diffs: Map<string, Diff | CodeDiff>): FormattedDiff {
const entries = [...diffs.entries()]; const entries = [...diffs.entries()];
const sections = { const sections = {
Added: sortEntries(entries) Added: sortEntries(entries)
.map( .map(
([name, diff]) => ([name, diff]) =>
diff.type === DiffType.Added && `+ ${fileBase(name)} (${formatBytes(diff.size)})`, diff.type === DiffType.Added &&
("size" in diff
? `+ ${fileBase(name)} (${formatBytes(diff.size)})`
: `+ ${name}: ${diff.label || diff.source}`),
)
.filter((x) => typeof x === "string"),
Changed: sortEntries(entries)
.map(
([name, diff]) =>
diff.type === DiffType.Changed &&
`- ${name}: ${diff.oldLabel || diff.oldSource}\n+ ${name}: ${diff.label || diff.source}`,
) )
.filter((x) => typeof x === "string"), .filter((x) => typeof x === "string"),
Renamed: sortEntries(entries) Renamed: sortEntries(entries)
.map( .map(
([name, diff]) => ([name, diff]) =>
diff.type === DiffType.Renamed && diff.type === DiffType.Renamed &&
`- ${fileBase(diff.oldName, name)}\n+ ${fileBase(name, diff.oldName)}`, ("size" in diff
? `- ${fileBase(diff.oldName, name)}\n+ ${fileBase(name, diff.oldName)}`
: `- ${diff.oldName}\n+ ${name}`),
) )
.filter((x) => typeof x === "string"), .filter((x) => typeof x === "string"),
Removed: sortEntries(entries) Removed: sortEntries(entries)
.map( .map(
([name, diff]) => ([name, diff]) =>
diff.type === DiffType.Removed && `- ${fileBase(name)} (${formatBytes(diff.size)})`, diff.type === DiffType.Removed &&
("size" in diff
? `- ${fileBase(name)} (${formatBytes(diff.size)})`
: `- ${name}: ${diff.label || diff.source}`),
) )
.filter((x) => typeof x === "string"), .filter((x) => typeof x === "string"),
}; };
@ -72,26 +98,191 @@ function formatDiff(diffs: Map<string, CodeDiff>): FormattedDiff {
}; };
} }
export async function webhook(diffs: Differs) { interface WebhookEmbed {
if (diffs.code?.size) { title: string;
const body = JSON.stringify({ body: FormattedDiff;
content: `Code changes for **${version} (${cuteVersion})**`, image?: Canvas;
embeds: [ footer: string;
key: string;
}
async function sendWebhook(webhook: string, role: string, embeds: WebhookEmbed[], color?: number) {
const body = new FormData();
const images = new Map<number, string>(),
diffs = new Map<number, string>();
let fi = 0;
for (let i = 0; i < embeds.length; i++) {
const embed = embeds[i];
if (embed.image) {
const key = fi++;
const filename = `${embed.key}.png`;
images.set(i, filename);
body.append(`files[${key}]`, new Blob([embed.image.toBuffer("image/png")], { type: "image/png" }), filename);
}
if (embed.body.capped) {
const key = fi++;
const filename = `${embed.key}-changes.diff`;
diffs.set(i, filename);
body.append(`files[${key}]`, new Blob([embed.body.file], { type: "text/plain" }), filename);
}
}
body.append(
"payload_json",
JSON.stringify({
flags: MessageFlags.IsComponentsV2,
components: [
{ {
title: "Code", type: ComponentType.TextDisplay,
description: formatDiff(diffs.code).embed.join("\n"), content: `<@&${role}>`,
footer: { text: makeFooter(diffs.code.size, "code") }, },
...embeds.map(
({ title, body, image, footer }, i) =>
({
type: ComponentType.Container,
accent_color: color,
components: [
{
type: ComponentType.TextDisplay,
content: `## ${title}\n-# ${version} (${cuteVersion})`,
},
...body.embed.map((content) => ({
type: ComponentType.TextDisplay,
content,
})),
body.capped && {
type: ComponentType.File,
file: {
url: `attachment://${diffs.get(i)}`,
},
},
body.capped && {
type: ComponentType.Separator,
},
image && {
type: ComponentType.MediaGallery,
items: [
{
media: {
url: `attachment://${images.get(i)}`,
},
}, },
], ],
}); },
{
type: ComponentType.TextDisplay,
content: footer,
},
].filter((x) => !!x),
}) as APIMessageTopLevelComponent,
),
],
allowed_mentions: process.env.NODE_ENV === "test" ? { parse: [] } : { roles: [role] },
} as RESTPostAPIWebhookWithTokenJSONBody),
);
const webhookUrl = process.env.code_webhook; const url = new URL(webhook);
if (webhookUrl) { url.searchParams.set("wait", "true");
await fetch(webhookUrl, { url.searchParams.set("with_components", "true");
const res = await fetch(url, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" },
body, body,
}); });
const data = await res.text();
if (!res.ok)
throw new Error(`Failed to send webhook message with embeds ${embeds.map((x) => x.title).join(", ")}: ${data}`);
const discordToken = process.env.bot_token;
if (!discordToken) return;
try {
const message = JSON.parse(data);
const route = `${RouteBases.api}/${Routes.channelMessageCrosspost(message.channel_id, message.id)}`;
await fetch(route, {
method: "POST",
headers: {
Authorization: discordToken,
},
});
} catch {}
}
export async function webhook(diffs: Differs) {
if (diffs.raw?.size || diffs.semantic?.size) {
const wh = process.env.color_webhook;
if (!wh) {
console.warn("Missing color_webhook, skipping color webhook");
} else {
await sendWebhook(
wh,
"1227327297795657850",
[
diffs.raw?.size && {
title: "Raw colors",
body: formatDiff(diffs.raw),
image: drawSections(await makeSections(diffs.raw)),
footer: makeFooter(diffs.raw.size, "raw color"),
key: "raw-colors",
},
diffs.semantic?.size && {
title: "Semantic colors",
body: formatDiff(diffs.semantic),
image: drawSections(await makeSections(diffs.semantic)),
footer: makeFooter(diffs.semantic.size, "semantic color"),
key: "semantic-colors",
},
].filter((x) => !!x) as WebhookEmbed[],
0xd8ef9e,
);
} }
} }
if (diffs.icons?.size) {
const wh = process.env.icons_webhook;
if (!wh) {
console.warn("Missing icons_webhook, skipping");
} else {
await sendWebhook(
wh,
"1227327765079003217",
[
{
title: "Icons",
body: formatDiff(diffs.icons),
image: drawSections(await makeSections(diffs.icons, true)),
footer: makeFooter(diffs.icons.size, "icon"),
key: "icons",
},
],
0xf4b8f7,
);
}
}
if (diffs.code?.size) {
const wh = process.env.code_webhook;
if (!wh) {
console.warn("Missing code_webhook, skipping");
return;
}
await sendWebhook(
wh,
"1233861867059941387",
[
{
title: "Code",
body: formatDiff(diffs.code),
footer: makeFooter(diffs.code.size, "code"),
key: "code",
},
],
0x86faf3,
);
}
} }