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

@lionheart-group/task-image-runner

v2.3.0

Published

Image optimizer and converter, and a width/height filler and alt linter for image tags in markup

Readme

@lionheart-group/task-image-runner

Image optimizer and converter built on sharp and svgo. Optimizes jpeg, png and svg in place, and converts images to webp or jpeg alongside the originals. Also fills width/height on image tags in HTML and template files, resolving src values written in whatever syntax the project uses, checks those same tags for a missing or empty alt, and lists images nothing references.

Requires Node ^22.13.0 || ^24.0.0 || >=26.0.0.

Install

npm install --save-dev @lionheart-group/task-image-runner

The binary is lh-image-runner.

Commands

optimize:image <pattern>

Re-encodes matching images in place. jpeg goes through mozjpeg, png through sharp at compression level 9, svg through svgo. A file that comes out no smaller is left alone.

lh-image-runner optimize:image 'src/img/**/*.{jpg,jpeg,png,svg}'

convert:image <mode> <pattern>

Writes a converted copy next to each source. mode is webp, jpg or jpeg. Sources are never modified.

lh-image-runner convert:image webp 'src/img/**/*.{jpg,png}'

attr:img:size [pattern]

Fills width and height on <img>, <source srcset> and <video poster> in markup in place, reading each image's intrinsic size. Without them the browser cannot size the box until the image arrives, and the page shifts under the reader.

lh-image-runner attr:img:size 'resources/views/**/*.{html,php}'

The pattern is optional; with none, the one in the config file is used, so a project can commit its settings and run the command bare.

The src values are the point. They are rarely plain paths:

<img src="<?php the_asset_url('img/logo.png') ?>">
<img src="{{ base }}/img/logo.png" />

The helper's name, the variable's name and the directory each points at all vary per project, so they are configured rather than guessed - see Configuration.

A tag that already carries width and height is left alone. --force rewrites both to the real size, which is how a page is corrected after an image is replaced with one of a different shape. A value that is itself a template expression - width="<?php echo $w ?>" - is never overwritten; that tag is reported and left as written.

Only the bytes inside a tag are touched. Line endings, a byte order mark, the trailing newline, quote styles, attribute order and every template expression survive untouched, and running the command twice produces identical bytes.

Dimensions come from the image as a browser would see it: an EXIF-rotated photo is reported the way it is displayed, an animated gif by one frame rather than the whole strip, and an SVG by its own width/height - converted from pt or in where it uses them - or by its viewBox. An SVG with neither is reported rather than guessed at.

For srcset, the smallest candidate wins, since that is the CSS pixel reference. Candidates within one srcset that disagree about their aspect ratio are reported; two sibling <source media=...> elements that disagree are not, because that is art direction rather than a mistake.

attr:img:alt [pattern]

Reports <img> tags with a missing or empty alt, in the same markup and through the same lexer as attr:img:size.

lh-image-runner attr:img:alt 'resources/views/**/*.{html,php}'

It writes nothing. The pattern is optional, and .image-runner.json is shared with attr:img:size, so a project that has configured one has configured both.

Each finding names the image it is about on the line underneath, resolved through the same resolve rules as attr:img:size so that an editor's terminal turns it into a link:

✖ templates/page.php:3:3 - <img> has no alt attribute
    src/assets/img/hero.jpg

A src that does not resolve - an external URL, a value built at runtime, a project with no rules configured - falls back to the src as written, which still says which <img> this was when several sit on neighbouring lines:

✖ templates/page.php:5:3 - <img> has no alt attribute
    src "<?php echo $post->thumb ?>"

Failing to resolve is never a finding in itself. This command lints alt, not src.

| The tag | Reported as | | ---------------------------------------------------------------------- | -------------------------------------------- | | no alt | error | | no alt, but the tag builds its attributes from a template expression | warning | | alt with no value at all | warning | | alt="", or whitespace only | warning | | alt whose value is a template expression | nothing | | alt with real text | nothing | | more than one alt | warning, and the first one is the one judged |

An alt built from a template expression is never reported. It may well be empty at runtime, but that cannot be known from the file, and a check that fires on correct code is a check people switch off.

