reading time : 10 minutes

You know that moment when your local site looks like nothing, but once deployed online everything is perfect? That’s what happened to me with my Gradle JBake plugin. The task`serve`displayed square buttons, dark text on a black background, and a CSS strangely missing. However`publishSite`It was generating the same site, which itself was flawless. The culprit was neither the CSS, nor the browser, nor the cache. It was a line of Kotlin code — and a bad habit with command‑line arguments.

tic

[]

The scene: four screenshots, two renders

It was after a system crash. I had taken four screenshots to compare the local rendering (./gradlew serve`on`localhost:8820) with the inline rendering (`publishSite`on GitHub Pages). Two screenshots per page: the top and the bottom.

Rendered locally

Top of page— The buttons Project and Template aresmall, rectangular, standard colors (blue and green). The card Start Writing has aplain black background, without frame.

Rendu local - haut de page

Footer— The subtitle "Derniers articles et ressources" isalmost illegible, too dark on black background. The dates under the blog images ("17 October 2013", etc.) areabsent.

Rendu local - bas de page

Online rendering

Top of page— The same buttons arelarge, ovaux, flashy neon green, text in uppercase. The card has arounded white frame with drop shadow.

Rendu en ligne - haut de page

Bottom of page— The subtitle iswell readable. The dates arevisible. The item cards haverounded cornersand the titles arein bold.

Rendu en ligne - bas de page

First reflex: the theme. I use a dynamic theme system (light, dark, high-contrast) based on`localStorage`. Maybe the local store triggered a high-contrast theme? No. I had already writtenhttps://cheroliv.com/blog/2025/0098_preloading_css_variable_post.html[an entire article on the preloading of themes], I know how it works. And then, even in high-contrast mode, the CSS structure (oval buttons, shadows) should have been there. It wasn’t there.

Second reflex: the browser cache. No, I had run the test with clean profiles. The diff persisted.

Third reflex:`serve`was not serving the same files as`publishSite`.

The diagnosis: serve was not pointing to the correct folder

My Gradle plugin`bakery`has two main tasks:

  • bake: generates the static site in`build/bake/`

  • serve: launches a local web server

  • publishSite: push`build/bake/`to GitHub Pages

The divergence could only come from`serve`. Si publishSite`was publishing the correct content, it’s that`build/bake/`was good. If`serve`what it was showing, however, was that it wasn’t useful`build/bake/.

Let’s look at the code. In`SiteManager.kt`, the task`serve`is defined as follows:

SiteManager.kt — La tâche serve (version buggy)
tasks.register("serve", JavaExec::class.java) { task ->
    task.apply {
        mainClass.set("org.jbake.launcher.Main")
        classpath = jbakeRuntime
        environment("GEM_PATH", jbakeRuntime.asPath)
        jvmArgs(/* ... */)
        args = listOf(
            "-b", file(site.bake.srcPath).absolutePath,
            "-s", layout.buildDirectory.get()
                .asFile.resolve(site.bake.destDirPath)
                .absolutePath
        )
    }
}

org.jbake.launcher.Main`is the entry point of the JBake 2.7.0 CLI. The idea: to pass-b`for the source folder and`-s`for the destination folder, then start the server mode. But…​

Root cause: JBake CLI does not work like that

JBake 2.7.0 is waiting forpositional argumentsfor source and destination, then optional flags. The signature is:

jbake <source> <destination> [options]

However, in my code, I was writing :

args = listOf("-b", "/path/to/site", "-s", "/path/to/build/bake")

JBake interpreted it as:

  1. -b→ parse the "bake" flag (boolean), consumed

  2. /path/to/site→ becomes positional argument 1 (source)

  3. -s→ parse the "serve" flag (boolean), server startedimmediately

  4. /path/to/build/bake→ becomes an orphan argument, ignored or misinterpreted

Result: Result: JBake was taking

</think>

