diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index e1b6302..b702ff6 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -7,7 +7,7 @@ jobs: strategy: matrix: node-version: - - 18.x + - 20.x - 22.x - latest steps: diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b12557..650a902 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,18 @@ faucet-pipeline-static version history ====================================== +v2.2.0 +------ + +_TBD_ + +maintenance release to update dependencies; no significant changes + +improvements for developers: + +* it now exposes `buildProcessPipeline` for other pipelines that convert directories of files instead of single files + + v2.1.0 ------ diff --git a/index.js b/index.js deleted file mode 100644 index 40833ac..0000000 --- a/index.js +++ /dev/null @@ -1,91 +0,0 @@ -let { readFile, stat } = require("fs").promises; -let path = require("path"); -let { FileFinder } = require("faucet-pipeline-core/lib/util/files/finder"); - -module.exports = { - key: "static", - bucket: "static", - plugin: faucetStatic -}; - -function faucetStatic(config, assetManager, { compact } = {}) { - let copiers = config.map(copyConfig => - makeCopier(copyConfig, assetManager, { compact })); - - return filepaths => Promise.all(copiers.map(copy => copy(filepaths))); -} - -function makeCopier(copyConfig, assetManager, { compact } = {}) { - let source = assetManager.resolvePath(copyConfig.source); - let target = assetManager.resolvePath(copyConfig.target, { - enforceRelative: true - }); - let fileFinder = new FileFinder(source, { - skipDotfiles: true, - filter: copyConfig.filter - }); - let { fingerprint } = copyConfig; - let plugins = determinePlugins(compact, copyConfig); - - return filepaths => { - return Promise.all([ - (filepaths ? fileFinder.match(filepaths) : fileFinder.all()), - determineTargetDir(source, target) - ]).then(([fileNames, targetDir]) => { - return processFiles(fileNames, { - assetManager, source, target, targetDir, plugins, fingerprint - }); - }); - }; -} - -function determinePlugins(compact, copyConfig) { - if(!compact) { - return {}; - } - - return copyConfig.compact || {}; -} - -// If `source` is a directory, `target` is used as target directory - -// otherwise, `target`'s parent directory is used -function determineTargetDir(source, target) { - return stat(source). - then(results => results.isDirectory() ? target : path.dirname(target)); -} - -function processFiles(fileNames, config) { - return Promise.all(fileNames.map(fileName => processFile(fileName, config))); -} - -async function processFile(fileName, - { source, target, targetDir, fingerprint, assetManager, plugins }) { - let sourcePath = path.join(source, fileName); - let targetPath = path.join(target, fileName); - - try { - var content = await readFile(sourcePath); // eslint-disable-line no-var - } catch(err) { - if(err.code !== "ENOENT") { - throw err; - } - console.error(`WARNING: \`${sourcePath}\` no longer exists`); - return; - } - - let type = determineFileType(sourcePath); - if(type && plugins[type]) { - let plugin = plugins[type]; - content = await plugin(content); - } - - let options = { targetDir }; - if(fingerprint !== undefined) { - options.fingerprint = fingerprint; - } - return assetManager.writeFile(targetPath, content, options); -} - -function determineFileType(sourcePath) { - return path.extname(sourcePath).substr(1).toLowerCase(); -} diff --git a/lib/index.js b/lib/index.js new file mode 100644 index 0000000..a2ad2a7 --- /dev/null +++ b/lib/index.js @@ -0,0 +1,72 @@ +import { buildProcessPipeline } from "./util.js"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; + +export const key = "static"; +export const bucket = "static"; + +/** @type FaucetPlugin */ +export function plugin(config, assetManager, options) { + let pipeline = config.map(copyConfig => { + let processFile = buildProcessFile(copyConfig, options); + let { source, target, filter } = copyConfig; + return buildProcessPipeline(source, target, processFile, assetManager, filter); + }); + + return filepaths => Promise.all(pipeline.map(copy => copy(filepaths))); +} + +/** + * Returns a function that copies a single file with optional compactor + * + * @param {Config} copyConfig + * @param {FaucetPluginOptions} options + * @returns {ProcessFile} + */ +function buildProcessFile(copyConfig, options) { + let compactors = (options.compact && copyConfig.compact) || {}; + + return async function(filename, + { source, target, targetDir, assetManager }) { + let sourcePath = path.join(source, filename); + let targetPath = path.join(target, filename); + + let content; + try { + content = await readFile(sourcePath); + } catch(err) { + // @ts-expect-error TS2345 + if(err.code !== "ENOENT") { + throw err; + } + console.error(`WARNING: \`${sourcePath}\` no longer exists`); + return; + } + + let fileExtension = path.extname(sourcePath).substr(1).toLowerCase(); + if(fileExtension && compactors[fileExtension]) { + let compactor = compactors[fileExtension]; + content = await compactor(content); + } + + /** @type WriteFileOpts */ + let options = { targetDir }; + if(copyConfig.fingerprint !== undefined) { + options.fingerprint = copyConfig.fingerprint; + } + return assetManager.writeFile(targetPath, content, options); + }; +} + +/** + * @import { + * Config, + * ProcessFile + * } from "./types.ts" + * + * @import { + * FaucetPlugin, + * FaucetPluginOptions, + * WriteFileOpts, + * } from "faucet-pipeline-core/lib/types.ts" +*/ diff --git a/lib/types.ts b/lib/types.ts new file mode 100644 index 0000000..357269a --- /dev/null +++ b/lib/types.ts @@ -0,0 +1,39 @@ +import { AssetManager } from "faucet-pipeline-core/lib/types.ts" + +export interface Config { + source: string, + target: string, + targetDir: string, + fingerprint?: boolean, + compact?: CompactorMap, + assetManager: AssetManager, + filter?: Filter +} + +export interface CompactorMap { + [fileExtension: string]: Compactor +} + +export interface Compactor { + (contact: Buffer): Promise +} + +export interface ProcessFile { + (filename: string, opts: ProcessFileOptions): Promise +} + +export interface ProcessFileOptions { + source: string, + target: string, + targetDir: string, + assetManager: AssetManager, +} + +export interface FileFinderOptions { + skipDotfiles: boolean, + filter?: Filter +} + +export interface Filter { + (filename: string): boolean +} diff --git a/lib/util.js b/lib/util.js new file mode 100644 index 0000000..2084d9d --- /dev/null +++ b/lib/util.js @@ -0,0 +1,123 @@ +import { readdir, stat } from "node:fs/promises"; +import path from "node:path"; + +/** + * Creates a processor for a single configuration + * + * @param {string} source - the source folder or file for this pipeline + * @param {string} target - the target folder or file for this pipeline + * @param {ProcessFile} processFile - process a single file + * @param {AssetManager} assetManager + * @param {Filter} [filter] - optional filter based on filenames + * @returns {FaucetPluginFunc} + */ +export function buildProcessPipeline(source, target, processFile, assetManager, filter) { + source = assetManager.resolvePath(source); + target = assetManager.resolvePath(target, { + enforceRelative: true + }); + let fileFinder = new FileFinder(source, { + skipDotfiles: true, + filter + }); + + return async filepaths => { + let [filenames, targetDir] = await Promise.all([ + (filepaths ? fileFinder.match(filepaths) : fileFinder.all()), + determineTargetDir(source, target) + ]); + + return Promise.all(filenames.map(filename => processFile(filename, { + assetManager, source, target, targetDir + }))); + }; +} + +/** + * If `source` is a directory, `target` is used as target directory - + * otherwise, `target`'s parent directory is used + * + * @param {string} source + * @param {string} target + * @returns {Promise} + */ +async function determineTargetDir(source, target) { + let results = await stat(source); + return results.isDirectory() ? target : path.dirname(target); +} + +class FileFinder { + /** + * @param {string} root + * @param {FileFinderOptions} options + */ + constructor(root, { skipDotfiles, filter }) { + this._root = root; + + /** + * @param {string} filename + * @return {boolean} + */ + this._filter = filename => { + if(skipDotfiles && path.basename(filename).startsWith(".")) { + return false; + } + return filter ? filter(filename) : true; + }; + } + + /** + * A list of relative file paths within the respective directory + * + * @returns {Promise} + */ + async all() { + let filenames = await tree(this._root); + return filenames.filter(this._filter); + } + + /** + * All file paths that match the filter function + * + * @param {string[]} filepaths + * @returns {Promise} + */ + async match(filepaths) { + return filepaths.map(filepath => path.relative(this._root, filepath)). + filter(filename => !filename.startsWith("..")). + filter(this._filter); + } +} + +/** + * Flat list of all files of a directory tree + * + * @param {string} filepath + * @param {string} referenceDir + * @returns {Promise} + */ +async function tree(filepath, referenceDir = filepath) { + let stats = await stat(filepath); + + if(!stats.isDirectory()) { + return [path.relative(referenceDir, filepath)]; + } + + let entries = await Promise.all((await readdir(filepath)).map(entry => { + return tree(path.join(filepath, entry), referenceDir); + })); + return entries.flat(); +} + +/** + * @import { + * Filter, + * FileFinderOptions, + * ProcessFile + * } from "./types.ts" + * + * @import { + * AssetManager, + * FaucetPluginFunc, + * } from "faucet-pipeline-core/lib/types.ts" +*/ diff --git a/package.json b/package.json index d6c5a77..c64c1e5 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,14 @@ { "name": "faucet-pipeline-static", - "version": "2.1.0", + "version": "2.2.0", "description": "static files for faucet-pipeline", - "main": "index.js", + "main": "./lib/index.js", + "type": "module", "scripts": { - "test": "npm-run-all --parallel lint test:cli", + "test": "npm run lint && npm run typecheck && npm run test:cli", "test:cli": "./test/run", - "lint": "eslint --cache index.js test && echo ✓" + "lint": "eslint --cache ./lib ./test && echo ✓", + "typecheck": "tsc" }, "repository": { "type": "git", @@ -19,15 +21,16 @@ }, "homepage": "https://www.faucet-pipeline.org", "engines": { - "node": ">= 18" + "node": ">= 20.19.0" }, "dependencies": { - "faucet-pipeline-core": "^3.0.0" + "faucet-pipeline-core": "git+https://github.com/faucet-pipeline/faucet-pipeline-core.git#util-cleanup" }, "devDependencies": { + "@types/node": "^22.13.10", "eslint-config-fnd": "^1.13.0", "json-diff": "^1.0.0", - "npm-run-all": "^4.1.5", - "release-util-fnd": "^3.0.0" + "release-util-fnd": "^3.0.0", + "typescript": "^5.8.2" } } diff --git a/test/test_basic/faucet.config.js b/test/test_basic/faucet.config.js index c075370..7e9350c 100644 --- a/test/test_basic/faucet.config.js +++ b/test/test_basic/faucet.config.js @@ -1,10 +1,9 @@ -"use strict"; -let path = require("path"); +import { resolve } from "node:path"; -module.exports = { - static: [{ - source: "./src", - target: "./dist" - }], - plugins: [path.resolve(__dirname, "../..")] -}; +const config = [{ + source: "./src", + target: "./dist" +}]; +export { config as static }; + +export const plugins = [resolve(import.meta.dirname, "../..")]; diff --git a/test/test_fingerprint/faucet.config.js b/test/test_fingerprint/faucet.config.js index 1bd7f28..340eab5 100644 --- a/test/test_fingerprint/faucet.config.js +++ b/test/test_fingerprint/faucet.config.js @@ -1,13 +1,13 @@ -"use strict"; -let path = require("path"); +import { resolve } from "node:path"; -module.exports = { - static: [{ - source: "./src", - target: "./dist" - }], - manifest: { - target: "./dist/manifest.json" - }, - plugins: [path.resolve(__dirname, "../..")] +const config = [{ + source: "./src", + target: "./dist" +}]; +export { config as static }; + +export const manifest = { + target: "./dist/manifest.json" }; + +export const plugins = [resolve(import.meta.dirname, "../..")]; diff --git a/test/test_key_config/faucet.config.js b/test/test_key_config/faucet.config.js index 3ff6f64..6a1a831 100644 --- a/test/test_key_config/faucet.config.js +++ b/test/test_key_config/faucet.config.js @@ -1,14 +1,14 @@ -"use strict"; -let path = require("path"); +import { resolve, relative } from "node:path"; -module.exports = { - static: [{ - source: "./src", - target: "./dist" - }], - manifest: { - target: "./dist/manifest.json", - key: (f, targetDir) => path.relative(targetDir, f) - }, - plugins: [path.resolve(__dirname, "../..")] +const config = [{ + source: "./src", + target: "./dist" +}]; +export { config as static }; + +export const manifest = { + target: "./dist/manifest.json", + key: (f, targetDir) => relative(targetDir, f) }; + +export const plugins = [resolve(import.meta.dirname, "../..")]; diff --git a/test/test_key_for_single_file/faucet.config.js b/test/test_key_for_single_file/faucet.config.js index 4642185..df75818 100644 --- a/test/test_key_for_single_file/faucet.config.js +++ b/test/test_key_for_single_file/faucet.config.js @@ -1,14 +1,14 @@ -"use strict"; -let path = require("path"); +import { resolve, relative } from "node:path"; -module.exports = { - static: [{ - source: "./src/test.txt", - target: "./dist/test.txt" - }], - manifest: { - target: "./dist/manifest.json", - key: (f, targetDir) => path.relative(targetDir, f) - }, - plugins: [path.resolve(__dirname, "../..")] +const config = [{ + source: "./src/test.txt", + target: "./dist/test.txt" +}]; +export { config as static }; + +export const manifest = { + target: "./dist/manifest.json", + key: (f, targetDir) => relative(targetDir, f) }; + +export const plugins = [resolve(import.meta.dirname, "../..")]; diff --git a/test/test_manifest_base_uri/faucet.config.js b/test/test_manifest_base_uri/faucet.config.js index 1dd1fca..dbd6b42 100644 --- a/test/test_manifest_base_uri/faucet.config.js +++ b/test/test_manifest_base_uri/faucet.config.js @@ -1,14 +1,14 @@ -"use strict"; -let path = require("path"); +import { resolve, relative } from "node:path"; -module.exports = { - static: [{ - source: "./src", - target: "./dist" - }], - manifest: { - target: "./dist/manifest.json", - value: f => `/assets/${path.relative("./dist", f)}` - }, - plugins: [path.resolve(__dirname, "../..")] +const config = [{ + source: "./src", + target: "./dist" +}]; +export { config as static }; + +export const manifest = { + target: "./dist/manifest.json", + value: f => `/assets/${relative("./dist", f)}` }; + +export const plugins = [resolve(import.meta.dirname, "../..")]; diff --git a/test/test_match_dirname/faucet.config.js b/test/test_match_dirname/faucet.config.js index d17a793..92ac275 100644 --- a/test/test_match_dirname/faucet.config.js +++ b/test/test_match_dirname/faucet.config.js @@ -1,11 +1,10 @@ -"use strict"; -let path = require("path"); +import { resolve } from "node:path"; -module.exports = { - static: [{ - source: "./src", - target: "./dist", - filter: path => path.startsWith("inner/") - }], - plugins: [path.resolve(__dirname, "../..")] -}; +const config = [{ + source: "./src", + target: "./dist", + filter: path => path.startsWith("inner/") +}]; +export { config as static }; + +export const plugins = [resolve(import.meta.dirname, "../..")]; diff --git a/test/test_match_extension/faucet.config.js b/test/test_match_extension/faucet.config.js index ca52325..4637f00 100644 --- a/test/test_match_extension/faucet.config.js +++ b/test/test_match_extension/faucet.config.js @@ -1,11 +1,10 @@ -"use strict"; -let path = require("path"); +import { resolve } from "node:path"; -module.exports = { - static: [{ - source: "./src", - target: "./dist", - filter: path => path.endsWith(".txt") - }], - plugins: [path.resolve(__dirname, "../..")] -}; +const config = [{ + source: "./src", + target: "./dist", + filter: path => path.endsWith(".txt") +}]; +export { config as static }; + +export const plugins = [resolve(import.meta.dirname, "../..")]; diff --git a/test/test_match_multiple/faucet.config.js b/test/test_match_multiple/faucet.config.js index 10c5111..d8e6288 100644 --- a/test/test_match_multiple/faucet.config.js +++ b/test/test_match_multiple/faucet.config.js @@ -1,11 +1,10 @@ -"use strict"; -let path = require("path"); +import { resolve } from "node:path"; -module.exports = { - static: [{ - source: "./src", - target: "./dist", - filter: path => path.endsWith(".txt") && !path.startsWith("inner/") - }], - plugins: [path.resolve(__dirname, "../..")] -}; +const config = [{ + source: "./src", + target: "./dist", + filter: path => path.endsWith(".txt") && !path.startsWith("inner/") +}]; +export { config as static }; + +export const plugins = [resolve(import.meta.dirname, "../..")]; diff --git a/test/test_match_negation/faucet.config.js b/test/test_match_negation/faucet.config.js index ec5b8cf..7ba2216 100644 --- a/test/test_match_negation/faucet.config.js +++ b/test/test_match_negation/faucet.config.js @@ -1,11 +1,10 @@ -"use strict"; -let path = require("path"); +import { resolve } from "node:path"; -module.exports = { - static: [{ - source: "./src", - target: "./dist", - filter: path => !path.endsWith("/test2.txt") - }], - plugins: [path.resolve(__dirname, "../..")] -}; +const config = [{ + source: "./src", + target: "./dist", + filter: path => !path.endsWith("/test2.txt") +}]; +export { config as static }; + +export const plugins = [resolve(import.meta.dirname, "../..")]; diff --git a/test/test_no_fingerprint/faucet.config.js b/test/test_no_fingerprint/faucet.config.js index f46b3cf..0731458 100644 --- a/test/test_no_fingerprint/faucet.config.js +++ b/test/test_no_fingerprint/faucet.config.js @@ -1,14 +1,13 @@ -"use strict"; -let path = require("path"); +import { resolve } from "node:path"; -module.exports = { - static: [{ - source: "./src", - target: "./dist/no-fingerprint", - fingerprint: false - }, { - source: "./src", - target: "./dist/fingerprint" - }], - plugins: [path.resolve(__dirname, "../..")] -}; +const config = [{ + source: "./src", + target: "./dist/no-fingerprint", + fingerprint: false +}, { + source: "./src", + target: "./dist/fingerprint" +}]; +export { config as static }; + +export const plugins = [resolve(import.meta.dirname, "../..")]; diff --git a/test/test_single/faucet.config.js b/test/test_single/faucet.config.js index 4fac638..e6ae6c4 100644 --- a/test/test_single/faucet.config.js +++ b/test/test_single/faucet.config.js @@ -1,10 +1,9 @@ -"use strict"; -let path = require("path"); +import { resolve } from "node:path"; -module.exports = { - static: [{ - source: "./src.txt", - target: "./dist/dist.txt" - }], - plugins: [path.resolve(__dirname, "../..")] -}; +const config = [{ + source: "./src.txt", + target: "./dist/dist.txt" +}]; +export { config as static }; + +export const plugins = [resolve(import.meta.dirname, "../..")]; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..c09dc11 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "noEmit": true, + "strict": true, + "allowImportingTsExtensions": true, + "lib": ["dom", "es2023"], + "module": "nodenext", + "isolatedModules": true, + "erasableSyntaxOnly": true + }, + "exclude": ["./test/**"] +}