alt="" is reported even though it is the correct way to mark a decorative image. Nothing distinguishes a deliberate decorative image from one whose alt was left blank, and the point of the check is to make that list reviewable. It is a warning, never an error.

Its exit code is the opposite of attr:img:size's, deliberately. There, a src that cannot be resolved is information about the project and never fails the run. Here, a missing alt is a defect in the markup, and failing the build is the reason to run it at all. Warnings never fail the run.

find:img:unused [pattern]

Lists the images under your resolve bases that no markup file references. It is a report and nothing more: it never deletes anything, and never writes.

lh-image-runner find:img:unused

Every src, every srcset candidate and every poster in the matched templates is resolved through the same rules as attr:img:size, and the images under each base are compared against the result.

⚠ src/assets/img/old-hero.jpg  1.2MB
⚠ src/assets/img/old-hero.jpg.webp  0.41MB
ℹ Not referenced by a tag, but named in a template - check before deleting:
    src/assets/img/logo-home.png
ℹ 3 dynamic src values could not be followed; an image used only through one of them is listed above (--verbose to list)
ℹ Templates: 34 scanned. Images: 212 found, 186 in use, 18 unused (6.4MB), 1 named

Its output is a list of files someone is about to delete, so every judgement it makes leans towards in use. Missing an unused image leaves some clutter; reporting a used one puts a broken image on a live page.

  • Converted copies travel with their source. A referenced hero.jpg keeps hero.jpg.webp and hero.webp; a referenced hero.jpg.webp keeps hero.jpg, which convert:image needs to regenerate it. A hand-made logo.jpg beside a referenced logo.png is kept too, since nothing distinguishes it from convert:image's -f '[name]' output. A --format that writes into another directory cannot be followed.
  • An image whose file name appears in a template is set apart, not reported unused: $logo = 'img/logo-home.png' handed to a helper through a variable cannot be followed, but the name is right there. The same goes for anything else in the pattern - add stylesheets or scripts to it, as in "pattern": "**/*.{php,html,scss,js}", and a url(../img/bg.png) counts. attr:img:size and attr:img:alt find no tags in those files and are unaffected.
  • Dynamic src values are counted every run, because an image reached only through one of them is listed as unused. --verbose lists them.

Only directories named by a base are searched. A config with no base at all stops the run rather than reporting that nothing is unused.

It exits 0 whatever it finds; --check exits 1 when anything is unused, for a project that wants that enforced. Images that are only named in a template never count towards --check.

Options

| Option | Commands | Default | Meaning | | ----------------------- | --------------- | ----------------------------------------------------- | ---------------------------------------------------------- | | -c, --cache <path> | both | .optimize-cache.json / .convert-[mode]-cache.json | Where the result cache is written | | -q, --quality <n> | both | 80 | Encoder quality, 1–100 | | -j, --concurrency <n> | both | number of cores | How many images to process at once | | -f, --format <format> | convert | [name].[ext] | Output filename, before the mode's extension | | --config <path> | attr:img:size | the nearest .image-runner.json | Where the resolver rules live | | -f, --force | attr:img:size | off | Rewrite width/height that are already there | | --dry-run | attr:img:size | off | Report the changes and write nothing | | --check | attr:img:size | off | Write nothing, and exit 1 if anything still needs sizing | | --print-config | attr:img:size | off | Print the merged config and the compiled rules, then stop | | --verbose | attr:img:size | off | List the dynamic src values that were skipped | | --config <path> | attr:img:alt | the nearest .image-runner.json | Where the shared pattern lives | | --print-config | attr:img:alt | off | Print the merged config and stop | | --check | find:img:unused | off | Exit 1 if any image is unused | | --verbose | find:img:unused | off | List the dynamic src values that could not be followed |

--format takes [name] and [ext] from the source, and the mode's extension is appended: with the default, logo.png becomes logo.png.webp. [name] alone gives logo.webp — note that logo.png and logo.jpg then both map onto that one output. It may name a directory, as in -f 'webp/[name]', which is created if needed. A format that resolves to the source itself is refused, since converting never modifies its input.

--quality and --concurrency reject anything that is not a whole number in range and exit 1. They used to accept any string; Number("abc") is NaN, which silently started no workers at all.

