gulpfile.js

Summary

Maintainability
D
1 day
Test Coverage
// @ts-check
const fs = require('fs');
const path = require('path');
const gulp = require('gulp');
const minimist = require('minimist');
/**
 * @typedef {Object} BabelMismatch
 * @property {import('@babel/core').TransformOptions['filename']} patch
 * @property {NonNullable<Parameters<typeof import('gulp-babel')>[0]>['filename']} original
 */
/**
 * gulp-babel seems to be no longer supported, so I patch the type declaration here manually.
 * @type {(options?: import('@babel/core').TransformOptions) => NodeJS.ReadWriteStream}
 *
 * {@link BabelMismatch.patch} is not assignable to {@link BabelMismatch.original}.
 */
// @ts-expect-error: Types of property `filename` are incompatible. See the links above.
const babel = require('gulp-babel');
const rename = require('gulp-rename');
const uglify = require('gulp-uglify');
const browserSync = require('browser-sync').create();
/**
 * @typedef {Object} PrintMismatch
 * @property {NodeJS.ReadWriteStream} patch
 * @property {import('stream').Stream} original
 */
/**
 * gulp-print seems to be no longer supported, so I patch the type declaration here manually.
 * @type {(format?: import('gulp-print').FormatFunction) => NodeJS.ReadWriteStream}
 *
 * {@link PrintMismatch.patch} is not assignable to {@link PrintMismatch.original}.
 */
// @ts-expect-error: Type mismatch due to missing properties. See the links above.
const print = require('gulp-print').default;
/**
 * @typedef {<F extends () => NodeJS.ReadWriteStream, A extends Parameters<F>>(filter: F, args: A) => ReturnType<F>} Through
 */
/**
 * @type {(tapFunction: (file: import('vinyl'), t?: {through?: Through}) => void) => NodeJS.ReadWriteStream}
 */
const tap = require('gulp-tap');
const gulpIgnore = require('gulp-ignore');
const postcss = require('gulp-postcss');
const mergeStream = require('merge-stream');
/**
 * The signature should be `createResolver(config, options).resolve(key, ...)`.
 * @type {(...args: Parameters<typeof import('resolve-options')>) => {resolve: (key: string, ...args: any[]) => number | string | boolean | Date | undefined | null}}
 */
const createResolver = require('resolve-options');
const webpackStream = require('webpack-stream');
const webpack = require('webpack');
const flatmap = require('gulp-flatmap');
const ts = require('gulp-typescript');
const concat = require('gulp-concat');
const vinylMap = require('vinyl-map');

const knownOptions = {
    string: ['src', 'dest'],
    number: ['port'],
    boolean: ['force', 'devMode'],
    default: {
        src: 'zul/src/main/resources/web/js',
        dest: 'zul/build/resources/main/web/js',
        force: false,
        port: 8080,
        devMode: false,
        subprojectPaths: 'zk,zul,../zkcml/zkex,../zkcml/zkmax,../zkcml/client-bind',
    }
};
const options = minimist(process.argv.slice(3), knownOptions);
/**
 * `options.subprojectPaths` is set from "build.gradle" in "zktest" and "zephyr-test".
 * In any case, duplicated paths should be pruned.
 */
options.subprojectPaths = [...new Set(
    /** @type {string} */(options.subprojectPaths).split(',')
)];

/**
 * Workaround for maven frontend-maven-plugin passing quoted strings
 * @param {string} txt
 */
function stripQuotes(txt) {
    if (txt.startsWith('"') && txt.endsWith('"')) {
        return txt.substring(1, txt.length - 1);
    }
    return txt;
}

function watch_job(glob, job) {
    const watcher = gulp.watch(glob, { ignoreInitial: false }, job);
    watcher.on('change', function (path) {
        console.log('Detect file change: ' + path + '...');
    });
    return watcher;
}

const config = {
    cwd: {
        type: 'string',
        default: process.cwd,
    }
};

/**
 * @param {string} destDir - Output directory
 * @param {boolean} [force] - Force keep
 */
