Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ jobs:
strategy:
matrix:
node-version:
- 18.x
- 20.x
- 22.x
- latest
steps:
Expand Down
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
------

Expand Down
91 changes: 0 additions & 91 deletions index.js

This file was deleted.

72 changes: 72 additions & 0 deletions lib/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { buildProcessPipeline } from "./util.js";
import { readFile } from "node:fs/promises";
import path from "node:path";

export const key = "static";
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@FND Do you remember what this name is actually used for? I'm not sure it is used for plugins that are in the default list, only for the ones that are not:
https://github.com/faucet-pipeline/faucet-pipeline-core/blob/main/lib/plugins.js#L5

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was confused by this as well and haven't investigated it yet (or rather: I don't understand our implementation anymore without investing a fair amount of effort), but tests suggest that a plugin can override faucet-core's defaults. I'm not 100 % sure that interpretation is correct tough.

But yes, this value is most likely inert unless it differs from faucet-core's baked-in defaults. It still seems proper for plugins to be self-descriptive though.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you don't really need this option for the plugins that are defined in core, but only for those that are not.

export const bucket = "static";

/** @type FaucetPlugin<Config> */
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"
*/
39 changes: 39 additions & 0 deletions lib/types.ts
Original file line number Diff line number Diff line change
@@ -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<Buffer>
}

export interface ProcessFile {
(filename: string, opts: ProcessFileOptions): Promise<unknown>
}

export interface ProcessFileOptions {
source: string,
target: string,
targetDir: string,
assetManager: AssetManager,
}

export interface FileFinderOptions {
skipDotfiles: boolean,
filter?: Filter
}

export interface Filter {
(filename: string): boolean
}
Copy link
Contributor

@FND FND Mar 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FWIW, I generally try to keep things local to the respective file, i.e. *.ts is a last resort for me.

In this case, I would have added the following to the bottom of util.js:

/** @typedef {{ skipDotfiles: boolean, filter?: Filter }} FileFinderOptions */

This not only reduces indirections (which can become quite taxing for the mind), it's also in line with deletability and means you need less expansive @import statements. In fact, I would argue that the need for multi-line @import is a potential smell.

That smell also suggests we might want to move FileFinder to a separate module (finder.js?) and break up types.ts into separate modules (e.g. util.types.ts and finder.types.ts) - that, too, comes down to deletability.

However, that's just me; YMMV.

123 changes: 123 additions & 0 deletions lib/util.js
Original file line number Diff line number Diff line change
@@ -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<string>}
*/
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<string[]>}
*/
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<string[]>}
*/
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<string[]>}
*/
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"
*/
19 changes: 11 additions & 8 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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"
}
}
Loading