Configuration

attr:img:size reads .image-runner.json. The other two commands take no config; if one ever does, it gets its own key in this file.

A WordPress theme

wp-content/themes/acme/.image-runner.json:

{
    "pattern": "**/*.php",
    "resolve": [{ "expr": "get_template_directory_uri", "base": "." }]
}
<!-- before -->
<img src="<?php echo get_template_directory_uri(); ?>/assets/img/logo.png" alt="">

<!-- after -->
<img src="<?php echo get_template_directory_uri(); ?>/assets/img/logo.png" width="240" height="64" alt="">

The rule matches the expression and captures everything after it, so the path is /assets/img/logo.png. base is . because the config sits at the theme root, which is what those paths are relative to.

An in-house framework

{
    "pattern": "resources/views/**/*.{html,php}",
    "force": false,
    "resolve": [
        { "php": "the_asset_url", "base": "src/assets" },
        { "expr": "base", "base": "public" },
        { "prefix": "/assets/", "base": "public/assets" },
        {
            "pattern": "^\\{\\{\\s*img\\('(.+?)'\\)\\s*\\}\\}$",
            "to": "src/img/$1"
        }
    ]
}

| src as written | resolves to | | ------------------------------------- | ------------------------- | | <?php the_asset_url('img/a.png') ?> | src/assets/img/a.png | | {{ base }}/img/a.png | public/img/a.png | | /assets/img/a.png | public/assets/img/a.png | | {{ img('a.png') }} | src/img/a.png |

The four ways of writing a rule

