diff options
author | Timmy Willison <timmywil@users.noreply.github.com> | 2023-09-18 12:39:00 -0400 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-09-18 12:39:00 -0400 |
commit | 2bdecf8b7bd10864e5337a4e24e39476c78cf23a (patch) | |
tree | 4685fc5ca912e368c294a3949c7ef5b663fec980 /build | |
parent | f75daab09102a4dd5107deadb55d4a169f86254a (diff) | |
download | jquery-2bdecf8b7bd10864e5337a4e24e39476c78cf23a.tar.gz jquery-2bdecf8b7bd10864e5337a4e24e39476c78cf23a.zip |
Build: migrate most grunt tasks off of grunt
Updated tasks include:
- lint
- npmcopy
- build, minify, and process for distribution.
- new custom build command using yargs
- compare size of minified/gzip built files
- pretest scripts, including qunit-fixture, babel transpilation, and npmcopy
- node smoke tests
- promises aplus tests
- new watch task using `rollup.watch` directly
Also:
- upgraded husky and added the new lint command
- updated lint config to use new "flat" config format. See https://eslint.org/docs/latest/use/configure/configuration-files-new
- Temporarily disabled one lint rule until flat config is supported by eslint-plugin-import. See https://github.com/import-js/eslint-plugin-import/issues/2556
- committed package-lock.json
- updated all test scripts to use the new build
- added an express test server that uses middleware-mockserver (this can be used to run tests without karma)
- build-all-variants is now build:all
Close gh-5318
Diffstat (limited to 'build')
-rwxr-xr-x | build/command.js | 75 | ||||
-rw-r--r-- | build/grunt-tasks/testswarm.js (renamed from build/tasks/testswarm.js) | 0 | ||||
-rw-r--r-- | build/tasks/build.js | 630 | ||||
-rw-r--r-- | build/tasks/compare_size.mjs | 128 | ||||
-rw-r--r-- | build/tasks/dist.js | 95 | ||||
-rw-r--r-- | build/tasks/lib/getTimestamp.js | 9 | ||||
-rw-r--r-- | build/tasks/lib/isCleanWorkingDir.js | 9 | ||||
-rw-r--r-- | build/tasks/lib/slim-exclude.js (renamed from build/tasks/lib/slim-build-flags.js) | 10 | ||||
-rw-r--r-- | build/tasks/lib/spawn_test.js | 16 | ||||
-rw-r--r-- | build/tasks/lib/verifyNodeVersion.js | 12 | ||||
-rw-r--r-- | build/tasks/minify.js | 114 | ||||
-rw-r--r-- | build/tasks/node_smoke_tests.js | 97 | ||||
-rw-r--r-- | build/tasks/npmcopy.js | 42 | ||||
-rw-r--r-- | build/tasks/promises_aplus_tests.js | 45 | ||||
-rw-r--r-- | build/tasks/qunit-fixture.js | 17 | ||||
-rw-r--r-- | build/tasks/qunit_fixture.js | 22 |
16 files changed, 777 insertions, 544 deletions
diff --git a/build/command.js b/build/command.js new file mode 100755 index 000000000..ee1a153bc --- /dev/null +++ b/build/command.js @@ -0,0 +1,75 @@ +"use strict"; + +const { build } = require( "./tasks/build" ); +const yargs = require( "yargs/yargs" ); +const slimExclude = require( "./tasks/lib/slim-exclude" ); + +const argv = yargs( process.argv.slice( 2 ) ) + .version( false ) + .command( { + command: "[options]", + describe: "Build a jQuery bundle" + } ) + .option( "filename", { + alias: "f", + type: "string", + description: + "Set the filename of the built file. Defaults to jquery.js." + } ) + .option( "dir", { + alias: "d", + type: "string", + description: + "Set the dir to which to output the built file. Defaults to /dist." + } ) + .option( "version", { + alias: "v", + type: "string", + description: + "Set the version to include in the built file. " + + "Defaults to the version in package.json plus the " + + "short commit SHA and any excluded modules." + } ) + .option( "watch", { + alias: "w", + type: "boolean", + description: + "Watch the source files and rebuild when they change." + } ) + .option( "exclude", { + alias: "e", + type: "array", + description: + "Modules to exclude from the build. " + + "Specifying this option will cause the " + + "specified modules to be excluded from the build." + } ) + .option( "include", { + alias: "i", + type: "array", + description: + "Modules to include in the build. " + + "Specifying this option will override the " + + "default included modules and only include these modules." + } ) + .option( "esm", { + type: "boolean", + description: + "Build an ES module (ESM) bundle. " + + "By default, a UMD bundle is built." + } ) + .option( "slim", { + alias: "s", + type: "boolean", + description: "Build a slim bundle, which excludes " + + slimExclude.join( ", " ) + } ) + .option( "amd", { + type: "string", + description: + "Set the name of the AMD module. Leave blank to make an anonymous module." + } ) + .help() + .argv; + +build( argv ); diff --git a/build/tasks/testswarm.js b/build/grunt-tasks/testswarm.js index d2653e0e0..d2653e0e0 100644 --- a/build/tasks/testswarm.js +++ b/build/grunt-tasks/testswarm.js diff --git a/build/tasks/build.js b/build/tasks/build.js index 3fc37da8a..1a0d7d75a 100644 --- a/build/tasks/build.js +++ b/build/tasks/build.js @@ -6,350 +6,356 @@ "use strict"; -module.exports = function( grunt ) { - const fs = require( "fs" ); - const path = require( "path" ); - const rollup = require( "rollup" ); - const slimBuildFlags = require( "./lib/slim-build-flags" ); - const rollupFileOverrides = require( "./lib/rollup-plugin-file-overrides" ); - const srcFolder = path.resolve( `${ __dirname }/../../src` ); - const read = function( fileName ) { - return grunt.file.read( `${ srcFolder }/${ fileName }` ); - }; +const fs = require( "fs" ); +const path = require( "path" ); +const util = require( "util" ); +const exec = util.promisify( require( "child_process" ).exec ); +const rollup = require( "rollup" ); +const excludedFromSlim = require( "./lib/slim-exclude" ); +const rollupFileOverrides = require( "./lib/rollup-plugin-file-overrides" ); +const pkg = require( "../../package.json" ); +const isCleanWorkingDir = require( "./lib/isCleanWorkingDir" ); +const minify = require( "./minify" ); +const getTimestamp = require( "./lib/getTimestamp" ); +const verifyNodeVersion = require( "./lib/verifyNodeVersion" ); +const srcFolder = path.resolve( __dirname, "../../src" ); + +const minimum = [ "core" ]; + +// Exclude specified modules if the module matching the key is removed +const removeWith = { + ajax: [ "manipulation/_evalUrl", "deprecated/ajax-event-alias" ], + callbacks: [ "deferred" ], + css: [ "effects", "dimensions", "offset" ], + "css/showHide": [ "effects" ], + deferred: { + remove: [ "ajax", "effects", "queue", "core/ready" ], + include: [ "core/ready-no-deferred" ] + }, + event: [ "deprecated/ajax-event-alias", "deprecated/event" ], + selector: [ "css/hiddenVisibleSelectors", "effects/animatedSelector" ] +}; - const inputFileName = "jquery.js"; - const inputRollupOptions = { - input: `${ srcFolder }/${ inputFileName }` - }; +async function read( filename ) { + return fs.promises.readFile( path.join( srcFolder, filename ), "utf8" ); +} + +// Remove the src folder and file extension +// and ensure unix-style path separators +function moduleName( filename ) { + return filename + .replace( `${srcFolder}${path.sep}`, "" ) + .replace( /\.js$/, "" ) + .split( path.sep ) + .join( path.posix.sep ); +} + +async function readdirRecursive( dir, all = [] ) { + let files; + try { + files = await fs.promises.readdir( path.join( srcFolder, dir ), { + withFileTypes: true + } ); + } catch ( e ) { + return all; + } + for ( const file of files ) { + const filepath = path.join( dir, file.name ); - function getOutputRollupOptions( { - esm = false - } = {} ) { - const wrapperFileName = `wrapper${ esm ? "-esm" : "" }.js`; + if ( file.isDirectory() ) { + all.push( ...( await readdirRecursive( filepath ) ) ); + } else { + all.push( moduleName( filepath ) ); + } + } + return all; +} - // Catch `// @CODE` and subsequent comment lines event if they don't start - // in the first column. - const wrapper = read( wrapperFileName ) - .split( /[\x20\t]*\/\/ @CODE\n(?:[\x20\t]*\/\/[^\n]+\n)*/ ); +async function getOutputRollupOptions( { esm = false } = {} ) { + const wrapperFileName = `wrapper${esm ? "-esm" : ""}.js`; + const wrapperSource = await read( wrapperFileName ); - return { + // Catch `// @CODE` and subsequent comment lines event if they don't start + // in the first column. + const wrapper = wrapperSource.split( + /[\x20\t]*\/\/ @CODE\n(?:[\x20\t]*\/\/[^\n]+\n)*/ + ); - // The ESM format is not actually used as we strip it during the - // build, inserting our own wrappers; it's just that it doesn't - // generate any extra wrappers so there's nothing for us to remove. - format: "esm", + return { - intro: `${ wrapper[ 0 ].replace( /\n*$/, "" ) }`, - outro: wrapper[ 1 ].replace( /^\n*/, "" ) - }; - } + // The ESM format is not actually used as we strip it during the + // build, inserting our own wrappers; it's just that it doesn't + // generate any extra wrappers so there's nothing for us to remove. + format: "esm", - const fileOverrides = new Map(); + intro: wrapper[ 0 ].replace( /\n*$/, "" ), + outro: wrapper[ 1 ].replace( /^\n*/, "" ) + }; +} - function getOverride( filePath ) { - return fileOverrides.get( path.resolve( filePath ) ); - } +const fileOverrides = new Map(); - function setOverride( filePath, source ) { +function setOverride( filePath, source ) { - // We want normalized paths in overrides as they will be matched - // against normalized paths in the file overrides Rollup plugin. - fileOverrides.set( path.resolve( filePath ), source ); - } + // We want normalized paths in overrides as they will be matched + // against normalized paths in the file overrides Rollup plugin. + fileOverrides.set( path.resolve( filePath ), source ); +} - grunt.registerMultiTask( - "build", - "Build jQuery ECMAScript modules, " + - "(include/exclude modules with +/- flags), embed date/version", - async function() { - const done = this.async(); - - try { - const flags = this.flags; - const optIn = flags[ "*" ]; - let name = grunt.option( "filename" ); - const esm = !!grunt.option( "esm" ); - const distFolder = grunt.option( "dist-folder" ); - const minimum = this.data.minimum; - const removeWith = this.data.removeWith; - const excluded = []; - const included = []; - let version = grunt.config( "pkg.version" ); - - // We'll skip printing the whole big exclusions for a bare `build:*:*:slim` which - // usually comes from `custom:slim`. - const isPureSlim = !!( flags.slim && flags[ "*" ] && - Object.keys( flags ).length === 2 ); - - delete flags[ "*" ]; - - if ( flags.slim ) { - delete flags.slim; - for ( const flag of slimBuildFlags ) { - flags[ flag ] = true; - } - } +function unique( array ) { + return [ ...new Set( array ) ]; +} +async function checkExclude( exclude, include ) { + const included = [ ...include ]; + const excluded = [ ...exclude ]; - /** - * Recursively calls the excluder to remove on all modules in the list - * @param {Array} list - * @param {String} [prepend] Prepend this to the module name. - * Indicates we're walking a directory - */ - const excludeList = ( list, prepend ) => { - if ( list ) { - prepend = prepend ? `${ prepend }/` : ""; - list.forEach( function( module ) { - - // Exclude var modules as well - if ( module === "var" ) { - excludeList( - fs.readdirSync( `${ srcFolder }/${ prepend }${ module }` ), - prepend + module - ); - return; - } - if ( prepend ) { - - // Skip if this is not a js file and we're walking files in a dir - if ( !( module = /([\w-\/]+)\.js$/.exec( module ) ) ) { - return; - } - - // Prepend folder name if passed - // Remove .js extension - module = prepend + module[ 1 ]; - } - - // Avoid infinite recursion - if ( excluded.indexOf( module ) === -1 ) { - excluder( "-" + module ); - } - } ); - } - }; - - /** - * Adds the specified module to the excluded or included list, depending on the flag - * @param {String} flag A module path relative to - * the src directory starting with + or - to indicate - * whether it should be included or excluded - */ - const excluder = flag => { - let additional; - const m = /^(\+|-|)([\w\/-]+)$/.exec( flag ); - const exclude = m[ 1 ] === "-"; - const module = m[ 2 ]; - - if ( exclude ) { - - // Can't exclude certain modules - if ( minimum.indexOf( module ) === -1 ) { - - // Add to excluded - if ( excluded.indexOf( module ) === -1 ) { - grunt.log.writeln( flag ); - excluded.push( module ); - - // Exclude all files in the folder of the same name - // These are the removable dependencies - // It's fine if the directory is not there - try { - - // `selector` is a special case as we don't just remove - // the module, but we replace it with `selector-native` - // which re-uses parts of the `src/selector` folder. - if ( module !== "selector" ) { - excludeList( - fs.readdirSync( `${ srcFolder }/${ module }` ), - module - ); - } - } catch ( e ) { - grunt.verbose.writeln( e ); - } - } - - additional = removeWith[ module ]; - - // Check removeWith list - if ( additional ) { - excludeList( additional.remove || additional ); - if ( additional.include ) { - included.push( ...additional.include ); - grunt.log.writeln( "+" + additional.include ); - } - } - } else { - grunt.log.error( "Module \"" + module + "\" is a minimum requirement." ); - } - } else { - grunt.log.writeln( flag ); - included.push( module ); - } - }; - - // Filename can be passed to the command line using - // command line options - // e.g. grunt build --filename=jquery-custom.js - name = name ? `${ distFolder }/${ name }` : this.data.dest; - - // append commit id to version - if ( process.env.COMMIT ) { - version += " " + process.env.COMMIT; - } + for ( const module of exclude ) { + if ( minimum.indexOf( module ) !== -1 ) { + throw new Error( `Module \"${module}\" is a minimum requirement.` ); + } - // figure out which files to exclude based on these rules in this order: - // dependency explicit exclude - // > explicit exclude - // > explicit include - // > dependency implicit exclude - // > implicit exclude - // examples: - // * none (implicit exclude) - // *:* all (implicit include) - // *:*:-css all except css and dependents (explicit > implicit) - // *:*:-css:+effects same (excludes effects because explicit include is - // trumped by explicit exclude of dependency) - // *:+effects none except effects and its dependencies - // (explicit include trumps implicit exclude of dependency) - for ( const flag in flags ) { - excluder( flag ); - } + // Exclude all files in the dir of the same name + // These are the removable dependencies + // It's fine if the directory is not there + // `selector` is a special case as we don't just remove + // the module, but we replace it with `selector-native` + // which re-uses parts of the `src/selector` dir. + if ( module !== "selector" ) { + const files = await readdirRecursive( module ); + excluded.push( ...files ); + } - // Remove the jQuery export from the entry file, we'll use our own - // custom wrapper. - setOverride( inputRollupOptions.input, - read( inputFileName ).replace( /\n*export \{ jQuery, jQuery as \$ };\n*/, "\n" ) ); - - // Replace exports/global with a noop noConflict - if ( excluded.includes( "exports/global" ) ) { - const index = excluded.indexOf( "exports/global" ); - setOverride( `${ srcFolder }/exports/global.js`, - "import jQuery from \"../core.js\";\n\n" + - "jQuery.noConflict = function() {};" ); - excluded.splice( index, 1 ); - } + // Check removeWith list + const additional = removeWith[ module ]; + if ( additional ) { + const [ additionalExcluded, additionalIncluded ] = await checkExclude( + additional.remove || additional, + additional.include || [] + ); + excluded.push( ...additionalExcluded ); + included.push( ...additionalIncluded ); + } + } + + return [ unique( excluded ), unique( included ) ]; +} + +async function writeCompiled( { code, dir, filename, version } ) { + const compiledContents = code + + // Embed Version + .replace( /@VERSION/g, version ) + + // Embed Date + // yyyy-mm-ddThh:mmZ + .replace( /@DATE/g, new Date().toISOString().replace( /:\d+\.\d+Z$/, "Z" ) ); + + await fs.promises.writeFile( path.join( dir, filename ), compiledContents ); + console.log( `[${getTimestamp()}] ${filename} v${version} created.` ); +} + +// Build jQuery ECMAScript modules +async function build( { + amd, + dir = "dist", + exclude = [], + filename = "jquery.js", + include = [], + esm = false, + slim = false, + version, + watch = false +} = {} ) { + const pureSlim = slim && !exclude.length && !include.length; + + // Add the short commit hash to the version string + // when the version is not for a release. + if ( !version ) { + const { stdout } = await exec( "git rev-parse --short HEAD" ); + const isClean = await isCleanWorkingDir(); + + // "+[slim.]SHA" is semantically correct + // Add ".dirty" as well if the working dir is not clean + version = `${pkg.version}+${slim ? "slim." : ""}${stdout.trim()}${isClean ? "" : ".dirty"}`; + } else if ( slim ) { + version += "+slim"; + } + + await fs.promises.mkdir( dir, { recursive: true } ); + + // Exclude slim modules when slim is true + const [ excluded, included ] = await checkExclude( + slim ? exclude.concat( excludedFromSlim ) : exclude, + include + ); + + // Replace exports/global with a noop noConflict + if ( excluded.includes( "exports/global" ) ) { + const index = excluded.indexOf( "exports/global" ); + setOverride( + `${srcFolder}/exports/global.js`, + "import { jQuery } from \"../core.js\";\n\n" + + "jQuery.noConflict = function() {};" + ); + excluded.splice( index, 1 ); + } - // Set a desired AMD name. - let amdName = grunt.option( "amd" ); - if ( amdName != null ) { - if ( amdName ) { - grunt.log.writeln( "Naming jQuery with AMD name: " + amdName ); - } else { - grunt.log.writeln( "AMD name now anonymous" ); - } + // Set a desired AMD name. + if ( amd != null ) { + if ( amd ) { + console.log( "Naming jQuery with AMD name: " + amd ); + } else { + console.log( "AMD name now anonymous" ); + } + + // Replace the AMD name in the AMD export + // No name means an anonymous define + const amdExportContents = await read( "exports/amd.js" ); + setOverride( + `${srcFolder}/exports/amd.js`, + amdExportContents.replace( // Remove the comma for anonymous defines - setOverride( `${ srcFolder }/exports/amd.js`, - read( "exports/amd.js" ) - .replace( /(\s*)"jquery"(,\s*)/, - amdName ? "$1\"" + amdName + "\"$2" : "" ) ); - } + /(\s*)"jquery"(,\s*)/, + amd ? `$1\"${amd}\"$2` : " " + ) + ); + } - grunt.verbose.writeflags( excluded, "Excluded" ); - grunt.verbose.writeflags( included, "Included" ); + // Append excluded modules to version. + // Skip adding exclusions for slim builds. + // Don't worry about semver syntax for these. + if ( !pureSlim && excluded.length ) { + version += " -" + excluded.join( ",-" ); + } - // Indicate a Slim build without listing all the exclusions - // to save space. - if ( isPureSlim ) { - version += " slim"; + // Append extra included modules to version. + if ( !pureSlim && included.length ) { + version += " +" + included.join( ",+" ); + } - // Append excluded modules to version. - } else if ( excluded.length ) { - version += " -" + excluded.join( ",-" ); - } + const inputOptions = { + input: `${srcFolder}/jquery.js` + }; - if ( excluded.length ) { - - // Set pkg.version to version with excludes or with the "slim" marker, - // so minified file picks it up but skip the commit hash the same way - // it's done for the full build. - const commitlessVersion = version.replace( " " + process.env.COMMIT, "" ); - grunt.config.set( "pkg.version", commitlessVersion ); - grunt.verbose.writeln( "Version changed to " + commitlessVersion ); - - // Replace excluded modules with empty sources. - for ( const module of excluded ) { - setOverride( - `${ srcFolder }/${ module }.js`, - - // The `selector` module is not removed, but replaced - // with `selector-native`. - module === "selector" ? read( "selector-native.js" ) : "" - ); - } - } + const includedImports = included + .map( ( module ) => `import "./${module}.js";` ) + .join( "\n" ); - // Turn off opt-in if necessary - if ( !optIn ) { + const jQueryFileContents = await read( "jquery.js" ); + if ( include.length ) { - // Remove the default inclusions, they will be overwritten with the explicitly - // included ones. - setOverride( inputRollupOptions.input, "" ); + // If include is specified, only add those modules. + setOverride( inputOptions.input, includedImports ); + } else { - } + // Remove the jQuery export from the entry file, we'll use our own + // custom wrapper. + setOverride( + inputOptions.input, + jQueryFileContents.replace( /\n*export \{ jQuery, jQuery as \$ };\n*/, "\n" ) + + includedImports + ); + } - // Import the explicitly included modules. - if ( included.length ) { - setOverride( inputRollupOptions.input, - getOverride( inputRollupOptions.input ) + included - .map( module => `import "./${module}.js";` ) - .join( "\n" ) ); - } + // Replace excluded modules with empty sources. + for ( const module of excluded ) { + setOverride( + `${srcFolder}/${module}.js`, - const bundle = await rollup.rollup( { - ...inputRollupOptions, - plugins: [ rollupFileOverrides( fileOverrides ) ] - } ); + // The `selector` module is not removed, but replaced + // with `selector-native`. + module === "selector" ? await read( "selector-native.js" ) : "" + ); + } - const outputRollupOptions = - getOutputRollupOptions( { esm } ); + const bundle = await rollup.rollup( { + ...inputOptions, + plugins: [ rollupFileOverrides( fileOverrides ) ] + } ); - const { output: [ { code } ] } = await bundle.generate( outputRollupOptions ); + const outputOptions = await getOutputRollupOptions( { esm } ); - const compiledContents = code + if ( watch ) { + const watcher = rollup.watch( { + ...inputOptions, + output: [ outputOptions ], + plugins: [ rollupFileOverrides( fileOverrides ) ], + watch: { + include: `${srcFolder}/**`, + skipWrite: true + } + } ); + + watcher.on( "event", async( event ) => { + switch ( event.code ) { + case "ERROR": + console.error( event.error ); + break; + case "BUNDLE_END": + const { + output: [ { code } ] + } = await event.result.generate( outputOptions ); + + await writeCompiled( { + code, + dir, + filename, + version + } ); - // Embed Version - .replace( /@VERSION/g, version ) + await minify( { dir, filename, esm } ); + break; + } + } ); - // Embed Date - // yyyy-mm-ddThh:mmZ - .replace( - /@DATE/g, - ( new Date() ).toISOString() - .replace( /:\d+\.\d+Z$/, "Z" ) - ); + return watcher; + } else { + const { + output: [ { code } ] + } = await bundle.generate( outputOptions ); - grunt.file.write( name, compiledContents ); - grunt.log.ok( `File '${ name }' created.` ); - done(); - } catch ( err ) { - done( err ); - } - } ); + await writeCompiled( { code, dir, filename, version } ); + await minify( { dir, filename, esm } ); + } +} + +async function buildDefaultFiles( { version, watch } = {} ) { + await Promise.all( [ + build( { version, watch } ), + build( { filename: "jquery.slim.js", slim: true, version, watch } ), + build( { + dir: "dist-module", + filename: "jquery.module.js", + esm: true, + version, + watch + } ), + build( { + dir: "dist-module", + filename: "jquery.slim.module.js", + esm: true, + slim: true, + version, + watch + } ) + ] ); + + // Earlier Node.js versions do not support the ESM format. + if ( !verifyNodeVersion() ) { + return; + } - // Special "alias" task to make custom build creation less grawlix-y - // Translation example - // - // grunt custom:+ajax,-dimensions,-effects,-offset - // - // Becomes: - // - // grunt build:*:*:+ajax:-dimensions:-effects:-offset - // - // There's also a special "slim" alias that resolves to the jQuery Slim build - // configuration: - // - // grunt custom:slim - grunt.registerTask( "custom", function() { - const args = this.args; - const modules = args.length ? - args[ 0 ].split( "," ).join( ":" ) : - ""; - - grunt.log.writeln( "Creating custom build...\n" ); - grunt.task.run( [ "build:*:*" + ( modules ? ":" + modules : "" ), "minify", "dist" ] ); + const { compareSize } = await import( "./compare_size.mjs" ); + return compareSize( { + files: [ + "dist/jquery.min.js", + "dist/jquery.slim.min.js", + "dist-module/jquery.module.min.js", + "dist-module/jquery.slim.module.min.js" + ] } ); -}; +} + +module.exports = { build, buildDefaultFiles }; diff --git a/build/tasks/compare_size.mjs b/build/tasks/compare_size.mjs new file mode 100644 index 000000000..fd6d8e72f --- /dev/null +++ b/build/tasks/compare_size.mjs @@ -0,0 +1,128 @@ +import chalk from "chalk"; +import fs from "node:fs"; +import { promisify } from "node:util"; +import zlib from "node:zlib"; +import { exec as nodeExec } from "node:child_process"; +import isCleanWorkingDir from "./lib/isCleanWorkingDir.js"; + +const gzip = promisify( zlib.gzip ); +const exec = promisify( nodeExec ); + +async function getBranchName() { + const { stdout } = await exec( "git rev-parse --abbrev-ref HEAD" ); + return stdout.trim(); +} + +async function getCache( loc ) { + try { + const contents = await fs.promises.readFile( loc, "utf8" ); + return JSON.parse( contents ); + } catch ( err ) { + return {}; + } +} + +function saveCache( loc, cache ) { + return fs.promises.writeFile( loc, JSON.stringify( cache ) ); +} + +function compareSizes( existing, current, padLength ) { + if ( typeof current !== "number" ) { + return chalk.grey( `${existing}`.padStart( padLength ) ); + } + const delta = current - existing; + if ( delta > 0 ) { + return chalk.red( `+${delta}`.padStart( padLength ) ); + } + return chalk.green( `${delta}`.padStart( padLength ) ); +} + +export async function compareSize( { cache = ".sizecache.json", files } = {} ) { + if ( !files || !files.length ) { + throw new Error( "No files specified" ); + } + + const branch = await getBranchName(); + const sizeCache = await getCache( cache ); + + let rawPadLength = 0; + let gzPadLength = 0; + const results = await Promise.all( + files.map( async function( filename ) { + + let contents = await fs.promises.readFile( filename, "utf8" ); + + // Remove the banner for size comparisons. + // The version string can vary widely by short SHA. + contents = contents.replace( /\/\*\! jQuery[^\n]+/, "" ); + + const size = Buffer.byteLength( contents, "utf8" ); + const gzippedSize = ( await gzip( contents ) ).length; + + // Add one to give space for the `+` or `-` in the comparison + rawPadLength = Math.max( rawPadLength, size.toString().length + 1 ); + gzPadLength = Math.max( gzPadLength, gzippedSize.toString().length + 1 ); + + return { filename, raw: size, gz: gzippedSize }; + } ) + ); + + const header = "raw".padStart( rawPadLength ) + + "gz".padStart( gzPadLength + 1 ) + + " Filename"; + + const sizes = results.map( function( result ) { + const rawSize = result.raw.toString().padStart( rawPadLength ); + const gzSize = result.gz.toString().padStart( gzPadLength ); + return `${rawSize} ${gzSize} ${result.filename}`; + } ); + + const comparisons = Object.keys( sizeCache ).map( function( branch ) { + const branchSizes = Object.keys( sizeCache[ branch ] ).map( function( filename ) { + const branchResult = sizeCache[ branch ][ filename ]; + const compareResult = results.find( function( result ) { + return result.filename === filename; + } ) || {}; + + const compareRaw = compareSizes( branchResult.raw, compareResult.raw, rawPadLength ); + const compareGz = compareSizes( branchResult.gz, compareResult.gz, gzPadLength ); + return `${compareRaw} ${compareGz} ${filename}`; + } ); + + return [ + "", // New line before each branch + chalk.bold( branch ), + header, + ...branchSizes + ].join( "\n" ); + } ); + + const output = [ + "", // Opening new line + chalk.bold( "Sizes" ), + header, + ...sizes, + ...comparisons, + "" // Closing new line + ].join( "\n" ); + + console.log( output ); + + // Only save cache for the current branch + // if the working directory is clean. + if ( await isCleanWorkingDir() ) { + sizeCache[ branch ] = {}; + results.forEach( function( result ) { + sizeCache[ branch ][ result.filename ] = { + raw: result.raw, + gz: result.gz + }; + } ); + + await saveCache( cache, sizeCache ); + + console.log( `Saved cache for ${branch}.` ); + } + + return results; +} diff --git a/build/tasks/dist.js b/build/tasks/dist.js index 36ce38b00..d6488aa1b 100644 --- a/build/tasks/dist.js +++ b/build/tasks/dist.js @@ -1,72 +1,31 @@ "use strict"; -module.exports = function( grunt ) { - const fs = require( "fs" ); - const filename = grunt.option( "filename" ); - const distFolder = grunt.option( "dist-folder" ); - const distPaths = [ - `${ distFolder }/${ filename }`, - `${ distFolder }/${ filename.replace( ".js", ".min.js" ) }`, - `${ distFolder }/${ filename.replace( ".js", ".min.map" ) }` - ]; - - // Process files for distribution - grunt.registerTask( "dist", function() { - let stored, flags, paths, nonascii; - - // Check for stored destination paths - // ( set in dist/.destination.json ) - stored = Object.keys( grunt.config( "dst" ) ); - - // Allow command line input as well - flags = Object.keys( this.flags ); - - // Combine all output target paths - paths = [].concat( stored, flags ).filter( function( path ) { - return path !== "*"; - } ); - - // Ensure the dist files are pure ASCII - nonascii = false; - - distPaths.forEach( function( filename ) { - let i, c; - const text = fs.readFileSync( filename, "utf8" ); - - // Ensure files use only \n for line endings, not \r\n - if ( /\x0d\x0a/.test( text ) ) { - grunt.log.writeln( filename + ": Incorrect line endings (\\r\\n)" ); - nonascii = true; +// Process files for distribution. +module.exports = async function processForDist( text, filename ) { + if ( !text ) { + throw new Error( "text required for processForDist" ); + } + + if ( !filename ) { + throw new Error( "filename required for processForDist" ); + } + + // Ensure files use only \n for line endings, not \r\n + if ( /\x0d\x0a/.test( text ) ) { + throw new Error( filename + ": Incorrect line endings (\\r\\n)" ); + } + + // Ensure only ASCII chars so script tags don't need a charset attribute + if ( text.length !== Buffer.byteLength( text, "utf8" ) ) { + let message = filename + ": Non-ASCII characters detected:\n"; + for ( let i = 0; i < text.length; i++ ) { + const c = text.charCodeAt( i ); + if ( c > 127 ) { + message += "- position " + i + ": " + c + "\n"; + message += "==> " + text.substring( i - 20, i + 20 ); + break; } - - // Ensure only ASCII chars so script tags don't need a charset attribute - if ( text.length !== Buffer.byteLength( text, "utf8" ) ) { - grunt.log.writeln( filename + ": Non-ASCII characters detected:" ); - for ( i = 0; i < text.length; i++ ) { - c = text.charCodeAt( i ); - if ( c > 127 ) { - grunt.log.writeln( "- position " + i + ": " + c ); - grunt.log.writeln( "-- " + text.substring( i - 20, i + 20 ) ); - break; - } - } - nonascii = true; - } - - // Optionally copy dist files to other locations - paths.forEach( function( path ) { - let created; - - if ( !/\/$/.test( path ) ) { - path += "/"; - } - - created = path + filename.replace( "dist/", "" ); - grunt.file.write( created, text ); - grunt.log.writeln( "File '" + created + "' created." ); - } ); - } ); - - return !nonascii; - } ); + } + throw new Error( message ); + } }; diff --git a/build/tasks/lib/getTimestamp.js b/build/tasks/lib/getTimestamp.js new file mode 100644 index 000000000..c3c8aed11 --- /dev/null +++ b/build/tasks/lib/getTimestamp.js @@ -0,0 +1,9 @@ +"use strict"; + +module.exports = function getTimestamp() { + const now = new Date(); + const hours = now.getHours().toString().padStart( 2, "0" ); + const minutes = now.getMinutes().toString().padStart( 2, "0" ); + const seconds = now.getSeconds().toString().padStart( 2, "0" ); + return `${hours}:${minutes}:${seconds}`; +}; diff --git a/build/tasks/lib/isCleanWorkingDir.js b/build/tasks/lib/isCleanWorkingDir.js new file mode 100644 index 000000000..16c87fd9d --- /dev/null +++ b/build/tasks/lib/isCleanWorkingDir.js @@ -0,0 +1,9 @@ +"use strict"; + +const util = require( "util" ); +const exec = util.promisify( require( "child_process" ).exec ); + +module.exports = async function isCleanWorkingDir() { + const { stdout } = await exec( "git status --untracked-files=no --porcelain" ); + return !stdout.trim(); +}; diff --git a/build/tasks/lib/slim-build-flags.js b/build/tasks/lib/slim-exclude.js index a3574df21..cc74cbac5 100644 --- a/build/tasks/lib/slim-build-flags.js +++ b/build/tasks/lib/slim-exclude.js @@ -2,9 +2,9 @@ // NOTE: keep it in sync with test/data/testinit.js module.exports = [ - "-ajax", - "-callbacks", - "-deferred", - "-effects", - "-queue" + "ajax", + "callbacks", + "deferred", + "effects", + "queue" ]; diff --git a/build/tasks/lib/spawn_test.js b/build/tasks/lib/spawn_test.js deleted file mode 100644 index 146155411..000000000 --- a/build/tasks/lib/spawn_test.js +++ /dev/null @@ -1,16 +0,0 @@ -"use strict"; - -// Run Node with provided parameters: the first one being the Grunt -// done function and latter ones being files to be tested. -// See the comment in ../node_smoke_tests.js for more information. -module.exports = function spawnTest( done, command ) { - var spawn = require( "child_process" ).spawn; - - spawn( command, { - stdio: "inherit", - shell: true - } ) - .on( "close", function( code ) { - done( code === 0 ); - } ); -}; diff --git a/build/tasks/lib/verifyNodeVersion.js b/build/tasks/lib/verifyNodeVersion.js new file mode 100644 index 000000000..80d57ed6e --- /dev/null +++ b/build/tasks/lib/verifyNodeVersion.js @@ -0,0 +1,12 @@ +"use strict"; + +const { version } = require( "process" ); +const nodeV16OrNewer = !/^v1[0-5]\./.test( version ); + +module.exports = function verifyNodeVersion() { + if ( !nodeV16OrNewer ) { + console.log( "Old Node.js detected, task skipped..." ); + return false; + } + return true; +}; diff --git a/build/tasks/minify.js b/build/tasks/minify.js index 6d3c6c568..c5e0ff25f 100644 --- a/build/tasks/minify.js +++ b/build/tasks/minify.js @@ -1,57 +1,67 @@ -/** - * Minify JavaScript using SWC. - */ - "use strict"; -module.exports = ( grunt ) => { - const swc = require( "@swc/core" ); - - grunt.registerMultiTask( - "minify", - "Minify JavaScript using SWC", - async function() { - const done = this.async(); - const options = this.options(); - const sourceMapFilename = options.sourceMap && options.sourceMap.filename; - const sourceMapOverrides = options.sourceMap && options.sourceMap.overrides || {}; - - await Promise.all( this.files.map( async( { src, dest } ) => { - if ( src.length !== 1 ) { - grunt.fatal( "The minify task requires a single source per destination" ); - } - - const { code, map: incompleteMap } = await swc.minify( - grunt.file.read( src[ 0 ] ), - { - ...options.swc, - inlineSourcesContent: false, - sourceMap: sourceMapFilename ? - { - filename: sourceMapFilename - } : - false - } - ); - - // Can't seem to get SWC to not use CRLF on Windows, so replace them with LF. - grunt.file.write( dest, code.replace( /\r\n/g, "\n" ) ); - - if ( sourceMapFilename ) { - - // Apply map overrides if needed. See the task config description - // for more details. - const mapObject = { - ...JSON.parse( incompleteMap ), - ...sourceMapOverrides - }; - const map = JSON.stringify( mapObject ); - - grunt.file.write( sourceMapFilename, map ); - } - } ) ); - - done(); +const swc = require( "@swc/core" ); +const fs = require( "fs" ); +const path = require( "path" ); +const processForDist = require( "./dist" ); +const getTimestamp = require( "./lib/getTimestamp" ); + +const rjs = /\.js$/; + +module.exports = async function minify( { filename, dir, esm } ) { + const contents = await fs.promises.readFile( path.join( dir, filename ), "utf8" ); + const version = /jQuery JavaScript Library ([^\n]+)/.exec( contents )[ 1 ]; + + const { code, map: incompleteMap } = await swc.minify( + contents, + { + compress: { + ecma: esm ? 2015 : 5, + hoist_funs: false, + loops: false + }, + format: { + ecma: esm ? 2015 : 5, + asciiOnly: true, + comments: false, + preamble: `/*! jQuery ${version}` + + " | (c) OpenJS Foundation and other contributors" + + " | jquery.org/license */\n" + }, + mangle: true, + inlineSourcesContent: false, + sourceMap: true } ); + + const minFilename = filename.replace( rjs, ".min.js" ); + const mapFilename = filename.replace( rjs, ".min.map" ); + + // The map's `files` & `sources` property are set incorrectly, fix + // them via overrides from the task config. + // See https://github.com/swc-project/swc/issues/7588#issuecomment-1624345254 + const map = JSON.stringify( { + ...JSON.parse( incompleteMap ), + file: minFilename, + sources: [ filename ] + } ); + + await Promise.all( [ + fs.promises.writeFile( + path.join( dir, minFilename ), + code + ), + fs.promises.writeFile( + path.join( dir, mapFilename ), + map + ) + ] ); + + // Always process files for dist + // Doing it here avoids extra file reads + processForDist( contents, filename ); + processForDist( code, minFilename ); + processForDist( map, mapFilename ); + + console.log( `[${getTimestamp()}] ${minFilename} ${version} with ${mapFilename} created.` ); }; diff --git a/build/tasks/node_smoke_tests.js b/build/tasks/node_smoke_tests.js index 7edbac881..5aa7660b0 100644 --- a/build/tasks/node_smoke_tests.js +++ b/build/tasks/node_smoke_tests.js @@ -1,51 +1,50 @@ "use strict"; -module.exports = ( grunt ) => { - const fs = require( "fs" ); - const spawnTest = require( "./lib/spawn_test.js" ); - const nodeV16OrNewer = !/^v1[0-5]\./.test( process.version ); - - grunt.registerTask( "node_smoke_tests", function( moduleType, jQueryModuleSpecifier ) { - if ( - ( moduleType !== "commonjs" && moduleType !== "module" ) || - !jQueryModuleSpecifier - ) { - grunt.fatal( "Use `node_smoke_tests:commonjs:JQUERY` " + - "or `node_smoke_tests:module:JQUERY.\n" + - "JQUERY can be `jquery`, `jquery/slim` or a path to any of them." ); - } - - if ( !nodeV16OrNewer ) { - grunt.log.writeln( "Old Node.js detected, running the task " + - `"node_smoke_tests:${ moduleType }:${ jQueryModuleSpecifier }" skipped...` ); - return; - } - - const testsDir = `./test/node_smoke_tests/${ moduleType }`; - const nodeSmokeTests = []; - - // Fire up all tests defined in test/node_smoke_tests/*.js in spawned sub-processes. - // All the files under test/node_smoke_tests/*.js are supposed to exit with 0 code - // on success or another one on failure. Spawning in sub-processes is - // important so that the tests & the main process don't interfere with - // each other, e.g. so that they don't share the `require` cache. - - fs.readdirSync( testsDir ) - .filter( ( testFilePath ) => - fs.statSync( `${ testsDir }/${ testFilePath }` ).isFile() && - /\.[cm]?js$/.test( testFilePath ) - ) - .forEach( ( testFilePath ) => { - const taskName = `node_${ testFilePath.replace( /\.[cm]?js$/, "" ) }:${ moduleType }:${ jQueryModuleSpecifier }`; - - grunt.registerTask( taskName, function() { - spawnTest( this.async(), `node "${ testsDir }/${ - testFilePath }" ${ jQueryModuleSpecifier }` ); - } ); - - nodeSmokeTests.push( taskName ); - } ); - - grunt.task.run( nodeSmokeTests ); - } ); -}; +const fs = require( "fs" ); +const util = require( "util" ); +const exec = util.promisify( require( "child_process" ).exec ); +const verifyNodeVersion = require( "./lib/verifyNodeVersion" ); + +const allowedModules = [ "commonjs", "module" ]; + +if ( !verifyNodeVersion() ) { + return; +} + +// Fire up all tests defined in test/node_smoke_tests/*.js in spawned sub-processes. +// All the files under test/node_smoke_tests/*.js are supposed to exit with 0 code +// on success or another one on failure. Spawning in sub-processes is +// important so that the tests & the main process don't interfere with +// each other, e.g. so that they don't share the `require` cache. + +async function runTests( sourceType, module ) { + if ( !allowedModules.includes( sourceType ) ) { + throw new Error( + `Usage: \`node_smoke_tests [${allowedModules.join( "|" )}]:JQUERY\`` + ); + } + const dir = `./test/node_smoke_tests/${sourceType}`; + const files = await fs.promises.readdir( dir, { withFileTypes: true } ); + const testFiles = files.filter( ( testFilePath ) => testFilePath.isFile() ); + await Promise.all( + testFiles.map( ( testFile ) => + exec( `node "${dir}/${testFile.name}" "${module}"` ) + ) + ); + console.log( `Node smoke tests passed for ${sourceType} "${module}".` ); +} + +async function runDefaultTests() { + await Promise.all( [ + runTests( "commonjs", "jquery" ), + runTests( "commonjs", "jquery/slim" ), + runTests( "commonjs", "./dist/jquery.js" ), + runTests( "commonjs", "./dist/jquery.slim.js" ), + runTests( "module", "jquery" ), + runTests( "module", "jquery/slim" ), + runTests( "module", "./dist-module/jquery.module.js" ), + runTests( "module", "./dist-module/jquery.slim.module.js" ) + ] ); +} + +runDefaultTests(); diff --git a/build/tasks/npmcopy.js b/build/tasks/npmcopy.js new file mode 100644 index 000000000..c57acc2e7 --- /dev/null +++ b/build/tasks/npmcopy.js @@ -0,0 +1,42 @@ +"use strict"; + +const fs = require( "fs" ); +const path = require( "path" ); + +const projectDir = path.resolve( __dirname, "..", ".." ); + +const files = { + "bootstrap/bootstrap.css": "bootstrap/dist/css/bootstrap.css", + "bootstrap/bootstrap.min.css": "bootstrap/dist/css/bootstrap.min.css", + "bootstrap/bootstrap.min.css.map": "bootstrap/dist/css/bootstrap.min.css.map", + + "core-js-bundle/core-js-bundle.js": "core-js-bundle/minified.js", + "core-js-bundle/LICENSE": "core-js-bundle/LICENSE", + + "npo/npo.js": "native-promise-only/lib/npo.src.js", + + "qunit/qunit.js": "qunit/qunit/qunit.js", + "qunit/qunit.css": "qunit/qunit/qunit.css", + "qunit/LICENSE.txt": "qunit/LICENSE.txt", + + "requirejs/require.js": "requirejs/require.js", + + "sinon/sinon.js": "sinon/pkg/sinon.js", + "sinon/LICENSE.txt": "sinon/LICENSE" +}; + +async function npmcopy() { + await fs.promises.mkdir( path.resolve( projectDir, "external" ), { + recursive: true + } ); + for ( const [ dest, source ] of Object.entries( files ) ) { + const from = path.resolve( projectDir, "node_modules", source ); + const to = path.resolve( projectDir, "external", dest ); + const toDir = path.dirname( to ); + await fs.promises.mkdir( toDir, { recursive: true } ); + await fs.promises.copyFile( from, to ); + console.log( `${source} → ${dest}` ); + } +} + +npmcopy(); diff --git a/build/tasks/promises_aplus_tests.js b/build/tasks/promises_aplus_tests.js index 1bbeff08e..fc94c8e02 100644 --- a/build/tasks/promises_aplus_tests.js +++ b/build/tasks/promises_aplus_tests.js @@ -1,27 +1,32 @@ "use strict"; -module.exports = grunt => { - const timeout = 2000; - const spawnTest = require( "./lib/spawn_test.js" ); +const { spawn } = require( "child_process" ); +const verifyNodeVersion = require( "./lib/verifyNodeVersion" ); +const path = require( "path" ); +const os = require( "os" ); - grunt.registerTask( "promises_aplus_tests", - [ "promises_aplus_tests:deferred", "promises_aplus_tests:when" ] ); +if ( !verifyNodeVersion() ) { + return; +} - grunt.registerTask( "promises_aplus_tests:deferred", function() { - spawnTest( this.async(), - "\"" + __dirname + "/../../node_modules/.bin/promises-aplus-tests\"" + - " test/promises_aplus_adapters/deferred.cjs" + - " --reporter dot" + - " --timeout " + timeout - ); - } ); +const command = path.resolve( + __dirname, + `../../node_modules/.bin/promises-aplus-tests${os.platform() === "win32" ? ".cmd" : ""}` +); +const args = [ "--reporter", "dot", "--timeout", "2000" ]; +const tests = [ + "test/promises_aplus_adapters/deferred.cjs", + "test/promises_aplus_adapters/when.cjs" +]; - grunt.registerTask( "promises_aplus_tests:when", function() { - spawnTest( this.async(), - "\"" + __dirname + "/../../node_modules/.bin/promises-aplus-tests\"" + - " test/promises_aplus_adapters/when.cjs" + - " --reporter dot" + - " --timeout " + timeout +async function runTests() { + tests.forEach( ( test ) => { + spawn( + command, + [ test ].concat( args ), + { stdio: "inherit" } ); } ); -}; +} + +runTests(); diff --git a/build/tasks/qunit-fixture.js b/build/tasks/qunit-fixture.js new file mode 100644 index 000000000..3059bb929 --- /dev/null +++ b/build/tasks/qunit-fixture.js @@ -0,0 +1,17 @@ +"use strict"; + +const fs = require( "fs" ); + +async function generateFixture() { + const fixture = await fs.promises.readFile( "./test/data/qunit-fixture.html", "utf8" ); + await fs.promises.writeFile( + "./test/data/qunit-fixture.js", + "// Generated by build/tasks/qunit-fixture.js\n" + + "QUnit.config.fixture = " + + JSON.stringify( fixture.replace( /\r\n/g, "\n" ) ) + + ";\n" + ); + console.log( "Updated ./test/data/qunit-fixture.js" ); +} + +generateFixture(); diff --git a/build/tasks/qunit_fixture.js b/build/tasks/qunit_fixture.js deleted file mode 100644 index 138ca662d..000000000 --- a/build/tasks/qunit_fixture.js +++ /dev/null @@ -1,22 +0,0 @@ -"use strict"; - -var fs = require( "fs" ); - -module.exports = function( grunt ) { - grunt.registerTask( "qunit_fixture", function() { - var dest = "./test/data/qunit-fixture.js"; - fs.writeFileSync( - dest, - "// Generated by build/tasks/qunit_fixture.js\n" + - "QUnit.config.fixture = " + - JSON.stringify( - fs.readFileSync( - "./test/data/qunit-fixture.html", - "utf8" - ).toString().replace( /\r\n/g, "\n" ) - ) + - ";\n" - ); - grunt.log.ok( "Updated " + dest + "." ); - } ); -}; |