396 lines
11 KiB
JavaScript
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
|
|
};
|