gocodebox/lifterlms

View on GitHub
packages/dev/src/cmds/update-version.js

Summary

Maintainability
B
5 hrs
Test Coverage
const
    chalk = require( 'chalk' ),
    semver = require( 'semver' ),
    columnify = require( 'columnify' ),
    replace = require( 'replace-in-file' ),
    { writeFileSync } = require( 'fs' ),
    { getCurrentVersion, getNextVersion, logResult, getConfig, hasConfig, execSync } = require( '../utils' );

/**
 * Update [version] placeholders via a regex against a list of file globs
 *
 * @since 0.0.1
 * @since 0.0.5 Added `flags` argument to allow customization of the regex flags.
 *
 * @param {string} files  Comma separated list of file globs.
 * @param {regex}  regex  A regular expression to use for the replacements.
 * @param {string  flags  A regex options string.
 * @param {string} ignore A comma separated list of file globs to be ignored.
 * @param {string} ver   The semantic version string to replace the placeholder with.
 * @return {Object} Replacement result object from `replace.sync()`.
 */
function updateVersions( files, regex, flags, ignore, ver ) {
    const commasToArray = ( string ) => string.split( ',' ).map( ( s ) => s.trim() );

    files = commasToArray( files );

    logResult( `Replacing ${ chalk.bold( files ) } using regex ${ chalk.bold( regex ) }.` );

    const
        opts = {
            files,
            from: new RegExp( regex, flags ),
            to: ver,
            ignore: ignore ? commasToArray( ignore ) : null,
            countMatches: true,
        };

    return replace.sync( opts );
}

/**
 * Updates the version number in the package's config file.
 *
 * If a package.json file is present, uses `npm version` to update the project's version.
 *
 * If there is no package.json, will attempt to update the `extra.llms.version` item in the
 * project's composer.json file.
 *
 * @since 0.0.1
 *
 * @param {string} ver Semantic version string.
 * @return {Object} A replacement result string.
 */
function updateConfig( ver ) {
    const ret = {
        Matches: chalk.yellow( 1 ),
        Replacements: chalk.yellow( 1 ),
    };

    if ( hasConfig( 'package' ) ) {
        // Silence update errors. When updating new files and the package has already been updated the CLI throws an error which we can ignore.
        try {
            logResult( 'Updating package.json.' );
            execSync( `npm version --no-git-tag-version ${ ver }`, true );
            return [
                {
                    File: chalk.green( 'package.json' ),
                    ...ret,
                },
                {
                    File: chalk.green( 'package-lock.json' ),
                    ...ret,
                },
            ];
        } catch ( e ) {}
    } else if ( hasConfig( 'composer' ) ) {
        const composer = getConfig( 'composer' );
        if ( composer?.extra?.llms?.version ) {
            logResult( 'Updating composer.json.' );
            composer.extra.llms.version = ver;
            writeFileSync( `${ process.cwd() }/composer.json`, JSON.stringify( composer, null, 2 ) );
            return [
                {
                    File: chalk.green( 'composer.json' ),
                    ...ret,
                },
            ];
        }
    }

    return false;
}

const deprecatedFunctions = [
    // WP deprecation warnings.
    '_deprecated_argument',
    '_deprecated_constructor',
    '_deprecated_hook',
    '_deprecated_file',
    '_deprecated_function',

    // Doing it wrong warning.
    '_doing_it_wrong',

    // Deprecated hook callers.
    'apply_filters_deprecated',
    'do_action_deprecated',
    
    // LLMS deprecation warnings.
    'llms_deprecated_function',
].join( '|' );


