diff --git a/.gitignore b/.gitignore index 4d221f5fa..0bca4e881 100644 --- a/.gitignore +++ b/.gitignore @@ -70,6 +70,7 @@ Thumbs.db /src/assets/lib /src/assets/lang/* /src/assets/env.json +/src/assets/fonts/icons.json /moodle.config.*.json !/moodle.config.example.json diff --git a/gulp/task-build-icons-json.js b/gulp/task-build-icons-json.js new file mode 100644 index 000000000..2d2717f8b --- /dev/null +++ b/gulp/task-build-icons-json.js @@ -0,0 +1,118 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +const { writeFile, readdirSync, statSync, readFileSync } = require('fs'); + +const FONTS_PATH = 'src/assets/fonts'; +const ICONS_JSON_FILE_PATH = 'src/assets/fonts/icons.json'; + +/** + * Get object with the map of icons for all fonts. + * + * @returns Icons map. + */ +function getIconsMap() { + const config = JSON.parse(readFileSync('moodle.config.json')); + let icons = {}; + + const fonts = readdirSync(FONTS_PATH); + fonts.forEach(font => { + const fontPath = `${FONTS_PATH}/${font}`; + if (statSync(fontPath).isFile()) { + // Not a font, ignore. + return; + } + + icons = { + ...icons, + ...getFontIconsMap(config.iconsPrefixes, font, fontPath), + }; + }); + + return icons; +} + +/** + * Get object with the map of icons for a certain font. + * + * @param prefixes Prefixes to add to the icons. + * @param fontName Font name. + * @param fontPath Font path. + * @returns Icons map. + */ +function getFontIconsMap(prefixes, fontName, fontPath) { + const icons = {}; + const fontLibraries = readdirSync(fontPath); + + fontLibraries.forEach(libraryName => { + const libraryPath = `${fontPath}/${libraryName}`; + if (statSync(libraryPath).isFile()) { + // Not a font library, ignore. + return; + } + + const libraryPrefixes = prefixes?.[fontName]?.[libraryName]; + if (!libraryPrefixes || !libraryPrefixes.length) { + console.warn(`WARNING: There is no prefix for the library ${fontName}/${libraryName}. Adding icons without prefix is ` + + 'discouraged, please add a prefix for your library in moodle.config.json file, in the iconsPrefixes property.'); + } + + const libraryIcons = readdirSync(libraryPath); + libraryIcons.forEach(iconFileName => { + if (!iconFileName.endsWith('.svg')) { + // Only accept svg files. + return; + } + + if (iconFileName.includes('_')) { + throw Error(`Icon ${libraryPath}/${iconFileName} has an invalid name, it cannot contain '_'. ` + + 'Please rename it to use \'-\' instead.'); + } + + const iconName = iconFileName.replace('.svg', ''); + const iconPath = `${libraryPath}/${iconFileName}`.replace('src/', ''); + + if (!libraryPrefixes || !libraryPrefixes.length) { + icons[iconName] = iconPath; + return; + } + + libraryPrefixes.forEach(prefix => { + icons[`${prefix}-${iconName}`] = iconPath; + }); + }); + }); + + return icons; +} + +/** + * Task to build a JSON file with the list of icons to add to Ionicons. + */ +class BuildIconsJsonTask { + + /** + * Run the task. + * + * @param done Function to call when done. + */ + run(done) { + const icons = getIconsMap(); + + writeFile(ICONS_JSON_FILE_PATH, JSON.stringify(icons), done); + } + +} + +module.exports = BuildIconsJsonTask; diff --git a/gulpfile.js b/gulpfile.js index e7f127210..75ec00a39 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -15,6 +15,7 @@ const BuildLangTask = require('./gulp/task-build-lang'); const BuildBehatPluginTask = require('./gulp/task-build-behat-plugin'); const BuildEnvTask = require('./gulp/task-build-env'); +const BuildIconsJsonTask = require('./gulp/task-build-icons-json'); const PushTask = require('./gulp/task-push'); const OverrideLangTask = require('./gulp/task-override-lang'); const Utils = require('./gulp/utils'); @@ -47,6 +48,10 @@ gulp.task('env', (done) => { new BuildEnvTask().run(done); }); +gulp.task('icons', (done) => { + new BuildIconsJsonTask().run(done); +}); + // Build a Moodle plugin to run Behat tests. if (BuildBehatPluginTask.isBehatConfigured()) { gulp.task('behat', (done) => { @@ -63,6 +68,7 @@ gulp.task( gulp.parallel([ 'lang', 'env', + 'icons', ...(BuildBehatPluginTask.isBehatConfigured() ? ['behat'] : []) ]), ); diff --git a/moodle.config.json b/moodle.config.json index dbf81aa21..33aeb6439 100644 --- a/moodle.config.json +++ b/moodle.config.json @@ -111,5 +111,16 @@ "long": 3500, "sticky": 0 }, - "disableTokenFile": false + "disableTokenFile": false, + "iconsPrefixes": { + "font-awesome": { + "brands": ["fab"], + "regular": ["far"], + "solid": ["fa", "fas"] + }, + "moodle": { + "font-awesome": ["fam"], + "moodle": ["moodle"] + } + } } diff --git a/src/assets/fonts/moodle/moodle/agg_mean.svg b/src/assets/fonts/moodle/moodle/agg-mean.svg similarity index 99% rename from src/assets/fonts/moodle/moodle/agg_mean.svg rename to src/assets/fonts/moodle/moodle/agg-mean.svg index c10adf743..39854b56b 100644 --- a/src/assets/fonts/moodle/moodle/agg_mean.svg +++ b/src/assets/fonts/moodle/moodle/agg-mean.svg @@ -1 +1 @@ - + diff --git a/src/assets/fonts/moodle/moodle/agg_sum.svg b/src/assets/fonts/moodle/moodle/agg-sum.svg similarity index 99% rename from src/assets/fonts/moodle/moodle/agg_sum.svg rename to src/assets/fonts/moodle/moodle/agg-sum.svg index 6108dc6df..1f13bb083 100644 --- a/src/assets/fonts/moodle/moodle/agg_sum.svg +++ b/src/assets/fonts/moodle/moodle/agg-sum.svg @@ -1 +1 @@ - + diff --git a/src/core/directives/fa-icon.ts b/src/core/directives/fa-icon.ts index c80b8b643..16807780a 100644 --- a/src/core/directives/fa-icon.ts +++ b/src/core/directives/fa-icon.ts @@ -15,6 +15,7 @@ import { AfterViewInit, Directive, ElementRef, Input, OnChanges, SimpleChange } from '@angular/core'; import { CoreLogger } from '@singletons/logger'; import { CoreIcons } from '@singletons/icons'; +import { CoreConstants } from '../constants'; /** * Directive to enable font-awesome 6.4 as ionicons. @@ -41,65 +42,27 @@ export class CoreFaIconDirective implements AfterViewInit, OnChanges { } /** - * Detect icon name and use svg. + * Validate icon, e.g. checking if it's using a deprecated name. */ - async setIcon(): Promise { - let library = ''; - let iconName = this.name; - let font = 'ionicons'; - const parts = iconName.split('-', 2); - if (parts.length === 2) { - switch (parts[0]) { - case 'far': - library = 'regular'; - font = 'font-awesome'; - break; - case 'fa': - case 'fas': - library = 'solid'; - font = 'font-awesome'; - break; - case 'fab': - library = 'brands'; - font = 'font-awesome'; - break; - case 'moodle': - library = 'moodle'; - font = 'moodle'; - break; - case 'fam': - library = 'font-awesome'; - font = 'moodle'; - break; - default: - break; - } + async validateIcon(): Promise { + if (CoreConstants.BUILD.isDevelopment && !CoreIcons.isIconNamePrefixed(this.name)) { + this.logger.warn(`Not prefixed icon ${this.name} detected, it could be an Ionic icon. Font-awesome is preferred.`); } - if (font === 'ionicons') { - this.element.removeAttribute('src'); - this.logger.warn(`Ionic icon ${this.name} detected`); - - return; + if (this.name.includes('_')) { + // Ionic icons cannot contain a '_' in the name, Ionic doesn't load them, replace it with '-'. + this.logger.warn(`Icon ${this.name} contains '_' character and it's not allowed, replacing it with '-'.`); + this.updateName(this.name.replace(/_/g, '-')); } - iconName = iconName.substring(parts[0].length + 1); - - // Set it here to avoid loading unexisting icon paths (svg/iconName) caused by the tick delay of the checkIconAlias promise. - let src = CoreIcons.getIconSrc(font, library, iconName); - this.element.setAttribute('src', src); - - if (font === 'font-awesome') { + if (this.name.match(/^fa[brs]?-/)) { + // It's a font-awesome icon, check if it's using a deprecated name. + const iconName = this.name.substring(this.name.indexOf('-') + 1); const { fileName } = await CoreIcons.getFontAwesomeIconFileName(iconName); if (fileName !== iconName) { - src = CoreIcons.getIconSrc(font, library, fileName); - this.element.setAttribute('src', src); + this.updateName(this.name.replace(iconName, fileName)); } } - - this.element.classList.add('faicon'); - CoreIcons.validateIcon(this.name, src); - } /** @@ -123,7 +86,17 @@ export class CoreFaIconDirective implements AfterViewInit, OnChanges { return; } - this.setIcon(); + this.validateIcon(); + } + + /** + * Update the icon name. + * + * @param newName New name to use. + */ + protected updateName(newName: string): void { + this.name = newName; + this.element.setAttribute('name', newName); } } diff --git a/src/core/features/grades/services/grades-helper.ts b/src/core/features/grades/services/grades-helper.ts index 40d07e1e4..c85353011 100644 --- a/src/core/features/grades/services/grades-helper.ts +++ b/src/core/features/grades/services/grades-helper.ts @@ -540,11 +540,11 @@ export class CoreGradesHelperProvider { text = text.replace('%2F', '/').replace('%2f', '/'); if (text.indexOf('/agg_mean') > -1) { row.itemtype = 'agg_mean'; - row.icon = 'moodle-agg_mean'; + row.icon = 'moodle-agg-mean'; row.iconAlt = Translate.instant('core.grades.aggregatemean'); } else if (text.indexOf('/agg_sum') > -1) { row.itemtype = 'agg_sum'; - row.icon = 'moodle-agg_sum'; + row.icon = 'moodle-agg-sum'; row.iconAlt = Translate.instant('core.grades.aggregatesum'); } else if (text.indexOf('/outcomes') > -1 || text.indexOf('fa-tasks') > -1 || text.indexOf('fa-list-check') > -1) { row.itemtype = 'outcome'; diff --git a/src/core/initializers/initialize-icons.ts b/src/core/initializers/initialize-icons.ts new file mode 100644 index 000000000..7595267e7 --- /dev/null +++ b/src/core/initializers/initialize-icons.ts @@ -0,0 +1,22 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { CoreIcons } from '@singletons/icons'; + +/** + * Add custom icons to Ionicons. + */ +export default function(): void { + CoreIcons.addIconsToIonicons(); +} diff --git a/src/core/singletons/icons.ts b/src/core/singletons/icons.ts index dec18251e..284020935 100644 --- a/src/core/singletons/icons.ts +++ b/src/core/singletons/icons.ts @@ -12,26 +12,29 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Http } from '@singletons'; import { CoreConstants } from '../constants'; import { CoreLogger } from './logger'; import aliases from '@/assets/fonts/font-awesome/aliases.json'; -import { firstValueFrom } from 'rxjs'; +import { addIcons } from 'ionicons'; +import icons from '@/assets/fonts/icons.json'; /** * Singleton with helper functions for icon management. */ export class CoreIcons { - /** - * Object used to store whether icons exist or not during development. - */ - private static readonly DEV_ICONS_STATUS: Record> = {}; - private static readonly ALIASES = { ...aliases } as unknown as Record; + private static readonly CUSTOM_ICONS = { ...icons } as unknown as Record; protected static logger = CoreLogger.getInstance('CoreIcons'); + /** + * Add custom icons to Ionicons. + */ + static addIconsToIonicons(): void { + addIcons(CoreIcons.CUSTOM_ICONS); + } + /** * Check icon alias and returns the new icon name. * @@ -55,30 +58,24 @@ export class CoreIcons { } /** - * Validate that an icon exists, or show warning otherwise (only in development and testing environments). + * Validate that an icon exists in the list of custom icons for the app, or show warning otherwise + * (only in development and testing environments). * - * @param name Icon name. - * @param src Icon source url. + * @param font Font Family. + * @param library Library to use. + * @param icon Icon Name. */ - static validateIcon(name: string, src: string): void { + static validateIcon(font: string, library: string, icon: string): void { if (!CoreConstants.BUILD.isDevelopment && !CoreConstants.BUILD.isTesting) { return; } - if (!(src in CoreIcons.DEV_ICONS_STATUS)) { - CoreIcons.DEV_ICONS_STATUS[src] = firstValueFrom(Http.get(src, { responseType: 'text' })) - .then(() => true) - .catch(() => false); + if ( + CoreIcons.CUSTOM_ICONS[icon] === undefined && + CoreIcons.CUSTOM_ICONS[CoreIcons.prefixIconName(font, library, icon)] === undefined + ) { + this.logger.error(`Icon ${icon} not found`); } - - // eslint-disable-next-line promise/catch-or-return - CoreIcons.DEV_ICONS_STATUS[src].then(exists => { - if (exists) { - return; - } - - return this.logger.error(`Icon ${name} not found`); - }); } /** @@ -174,8 +171,7 @@ export class CoreIcons { newIcon.setAttribute('src', src); - newIcon.classList.add('faicon'); - CoreIcons.validateIcon(iconName, src); + CoreIcons.validateIcon('font-awesome', library, iconName); icon.parentElement?.insertBefore(newIcon, icon); icon.remove(); @@ -195,4 +191,34 @@ export class CoreIcons { return `assets/fonts/${font}/${library}/${icon}.svg`; } + /** + * Prefix an icon name using the library prefix. + * + * @param font Font Family. + * @param library Library to use. + * @param icon Icon Name. + * @returns Prefixed icon name. + */ + static prefixIconName(font: string, library: string, icon: string): string { + const prefixes = CoreConstants.CONFIG.iconsPrefixes?.[font]?.[library]; + if (!prefixes || !prefixes.length) { + return icon; + } + + return `${prefixes[0]}-${icon}`; + } + + /** + * Check if an icon name contains any of the prefixes configured for icons. + * If it doesn't then it probably is an Ionicon. + * + * @param icon Icon Name. + * @returns Whether icon is prefixed. + */ + static isIconNamePrefixed(icon: string): boolean { + return Object.values(CoreConstants.CONFIG.iconsPrefixes ?? {}) + .some(library => Object.values(library) + .some(prefixes => prefixes.some(prefix => icon.startsWith(`${prefix}-`)))); + } + } diff --git a/src/core/singletons/tests/icons.test.ts b/src/core/singletons/tests/icons.test.ts index e2abc8a90..9bd18d033 100644 --- a/src/core/singletons/tests/icons.test.ts +++ b/src/core/singletons/tests/icons.test.ts @@ -12,6 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +import { CoreConstants } from '@/core/constants'; import { CoreIcons } from '@singletons/icons'; describe('CoreIcons singleton', () => { @@ -81,4 +82,43 @@ describe('CoreIcons singleton', () => { .toEqual('assets/fonts/font-awesome/solid/square-check.svg'); }); + it('prefixes icons names', () => { + // Arrange. + CoreConstants.CONFIG.iconsPrefixes = { + foo: { + bar: ['fo', 'for'], + baz: ['foz'], + }, + lorem: { + ipsum: ['lorip'], + }, + }; + + // Act and assert. + expect(CoreIcons.prefixIconName('foo', 'bar', 'myicon')).toEqual('fo-myicon'); + expect(CoreIcons.prefixIconName('foo', 'baz', 'myicon')).toEqual('foz-myicon'); + expect(CoreIcons.prefixIconName('lorem', 'ipsum', 'myicon')).toEqual('lorip-myicon'); + expect(CoreIcons.prefixIconName('invalid', 'invalid', 'myicon')).toEqual('myicon'); + }); + + it('check if an icon is prefixed', () => { + // Arrange. + CoreConstants.CONFIG.iconsPrefixes = { + foo: { + bar: ['fo', 'for'], + baz: ['foz'], + }, + lorem: { + ipsum: ['lorip'], + }, + }; + + // Act and assert. + expect(CoreIcons.isIconNamePrefixed('fo-myicon')).toEqual(true); + expect(CoreIcons.isIconNamePrefixed('foz-myicon')).toEqual(true); + expect(CoreIcons.isIconNamePrefixed('lorip-myicon')).toEqual(true); + expect(CoreIcons.isIconNamePrefixed('myicon')).toEqual(false); + expect(CoreIcons.isIconNamePrefixed('fox-myicon')).toEqual(false); + }); + }); diff --git a/src/types/config.d.ts b/src/types/config.d.ts index 4c6679c9c..2b6f6ffc1 100644 --- a/src/types/config.d.ts +++ b/src/types/config.d.ts @@ -75,4 +75,5 @@ export interface EnvironmentConfig { disableTokenFile: boolean; // Disable the use of tokenpluginfile.php for downloading files (so it fallbacks to pluginfile.php) demoMode?: boolean; // Whether to run the app in "demo mode". hideInformativeLinks?: boolean; // Whether to hide informative links. + iconsPrefixes?: Record>; // Prefixes for custom font icons (located in src/assets/fonts). }