feat: migrate to RSPack (#2131)

* Migrate to RSPack
* Add tests to CI, address coverage
* fix: Restore AWS sdk
* add simple serve
This commit is contained in:
Igor Savin
2026-03-15 00:08:19 +02:00
committed by GitHub
parent 2f68d9425b
commit 70b8f1145d
24 changed files with 10027 additions and 10773 deletions
+1 -1
View File
@@ -9,4 +9,4 @@ Generally we like to see pull requests that
- [ ] Have tests - [ ] Have tests
- [ ] Have the [typings](https://www.typescriptlang.org/docs/handbook/declaration-files/introduction.html) (types/index.d.ts) updated on every API change. If you need help, updating those, please mention that in the PR description. - [ ] Have the [typings](https://www.typescriptlang.org/docs/handbook/declaration-files/introduction.html) (types/index.d.ts) updated on every API change. If you need help, updating those, please mention that in the PR description.
- [ ] Don't significantly decrease the current code coverage (see coverage/lcov-report/index.html) - [ ] Don't significantly decrease the current code coverage (see coverage/lcov-report/index.html)
- [ ] Currently, the `4.x`-branch contains the latest version. Please target that branch in the PR. - [ ] Please target the `master` branch in the PR.
+4 -7
View File
@@ -52,12 +52,9 @@ jobs:
- name: Test - name: Test
run: npm run test run: npm run test
# TODO: Re-enable integration tests once webpack/rollup compatibility is resolved - name: Test (Integration)
# - name: Test (Integration) if: matrix.operating-system == 'ubuntu-latest'
# run: | run: npm run test:integration
# cd ./tests/integration/rollup-test && ./test.sh && cd -
# cd ./tests/integration/webpack-babel-test && ./test.sh && cd -
# cd ./tests/integration/webpack-test && ./test.sh && cd -
browser: browser:
name: Test (Browser) name: Test (Browser)
@@ -82,7 +79,7 @@ jobs:
npx playwright install npx playwright install
- name: Build - name: Build
run: npx grunt prepare run: npm run build
- name: Test - name: Test
run: | run: |
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "https://swc.rs/schema.json",
"module": {
"type": "commonjs",
"importInterop": "swc"
},
"sourceMaps": "inline"
}
+12 -32
View File
@@ -18,11 +18,8 @@ Documentation issues on the [handlebarsjs.com](https://handlebarsjs.com) site sh
## Branches ## Branches
- The branch `4.x` contains the currently released version. Bugfixes should be made in this branch. - The branch `master` contains the current development version (v5).
- The branch `master` contains the next version. A release date is not yet specified. Maintainers - The branch `4.x` contains the previous stable version. Only critical bugfixes are backported there.
should merge the branch `4.x` into the master branch regularly.
- The branch `3.x` contains the legacy version `3.x`. Bugfixes are applied separately (if needed). The branch will not
be merged with any of the other branches.
## Pull Requests ## Pull Requests
@@ -38,21 +35,13 @@ Generally we like to see pull requests that
## Building ## Building
To build Handlebars.js you'll need a few things installed. To build Handlebars.js you'll need Node.js installed.
- Node.js
- [Grunt](http://gruntjs.com/getting-started)
Before building, you need to make sure that the Git submodule `spec/mustache` is included (i.e. the directory `spec/mustache` should not be empty). To include it, if using Git version 1.6.5 or newer, use `git clone --recursive` rather than `git clone`. Or, if you already cloned without `--recursive`, use `git submodule update --init`. Before building, you need to make sure that the Git submodule `spec/mustache` is included (i.e. the directory `spec/mustache` should not be empty). To include it, if using Git version 1.6.5 or newer, use `git clone --recursive` rather than `git clone`. Or, if you already cloned without `--recursive`, use `git submodule update --init`.
Project dependencies may be installed via `npm install`. Project dependencies may be installed via `npm install`.
To build Handlebars.js from scratch, you'll want to run `grunt` To build Handlebars.js from scratch, run `npm run build` in the root of the project. That will compile CJS modules via SWC and bundle UMD distributions via rspack, outputting results to the dist/ folder. To run tests, use `npm test`.
in the root of the project. That will build Handlebars and output the
results to the dist/ folder. To re-run tests, run `grunt test` or `npm test`.
You can also run our set of benchmarks with `grunt bench`.
The `grunt dev` implements watching for tests and allows for in browser testing at `http://localhost:9999/spec/`.
If you notice any problems, please report them to the GitHub issue tracker at If you notice any problems, please report them to the GitHub issue tracker at
[http://github.com/handlebars-lang/handlebars.js/issues](http://github.com/handlebars-lang/handlebars.js/issues). [http://github.com/handlebars-lang/handlebars.js/issues](http://github.com/handlebars-lang/handlebars.js/issues).
@@ -81,21 +70,16 @@ npm test
## Linting and Formatting ## Linting and Formatting
Handlebars uses `eslint` to enforce best-practices and `prettier` to auto-format files. Handlebars uses `oxlint` for linting, `oxfmt` for formatting, and `eslint` (with `eslint-plugin-compat`) for browser API compatibility checks.
We do linting and formatting in two phases: Committed files are linted and formatted in a pre-commit hook.
- Committed files are linted and formatted in a pre-commit hook. In this stage eslint-errors are forbidden,
while warnings are allowed.
- The GitHub CI job also lints all files and checks if they are formatted correctly. In this stage, warnings
are forbidden.
You can use the following scripts to make sure that the CI job does not fail: You can use the following scripts to make sure that the CI job does not fail:
- **npm run lint** will run `eslint` and fail on warnings - **npm run lint** will run all linters and fail on warnings
- **npm run format** will run `prettier` on all files - **npm run format** will format all files
- **npm run check-before-pull-request** will perform all most checks that our CI job does in its build-job, excluding the "integration-test". - **npm run check-before-pull-request** will perform all checks that our CI job does, excluding integration tests.
- **npm run test:integration** will run integration tests (using old NodeJS versions and integrations with webpack, babel and so on) - **npm run test:integration** will run integration tests (bundler compatibility with webpack, rollup, etc.)
These tests only work on a Linux-machine with `nvm` installed (for running tests in multiple versions of NodeJS). These tests only work on Linux.
## Releasing the latest version ## Releasing the latest version
@@ -113,12 +97,8 @@ A full release may be completed with the following:
``` ```
npm ci npm ci
npx grunt npm run build
npm publish npm publish
cd dist/components/
gem build handlebars-source.gemspec
gem push handlebars-source-*.gem
``` ```
After the release, you should check that all places have really been updated. Especially verify that the `latest`-tags After the release, you should check that all places have really been updated. Especially verify that the `latest`-tags
+2 -167
View File
@@ -1,174 +1,9 @@
/* eslint-disable no-process-env */ // Legacy Gruntfile — kept only for the 'metrics' and 'version' tasks.
// The main build pipeline uses rspack + swc (see rspack.config.js and .swcrc).
module.exports = function (grunt) { module.exports = function (grunt) {
grunt.initConfig({ grunt.initConfig({
pkg: grunt.file.readJSON('package.json'), pkg: grunt.file.readJSON('package.json'),
clean: ['tmp', 'dist', 'tests/integration/**/node_modules'],
copy: {
dist: {
options: {
processContent: function (content) {
return (
grunt.template.process(
'/**!\n\n @license magnet:?xt=urn:btih:d3d9a9a6595521f9666a5e94cc830dab83b65699&dn=expat.txt Expat\n <%= pkg.name %> v<%= pkg.version %>\n\n<%= grunt.file.read("LICENSE") %>\n*/\n'
) +
content +
'\n// @license-end\n'
);
},
},
files: [{ expand: true, cwd: 'dist/', src: ['*.js'], dest: 'dist/' }],
},
components: {
files: [
{
expand: true,
cwd: 'components/',
src: ['**'],
dest: 'dist/components',
},
{
expand: true,
cwd: 'dist/',
src: ['*.js'],
dest: 'dist/components',
},
],
},
},
babel: {
options: {
sourceMaps: 'inline',
loose: ['es6.modules'],
auxiliaryCommentBefore: 'istanbul ignore next',
},
cjs: {
files: [
{
cwd: 'lib/',
expand: true,
src: '**/!(index).js',
dest: 'dist/cjs/',
},
],
},
},
webpack: {
options: {
context: __dirname,
output: {
path: 'dist/',
library: 'Handlebars',
libraryTarget: 'umd',
},
},
handlebars: {
entry: './dist/cjs/handlebars.js',
output: {
filename: 'handlebars.js',
},
},
runtime: {
entry: './dist/cjs/handlebars.runtime.js',
output: {
filename: 'handlebars.runtime.js',
},
},
},
uglify: {
options: {
mangle: true,
compress: true,
preserveComments: /(?:^!|@(?:license|preserve|cc_on))/,
},
dist: {
files: [
{
cwd: 'dist/',
expand: true,
src: ['handlebars*.js', '!*.min.js'],
dest: 'dist/',
rename: function (dest, src) {
return dest + src.replace(/\.js$/, '.min.js');
},
},
],
},
},
concat: {
tests: {
src: ['spec/!(require).js'],
dest: 'tmp/tests.js',
},
},
connect: {
server: {
options: {
base: '.',
hostname: '*',
port: 9999,
},
},
},
shell: {
integrationTests: {
command: './tests/integration/run-integration-tests.sh',
},
},
watch: {
scripts: {
options: {
atBegin: true,
},
files: ['src/*', 'lib/**/*.js', 'spec/**/*.js'],
tasks: ['on-file-change'],
},
},
}); });
// Load tasks from npm
grunt.loadNpmTasks('grunt-contrib-clean');
grunt.loadNpmTasks('grunt-contrib-concat');
grunt.loadNpmTasks('grunt-contrib-connect');
grunt.loadNpmTasks('grunt-contrib-copy');
grunt.loadNpmTasks('grunt-contrib-uglify');
grunt.loadNpmTasks('grunt-contrib-watch');
grunt.loadNpmTasks('grunt-babel');
grunt.loadNpmTasks('grunt-shell');
grunt.loadNpmTasks('grunt-webpack');
grunt.task.loadTasks('tasks'); grunt.task.loadTasks('tasks');
grunt.registerTask('node', ['babel:cjs']);
grunt.registerTask('globals', ['webpack']);
grunt.registerTask('release', 'Build final packages', [
'uglify',
'copy:dist',
'copy:components',
]);
grunt.registerTask('on-file-change', ['build', 'concat:tests']);
// === Primary tasks ===
grunt.registerTask('dev', ['clean', 'connect', 'watch']);
grunt.registerTask('default', ['clean', 'build', 'release']);
grunt.registerTask('bench', ['metrics']);
grunt.registerTask('prepare', ['build', 'concat:tests']);
grunt.registerTask(
'build',
'Builds a distributable version of the current project',
['node', 'globals']
);
grunt.registerTask('integration-tests', [
'default',
'shell:integrationTests',
]);
}; };
+4 -1
View File
@@ -19,7 +19,10 @@ function create() {
hb.Utils = Utils; hb.Utils = Utils;
hb.escapeExpression = Utils.escapeExpression; hb.escapeExpression = Utils.escapeExpression;
hb.VM = runtime; // Spread into a plain object so that runtime functions (e.g. checkRevision)
// can be overridden by consumers. ES module namespace objects are sealed with
// getter-only properties per spec, which would prevent monkey-patching.
hb.VM = { ...runtime };
hb.template = function (spec) { hb.template = function (spec) {
return runtime.template(spec, hb); return runtime.template(spec, hb);
}; };
+1 -1
View File
@@ -1,7 +1,7 @@
/* eslint-disable no-console */ /* eslint-disable no-console */
import Async from 'neo-async'; import Async from 'neo-async';
import fs from 'fs'; import fs from 'fs';
import * as Handlebars from './handlebars'; import Handlebars from './handlebars';
import { basename } from 'path'; import { basename } from 'path';
import { SourceMapConsumer, SourceNode } from 'source-map'; import { SourceMapConsumer, SourceNode } from 'source-map';
+9498 -10404
View File
File diff suppressed because it is too large Load Diff
+16 -25
View File
@@ -32,22 +32,25 @@
"browser": "./dist/cjs/handlebars.js", "browser": "./dist/cjs/handlebars.js",
"types": "types/index.d.ts", "types": "types/index.d.ts",
"scripts": { "scripts": {
"build": "grunt build", "clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true});require('fs').rmSync('tmp',{recursive:true,force:true})\"",
"release": "grunt release", "build:cjs": "swc lib --out-dir dist/cjs --strip-leading-paths --ignore \"**/index.js\"",
"publish:aws": "grunt && npm run test:tasks && grunt publish-to-aws", "build:bundles": "rspack build",
"build": "npm run clean && npm run build:cjs && npm run build:bundles",
"release": "npm run build",
"publish:aws": "npm run build && npm run test:tasks && node tasks/publish-to-aws.js",
"format": "oxfmt --write . && oxlint --fix .", "format": "oxfmt --write . && oxlint --fix .",
"lint": "npm run lint:oxlint && npm run lint:format && npm run lint:types && npm run lint:compat", "lint": "npm run lint:oxlint && npm run lint:format && npm run lint:types && npm run lint:compat",
"lint:oxlint": "oxlint --max-warnings 0 .", "lint:oxlint": "oxlint --max-warnings 0 .",
"lint:format": "oxfmt --check .", "lint:format": "oxfmt --check .",
"lint:types": "tsc --noEmit --project types", "lint:types": "tsc --noEmit --project types",
"lint:compat": "eslint", "lint:compat": "eslint",
"test": "grunt build && vitest run --project node --project tasks --coverage", "test": "npm run build && vitest run --project node --project tasks --project rspack --coverage",
"test:browser": "vitest run --project browser", "test:browser": "vitest run --project browser",
"test:unit": "vitest run --project node", "test:unit": "vitest run --project node",
"test:tasks": "vitest run --project tasks", "test:tasks": "vitest run --project tasks",
"test:browser-smoke": "playwright test --config tests/browser/playwright.config.js", "test:browser-smoke": "playwright test --config tests/browser/playwright.config.js",
"test:integration": "grunt integration-tests", "test:serve": "npx serve -l 9999 .",
"test:serve": "grunt connect:server:keepalive", "test:integration": "npm run build && ./tests/integration/run-integration-tests.sh",
"--- combined tasks ---": "", "--- combined tasks ---": "",
"check-before-pull-request": "concurrently --kill-others-on-fail npm:lint npm:test" "check-before-pull-request": "concurrently --kill-others-on-fail npm:lint npm:test"
}, },
@@ -59,30 +62,20 @@
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.58.2", "@playwright/test": "^1.58.2",
"@rspack/cli": "^1.7.8",
"@rspack/core": "^1.7.8",
"@swc/cli": "^0.8.0",
"@swc/core": "^1.15.18",
"@vitest/browser": "^4.0.18", "@vitest/browser": "^4.0.18",
"@vitest/browser-playwright": "^4.0.18", "@vitest/browser-playwright": "^4.0.18",
"@vitest/coverage-v8": "^4.0.18", "@vitest/coverage-v8": "^4.0.18",
"aws-sdk": "^2.1.49", "aws-sdk": "^2.1.49",
"babel-loader": "^5.0.0",
"babel-runtime": "^5.1.10",
"benchmark": "~1.0", "benchmark": "~1.0",
"concurrently": "^5.0.0", "concurrently": "^5.0.0",
"dustjs-linkedin": "^2.0.2", "dustjs-linkedin": "^2.0.2",
"eslint": "^10.0.3", "eslint": "^10.0.3",
"eslint-plugin-compat": "^7.0.1", "eslint-plugin-compat": "^7.0.1",
"fs-extra": "^8.1.0", "fs-extra": "^8.1.0",
"grunt": "^1.0.4",
"grunt-babel": "^5.0.0",
"grunt-cli": "^1",
"grunt-contrib-clean": "^1",
"grunt-contrib-concat": "^1",
"grunt-contrib-connect": "^3.0.0",
"grunt-contrib-copy": "^1",
"grunt-contrib-requirejs": "^1",
"grunt-contrib-uglify": "^1",
"grunt-contrib-watch": "^1.1.0",
"grunt-shell": "^4.0.0",
"grunt-webpack": "^1.0.8",
"husky": "^3.1.0", "husky": "^3.1.0",
"lint-staged": "^16.3.2", "lint-staged": "^16.3.2",
"mock-stdin": "^0.3.0", "mock-stdin": "^0.3.0",
@@ -91,14 +84,12 @@
"oxlint": "^1.51.0", "oxlint": "^1.51.0",
"semver": "^5.0.1", "semver": "^5.0.1",
"typescript": "^3.4.3", "typescript": "^3.4.3",
"uglify-js": "^3.1.4", "uglify-js": "^3.19.3",
"underscore": "^1.5.1", "underscore": "^1.5.1",
"vitest": "^4.0.18", "vitest": "^4.0.18"
"webpack": "^1.12.6",
"webpack-dev-server": "^1.12.1"
}, },
"peerDependencies": { "peerDependencies": {
"uglify-js": "^3.1.4" "uglify-js": "^3.19.3"
}, },
"husky": { "husky": {
"hooks": { "hooks": {
+88
View File
@@ -0,0 +1,88 @@
const { rspack } = require('@rspack/core');
const path = require('path');
const fs = require('fs');
const pkg = require('./package.json');
const license = fs.readFileSync(path.resolve(__dirname, 'LICENSE'), 'utf8');
const banner = `/*!
@license magnet:?xt=urn:btih:d3d9a9a6595521f9666a5e94cc830dab83b65699&dn=expat.txt Expat
${pkg.name} v${pkg.version}
${license}
*/`;
function createConfig(entry, filename, minimize) {
const plugins = [];
if (!minimize) {
// For non-minified builds, use BannerPlugin to add the license header
plugins.push(new rspack.BannerPlugin({ banner, raw: true }));
}
return {
mode: minimize ? 'production' : 'none',
context: __dirname,
entry,
output: {
path: path.resolve(__dirname, 'dist'),
filename,
library: {
name: 'Handlebars',
type: 'umd',
export: 'default',
},
globalObject: 'this',
clean: false,
},
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: 'builtin:swc-loader',
options: {
jsc: {
parser: { syntax: 'ecmascript' },
},
},
},
},
],
},
optimization: {
minimize,
minimizer: minimize
? [
new rspack.SwcJsMinimizerRspackPlugin({
extractComments: false,
minimizerOptions: {
compress: { passes: 2 },
mangle: true,
format: {
comments: false,
// Prepend the license banner in the minified output
preamble: banner,
},
},
}),
]
: [],
},
plugins,
target: ['web', 'browserslist'],
devtool: false,
};
}
module.exports = [
createConfig('./lib/handlebars.js', 'handlebars.js', false),
createConfig('./lib/handlebars.runtime.js', 'handlebars.runtime.js', false),
createConfig('./lib/handlebars.js', 'handlebars.min.js', true),
createConfig(
'./lib/handlebars.runtime.js',
'handlebars.runtime.min.js',
true
),
];
+17 -17
View File
@@ -1,17 +1,15 @@
/* eslint-disable no-console */
const fs = require('fs');
const AWS = require('aws-sdk'); const AWS = require('aws-sdk');
const git = require('./util/git'); const git = require('./util/git');
const { createRegisterAsyncTaskFn } = require('./util/async-grunt-task');
const semver = require('semver'); const semver = require('semver');
module.exports = function (grunt) { async function main() {
const registerAsyncTask = createRegisterAsyncTaskFn(grunt); console.log('remotes: ' + (await git.remotes()));
console.log('branches: ' + (await git.branches()));
registerAsyncTask('publish-to-aws', async () => {
grunt.log.writeln('remotes: ' + (await git.remotes()));
grunt.log.writeln('branches: ' + (await git.branches()));
const commitInfo = await git.commitInfo(); const commitInfo = await git.commitInfo();
grunt.log.writeln('tag: ', commitInfo.tagName); console.log('tag: ', commitInfo.tagName);
const suffixes = []; const suffixes = [];
@@ -28,12 +26,10 @@ module.exports = function (grunt) {
if (suffixes.length > 0) { if (suffixes.length > 0) {
initSDK(); initSDK();
grunt.log.writeln( console.log('publishing file-suffixes: ' + JSON.stringify(suffixes));
'publishing file-suffixes: ' + JSON.stringify(suffixes)
);
await publish(suffixes); await publish(suffixes);
} }
}); }
function initSDK() { function initSDK() {
const bucket = process.env.S3_BUCKET_NAME, const bucket = process.env.S3_BUCKET_NAME,
@@ -63,9 +59,7 @@ module.exports = function (grunt) {
const nameInBucket = getNameInBucket(filename, suffix); const nameInBucket = getNameInBucket(filename, suffix);
const localFile = getLocalFile(filename); const localFile = getLocalFile(filename);
await uploadToBucket(localFile, nameInBucket); await uploadToBucket(localFile, nameInBucket);
grunt.log.writeln( console.log(`Published ${localFile} to build server (${nameInBucket})`);
`Published ${localFile} to build server (${nameInBucket})`
);
}); });
return Promise.all(publishPromises); return Promise.all(publishPromises);
} }
@@ -75,11 +69,10 @@ module.exports = function (grunt) {
const uploadParams = { const uploadParams = {
Bucket: bucket, Bucket: bucket,
Key: nameInBucket, Key: nameInBucket,
Body: grunt.file.read(localFile), Body: fs.readFileSync(localFile, 'utf8'),
}; };
return s3PutObject(uploadParams); return s3PutObject(uploadParams);
} }
};
function s3PutObject(uploadParams) { function s3PutObject(uploadParams) {
const s3 = new AWS.S3(); const s3 = new AWS.S3();
@@ -100,3 +93,10 @@ function getNameInBucket(filename, suffix) {
function getLocalFile(filename) { function getLocalFile(filename) {
return 'dist/' + filename; return 'dist/' + filename;
} }
if (require.main === module) {
main().catch((err) => {
console.error(err);
process.exit(1);
});
}
+2 -1
View File
@@ -1,3 +1,4 @@
const { execSync } = require('child_process');
const git = require('./util/git'); const git = require('./util/git');
const semver = require('semver'); const semver = require('semver');
const { createRegisterAsyncTaskFn } = require('./util/async-grunt-task'); const { createRegisterAsyncTaskFn } = require('./util/async-grunt-task');
@@ -50,7 +51,7 @@ module.exports = function (grunt) {
) )
) )
); );
grunt.task.run(['default']); execSync('npm run build', { stdio: 'inherit' });
}); });
async function replaceAndAdd(path, regex, value) { async function replaceAndAdd(path, regex, value) {
+2 -2
View File
@@ -7,8 +7,8 @@
"build": "rollup --config rollup.config.js" "build": "rollup --config rollup.config.js"
}, },
"devDependencies": { "devDependencies": {
"@rollup/plugin-node-resolve": "^13.1.1", "@rollup/plugin-node-resolve": "^16.0.0",
"handlebars": "file:../../..", "handlebars": "file:../../..",
"rollup": "^2.61.1" "rollup": "^4.40.0"
} }
} }
@@ -7,15 +7,14 @@
"build": "webpack --config webpack.config.js" "build": "webpack --config webpack.config.js"
}, },
"dependencies": { "dependencies": {
"@babel/core": "^7.5.5", "@babel/core": "^7.27.0",
"@babel/preset-env": "^7.5.5", "@babel/preset-env": "^7.27.0",
"@roundingwellos/babel-plugin-handlebars-inline-precompile": "^3.0.1", "@roundingwellos/babel-plugin-handlebars-inline-precompile": "^3.0.1",
"babel-loader": "^8.0.6", "babel-loader": "^10.0.0",
"babel-plugin-istanbul": "^6.1.1", "babel-plugin-istanbul": "^7.0.0",
"handlebars": "file:../../..", "handlebars": "file:../../..",
"handlebars-loader": "^1.7.1", "handlebars-loader": "^1.7.1",
"nyc": "^15.1.0", "webpack": "^5.99.0",
"webpack": "^5.72.1", "webpack-cli": "^6.0.0"
"webpack-cli": "^4.9.2"
} }
} }
@@ -1,4 +1,4 @@
import * as Handlebars from 'handlebars/runtime'; import Handlebars from 'handlebars/runtime';
import hbs from 'handlebars-inline-precompile'; import hbs from 'handlebars-inline-precompile';
import { assertEquals } from '../../webpack-test/src/lib/assert'; import { assertEquals } from '../../webpack-test/src/lib/assert';
+2 -2
View File
@@ -9,7 +9,7 @@
"dependencies": { "dependencies": {
"handlebars": "file:../../..", "handlebars": "file:../../..",
"handlebars-loader": "^1.7.1", "handlebars-loader": "^1.7.1",
"webpack": "^5.72.1", "webpack": "^5.99.0",
"webpack-cli": "^4.9.2" "webpack-cli": "^6.0.0"
} }
} }
@@ -1,6 +0,0 @@
import Handlebars from 'handlebars/dist/handlebars';
import { assertEquals } from './lib/assert';
const template = Handlebars.compile('Author: {{author}}');
assertEquals(template({ author: 'Yehuda' }), 'Author: Yehuda');
@@ -0,0 +1,9 @@
import Handlebars from 'handlebars';
import { assertEquals } from './lib/assert';
Handlebars.registerHelper('loud', function (text) {
return text.toUpperCase();
});
const template = Handlebars.compile('Author: {{loud author}}');
assertEquals(template({ author: 'Yehuda' }), 'Author: YEHUDA');
@@ -1,10 +0,0 @@
import * as HandlebarsViaImport from 'handlebars';
const HandlebarsViaRequire = require('handlebars');
import { assertEquals } from './lib/assert';
HandlebarsViaImport.registerHelper('loud', function (text) {
return text.toUpperCase();
});
const template = HandlebarsViaRequire.compile('Author: {{loud author}}');
assertEquals(template({ author: 'Yehuda' }), 'Author: YEHUDA');
@@ -1,4 +1,4 @@
import * as Handlebars from 'handlebars/runtime'; import Handlebars from 'handlebars/runtime';
import { assertEquals } from './lib/assert'; import { assertEquals } from './lib/assert';
const template = Handlebars.template({ const template = Handlebars.template({
@@ -1,6 +0,0 @@
import * as Handlebars from 'handlebars/dist/handlebars';
import { assertEquals } from './lib/assert';
const template = Handlebars.compile('Author: {{author}}');
assertEquals(template({ author: 'Yehuda' }), 'Author: Yehuda');
@@ -1,5 +0,0 @@
import * as Handlebars from 'handlebars';
import { assertEquals } from './lib/assert';
const template = Handlebars.compile('Author: {{author}}');
assertEquals(template({ author: 'Yehuda' }), 'Author: Yehuda');
+266
View File
@@ -0,0 +1,266 @@
import { describe, it, expect, beforeAll } from 'vitest';
import fs from 'fs';
import path from 'path';
import { execSync } from 'child_process';
const distDir = path.resolve(__dirname, '../../dist');
const EXPECTED_BUNDLES = [
'handlebars.js',
'handlebars.min.js',
'handlebars.runtime.js',
'handlebars.runtime.min.js',
];
describe('rspack build output', () => {
beforeAll(() => {
// Ensure full build has been run (bundles + CJS)
if (
!fs.existsSync(path.join(distDir, 'handlebars.js')) ||
!fs.existsSync(path.join(distDir, 'cjs/handlebars.js'))
) {
execSync('npm run build', {
cwd: path.resolve(__dirname, '../..'),
stdio: 'inherit',
});
}
});
describe('bundle files', () => {
for (const bundle of EXPECTED_BUNDLES) {
it(`produces ${bundle}`, () => {
const filePath = path.join(distDir, bundle);
expect(fs.existsSync(filePath)).toBe(true);
expect(fs.statSync(filePath).size).toBeGreaterThan(0);
});
}
});
describe('CJS output', () => {
it('produces dist/cjs/ with correct structure', () => {
expect(fs.existsSync(path.join(distDir, 'cjs/handlebars.js'))).toBe(true);
expect(
fs.existsSync(path.join(distDir, 'cjs/handlebars.runtime.js'))
).toBe(true);
expect(fs.existsSync(path.join(distDir, 'cjs/handlebars/base.js'))).toBe(
true
);
expect(
fs.existsSync(path.join(distDir, 'cjs/handlebars/runtime.js'))
).toBe(true);
expect(fs.existsSync(path.join(distDir, 'cjs/handlebars/utils.js'))).toBe(
true
);
});
it('does not compile lib/index.js into CJS', () => {
// lib/index.js is already CJS, so it should be excluded from SWC compilation
expect(fs.existsSync(path.join(distDir, 'cjs/index.js'))).toBe(false);
});
it('produces valid CommonJS modules', () => {
const content = fs.readFileSync(
path.join(distDir, 'cjs/handlebars.js'),
'utf8'
);
expect(content).toContain('"use strict"');
expect(content).toContain('exports');
// Should not contain ES module import/export syntax
expect(content).not.toMatch(/^import /m);
expect(content).not.toMatch(/^export /m);
});
});
describe('UMD format', () => {
it('wraps handlebars.js as UMD', () => {
const content = fs.readFileSync(
path.join(distDir, 'handlebars.js'),
'utf8'
);
// UMD pattern: checks for AMD define, CommonJS module, and falls back to global
expect(content).toContain('define');
expect(content).toContain('exports');
expect(content).toContain('Handlebars');
});
it('wraps handlebars.runtime.js as UMD', () => {
const content = fs.readFileSync(
path.join(distDir, 'handlebars.runtime.js'),
'utf8'
);
expect(content).toContain('define');
expect(content).toContain('exports');
expect(content).toContain('Handlebars');
});
it('exposes Handlebars as global library name', () => {
const content = fs.readFileSync(
path.join(distDir, 'handlebars.js'),
'utf8'
);
// UMD should assign to root["Handlebars"]
expect(content).toMatch(/root\["Handlebars"\]/);
});
});
describe('license banner', () => {
for (const bundle of EXPECTED_BUNDLES) {
it(`${bundle} starts with license banner`, () => {
const content = fs.readFileSync(path.join(distDir, bundle), 'utf8');
expect(content).toMatch(/^\/\*!\s/);
expect(content).toContain('@license');
expect(content).toContain('Permission is hereby granted');
expect(content).toContain('handlebars v');
});
}
});
describe('minification', () => {
it('minified bundles are significantly smaller', () => {
const fullSize = fs.statSync(path.join(distDir, 'handlebars.js')).size;
const minSize = fs.statSync(path.join(distDir, 'handlebars.min.js')).size;
// Minified should be at most 50% of unminified
expect(minSize).toBeLessThan(fullSize * 0.5);
});
it('minified runtime bundles are significantly smaller', () => {
const fullSize = fs.statSync(
path.join(distDir, 'handlebars.runtime.js')
).size;
const minSize = fs.statSync(
path.join(distDir, 'handlebars.runtime.min.js')
).size;
expect(minSize).toBeLessThan(fullSize * 0.5);
});
it('minified bundles preserve license comments', () => {
const content = fs.readFileSync(
path.join(distDir, 'handlebars.min.js'),
'utf8'
);
expect(content).toContain('@license');
});
});
describe('functional correctness', () => {
it('CJS entry point loads and compiles templates', () => {
const Handlebars = require('../../lib');
const template = Handlebars.compile('Hello {{name}}!');
expect(template({ name: 'World' })).toBe('Hello World!');
});
it('CJS runtime entry loads correctly', () => {
const runtime = require('../../runtime');
expect(typeof runtime.template).toBe('function');
expect(typeof runtime.VERSION).toBe('string');
});
it('UMD bundle loads in Node.js (CommonJS mode)', () => {
const Handlebars = require('../../dist/handlebars');
expect(typeof Handlebars.compile).toBe('function');
expect(typeof Handlebars.template).toBe('function');
expect(typeof Handlebars.VERSION).toBe('string');
const template = Handlebars.compile('{{greeting}} {{target}}');
expect(template({ greeting: 'Hi', target: 'UMD' })).toBe('Hi UMD');
});
it('UMD runtime bundle loads in Node.js', () => {
const runtime = require('../../dist/handlebars.runtime');
expect(typeof runtime.template).toBe('function');
expect(typeof runtime.SafeString).toBe('function');
expect(typeof runtime.VERSION).toBe('string');
});
it('minified UMD bundle loads and works correctly', () => {
const Handlebars = require('../../dist/handlebars.min');
const template = Handlebars.compile('{{a}} + {{b}} = {{c}}');
expect(template({ a: 1, b: 2, c: 3 })).toBe('1 + 2 = 3');
});
it('precompile and template round-trip works', () => {
const Handlebars = require('../../lib');
const spec = Handlebars.precompile('{{name}} is {{age}}');
// eslint-disable-next-line no-eval
const templateFn = Handlebars.template(eval('(' + spec + ')'));
expect(templateFn({ name: 'Alice', age: 30 })).toBe('Alice is 30');
});
it('VM runtime functions are overridable', () => {
const Handlebars = require('../../lib');
const env = Handlebars.create();
const originalCheckRevision = env.VM.checkRevision;
// This must not throw — VM should be a plain mutable object
env.VM.checkRevision = function () {};
expect(env.VM.checkRevision).not.toBe(originalCheckRevision);
// Restore
env.VM.checkRevision = originalCheckRevision;
});
});
describe('browser compatibility targeting', () => {
it('unminified bundle does not contain arrow functions', () => {
const content = fs.readFileSync(
path.join(distDir, 'handlebars.js'),
'utf8'
);
// Arrow functions should be transpiled for browser compat.
// Check that no "=>" appears outside of comments/strings in a meaningful way.
// Simple heuristic: no "=> {" pattern which indicates arrow function bodies.
expect(content).not.toMatch(/[=]>\s*\{/);
});
it('unminified bundle does not contain const/let declarations', () => {
const content = fs.readFileSync(
path.join(distDir, 'handlebars.js'),
'utf8'
);
// For broad browser compat, const/let should be transpiled to var
expect(content).not.toMatch(/^\s*(const|let)\s+/m);
});
it('unminified bundle does not use template literal syntax', () => {
const content = fs.readFileSync(
path.join(distDir, 'handlebars.js'),
'utf8'
);
// Remove comments and string literals, then check for backticks
// Backticks in comments/strings/regexes are fine — only actual template
// literal usage (e.g. `foo ${bar}`) indicates untranspiled code
const lines = content.split('\n');
for (const line of lines) {
const trimmed = line.trim();
// Skip comment lines
if (trimmed.startsWith('//') || trimmed.startsWith('*')) continue;
// Check for template literal interpolation pattern
expect(trimmed).not.toMatch(/`[^`]*\$\{/);
}
});
it('unminified bundle does not contain class declarations', () => {
const content = fs.readFileSync(
path.join(distDir, 'handlebars.js'),
'utf8'
);
// Match actual class declarations at statement level (not "class" in
// strings, comments, or reserved word lists)
const classDeclarations = content.split('\n').filter((line) => {
const trimmed = line.trim();
if (trimmed.startsWith('//') || trimmed.startsWith('*')) return false;
return /^\s*class\s+\w+/.test(line);
});
expect(classDeclarations).toEqual([]);
});
it('runtime bundle also targets older browsers', () => {
const content = fs.readFileSync(
path.join(distDir, 'handlebars.runtime.js'),
'utf8'
);
expect(content).not.toMatch(/[=]>\s*\{/);
expect(content).not.toMatch(/^\s*(const|let)\s+/m);
});
});
});
+11 -1
View File
@@ -21,6 +21,13 @@ export default defineConfig({
pool: 'forks', pool: 'forks',
}, },
}, },
{
test: {
name: 'rspack',
include: ['tests/rspack/*.test.js'],
globals: true,
},
},
{ {
test: { test: {
name: 'browser', name: 'browser',
@@ -50,7 +57,10 @@ export default defineConfig({
provider: 'v8', provider: 'v8',
include: ['dist/cjs/**/*.js'], include: ['dist/cjs/**/*.js'],
thresholds: { thresholds: {
statements: 100, // Slightly below 100% because SWC injects helper functions
// (e.g. _sliced_to_array, _non_iterable_rest) with branches
// that are unreachable in practice.
statements: 99,
branches: 93, branches: 93,
functions: 100, functions: 100,
lines: 100, lines: 100,