Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

NAS-130954 / 25.04 / Icon sprite #10788

Merged
merged 7 commits into from
Oct 3, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 28 additions & 19 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,31 +6,34 @@
"type": "module",
"homepage": "https://github.com/truenas/webui/",
"scripts": {
"ng": "ng",
"commitlint": "commitlint",
"check-env": "cd $(git rev-parse --show-toplevel) && yarn run ui check-env",
"start": "yarn run check-env && node --max_old_space_size=8192 ./node_modules/@angular/cli/bin/ng serve --proxy-config proxy.config.json",
"start:prod": "node --max_old_space_size=8192 ./node_modules/@angular/cli/bin/ng serve --configuration production",
"prebuild": "yarn icons",
"prestart": "yarn icons",
"build": "yarn run clean:dist && node ./setup-production-env.js && node --max_old_space_size=8192 node_modules/@angular/cli/bin/ng build",
"build:prod": "node ./setup-production-env.js && node scripts/setup_prod.js && yarn run build --configuration production",
"build:prod:aot": "node ./setup-production-env.js && yarn run build:prod --base-href /ui/",
"test": "jest",
"test:watch": "jest --watch",
"test:ci": "jest --runInBand",
"test:pr": "yarn run check-env && echo 'Setting up temporary environment file...\\n' && yarn run ui remote -i 'headless.local' && jest --coverage --maxWorkers=2",
"test:changed": "node scripts/test_changed.js",
"lint": "ng lint && stylelint 'src/**/*.scss'",
"lint:fix": "ng lint --fix && stylelint --fix 'src/**/*.scss'",
"clean:dist": "rimraf dist",
"check-env": "cd $(git rev-parse --show-toplevel) && yarn run ui check-env",
"clean:coverage": "rimraf coverage",
"reinstall": "rimraf yarn.lock; rimraf node_modules; yarn cache clean -f; yarn install",
"clean:dist": "rimraf dist",
"commitlint": "commitlint",
"compile-grammar": "lezer-generator --typeScript --noTerms src/app/modules/forms/search-input/services/query-parser/query.grammar -o src/app/modules/forms/search-input/services/query-parser/query-grammar.ts",
"extract": "node scripts/extract_strings.js",
"extract-ui-search-elements": "tsx scripts/ui-search/extract-ui-search-elements.ts",
"validate-translations": "node scripts/validate_translations.js",
"lint": "ng lint && stylelint 'src/**/*.scss'",
"lint:fix": "ng lint --fix && stylelint --fix 'src/**/*.scss'",
"icons": "tsx scripts/icon-sprite/make-sprite.ts",
"ng": "ng",
"prepare": "husky",
"ui": "cd $(git rev-parse --show-toplevel) && tsx ./scripts/ui/ui.ts",
"reinstall": "rimraf yarn.lock; rimraf node_modules; yarn cache clean -f; yarn install",
"start": "yarn run check-env && node --max_old_space_size=8192 ./node_modules/@angular/cli/bin/ng serve --proxy-config proxy.config.json",
"start:prod": "node --max_old_space_size=8192 ./node_modules/@angular/cli/bin/ng serve --configuration production",
"strict-null-checks": "node_modules/typescript/bin/tsc --project tsconfig.strictNullChecks.json",
"compile-grammar": "lezer-generator --typeScript --noTerms src/app/modules/forms/search-input/services/query-parser/query.grammar -o src/app/modules/forms/search-input/services/query-parser/query-grammar.ts"
"test": "jest",
"test:changed": "node scripts/test_changed.js",
"test:ci": "jest --runInBand",
"test:pr": "yarn run check-env && echo 'Setting up temporary environment file...\\n' && yarn run ui remote -i 'headless.local' && jest --coverage --maxWorkers=2",
"test:watch": "jest --watch",
"ui": "cd $(git rev-parse --show-toplevel) && tsx ./scripts/ui/ui.ts",
"validate-translations": "node scripts/validate_translations.js"
},
"repository": {
"type": "git",
Expand Down Expand Up @@ -78,8 +81,8 @@
"@lezer/common": "~1.2.1",
"@lezer/generator": "~1.7.1",
"@lezer/lr": "~1.4.2",
"@material-design-icons/font": "~0.14.13",
"@mdi/font": "~7.4.47",
"@material-design-icons/svg": "~0.14.13",
"@mdi/svg": "~7.4.47",
"@messageformat/core": "~3.3.0",
"@ngneat/reactive-forms": "~5.0.2",
"@ngneat/spectator": "~19.0.0",
Expand All @@ -102,13 +105,16 @@
"@types/dygraphs": "^2.1.10",
"@types/figlet": "~1.5.5",
"@types/fontfaceobserver": "^2.1.3",
"@types/glob": "~7.2.0",
"@types/jest": "~29.5.13",
"@types/jest-when": "^3.5.5",
"@types/js-yaml": "~4.0.8",
"@types/lodash-es": "~4.17.12",
"@types/mime-types": "~2.1.1",
"@types/node": "^18.19.1",
"@types/randomcolor": "~0.5.9",
"@types/svg-sprite": "~0.0.39",
"@types/vinyl": "~2.0.12",
"@typescript-eslint/eslint-plugin": "~6.18.1",
"@typescript-eslint/parser": "~6.18.1",
"@vendure/ngx-translate-extract": "~9.2.1",
Expand Down Expand Up @@ -148,6 +154,7 @@
"figlet": "~1.7.0",
"fontfaceobserver": "^2.3.0",
"fuse.js": "~7.0.0",
"glob": "7.2.3",
"html2canvas": "~1.4.1",
"husky": "^9.1.6",
"immer": "~10.1.1",
Expand Down Expand Up @@ -186,12 +193,14 @@
"stylelint": "^14.9.1",
"stylelint-config-sass-guidelines": "~9.0.1",
"stylelint-config-standard": "^26.0.0",
"svg-sprite": "~2.0.4",
"text-security": "~3.2.1",
"ts-jest": "~29.2.5",
"tsconfig-paths": "~4.2.0",
"tsx": "~4.19.1",
"typescript": "~5.5.4",
"utility-types": "~3.11.0",
"vinyl": "~3.0.0",
"zone.js": "~0.14.10"
},
"lint-staged": {
Expand Down
25 changes: 25 additions & 0 deletions scripts/icon-sprite/lib/add-custom-icons.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import fs from 'fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { resolve } from 'path';

export function addCustomIcons(usedIcons: Set<string>): Set<string> {
// TODO: Can be simplified in node 20.11+
// eslint-disable-next-line @typescript-eslint/naming-convention
const __dirname = path.dirname(fileURLToPath(import.meta.url));

const customIconsPath = resolve(__dirname, '../../../src/assets/icons/custom');

const customIcons = new Set<string>();

fs.readdirSync(customIconsPath).forEach((filename) => {
const icon = `ix-${filename.replace('.svg', '')}`;
if (!usedIcons.has(icon)) {
console.warn(`Custom icon "${icon}" does not appear to be used in the application.`);
}

customIcons.add(icon);
});

return new Set([...customIcons, ...usedIcons]);
}
25 changes: 25 additions & 0 deletions scripts/icon-sprite/lib/build-sprite.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import fs from 'fs';
import Spriter from 'svg-sprite';
import File from 'vinyl';

export type SpriteResult = Record<string, { sprite: File }>;

export async function buildSprite(icons: Map<string, string>): Promise<SpriteResult> {
const spriter = new Spriter({
mode: {
stack: true,
},
});

icons.forEach((path, name) => {
try {
spriter.add(name, null, fs.readFileSync(path, 'utf-8'));
} catch (error) {
console.error(`Failed to add icon "${name}": `);
throw error;
}
});

const { result } = await spriter.compileAsync() as { result: SpriteResult };
return result;
}
23 changes: 23 additions & 0 deletions scripts/icon-sprite/lib/find-icons-in-templates.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import fs from 'fs';
import * as cheerio from 'cheerio';
import glob from 'glob';

export function findIconsInTemplates(path: string): Set<string> {
const iconNames = new Set<string>();

const templates = glob.sync(`${path}/**/*.html`);

templates.forEach((template) => {
const content = fs.readFileSync(template, 'utf-8');
const parsedTemplate = cheerio.load(content);

parsedTemplate('ix-icon').each((_, iconTag) => {
const name = parsedTemplate(iconTag).attr('name');
if (name) {
iconNames.add(name);
}
});
});

return iconNames;
}
18 changes: 18 additions & 0 deletions scripts/icon-sprite/lib/find-icons-with-marker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { execSync } from 'node:child_process';

export function findIconsWithMarker(path: string): Set<string> {
const command = `grep -rEo "iconMarker\\\\('[^']+'" --include="*.ts" --include="*.html" ${path}`;

const icons = new Set<string>();
const output = execSync(command, { encoding: 'utf-8' });
output
.split('\n')
.filter(Boolean)
.forEach((line) => {
const [, match] = line.split(':');
const value = match.match(/'([^']+)'/)[1];
icons.add(value);
});

return icons;
}
19 changes: 19 additions & 0 deletions scripts/icon-sprite/lib/get-icon-paths.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
export function getIconPaths(names: Set<string>): Map<string, string> {
const iconPaths = new Map<string, string>();

names.forEach((name) => {
if (name.startsWith('ix-')) {
iconPaths.set(name, `src/assets/icons/custom/${name.slice(3)}.svg`);
return;
}

if (name.startsWith('mdi-')) {
iconPaths.set(name, `node_modules/@mdi/svg/svg/${name.slice(4)}.svg`);
return;
}

iconPaths.set(name, `node_modules/@material-design-icons/svg/filled/${name}.svg`);
});

return iconPaths;
}
13 changes: 13 additions & 0 deletions scripts/icon-sprite/lib/warn-about-duplicates.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export function warnAboutDuplicates(icons: Set<string>): void {
icons.forEach((icon) => {
if (icon.startsWith('mdi-')) {
return;
}

if (!icons.has(`mdi-${icon}`)) {
return;
}

console.warn(`Both "${icon}" and "mdi-${icon}" are used in the application. Consider only using the 'mdi' version.`);
});
}
50 changes: 50 additions & 0 deletions scripts/icon-sprite/make-sprite.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import fs from 'fs';
import * as path from 'node:path';
import { fileURLToPath } from 'node:url';
import { resolve } from 'path';
import { addCustomIcons } from './lib/add-custom-icons';
import { buildSprite } from './lib/build-sprite';
import { findIconsInTemplates } from './lib/find-icons-in-templates';
import { findIconsWithMarker } from './lib/find-icons-with-marker';
import { getIconPaths } from './lib/get-icon-paths';
import { warnAboutDuplicates } from './lib/warn-about-duplicates';

