diff --git a/lib/compile.js b/lib/compile.js index 7f4154b7..b5e42882 100644 --- a/lib/compile.js +++ b/lib/compile.js @@ -1,6 +1,5 @@ const FS = require('fs'); -const minimatch = require('minimatch'); -const WALK = require('walk'); +const {matchesGlob} = require('node:path'); const Twig = require('..'); const PATHS = require('./paths'); @@ -8,8 +7,7 @@ const {twig} = Twig; exports.defaults = { compress: false, - pattern: '*\\.twig', - recursive: false + pattern: '*.twig', }; exports.compile = function (options, files) { @@ -19,48 +17,35 @@ exports.compile = function (options, files) { } files.forEach(file => { - FS.stat(file, (err, stats) => { - if (err) { - console.error('ERROR ' + file + ': Unable to stat file'); - return; - } + const stats = FS.statSync(file); - if (stats.isDirectory()) { - parseTemplateFolder(file, options.pattern); - } else if (stats.isFile()) { - parseTemplateFile(file); - } else { - console.log('ERROR ' + file + ': Unknown file information'); - } - }); + if (stats.isDirectory()) { + parseTemplateFolder(file, options.pattern); + } else if (stats.isFile()) { + parseTemplateFile(file); + } else { + console.log('ERROR ' + file + ': Unknown file information'); + } }); function parseTemplateFolder(directory, pattern) { directory = PATHS.stripSlash(directory); - // Get the files in the directory - // Walker options - const walker = WALK.walk(directory, {followLinks: false}); - const files = []; + const entries = FS.readdirSync(directory, {recursive: true, withFileTypes: true}); - walker.on('file', (root, stat, next) => { - // Normalize (remove / from end if present) - root = PATHS.stripSlash(root); + for (const entry of entries) { + if (!entry.isFile()) { + continue; + } // Match against file pattern - const {name} = stat; - const file = root + '/' + name; - if (minimatch(name, pattern)) { + const {name} = entry; + if (matchesGlob(name, pattern)) { + const root = PATHS.stripSlash(entry.parentPath || entry.path); + const file = root + '/' + name; parseTemplateFile(file, directory); - files.push(file); } - - next(); - }); - - walker.on('end', () => { - // Console.log(files); - }); + } } function parseTemplateFile(file, base) { @@ -93,6 +78,7 @@ exports.compile = function (options, files) { twig({ id: outputId, path: file, + async: false, load(template) { // Compile! const output = template.compile(options); diff --git a/package-lock.json b/package-lock.json index 5ca2a8cc..239f561d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,9 +10,7 @@ "license": "BSD-2-Clause", "dependencies": { "@babel/runtime": "^7.8.4", - "locutus": "^3.0.9", - "minimatch": "^10", - "walk": "2.3.x" + "locutus": "^3.0.9" }, "bin": { "twigjs": "bin/twigjs" @@ -2356,6 +2354,7 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, "license": "MIT", "engines": { "node": "18 || 20 || >=22" @@ -2378,6 +2377,7 @@ "version": "5.0.5", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" @@ -2991,12 +2991,6 @@ "dev": true, "license": "ISC" }, - "node_modules/foreachasync": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/foreachasync/-/foreachasync-3.0.0.tgz", - "integrity": "sha512-J+ler7Ta54FwwNcx6wQRDhTIbNeyDcARMkOcguEqnEdtm0jKvN3Li3PDAb2Du3ubJYEWfYL83XMROXdsXAXycw==", - "license": "Apache2" - }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -3524,6 +3518,7 @@ "version": "10.2.4", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "brace-expansion": "^5.0.2" @@ -4532,15 +4527,6 @@ "dev": true, "license": "MIT" }, - "node_modules/walk": { - "version": "2.3.15", - "resolved": "https://registry.npmjs.org/walk/-/walk-2.3.15.tgz", - "integrity": "sha512-4eRTBZljBfIISK1Vnt69Gvr2w/wc3U6Vtrw7qiN5iqYJPH7LElcYh/iU4XWhdCy2dZqv1ToMyYlybDylfG/5Vg==", - "license": "(MIT OR Apache-2.0)", - "dependencies": { - "foreachasync": "^3.0.0" - } - }, "node_modules/watchpack": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz", diff --git a/package.json b/package.json index 1a46443c..74fe779d 100644 --- a/package.json +++ b/package.json @@ -33,9 +33,7 @@ }, "dependencies": { "@babel/runtime": "^7.8.4", - "locutus": "^3.0.9", - "minimatch": "^10", - "walk": "2.3.x" + "locutus": "^3.0.9" }, "devDependencies": { "@babel/core": "^7.8.4", diff --git a/test/test.compile.js b/test/test.compile.js new file mode 100644 index 00000000..6a157afe --- /dev/null +++ b/test/test.compile.js @@ -0,0 +1,307 @@ +const fs = require('fs'); +const path = require('path'); +const sinon = require('sinon'); +require('should-sinon'); + +const Twig = require('..'); +const compileModule = require('../lib/compile'); +const PATHS = require('../lib/paths'); + +describe('lib/compile ->', function () { + describe('defaults ->', function () { + it('should have compress set to false', function () { + compileModule.defaults.compress.should.equal(false); + }); + + it('should have pattern set to *.twig', function () { + compileModule.defaults.pattern.should.equal('*.twig'); + }); + }); + + describe('compile ->', function () { + let mkdirStub; + let writeFileStub; + + beforeEach(function () { + // Disable Twig template caching so the same template IDs can + // be registered across tests without colliding. + Twig.cache(false); + mkdirStub = sinon.stub(PATHS, 'mkdir'); + writeFileStub = sinon.stub(fs, 'writeFile'); + }); + + afterEach(function () { + sinon.restore(); + Twig.cache(true); + }); + + describe('output directory ->', function () { + it('should create output directory when output option is provided', function () { + compileModule.compile({output: 'dist/templates'}, []); + mkdirStub.should.be.calledWith('dist/templates'); + }); + + it('should not create output directory when output option is not provided', function () { + compileModule.compile({}, []); + mkdirStub.should.not.be.called(); + }); + }); + + describe('fs.statSync handling ->', function () { + it('should call fs.statSync for each file', function () { + sinon.stub(console, 'log'); + const statStub = sinon.stub(fs, 'statSync').returns({ + isDirectory() { + return false; + }, + isFile() { + return false; + } + }); + compileModule.compile({}, ['file1.twig', 'file2.twig', 'file3.twig']); + + statStub.should.be.calledThrice(); + statStub.firstCall.args[0].should.equal('file1.twig'); + statStub.secondCall.args[0].should.equal('file2.twig'); + statStub.thirdCall.args[0].should.equal('file3.twig'); + }); + + it('should throw when fs.statSync fails', function () { + sinon.stub(fs, 'statSync').throws(new Error('ENOENT')); + + (function () { + compileModule.compile({}, ['missing.twig']); + }).should.throw('ENOENT'); + }); + + it('should log error for unknown file types', function () { + const consoleStub = sinon.stub(console, 'log'); + sinon.stub(fs, 'statSync').returns({ + isDirectory() { + return false; + }, + isFile() { + return false; + } + }); + + compileModule.compile({}, ['unknown_type']); + + consoleStub.should.be.calledOnce(); + consoleStub.firstCall.args[0].should.containEql('ERROR'); + consoleStub.firstCall.args[0].should.containEql('unknown_type'); + consoleStub.firstCall.args[0].should.containEql('Unknown file information'); + }); + }); + + describe('single file compilation ->', function () { + it('should compile a template file and write output with .js extension', function () { + const testFile = path.join(__dirname, 'compiler', 'test.twig'); + writeFileStub.callsFake((outputFile, output, encoding, cb) => { + cb(null); + outputFile.should.equal(testFile + '.js'); + encoding.should.equal('utf8'); + output.should.be.a.String(); + output.should.containEql('precompiled: true'); + }); + + compileModule.compile({}, [testFile]); + }); + + it('should use the file path as the template id when no output directory is specified', function () { + const testFile = path.join(__dirname, 'compiler', 'test.twig'); + writeFileStub.callsFake((outputFile, output, encoding, cb) => { + cb(null); + output.should.containEql('id:"' + testFile + '"'); + }); + + compileModule.compile({}, [testFile]); + }); + + it('should write compiled output to the output directory when specified', function () { + const testFile = path.join(__dirname, 'compiler', 'test.twig'); + const outputDir = path.join(__dirname, 'compiler', 'output'); + writeFileStub.callsFake((outputFile, output, encoding, cb) => { + cb(null); + outputFile.should.equal(outputDir + '/test.twig.js'); + output.should.be.a.String(); + output.should.containEql('precompiled: true'); + }); + + compileModule.compile({output: outputDir}, [testFile]); + }); + + it('should use the output directory in the template id when output is specified', function () { + const testFile = path.join(__dirname, 'compiler', 'test.twig'); + const outputDir = path.join(__dirname, 'compiler', 'output'); + writeFileStub.callsFake((outputFile, output, encoding, cb) => { + cb(null); + output.should.containEql('id:"' + outputDir + '/test.twig"'); + }); + + compileModule.compile({output: outputDir}, [testFile]); + }); + + it('should log success message when compilation succeeds', function () { + const consoleStub = sinon.stub(console, 'log'); + const testFile = path.join(__dirname, 'compiler', 'test.twig'); + writeFileStub.callsFake((outputFile, output, encoding, cb) => { + cb(null); + consoleStub.should.be.calledOnce(); + consoleStub.firstCall.args[0].should.containEql('Compiled'); + consoleStub.firstCall.args[0].should.containEql(testFile); + consoleStub.firstCall.args[0].should.containEql(testFile + '.js'); + }); + + compileModule.compile({}, [testFile]); + }); + + it('should log error when writeFile fails', function () { + const consoleStub = sinon.stub(console, 'log'); + const testFile = path.join(__dirname, 'compiler', 'test.twig'); + writeFileStub.callsFake((outputFile, output, encoding, cb) => { + cb(new Error('disk full')); + consoleStub.should.be.calledOnce(); + consoleStub.firstCall.args[0].should.containEql('Unable to compile'); + consoleStub.firstCall.args[0].should.containEql(testFile); + }); + + compileModule.compile({}, [testFile]); + }); + + it('should pass options through to template.compile using default wrap format', function () { + const testFile = path.join(__dirname, 'compiler', 'test.twig'); + writeFileStub.callsFake((outputFile, output, encoding, cb) => { + cb(null); + output.should.startWith('twig({'); + output.should.containEql('precompiled: true'); + }); + + compileModule.compile({}, [testFile]); + }); + }); + + describe('directory compilation ->', function () { + it('should walk a directory and compile all matching .twig files', function () { + const srcDir = path.join(__dirname, 'compiler', 'src'); + const compiledFiles = []; + + writeFileStub.callsFake((outputFile, output, encoding, cb) => { + compiledFiles.push(outputFile); + output.should.be.a.String(); + output.should.containEql('precompiled: true'); + cb(null); + }); + + compileModule.compile({pattern: '*.twig'}, [srcDir]); + + // src/ contains dir_test.twig and sub/sub.twig + compiledFiles.length.should.equal(2); + compiledFiles.sort(); + + const dirTestFile = path.join(srcDir, 'dir_test.twig.js'); + const subFile = path.join(srcDir, 'sub', 'sub.twig.js'); + + compiledFiles.should.containEql(dirTestFile); + compiledFiles.should.containEql(subFile); + }); + + it('should only compile files matching the given pattern', function () { + const srcDir = path.join(__dirname, 'compiler'); + const compiledFiles = []; + + writeFileStub.callsFake((outputFile, output, encoding, cb) => { + compiledFiles.push(path.basename(outputFile)); + cb(null); + }); + + compileModule.compile({pattern: '*.twig'}, [srcDir]); + + // Only .twig files should match, not test.html + compiledFiles.length.should.be.aboveOrEqual(1); + compiledFiles.forEach(file => { + file.should.endWith('.twig.js'); + }); + compiledFiles.should.not.containEql('test.html.js'); + }); + + it('should compile directory files into output directory', function () { + const srcDir = path.join(__dirname, 'compiler', 'src'); + const outputDir = path.join(__dirname, 'compiler', 'build_output'); + const compiledFiles = []; + + writeFileStub.callsFake((outputFile, output, encoding, cb) => { + compiledFiles.push(outputFile); + outputFile.should.startWith(outputDir + '/'); + outputFile.should.endWith('.twig.js'); + cb(null); + }); + + compileModule.compile({output: outputDir, pattern: '*.twig'}, [srcDir]); + + compiledFiles.length.should.equal(2); + // mkdir is called once for the output dir and once for each + // subdirectory structure under it + mkdirStub.should.be.called(); + mkdirStub.firstCall.args[0].should.equal(outputDir); + }); + + it('should strip trailing slash from directory path', function () { + const srcDir = path.join(__dirname, 'compiler', 'src') + '/'; + const compiledFiles = []; + + writeFileStub.callsFake((outputFile, output, encoding, cb) => { + compiledFiles.push(outputFile); + outputFile.should.not.containEql('//'); + cb(null); + }); + + compileModule.compile({pattern: '*.twig'}, [srcDir]); + + compiledFiles.length.should.be.aboveOrEqual(1); + }); + }); + + describe('using defaults ->', function () { + it('should use the default pattern to match only .twig files when compiling a directory', function () { + // The compiler directory contains test.twig, test.html, and + // subdirectories with more .twig files — the default pattern + // *.twig should match .twig files and skip test.html. + const srcDir = path.join(__dirname, 'compiler'); + const compiledFiles = []; + + writeFileStub.callsFake((outputFile, output, encoding, cb) => { + compiledFiles.push(path.basename(outputFile)); + cb(null); + }); + + // Pass defaults directly, as the CLI does + compileModule.compile(compileModule.defaults, [srcDir]); + + compiledFiles.length.should.be.aboveOrEqual(1); + compiledFiles.forEach(file => { + file.should.endWith('.twig.js'); + }); + compiledFiles.should.not.containEql('test.html.js'); + }); + }); + + describe('mixed files and directories ->', function () { + it('should handle a mix of file and directory inputs', function () { + const testFile = path.join(__dirname, 'compiler', 'test.twig'); + const srcDir = path.join(__dirname, 'compiler', 'src'); + const compiledFiles = []; + + writeFileStub.callsFake((outputFile, output, encoding, cb) => { + compiledFiles.push(outputFile); + cb(null); + }); + + compileModule.compile({pattern: '*.twig'}, [testFile, srcDir]); + + // test.twig (single file) + dir_test.twig + sub/sub.twig (from directory walk) + compiledFiles.length.should.equal(3); + }); + }); + }); +});