@bongo/bongo 0.0.0-stage → 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/readme.md ADDED
@@ -0,0 +1,943 @@
1
+ # bongo
2
+
3
+ > A static site generator built on Rolldown and LightningCSS.
4
+
5
+ Published as `@bongo/bongo`, but projects should install and reference it through the `bongo` alias:
6
+
7
+ ```sh
8
+ bun add bongo@npm:@bongo/bongo
9
+ ```
10
+
11
+ All examples below use `bongo`.
12
+
13
+ ## Overview
14
+
15
+ `bongo` processes content files through a loader pipeline, applies layouts and partials with [tempura](https://github.com/lukeed/tempura), and bundles client assets with Rolldown for JavaScript and LightningCSS for CSS.
16
+
17
+ ## Usage
18
+
19
+ ```sh
20
+ bongo build
21
+ bongo dev
22
+ ```
23
+
24
+ CLI options:
25
+
26
+ ```sh
27
+ bongo build -C site --minify
28
+ bongo dev -C site --host --port 3000
29
+ bongo dev --write
30
+ bongo build --debug
31
+ bongo build --fresh
32
+ bongo build --drafts
33
+ ```
34
+
35
+ An unknown option, or a `--port` that is not a port number, stops the command with an error. A bare `--host`, with no value, exposes the dev server on your network.
36
+
37
+ When it is done, `bongo build` prints the size of each file in `public/`, and its gzip size. Above 50 files, it prints the total size of each kind of file instead, such as `.html` or `.png`, and lists only the scripts and stylesheets, with their gzip sizes.
38
+
39
+ `--debug` logs each stage of a build, such as `loading the config and data/` or `bundling assets`, and how long it took. Without it, a stage that is still running after 15 seconds prints `still <stage>` every 15 seconds, so a build that waits on something, like a top-level `await` in your config, says what it waits on.
40
+
41
+ ### Output folder
42
+
43
+ `bongo build` writes over the files that an earlier run left in `public/`, which is much faster than making them again. When it is done, it deletes each file of the last build that it did not write, so no file from an earlier run stays. The record of the last build lists those files, so `bongo build` does not list `public/`. Thus a file that something else put in `public/` stays, until a build without the record, such as `bongo build --fresh`, lists the folder and deletes it. When the build fails, it empties `public/` and deletes the record, so no part of the failed build stays.
44
+
45
+ `bongo dev` does not use `public/`. It keeps its output in memory and serves it from there, so a `bongo build` that runs at the same time never changes what dev serves, and dev never changes what the build writes. With `--write`, `bongo dev` also writes its output to `public/`: it empties the folder before the first build, and each rebuild then deletes only the files that the new build no longer writes.
46
+
47
+ bongo never follows a symlink in `public/`. It deletes a symlink that a write or a deletion goes through, and a build that lists the folder deletes each symlink in it. It never deletes the file or folder that a symlink points to. It refuses to empty or write over `public/`, and the build fails, when the folder resolves through symlinks to:
48
+
49
+ - the project root, or a folder above it,
50
+ - a folder outside the project,
51
+ - `assets/`, `content/`, `data/`, `layouts/`, or `partials/`, or a folder in one of them or that holds one of them,
52
+ - the home folder, a folder above it, or `/`.
53
+
54
+ ### Incremental builds
55
+
56
+ A build renders only the pages that a change reaches; the other pages keep their files from the last build. `bongo build` keeps a record of its last good build in `node_modules/.cache/bongo/memo.json`, and `bongo dev` keeps it in memory. Each build reads and hashes only the source files whose size or mtime changed since the record, and compares those hashes with it; a file that changed less than 2 seconds before a build is read again by the next. A page renders again when its source changes; when it reads `$pages`, `$tags`, or `$section` through its source, layouts, or partials, and a page in that list changes its path, title, summary, section, tags, headings, or frontmatter; when a file that it reads with `$assets.load` changes; when an asset that it links changes, or a module or stylesheet that the asset imports; when the config has `$hooks.transform` and an output file is added, deleted, or changes its frontmatter; or when its file in `public/` changed or is gone.
57
+
58
+ Every page renders again when the config changes, or a local file that it imports, `data/`, `layouts/`, `partials/`, the lockfile, the version of bongo, the runtime, `--minify`, or `--drafts`. Output files and data modules render on every build. `bongo dev` names each script and stylesheet by its entry, not by its content, so an edit to one renders only the pages that inline it or use its CSS module classes.
59
+
60
+ bongo cannot see a change that keeps the size and the mtime of a source file, as `cp -p` or `rsync -t` can make, or what a template, a hook, or a loader reads by itself, such as the time, an environment variable, or a file that it opens. Use `--fresh` to render every page for such a build. A build that fails empties `public/`, so the next build renders every page.
61
+
62
+ ### Minify
63
+
64
+ `--minify` minifies every JavaScript bundle with Rolldown and every stylesheet with LightningCSS, including inline ones. It also minifies the HTML pages with [tdewolff/minify](https://github.com/tdewolff/minify) when its `minify` CLI is on your `$PATH`. If the CLI is missing, the build still succeeds: it prints one notice and leaves the HTML as it is. Without `--minify`, nothing is minified.
65
+
66
+ ## How It Works
67
+
68
+ When you run `bongo build`:
69
+
70
+ 1. `bongo build` keeps the files of the last build in `public/` to write over them. The record of the last build lists them; without it, bongo lists the folder and deletes each symlink in it. Config is loaded from `bongo.config.{ts,mjs,cjs,js}` in the project folder: `cwd`, or the folder that `-C` names. Rolldown bundles the config and the local files it imports into one temporary `.mjs` module, which bongo imports and then deletes. This works the same in Bun, Deno, and Node. Packages stay external and resolve as usual. A package linked into `node_modules` from outside it, such as a workspace package, is local code: it is bundled too, and the packages it imports resolve from its own folder. `import.meta.url`, `import.meta.dirname`, and `__dirname` keep pointing at each source file. TypeScript partials, `data/` modules, and [data modules](#data-modules) load the same way.
71
+ 2. Files in `data/` are loaded as `$data`. Layouts and partials are collected from `layouts/` and `partials/`. Handlebars files (`.hbs`) are compiled with tempura. JavaScript and TypeScript partials are loaded as modules.
72
+ 3. Content files are passed through registered loaders, then rendered through their [layouts](#layouts). A page that no change reached since the last build keeps its file and skips its loaders; see [Incremental builds](#incremental-builds). [Output files](#output-files), such as `sitemap.xml.hbs`, get no layout. [Data modules](#data-modules), such as `search.json.ts`, run once every page is known, and write JSON.
73
+ 4. Rendered HTML is scanned for `<bongo:*>` elements, and each page's content for `<img>` elements with a relative `src`. Assets are registered and replaced with placeholder tokens.
74
+ 5. Asset groups are flushed. JavaScript goes through Rolldown; CSS goes through LightningCSS; images go through `Bun.Image`. Output filenames include content hashes.
75
+ 6. Placeholders are substituted with external asset URLs or inline asset contents.
76
+ 7. Pages are written to `public/`, or kept in memory by `bongo dev`. So that a large site does not fill the memory, `bongo build` writes each page to `public/` as soon as it renders, then reads it again for steps 6 and 7. Last, it deletes each file of the last build that this build did not write. With `--minify`, they are then minified when the `minify` CLI is available.
77
+
78
+ ## Config
79
+
80
+ ```ts
81
+ import { markdown } from '@bongo/markdown';
82
+ import { shiki } from '@bongo/markdown/highlight';
83
+
84
+ export default {
85
+ $site: {
86
+ title: 'My Site',
87
+ url: 'https://example.com/',
88
+ lang: 'en',
89
+ },
90
+
91
+ $utils: {
92
+ excerpt(text: string, len: number) {
93
+ return text.slice(0, len);
94
+ },
95
+ },
96
+
97
+ plugins: [
98
+ markdown({
99
+ highlighter: shiki({
100
+ theme: 'github-dark',
101
+ langs: ['js', 'ts', 'css', 'bash'],
102
+ }),
103
+ }),
104
+ ],
105
+ };
106
+ ```
107
+
108
+ Config fields:
109
+
110
+ - `$site` — site metadata; available in templates as `{{$site.title}}`. The `url` field also drives `publicPath`.
111
+ - `$loaders` — loader map keyed by file extension; see [Loaders](#loaders).
112
+ - `$utils` — helper functions available in templates; see [Utils](#utils).
113
+ - `$partials` — programmatic partial functions.
114
+ - `$images` — image defaults; see [Images](#images).
115
+ - `$build` — build options; see [Build targets](#build-targets).
116
+ - `$hooks` — functions that change the build output; see [Hooks](#hooks).
117
+ - `plugins` — array of partial config objects, merged in order.
118
+
119
+ `$site` fields are `url`, `title`, `lang`, and the optional `description` and `menu`. `menu` maps names to `{ name, href }` links. bongo itself reads only `url`; templates get every field as it is, and `@bongo/feed` reads `title` and `lang` too.
120
+
121
+ To type-check a config, write `export default {…} satisfies Config`. `define(config)` from `bongo` does the same in a JavaScript config: it returns the config as it is, typed as `Config`.
122
+
123
+ ```js
124
+ // bongo.config.js
125
+ import { define } from 'bongo';
126
+
127
+ export default define({
128
+ $site: { title: 'My Site', url: 'https://example.com/', lang: 'en' },
129
+ });
130
+ ```
131
+
132
+ ### Utils
133
+
134
+ bongo gives `$utils` these helpers. Layouts, partials, `.hbs` content, and [data modules](#data-modules) can call them:
135
+
136
+ | Helper | Returns |
137
+ | --- | --- |
138
+ | `truncate(text, length)` | The first `length` characters of `text`, with no ellipsis. An empty `text` gives `''`. |
139
+ | `excerpt(html, length)` | The plain text of `html`, cut to at most `length` characters at the end of a word, with `…` when it cuts. It removes tags, comments, scripts, and styles, decodes entities such as `&amp;`, and collapses whitespace. A block tag, such as `<p>` or `<br>`, counts as a space. A first word longer than `length` is cut. `excerpt('<p>Hello <b>big</b> world</p>', 12)` is `Hello big…`. |
140
+ | `countwords(text)` | The number of words in `text`, split on whitespace. An empty `text` gives `0`. |
141
+ | `minutes(text, wpm?)` | The reading time of `text` in whole minutes, rounded up, at `wpm` words a minute (default `200`). It counts the words of the plain text, as `excerpt` makes it, so `$page.content` works. Text with no words gives `0`. |
142
+ | `slug(text)` | A URL or anchor slug: `text` in lower case, with punctuation, symbols, and emoji removed, and each space as `-`. Letters and digits of every language stay. It is the id that `@bongo/markdown` gives a heading with `headingIds: true`, so `slug(heading.text)` equals `heading.id`, except that `slug` does not add `-1` to a repeated slug. `slug('Hello, world!')` is `hello-world`. |
143
+ | `url.abs(to, from?)` | The full URL of `to`, resolved against `from`, or against `$site.url` when there is no `from`. For `https://example.com/blog/`, `url.abs('post/')` is `https://example.com/blog/post/`, and `url.abs('/about/')` is `https://example.com/about/`. |
144
+ | `url.path(to, from?)` | The URL path of `to`, with its query and hash, resolved as `url.abs` resolves it. For `https://example.com/blog/`, `url.path('docs/')` is `/blog/docs/`, and `url.path('/about/')` is `/about/`. A path that already starts with `/`, such as `$page.path`, stays as it is, so links work on a site under a subpath. |
145
+ | `url.ext(path, ext)` | The path of a page plus an extension, as `<bongo:alternate>` names its Markdown copy: `url.ext('/config/', 'md')` is `/config.md`, and `url.ext('/', 'md')` is `/index.md`. |
146
+ | `date.format(value, options?)` | `value` as an ISO 8601 string, such as `2024-05-01T00:00:00.000Z`. With [`Intl.DateTimeFormat`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/DateTimeFormat#options) options, the date in the language of `$site.lang`, in UTC unless you set `timeZone`: `date.format('2024-05-01', { dateStyle: 'long' })` is `May 1, 2024`. An invalid date fails the build. |
147
+ | `json(value)` | `value` as JSON that is safe inside a `<script>` element: it escapes `<`, `>`, `&`, U+2028, and U+2029 as `\u003c` and so on, so text such as `</script>` cannot end the element. Print it raw, with `{{{ }}}`: `<script type="application/ld+json">{{{ $utils.json(data) }}}</script>`. `undefined` gives `null`. |
148
+
149
+ A function in your `$utils` replaces the built-in one with the same name. The `url` and `date` groups merge with yours, so `$utils: { date: { year } }` keeps `date.format`. You can add your own groups too, such as `$utils: { str: { up } }`:
150
+
151
+ ```ts
152
+ export default {
153
+ $utils: {
154
+ truncate(text: string, length: number) {
155
+ return text.length > length ? text.slice(0, length) + '…' : text;
156
+ },
157
+ date: {
158
+ year: (value: string) => new Date(value).getUTCFullYear(),
159
+ },
160
+ },
161
+ };
162
+ ```
163
+
164
+ ### Build targets
165
+
166
+ `$build.target` names the browsers that your site supports, as a [browserslist](https://browsersl.ist) query or a list of queries. The default is `'defaults'`: `> 0.5%, last 2 versions, Firefox ESR, not dead`.
167
+
168
+ ```ts
169
+ export default {
170
+ $build: {
171
+ target: ['chrome >= 120', 'safari >= 17', 'firefox >= 121'],
172
+ },
173
+ };
174
+ ```
175
+
176
+ - LightningCSS uses it for each `<bongo:style>` and the CSS that it imports. It adds vendor prefixes and lowers newer syntax, such as nesting and `inset`.
177
+ - Rolldown uses it for each `<bongo:script>` and for `<bongo:client>`. It lowers newer JavaScript syntax, such as `?.` and `??`, and adds no polyfills. Rolldown targets Chrome, Edge, Firefox, Internet Explorer, Opera, Safari, and iOS Safari, so bongo drops the other browsers of the query, such as Android and Samsung.
178
+ - `<bongo:script target="...">` replaces the target for that one script.
179
+ - bongo does not read `.browserslistrc` or the `browserslist` field of `package.json`.
180
+
181
+ Run `bunx browserslist '<query>'` to see the browsers of a query.
182
+
183
+ ### JavaScript API
184
+
185
+ `build(options)` from `bongo` runs one build, as `bongo build` does, and resolves when the site is written:
186
+
187
+ ```ts
188
+ import { build } from 'bongo';
189
+
190
+ await build({ cwd: 'site', minify: true });
191
+ ```
192
+
193
+ - `cwd` — the project folder. Required.
194
+ - `minify` — the same as `--minify`.
195
+ - `debug` — the same as `--debug`.
196
+ - `fresh` — the same as `--fresh`.
197
+ - `drafts` — the same as `--drafts`.
198
+
199
+ `dev(options)` starts the dev server, as `bongo dev` does. It resolves after the first build, and the server keeps running. It takes `cwd`, `debug`, `drafts`, `fresh`, `port`, `host`, and `write`, the same as the `bongo dev` options:
200
+
201
+ ```ts
202
+ import { dev } from 'bongo';
203
+
204
+ await dev({ cwd: 'site', port: 3000 });
205
+ ```
206
+
207
+ ## Frontmatter
208
+
209
+ A content file can start with a YAML block between two `---` lines. Its values become `$page.params`.
210
+
211
+ ```yaml
212
+ ---
213
+ title: Hello, world
214
+ date: 2024-05-01
215
+ draft: false
216
+ weight: 3
217
+ tags: [js, css]
218
+ authors:
219
+ - name: Luke
220
+ url: https://lukeed.com
221
+ cover:
222
+ src: hero.webp
223
+ alt: "Issue #1"
224
+ ---
225
+ ```
226
+
227
+ bongo reads a small subset of YAML:
228
+
229
+ - Values are typed. `42` and `-1.5` are numbers, `true` and `false` are booleans, and `null`, `~`, and an empty value are `null`. Everything else is a string.
230
+ - Dates stay strings, as written. Text like `1.0.0`, and numbers with a leading zero like `007`, also stay strings.
231
+ - A string can be plain, `'single-quoted'` (write `''` for a quote), or `"double-quoted"` with backslash escapes like `\n` and `\"`. A quoted value is always a string.
232
+ - Lists are inline, `[a, b]`, or one `- item` per line. Maps are inline, `{a: 1}`, or nested by indentation. Indent with spaces, not tabs.
233
+ - `#` after a space starts a comment.
234
+ - A plain value runs from the first `: ` to the end of the line, so `summary: Install with: brew` works. A value that only starts like a quoted string, list, or map, such as `[WIP] Notes`, is plain text.
235
+ - Not supported: multi-line strings (`|`, `>`, or a value that continues on the next line), anchors, aliases, and tags. Inline lists and maps must fit on one line.
236
+
237
+ An error names the file and the line, for example `content/blog/post.md: Invalid frontmatter: duplicate key "title" at line 3`.
238
+
239
+ `$page.title` and `$page.summary` are always text, so `title: 2024` gives `"2024"`. `$page.tags` takes a list or a comma-separated string.
240
+
241
+ A page is a draft when it has `draft: true`, or a `date` after the build starts, unless it has `draft: false`. `bongo build` leaves drafts out: no file, and no entry in `$pages`, `$section`, or `$tags`, so sitemaps, feeds, and data modules never see them. `bongo build --drafts` builds them, and `bongo dev` builds them unless `--no-drafts`. A `date` with no time is 00:00 UTC, and a time with no offset is in UTC; a `date` that is not an ISO date schedules nothing. A page whose `date` passes joins the first build after that moment, so run a build then, for example from a scheduled job in CI. `$page.draft` is `true` for a draft.
242
+
243
+ `url` sets a custom path for a page. `url: /about/` writes `about/index.html`. A path that ends in `.html`, such as `url: /404.html`, writes that file.
244
+
245
+ ### Migrate from string-only frontmatter
246
+
247
+ Older versions of bongo read every value as a string and split each line on its first `:`. To update a site:
248
+
249
+ - `hidden: true` is now the boolean `true`, not `'true'`. Compare with `=== true`.
250
+ - `stars: 120` is now the number `120`. In templates, `+` now adds instead of joins.
251
+ - `forks: 0` is now the number `0`, which `{{#if $page.params.forks}}` treats as false. To show zeros, test `{{#if $page.params.forks != null}}`.
252
+ - `version: 1.0` is now the number `1`. Quote values that must stay text: `version: "1.0"`.
253
+ - `title: Issue #42` is now `Issue`, because ` #` starts a comment. Quote it: `title: "Issue #42"`.
254
+ - `note:` with no value is now `null`, not `''`.
255
+ - A line that is not `key: value` is now an error, and so is a duplicate key.
256
+ - `key:` followed by indented lines is now a nested map or list.
257
+
258
+ ## Data
259
+
260
+ Put JSON, YAML, or module files in `data/` to use them in every template as `$data`. A file's key is its path without the extension:
261
+
262
+ | File | Template value |
263
+ | --- | --- |
264
+ | `data/site.yaml` | `$data.site` |
265
+ | `data/oss/history.json` | `$data.oss.history` |
266
+ | `data/stats.ts` | `$data.stats`, the module's default export |
267
+
268
+ ```hbs
269
+ <ul>
270
+ {{#each $data.oss.history as row}}
271
+ <li>{{ row[0] }}: {{ row[1] }}</li>
272
+ {{/each}}
273
+ </ul>
274
+ ```
275
+
276
+ - YAML files use the same subset as [Frontmatter](#frontmatter).
277
+ - A `.ts`, `.mts`, `.js`, `.mjs`, or `.cjs` file must have a default export. It can use top-level `await`, for example to fetch data at build time.
278
+ - Use brackets for a key that is not a valid name: `$data.oss['arr-filter']`.
279
+ - Data from JSON and YAML files is read-only. Copy it before you sort it: `$data.posts.toSorted(...)`.
280
+ - bongo skips files and folders whose names start with `.`. Any other file type fails the build. Two files with the same key, such as `data/a.json` and `data/a.yaml`, also fail the build.
281
+
282
+ `$data` is available in layouts, partials, and `.hbs` content. Helpers in `$utils` do not get it automatically. Give them the value they need: `{{ $utils.total($data.oss.history) }}`.
283
+
284
+ ## Loaders
285
+
286
+ Loaders transform content files by file extension. Register them with `$loaders`:
287
+
288
+ ```ts
289
+ export const $loaders = {
290
+ md(filename, content, utils) {
291
+ return {
292
+ filename: filename + '.html',
293
+ content: renderedHtml,
294
+ metadata: { title: '...' },
295
+ headings: [{ level: 2, id: 'install', text: 'Install' }],
296
+ };
297
+ },
298
+ };
299
+ ```
300
+
301
+ > **Note:** You can use `@bongo/markdown` for a ready-made solution.
302
+
303
+ Loaders are chained by extension. A `.md` file produces `.md.html`, the `.html` extension stops the chain and enters the template pipeline.
304
+
305
+ A loader result has:
306
+
307
+ - `filename` — the new file name. Its extension picks the next loader.
308
+ - `content` — the new content.
309
+ - `metadata` — values that become `$page.params`, as frontmatter does.
310
+ - `headings` — the headings of the HTML, in document order, as `{ level, id, text, html? }`. They become `$page.headings`. Without `html`, bongo uses the escaped `text`. When more than one loader in a chain gives headings, the last one wins.
311
+
312
+ Loader utilities:
313
+
314
+ - `utils.decode(uint8array)` — convert a buffer to a string.
315
+ - `utils.render(partialName, args)` — invoke a registered partial.
316
+ - `utils.attrs(input)` — parse element attributes, like `title="Hi" count=2 tags=[a, b] featured`, into data. Values are never evaluated. `@bongo/markdown` uses it for `<bongo:*>` partial elements.
317
+ - `utils.frontmatter(content)` — parse the `---` block at the top of `content`, as described in [Frontmatter](#frontmatter). Returns `{ offset, value }`, where `offset` is where the body starts.
318
+
319
+ ## Hooks
320
+
321
+ `$hooks.transform(text, ctx)` changes each page and each [output file](#output-files) after it renders and before bongo writes it. Pages arrive with their final asset URLs. Return the new text, or a promise of it.
322
+
323
+ ```ts
324
+ export default {
325
+ $hooks: {
326
+ transform(text, ctx) {
327
+ return ctx.kind === 'page' ? text.replace('{year}', String(new Date().getFullYear())) : text;
328
+ },
329
+ },
330
+ };
331
+ ```
332
+
333
+ `ctx` has:
334
+
335
+ - `kind` — `'page'` or `'output'`.
336
+ - `path` — the URL path, such as `/blog/post/` or `/blog/index.xml`.
337
+ - `params` — the frontmatter.
338
+ - `outputs` — every output file of the build, as `{ path, params }`, sorted by path.
339
+ - `config` — the config, with its plugins merged.
340
+
341
+ When plugins and the config each set `transform`, all of them run: first the plugins, in order, then the config's own. Each one gets the text that the one before it returned. If one throws, the build fails, and the error names the content file and the layouts that rendered it.
342
+
343
+ ## publicPath
344
+
345
+ The `publicPath` prefix applied to external asset URLs is derived from `$site.url`:
346
+
347
+ - Root-served site (`https://example.com`) -> `publicPath = /`
348
+ - Subpath-served site (`https://example.com/blog/`) -> `publicPath = /blog/`
349
+
350
+ No manual configuration is required. The absolute `url` of each page, in `$page.url` and `$pages`, includes the subpath too: `content/post.md` is `https://example.com/blog/post/`. `$page.path`, the paths in `$pages`, and the paths of output files, data modules, and `$hooks.transform` include it too: on `https://example.com/blog/`, `content/post.md` has the path `/blog/post/`. A root-relative path is always a URL path, as a browser requests it, so `href="{{ page.path }}"` works on any site. `bongo dev` serves the site under the same subpath.
351
+
352
+ ## Layouts
353
+
354
+ Each page renders through a layout in `layouts/`. bongo looks in the folder of the page first, then in each parent folder, then in `layouts/` itself. In each folder, it uses the first of `<name>.hbs`, `single.hbs`, `index.hbs`, and `html.hbs`, where `<name>` is the file name of the page. An index page looks for `index.hbs` and `html.hbs`. Thus one `index.hbs` can serve a whole folder. For `content/blog/2025/post.md`, bongo looks in `layouts/blog/2025/`, then `layouts/blog/`, then `layouts/`.
355
+
356
+ When that layout is not an `html.hbs`, bongo wraps its output in the nearest `html.hbs`, found the same way. If no `html.hbs` exists, the build fails with `Missing "html.hbs" template`.
357
+
358
+ ## Templates
359
+
360
+ Templates use [tempura](https://github.com/lukeed/tempura) syntax. Asset entrypoints are declared with `<bongo:style>`, `<bongo:script>`, `<bongo:client>`, `<bongo:asset>`, and `<bongo:image>` custom elements. `<bongo:image>` is described in [Images](#images).
361
+
362
+ ```hbs
363
+ {{! layouts/html.hbs }}
364
+ <!doctype html>
365
+ <html lang="{{$site.lang}}">
366
+ <head>
367
+ <meta charset="utf-8"/>
368
+ <title>{{ $page.title }}</title>
369
+
370
+ {{! External CSS: emits <link rel="stylesheet" href="/style.hash.css"/> }}
371
+ <bongo:style src="app.css"></bongo:style>
372
+
373
+ {{! Inline CSS: splices built file contents into a <style> block }}
374
+ <bongo:style src="print.css" inline></bongo:style>
375
+ </head>
376
+ <body>
377
+ {{{ $page.content }}}
378
+
379
+ {{! External JS: emits <script type="module" src="/main.hash.js"></script> }}
380
+ <bongo:script src="main.ts"></bongo:script>
381
+
382
+ {{! Async external JS }}
383
+ <bongo:script src="analytics.ts" async></bongo:script>
384
+
385
+ {{! Inline JS: the built code in a <script type="module"> element }}
386
+ <bongo:script src="boot.ts" inline></bongo:script>
387
+ </body>
388
+ </html>
389
+ ```
390
+
391
+ `<bongo:style>` attributes:
392
+
393
+ - `src="..."` — path relative to `assets/`.
394
+ - `inline` — splice built CSS directly into a `<style>` block. Omit for an external stylesheet link.
395
+ - `global` — disable CSS modules auto-detection on `*.module.css` sources.
396
+ - Any other attribute, such as `media` or `nonce`, goes on the `<link>` or the `<style>`.
397
+
398
+ `<bongo:script>` attributes:
399
+
400
+ - `src="..."` — path relative to `assets/`.
401
+ - `inline` — splice built JavaScript directly into the `<script>` element. An inline script is built alone, with no shared chunks, so it imports nothing.
402
+ - `async` — add the `async` attribute.
403
+ - `defer` — add the `defer` attribute to a `cjs` or `iife` script. The browser defers module scripts already.
404
+ - `format="esm"|"iife"|"cjs"` — output format. Defaults to `esm`, a `<script type="module">`. `iife` and `cjs` write a classic script, which runs at once, and each is built alone, with dynamic imports inlined. A browser has no `require` or `exports`, so prefer `iife` to `cjs`. Any other value fails the build.
405
+ - `target="..."` — browserslist target string. It replaces [`$build.target`](#build-targets) for this script.
406
+ - Any other attribute, such as `nonce` or `id`, goes on the `<script>`. `format` sets its `type`.
407
+
408
+ `<bongo:client>` bundles bongo's browser runtime and emits a module script that calls `client(...)`. It emits only that script, so you can put it anywhere in `<head>` or `<body>`:
409
+
410
+ ```hbs
411
+ <bongo:client />
412
+ <bongo:client transitions />
413
+ <bongo:client transitions="slide" />
414
+ ```
415
+
416
+ Multiple `<bongo:client>` tags on a page share one bundled runtime. A `nonce` goes on the script; every other attribute becomes a client option.
417
+
418
+ `<bongo:alternate type="…" href="…" />` becomes a `<link rel="alternate">`, which names another version of the page, such as a feed or a translation. Other attributes, such as `title` and `hreflang`, go on the `<link>`. For `type="text/markdown"`, `href` defaults to the route of the page plus `.md`, and bongo also writes a Markdown copy of the page there: the `title` and `summary` of its frontmatter, then its Markdown as written. bongo reads the source again when it writes the copy, and `bongo dev` makes a copy on its first request. A page from an `.html` or `.hbs` file has no Markdown to copy, so it gets no copy and no `<link>`. Two pages that name the same file, or a copy that lands on another file of the site, fail the build.
419
+
420
+ `<bongo:asset>` publishes any file from `assets/`, such as a font, image, or icon, under a content-hashed name. It goes through the same pipeline as files referenced from CSS `url()`: a file used by both gets one copy and one URL. That makes it the way to preload a font that your CSS loads.
421
+
422
+ ```hbs
423
+ {{! Preload: emits <link rel="preload" href="/inter.3f9a1c2e.woff2" as="font" type="font/woff2" crossorigin/> }}
424
+ <bongo:asset preload src="fonts/inter.woff2" />
425
+
426
+ {{! Plain: becomes the bare URL, so it works inside attributes }}
427
+ <link rel="icon" href="<bongo:asset src='img/favicon.png' />"/>
428
+ <img src="<bongo:asset src=img/hero.webp />" alt="…"/>
429
+ ```
430
+
431
+ Without `preload`, the element is replaced by the file's public URL and nothing else. Inside an attribute, give `<bongo:asset>` its own attributes in single quotes or without quotes, so they do not end the outer attribute. The URL is percent-encoded, so it is safe in any attribute.
432
+
433
+ With `preload`, bongo emits a `<link rel="preload">` and infers its attributes from the file extension:
434
+
435
+ | Files | `as` | `type` | `crossorigin` |
436
+ | --- | --- | --- | --- |
437
+ | `.woff2`, `.woff`, `.ttf`, `.otf` | `font` | `font/woff2`, … | yes, as preload requires for fonts |
438
+ | images, such as `.png`, `.jpg`, `.svg`, `.webp`, `.avif` | `image` | `image/png`, … | no |
439
+ | `.css` | `style` | `text/css` | no |
440
+ | `.js`, `.mjs` | `script` | `text/javascript` | no |
441
+ | anything else, such as `.json` or `.wasm` | `fetch` | when known | yes |
442
+
443
+ `<bongo:asset>` attributes:
444
+
445
+ - `src="..."` — path relative to `assets/`.
446
+ - `preload` — emit a `<link rel="preload">` instead of the bare URL.
447
+ - `as="..."`, `type="..."` — override the inferred values. `type=false` leaves `type` out.
448
+ - `crossorigin` or `crossorigin="use-credentials"` — override the inferred value. `crossorigin=false` leaves it out.
449
+ - `media="..."`, `fetchpriority="high|low|auto"` — copied onto the `<link>`.
450
+
451
+ A `src` that does not exist fails the build. The error shows the element and the content file and layouts that rendered it.
452
+
453
+ ## View Transitions
454
+
455
+ Add `transition:name` and `transition:animate` to HTML elements in a layout or partial. bongo rewrites them at build time.
456
+
457
+ ```hbs
458
+ <header transition:name="site-header">...</header>
459
+ <nav transition:name="nav" transition:animate="slide">...</nav>
460
+ ```
461
+
462
+ - `transition:name="NAME"` — removed from the element; `view-transition-name: NAME` is merged into the element's `style` attribute.
463
+ - `transition:animate="KEYFRAME"` — requires a named `@keyframes KEYFRAME` in your CSS. bongo injects a `<style data-bongo-transitions>` block in `<head>` that wires the keyframe to matching view-transition pseudo-elements.
464
+
465
+ If `transition:animate` is present without `transition:name`, an automatic name is generated.
466
+
467
+ An element with `transition:persist="KEY"`, or `transition:persist` and an `id`, is not replaced on a client navigation: the client runtime moves it into the new page in place of the element with the same key, so it keeps its scroll position, focus, and state. Update it yourself in a `bongo:replaced` handler, such as the link that marks the current page.
468
+
469
+ ## Partials
470
+
471
+ ```hbs
472
+ {{#partial src="header" }}
473
+ {{#partial src="card" title="Hello" }}
474
+ ```
475
+
476
+ Each attribute after `src` is an argument. A value is a quoted string, a bare value with no spaces, such as `count=3` or `post=$page`, a `{…}` map, a `[…]` list, or a `( … )` group, which holds any expression with spaces: `active=($page.section === 'docs')`. Other text fails the build with `Cannot read the arguments of "partial"` and quotes the text.
477
+
478
+ A partial in `partials/` gets its arguments, the config values (`$site`, `$utils`, `$data`, `$assets`), and the `$page`, `$pages`, `$section`, and `$tags` of the page that renders it. An argument with the same name wins: `{{#partial src="postmeta" $page=post}}`. A `$partials` function gets only its arguments. A partial that a Markdown element or a loader calls runs when the content file loads, before any page renders, so it gets no page values.
479
+
480
+ A `.hbs` partial declares the arguments that it reads with `{{#expect title, date}}`. `$slot`, the inner HTML of a Markdown element, is always declared. A partial that reads a name it does not declare fails the build with `partials/card.hbs reads "title", but does not declare it. Add {{#expect title}} at the top of the partial.`
481
+
482
+ ## Template Variables
483
+
484
+ - `$site` — global site config.
485
+ - `$page` — current page. Includes `path`, `url`, `title`, `summary`, `params`, `section`, `tags`, `headings`, `draft`, and `content`. `headings` lists `{ level, id, text, html }` from the page's loader; `.html` and `.hbs` content has none.
486
+ - `$pages` — every page in the build, sorted by path, without drafts unless the build has them. In output files and data modules, each entry also has `alternates`, the `<bongo:alternate>` elements of the page by type, such as `{ 'text/markdown': '/about.md' }`.
487
+ - `$section` — pages scoped to the current page's section directory.
488
+ - `$tags` — array of `{ tag, pages }` entries.
489
+ - `$assets` — `load(filename, encoding?)` reads a file from `assets/` and returns a promise. `render(template, output?, options?)` draws a partial as an image and returns a promise of its URL path. See [Dynamic Images](#dynamic-images).
490
+ - `$utils` — configured helper functions.
491
+ - `$data` — the files in `data/`. See [Data](#data).
492
+
493
+ Declare your own variables with `{{#var name = value}}`. The value is a JavaScript expression, and the variable holds it to the end of the file. Declare it again to change it. `{{#each list as (item, i)}}` declares `item` and its index `i` for each item. A partial does not see the variables of its caller: pass them as arguments, and declare them in the partial with `{{#expect name}}`.
494
+
495
+ ```hbs
496
+ {{#var related = $section.filter(post => post.path !== $page.path).slice(0, 3)}}
497
+ {{#each related as (post, i)}}<a href="{{ post.path }}">{{ i + 1 }}. {{ post.title }}</a>{{/each}}
498
+ ```
499
+
500
+ Content files ending in `.hbs` are tempura-compiled with the same context as layouts: `$site`, `$page`, `$pages`, `$section`, `$tags`, `$assets`, `$utils`, `$data`, plus `params` as an alias for `$page.params`.
501
+
502
+ The current page's own `$page.content` is empty while its body is compiling. It is the value produced by that compile.
503
+
504
+ ## Output files
505
+
506
+ A `.hbs` file in `content/` is an output file, not a page, when its second extension is a file type with a known content type, other than `.html`, such as `.xml`, `.txt`, `.json`, `.svg`, or `.webmanifest`. `content/v1.2.hbs` stays a page, because `.2` is not a file type. bongo writes an output file to its own path, without the `.hbs`:
507
+
508
+ | File | Output |
509
+ | --- | --- |
510
+ | `content/sitemap.xml.hbs` | `/sitemap.xml` |
511
+ | `content/blog/index.xml.hbs` | `/blog/index.xml` |
512
+ | `content/robots.txt.hbs` | `/robots.txt` |
513
+
514
+ An output file compiles with the same values as `.hbs` content: `$site`, `$page`, `$pages`, `$section`, `$tags`, `$data`, `$utils`, and its own frontmatter as `params`. It is different from a page:
515
+
516
+ - It gets no layout. bongo writes it as it renders: it does not replace `<bongo:*>` elements or content images, adds no dev reload script, and never minifies it.
517
+ - It renders after every page, so the entries of `$pages` have the `alternates` of each page. An `llms.txt.hbs` can list the Markdown copy of each page this way.
518
+ - It is not in `$pages`, `$section`, or `$tags`, so a sitemap or a feed never lists itself.
519
+ - Its frontmatter cannot set `url`. To change its path, rename the file.
520
+ - `bongo dev` serves it with the content type of its extension, such as `text/xml`, and renders it again after every change to a page, because it can read every page.
521
+
522
+ As in all templates, tempura removes the line break just before each `{{ }}` tag and at the end of the file. Put a tag at the end of its line to keep the output tidy. The output starts after the `---` line that closes the frontmatter, so an XML file can start with `<?xml`.
523
+
524
+ A sitemap, in `content/sitemap.xml.hbs`:
525
+
526
+ ```hbs
527
+ <?xml version="1.0" encoding="utf-8"?>
528
+ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
529
+ {{#each $pages as page}}
530
+ <url>
531
+ <loc>{{ page.url }}</loc>
532
+ {{#if page.params.lastmod}}
533
+ <lastmod>{{ page.params.lastmod }}</lastmod>
534
+ {{/if}}
535
+ </url>
536
+ {{/each}}
537
+ </urlset>
538
+ ```
539
+
540
+ A `robots.txt`, in `content/robots.txt.hbs`:
541
+
542
+ ```hbs
543
+ User-agent: *
544
+ Allow: /
545
+ Sitemap: {{ $site.url }}sitemap.xml
546
+ ```
547
+
548
+ A feed is an output file whose frontmatter has `format: rss` or `format: atom`. The [`@bongo/feed`](../plugin.feed/readme.md) plugin turns its `<feed:*>` elements into RSS or Atom, and each `<feed:link>` in your layouts into a link to it. In `content/blog/index.xml.hbs`:
549
+
550
+ ```hbs
551
+ ---
552
+ format: rss
553
+ title: My blog
554
+ ---
555
+ <feed:channel>
556
+ {{#each $section.filter(post => post.params.date) as post}}
557
+ <feed:item href="{{ post.path }}" title="{{ post.title }}" date="{{ post.params.date }}" summary="{{ post.summary }}" />
558
+ {{/each}}
559
+ </feed:channel>
560
+ ```
561
+
562
+ `content/feed.json.hbs` also works, but it is not recommended: `{{ }}` escapes text for HTML, not for JSON, so the output is easily invalid. For JSON, use a [data module](#data-modules).
563
+
564
+ ## Data modules
565
+
566
+ A `.json.ts` or `.json.js` file in `content/` is a data module. bongo runs it and writes its default export as JSON, without the script extension:
567
+
568
+ | File | Output |
569
+ | --- | --- |
570
+ | `content/search.json.ts` | `/search.json` |
571
+ | `content/blog/posts.json.js` | `/blog/posts.json` |
572
+
573
+ The default export is a value, or a function that returns one. The function receives `$site`, `$pages`, `$section`, `$tags`, `$data`, and `$utils`, as `.hbs` content does, and it may be async. Each page in `$pages` has its `headings`, so a search index needs no second read of the sources. Type it with `Build.Data`:
574
+
575
+ ```ts
576
+ // content/search.json.ts
577
+ import type { Build } from 'bongo';
578
+
579
+ export default ({ $pages }: Build.Data) => $pages
580
+ .filter(page => page.section === 'blog')
581
+ .map(page => ({ title: page.title, path: page.path }));
582
+ ```
583
+
584
+ A data module is like an output file:
585
+
586
+ - It is not a page: it gets no layout, is not in `$pages`, `$section`, or `$tags`, and is never minified or passed to `$hooks.transform`. The JSON is always compact.
587
+ - It may import local files and packages, as the config does. It runs on every build, so it always reads the current pages.
588
+ - `bongo dev` serves it as `application/json`. It runs again after every change to a page, to the module, or to a local file it imports.
589
+ - If it has no default export, throws, or returns something JSON cannot hold, such as `undefined`, the build fails and names the file.
590
+
591
+ ## Images
592
+
593
+ bongo resizes and converts images with [`Bun.Image`](https://bun.com/docs/runtime/image), so image processing needs the Bun runtime. The `bongo` command starts with Node, so run it through Bun:
594
+
595
+ ```sh
596
+ bunx --bun bongo build
597
+ bun --bun run build # for a "build": "bongo build" script
598
+ ```
599
+
600
+ Under Node, bongo copies each image as it is, under a hashed name, and prints one notice for each build. The build does not fail. It adds `width` and `height` only to SVG and GIF images.
601
+
602
+ JPEG, PNG, and WebP files are encoded with the same codecs on every platform, so a build makes the same bytes on macOS, Linux, and Windows.
603
+
604
+ bongo adds `width` and `height` to images. If your CSS limits the width of images, also give them `height: auto`, so they keep their aspect ratio:
605
+
606
+ ```css
607
+ img { max-width: 100%; height: auto; }
608
+ ```
609
+
610
+ ### `<bongo:image>`
611
+
612
+ ```hbs
613
+ <bongo:image src="img/hero.jpg" alt="A hero" sizes="(min-width: 800px) 720px, 100vw" />
614
+ ```
615
+
616
+ With the default settings and a 2400×1600 JPEG, this becomes the following, on one line:
617
+
618
+ ```html
619
+ <picture>
620
+ <source type="image/webp" srcset="/hero-640.1a2b3c4d.webp 640w, /hero-960.2b3c4d5e.webp 960w, /hero-1280.3c4d5e6f.webp 1280w, /hero-1920.4d5e6f7a.webp 1920w, /hero-2400.5e6f7a8b.webp 2400w" sizes="(min-width: 800px) 720px, 100vw"/>
621
+ <img src="/hero-2400.6f7a8b9c.jpg" srcset="/hero-640.7a8b9c0d.jpg 640w, /hero-960.8b9c0d1e.jpg 960w, /hero-1280.9c0d1e2f.jpg 1280w, /hero-1920.0d1e2f3a.jpg 1920w, /hero-2400.6f7a8b9c.jpg 2400w" sizes="(min-width: 800px) 720px, 100vw" alt="A hero" loading="lazy" decoding="async" width="2400" height="1600"/>
622
+ </picture>
623
+ ```
624
+
625
+ The path in `src` tells bongo where the file is:
626
+
627
+ - `img/hero.jpg` is relative to `assets/`, as for `<bongo:asset>`.
628
+ - `./hero.jpg`, `../hero.jpg`, and `/blog/post/hero.jpg` resolve against the URL of the page, as a browser resolves them, and bongo reads the file from `content/`. For `content/blog/post.md`, which is served at `/blog/post/`, `./hero.jpg` is `content/blog/post/hero.jpg`. A root-relative path is the URL path that the browser requests, so on a site under `/blog/` it starts with `/blog/`, and `{{ post.path }}{{ post.params.cover }}` works in a list of posts. A path outside the site fails the build.
629
+
630
+ `<bongo:image>` attributes:
631
+
632
+ - `src="..."` — the image. Required.
633
+ - `sizes="..."` — defaults to `$images.sizes`.
634
+ - `widths="640,1280"`, `formats="webp,jpeg"`, `quality="60"` — override `$images` for this image.
635
+ - `width`, `height` — default to the size of the source. If you give only one, bongo calculates the other from the aspect ratio.
636
+ - `loading` — defaults to `lazy`, or to `eager` when `fetchpriority="high"`.
637
+ - `decoding` — defaults to `async`.
638
+ - All other attributes, such as `alt`, `class`, and `fetchpriority`, go on the `<img>`.
639
+
640
+ A `sizes` value that starts with `auto` lets the browser use the width of the image in the layout. Browsers use `auto` only for lazy images, so bongo removes it from eager images.
641
+
642
+ ### Content images
643
+
644
+ By default, bongo also processes each `<img>` in content whose `src` is a relative path. This includes Markdown images, such as `![A hero](hero.jpg)`, and `<img>` elements that you write in Markdown, HTML, or `.hbs` content. The path resolves against the URL of the page, as for `./` paths.
645
+
646
+ The `<img>` stays where it is. bongo does not add a `<picture>`, so CSS such as `p > img` still applies. The `<img>` gets a `src`, `srcset`, and `sizes` in the first format in `$images.formats`, which is WebP by default and which every current browser supports, its `width` and `height`, `loading="lazy"`, and `decoding="async"`. Attributes that you write, such as `width`, `sizes`, or `loading`, stay as you wrote them.
647
+
648
+ bongo does not change an `<img>` that:
649
+
650
+ - has a `srcset`,
651
+ - is in a `<picture>`,
652
+ - has an absolute URL, a root-relative path such as `/img/a.png`, or a `data:` URL in `src`.
653
+
654
+ To leave all content images as written, set `$images.content` to `false`. To get a `<picture>` with all formats in Markdown, write a `<bongo:image>`.
655
+
656
+ ### Processing
657
+
658
+ - bongo makes one file for each width in `widths` that is smaller than the source. If one or more widths are not smaller, bongo makes one file at the width of the source instead. bongo never enlarges an image.
659
+ - Files are named `name-width.hash.ext`. The hash comes from the contents of the source, the encoder settings, and the Bun version.
660
+ - bongo applies the EXIF orientation of JPEG files and removes the EXIF data, such as GPS positions. It keeps the color profile.
661
+ - bongo resizes JPEG, PNG, and WebP files. It copies all other images once under a hashed name, as for `<bongo:asset>`: SVG, GIF, TIFF, AVIF, HEIC, and animated PNG and WebP files. It adds their `width` and `height` when it can read them. For SVG, they come from the `width`, `height`, or `viewBox` attributes.
662
+
663
+ ### Cache
664
+
665
+ bongo keeps encoded images in `node_modules/.cache/bongo/images`. A rebuild copies unchanged images from there, and encodes only new or changed images. To clear the cache, delete the directory.
666
+
667
+ bongo records when a build last used each cached file, as the file's modified time, which it updates at most once a day. At the end of a build, at most once a day, bongo deletes the files that no build has used for 30 days. Set `$images.prune` to another number of days, or to `false` to keep every file. When two builds run at the same time and one deletes a file that the other is about to use, the other encodes it again, so a prune never breaks a build.
668
+
669
+ ### Dev
670
+
671
+ `bongo dev` does not encode images when it builds. It reads the size of each image and writes the pages, so the first build stays fast. The dev server encodes a file the first time that the browser asks for it, and then serves it from the cache. A browser asks for only one file per image, so most files are never encoded in dev. When you change an image, bongo rebuilds, and the page reloads with the new URLs.
672
+
673
+ ### Errors
674
+
675
+ A missing image fails the build. The error shows the element and the files that rendered it:
676
+
677
+ ```
678
+ Missing "content/blog/post/hero.jpg" for <img src="hero.jpg" alt="">, rendering content/blog/post.md
679
+ ```
680
+
681
+ An image path that resolves outside `assets/` or `content/` also fails the build.
682
+
683
+ `Bun.Image` encodes AVIF only on Apple Silicon M3 or newer Macs and on Windows. If you ask for `avif` on another machine, such as Linux CI, the build fails and says so.
684
+
685
+ ### Image config
686
+
687
+ ```ts
688
+ export default {
689
+ $images: {
690
+ widths: [640, 960, 1280, 1920, 2880],
691
+ formats: ['webp', 'original'],
692
+ quality: { avif: 50, webp: 80, jpeg: 80 },
693
+ sizes: 'auto, 100vw',
694
+ content: true,
695
+ prune: 30,
696
+ },
697
+ };
698
+ ```
699
+
700
+ These are the defaults.
701
+
702
+ - `widths` — output widths, in pixels. 2880 serves a full-width image on a 1440 pixel screen with a pixel ratio of 2.
703
+ - `formats` — `avif`, `webp`, `jpeg`, `png`, or `original`, best first. `original` is the format of each source. Content images use only the first format. `<bongo:image>` puts the last format in its `<img>` as the fallback and the others in `<source>` elements. Both should be formats that all browsers support. `avif` is not in the defaults, because only some machines can encode it.
704
+ - `quality` — from 1 to 100, for AVIF, WebP, and JPEG. Give one number for all of them, or one number for each format. PNG is always lossless.
705
+ - `sizes` — the `sizes` of images that do not have one.
706
+ - `content` — process `<img>` elements in content.
707
+ - `prune` — delete cached images and [dynamic images](#dynamic-images) that no build has used for this many days, or `false` to keep them all.
708
+
709
+ ## Dynamic Images
710
+
711
+ `$assets.render(template, output?, options?)` draws a partial as an image, and returns the URL path of that image. Use it for a social card for each page:
712
+
713
+ ```hbs
714
+ {{#var card = await $assets.render('og/card')}}
715
+ <meta property="og:image" content="{{ $utils.url.abs(card) }}"/>
716
+ ```
717
+
718
+ bongo draws with [Satori](https://github.com/vercel/satori) and converts the drawing with [resvg](https://github.com/thx/resvg-js). They are optional peer dependencies: `bun add -d satori @resvg/resvg-js`. SVG output needs only `satori`.
719
+
720
+ - `template` — a partial name, such as `og/card` for `partials/og/card.hbs`. The partial gets the `$page`, `$pages`, `$section`, and `$tags` of the page that calls `$assets.render`.
721
+ - `output` — optional. The path of the image in `public/`, such as `og/home.png`. The extension sets the format. Without it, the image is a PNG with a name from its partial and its content, such as `card.3f9a1c2e.png`.
722
+ - `options` — `width` and `height` in pixels, 1200 and 630 by default, and `args`, values for the partial.
723
+
724
+ `.svg` and `.png` work in Bun, Deno, and Node. `.jpg`, `.jpeg`, and `.webp` use `Bun.Image`, so they need the Bun runtime, and fail the build elsewhere. Their quality comes from `$images.quality`.
725
+
726
+ The partial must have one root element. Set styles with the `style` attribute. Declare fonts with `@font-face` rules in a `<style>` element, with a `src` in `assets/`: TTF, OTF, or WOFF, but not WOFF2. bongo reads the rules and removes the element. An `<img src>` or a CSS `url()` with a path reads that file from `assets/` into the drawing.
727
+
728
+ `bongo build` draws every image. `bongo dev` draws an image at its first request. bongo keeps each drawing in `node_modules/.cache/bongo/render`, by a hash of its input and the versions of Satori, resvg, and Bun. Two pages that draw different images to the same `output` fail the build.
729
+
730
+ ## CSS
731
+
732
+ All CSS is handled by [LightningCSS](https://lightningcss.dev): `@import` bundling, autoprefixing, nesting, and optional minification. Prefixes and lowered syntax follow the [build target](#build-targets), which is `'defaults'` unless you set it.
733
+
734
+ `.module.css` files automatically enable CSS modules.
735
+
736
+ ### Packages in `@import`
737
+
738
+ An `@import` can name a package in `node_modules`:
739
+
740
+ ```css
741
+ @import "@twinkleplop/theme-github";
742
+ @import "@twinkleplop/theme-github/dark";
743
+ @import "some-pkg/dist/theme.css";
744
+ ```
745
+
746
+ bongo first looks for the path next to the CSS file, so `@import "theme.css"` still loads the `theme.css` beside it. If that file does not exist, bongo looks for the package in the `node_modules` folder next to the CSS file, then in the `node_modules` folder of each parent folder, so workspaces and hoisted packages work. It follows symlinks. In the package, bongo uses the first of these that is a file:
747
+
748
+ 1. A `.css` target in the `exports` of its `package.json`, with the `style`, `import`, or `require` condition, in that order. The `default` condition applies with each one.
749
+ 2. The `style` field, when you import the package itself.
750
+ 3. The path after the package name, as a file in the package. For `some-pkg/dist/theme.css`, this is `dist/theme.css`.
751
+
752
+ Relative (`./`, `../`), absolute (`/`), and URL imports do not change. A `url()` in a package stylesheet resolves against that stylesheet, as described below, so the fonts and images that a theme package ships work. If nothing resolves, the build fails with an error that names the `@import` and the CSS file that contains it.
753
+
754
+ `bongo dev` does not watch the packages that CSS imports from `node_modules`. After you update one, save the CSS file that imports it, or restart `bongo dev`.
755
+
756
+ ### Files in `url()`
757
+
758
+ Fonts, images, and other files that CSS references with a relative `url()` can live next to the CSS under `assets/`. Each `url()` resolves against the CSS file that contains it, including files pulled in with `@import`.
759
+
760
+ ```css
761
+ /* assets/theme/fonts.css */
762
+ @font-face {
763
+ font-family: Inter;
764
+ src: url("../fonts/inter.woff2") format("woff2");
765
+ }
766
+ ```
767
+
768
+ bongo copies each referenced file to the output once, with a content hash in its name, and rewrites the `url()` to its public path under `publicPath`, for example `url("/inter.3f9a1c2e.woff2")`. A `?query` or `#fragment` on the URL is kept. Linked and inline styles behave the same, and `bongo dev` rebuilds when a referenced file changes.
769
+
770
+ These URLs are left as written:
771
+
772
+ - external URLs, such as `https:`, `//cdn.example.com/…`, and `data:`
773
+ - root-relative URLs, such as `/img/logo.png`, which point at files from `content/`. The browser requests them as written, so on a site under a subpath they must start with it.
774
+ - fragment-only URLs, such as `url(#gradient)`
775
+
776
+ If a relative `url()` points at a file that does not exist, the build fails with an error that names the URL and the CSS file that contains it.
777
+
778
+ ## Dev Server
779
+
780
+ `bongo dev` starts the HTTP server and runs the initial build behind it. File changes in `assets/`, `layouts/`, `partials/`, or `content/` trigger a rebuild.
781
+
782
+ The dev server keeps each build in memory and serves it from there, as Vite does. It does not read or write `public/`, unless you pass `--write`; see [Output folder](#output-folder). It serves a file that bongo only copies from `content/`, such as a PDF, from its source, so an edit shows at once. It encodes each resized image the first time the browser asks for it, then reads it from `node_modules/.cache/bongo/images`.
783
+
784
+ A change to the config, to any local file it imports, or to anything in `data/` loads the config and data again, then rebuilds every page. A workspace package, or any package linked into `node_modules` from outside it, counts as a local file. You do not need to restart. If the config or a data file fails to load, the error overlay shows the error, and the last good build keeps serving until you fix it. The same is true for a rebuild that fails. If the first build fails, there is no good build yet: `bongo dev` keeps running, and answers each request with a page that shows the error. When a fix builds, that page reloads.
785
+
786
+ When `$site.url` has a subpath, such as `https://example.com/blog/`, `bongo dev` serves the site under `/blog/`, as a host does, and redirects `/` there.
787
+
788
+ A path with no matching file gets a `404` status, the same as on a static host, so broken links show up during development. The response body is your own 404 page when the build has one: `404.html`, or `404/index.html` (which `content/404.md` produces). Most static hosts serve `404.html` for a missing path, so give that page `url: /404.html`.
789
+
790
+ Rebuilds send one of these messages to each open page, over its WebSocket:
791
+
792
+ | Message | Meaning |
793
+ |---|---|
794
+ | `{ type: 'reload' }` | Successful rebuild; browser reloads the page. |
795
+ | `{ type: 'error', errors }` | Build failed; full-viewport error overlay mounts. |
796
+ | `{ type: 'error-clear' }` | Next successful build; overlay unmounts, then the browser reloads. |
797
+
798
+ The `hmr` message type exists in the type union for forward compatibility. Today, file changes trigger a full reload.
799
+
800
+ ## Client Runtime
801
+
802
+ `bongo/client` turns a statically generated site into a client-navigated SPA.
803
+
804
+ ```ts
805
+ import { client, navigate, on, off } from 'bongo/client';
806
+ ```
807
+
808
+ The simplest setup is the `<bongo:client />` element:
809
+
810
+ ```hbs
811
+ {{! layouts/html.hbs }}
812
+ <bongo:client transitions />
813
+ ```
814
+
815
+ That expands at build time to:
816
+
817
+ ```html
818
+ <script type="module">
819
+ import { client } from "/client.D59fAaym.js";
820
+ client({ transitions: true });
821
+ </script>
822
+ ```
823
+
824
+ At startup, the runtime adds a hidden `aria-live` region, `<div id="bongo:live">`, at the start of `<body>`. It keeps that element across navigations and writes the new page title into it after each one. If the page already has an element with that id, the runtime uses it.
825
+
826
+ If you also need custom event handlers or boot code, keep a separate `<bongo:script>`:
827
+
828
+ ```hbs
829
+ <bongo:client transitions />
830
+ <bongo:script src="main.ts"></bongo:script>
831
+ ```
832
+
833
+ ```ts
834
+ import { on } from 'bongo/client';
835
+
836
+ on('bongo:navigated', ({ to }) => {
837
+ console.log('navigated to', to.pathname);
838
+ });
839
+ ```
840
+
841
+ Or call `client()` yourself:
842
+
843
+ ```ts
844
+ import { client } from 'bongo/client';
845
+
846
+ client({ transitions: true });
847
+ ```
848
+
849
+ ### ClientOptions
850
+
851
+ | Option | Type | Default | Description |
852
+ |---|---|---|---|
853
+ | `transitions` | `boolean \| 'fade' \| 'initial' \| 'slide' \| string` | `false` | View-transition preset. `true` / `'fade'` injects a cross-fade keyframe. `'slide'` injects a slide keyframe. `'initial'` wraps the swap without injecting CSS. Custom string wraps only; user CSS provides the rules. Any falsy value disables transitions. Opt a link out with `transition:disable`. |
854
+ | `prefetch` | `boolean \| 'hover' \| 'tap'` | `false` | Prefetch same-origin links into the in-memory HTML cache, and fetch the new stylesheets of each page into the HTTP cache. `true` / `'hover'` fires after hover settles, or immediately on touch. `'tap'` fires on `mousedown` / `touchstart`. Opt a link out with `prefetch:disable`. |
855
+
856
+ ### Events
857
+
858
+ Subscribe with `on(event, handler)`. It returns an unsubscribe function. Remove a specific handler with `off(event, handler)`.
859
+
860
+ ```ts
861
+ on('bongo:navigated', ({ to }) => {
862
+ console.log('navigated to', to.pathname);
863
+ });
864
+ ```
865
+
866
+ | Event | Detail shape | Fires when |
867
+ |---|---|---|
868
+ | `bongo:ready` | `{ url: URL }` | Once, immediately after `client()` is called. |
869
+ | `bongo:navigate` | `{ to: URL; from: URL }` | Navigation intercepted, before the fetch. |
870
+ | `bongo:replace` | `{ to: URL; from: URL; doc: Document }` | HTML fetched and parsed; swap has not happened yet. |
871
+ | `bongo:replaced` | `{ to: URL; from: URL }` | DOM swapped. |
872
+ | `bongo:navigated` | `{ to: URL; from: URL }` | Navigation settled. |
873
+ | `bongo:error` | `{ to: URL; error: Error }` | Navigation fetch or swap threw. |
874
+
875
+ ### Programmatic Navigation
876
+
877
+ ```ts
878
+ navigate('/about');
879
+ navigate('/old-url', { replace: true });
880
+ ```
881
+
882
+ `navigate()` returns `Promise<void>` and resolves when navigation and any transition complete.
883
+
884
+ ### Opt Out
885
+
886
+ Force a full-page load:
887
+
888
+ ```html
889
+ <a href="/legacy" transition:disable>Legacy dashboard</a>
890
+ ```
891
+
892
+ Disable prefetch without forcing a full-page load:
893
+
894
+ ```html
895
+ <a href="/logout" prefetch:disable>Log out</a>
896
+ ```
897
+
898
+ ### Stylesheets
899
+
900
+ On navigation, the runtime compares the `<link rel="stylesheet">` and `<style>` elements in the `<head>` of the two pages. It keeps the stylesheets that both pages use. It adds the new stylesheets in the order of the new page, but keeps them disabled, so the current page does not change. When each new stylesheet loads or fails, or after 3 seconds, the swap enables the new stylesheets, replaces `<body>`, and removes the stylesheets that the new page does not use. A view transition starts only after this wait.
901
+
902
+ - A `<link>` matches by its resolved `href`, its `media`, and its `disabled` attribute. A `<style>` matches by its content and its `media`.
903
+ - The runtime does not wait for a stylesheet that does not apply, such as a `disabled` link or a `media` query that does not match.
904
+ - The runtime never removes a stylesheet that a script adds after `client()` starts.
905
+ - Stylesheets in `<body>` change with the body.
906
+
907
+ With `prefetch`, the runtime also fetches the new stylesheets of a page before the click. If the server lets the browser reuse them without a new request, for example with a long `max-age` on hashed files, navigation does not wait for them.
908
+
909
+ ### Scroll Restoration
910
+
911
+ `client()` sets `history.scrollRestoration = 'manual'`.
912
+
913
+ - Back / forward restores the saved `{x, y}` position for that URL.
914
+ - Push / replace scrolls to the hash element if present, otherwise `(0, 0)`.
915
+
916
+ ### Browser Support
917
+
918
+ The Navigation API is available in Chrome and Edge, and is behind a flag or unsupported in Firefox and Safari at time of writing. If the API is absent, `client()` emits `bongo:ready` and returns without installing navigation interception.
919
+
920
+ ## TypeScript
921
+
922
+ `bongo` ships ambient JSX types for its custom elements and attributes. Extend the bundled config:
923
+
924
+ ```json
925
+ {
926
+ "extends": "bongo/tsconfig",
927
+ "compilerOptions": {
928
+ // your own settings
929
+ }
930
+ }
931
+ ```
932
+
933
+ The augmentation adds:
934
+
935
+ - `<bongo:asset>`, `<bongo:client>`, `<bongo:image>`, `<bongo:script>`, and `<bongo:style>` intrinsic elements.
936
+ - `transition:name` and `transition:animate` on every HTML element.
937
+ - `transition:disable` and `prefetch:disable` on anchors.
938
+
939
+ Or reference the types directly:
940
+
941
+ ```ts
942
+ /// <reference types="bongo/jsx" />
943
+ ```