@typo3/icons
Version:
SVG icons for the TYPO3 CMS backend
835 lines (743 loc) • 26.4 kB
JavaScript
//
// Require
//
const fs = require('fs');
const pkg = require('./package.json');
const path = require('path');
const { deleteAsync, deleteSync } = require('del');
const twig = require('gulp-twig');
const svgmin = require('gulp-svgmin');
const rename = require('gulp-rename');
const gulp = require('gulp');
const svgSprite = require('gulp-svg-sprite');
const yaml = require('js-yaml');
const merge = require('deepmerge');
const execSync = require('child_process').execSync;
const { exec } = require('child_process');
const minifyCSS = require('gulp-clean-css');
const sass = require('gulp-sass')(require('sass'));
//
// Options
//
const options = {
rendering: {
actions: {
preview: true,
buttons: true,
},
apps: {
preview: true,
buttons: true,
},
avatar: {
preview: true,
buttons: true,
},
content: {
preview: true,
buttons: true,
},
default: {
preview: true,
buttons: true,
},
files: {
preview: true,
},
form: {
preview: true,
buttons: true,
},
information: {
preview: true,
},
install: {
preview: true,
},
mimetypes: {
preview: true,
buttons: true,
},
miscellaneous: {
preview: true,
buttons: true,
},
module: {
preview: true,
menuview: true,
},
overlay: {
overlay: true,
},
spinner: {
preview: true,
buttons: true,
spinning: true,
},
status: {
preview: true,
buttons: true,
},
},
src: './src/',
dist: './dist/',
dist_svgs: './dist/svgs/',
dist_sprites: './dist/sprites/',
dist_scss: './dist/scss/',
assets: './assets/',
site: './_site/',
meta: './meta/',
material: './material/'
};
//
// Custom Functions
//
function getFolders(dir) {
return fs.readdirSync(dir)
.filter(function (file) {
return fs.statSync(path.join(dir, file)).isDirectory();
});
}
function getIcons(dir) {
return fs.readdirSync(dir)
.filter(function (file) {
var fileExtension = path.extname(path.join(dir, file));
if (fileExtension === ".svg") {
return true;
} else {
return false;
}
});
}
function getMetaFiles(dir) {
return fs.readdirSync(dir)
.filter(function (file) {
var fileExtension = path.extname(path.join(dir, file));
if (fileExtension === ".yaml") {
return true;
} else {
return false;
}
});
}
function getCategories() {
let categories = getFolders(options.src);
return categories.map((category) => {
let icons = getIcons(path.join(options.dist_svgs, category)).map((icon) => { return path.basename(icon, '.svg') });
return {
identifier: category,
title: category.charAt(0).toUpperCase() + category.slice(1),
sprite: 'sprites/' + category + '.svg',
rendering: options.rendering[category] ?? {},
icons: icons,
count: icons.length
};
});
}
function getMetaDataFileName(identifier, folder) {
return path.join(options.meta, folder, identifier + '.yaml' );
}
function getMetaData(identifier, folder) {
let defaultmeta = {
alias: [
],
changes: [
],
tags: [
]
};
let metafile = path.join(options.meta, folder, identifier + '.yaml' );
let metadata = {};
if (fs.existsSync(metafile)) {
metadata = yaml.load(fs.readFileSync(metafile, 'utf8'));
}
return merge.all([defaultmeta, metadata]);
}
function updateMetaData(identifier, folder, metadata) {
let metafile = getMetaDataFileName(identifier, folder);
if (!fs.existsSync(path.dirname(metafile))){
fs.mkdirSync(path.dirname(metafile));
}
fs.writeFileSync(metafile, yaml.dump(metadata), 'utf8');
}
function getVersionChanges(identifier, folder) {
let filename = path.join(options.src, folder, identifier + '.svg');
let commithashes = execSync('git log --find-renames --find-renames=100% --pretty=format:"%H" ' + filename).toString().split("\n");
let fileversions = {};
for (let key in commithashes) {
let hashversions = execSync('git tag --contains ' + commithashes[key]).toString().split("\n");
for (let versionKey in hashversions) {
if (hashversions[versionKey].length > 0) {
hashversion = hashversions[versionKey].charAt(0) === 'v' ? hashversions[versionKey].substr(1) : hashversions[versionKey];
fileversions[hashversion] = hashversion;
}
}
}
let versions = [];
for (let key in fileversions) {
versions.push(key);
}
return sortVersions(versions);
}
function sortVersions(versions) {
return versions.sort((a, b) => a.localeCompare(b, undefined, { numeric:true }) );
}
function generateVersionData(identifier, folder) {
let metadata = getMetaData(identifier, folder)
metadata.changes = getVersionChanges(identifier, folder);
updateMetaData(identifier, folder, metadata);
}
function getData() {
let data = {};
data.icons = {};
data.aliases = {};
let folders = getFolders(options.dist_svgs);
for (var folderCount = 0; folderCount < folders.length; folderCount++) {
let folder = folders[folderCount];
let iconFiles = getIcons(path.join(options.dist_svgs, folder));
for (var i = 0; i < iconFiles.length; i++) {
let file = path.join('svgs', folder, iconFiles[i]);
let identifier = path.basename(file, '.svg');
let metaData = getMetaData(identifier, folder);
if (metaData.alias.length > 0) {
for (let aliasKey in metaData.alias) {
data.aliases[metaData.alias[aliasKey]] = identifier;
}
}
data.icons[identifier] = {};
data.icons[identifier].identifier = identifier;
data.icons[identifier].category = folder;
data.icons[identifier].svg = 'svgs/' + folder + '/' + identifier + '.svg';
data.icons[identifier].sprite = 'sprites/' + folder + '.svg' + '#' + identifier;
data.icons[identifier].bidi = metaData.bidi ? true : false;
}
}
return data;
}
function escapeSvg(svg) {
const charReplacementMap = {
'"': '\'',
'#': '%23',
'<': '%3c',
'>': '%3e'
};
const regex = new RegExp('[' + Object.keys(charReplacementMap).join('') + ']', 'g');
return svg.replace(regex, c => charReplacementMap[c]);
}
function playSound() {
// Try different sound commands based on platform (silently)
const soundCommands = [
'echo -e "\\a"', // Terminal bell
'paplay /usr/share/sounds/alsa/Front_Left.wav 2>/dev/null', // Linux
'afplay /System/Library/Sounds/Glass.aiff 2>/dev/null', // macOS
'powershell -c "[console]::beep(800,200)" 2>$null' // Windows
];
soundCommands.forEach(cmd => {
exec(cmd, { stdio: 'ignore' }, () => {}); // Completely silent
});
console.log('✅ Build complete!');
}
//
// Icons Clean
//
gulp.task('icons-clean', () => {
return deleteAsync([options.dist], { force: true });
});
//
// Icons Sass
//
gulp.task('icons-sass', () => {
let tasks = [];
tasks.push(new Promise((resolve) => {
gulp.src(path.join(options.assets, 'scss/icons.scss'))
.pipe(gulp.dest(options.dist))
.pipe(sass().on('error', sass.logError))
.pipe(minifyCSS())
.pipe(gulp.dest(options.dist))
.on('end', resolve);
}));
tasks.push(new Promise((resolve) => {
gulp.src(path.join(options.assets, 'scss/icons-bootstrap.scss'))
.pipe(gulp.dest(options.dist))
.pipe(sass().on('error', sass.logError))
.pipe(minifyCSS())
.pipe(gulp.dest(options.dist))
.on('end', resolve);
}));
return Promise.all(tasks);
});
//
// Icons Minify SVGs
//
gulp.task('icons-min', () => {
return gulp.src([options.src + '**/*.svg'])
.pipe(svgmin())
.pipe(gulp.dest(options.dist_svgs));
});
//
// Icons SVG Sprites
//
gulp.task('icons-sprites', () => {
let processFolder = (folder) => {
return new Promise((resolve, reject) => {
// Get sorted list of SVG files
const svgFiles = getIcons(path.join(options.dist_svgs, folder))
.sort()
.map(file => path.join(options.dist_svgs, folder, file));
gulp.src(svgFiles)
.pipe(svgSprite({
svg: {
rootAttributes: {
class: 'typo3-icons-' + folder,
style: 'display: none;'
},
namespaceIDs: true,
namespaceClassnames: false
},
shape: {
transform: [
{
svgo: {
plugins: [
{
name: 'preset-default',
},
{
name: 'removeAttrs',
params: {
attrs: 'xml:space',
},
},
],
},
},
],
},
mode: {
symbol: {
dest: '',
sprite: folder + '.svg'
}
}
}))
.on('error', reject)
.pipe(gulp.dest(options.dist_sprites))
.on('end', resolve);
});
};
return Promise.all(getFolders(options.dist_svgs).map(processFolder));
});
//
// Icons Data
//
gulp.task('icons-data', (cb) => {
let data = JSON.stringify(getData(), null, 2);
fs.writeFile(options.dist + 'icons.json', data, 'utf8', (err) => {
if (err) {
cb(err);
} else {
cb();
}
});
});
//
// Icon variables
//
gulp.task('icons-variables', async (cb) => {
try {
const data = getData();
if (!fs.existsSync(options.dist_scss)){
fs.mkdirSync(options.dist_scss, { recursive: true });
}
// Clear existing files first
const categories = getCategories();
for (const category of Object.values(categories)) {
const categoryFile = options.dist_scss + `icons-variables-${category.identifier}.scss`;
if (fs.existsSync(categoryFile)) {
fs.unlinkSync(categoryFile);
}
}
const mainFile = options.dist_scss + `icons-variables.scss`;
if (fs.existsSync(mainFile)) {
fs.unlinkSync(mainFile);
}
// Write icon variables (sorted by identifier)
const sortedIcons = Object.entries(data.icons).sort(([a], [b]) => a.localeCompare(b));
const categoryFiles = {};
// Group by category and prepare content
for (const [identifier, icon] of sortedIcons) {
if (!categoryFiles[icon.category]) {
categoryFiles[icon.category] = [];
}
const inlineIcon = fs.readFileSync(path.join(options.dist, icon.svg), 'utf8');
const scssVariable = `$icon-${identifier}: url("data:image/svg+xml,${escapeSvg(inlineIcon)}") !default;`;
categoryFiles[icon.category].push(scssVariable);
}
// Write each category file with sorted content
const writePromises = [];
for (const [category, variables] of Object.entries(categoryFiles)) {
writePromises.push(
new Promise((resolve, reject) => {
fs.writeFile(options.dist_scss + `icons-variables-${category}.scss`, variables.join('\n') + '\n', 'utf8', (err) => {
if (err) reject(err);
else resolve();
});
})
);
}
await Promise.all(writePromises);
// Write main imports file (sequentially to maintain order)
const sortedCategories = Object.values(categories).sort((a, b) => a.identifier.localeCompare(b.identifier));
const imports = sortedCategories.map(category => `@import 'icons-variables-${category.identifier}';`).join('\n') + '\n';
await fs.promises.writeFile(options.dist_scss + `icons-variables.scss`, imports, 'utf8');
cb();
} catch (err) {
cb(err);
}
});
//
// Versions History
//
gulp.task('version-history', () => {
let tasks = [];
tasks.push(new Promise((resolve) => {
let folders = getFolders(options.meta);
for (var folderCount = 0; folderCount < folders.length; folderCount++) {
let folder = folders[folderCount];
let files = getMetaFiles(path.join(options.meta, folder));
for (var i = 0; i < files.length; i++) {
let file = path.join(folder, files[i]);
let identifier = path.basename(file, '.yaml');
fs.stat(path.join(options.src, folder, identifier + '.svg'), (error) => {
if (error !== null && error.code === 'ENOENT') {
deleteSync(path.join(options.meta, file), { force: true });
}
});
}
}
resolve();
}));
tasks.push(new Promise((resolve) => {
let folders = getFolders(options.src);
for (var folderCount = 0; folderCount < folders.length; folderCount++) {
var folder = folders[folderCount];
var iconFiles = getIcons(options.src + folder);
for (var i = 0; i < iconFiles.length; i++) {
let file = path.join(folder, iconFiles[i]);
let identifier = path.basename(file, '.svg');
generateVersionData(identifier, folder);
}
}
resolve();
}));
return Promise.all(tasks);
});
//
// Changelog Helper Functions
//
const COMMIT_TYPES = {
'FEATURE': 'features',
'BUGFIX': 'bugfixes',
'TASK': 'tasks',
'!!!': 'breaking',
'BREAKING': 'breaking'
};
function categorizeCommits(latestTag) {
const gitCommand = `git log ${latestTag}..HEAD --oneline --no-merges --grep="^\\[RELEASE\\]" --invert-grep`;
const commits = execSync(gitCommand).toString().trim();
if (!commits) {
return null;
}
const categories = {
breaking: [],
features: [],
bugfixes: [],
tasks: [],
other: []
};
const commitPattern = /^([a-f0-9]+)\s+\[([^\]]+)\]\s+(.+)$/;
const fallbackPattern = /^([a-f0-9]+)\s+(.+)$/;
commits.split('\n').forEach(line => {
const match = line.match(commitPattern);
if (match) {
const [, hash, prefix, message] = match;
const category = COMMIT_TYPES[prefix.toUpperCase()] || 'other';
categories[category].push({ hash, prefix, message });
} else {
const fallbackMatch = line.match(fallbackPattern);
if (fallbackMatch) {
const [, hash, message] = fallbackMatch;
categories.other.push({ hash, prefix: null, message });
}
}
});
return categories;
}
//
// Update CHANGELOG.md file (used by npm version hook)
//
gulp.task('changelog-file', (cb) => {
try {
const pkg = require('./package.json');
const newVersion = pkg.version;
// Get the latest tag (before the new one)
const latestTag = execSync('git tag --sort=-v:refname | head -n1').toString().trim();
if (!latestTag) {
console.log('No previous tags found, skipping changelog update');
cb();
return;
}
const categories = categorizeCommits(latestTag);
if (!categories) {
console.log(`No new commits since ${latestTag}`);
cb();
return;
}
// Combine all commits in order: breaking, features, bugfixes, tasks, other
const allCommits = [
...categories.breaking,
...categories.features,
...categories.bugfixes,
...categories.tasks,
...categories.other
];
// Output preview to console
console.log('\n' + '='.repeat(60));
console.log(`Adding to CHANGELOG.md for version ${newVersion}`);
console.log('='.repeat(60) + '\n');
allCommits.forEach(item => {
if (item.prefix) {
console.log(`${item.hash} [${item.prefix}] ${item.message}`);
} else {
console.log(`${item.hash} ${item.message}`);
}
});
console.log('\n' + '='.repeat(60) + '\n');
// Build the new changelog entry
const date = new Date().toISOString().split('T')[0];
let entry = `## [${newVersion}] - ${date}\n\n`;
allCommits.forEach(item => {
if (item.prefix) {
entry += `${item.hash} [${item.prefix}] ${item.message}\n`;
} else {
entry += `${item.hash} ${item.message}\n`;
}
});
entry += '\n';
// Read existing CHANGELOG.md or create new one
let changelog = '';
const changelogPath = './CHANGELOG.md';
if (fs.existsSync(changelogPath)) {
changelog = fs.readFileSync(changelogPath, 'utf8');
// Insert new entry after the header
const lines = changelog.split('\n');
const headerEndIndex = lines.findIndex((line, i) => i > 0 && line.startsWith('##'));
if (headerEndIndex !== -1) {
lines.splice(headerEndIndex, 0, entry);
changelog = lines.join('\n');
} else {
changelog = changelog + '\n' + entry;
}
} else {
// Create new changelog with header
changelog = `# Changelog\n\nAll notable changes to this project will be documented in this file.\n\nThe format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).\n\n${entry}`;
}
// Write the updated changelog
fs.writeFileSync(changelogPath, changelog);
console.log(`✓ Updated CHANGELOG.md for version ${newVersion}`);
cb();
} catch (error) {
console.error('Error updating changelog:', error.message);
cb(error);
}
});
/**
* Docs
*/
gulp.task('site-clean', () => {
return deleteAsync([options.site], { force: true });
});
gulp.task('site-build', function (cb) {
let tasks = [];
// Build Docs CSS
tasks.push(new Promise((resolve, reject) => {
gulp.src(path.join(options.assets, 'scss/docs.scss'))
.pipe(sass().on('error', sass.logError))
.pipe(minifyCSS())
.pipe(gulp.dest(path.join(options.site, 'assets', 'css')))
.on('end', resolve)
.on('error', reject);
}));
// Copy static assets
tasks.push(new Promise((resolve, reject) => {
gulp.src([path.join(options.assets, '**/*'),
'!' + path.join(options.assets, '**/*(*.scss)'),
], { base: options.assets })
.pipe(gulp.dest(path.join(options.site, 'assets')))
.on('end', resolve)
.on('error', reject);
}));
tasks.push(new Promise((resolve, reject) => {
gulp.src([path.join(options.dist, '**/*')], { base: options.dist } )
.pipe(gulp.dest(path.join(options.site, 'dist')))
.on('end', resolve)
.on('error', reject);
}));
// Wait for asset copying to complete before processing templates
Promise.all(tasks).then(() => {
let templateTasks = [];
// Fetch generated data
let typo3 = JSON.parse(fs.readFileSync('./typo3.json', 'utf8'))
let categories = getCategories();
let data = JSON.parse(fs.readFileSync(options.dist + 'icons.json', 'utf8'));
let icons = data.icons;
for (let iconKey in icons) {
const iconContent = fs.readFileSync(path.join(options.dist, icons[iconKey].svg), 'utf8');
icons[iconKey]._meta = getMetaData(icons[iconKey].identifier, icons[iconKey].category);
icons[iconKey]._inline = iconContent
icons[iconKey]._inlineEscaped = escapeSvg(iconContent)
}
const cacheBuster = Date.now();
// Index
templateTasks.push(new Promise((resolve, reject) => {
gulp.src('./tmpl/html/docs/index.html.twig')
.pipe(twig({
data: {
pkg: pkg,
typo3: typo3,
icons: icons,
category: {},
categories: categories,
rendering: {},
pathPrefix: '',
cacheBuster: cacheBuster,
}
}))
.pipe(rename('index.html'))
.pipe(gulp.dest(path.join(options.site)))
.on('end', resolve)
.on('error', reject);
}));
// Guide
templateTasks.push(new Promise((resolve, reject) => {
gulp.src('./tmpl/html/docs/guide.html.twig')
.pipe(twig({
data: {
pkg: pkg,
typo3: typo3,
icons: icons,
category: {},
categories: categories,
rendering: {},
pathPrefix: '',
cacheBuster: cacheBuster,
}
}))
.pipe(rename('guide.html'))
.pipe(gulp.dest(path.join(options.site)))
.on('end', resolve)
.on('error', reject);
}));
// Build pages
for (let categoryKey in categories) {
let category = categories[categoryKey];
templateTasks.push(new Promise((resolve, reject) => {
gulp.src('./tmpl/html/docs/section.html.twig')
.pipe(twig({
data: {
pkg: pkg,
typo3: typo3,
icons: icons,
category: category,
categories: categories,
pathPrefix: '../',
cacheBuster: cacheBuster,
}
}))
.pipe(rename(category.identifier + '.html'))
.pipe(gulp.dest(path.join(options.site, 'icons')))
.on('end', resolve)
.on('error', reject);
}));
for (let iconKey in category.icons) {
let iconIdentifier = category.icons[iconKey];
let icon = icons[iconIdentifier];
templateTasks.push(new Promise((resolve, reject) => {
gulp.src('./tmpl/html/docs/single.html.twig')
.pipe(twig({
data: {
pkg: pkg,
typo3: typo3,
icon: icon,
icons: icons,
category: category,
categories: categories,
pathPrefix: '../../',
cacheBuster: cacheBuster,
}
}))
.pipe(rename(iconIdentifier + '.html'))
.pipe(gulp.dest(path.join(options.site, 'icons', category.identifier)))
.on('end', resolve)
.on('error', reject);
}));
}
}
Promise.all(templateTasks).then(() => {
cb();
}).catch(cb);
}).catch(cb);
});
//
// Watch Task with completion callbacks
//
function svgNotify(cb) {
playSound();
cb();
}
function scssNotify(cb) {
playSound();
cb();
}
function templateNotify(cb) {
playSound();
cb();
}
gulp.task('watch-svg-complete', gulp.series('icons-min', 'icons-sprites', 'icons-data', 'icons-variables', 'site-build', svgNotify));
gulp.task('watch-scss-complete', gulp.series('icons-sass', 'site-build', scssNotify));
gulp.task('watch-template-complete', gulp.series('site-build', templateNotify));
gulp.task('watch', () => {
// Watch SVG source files
gulp.watch([options.src + '**/*.svg'], gulp.series('watch-svg-complete'));
// Watch SCSS files
gulp.watch([options.assets + 'scss/**/*.scss'], gulp.series('watch-scss-complete'));
// Watch template files
gulp.watch(['./tmpl/**/*.twig', './typo3.json'], gulp.series('watch-template-complete'));
console.log('🔍 Watching for file changes...');
});
//
// Tasks
//
gulp.task('icons', gulp.series(
'icons-clean',
'icons-sass',
'icons-min',
'icons-sprites',
'icons-data',
'icons-variables'
));
gulp.task('default', gulp.series(
'icons'
));
gulp.task('version', gulp.series(
'version-history'
));
gulp.task('site', gulp.series(
'site-clean',
'site-build'
));
gulp.task('dev', gulp.series(
'icons',
'site',
'watch'
));