npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

airdry

v0.0.3

Published

Generate text files from JS modules: compile *.<type>.js to its output type (CSS, HTML, SVG, JSON…), copy assets, and watch with transitive rebuilds.

Readme

airdry

Fed up with the lack of loops and abstractions in declarative languages like CSS and HTML? Want to share data and helpers between files, but without the overhead of a full build system? You’ve come to the right place.

airdry is a lightweight JS/TS-based templating engine for any text file: CSS, HTML, SVG, whatever. You write a JS (or TypeScript) file that default-exports a string; airdry compiles it to a real file on disk. Originally created for CSS, but it felt too damn good to limit to that.

Airdry is designed to be incrementally adoptable (a bit like how you only airdry the clothes that need it 😉). To airdry a file, just rename it from foo.xyz to foo.xyz.js (or foo.xyz.ts) and wrap the content with export default \ … `;. So x.css.jsx.css, x.html.tsx.html, x.svg.jsx.svg`, and so on. That’s it.

You can now use JS to generate the content, import shared data and helpers (airdry provides some too, depending on the language), the sky is the limit.

Plain files are left untouched when you build in place, and carried along for the ride when building to a separate directory.

Compiled files go through Prettier using your project config whenever it recognizes the output type, so you can focus on the code, not worrying about balancing whitespace.

As you’d expect, it comes with a watch mode, where changing a shared helper rebuilds every output that depends on it.

Airdrying specific languages

When it comes to specific languages, aidry provides some helpers to make your life easier, especially when authoring complex templates.

At a minimum, each language supports a tagged template for building strings, which handles interpolated values in a sensible way (arrays, objects, etc.). Additionally, these tagged templates enable proper syntax highlighting in editors that support it (e.g. via the es6-string-html VS Code plugin).

CSS

Airdry was originally created for CSS, so it has an extensive library of helpers for writing more readable CSS templates.

Install

npm i -D airdry