function ignoreSameFile(destDir, force) {
    return gulpIgnore.exclude(function (file) {
        if (force) return false;
        // simulate gulp.dest() to find a output path
        const optResolver = createResolver(config, {});
        const cwd = path.resolve(/** @type {string} */(optResolver.resolve('cwd', file)));
        const basePath = path.resolve(cwd, destDir);
        const writePath = path.resolve(basePath, file.relative);
        if (fs.existsSync(writePath)) {
            const srcStat = fs.statSync(file.path);
            const outStat = fs.statSync(writePath);
            if (srcStat.mtime <= outStat.mtime) {
                return true;
            }
        }
        return false;
    });
}

function typescript_build_single(cb) {
    const sources = stripQuotes(options.src);
    const destDir = stripQuotes(options.dest);
    const force = options.force;
    typescript_build(sources, destDir, force);
    cb();
}
function typescript_build_single_with_src(cb) {
    if (options.devMode) {
        cb();
        return; // skip when in development mode
    }
    const sources = stripQuotes(options.src);
    const destDir = stripQuotes(options.dest);
    const force = options.force;
    typescript_build(sources, destDir, force, undefined, true);
    cb();
}

/**
 * See {@link typescript_build_single}
 * @param {string} src - Directory containing JS/TS sources
 * @param {string} dest - Output directory
 * @param {boolean} [force] - Force keep. See {@link ignoreSameFile}
 * @param {number | Date} [since] - Only find files that have been modified since the time specified
 */
function typescript_build(src, dest, force, since, isSrcOnly/*fix gulp v5 issue*/) {
    const webpackConfig = require('./webpack.config.js');
    /** @type {import('vinyl-fs').SrcOptions} */
    const defaultSrcOptions = {
        root: src,
        since,
    };
    const tsBundledEntries = gulp.src('/**/index.ts', defaultSrcOptions);

    // Streams are not sequenced. If one uses `ignoreSameFile` on stream 1, but stream 4
    // executes first, then `*.ts` will be excluded from compilation because stream 4 would
    // have already copied the same `*.ts` into `dest`. Thus, I don't rely on ignoreSameFile.
    // Fortunately, stream 1 and stream 2 both produces only `*.js` which stream 4 will not
    // overwrite (stream 4 explicitly ignores `*.js` in `gulp.src()`).
    return isSrcOnly ? // Bundle `index.ts` with webpack as `index.src.js` without source map
        bundleWithWebpack( // stream 3
            tsBundledEntries, {
                ...webpackConfig,
                mode: 'development',
                optimization: {
                    minimize: false,
                },
                devtool: false,
            },
            'src.js',
        ) : mergeStream(
        // Transpile single files with babel which are not siblings of some `index.ts`
        // Only for JS files, because *.ts will be included by `index.ts`
        gulp.src('/**/@(*.js)', { // stream 1
            ...defaultSrcOptions,
            ignore: ['/**/*.d.ts'],
        })
            .pipe(gulpIgnore.exclude(
                file => fs.existsSync(path.join(path.dirname(file.path), 'index.ts'))
            ))
            .pipe(ignoreSameFile(dest))
            .pipe(babel({
                root: __dirname
            }))
            .pipe(rename({ suffix: '.src' }))
            .pipe(gulp.dest(dest))
            .pipe(uglify())
            .pipe(rename(function (path) {
                path.basename = path.basename.replace(/\.src/, '');
            }))
            .pipe(gulp.dest(dest))
            .pipe(print()),
        // Bundle `index.ts` with webpack as `index.js` with source map
        bundleWithWebpack( // stream 2
            tsBundledEntries, {
                ...webpackConfig,
                mode: 'production',
                devtool: 'source-map',
            },
            'js',
        ),
        // fix copy resource in zipjs folder
        gulp.src('/**/!(*.less|*.js|*.d.ts)', { // stream 4
            ...defaultSrcOptions,
            nodir: true,
        })
            .pipe(ignoreSameFile(dest))
            .pipe(gulp.dest(dest))
            .pipe(print())
    );

    /**
     * @param {NodeJS.ReadWriteStream} bundleEntries
     * @param {webpack.Configuration} webpackConfig
     * @param {string} fileExtension - `js`, `src.js`, etc.
     * @returns {NodeJS.ReadWriteStream}
     */
    function bundleWithWebpack(bundleEntries, webpackConfig, fileExtension) {
        return bundleEntries
            // There is no official way to specify the "library" property in a
            // webpack "entry" from webpack-stream, so we manipulate the stream
            // manually; note that specifying the "library" property in "output"
            // doesn't work. However, webpack-stream doesn't implement _readableState
            // which gulp-tap reqires. Thus, we use gulp-flatmap.
            .pipe(flatmap(function (stream, file) {
                const entryName = path.join(
                    path.dirname(file.relative),
                    path.basename(file.path, path.extname(file.path))
                );
                webpackConfig.entry = {
                    [entryName]: {
                        import: file.path,
                        library: {
                            type: 'window',
                            export: 'default',
                        },
                    },
                };
                webpackConfig.output = {
                    filename: `[name].${fileExtension}`,
                    path: process.cwd(),
                };
                return webpackStream(webpackConfig, webpack);
            }))
            .pipe(gulp.dest(dest))
            .pipe(print());
    }
}