const defaultReplacements = [
    // 1. Replace [version] placeholder in all @since, @version, and @deprecated tags.
    [ 
        './**',
        '(?<=@(?:since|version|deprecated) +)(\\[version\\])',
        'g',
        false,
    ],

    // 2. Replace [version] placeholder in all deprecate function methods tags.
    [ 
        './*.php,./**/*.php',
        `(?<=(?:${ deprecatedFunctions }\\().+)(?<=\')(\\[version\\])(?=\')`,
        'g',
        false,
    ],

    // 3. Replace plugin metadata "Version" with current version.
    [ 
        '*lifterlms*.php',
        '(?<=[Vv]ersion *[:=] *[ \'\"])(0|[1-9]\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-((?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\\+([0-9a-zA-Z-]+(?:\\.[0-9a-zA-Z-]+)*))?',
        'g',
        true,
    ],

    // 4. Replace LIFTERLMS*_VERSION constants with the current version.
    [ 
        '*lifterlms*.php',
        '(?<=define\\( \'(?:LLMS|LIFTERLMS).*_VERSION\', \')(.*)(?=\' \\);)',
        'g',
        true,
    ],

    // 5. Replace theme stylesheet's version number with the current version.
    [ 
        './style.css',
        '(?<=Version: )(.+)',
        'g',
        true,
    ],
];

/**
 * Command: update-version
 *
 * @since 0.0.1
 * @since 0.2.0 Added 4th parameter to the replacement arrays to allow excluding replacement sets when building a prerelease.
 *
 * @type {Object}
 */
module.exports = {
    command: 'update-version',
    description: 'Update the project version and replace all [version] placeholders.',
    options: [
        [ '-i, --increment <level>', 'Increment the version by the specified level. Accepts: major, minor, patch, premajor, preminor, prepatch, or prerelease.', 'patch' ],
        [ '-p, --preid <identifier>', 'Identifier to be used to prefix premajor, preminor, prepatch or prerelease version increments.' ],
        [ '-F, --force <version>', 'Specify an explicit version instead of incrementing the current version with --increment.' ],
        [ '-r, --replacements <replacement...>]', 'Replacements to be made. Each replacement is an array containing a list of globs for the files to be tested, a regex used to perform the replacement, an optional list of RegEx flags (defaults to `g` if not supplied), and a boolean used to specify if the replacement should be run when building a prerelease (defaults to `false` if not supplied). It is recommended that this argument to configured via a configuration file as opposed to being passed via a CLI flag.', defaultReplacements ],
        [ '-e, --extra-replacements <replacement...>]', 'Additional replacements added to --replacements array. This option allows adding to the default replacements instead of overwriting them.', [] ],
        [ '-E, --exclude <glob...>', 'Specify files to exclude from the update.', './vendor/**, ./node_modules/**, ./tmp/**, ./dist/**, ./docs/**, ./packages/**' ],
        [ '-s, --skip-config', 'Skip updating the version of the package.json or composer.json file.' ],
    ],
    action: ( { increment, preid, exclude, force, skipConfig, replacements, extraReplacements } ) => {
        const version = force ? force : getNextVersion( getCurrentVersion(), increment, preid );

        if ( ! semver.valid( version ) ) {
            logResult( `The supplied version string ${ chalk.bold( version ) } is invalid.`, 'error' );
            process.exit( 1 );
        }

        const isPrerelease = semver.prerelease( version )?.length ? true : false;

        // Add extraReplacements.
        replacements = [ ...replacements, ...extraReplacements ];

        const res = [];
        if ( ! skipConfig ) {
            const configUpdate = updateConfig( version );
            if ( configUpdate ) {
                configUpdate.forEach( ( configRes ) => res.push( configRes ) );
            }
        }

        logResult( `Updating project files to version ${ chalk.bold( version ) }.` );

        for ( let i = 0; i < replacements.length; i++ ) {
            if ( replacements[ i ].length < 3 ) {
                replacements[ i ].push( 'g' );
            }

            const [ glob, regex, flags, forPreleases ] = replacements[ i ];

            if ( isPrerelease && ! forPreleases ) {
                continue;
            }

            updateVersions( glob, regex, flags, exclude, version )
                .filter( ( { hasChanged } ) => hasChanged )
                .forEach( ( update ) => {
                    res.push( {
                        File: chalk.green( update.file ),
                        Matches: chalk.yellow( update.numMatches ),
                        Replacements: chalk.yellow( update.numReplacements ),
                    } );
                }
                );
        }

        if ( ! res.length ) {
            logResult( 'No updates made.', 'warning' );
        } else {
            logResult( 'Version update completed.', 'success' );
            console.log(
                columnify(
                    res,
                    {
                        headingTransform: ( heading ) => chalk.bold.underline( heading ),
                    },
                )
            );
        }
    },
};