airdry requires Node ≥ 24.14 (it uses fs.watch's ignore option).

Prettier is a peer dependency (npm installs it automatically if your project doesn't already have it), so your project controls its version — and its config and plugins shape the generated output.

Usage

airdry                       # discover *.<type>.{js,ts} and build them in place
airdry --watch               # build, then rebuild affected files on change
airdry -i styles -o public   # set input/output directories
airdry --config build.js     # use a specific config file

Add it to your scripts:

{
	"scripts": {
		"build": "airdry",
		"dev": "airdry --watch"
	}
}

By default airdry builds in place: it finds every entry under the current directory (skipping node_modules), uses their common ancestor as the input, and writes each x.<type>.<ext>x.<type> beside its source (copied files stay put, never copied onto themselves). An entry is any double-extension source module — JS or TypeScript (x.<type>.js, x.<type>.ts, also .mjs/.cjs/.mts/.cts) — minus convention files that aren't outputs — *.config.*, *.test.*, *.spec.*, and dotfiles. Narrow or widen this with entries. No entries → nothing to do (though --watch keeps watching, building entries as they appear). Pass output (or -o) to write to a separate directory instead; either way the input's directory structure is mirrored, in both build and watch. You can also provide an explicit input directory to make this faster and more predictable via -i/--input. This is strongly recommended when using output.

Options

| Option | Flag | Config key | Default | Description | | ---------- | -------------- | ---------- | ------------------------------------ | ------------------------------------------------------------------- | | input dir | -i, --input | input | discovered | Where entries are read from; auto-discovered if unset. | | output dir | -o, --output | output | = input | Where output is written; defaults to in place. | | entries | — | entries | **/[!.]*.*.{js,mjs,cjs,ts,mts,cts} | Globs (input-relative) for entries to compile. | | copy | — | copy | [] (none) | Globs (input-relative) for files copied verbatim. | | exclude | — | exclude | [] | Globs (input-relative) pruned from discovery — e.g. a nested build. | | config | -c, --config | — | airdry.config.* | Config module; auto-discovered (any module ext) if unset. | | watch | -w, --watch | — | off | Rebuild affected files on change. |

entries, copy, and exclude are config-only (no flags). Precedence for the rest is CLI flag → config file → default.

Watch mode

airdry --watch builds once, then watches the input directory and rebuilds affected outputs on change. The watched root is your input — the common ancestor of your entries, or the current directory if you start with none (so files added later are still picked up).

One caveat: the watched root is fixed when watching starts, so an entry added above it needs a restart (a normal re-run re-discovers it). Pass an explicit -i/--input to pin the root where you want it.

node_modules is skipped via fs.watch's ignore option, so watching even a project root stays cheap — its churn can't affect outputs anyway, since the dependency graph only follows relative imports.

Source kinds

airdry looks at every file under the input directory (by default, the common ancestor of your entries) and sorts each into one of three kinds:

| File | Kind | Output | | ----------------- | ------- | -------------------------------------------------------- | | matches entries | entry | default-exported string → Prettier → <output>/x.<type> | | matches copy | copied | copied verbatim, path mirrored under <output> | | anything else | partial | none — imported by entries to share data/helpers |

The output type is read straight off the filename: x.css.jsx.css, x.html.tsx.html. Prettier formats the result when it recognizes that type and otherwise writes it as-is, so airdry can emit anything. The copy globs are empty by default — only entries produce output — so set copy to e.g. **/*.css to carry plain stylesheets through, or point it at assets (SVGs, fonts, images); see Configuration. Entries are always compiled, never copied, even if a copy glob would match them.

An entry's default export is the output string; named exports (e.g. metadata) are ignored by the build. A tokens.css.js might look like:

import { colors } from "./colors.js";

export default `:root {
${Object.entries(colors)
	.map(([name, value]) => `--color-${name}: ${value};`)
	.join("\n")}
}`;

With colors.js:

export const colors = {
	gray: "oklch(50% 0.03 270)",
	pink: "oklch(60% 0.28 355)",
	indigo: "oklch(52% 0.178 268)",
	// ...
};

TypeScript

Entries and partials can be TypeScript: name an entry x.<type>.ts (it still compiles to x.<type> — the output type comes from the middle extension, so TS is only the source language) and import .ts files like any other partial. No build step or config — airdry relies on Node's native type stripping (Node ≥ 24), so no extra tooling is involved.

import { colors } from "./colors.ts";

type Token = `--color-${string}`;
const decl = (name: string, value: string): `${Token}: ${string};` => `--color-${name}: ${value};`;

export default `:root {\n${Object.entries(colors)
	.map(([n, v]) => decl(n, v))
	.join("\n")}\n}`;

Because Node only strips types (it doesn't transform), TypeScript that emits runtime code — enum, namespace with values, constructor parameter properties — isn't supported; stick to erasable syntax. .mts/.cts work too, as do plain .mjs/.cjs modules.

Importing assets

Entries and partials can import more than JS and JSON. Any relative import of a non-JS file is read for you — no with {} needed:

  • a UTF-8 text file imports as a string (SVG markup, a shader, a snippet of raw CSS to inline);
  • a .json file imports as the parsed object (the with { type: "json" } that Node otherwise requires is optional);
  • anything else imports as a Uint8Array (fonts, images) — a file is treated as text when it's valid UTF-8 with no NUL byte, otherwise as bytes.
import frame from "./frame.svg"; // string
import tokens from "./tokens.json"; // parsed object

export default `.frame { background: ${tokens.bg}; }`;

Binary files come in as bytes, ready to inline — e.g. a font as a data: URL:

import font from "./inter.woff2"; // Uint8Array

let url = `data:font/woff2;base64,${Buffer.from(font).toString("base64")}`;

export default `@font-face { font-family: Inter; src: url("${url}") format("woff2"); }`;

For other formats (YAML, TOML, CSV…), import the text and parse it where you need it — any parser, any options:

import { parse } from "yaml";
import text from "./tokens.yaml";

export const tokens = parse(text);

Importing a directory

Import a directory to get its files as an object, keyed by file name. Each value is that file's content, read with the same rules as a direct import (parsed JSON, else text or bytes) — and read lazily, so Object.keys(dir) just lists names without touching the files:

import icons from "./icons"; // { "arrow.svg": …, "close.svg": … }

export default Object.keys(icons)
	.map(name => `.icon-${name.replace(".svg", "")} { background: url("…"); }`)
	.join("\n");

Indexing reads a file on demand — icons["arrow.svg"] yields its content as a string. Only the directory's immediate files are listed; subdirectories and dotfiles are skipped, and names are sorted so the output is stable everywhere.

In watch mode, changing an imported file rebuilds every entry that depends on it, just like a changed .js partial. For a directory, that includes adding, removing, or editing any of its files.

Configuration

Zero config by default: airdry discovers your entries and builds them in place. To override (separate output dir, fixed input, extra assets), add an airdry.config.js that default-exports a build:

export default {
	input: "styles", // relative to the config file
	output: ".", // write relative to the config file
	entries: "**/*.css.js", // which files to compile (default: any *.<type>.{js,ts,…})
	copy: ["**/*.css", "**/*.svg", "fonts/**"], // files copied verbatim
	exclude: ["vendor"], // subtrees pruned from discovery
};

input/output paths resolve relative to the config file (CLI paths relative to the current directory). entries, copy, and exclude are input-relative globs:

  • entries picks what to compile. Default **/[!.]*.*.{js,mjs,cjs,ts,mts,cts} (any double-extension JS or TS source module). Set it to **/*.css.js to compile only CSS, or widen it for other types.
  • copy picks files copied through untouched (default none) — use it to carry plain stylesheets along, or to ship assets like SVGs, fonts, and images. Entries are always compiled, never copied, even if a copy glob would match them.
  • exclude prunes paths from both — e.g. a subtree owned by another build.

With no --config, airdry auto-discovers the first airdry.config.<ext> in the current directory across the module extensions it runs (.js, .mjs, .cjs, .ts, .mts, .cts.js wins if several exist). Point at a different one with airdry --config path/to/config.ts. The config may be ESM or CommonJS, JS or TypeScript.

Formatting follows the Prettier config resolved from each output file, so the generated output matches the rest of your project.

Multiple builds

A config can also default-export an array of builds, each with its own input, output, and settings. They run independently — share settings with a plain object spread rather than a merge layer.

For example, suppose you want to compile src/*.css.jsdist/ but also build index.html.js in place, while keeping the second build out of the first one's subtree. You can do this:

export default [
	{ input: "src", output: "dist" }, // src/*.css.js → dist/
	{ input: ".", exclude: ["src", "dist"] }, // index.html.js → index.html, in place
];

The second build's exclude keeps it out of the subtree the first one owns (a "donut" scope).

Programmatic API

airdry(config, options?) separates what to build (config: one build, or an array of them) from how to run it (options: watch, signal, cwd). A one-shot run resolves to the total number of failed entries:

import airdry from "airdry";

let failures = await airdry({ input: "src", output: "dist" });

// Several independent builds at once:
await airdry([
	{ input: "src", output: "dist" },
	{ input: ".", exclude: ["src", "dist"] },
]);

Pass watch: true (with an optional AbortSignal) in the second argument to keep rebuilding:

let stop = new AbortController();
await airdry({ input: "src", output: "dist" }, { watch: true, signal: stop.signal });
// …later: stop.abort();

The named Builder export is a single build with separate build() / watch({ signal }) methods, if you'd rather hold the instance.

How it works

  • Each build batch evaluates its entries in a fresh worker_threads realm. A fresh realm has an empty module registry, so a change to a transitive partial is reflected in the output — re-import()ing in a long-lived process would re-run only the entry, not its cached imports.
  • The dependency graph is built by reading (not evaluating) source, so a temporarily-broken entry still tracks its imports and is retried when they change.
  • A failing entry is logged and skipped; its previous output is kept and watching continues. A one-shot build exits non-zero if any entry failed.