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).
}