They are four front doors onto one mechanism: every rule compiles to a pattern and a replacement, and the resolver knows nothing about which door was used.

  • php - the whole src is one helper call. Accepts <?php and <?=, an optional echo, either quote style, a trailing ;, whitespace anywhere it can legally go, and extra arguments: the_asset_url('img/a.png', true) is the same call with the same first argument.
  • expr - an expression stands in front and the path follows. Covers {{ base }}/x, {{ $base }}/x, {% base %}/x, <%= base %>/x and <?php echo helper(); ?>/x. Delimiters must pair up, so {{ base %} does not match.
  • prefix - a literal string prefix. It runs against the whole value, so a project that hard-codes its own CDN can write { "prefix": "https://cdn.example.com/", "base": "public" } and have absolute URLs resolve.
  • pattern - a regular expression, with to as the replacement. Not anchored for you: the pattern is matched against the whole value, so anchor it unless you mean a substring.

base is shorthand for a to that appends what the rule captured, so the two are never given together. Write base when the rule points at a directory and to when you want to reshape the path.

Because this is JSON, every backslash in a pattern is doubled: \d is written "\\d". It is the most likely mistake in the file, and the error message says so.

Rule order

File order, first match wins - but a rule that matches and finds nothing falls through to the next. That is how one helper backed by two asset roots is written: two rules, not one hand-built alternation. When nothing is found, the warning names every path that was tried.

Where the config is found, and what its paths mean

Without --config, the file is looked for in the current directory and then upwards, stopping at the repository root (a directory holding .git) and at your home directory. Walking up is what makes the command work from a package subdirectory in a monorepo; stopping is what keeps a stray ~/.image-runner.json from quietly governing every project on the machine.

Relative paths in the config are relative to the config file, not to the current directory. "base": "src/assets" is written once and committed - it is a statement about the repository's layout, and it should mean the same thing from wherever the command is run. Measured from the current directory instead, it breaks for npm run from a package subdirectory, for an editor task runner, and for a CI job that does cd packages/theme first - and it breaks quietly, warning on every src and exiting 0 having done nothing.

The same goes for the config's pattern. A pattern typed on the command line is relative to the current directory, like everything else typed at a prompt. Every run prints which config and which root are in force.

With no config at all the command still runs, with one built-in rule: a src with no scheme, no template expression and no leading / resolves against the directory of the file it is written in. A root-absolute /img/a.png gets no built-in rule, because where the web root lives is exactly what cannot be guessed.

Settings resolve in one direction: command-line flag, then config file, then the built-in default.

What is skipped, and how loudly

| src | Reported | | -------------------------------------------------------------------------------------- | ---------------------------------------------------- | | an external URL, a data: URI, a protocol-relative host - and no rule claimed it | never | | still holds a template expression after no rule matched - <?php echo $post->thumb ?> | counted, one closing line, listed under --verbose | | looks like yours, but the file is not there | one warning each, with the path and everything tried | | src="" | one warning each |

None of these fail the run. A page referencing something this tool cannot see is information about the project, reported on every run; it is not a reason to turn a build red.

A config whose shape is wrong is different, and stops the run before a single file is read. This command rewrites source files, so a rule silently dropped for a typo would leave a whole directory of images unsized and look exactly like a broken scanner. A base that does not exist is not a shape error - it is often the output of a build step that has not run in this checkout - so it warns.

--print-config prints the merged settings and every compiled rule, which is the way to find out why a rule is not matching.

The cache

optimize:image and convert:image record what they did, keyed on a hash of the source's contents and its size, plus the settings that shaped the output — quality, and for convert the format and mode. Change any of them, or edit the image, and the file is processed again.

Convert also checks that its output is still there, so deleting a generated file regenerates it.

Failures are recorded too. An image sharp cannot read is reported and skipped on later runs rather than retried every build, until the file changes, the settings change, or the encoder version does — a libvips release can read an image its predecessor could not.

Committing it

The cache is meant to be committed, which is why the key is derived from the contents rather than from the modification time. git does not restore mtimes, so a key based on them holds only on the machine that wrote it: a fresh clone, a CI checkout or a branch switch gives every source a new mtime, and the whole corpus is re-encoded and every entry rewritten — on every checkout. A hash survives that.

Hashing means each source is read once per run even when nothing has changed. Over 51MB of jpeg that measures about 29ms, against the 2.8s the encoding it avoids takes.

The file is written sorted and indented, so two runs that did the same work produce the same bytes and it only appears in a diff when something actually changed.

Deleting the cache file is safe. Convert rebuilds its outputs from the sources. Optimize re-encodes everything once, which for jpeg means a second lossy generation, so prefer keeping it.

Caches written by 1.x are honoured rather than discarded, and re-keyed the first time each entry is read.

The attr:img:size cache

Keyed on the markup file's contents, the rules and --force, and the dimensions of every image the file referenced — not on those images' contents. The only thing this command takes from an image is its intrinsic size; reading a header is cheaper than hashing the whole file, and an image that was re-encoded without changing shape has no effect on any page that mentions it, so it should not drag every template through a rescan.

A hit skips the lexing, the src resolution and the lookups, and replays the warnings the file produced, so the record reads the same on every run.

Nothing is written under --dry-run or --check: those runs change no files, so recording them as done would be a lie the next run would believe.

Output

One line per image, plus a closing summary, on stdout. Those lines are the record: they are never rewritten, so a pipe, a log file and a CI transcript hold exactly what the terminal showed. Failures go there too - what happened to each file belongs in one place, and the exit code already says whether the run went well.

The progress bar is a single line on stderr, overwritten in place, and only when stderr is a terminal. Redirect it and nothing is written at all.

lh-image-runner optimize:image 'src/img/**/*.png' > images.log

Exit codes

| Code | When | | ---- | ----------------------------------------------------------------- | | 0 | Every image was processed, or every failure was already on record | | 1 | This run found a new failure, or an option was invalid |

For attr:img:size, 1 means the config file was missing or wrong, a markup file could not be read or written, or --check found something still to size. A src that could not be resolved never fails the run.

For attr:img:alt, 1 means at least one <img> has no alt, or a markup file could not be read, or the config was invalid. Warnings - an empty alt, a duplicated one, a tag whose attributes are hidden behind a template - never fail the run.

For find:img:unused, 1 means --check found an unused image, a template could not be read, the config was invalid, or no resolve rule has a base. Unused images alone never fail a run without --check.

A failure already in the cache does not fail the run again, so a project carrying a broken image goes red once rather than forever. Fixing or removing the image, or touching it at all, brings it back for another attempt.

Writing

Files are written through a temporary sibling and renamed into place, so an interrupted write cannot leave a truncated image. Permissions are preserved, and a symlinked source is written through rather than replaced.