async function makeSprite(): Promise<void> {
try {
// TODO: Can be simplified in node 20.11+
// eslint-disable-next-line @typescript-eslint/naming-convention
const __dirname = path.dirname(fileURLToPath(import.meta.url));

const srcDir = resolve(__dirname, '../../src');
const targetPath = resolve(__dirname, '../../src/assets/icons/sprite.svg');

const templateIcons = findIconsInTemplates(srcDir);
const markerIcons = findIconsWithMarker(srcDir);
const usedIcons = new Set([...templateIcons, ...markerIcons]);

const allIcons = addCustomIcons(usedIcons);

warnAboutDuplicates(allIcons);

if (!allIcons.size) {
throw new Error('No icons found in the project.');
}

const icons = getIconPaths(allIcons);

const result = await buildSprite(icons);
const file = Object.values(result)[0].sprite;

const buffer = file.contents as Buffer;
const size = buffer.length / 1024;

fs.writeFileSync(targetPath, buffer);

console.info(`Generated icon sprite with ${allIcons.size} icons (${size.toFixed(2)} KiB).`);
} catch (error: unknown) {
console.error('Error when building the icon sprite:');
throw error;
}
}

makeSprite();
2 changes: 0 additions & 2 deletions scripts/ui-search/find-component-files.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable import/no-extraneous-dependencies */
import glob from 'glob';

