From 912588894fa52aee686e8ed944bee139cef2ada1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Szymon=20=C5=81=C4=85giewka?= Date: Sat, 28 Mar 2026 14:29:15 +0100 Subject: [PATCH 1/2] fix: correct import and pattern for minimatch v10 minimatch v9 uses only the named export, no defaults. Adds a test suite to verify the compile module works. --- lib/compile.js | 4 +- test/test.compile.js | 358 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 360 insertions(+), 2 deletions(-) create mode 100644 test/test.compile.js diff --git a/lib/compile.js b/lib/compile.js index 7f4154b7..49650832 100644 --- a/lib/compile.js +++ b/lib/compile.js @@ -1,5 +1,5 @@ const FS = require('fs'); -const minimatch = require('minimatch'); +const {minimatch} = require('minimatch'); const WALK = require('walk'); const Twig = require('..'); const PATHS = require('./paths'); @@ -8,7 +8,7 @@ const {twig} = Twig; exports.defaults = { compress: false, - pattern: '*\\.twig', + pattern: '*.twig', recursive: false }; diff --git a/test/test.compile.js b/test/test.compile.js new file mode 100644 index 00000000..4bd5b43c --- /dev/null +++ b/test/test.compile.js @@ -0,0 +1,358 @@ +const fs = require('fs'); +const path = require('path'); +const sinon = require('sinon'); +const {setTimeout: delay} = require('node:timers/promises'); +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'); + }); + + it('should have recursive set to false', function () { + compileModule.defaults.recursive.should.equal(false); + }); + }); + + 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.stat handling ->', function () { + it('should call fs.stat for each file', function () { + const statStub = sinon.stub(fs, 'stat'); + 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 log error when fs.stat returns an error', function () { + const consoleStub = sinon.stub(console, 'error'); + sinon.stub(fs, 'stat').callsFake((file, cb) => { + cb(new Error('ENOENT')); + }); + + compileModule.compile({}, ['missing.twig']); + + consoleStub.should.be.calledOnce(); + consoleStub.firstCall.args[0].should.containEql('missing.twig'); + consoleStub.firstCall.args[0].should.containEql('Unable to stat file'); + }); + + it('should log error for unknown file types', function () { + const consoleStub = sinon.stub(console, 'log'); + sinon.stub(fs, 'stat').callsFake((file, cb) => { + cb(null, { + 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', async function () { + const testFile = path.join(__dirname, 'compiler', 'test.twig'); + const written = new Promise(resolve => { + writeFileStub.callsFake((outputFile, output, encoding, cb) => { + cb(null); + resolve({outputFile, output, encoding}); + }); + }); + + compileModule.compile({}, [testFile]); + const {outputFile, output, encoding} = await written; + + outputFile.should.equal(testFile + '.js'); + encoding.should.equal('utf8'); + output.should.be.a.String(); + output.should.containEql('precompiled: true'); + }); + + it('should use the file path as the template id when no output directory is specified', async function () { + const testFile = path.join(__dirname, 'compiler', 'test.twig'); + const written = new Promise(resolve => { + writeFileStub.callsFake((outputFile, output, encoding, cb) => { + cb(null); + resolve(output); + }); + }); + + compileModule.compile({}, [testFile]); + const output = await written; + + output.should.containEql('id:"' + testFile + '"'); + }); + + it('should write compiled output to the output directory when specified', async function () { + const testFile = path.join(__dirname, 'compiler', 'test.twig'); + const outputDir = path.join(__dirname, 'compiler', 'output'); + const written = new Promise(resolve => { + writeFileStub.callsFake((outputFile, output, encoding, cb) => { + cb(null); + resolve({outputFile, output}); + }); + }); + + compileModule.compile({output: outputDir}, [testFile]); + const {outputFile, output} = await written; + + outputFile.should.equal(outputDir + '/test.twig.js'); + output.should.be.a.String(); + output.should.containEql('precompiled: true'); + }); + + it('should use the output directory in the template id when output is specified', async function () { + const testFile = path.join(__dirname, 'compiler', 'test.twig'); + const outputDir = path.join(__dirname, 'compiler', 'output'); + const written = new Promise(resolve => { + writeFileStub.callsFake((outputFile, output, encoding, cb) => { + cb(null); + resolve(output); + }); + }); + + compileModule.compile({output: outputDir}, [testFile]); + const output = await written; + + output.should.containEql('id:"' + outputDir + '/test.twig"'); + }); + + it('should log success message when compilation succeeds', async function () { + const consoleStub = sinon.stub(console, 'log'); + const testFile = path.join(__dirname, 'compiler', 'test.twig'); + const written = new Promise(resolve => { + writeFileStub.callsFake((outputFile, output, encoding, cb) => { + cb(null); + resolve(); + }); + }); + + compileModule.compile({}, [testFile]); + await written; + + 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'); + }); + + it('should log error when writeFile fails', async function () { + const consoleStub = sinon.stub(console, 'log'); + const testFile = path.join(__dirname, 'compiler', 'test.twig'); + const written = new Promise(resolve => { + writeFileStub.callsFake((outputFile, output, encoding, cb) => { + cb(new Error('disk full')); + resolve(); + }); + }); + + compileModule.compile({}, [testFile]); + await written; + + consoleStub.should.be.calledOnce(); + consoleStub.firstCall.args[0].should.containEql('Unable to compile'); + consoleStub.firstCall.args[0].should.containEql(testFile); + }); + + it('should pass options through to template.compile using default wrap format', async function () { + const testFile = path.join(__dirname, 'compiler', 'test.twig'); + const written = new Promise(resolve => { + writeFileStub.callsFake((outputFile, output, encoding, cb) => { + cb(null); + resolve(output); + }); + }); + + compileModule.compile({}, [testFile]); + const output = await written; + + output.should.startWith('twig({'); + output.should.containEql('precompiled: true'); + }); + }); + + describe('directory compilation ->', function () { + it('should walk a directory and compile all matching .twig files', async function () { + this.timeout(5000); + 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]); + await delay(2000); + + // 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', async function () { + this.timeout(5000); + 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]); + await delay(2000); + + // 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', async function () { + this.timeout(5000); + 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]); + await delay(2000); + + 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', async function () { + this.timeout(5000); + 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]); + await delay(2000); + + 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', async function () { + this.timeout(5000); + // 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]); + await delay(2000); + + 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', async function () { + this.timeout(5000); + 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]); + await delay(2000); + + // test.twig (single file) + dir_test.twig + sub/sub.twig (from directory walk) + compiledFiles.length.should.equal(3); + }); + }); + }); +}); From 59c63ba541ac98b9979c3e69601e481250d79411 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Szymon=20=C5=81=C4=85giewka?= Date: Sat, 28 Mar 2026 14:59:05 +0100 Subject: [PATCH 2/2] refactor: replace walk with recursive fs.readdir `readdir` with `recursive` is possible since node v20.1.0/v18.17.0. This project already requires node v22, therefore this option can be safely used. The original project suggests to use `@root/walk` which also uses `readdir` but with manual recursion. The new lib was written ~3 years before stdlib had it. --- lib/compile.js | 30 +++++++++++------------------- package-lock.json | 18 +----------------- package.json | 3 +-- 3 files changed, 13 insertions(+), 38 deletions(-) diff --git a/lib/compile.js b/lib/compile.js index 49650832..4e9dae99 100644 --- a/lib/compile.js +++ b/lib/compile.js @@ -1,6 +1,6 @@ const FS = require('fs'); +const FSP = require('node:fs/promises'); const {minimatch} = require('minimatch'); -const WALK = require('walk'); const Twig = require('..'); const PATHS = require('./paths'); @@ -35,32 +35,24 @@ exports.compile = function (options, files) { }); }); - function parseTemplateFolder(directory, pattern) { + async 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 = await FSP.readdir(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; + const {name} = entry; if (minimatch(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) { diff --git a/package-lock.json b/package-lock.json index def79c4d..e3f02890 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,8 +11,7 @@ "dependencies": { "@babel/runtime": "^7.8.4", "locutus": "^3.0.9", - "minimatch": "^10", - "walk": "2.3.x" + "minimatch": "^10" }, "bin": { "twigjs": "bin/twigjs" @@ -2991,12 +2990,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", @@ -4532,15 +4525,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..a194482d 100644 --- a/package.json +++ b/package.json @@ -34,8 +34,7 @@ "dependencies": { "@babel/runtime": "^7.8.4", "locutus": "^3.0.9", - "minimatch": "^10", - "walk": "2.3.x" + "minimatch": "^10" }, "devDependencies": { "@babel/core": "^7.8.4",