refactor: dedupe diff logic, simplify runner, decompose canvas

- extract 4 diff fns into generic diffEntries + diffEntriesCode
- drop 120s polling loop; await gzip promise directly
- filter-before-map in webhook, inline color cast helpers
- decompose drawSections into measure/render helpers
- validate colors VM eval result shape
- chain git commits via lastCommit promise
- drop unused Differs fields, trim types
This commit is contained in:
lvwmwm 2026-09-04 13:20:42 -03:00
parent 0f7170e1c7
commit dc2b7299ff
10 changed files with 390 additions and 399 deletions

View file

@ -1,8 +1,8 @@
import { readdir } from "node:fs/promises";
import { join } from "node:path";
import { createCanvas, GlobalFonts } from "@napi-rs/canvas";
import { createCanvas, GlobalFonts, type SKRSContext2D } from "@napi-rs/canvas";
import { file } from "bun";
import type { Row, Section } from "./factory";
import type { Item, Row, Section } from "./factory";
import { measureText, transformSvg, wrapText } from "./utils";
async function bulkRegister(family: string, path: string) {
@ -11,6 +11,37 @@ async function bulkRegister(family: string, path: string) {
await bulkRegister("GG Sans", join(import.meta.dir, "fonts/ggsans"));
await bulkRegister("GG Mono", join(import.meta.dir, "fonts/ggmono"));
const layout = {
background: { padding: 24, gap: 18 },
section: { gap: 8 },
card: { radius: 24, padding: 12, gapX: 16, gapY: 8 },
blob: { radius: 12 },
color: { radius: 24 },
};
const textSizes = { header: 24, title: 18, blob: 24 };
const colors = {
background: "#1c1d23",
section: "#26272f",
blob: "#6c6f7c1f",
textNormal: "#c7c8ce",
textMuted: "#818491",
watermark: "#2f3035",
};
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");
const headerFont = `600 ${textSizes.header}px GG Sans`;
const titleFont = `400 ${textSizes.title}px GG Mono`;
function calcColumnWidth(column: { textWidth: number; itemWidth: number }[]) {
return Math.max(0, ...column.map(({ textWidth, itemWidth }) => Math.max(0, textWidth, itemWidth)));
}
@ -27,81 +58,37 @@ 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,
},
};
function measureItemWidth(item: Item) {
if (item.type === "blob") return blobSize;
if (item.type === "image") return (item.image.width / item.image.height) * itemSize;
return itemSize;
}
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`;
function measureRow(row: Row) {
return {
title: row.title,
subtitle: row.subtitle,
textWidth: Math.max(
0,
row.title ? measureText(mctx, titleFont, row.title).width : 0,
row.subtitle ? measureText(mctx, titleFont, row.subtitle).width : 0,
),
item: row.item,
itemWidth: measureItemWidth(row.item),
};
}
function measureSections(sections: Section[]) {
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,
})),
),
columns: columns.map((column) => column.map(measureRow)),
}));
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;
@ -118,6 +105,56 @@ export function drawSections(sections: Section[]) {
)
.reduce((a, b) => a + b, 0) - layout.background.gap;
return { sects, columnWidths, sectionWidth, width, height };
}
function renderBlob(ctx: SKRSContext2D, blob: string, x: number, y: number, width: number, height: number) {
ctx.fillStyle = colors.blob;
ctx.beginPath();
ctx.roundRect(x, y, width, height, 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, blob, width);
const lineHeight = textSizes.blob / 2;
for (let i = 0; i < wrapped.length; i++) {
ctx.fillText(wrapped[i], x + width / 2, y + height / 2 - lineHeight * (wrapped.length - 1) + textSizes.blob * i);
}
ctx.textAlign = "left";
ctx.textBaseline = "top";
}
function renderColor(ctx: SKRSContext2D, colorGrid: string[][], x: number, y: number) {
for (let i = 0; i < colorGrid.length; i++) {
const row = colorGrid[i];
for (let j = 0; j < row.length; j++) {
ctx.save();
ctx.beginPath();
ctx.rect(
Math.floor(x + (itemSize / row.length) * j),
Math.floor(y + (itemSize / colorGrid.length) * i),
Math.ceil(itemSize / row.length),
Math.ceil(itemSize / colorGrid.length),
);
ctx.clip();
ctx.beginPath();
ctx.fillStyle = row[j];
ctx.roundRect(x, y, itemSize, itemSize, layout.color.radius);
ctx.fill();
ctx.restore();
}
}
}
export function drawSections(sections: Section[]) {
const { sects, columnWidths, sectionWidth, width, height } = measureSections(sections);
const canvas = createCanvas(layout.background.padding * 2 + width, layout.background.padding * 2 + height);
const ctx = canvas.getContext("2d");
ctx.textBaseline = "top";
@ -125,8 +162,8 @@ export function drawSections(sections: Section[]) {
ctx.fillStyle = colors.background;
ctx.fillRect(0, 0, canvas.width, canvas.height);
let x = layout.background.padding,
y = layout.background.padding;
let x = layout.background.padding;
let y = layout.background.padding;
for (const sect of sects) {
ctx.fillStyle = colors.textNormal;
ctx.font = headerFont;
@ -165,54 +202,12 @@ export function drawSections(sections: Section[]) {
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";
renderBlob(ctx, row.item.blob, wx, wy, columnWidth, sectionHeight - wy + y);
} 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();
}
}
renderColor(ctx, row.item.colors, midX, wy);
wy += itemSize + layout.card.gapY;
}
}
@ -225,7 +220,6 @@ export function drawSections(sections: Section[]) {
y += layout.background.gap;
}
// watermark
ctx.drawImage(watermark, canvas.width - watermark.width - 1, 1);
return canvas;

View file

@ -1,13 +1,16 @@
import { type CodeDiff, DiffType } from "./types";
export default {
code: new Map([
raw: new Map(),
semantic: new Map(),
icons: new Map(),
code: new Map<string, CodeDiff>([
[
"app/foomodule.tsx",
{
type: DiffType.Added,
size: 24782,
} as CodeDiff,
},
],
[
"app/ui/barmodule.ts",
@ -15,14 +18,14 @@ export default {
type: DiffType.Renamed,
oldName: "app/legacy/ui/bar_module.ts",
size: 5212,
} as CodeDiff,
},
],
[
"app/intl/ReallyBigCoolComponent.tsx",
{
type: DiffType.Removed,
size: 46532,
} as CodeDiff,
},
],
]),
};

View file

@ -35,8 +35,11 @@ export type CodeDiff =
size: number;
};
export type Differs = Record<"semantic" | "raw" | "icons", Map<string, Diff> | undefined> & {
code: Map<string, CodeDiff> | undefined;
export type Differs = {
semantic: Map<string, Diff>;
raw: Map<string, Diff>;
icons: Map<string, Diff>;
code: Map<string, CodeDiff>;
};
export type RawColors = Record<string, string>;

View file

@ -6,7 +6,11 @@ export const gitChanged = new Set<string>();
export async function fetchGitChanged() {
for (const cwd of ["../data", "../canvas"] as const) {
try {
const out = await Bun.$`git status -z`.cwd(cwd).quiet().nothrow().then((r) => r.text());
const out = await Bun.$`git status -z`
.cwd(cwd)
.quiet()
.nothrow()
.then((r) => r.text());
for (const changed of out
.split("\x00")
.filter((x) => x !== "")
@ -16,35 +20,26 @@ export async function fetchGitChanged() {
}
}
const gitQueue: Promise<void>[] = [];
let lastCommit: Promise<void> = Promise.resolve();
export async function commit(files: string[], message: string, cwd = "../data") {
while (gitQueue[0]) {
await gitQueue[0];
}
const { promise, resolve } = Promise.withResolvers<void>();
gitQueue.push(promise);
if (process.env.NODE_ENV === "test" && !commitAnyway) {
await fetchGitChanged();
} else {
await Bun.$`git restore --staged .`.cwd(cwd).nothrow().quiet().then(handleShellErr);
if (cwd === "../data") await fetchGitChanged();
await Bun.$`git add ${{ raw: files.map((x) => Bun.$.escape(x)).join(" ") }}`
.cwd(cwd)
.nothrow()
.quiet()
.then(handleShellErr);
await Bun.$`git commit -m ${message}`
.cwd(cwd)
.nothrow()
.quiet()
.then((e) => void e);
}
resolve();
const promInd = gitQueue.indexOf(promise);
if (promInd !== -1) gitQueue.splice(promInd, 1);
lastCommit = lastCommit.then(async () => {
if (process.env.NODE_ENV === "test" && !commitAnyway) {
await fetchGitChanged();
} else {
await Bun.$`git restore --staged .`.cwd(cwd).nothrow().quiet().then(handleShellErr);
if (cwd === "../data") await fetchGitChanged();
await Bun.$`git add ${{ raw: files.map((x) => Bun.$.escape(x)).join(" ") }}`
.cwd(cwd)
.nothrow()
.quiet()
.then(handleShellErr);
await Bun.$`git commit -m ${message}`
.cwd(cwd)
.nothrow()
.quiet()
.then((e) => void e);
}
});
return lastCommit;
}

View file

@ -1,4 +1,3 @@
import { exists, rm } from "node:fs/promises";
import mock from "../mock";
import type { Differs } from "../types";
import { commit } from "./git";
@ -8,7 +7,6 @@ import codeTask from "./tasks/code";
import colorsTask from "./tasks/colors";
import decompile from "./tasks/decompile";
import diffs from "./tasks/diffs";
import iconsTask from "./tasks/icons";
import { formatError, handleShellErr, join } from "./utils";
export async function runTasks() {
@ -40,7 +38,9 @@ export async function runTasks() {
},
true,
);
let differs: Differs | undefined;
let differs: Differs = { raw: new Map(), semantic: new Map(), icons: new Map(), code: new Map() };
let gzipDone: Promise<void> | undefined;
if (!isMock) {
try {
@ -50,11 +50,19 @@ export async function runTasks() {
await Bun.$`git reset --hard`.cwd("../data").nothrow().quiet().then(handleShellErr);
await Bun.write("../data/version.txt", version);
// canvas branch may not exist in test
await Bun.$`git reset --hard`.cwd("../canvas").nothrow().quiet().then(() => {});
await Bun.$`git reset --hard`
.cwd("../canvas")
.nothrow()
.quiet()
.then(() => {});
await Bun.write("../canvas/version.txt", version).catch(() => {});
await Bun.$`git restore --staged .`.cwd("../data").nothrow().quiet().then(handleShellErr);
await Bun.$`git restore --staged .`.cwd("../canvas").nothrow().quiet().then(() => {});
await Bun.$`git restore --staged .`
.cwd("../canvas")
.nothrow()
.quiet()
.then(() => {});
progress.update("preinit_discard", true);
@ -76,11 +84,11 @@ export async function runTasks() {
}
try {
await wrapPromise(
gzipDone = (await wrapPromise(
decompile(progress, join(apksFolder, "base", "assets", "index.android.bundle")),
progress,
"decompile",
);
)) as Promise<void> | undefined;
} catch (e) {
throw new Error(`Failed to decompile!\n${e}`);
}
@ -88,8 +96,7 @@ export async function runTasks() {
const code = (await Bun.file(codePath).text()).replace(/\r/g, "").split("\n");
await wrapPromise(codeTask(progress, code), progress, "code");
if (progress.someFailed("code"))
throw new Error(`Failed at parser tasks!\n${progress.prettyErrors("code")}`);
if (progress.someFailed("code")) throw new Error(`Failed at parser tasks!\n${progress.prettyErrors("code")}`);
// colors is non-critical, run in background
wrapPromise(colorsTask(code), progress, "colors").catch((e) => {
progress.update("colors", false, String(e));
@ -103,22 +110,25 @@ export async function runTasks() {
progress.update("icons", null);
progress.update("diff_icons", null);
let waits = 0;
while (!progress.isFinished("decompile_gzip") && waits < 120) {
await Bun.sleep(1000);
waits++;
}
if (!progress.isFinished("decompile_gzip")) {
console.warn("decompile_gzip still not finished after 120s, continuing anyway");
progress.update("decompile_gzip", true);
if (gzipDone) {
try {
await gzipDone;
} catch (e) {
if (progress.someFailed("decompile_gzip"))
throw new Error(`Failed at the decompile gzip task!\n${progress.prettyErrors("decompile_gzip")}`);
throw e;
}
}
if (progress.someFailed("decompile_gzip"))
throw new Error(`Failed at the decompile gzip task!\n${progress.prettyErrors("decompile_gzip")}`);
try {
differs = await diffs(progress);
progress.update("diff", true);
const result = await diffs(progress);
if (result) {
differs = result;
progress.update("diff", true);
}
} catch (e: any) {
progress.update("diff", false, formatError(e));
throw new Error(`Failed to generate diffs!\n${formatError(e)}`);
@ -127,7 +137,7 @@ export async function runTasks() {
differs = mock;
}
if (differs && !isQuiet) {
if (!isQuiet) {
try {
const { webhook } = await import("./tasks/webhook");
await wrapPromise(webhook(differs), progress, "webhook");

View file

@ -1,4 +1,4 @@
import { mkdir, rm, readdir, stat } from "node:fs/promises";
import { mkdir, readdir, rm, stat } from "node:fs/promises";
import { join as pathJoin } from "node:path";
import { commit } from "../git";
import type { Progress } from "../progress";
@ -13,7 +13,10 @@ function extractModuleId(filename: string): number | undefined {
}
function sanitizePath(p: string): string {
return p.replace(/\\/g, "/").replace(/^\.\.\//, "").replace(/[<>:"|?*]/g, "_");
return p
.replace(/\\/g, "/")
.replace(/^\.\.\//, "")
.replace(/[<>:"|?*]/g, "_");
}
export default async function code(progress: Progress, _code: string[]) {

View file

@ -28,19 +28,34 @@ function evalRawModule(snippet: string): any {
code = code.replace(/set\.fileFinishedImporting/g, "__importSet.fileFinishedImporting");
code = code.replace(/export\s+const\s+_private/, "const _private");
code = code + "\n; _private;";
return runInNewContext(code, {});
const result = runInNewContext(code, {});
if (!result || typeof result !== "object" || !result.RawColors || typeof result.RawColors !== "object") {
throw new Error(
"VM evaluation failed: RawColors not found or invalid shape. The regex transforms may not match the current decompiler output.",
);
}
return result;
}
function evalSemanticModule(snippet: string): any {
let code = snippet;
code = code.replace(/^import set from "set".*$/m, "const __importSet = { fileFinishedImporting: () => {} };");
code = code.replace(/^import items from "items".*$/m, "const items = { _private: { Themes: new Proxy({}, { get(_, k){ return k.toString().toLowerCase(); } }) } };");
code = code.replace(
/^import items from "items".*$/m,
"const items = { _private: { Themes: new Proxy({}, { get(_, k){ return k.toString().toLowerCase(); } }) } };",
);
code = code.replace(/^import.*$/gm, "");
code = code.replace(/set\.fileFinishedImporting/g, "__importSet.fileFinishedImporting");
code = code.replace(/export\s+const\s+_private/, "const _private");
code = code + "\n; _private;";
code = fixHermesDecIndex(code);
return runInNewContext(code, {});
const result = runInNewContext(code, {});
if (!result || typeof result !== "object" || !result.SemanticColors || typeof result.SemanticColors !== "object") {
throw new Error(
"VM evaluation failed: SemanticColors not found or invalid shape. The regex transforms may not match the current decompiler output.",
);
}
return result;
}
export function getInternalRawColors(code: string[]) {
@ -59,7 +74,9 @@ export function getInternalSemanticColors(code: string[], raw: Record<string, st
if (!snippet) throw new Error("Cannot find semantic definitions module");
const mod = evalSemanticModule(snippet);
const semantic: SemanticColors = {};
for (const [key, colors] of Object.entries(mod.SemanticColors as Record<string, Record<string, { raw: string; opacity: number }>>)) {
for (const [key, colors] of Object.entries(
mod.SemanticColors as Record<string, Record<string, { raw: string; opacity: number }>>,
)) {
const color: SemanticColors[string] = {};
for (const [theme, val] of Object.entries(colors)) {
if (typeof val !== "object" || !("raw" in val)) continue;
@ -88,5 +105,9 @@ export default async function colors(code: string[]) {
await Bun.write("../canvas/semantic.json", JSON.stringify(sortObj(semantic), null, 4));
await Bun.write("../canvas/semantic_simple.json", JSON.stringify(sortObj(convertSimpleSemantic(semantic)), null, 4));
await commit(["raw.json", "semantic.json", "semantic_simple.json"], `chore: update colors for ${cuteVersion}`, "../canvas");
await commit(
["raw.json", "semantic.json", "semantic_simple.json"],
`chore: update colors for ${cuteVersion}`,
"../canvas",
);
}

View file

@ -26,18 +26,11 @@ export default async function decompile(progress: Progress, pathToBundle: string
progress.start("decompile_decompiling");
const decompilerBin = join(pathToDecompiler, "target/release/hermes-decomp");
if (!(await Bun.file(decompilerBin).exists())) {
await Bun.$`cargo build --release -p hbc-decomp-cli`
.cwd(pathToDecompiler)
.quiet()
.nothrow()
.then(handleShellErr);
await Bun.$`cargo build --release -p hbc-decomp-cli`.cwd(pathToDecompiler).quiet().nothrow().then(handleShellErr);
}
if (!(await Bun.file(codePath).exists())) {
await Bun.$`${decompilerBin} decompile ${pathToBundle} --output ${codePath}`
.quiet()
.nothrow()
.then(handleShellErr);
await Bun.$`${decompilerBin} decompile ${pathToBundle} --output ${codePath}`.quiet().nothrow().then(handleShellErr);
}
if (!(await exists(modulesPath))) {
@ -70,15 +63,20 @@ export default async function decompile(progress: Progress, pathToBundle: string
const gzipper = new Worker(gzipWorkerURL);
progress.start("decompile_gzip");
const { promise: gzipDone, resolve: resolveGzip } = Promise.withResolvers<void>();
gzipper.addEventListener("message", async ({ data }) => {
if (data === true) {
await commit([gzFile, "module-paths.json", "index.android.bundle"], `chore: update decompiled code for ${cuteVersion}`);
await commit(
[gzFile, "module-paths.json", "index.android.bundle"],
`chore: update decompiled code for ${cuteVersion}`,
);
progress.update("decompile_gzip", true);
}
gzipper.terminate();
resolveGzip();
});
gzipper.postMessage({ path: codePath, target: join("../data", gzFile) });
} else {
progress.update("decompile_gzip", null);
return gzipDone;
}
progress.update("decompile_gzip", null);
}

View file

@ -12,185 +12,183 @@ import { type Progress, wrapPromise } from "../progress";
import { diffAnyway, prevFiles } from "../shared";
import { join, parseJsonl } from "../utils";
async function diffRaw() {
if (!prevFiles.has("raw.json")) {
throw new Error("Missing prevFile: raw.json");
}
interface DiffOpts<TOld, TNew> {
getSource: (key: string, val: TNew) => string;
getLabel?: (key: string, val: TNew) => string;
getOldSource?: (key: string, val: TOld) => string;
getOldLabel?: (key: string, val: TOld) => string;
isRename: (newKey: string, oldVal: TOld, newVal: TNew) => boolean;
isChanged?: (oldVal: TOld, newVal: TNew) => boolean;
}
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>();
function diffEntries<TOld, TNew>(
oldEntries: Record<string, TOld>,
newEntries: Record<string, TNew>,
opts: DiffOpts<TOld, TNew>,
): Map<string, Diff> {
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];
const renamed = new Set<string>();
if (!oldName)
for (const [name, newVal] of Object.entries(newEntries)) {
const oldVal = oldEntries[name];
if (oldVal === undefined) {
const oldName = Object.entries(oldEntries).find(
([key, val]) => !(key in newEntries) && opts.isRename(name, val, newVal),
)?.[0];
if (!oldName) {
changes.set(name, {
type: DiffType.Added,
source,
source: opts.getSource(name, newVal),
label: opts.getLabel?.(name, newVal),
});
else {
} else {
renamed.add(oldName);
changes.set(name, {
type: DiffType.Renamed,
oldName,
source,
});
changes.set(name, { type: DiffType.Renamed, oldName, source: opts.getSource(name, newVal) });
}
} else if (oldRaw[name].toLowerCase() !== source.toLowerCase())
} else if (opts.isChanged?.(oldVal, newVal)) {
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,
source: opts.getSource(name, newVal),
label: opts.getLabel?.(name, newVal),
oldSource: opts.getOldSource?.(name, oldVal) ?? "",
oldLabel: opts.getOldLabel?.(name, oldVal),
});
}
}
for (const [name, oldVal] of Object.entries(oldEntries)) {
if (name in newEntries || renamed.has(name)) continue;
changes.set(name, {
type: DiffType.Removed,
source: opts.getOldSource?.(name, oldVal) ?? "",
label: opts.getOldLabel?.(name, oldVal),
});
}
return changes;
}
function getSemanticLabel(semantic: any) {
const light = semantic.light,
dark = semantic.darker ?? semantic.dark;
if (dark && light) return `☀️ ${light[0]}, 🌙 ${dark[0]}`;
function diffEntriesCode(
oldEntries: Record<string, { size: number; compSize: number }>,
newEntries: Record<string, { size: number; compSize: number }>,
): Map<string, CodeDiff> {
const changes = new Map<string, CodeDiff>();
const renamed = new Set<string>();
for (const [name, newVal] of Object.entries(newEntries)) {
if (!(name in oldEntries)) {
const oldName = Object.entries(oldEntries).find(
([key, val]) => !(key in newEntries) && val.compSize === newVal.compSize,
)?.[0];
if (!oldName) {
changes.set(name, { type: DiffType.Added, size: newVal.size });
} else {
renamed.add(oldName);
changes.set(name, { type: DiffType.Renamed, oldName, size: newVal.size });
}
}
}
for (const [name, oldVal] of Object.entries(oldEntries)) {
if (!(name in newEntries || renamed.has(name))) {
changes.set(name, { type: DiffType.Removed, size: oldVal.size });
}
}
return changes;
}
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();
return diffEntries(oldRaw, newRaw, {
getSource: (_, val) => val,
isRename: (newKey, oldVal, newVal) => !(newKey in newRaw) && oldVal === newVal,
isChanged: (oldVal, newVal) => oldVal.toLowerCase() !== newVal.toLowerCase(),
});
}
function getSemanticLabel(semantic: Record<string, [string, unknown]>) {
const light = semantic.light;
const 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");
}
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];
const changes = diffEntries(oldSemantic, newSemantic, {
getSource: (_, val) =>
Object.values(val)
.map(([color]) => color)
.join(","),
getLabel: (_, val) => getSemanticLabel(val),
isRename: (newKey, oldVal, newVal) => {
const themes = Object.keys(newVal).length;
return (
!(newKey in newSemantic) &&
Object.keys(oldVal).length === themes &&
Object.entries(oldVal).every(([skey, sval]) => sval[0] === newVal[skey]?.[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())
for (const [name, semantic] of Object.entries(newSemantic)) {
if (oldSemantic[name]) {
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,
});
for (const [name, semantic] of Object.entries(oldSemantic)) {
if (name in newSemantic) {
for (const [theme, [source]] of Object.entries(semantic)) {
if (!newSemantic[name][theme]) {
changes.set(`${name}.${theme}`, { type: DiffType.Removed, source });
}
}
}
}
return changes;
}
async function diffIcons() {
if (!prevFiles.has("icons.json")) {
throw new Error("Missing prevFile: icons.json");
}
if (!prevFiles.has("icons.json")) throw new Error("Missing prevFile: icons.json");
const oldIcons = JSON.parse(new TextDecoder().decode(prevFiles.get("icons.json"))) as Icons;
const newIcons: Icons = await Bun.file(join("../canvas", "icons.json")).json();
const iconDir = {
old: join("../canvas", "oldicons"),
new: join("../canvas", "icons"),
};
const iconDir = { old: join("../canvas", "oldicons"), new: join("../canvas", "icons") };
const renamed = new Set<string>();
const changes = new Map<string, Diff>();
for (const [name, icon] of Object.entries(newIcons)) {
const label = icon.hash.slice(0, 8);
if (!oldIcons[name]) {
const oldName = Object.entries(oldIcons).find(([key, val]) => !newIcons[key] && val.hash === icon.hash)?.[0];
if (!oldName)
changes.set(name, {
type: DiffType.Added,
source: join(iconDir.new, icon.file),
label,
});
else {
renamed.add(oldName);
changes.set(name, {
type: DiffType.Renamed,
oldName,
source: join(iconDir.new, icon.file),
});
}
} else if (oldIcons[name].hash !== icon.hash)
changes.set(name, {
type: DiffType.Changed,
source: join(iconDir.new, icon.file),
label,
oldSource: join(iconDir.old, oldIcons[name].file),
oldLabel: oldIcons[name].hash.slice(0, 8),
});
}
for (const [name, icon] of Object.entries(oldIcons))
if (!(newIcons[name] || renamed.has(name)))
changes.set(name, {
type: DiffType.Removed,
source: join(iconDir.old, icon.file),
label: icon.hash.slice(0, 8),
});
return changes;
return diffEntries(oldIcons, newIcons, {
getSource: (_, val) => join(iconDir.new, val.file),
getLabel: (_, val) => val.hash.slice(0, 8),
getOldSource: (_, val) => join(iconDir.old, val.file),
getOldLabel: (_, val) => val.hash.slice(0, 8),
isRename: (newKey, oldVal, newVal) => !(newKey in newIcons) && oldVal.hash === newVal.hash,
isChanged: (oldVal, newVal) => oldVal.hash !== newVal.hash,
});
}
function parseSource(text: string) {
@ -201,42 +199,12 @@ function parseSource(text: string) {
}
async function diffCode() {
if (!prevFiles.has("source.jsonl")) {
throw new Error("Missing prevFile: source.jsonl");
}
if (!prevFiles.has("source.jsonl")) throw new Error("Missing prevFile: source.jsonl");
const oldCode = parseSource(new TextDecoder().decode(prevFiles.get("source.jsonl")));
const newCode = parseSource(await Bun.file(join("../data", "source.jsonl")).text());
const renamed = new Set<string>();
const changes = new Map<string, CodeDiff>();
for (const [name, { size, compSize }] of Object.entries(newCode)) {
if (!oldCode[name]) {
const oldName = Object.entries(oldCode).find(([key, val]) => !newCode[key] && val.compSize === compSize)?.[0];
if (!oldName)
changes.set(name, {
type: DiffType.Added,
size,
});
else {
renamed.add(oldName);
changes.set(name, {
type: DiffType.Renamed,
oldName,
size,
});
}
}
}
for (const [name, { size }] of Object.entries(oldCode))
if (!(newCode[name] || renamed.has(name)))
changes.set(name, {
type: DiffType.Removed,
size,
});
return changes;
return diffEntriesCode(oldCode, newCode);
}
export default async function diffs(progress: Progress) {
@ -251,7 +219,7 @@ export default async function diffs(progress: Progress) {
return;
}
const differs = {} as Differs;
const differs: Differs = { raw: new Map(), semantic: new Map(), icons: new Map(), code: new Map() };
await Promise.all([
gitChanged.has("raw.json")

View file

@ -12,7 +12,7 @@ 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 { assert, formatBytes, sortEntries } from "../utils";
import { formatBytes, sortEntries } from "../utils";
function fileBase(path: string, other?: string) {
if (!other) return `... ${basename(path)}`;
@ -49,43 +49,39 @@ interface FormattedDiff {
}
function formatDiff(diffs: Map<string, Diff | CodeDiff>): FormattedDiff {
const entries = [...diffs.entries()];
const entries = sortEntries([...diffs.entries()]);
const sections = {
Added: sortEntries(entries)
.map(
([name, diff]) =>
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"),
Renamed: sortEntries(entries)
.map(
([name, diff]) =>
diff.type === DiffType.Renamed &&
("size" in diff
? `- ${fileBase(diff.oldName, name)}\n+ ${fileBase(name, diff.oldName)}`
: `- ${diff.oldName}\n+ ${name}`),
)
.filter((x) => typeof x === "string"),
Removed: sortEntries(entries)
.map(
([name, diff]) =>
diff.type === DiffType.Removed &&
("size" in diff
? `- ${fileBase(name)} (${formatBytes(diff.size)})`
: `- ${name}: ${diff.label || diff.source}`),
)
.filter((x) => typeof x === "string"),
Added: entries
.filter(([, diff]) => diff.type === DiffType.Added)
.map(([name, diff]) => {
if ("size" in diff) return `+ ${fileBase(name)} (${formatBytes(diff.size)})`;
const d = diff as Extract<Diff, { type: DiffType.Added | DiffType.Removed }>;
return `+ ${name}: ${d.label || d.source}`;
}),
Changed: entries
.filter(([, diff]) => diff.type === DiffType.Changed)
.map(([name, diff]) => {
const d = diff as Extract<Diff, { type: DiffType.Changed }>;
return `- ${name}: ${d.oldLabel || d.oldSource}\n+ ${name}: ${d.label || d.source}`;
}),
Renamed: entries
.filter(([, diff]) => diff.type === DiffType.Renamed)
.map(([name, diff]) => {
if ("size" in diff) {
const cd = diff as Extract<CodeDiff, { type: DiffType.Renamed }>;
return `- ${fileBase(cd.oldName, name)}\n+ ${fileBase(name, cd.oldName)}`;
}
const d = diff as Extract<Diff, { type: DiffType.Renamed }>;
return `- ${d.oldName}\n+ ${name}`;
}),
Removed: entries
.filter(([, diff]) => diff.type === DiffType.Removed)
.map(([name, diff]) => {
if ("size" in diff) return `- ${fileBase(name)} (${formatBytes(diff.size)})`;
const d = diff as Extract<Diff, { type: DiffType.Added | DiffType.Removed }>;
return `- ${name}: ${d.label || d.source}`;
}),
};
const text = Object.entries(sections).filter(([, lines]) => lines.length);
@ -214,7 +210,7 @@ async function sendWebhook(webhook: string, role: string, embeds: WebhookEmbed[]
}
export async function webhook(diffs: Differs) {
if (diffs.raw?.size || diffs.semantic?.size) {
if (diffs.raw.size || diffs.semantic.size) {
const wh = process.env.color_webhook;
if (!wh) {
console.warn("Missing color_webhook, skipping color webhook");
@ -223,14 +219,14 @@ export async function webhook(diffs: Differs) {
wh,
"1227327297795657850",
[
diffs.raw?.size && {
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 && {
diffs.semantic.size && {
title: "Semantic colors",
body: formatDiff(diffs.semantic),
image: drawSections(await makeSections(diffs.semantic)),
@ -243,7 +239,7 @@ export async function webhook(diffs: Differs) {
}
}
if (diffs.icons?.size) {
if (diffs.icons.size) {
const wh = process.env.icons_webhook;
if (!wh) {
console.warn("Missing icons_webhook, skipping");
@ -265,7 +261,7 @@ export async function webhook(diffs: Differs) {
}
}
if (diffs.code?.size) {
if (diffs.code.size) {
const wh = process.env.code_webhook;
if (!wh) {
console.warn("Missing code_webhook, skipping");