Skip to content

Commit 8b94e97

Browse files
committed
Add checks to make sure icons with visual changes have changelog entries (close #291)
1 parent 5c94733 commit 8b94e97

2 files changed

Lines changed: 131 additions & 20 deletions

File tree

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@
5959
"dotenv": "^17.3.1",
6060
"pluralize": "^8.0.0",
6161
"prettier": "^3.9.5",
62+
"sharp": "^0.35.3",
6263
"svg-path-parse": "^1.1.3",
6364
"svg2ttf": "^6.0.3",
6465
"svgicons2svgfont": "^16.0.0",

scripts/check_changelog.js

Lines changed: 130 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import { existsSync, readFileSync, writeFileSync, globSync } from "fs";
2+
import { readFile } from "fs/promises";
23
import { join, parse } from "path";
34
import { downloadExternalSourceAssets } from "../src/ExternalSourceManager.js";
45
import { downloadLegacyAssets } from "../src/LegacyAssetManager.js";
6+
import sharp from "sharp";
57

68
const version = JSON.parse(readFileSync("package.json")).version;
79
const currentMajorVersion = version.split(".")[1];
@@ -21,6 +23,9 @@ for (const importSource of importSources) {
2123
importSource.seenIcons = {};
2224
}
2325

26+
const svgPromisesByPath = new Map();
27+
const rasterBufferPromisesBySvgPath = new Map();
28+
2429
const iconChangeProps = [
2530
"oldId",
2631
"newId",
@@ -46,13 +51,17 @@ const changelogPath = "metadata/changelog.json";
4651

4752
const changelogs = JSON.parse(readFileSync(changelogPath));
4853

49-
if (validateChangelogs(changelogs)) {
54+
const startTime = Date.now();
55+
56+
if (await validateChangelogs(changelogs)) {
5057
const currentChangelog = changelogs.find(
5158
(c) => c.majorVersion === currentMajorVersion,
5259
);
53-
printTextForChangelog(currentChangelog);
60+
// printTextForChangelog(currentChangelog);
5461

55-
console.log("changelog.json is valid");
62+
console.log(
63+
"changelog.json is valid, done in " + (Date.now() - startTime) + " ms",
64+
);
5665
} else {
5766
console.log("changelog.json is not valid, exiting…");
5867
process.exit(1);
@@ -84,7 +93,7 @@ function formatChangelogs(changelogs) {
8493
return formattedChangelogs;
8594
}
8695

87-
function validateChangelogs(changelogs) {
96+
async function validateChangelogs(changelogs) {
8897
// sort oldest to newest
8998
const sortedChangelogs = changelogs.toSorted(
9099
(a, b) => parseInt(a.majorVersion) - parseInt(b.majorVersion),
@@ -93,7 +102,7 @@ function validateChangelogs(changelogs) {
93102
const iconsById = {};
94103

95104
for (const versionChangelog of sortedChangelogs) {
96-
if (!validateChangelog(versionChangelog, iconsById)) {
105+
if (!(await validateChangelog(versionChangelog, iconsById))) {
97106
return;
98107
}
99108
}
@@ -117,23 +126,59 @@ function validateChangelogs(changelogs) {
117126
return true;
118127
}
119128

120-
function validateChangelog(versionChangelog, iconsById) {
129+
async function validateChangelog(versionChangelog, iconsById) {
121130
// Make sure we process all deletions/changes before additions
122131
const sortedIconChanges = versionChangelog.iconChanges.toSorted((a, b) => {
123132
if (b.oldId && !a.oldId) return 1;
124133
if (!b.oldId && a.oldId) return -1;
125134
return 0;
126135
});
127136

137+
const hasIconChangeForIconId = {};
138+
128139
for (const iconChange of sortedIconChanges) {
129-
if (!validateIconChange(iconChange, versionChangelog, iconsById)) {
140+
if (iconChange.newId) {
141+
hasIconChangeForIconId[iconChange.newId] = true;
142+
}
143+
if (!(await validateIconChange(iconChange, versionChangelog, iconsById))) {
144+
return;
145+
}
146+
}
147+
148+
const v = parseInt(versionChangelog.majorVersion);
149+
150+
if (v > 1) {
151+
// ensure that a changelog entry exists if the icon SVG has changed
152+
const iconsDir =
153+
v === parseInt(currentMajorVersion) ? "./icons" : `./docs/v${v}`;
154+
const iconFiles = globSync(`${iconsDir}/**/*.svg`);
155+
const promises = iconFiles.map(async (file) => {
156+
const id = parse(file).name;
157+
if (!hasIconChangeForIconId[id]) {
158+
if (
159+
!(await svgsAreVisuallyEquivalent(
160+
`./docs/v${v - 1}/${id}.svg`,
161+
file,
162+
v > 4,
163+
))
164+
) {
165+
throw new Error(
166+
`Missing changelog entry for changed file "${id}.svg" in version ${v}`,
167+
);
168+
}
169+
}
170+
});
171+
try {
172+
await Promise.all(promises);
173+
} catch (error) {
174+
console.error(error);
130175
return;
131176
}
132177
}
133178
return true;
134179
}
135180

136-
function validateIconChange(iconChange, versionChangelog, iconsById) {
181+
async function validateIconChange(iconChange, versionChangelog, iconsById) {
137182
const v = parseInt(versionChangelog.majorVersion);
138183
for (const key in iconChange) {
139184
if (!iconChangeProps.includes(key)) {
@@ -243,23 +288,29 @@ function validateIconChange(iconChange, versionChangelog, iconsById) {
243288
);
244289
return;
245290
}
246-
if (v > 1 && (iconChange.by || iconChange.src)) {
291+
if (v > 1) {
247292
// expect SVGs to be different
248-
const oldFile = readFileSync(
249-
`./docs/v${v - 1}/${iconChange.oldId}.svg`,
250-
"utf8",
251-
);
252293
const newfileRoot =
253294
parseInt(currentMajorVersion) === v ? "./icons" : `./docs/v${v}`;
254-
const newFile = readFileSync(
295+
const sameSvg = await svgsAreVisuallyEquivalent(
296+
`./docs/v${v - 1}/${iconChange.oldId}.svg`,
255297
`${newfileRoot}/${iconChange.newId}.svg`,
256-
"utf8",
298+
v > 4,
257299
);
258-
if (newFile === oldFile) {
259-
console.error(
260-
`No difference between old icon "v${v - 1}/${iconChange.oldId}" and new icon "v${v}/${iconChange.newId}"`,
261-
);
262-
return;
300+
if (iconChange.by || iconChange.src) {
301+
if (sameSvg) {
302+
console.error(
303+
`No difference between SVGs of old icon "v${v - 1}/${iconChange.oldId}" and new icon "v${v}/${iconChange.newId}"`,
304+
);
305+
return;
306+
}
307+
} else {
308+
if (!sameSvg) {
309+
console.error(
310+
`Unexpected difference between SVGs of old icon "v${v - 1}/${iconChange.oldId}" and new icon "v${v}/${iconChange.newId}"`,
311+
);
312+
return;
313+
}
263314
}
264315
}
265316
if (iconChange.newId !== iconChange.oldId) {
@@ -457,6 +508,65 @@ function printTextForChangelog(changelog) {
457508
}
458509
}
459510

511+
function getSvg(svgPath) {
512+
if (!svgPromisesByPath.has(svgPath)) {
513+
svgPromisesByPath.set(svgPath, readFile(svgPath, "utf8"));
514+
}
515+
516+
return svgPromisesByPath.get(svgPath);
517+
}
518+
519+
function getRasterBuffer(svgPath, svg) {
520+
if (!rasterBufferPromisesBySvgPath.has(svgPath)) {
521+
rasterBufferPromisesBySvgPath.set(
522+
svgPath,
523+
sharp(Buffer.from(svg)).resize(60, 60).ensureAlpha().raw().toBuffer(),
524+
);
525+
}
526+
527+
return rasterBufferPromisesBySvgPath.get(svgPath);
528+
}
529+
530+
async function svgsAreVisuallyEquivalent(svgPath1, svgPath2, strict) {
531+
const [svg1, svg2] = await Promise.all([getSvg(svgPath1), getSvg(svgPath2)]);
532+
if (svg1 === svg2) return true;
533+
534+
const [a, b] = await Promise.all([
535+
getRasterBuffer(svgPath1, svg1),
536+
getRasterBuffer(svgPath2, svg2),
537+
]);
538+
539+
if (strict) return a.equals(b);
540+
541+
if (a.length !== b.length) return false;
542+
543+
// amount within which different channel values will be treated as the same value
544+
const channelTolerance = 8;
545+
// percent of pixels allowed to differ
546+
const pixelTolerance = 0.01;
547+
548+
let differentPixels = 0;
549+
const totalPixels = a.length / 4;
550+
551+
for (let i = 0; i < a.length; i += 4) {
552+
const different =
553+
Math.abs(a[i] - b[i]) > channelTolerance ||
554+
Math.abs(a[i + 1] - b[i + 1]) > channelTolerance ||
555+
Math.abs(a[i + 2] - b[i + 2]) > channelTolerance ||
556+
Math.abs(a[i + 3] - b[i + 3]) > channelTolerance;
557+
558+
if (different) {
559+
differentPixels++;
560+
561+
if (differentPixels / totalPixels > pixelTolerance) {
562+
return false;
563+
}
564+
}
565+
}
566+
567+
return true;
568+
}
569+
460570
function stringArray(value) {
461571
return typeof value === "string" ? [value] : [...value];
462572
}

0 commit comments

Comments
 (0)