export function findComponentFiles(pattern: string): Promise<string[]> {
Expand Down
2 changes: 1 addition & 1 deletion src/app/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import { createTranslateLoader } from 'app/core/classes/icu-translations-loader'
import { MockEnclosureWebsocketService } from 'app/core/testing/mock-enclosure/mock-enclosure-websocket.service';
import { getWindow, WINDOW } from 'app/helpers/window.helper';
import { FeedbackModule } from 'app/modules/feedback/feedback.module';
import { IxIconRegistry } from 'app/modules/ix-icon/ix-icon.service';
import { IxIconRegistry } from 'app/modules/ix-icon/ix-icon-registry.service';
import { SnackbarModule } from 'app/modules/snackbar/snackbar.module';
import { TwoFactorGuardService } from 'app/services/auth/two-factor-guard.service';
import { ErrorHandlerService } from 'app/services/error-handler.service';
Expand Down
5 changes: 3 additions & 2 deletions src/app/helptext/topbar.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { marker as T } from '@biesbjerg/ngx-translate-extract-marker';
import { helptextGlobal } from 'app/helptext/global-helptext';
import { iconMarker } from 'app/modules/ix-icon/icon-marker.util';

export const helptextTopbar = {
ha_status: T('HA Status'),
Expand Down Expand Up @@ -55,7 +56,7 @@ Login or signup is required.'),
tcDeregisterBtn: T('Deregister'),
tcDeregisterDialog: {
title: T('Deregister TrueCommand Cloud Service'),
icon: 'warning',
icon: iconMarker('warning'),
message: T('Are you sure you want to deregister TrueCommand Cloud Service?'),
confirmBtnMsg: T('Confirm'),
},
Expand All @@ -67,7 +68,7 @@ Login or signup is required.'),

stopTCConnectingDialog: {
title: T('Stop TrueCommand Cloud Connection'),
icon: 'warning',
icon: iconMarker('warning'),
message: T('Are you sure you want to stop connecting to the TrueCommand Cloud Service?'),
confirmBtnMsg: T('Confirm'),
},
Expand Down
3 changes: 2 additions & 1 deletion src/app/interfaces/empty-config.interface.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { EmptyType } from 'app/enums/empty-type.enum';
import { MarkedIcon } from 'app/modules/ix-icon/icon-marker.util';

export interface EmptyConfig {
type?: EmptyType;
large?: boolean;
compact?: boolean;
title: string;
message?: string;
icon?: string;
icon?: MarkedIcon;
button?: {
label: string;
action: () => void;
Expand Down
3 changes: 2 additions & 1 deletion src/app/interfaces/menu-item.interface.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Observable } from 'rxjs';
import { MarkedIcon } from 'app/modules/ix-icon/icon-marker.util';

export enum MenuItemType {
Link = 'link',
Expand All @@ -9,7 +10,7 @@ export interface MenuItem {
type: MenuItemType;
name?: string; // Used as display text for item and title for separator type
state?: string;
icon?: string;
icon?: MarkedIcon;
tooltip?: string;
sub?: SubMenuItem[];
isVisible$?: Observable<boolean>;
Expand Down
Loading
Loading