@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
Maintainers
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-runnerThe 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.jpgA 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:unusedEvery 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 namedIts 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.jpgkeepshero.jpg.webpandhero.webp; a referencedhero.jpg.webpkeepshero.jpg, whichconvert:imageneeds to regenerate it. A hand-madelogo.jpgbeside a referencedlogo.pngis kept too, since nothing distinguishes it fromconvert:image's-f '[name]'output. A--formatthat 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 aurl(../img/bg.png)counts.attr:img:sizeandattr:img:altfind 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.
--verboselists 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<?phpand<?=, an optionalecho, 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 %>/xand<?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, withtoas 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.logExit 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.
