feat: type-safe sc-icons

This commit is contained in:
2024-04-03 12:16:18 +02:00
committed by Rainer Killinger
parent 53c3d0ba0c
commit abf9999461
29 changed files with 211 additions and 172 deletions

View File

@@ -12,33 +12,34 @@
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import fs from 'fs';
import {omit} from '../src/app/_helpers/collections/omit';
import {pickBy} from '../src/app/_helpers/collections/pick';
import {readFileSync, writeFileSync} from 'fs';
import {omit, pickBy} from '@openstapps/collection-utils';
/**
* accumulate and transform licenses based on two license files
* @param {string} path
* @param {string} additionalLicensesPath
*/
function accumulateFile(path: string, additionalLicensesPath: string) {
const packageJson = JSON.parse(fs.readFileSync('./package.json').toString());
function accumulateFile(path, additionalLicensesPath) {
const packageJson = JSON.parse(readFileSync('./package.json').toString());
const dependencies = packageJson.dependencies;
console.log(`Accumulating licenses from ${path}`);
fs.writeFileSync(
writeFileSync(
path,
JSON.stringify(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Object.entries<any>({
...pickBy(JSON.parse(fs.readFileSync(path).toString()), (_, key: string) => {
const parts = key.split('@');
Object.entries({
...pickBy(JSON.parse(readFileSync(path).toString()), (_, key) => {
const parts = /** @type {string} */ (key).split('@');
return dependencies[parts.slice(0, -1).join('@')] === parts[parts.length - 1];
}),
...JSON.parse(fs.readFileSync(additionalLicensesPath).toString()),
...JSON.parse(readFileSync(additionalLicensesPath).toString()),
})
.map(([key, value]) => ({
licenseText: value.licenseFile && fs.readFileSync(value.licenseFile, 'utf8'),
licenseText: value.licenseFile && readFileSync(value.licenseFile, 'utf8'),
name: key,
...omit(value, 'licenseFile', 'path'),
}))

View File

@@ -12,18 +12,20 @@
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import fontkit, {Font} from 'fontkit';
import config from '../icons.config';
import {openSync} from 'fontkit';
import config from '../icons.config.mjs';
import {existsSync} from 'fs';
import {getUsedIconsHtml, getUsedIconsTS} from './gather-used-icons';
import {getUsedIconsHtml, getUsedIconsTS} from './gather-used-icons.mjs';
import {fetchCodePointMap} from './get-code-points.mjs';
const commandName = '"npm run minify-icons"';
const originalFont = fontkit.openSync(config.inputPath);
if (!existsSync(config.outputPath)) {
console.error(`Minified font not found. Run ${commandName} first.`);
process.exit(-1);
}
const modifiedFont = fontkit.openSync(config.outputPath);
/** @type {import('fontkit').Font} */
const modifiedFont = openSync(config.outputPath);
let success = true;
@@ -48,25 +50,16 @@ async function checkAll() {
}
/**
*
* @param {Record<string, string[]>} icons
*/
function check(icons: Record<string, string[]>) {
for (const [purpose, iconSet] of Object.entries(icons)) {
for (const icon of iconSet) {
if (!hasIcon(originalFont, icon)) {
success = false;
console.error(`${purpose}: ${icon} does not exist. Typo?`);
} else if (!hasIcon(modifiedFont, icon)) {
success = false;
console.error(`${purpose}: ${icon} not found in minified font. Run ${commandName} to regenerate it.`);
}
async function check(icons) {
const codePoints = await fetchCodePointMap();
for (const icon of Object.values(icons).flat()) {
const codePoint = codePoints.get(icon);
if (!codePoint) throw new Error(`"${icon}" is not a valid icon`);
if (!modifiedFont.getGlyph(Number.parseInt(codePoint, 16))) {
throw new Error(`"${icon}" (code point ${codePoint}) is missing`);
}
}
}
/**
*
*/
function hasIcon(font: Font, icon: string) {
return font.layout(icon).glyphs.some(it => it.isLigature);
}

View File

@@ -14,34 +14,39 @@
*/
import {glob} from 'glob';
import {readFileSync} from 'fs';
import {matchPropertyContent, matchTagProperties} from '../src/app/util/ion-icon/icon-match';
import {
matchPropertyAccess,
matchPropertyContent,
matchTagProperties,
} from '../src/app/util/ion-icon/icon-match.mjs';
/**
*
* @returns {Promise<Record<string, string[]>>}
*/
export async function getUsedIconsHtml(pattern = 'src/**/*.html'): Promise<Record<string, string[]>> {
export async function getUsedIconsHtml(pattern = 'src/**/*.html') {
return Object.fromEntries(
(await glob(pattern))
.map(file => [
file,
(readFileSync(file, 'utf8')
readFileSync(file, 'utf8')
.match(matchTagProperties('ion-icon'))
?.flatMap(match => {
return match.match(matchPropertyContent(['name', 'md', 'ios']));
})
.filter(it => !!it) as string[]) || [],
.filter(it => !!it) || [],
])
.filter(([, values]) => values.length > 0),
.filter(([, values]) => values && values.length > 0),
);
}
/**
*
* @returns {Promise<Record<string, string[]>>}
*/
export async function getUsedIconsTS(pattern = 'src/**/*.ts'): Promise<Record<string, string[]>> {
export async function getUsedIconsTS(pattern = 'src/**/*.ts') {
const regex = matchPropertyAccess('SCIcon');
return Object.fromEntries(
(await glob(pattern))
.map(file => [file, readFileSync(file, 'utf8').match(/(?<=Icon`)[\w-]+(?=`)/g) || []])
.filter(([, values]) => values.length > 0),
.map(file => [file, readFileSync(file, 'utf8').match(regex) || []])
.filter(([, values]) => values && values.length > 0),
);
}

View File

@@ -0,0 +1,23 @@
const url =
'https://raw.githubusercontent.com/google/material-design-icons/master/' +
'variablefont/MaterialSymbolsRounded%5BFILL%2CGRAD%2Copsz%2Cwght%5D.codepoints';
export async function fetchCodePointMap() {
const icons = await fetch(url)
.then(it => it.text())
.then(it => new Map(it.split('\n').map(it => /** @type {[string, string]} */ (it.split(' ')))));
if (icons.size < 100) throw new Error(`Code point map is very small, is the URL incorrect? ${url}`);
return icons;
}
/**
* @param {string[]} icons
*/
export async function getCodePoints(icons) {
const codePoints = await fetchCodePointMap();
return icons.map(icon => {
const code = codePoints.get(icon);
if (!code) throw new Error(`Code point for icon ${icon} not found`);
return code;
});
}

View File

@@ -19,5 +19,4 @@ export interface IconConfig {
inputPath: string;
outputPath: string;
additionalIcons?: {[purpose: string]: string[]};
codePoints?: {[name: string]: string};
}

View File

@@ -14,16 +14,17 @@
*/
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import fontkit from 'fontkit';
import {exec} from 'child_process';
import config from '../icons.config';
import config from '../icons.config.mjs';
import {statSync} from 'fs';
import {getUsedIconsHtml, getUsedIconsTS} from './gather-used-icons';
import {getUsedIconsHtml, getUsedIconsTS} from './gather-used-icons.mjs';
import {getCodePoints} from './get-code-points.mjs';
/**
*
* @param {string[] | string} command
* @returns {Promise<string>}
*/
async function run(command: string[] | string): Promise<string> {
async function run(command) {
const fullCommand = Array.isArray(command) ? command.join(' ') : command;
console.log(`>> ${fullCommand}`);
@@ -44,7 +45,8 @@ async function run(command: string[] | string): Promise<string> {
*
*/
async function minifyIconFont() {
const icons = new Set<string>();
/** @type {Set<string>} */
const icons = new Set();
for (const iconSet of [
...Object.values(config.additionalIcons || []),
@@ -56,35 +58,7 @@ async function minifyIconFont() {
}
}
console.log('Icons used:', [...icons.values()].sort());
const font = fontkit.openSync(config.inputPath);
const glyphs: string[] = ['5f-7a', '30-39'];
for (const icon of icons) {
const iconGlyphs = font.layout(icon).glyphs;
if (iconGlyphs.length === 0) {
console.error(`${icon} not found in font. Typo?`);
process.exit(-1);
}
const codePoints = iconGlyphs
.flatMap(it => font.stringsForGlyph(it.id))
.flatMap(it => [...it])
.map(it => it.codePointAt(0)!.toString(16));
if (codePoints.length === 0) {
if (config.codePoints?.[icon]) {
glyphs.push(config.codePoints[icon]);
} else {
console.log();
console.error(`${icon} code point could not be determined. Add it to config.codePoints.`);
process.exit(-1);
}
}
glyphs.push(...codePoints);
}
glyphs.sort();
const glyphs = ['5f-7a', '30-39', ...(await getCodePoints([...icons]))].sort();
console.log(
await run([
@@ -114,8 +88,10 @@ minifyIconFont();
/**
* Bytes to respective units
* @param {number} value
* @returns {string}
*/
function toByteUnit(value: number): string {
function toByteUnit(value) {
if (value < 1024) {
return `${value}B`;
} else if (value < 1024 * 1024) {