packages/dev/src/cmds/changelog/write.js
const
path = require( 'path' ),
{ readFileSync, writeFileSync, readdirSync, rmSync } = require( 'fs' ),
chalk = require( 'chalk' ),
semver = require( 'semver' ),
{
ChangelogEntry,
getNextVersion,
getCurrentVersion,
getChangelogOptions,
getChangelogValidationIssues,
getChangelogEntries,
getFileLink,
getIssueLink,
isProjectPublic,
determineVersionIncrement,
logResult,
execSync,
} = require( '../../utils' );
/**
* Accepts a date/time string and converts it to YYYY-MM-DD format used in changelog version titles.
*
* @since 0.0.1
*
* @param {string|number} date Timestamp or datetime string parseable by `Date.parse()`.
* @return {string} Date string in YYYY-MM-DD format.
*/
const formatDate = ( date ) => new Date( date ).toISOString().split( 'T' )[ 0 ];
/**
* Retrieve the an array of lines for the changelog entry's header.
*
* @since 0.0.1
*
* @param {string} version A semver string.
* @param {string} date A date string.
* @return {string[]} Array of lines.
*/
function getHeaderLines( version, date ) {
const lines = [ `v${ version } - ${ date }` ];
lines.push( '-'.repeat( lines[ 0 ].length ) );
return lines;
}
/**
* Retrieve the title for the changelog item's type.
*
* @since 0.0.1
*
* @param {string} type The changelog item type key.
* @return {string} The changelog item type title.
*/
function getTypeTitle( type ) {
const map = {
added: 'New Features',
changed: 'Updates and Enhancements',
fixed: 'Bug Fixes',
deprecated: 'Deprecations',
removed: 'Breaking Changes',
dev: 'Developer Notes',
performance: 'Performance Improvements',
security: 'Security Fixes',
template: 'Updated Templates',
};
return `\n##### ${ map[ type ] }\n`;
}
/**
* Formats a single changelog item.
*
* @since 0.0.1
* @since 0.1.0 Use `getIssueLink()` for generation of issue links.
* @since 0.2.1 Remove trailing `@` from the GitHub handler when building the contributor's profile URL.
*
* @param {ChangelogEntry} args The changelog entry object.
* @param {string} args.entry The content of the changelog entry.
* @param {string} args.type Entry type.
* @param {string[]} args.attributions List of individuals attributed to the entry.
* @param {string[]} args.links of GitHub issues linked to the entry.
* @param {boolean} includeLinks Whether or not to include links.
* @return {string} The formatted changelog entry line.
*/
function formatChangelogItem( { entry, type, attributions = [], links = [] }, includeLinks ) {
entry = entry.trim();
// Entries should always end in a full stop.
if ( 'template' !== type && ! [ '.', '?', '!' ].includes( entry.split( '' ).reverse()[ 0 ] ) ) {
entry += '.';
}
let line = '';
// Single entry, add a bullet.
if ( ! entry.includes( '\n' ) ) {
line += '+ ';
}
// Add the line(s).
line += entry;
// Add formatted attribution links.
if ( attributions.length ) {
attributions = attributions.map( ( v ) => {
if ( '@' === v.charAt( 0 ) ) {
v = `[${ v }](https://github.com/${ v.slice( 1 ) })`;
}
return v;
} );
line += ` Thanks ${ new Intl.ListFormat( 'en', { style: 'long', type: 'conjunction' } ).format( attributions ) }!`;
}
// Add issue links.
if ( includeLinks && links.length ) {
line += ' ' + links.map( ( iss ) => `[${ iss }](${ getIssueLink( iss ) })` ).join( ', ' );
}
return line;
}
/**
* Retrieve a list changelog entry objects for all the template files that have been modified.
*
* Compares the current git branch against the `trunk` branch in order to find all files in the `templates/` directory
* which have been modified.
*
* @since 0.0.1
* @since 0.1.0 Use `getFileLink()` to generate links to the template file.
*
* @param {boolean} includeLinks Whether or not the entry items should be formatted as links to the GitHub repository.
* @param {string} version A semver string.
* @return {ChangelogEntry[]} Array of changelog entry objects.
*/
function getUpdatedTemplates( includeLinks, version ) {
try {
return execSync( 'git diff --name-only trunk | grep "^templates/"', true ).split( '\n' ).map( ( template ) => {
return {
type: 'template',
entry: includeLinks ? `[${ template }](${ getFileLink( template, version ) })` : template,
};
} );
} catch ( e ) {}
return [];
}
/**
* Format the changelog entry for the given version.
*
* @since 0.0.1
*
* @param {string} version A semver string.
* @param {string} date Version release date in YYYY-MM-DD format.
* @param {ChangelogEntry[]} entries All entry objects to be included.
* @param {boolean} links Whether or not to add links to GitHub issues and templates. For public repos we want to show links, otherwise we don't bother.
* @return {string[]} Array of lines to be added to the changelog.
*/
function formatChangelogVersionEntry( version, date, entries, links ) {
const
groups = {},
{ type } = getChangelogOptions();
Object.keys( type ).forEach( ( groupKey ) => {
groups[ groupKey ] = [];
} );
groups.template = [];
// Add updated template list.
entries = [ ...entries, ...getUpdatedTemplates( links, version ) ];
entries.forEach( ( entry ) => {
groups[ entry.type ].push( entry );
} );
const lines = [
...getHeaderLines( version, date ),
];
Object.entries( groups ).forEach( ( [ groupType, groupEntries ] ) => {
if ( ! groupEntries.length ) {
return;
}
lines.push( getTypeTitle( groupType ) );
groupEntries.forEach( ( entry ) => {
lines.push( formatChangelogItem( entry, links ) );
} );
} );
return lines;
}
/**
* Delete all changelog entry files from the changelog directory.
*
* @since 0.0.1
*
* @param {string} dir Changelog directory.
* @return {void}
*/
function cleanupLogs( dir ) {
readdirSync( dir ).forEach( ( file ) => {
if ( file.endsWith( '.yml' ) ) {
rmSync( path.join( dir, file ) );
}
} );
}
module.exports = {
command: 'write',
description: 'Write existing changelog entries to the changelog file.',
options: [
[ '-p, --preid <identifier>', 'Identifier to be used to prefix premajor, preminor, prepatch or prerelease version increments.' ],
[ '-F, --force <version>', 'Use the specified version string instead of determining the version based on changelog entry significance.' ],
[ '-l, --log-file <file>', 'The changelog file.', 'CHANGELOG.md' ],
[ '-d, --date <YYYY-MM-DD>', 'Changelog publication date.', formatDate( Date.now() ) ],
[ '-L, --links', 'Add GitHub links to templates and issues in changelog entries.', true === isProjectPublic() ],
[ '-n, --no-links', 'Do not add GitHub links in changelog entries. Use this option to override the --links flag.' ],
[ '-D, --dry-run', 'Output what would be written to the changelog instead of writing it to the changelog file.' ],
[ '-k, --keep-entries', 'Preserve entry files deletion after the changelog is written.' ],
],
action: ( { dir, preid, force, logFile, date, links, dryRun, keepEntries } ) => {
try {
date = formatDate( date );
} catch ( e ) {
logResult( 'Invalid date supplied. Please provide a date in YYYY-MM-DD format.', 'error' );
process.exit( 1 );
}
const currentVersion = getCurrentVersion();
if ( ! currentVersion ) {
logResult( 'No current version found.\n A version number must defined in the package.json file or in the composer.json file at ".extra.llms.version".', 'error' );
process.exit( 1 );
}
const entries = getChangelogEntries( dir );
const areEntriesValid = entries.every( ( entry ) => {
const { valid } = getChangelogValidationIssues( entry );
return valid;
} );
if ( ! areEntriesValid ) {
logResult( 'One or more invalid changelog entries were found. Please resolve all validation issues and try again.', 'error' );
process.exit( 1 );
}
let version = force;
if ( ! version ) {
version = getNextVersion( currentVersion, determineVersionIncrement( dir, currentVersion, preid ), preid );
} else if ( ! semver.valid( version ) ) {
logResult( `The supplied version string ${ chalk.bold( version ) } is invalid.`, 'error' );
process.exit( 1 );
}
logResult( `${ dryRun ? 'Generating' : 'Writing' } changelog for version ${ chalk.bold( version ) }.` );
const logFileContents = readFileSync( logFile, 'utf8' );
const
logFileParts = logFileContents.split( '\n\n' ),
[ header, ...body ] = logFileParts,
items = formatChangelogVersionEntry( version, date, entries, links ).join( '\n' ) + '\n';
if ( dryRun ) {
console.log( items );
process.exit( 0 );
}
writeFileSync( logFile, [ header, items, ...body ].join( '\n\n' ) );
logResult( `Changelog for version ${ chalk.bold( version ) } written.` );
if ( ! keepEntries ) {
logResult( `Peforming entry file cleanup`, 'warning' );
cleanupLogs( dir );
}
},
};