Result: JBake was taking`/path/to/site`as a source,did not take into account the destinationprovided (build/bake), and used its default directory (often`./output`or a temporary copy). The file`css/styles.css`the build was therefore overwritten or ignored, and it was an old default CSS (without custom variables, without rounded corners, without shadows) that was served.

In comparison,bake et publishSite`use theOfficial Gradle JBake plugin( </think> (`jbake-gradle-plugin) who writes neatly in`build/bake/`via the Gradle API. They do not pass through the CLI.

The solution: positional arguments, not flags

The fix is trivial — once you know. All you have to do is pass source and destination as positional arguments, then`-s`finally :

SiteManager.kt — La tâche serve (version corrigée)
args = listOf(
    file(site.bake.srcPath).absolutePath,
    layout.buildDirectory.get()
        .asFile.resolve(site.bake.destDirPath)
        .absolutePath,
    "-s"
)

That’s it. Three lines changed, bug fixed.

After local publication of the plugin (publishToMavenLocal), un `./gradlew serve`Launch JBake with the correct syntax :

jbake /home/user/project/site /home/user/project/build/bake -s

And this time, the Jetty server embedded in JBake serves the content of`build/bake/`— identical to what is published online.

@startuml
!define RECTANGLE class

package "BEFORE (bug)" {
  RECTANGLE JBakeCLI1 {
    + args = ["-b", "site", "-s", "build/bake"]
  }

  RECTANGLE Source1 {
    + site/
  }

  RECTANGLE Dest1 {
    + ??? (dossier temporaire)
  }

  RECTANGLE Jetty1 {
    + sert mauvais fichiers
  }

  JBakeCLI1 --> Source1 : lit
  JBakeCLI1 -[#red]x Dest1 : destination ignorée
  Jetty1 --> Dest1 : sert
}

package "AFTER (fix)" {
  RECTANGLE JBakeCLI2 {
    + args = ["site", "build/bake", "-s"]
  }

  RECTANGLE Source2 {
    + site/
  }

  RECTANGLE Dest2 {
    + build/bake/
  }

  RECTANGLE Jetty2 {
    + sert les bons fichiers
  }

  JBakeCLI2 --> Source2 : lit
  JBakeCLI2 --> Dest2 : écrit dans
  Jetty2 --> Dest2 : sert OK
}
@enduml

Quick check with curl

To make sure the server serves the right content, a simple`curl`confirms that`index.html`contains`data-bs-theme="light"`and that`build/bake/css/styles.css`weighs exactly 31,402 bytes — identical to the file generated by`bake`.

$ ./gradlew serve
# Dans un autre terminal :
$ curl -s http://localhost:8820/ | grep data-bs-theme
<html ... data-bs-theme="light" ...>

$ curl -I http://localhost:8820/css/styles.css
HTTP/1.1 200 OK
Content-Length: 31402
Content-Type: text/css

The local rendering is nowpixel-identicalto the deployed rendering.

Why this bug was vicious

You see why it was hard to catch?

  1. No explicit errorJBake wasn’t crashing. It 'worked', simply with a bad folder.

  2. The build was working:`./gradlew bake`was correctly generating the files in`build/bake/`.

  3. The deployment was working:`publishSite`was pushing the right content online

  4. Only serve was broken: the development task, the one we constantly use to iterate.

  5. The habit of -key valueAs a developer, we are conditioned by years of Unix CLI-o output`, -i input). JBake CLI is an exception — source and destination are positional.

Conclusion

If you maintain a Gradle plugin that wraps JBake (or any CLI tool),read the arguments' doceven if you think you know them. An implicit assumption (-s= "set destination") can cost you hours of visual debugging.

Here, the fix was literally to change:

args = listOf("-b", src, "-s", dest)   // ❌ BUG

en :

args = listOf(src, dest, "-s")         // ✅ FIX

Three tokens moved, and my local site is once again as beautiful as the production site.

Lesson: when the rendering differs between local and prod without obvious reason, first suspect the pipeline — not the CSS, not the browser, and even less the framework. It’s often the step just before rendering that cheats.

Related articles