Image: Google Gemini

Automated WebP Conversion in a Roq Static Site

Roq does not currently optimize images. It copies whatever you put in public/ straight into target/roq/. There is an open Web Bundler issue about adding <img srcset> support, but no shipped solution today. For this site we wanted modern formats — concretely, WebP next to every PNG/JPG — and we did not want to fork the generator or bolt on an external Maven plugin.

It turns out you do not have to. Roq is a Quarkus app, and that gives you everything you need.

The Key Insight: RoqSelection

Roq exposes a CDI-producible type called RoqSelection. The generator collects every RoqSelection instance via @All List<RoqSelection> and treats each contained SelectedPath as a URL to fetch and write. Each path has an input (where Roq does an HTTP GET) and an output (where it lands inside target/roq/).

So the answer to "how does Roq know about the new files" is: we tell it. We produce one SelectedPath per source image with .webp as the output extension, and we wire up a small Vert.x route that does the actual encoding when Roq comes asking.

              ┌────────────────────────────────────┐
              │  WebpProducer @ApplicationScoped   │
   startup ──►│  scans public/, returns            │
              │  RoqSelection mapping              │
              │  /images/foo.webp on the URL       │
              │  to target/roq/images/foo.webp     │
              └────────────────────────────────────┘
                              │
                              ▼
              ┌────────────────────────────────────┐
              │  Vert.x route /*.webp              │
              │  reads original from classpath,    │
              │  encodes WebP, returns the bytes   │
              └────────────────────────────────────┘
                              │
                              ▼
        Roq HTTP-GETs each path, writes target/roq/images/foo.webp

The Producer

One CDI bean does both jobs: scan the classpath, register paths, install the route.

@ApplicationScoped
public class WebpProducer {

    @Inject WebpService webpService;

    @Produces @Singleton
    RoqSelection produce() {
        List<SelectedPath> paths = scan().stream()
            .map(rel -> {
                String webpPath = "/" + swapExtension(rel);
                return SelectedPath.builder()
                    .path(webpPath)
                    .outputPath(webpPath)
                    .build();
            })
            .toList();
        return new RoqSelection(paths);
    }

    void onStart(@Observes Router router) {
        router.routeWithRegex(".*\\.webp$").method(HttpMethod.GET).handler(ctx -> {
            String path = ctx.request().path();
            String base = path.substring(1, path.length() - ".webp".length());
            for (String ext : List.of(".png", ".jpg", ".jpeg")) {
                byte[] webp = webpService.convert("public/" + base + ext);
                if (webp != null) {
                    ctx.response()
                       .putHeader("Content-Type", "image/webp")
                       .end(Buffer.buffer(webp));
                    return;
                }
            }
            ctx.next();
        });
    }
}

The same regex route — .*\.webp$ — handles both the generator (which HTTP-GETs each registered SelectedPath to fetch the bytes) and the browser in dev mode (where the markup links to /logo.webp directly). The scan() method walks public/ on the classpath, handling both file and jar URLs.

The Encoder

For the actual WebP encoding we use io.github.darkxanter:webp-imageio:0.3.3 — a maintained fork of the original Luciad library that ships native binaries for, among others, Mac/aarch64. The original com.github.gotson fork still ships only Mac/x86_64 and fails on Apple Silicon with UnsatisfiedLinkError. Both expose the same com.luciad.imageio.webp package, so the switch is a one-line change in pom.xml.

@ApplicationScoped
public class WebpService {

    private final ConcurrentMap<String, byte[]> cache = new ConcurrentHashMap<>();

    public byte[] convert(String resourcePath) {
        return cache.computeIfAbsent(resourcePath, this::encode);
    }

    private byte[] encode(String resourcePath) {
        try (InputStream in = Thread.currentThread().getContextClassLoader()
                .getResourceAsStream(resourcePath)) {
            if (in == null) return null;
            BufferedImage source = ImageIO.read(in);
            ImageWriter writer = ImageIO.getImageWritersByMIMEType("image/webp").next();
            ByteArrayOutputStream baos = new ByteArrayOutputStream();
            try (var out = new MemoryCacheImageOutputStream(baos)) {
                writer.setOutput(out);
                writer.write(null, new IIOImage(source, null, null),
                             writer.getDefaultWriteParam());
            } finally {
                writer.dispose();
            }
            return baos.toByteArray();
        } catch (IOException e) {
            throw new RuntimeException("WebP encoding failed: " + resourcePath, e);
        }
    }
}

The ConcurrentHashMap cache keeps dev mode snappy — every page reload would otherwise re-encode the hero. In batch generation it does not matter much because each path is fetched once.

The Templates

Roq writing the .webp files to disk is only half the win. Browsers also have to know to ask for them. The standard answer is <picture> with a <source type="image/webp"> first and the original <img> as fallback. We wrapped that in a Qute user tag so the markup stays in one place:

{@java.lang.String src}
{@java.lang.String alt}
<picture>
  <source type="image/webp" srcset="{=src.webp}">
  <img src="{=src}" alt="{=alt ?: ''}">
</picture>

Used in any layout:

{#img src='/logo.png' alt='ITBH' /}

The .webp lookup is a small @TemplateExtension that swaps the extension:

@TemplateExtension
public class TemplateExtensions {
    static String webp(String src) {
        return src == null ? null : src.replaceAll("(?i)\\.(png|jpe?g)$", ".webp");
    }
    static String webp(RoqUrl url) {
        return url == null ? null : webp(url.toString());
    }
}

For CSS backgrounds — like the post hero, which is a background-image rather than an <img> — there is no <picture> equivalent, but image-set() does the same job:

<div class="post-hero" style="background-image: image-set(
       url('{page.image.webp}') type('image/webp'),
       url('{page.image}'))"></div>

Two Gotchas Worth Mentioning

Posts are not templates. A post is content for humans, written in Markdown. By default Roq still runs Qute over the body, which means a code block containing {src} blows up with "Key 'src' not found". The fix is one line in application.properties:

site.escaped-pages=posts/**

That keeps Qute rendering for layouts and frontmatter but tells it to leave the post body alone. We did not find this option until we went looking for it in the Roq frontmatter docs.

Named parameters in your own user tags need {=…} if quarkus.qute.alt-expr-syntax=true is set. Roq's sample projects ship with this flag on. Layouts like post.html still happily use {page.title} — that part keeps working. But inside your own templates/tags/img.html, an expression like {src} referencing a tag parameter renders as literal text; only {=src} resolves. Roq's bundled tags ship from a different template root and aren't affected. We confirmed this by switching the syntax back and forth and reading RoqFrontMatterStep1ScanProcessor.

Results

For our six source images — four post heroes, the homepage background, and the logo — total bytes drop from roughly 11 MB to 590 kB. Per-file savings:

File Original WebP Saved
Gemini hero (PNG) 7.4 MB 170 kB 98%
Post heroes (PNG) ~1 MB ~50 kB ~96%
Homepage bg (JPG) 340 kB 170 kB 50%
Logo (PNG) 420 kB 110 kB 75%

The originals stay on disk as a fallback. Browsers that support WebP — which today is all of them — pick the smaller variant automatically.

What This Cost

Around 230 lines of Java across three classes, one Qute user tag, one Maven dependency, one config line (site.escaped-pages=posts/** so post bodies aren't parsed for Qute expressions), and a handful of template edits to switch <img> and background-image over. No fork, no Maven plugin, no build-script dance. Standard Quarkus + Roq idioms throughout.

The pattern generalises: any time Roq's static output is "almost what you want", RoqSelection plus a Vert.x route is the lever to add or transform files without leaving the framework.