prism/gulpfile.js/changelog.js

396 lines
11 KiB
JavaScript

'use strict';
const { src, dest } = require('gulp');
const replace = require('gulp-replace');
const pump = require('pump');
const git = require('simple-git').gitP(__dirname);
const { changelog } = require('./paths');
const ISSUE_RE = /#(\d+)(?![\d\]])/g;
const ISSUE_SUB = '[#$1](https://github.com/PrismJS/prism/issues/$1)';
function linkify(cb) {
return pump([
src(changelog),
replace(ISSUE_RE, ISSUE_SUB),
replace(
/\[[\da-f]+(?:, *[\da-f]+)*\]/g,
m => m.replace(/([\da-f]{7})[\da-f]*/g, '[`$1`](https://github.com/PrismJS/prism/commit/$1)')
),
dest('.')
], cb);
}
/**
* Creates an array which iterates its items in the order given by `compareFn`.
*
* The array may not be sorted at all times.
*
* @param {(a: T, b: T) => number} compareFn
* @returns {T[]}
* @template T
*/
function createSortedArray(compareFn) {
/** @type {T[]} */
const a = [];
a['sort'] = function () {
return Array.prototype.sort.call(this, compareFn);
};
a[Symbol.iterator] = function () {
return this.slice().sort(compareFn)[Symbol.iterator]();
};
return a;
}
/**
* Parses the given log line and adds the list of the changed files to the output.
*
* @param {string} line A one-liner log line consisting of the commit hash and the commit message.
* @returns {Promise<CommitInfo>}
*
* @typedef {{ message: string, hash: string, changes: CommitChange[] }} CommitInfo
* @typedef {{ file: string, mode: ChangeMode }} CommitChange
* @typedef {"A" | "C" | "D" | "M" | "R" | "T" | "U" | "X" | "B"} ChangeMode
*/
async function getCommitInfo(line) {
// eslint-disable-next-line regexp/no-super-linear-backtracking
const [, hash, message] = /^([a-f\d]+)\s+(.*)$/i.exec(line);
/* The output looks like this:
*
* M components.js
* M components.json
*
* or nothing for e.g. reverts.
*/
const output = await git.raw(['diff-tree', '--no-commit-id', '--name-status', '-r', hash]);
const changes = !output ? [] : output.trim().split(/\n/g).map(line => {
const [, mode, file] = /(\w)\s+(.+)/.exec(line);
return { mode: /** @type {ChangeMode} */ (mode), file };
});
return { hash, message, changes };
}
/**
* Parses the output of `git log` with the given revision range.
*
* @param {string | Promise<string>} range The revision range in which the log will be parsed.
* @returns {Promise<CommitInfo[]>}
*/
async function getLog(range) {
/* The output looks like this:
*
* bfbe4464 Invoke `callback` after `after-highlight` hook (#1588)
* b41fb8f1 Fixes regex for JS examples (#1591)
*/
const output = await git.raw(['log', await Promise.resolve(range), '--oneline']);
if (output) {
const commits = output.trim().split(/\n/g);
return Promise.all(commits.map(getCommitInfo));
} else {
return [];
}
}
const revisionRanges = {
nextRelease() {
return git.raw(['describe', '--abbrev=0', '--tags']).then(res => `${res.trim()}..HEAD`);
}
};
const strCompare = (a, b) => a.localeCompare(b, 'en');
async function changes() {
const { languages, plugins } = require('../components.js');
const infos = await getLog(revisionRanges.nextRelease());
const entries = {
'TODO:': {},
'New components': {
['']: createSortedArray(strCompare)
},
'Updated components': {},
'Updated plugins': {},
'Updated themes': {},
'Other': {},
};
/**
*
* @param {string} category
* @param {string | { message: string, hash: string }} info
*/
function addEntry(category, info) {
const path = category.split(/\s*>>\s*/);
if (path[path.length - 1] !== '') {
path.push('');
}
let current = entries;
for (const key of path) {
if (key) {
current = current[key] = current[key] || {};
} else {
(current[key] = current[key] || []).push(info);
}
}
}
/** @param {CommitChange} change */
function notGenerated(change) {
return !change.file.endsWith('.min.js')
&& !change.file.startsWith('docs/')
&& ['prism.js', 'components.js', 'package-lock.json'].indexOf(change.file) === -1;
}
/** @param {CommitChange} change */
function notPartlyGenerated(change) {
return change.file !== 'plugins/autoloader/prism-autoloader.js' &&
change.file !== 'plugins/show-language/prism-show-language.js';
}
/** @param {CommitChange} change */
function notTests(change) {
return !/^tests\//.test(change.file);
}
/** @param {CommitChange} change */
function notExamples(change) {
return !/^examples\//.test(change.file);
}
/** @param {CommitChange} change */
function notFailures(change) {
return !/^known-failures.html$/.test(change.file);
}
/** @param {CommitChange} change */
function notComponentsJSON(change) {
return change.file !== 'components.json';
}
/**
* @param {((e: T, index: number) => boolean)[]} filters
* @returns {(e: T, index: number) => boolean}
* @template T
*/
function and(...filters) {
return (e, index) => {
for (let i = 0, l = filters.length; i < l; i++) {
if (!filters[i](e, index)) {
return false;
}
}
return true;
};
}
/**
* Some commit message have the format `component changed: actual message`.
* This function can be used to remove this prefix.
*
* @param {string} prefix
* @param {CommitInfo} info
* @returns {{ message: string, hash: string }}
*/
function removeMessagePrefix(prefix, info) {
const source = String.raw`^${prefix.replace(/([^-\w\s])/g, '\\$1').replace(/[-\s]/g, '[-\\s]')}:\s*`;
const patter = RegExp(source, 'i');
return {
message: info.message.replace(patter, ''),
hash: info.hash
};
}
/**
* @type {((info: CommitInfo) => boolean)[]}
*/
const commitSorters = [
function rebuild(info) {
if (info.changes.length > 0 && info.changes.filter(notGenerated).length === 0) {
console.log('Rebuild found: ' + info.message);
return true;
}
},
function addedComponent(info) {
let relevantChanges = info.changes.filter(and(notGenerated, notTests, notExamples, notFailures));
// `components.json` has to be modified
if (relevantChanges.some(c => c.file === 'components.json')) {
relevantChanges = relevantChanges.filter(and(notComponentsJSON, notPartlyGenerated));
// now, only the newly added JS should be left
if (relevantChanges.length === 1) {
const change = relevantChanges[0];
if (change.mode === 'A' && change.file.startsWith('components/prism-')) {
const lang = change.file.match(/prism-([\w-]+)\.js$/)[1];
const entry = languages[lang] || {
title: 'REMOVED LANGUAGE ' + lang,
};
const titles = [entry.title];
if (entry.aliasTitles) {
titles.push(...Object.values(entry.aliasTitles));
}
addEntry('New components', `__${titles.join('__ & __')}__: ${infoToString(info)}`);
return true;
}
}
}
},
function changedComponentOrCore(info) {
let relevantChanges = info.changes.filter(and(notGenerated, notTests, notExamples, notFailures));
// if `components.json` changed, then autoloader and show-language also change
if (relevantChanges.some(c => c.file === 'components.json')) {
relevantChanges = relevantChanges.filter(and(notComponentsJSON, notPartlyGenerated));
}
if (relevantChanges.length === 1) {
const change = relevantChanges[0];
if (change.mode === 'M' && change.file.startsWith('components/prism-')) {
const lang = change.file.match(/prism-([\w-]+)\.js$/)[1];
if (lang === 'core') {
addEntry('Other >> Core', removeMessagePrefix('Core', info));
} else {
const title = languages[lang].title;
addEntry('Updated components >> ' + title, removeMessagePrefix(title, info));
}
return true;
}
}
},
function changedPlugin(info) {
let relevantChanges = info.changes.filter(and(notGenerated, notTests, notExamples, c => !/\.(?:css|html)$/.test(c.file)));
if (relevantChanges.length > 0 &&
relevantChanges.every(c => c.mode === 'M' && /^plugins\/.*\.js$/.test(c.file))) {
if (relevantChanges.length === 1) {
const change = relevantChanges[0];
const id = change.file.match(/\/prism-([\w-]+)\.js/)[1];
const title = plugins[id].title || plugins[id];
addEntry('Updated plugins >> ' + title, removeMessagePrefix(title, info));
} else {
addEntry('Updated plugins', info);
}
return true;
}
},
function changedTheme(info) {
if (info.changes.length > 0 && info.changes.every(c => {
return /themes\/.*\.css/.test(c.file) && c.mode === 'M';
})) {
if (info.changes.length === 1) {
const change = info.changes[0];
let name = (change.file.match(/prism-(\w+)\.css$/) || [, 'Default'])[1];
name = name[0].toUpperCase() + name.substr(1);
addEntry('Updated themes >> ' + name, removeMessagePrefix(name, info));
} else {
addEntry('Updated themes', info);
}
return true;
}
},
function changedInfrastructure(info) {
let relevantChanges = info.changes.filter(notGenerated);
if (relevantChanges.length > 0 && relevantChanges.every(c => {
if (/^(?:gulpfile.js|tests)\//.test(c.file)) {
// gulp tasks or tests
return true;
}
if (/^\.[\w.]+$/.test(c.file)) {
// a .something file
return true;
}
return ['bower.json', 'CNAME', 'composer.json', 'package.json', 'package-lock.json'].indexOf(c.file) >= 0;
})) {
addEntry('Other >> Infrastructure', info);
return true;
}
// or dependencies.js
const excludeTests = info.changes.filter(notTests);
if (excludeTests.length === 1 && excludeTests[0].file === 'dependencies.js') {
addEntry('Other >> Infrastructure', info);
return true;
}
},
function changedWebsite(info) {
if (info.changes.length > 0 && info.changes.every(c => {
return /[\w-]+\.html$/.test(c.file) || /^(?:assets|docs)\//.test(c.file);
})) {
addEntry('Other >> Website', info);
return true;
}
},
function otherChanges(info) {
// detect changes of the Github setup
// This assumes that .md files are related to GitHub
if (info.changes.length > 0 && info.changes.every(c => /\.md$/i.test(c.file))) {
addEntry('Other', info);
return true;
}
},
];
for (const info of infos) {
if (!commitSorters.some(sorter => sorter(info))) {
addEntry('TODO:', info);
}
}
let md = '';
/**
* Stringifies the given commit info.
*
* @param {string | CommitInfo} info
* @returns {string}
*/
function infoToString(info) {
if (typeof info === 'string') {
return info;
}
return `${info.message} [\`${info.hash}\`](https://github.com/PrismJS/prism/commit/${info.hash})`;
}
function printCategory(category, indentation = '') {
for (const subCategory of Object.keys(category).sort(strCompare)) {
if (subCategory) {
md += `${indentation}* __${subCategory}__\n`;
printCategory(category[subCategory], indentation + ' ');
} else {
for (const info of category['']) {
md += `${indentation}* ${infoToString(info)}\n`;
}
}
}
}
for (const category of Object.keys(entries)) {
md += `\n### ${category}\n\n`;
printCategory(entries[category]);
}
console.log(md);
}
module.exports = {
linkify,
changes
};