function browsersync_init(done) {
    browserSync.init({
        proxy: `localhost:${options.port}`,
        startPath: options.startPath,
    });
    done();
}

/**
 * @param {string} src - Directory containing JS/TS sources
 * @param {string} dest - Output directory
 * @param {number | Date} [since] - Only find files that have been modified since the time specified
 * @returns {NodeJS.WritableStream}
 */
function typescript_dev(src, dest, since) {
    // WARNING: Don't pass `since`, as the cache liveliness detection is faulty.
    // Only pass `since` if the corretness can be verified in future versions.
    return typescript_build(src, dest)
        .pipe(browserSync.stream());
}

/**
 * @param {string} subprojectPath - E.g., "zul", "../zkcml/client-bind"
 * @param {number | Date} [since] - Only find files that have been modified since the time specified
 */
function typescript_dev_subproject(subprojectPath, since) {
    return typescript_dev(
        path.join(subprojectPath, 'src/main/resources'),
        path.join(subprojectPath, 'build/resources/main'),
        since, // gulp.lastRun(typescript_dev_zk)
        // TODO: `gulp.lastRun` is currently ignored in the downstream due to it being
        // unreliable. Hence, it is safe to ignore it here. See the warning in
        // `typescript_dev`. Read more docs and do more experiments to see how
        // we can make it work.
    );
}

