Skip to content

File format

A built deck is one HTML5 file. Everything it needs to render is inside it: the JavaScript, the CSS, the fonts, and the slide assets. Nothing loads from disk or over the network when the page opens. This page describes how that file is laid out so you can inspect it, diff it, or read its embedded data from your own tooling.

For how to produce the file and where to put it, see Building & deploying. This page is the format itself.

The file is a complete HTML document. Open it and you’ll find roughly this shape:

<!doctype html>
<!-- Generated by liebstoeckel engine 0.3.4, cli 0.3.6 · https://liebstoeckel.app -->
<html>
<head>
<meta charset="utf-8" />
<meta name="generator" content="liebstoeckel engine 0.3.4, cli 0.3.6" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Your deck title</title>
<style>/* compiled Tailwind + brand variables + code/atmosphere styles */</style>
</head>
<body data-brand="your-brand">
<div id="root"></div>
<script type="module">/* the whole client bundle */</script>
<!-- inert metadata blocks, described below -->
</body>
</html>

A few things are worth calling out:

  • One module script holds the whole app. The engine, your slides, and any plugins’ client code are bundled and minified into a single <script type="module">. The browser runs this; nothing else loads.
  • CSS lives in <style>. The compiled Tailwind output, your brand variables, and the code and atmosphere styles are all inline. Highlighted code is pre-tokenized at build time, so there is no syntax highlighter running in the page.
  • Fonts and images are data URIs. Font files become base64 url() values inside the CSS. Imported image assets are inlined the same way. Base64 makes the file larger than its parts, which is the cost of having no external requests.
  • The active brand is on <body>. Brand tokens render as [data-brand] CSS, and data-brand on the body selects which one applies.

Every build stamps its own provenance into the head, so you can tell what produced a file without opening it in a player. There are two copies of the same information:

  • A comment right after the doctype, for a human reading “view source”.
  • A standard <meta name="generator"> in <head>, for tooling that wants to read the version programmatically.
<!-- Generated by liebstoeckel engine 0.3.4, cli 0.3.6 · https://liebstoeckel.app -->
<meta name="generator" content="liebstoeckel engine 0.3.4, cli 0.3.6" />

The engine version is the runtime that renders the deck; the second tool is whatever drove the build (the cli here). A deck built through its own build.ts rather than the CLI records the engine version on its own.

The stamp carries versions only, never a build timestamp. That keeps the file byte-deterministic: the same deck built twice with the same toolchain produces the same bytes, which matters because the embedded source package is itself deterministic.

Below the module script, a build can embed up to four inert blocks. Each is a <script> the browser never executes, keyed by a data-liebstoeckel-* attribute so tools can find it. They sit before the final </body>.

AttributetypeContentsWhen present
data-liebstoeckel-pluginsapplication/jsonPlugin manifestDeck declares plugins
data-liebstoeckel-licensestext/plainThird-party license noticesDefault; off with --no-inline-licenses
data-liebstoeckel-sourceapplication/octet-streamThe deck’s source, compressedDefault; off with --no-inline-package
data-liebstoeckel-thumbnailsapplication/jsonSlide thumbnail manifestA Chromium was available at build time

The JSON blocks carry a "v" version field so the shape can change without breaking older readers.

Present only when the deck uses plugins. It lists each plugin by name and version, and for plugins with a server it carries the server bundle as base64-encoded ESM:

{
"v": 1,
"plugins": [
{ "name": "@liebstoeckel/plugin-poll", "version": "0.1.0", "hasServer": false },
{
"name": "@acme/quiz",
"version": "1.0.0",
"hasServer": true,
"id": "quiz",
"server": "<base64 ESM>",
"audienceWrites": ["answer"]
}
]
}

The browser never decodes the server field. A live server reads the manifest, runs the server bundles, and uses audienceWrites to decide what the audience may write. Offline, none of this runs and each plugin renders its fallback instead.

A plain-text block of third-party attribution, starting with a THIRD-PARTY SOFTWARE NOTICES header. It lists the MPL-2.0 engine, the liebstoeckel packages in the bundle, and every third-party library and font, with each one’s license text. Minification strips license comments out of the code, so the build collects the attribution and puts it back here. It is computed from what this build actually bundled, so swapping a font or adding a library updates it.

Read it or gate on it with liebstoeckel licenses. Disable the embed with build --no-inline-licenses (not recommended, since the notices are how you stay compliant with the bundled licenses).

By default the build embeds the deck’s own source so the .html can be turned back into an editable project. It is the deck packed with bun pm pack, recompressed, and base64-encoded. The data-codec attribute records the compression so the format can change later:

<script type="application/octet-stream" data-liebstoeckel-source data-codec="zstd">
<!-- base64 of a zstd-compressed tar of the deck -->
</script>

The pack respects your package.json files allowlist (or .npmignore / .gitignore), and never includes node_modules, dist, or .git. A build-time secret gate refuses to embed a .env or any file matching a credential signature, so this block should not leak secrets. For a smaller, opaque file with no recoverable source, build with --no-inline-package.

Turn the file back into a project with liebstoeckel eject:

Terminal window
liebstoeckel eject my-talk.html ./my-talk

If a headless Chromium was available at build time, the file carries one small WebP image per slide as a data URI, used to draw the overview grid without rendering every slide live:

{
"v": 1,
"w": 640,
"h": 360,
"thumbs": { "0": "data:image/webp;base64,...", "1": "data:image/webp;base64,..." }
}

Thumbnails are optional. The build skips them, with a note rather than an error, when no Chromium is found or when you set LIEBSTOECKEL_NO_THUMBS=1. Without the manifest the overview falls back to rendering slides directly. See Thumbnails.

The file does not contain a relay URL or a session token, and it makes no network requests on its own. Opened from disk or a static host, it runs entirely offline: plugins show their fallback, and the presenter view works through the browser’s BroadcastChannel.

The same file goes live without rebuilding. When you serve it with liebstoeckel live, the server injects a small bootstrap script ahead of the bundle. That script connects to a relay over a Yjs document, and from then on plugins instantiate their server bundles and sync state across the room. Because the live wiring is injected at serve time and not baked into the file, the exact same .html you email still opens standalone.

Comments

liebstoeckel

Code-first presentations your agent can author. One file out, no server.

© 2026 Leon Kaucher