exports['build:minify-css'] = function () {
    const sources = stripQuotes(options.src);
    const destDir = stripQuotes(options.dest);
    const force = options.force;
    if (!fs.existsSync(sources)) {
        return gulp.src('.');// ignore
    }
    return gulp.src(sources + '/**/**')
        .pipe(ignoreSameFile(destDir, force))
        .pipe(tap(function (file) {
            if (file.path.endsWith('.css.dsp')) {
                // ignore DSP syntax
                file.contents = Buffer.from(/** @type {NonNullable<typeof file.contents>} */(file.contents).toString('utf-8')
                    .replace(/<%/g, '/*!<%')
                    .replace(/\${([^}]*)}/g, (match, g1) => `\\9${g1}\\0`)
                    .replace(/<c:/g, '<%c--')
                    .replace(/%>/g, '%>*/')
                    .replace(/\/>/g, '--c%>'), 'utf-8');
            }
        }))
        .pipe(tap(function (file, t) {
            if (file.path.endsWith('.css.dsp')) {
                return /** @type {Required<NonNullable<typeof t>>} */(t).through(postcss, [[require('cssnano')]]);
            } else {
                console.log('copy...', file.path);
            }
        }))
        .pipe(tap(function (file) {
            if (file.path.endsWith('.css.dsp')) {
                // revert DSP syntax
                file.contents = Buffer.from(/** @type {NonNullable<typeof file.contents>} */(file.contents).toString('utf-8')
                    .replace(/\/\*!<%/g, '<%')
                    .replace(/\\9([^\\0]*)\\0/g, (match, g1) => `\${${g1}}`)
                    .replace(/<%c--/g, '<c:')
                    .replace(/--c%>/g, '/>')
                    .replace(/%>\*\//g, '%>'), 'utf-8');
            }
        }))
        .pipe(gulp.dest(destDir))
        .pipe(print());
};

const dtsEntry = 'index.d.ts';

exports['build:dts'] = function () {
    const { declarationDir } = require('./tsconfig.dts.json').compilerOptions;

    const zkDTS = ts.createProject('./tsconfig.dts.json');
    const zkcmlDTS = ts.createProject('../zkcml/tsconfig.dts.json');
    const zkProjectDirs = require('./tsconfig.json').include;
    const zkcmlProjectDirs = require('../zkcml/tsconfig.json').include.map(dir => path.join('../zkcml', dir));

    return mergeStream(
        zkDTS.src()
            .pipe(zkDTS())
            .dts
            .pipe(gulp.dest(declarationDir)),
        zkcmlDTS.src()
            .pipe(zkcmlDTS())
            .dts
            .pipe(gulp.dest(declarationDir)),
        gulp.src(zkProjectDirs.map(dir => dir + '/**/*.d.ts'), { base: '.' })
            .pipe(gulp.dest(declarationDir))
            .pipe(print()),
        gulp.src(zkcmlProjectDirs.map(dir => dir + '/**/*.d.ts'), { base: '../zkcml' })
            .pipe(gulp.dest(declarationDir))
            .pipe(print()),
        gulp.src(['./*/package.json', '../zkcml/*/package.json'])
            .pipe(print())
            .pipe(vinylMap((contents, filename) => {
                const { types } = JSON.parse(contents.toString());
                if (!types) {
                    return '';
                }
                const dir = path.basename(path.dirname(filename));
                return `/// <reference path="./${path.join(dir, types)}" />`;
            }))
            .pipe(concat(dtsEntry))
            .pipe(gulp.dest(declarationDir))
            .pipe(print()),
    );
};
/**
 * Requires the command line argument `--zk-version <VERSION>`
 */
exports['publish:dts'] = function (done) {
    // Don't name the CLI argument as `version` as, `gulp` will interpret it as
    // `gulp --version` regardless of argument position.
    const { 'zk-version': version } = options;
    if (!version || typeof version !== 'string') {
        console.log('Requires a version string: --zk-version <semVer>');
        console.log('For example: npm run publish:dts -- --zk-version <semVer>');
        return;
    }
    console.log(`version: ${version}`);

    const dtsPackage = {
        name: 'zk-types',
        version,
        license: require('./package.json').license,
        types: dtsEntry,
        dependencies: {
            ...require('./package.json').dependencies,
            ...require('../zkcml/package.json').dependencies,
        }
    };
    console.log(JSON.stringify(dtsPackage));
    fs.writeFileSync('./build/dts/package.json', JSON.stringify(dtsPackage));
    return done();
};

class Subproject {
    /**
     * @param {string} subprojectPath - E.g., "zul", "../zkcml/client-bind". With or without
     * a trailing slash ('/') doesn't matter, as we meticulously use `path.join`.
     */
    constructor(subprojectPath) {
        this.subprojectPath = subprojectPath;
    }
    get watcher() {
        return () => watch_job(
            path.join(this.subprojectPath, '**/*.ts'),
            this.builder,
        );
    }
    get builder() {
        return () => typescript_dev_subproject(this.subprojectPath);
    }
}

const subprojects = /** @type {string[]} */(options.subprojectPaths)
    .map(subprojectPath => new Subproject(subprojectPath));

exports['build:single'] = gulp.series(typescript_build_single, typescript_build_single_with_src);
exports.watch = gulp.series(
    browsersync_init,
    gulp.parallel(...subprojects.map(subproject => subproject.watcher)),
);
exports.build = gulp.parallel(...subprojects.map(subproject => subproject.builder));
exports.default = exports.build;