Pages and data
Registering pages with .page() and .pages(), shaping their data with controllers, fetching remote models, Markdown options and partial transforms.
.page()
Instead of (or in conjunction with) using the .scan() method you can pass a model to the view using the .page() method. This allows you to name the view and pass a model to that view. The model is then available in the handlebar template under the model property, e.g. {{model.name}}
import Kiss from 'kiss-ssg'
const kiss = new Kiss({ dev: true })
kiss
.page({
view: 'index.hbs',
model: 'index.json',
controller: 'index.js',
title: 'My Page Title',
})
.generate()
Note: The file locations of the models, views, and controllers are relative to the folder locations defined in the kiss configuration. Alternatively, instead of passing a file location you can pass a native object for that setting.
- Views: a
.hbsfile or a template string. - Models: a
.jsonfile, an HTTP API endpoint, or an object. - Controllers: a
.jsfile, or a function that returns page options to merge into the page's options.
The options that you can pass to .page() & pages() are:
{
view: 'index.hbs',
model: {},
controller: ({model})=>{return {model: model}},
title: 'Page Title',
description: 'A description of the page (useful for meta data)',
path: '/',
slug: 'index',
}
These options are both used internally by kiss and are available in view.
- view = A handlebars view.
- model = A json object, the name of the json file relative to the models folder or a URL for an API endpoint.
- controller = A function that returns a page options object - used for manipulating data in the model.
- title = The page title. Unset, it is the model's
title; with neither, the page's slug, title-cased (about-us→About Us, and a home page →Index) - description = The page's description. kiss sets no fallback: the layout prints it from wherever the site keeps it, this option (
{{description}}) or a model field ({{model.description}}) - config = Config overrides for this page only, merged over the global config
- path = the folder path to the page
- slug = the name of the file without the extension
- generate = whether to build this page at all (default
true); set tofalseto skip it entirely — e.g. a fanned-out.pages()item that fails a check in its controller. Nothing is written for that page, and.generate()/.complete()still resolve normally. It istrue,falseor left out (undefinedis the default): any other value, such as0,'',nullor a string from a CMS field, fails that page withgenerate must be true or false, because it used to skip the page while{{link}}still resolved to it.
path and slug make the URL: /{path}/{slug}.html.
Note: If you don't pass a path or a slug they will be inferred from the view — but only when the view is a .hbs filename. A view passed as a template string has no file path to infer from, so it gets a generated snippet-N slug and no folder; pass a slug (and a path, if you want one) yourself.
Note: Slugs and path segments are slugified: accented Latin letters are transliterated (Über uns → uber-uns), anything else outside a-z0-9 becomes a -, and leading/trailing dashes are trimmed. A title written in a script with no Latin equivalent (Japanese, Korean, Cyrillic…) has nothing to transliterate, so it falls back to a short stable hash such as p-9736ca69 — not pretty, but unique, which keeps each page in its own file. Pass an explicit slug when you want a readable URL for such a page. If two pages end up with the same output path the build fails and names the path — only one of them could ever exist on disk.
What the option list above leaves implicit:
- A
modelgiven as a folder name loads every*.jsonin it as an array. A URL model whose response is not 2xx fails that page. extdefaults tohtml. A leading.is stripped and the rest slugified, so anextderived from model data cannot steer the output path ('.xml'→xml,'../x'→x).aliaseslists old URL paths this page now answers, each emitted as a301(see Redirects). On a.pages()fan-out the aliases belong to each record, never to the registration.configoverrides settings for this page only, includingextensionLess: on anextensionLesssite,config: { extensionLess: false }withslug: '404'writes404.htmlrather than404/index.html, which is the filename Netlify and Cloudflare Pages look for and will not fall back from.
.pages()
.pages() takes the options .page() does, but its model is an array and it builds one page per record. The array can be one JSON file holding it (models/bakes.json, [{ "slug": "sourdough", "name": "Sourdough" }, …]) or a models folder of one file per record. Each page's view sees its own record as model:
import Kiss from 'kiss-ssg'
const kiss = new Kiss()
kiss
.pages({
view: 'bakes/bake.hbs',
model: 'bakes.json',
path: 'bakes',
controller: ({ model }) => ({ slug: model.slug, title: model.name }),
})
.generate()
That writes bakes/sourdough.html and bakes/rye.html. A record's own slug is ignored unless a controller returns it. Without the controller line the same call writes bakes/bakes-bake-1.html and bakes/bakes-bake-2.html (the view's folder and name, then -N), and the build still passes. title follows the same rule: a record's name is not read, and without a title of its own the page is titled from its slug (Bakes Bake 1). And .pages() writes to the folder path names, and to the build root when there is none; it does not take the view's folder as .page() does.
You can also pass a option mapper to act as a controller to the .page() and .pages() methods:
import Kiss from 'kiss-ssg'
const kiss = new Kiss()
kiss
.page({
title: 'My Team Page',
view: 'about/index.hbs',
model: 'departments.json',
controller: ({ model }) => {
return {
model: model.sort(
(a, b) => parseInt(a.sort_order) - parseInt(b.sort_order),
),
}
},
})
.generate()
Identity. Every page has an id — what {{link "<id>"}} resolves (see Helpers). It defaults to the view's route without its extension (blog/listing.hbs → blog/listing). Only .hbs is removed: menu/index.hbs is menu/index, not menu, and the home page index.hbs is index — set id yourself when you want the shorter name, so most pages need nothing; set id when you want a stable name (id: 'blog'), or when two .page() calls render one view (pagination), where neither page gets the default id and a notice says so. On a .pages() fan-out an item's default id is the registration's route — or the registration's own id, used as a prefix — plus the item's slug (blog/post/the-cascara-experiment), and a record may carry its own id, which wins outright; the registration's id is never broadcast to the items, the same rule aliases follows. A controller may return one. An explicit id is kept exactly as written, but the page is written under utils.toSlug of its slug, so an id built from a slug the data gives (id: `blog/${slug}`) should slug it the same way (utils.toSlug(slug)). Otherwise a slug with a trailing space or a leading - gives an id that names a page kiss did not write, and the audit's id-slug-mismatch says so. Two pages claiming one explicit id fail the build (Page id already claimed: <id>), an explicit id beats a colliding default one, and a generate: false page claims no id at all.
Ids from where a page is served: ids: 'path'. On a site whose pages share views (pagination, or a catalog that picks the view per item), the view's route names no single page, so most pages need an explicit id. ids: 'path' in the config makes the default id where the page is served instead: its output path with .html removed, then a trailing /index removed. So c/acute-medicine-courses.html is c/acute-medicine-courses, courses/index.html and about/index.html (under extensionLess) are courses and about, and the home page is index. Every other extension stays, so feed.xml is feed.xml, and on an extensionLess site, where a non-HTML page is written to feed/index.xml, it is feed/index.xml. A fan-out item gets its path too, so a .pages() registration's own id is not used as a prefix (a notice says so). Its id follows the slug it is written under, so set slug in its controller (otherwise it is the view's name plus -N, as everywhere). {{link "blog" slug=post.slug}} reaches the item written under blog/, and slugs its slug= with utils.toSlug as the page's file name is, so the slug the data gives works as it is. A catalog that picks the view per item does it in the controller, which may return view like any other page option. Explicit ids still win, and two outputs that reduce to one id (courses.html and courses/index.html) are both withdrawn, with a notice. An inline template with no slug (whose slug kiss makes up) gets no id. The setting is site-wide: a page's own config: { ids } is ignored.
Switching an existing site to 'path' changes every default id that differs and nothing else: no page moves. Check, before shipping:
- every
{{link}}that names a view route which is not also the page's path. The build fails on each one, so that list is the worklist. config.consent.ui.policyandui.history, including the default policy page idcookies, and the privacy page the audit finds by the idprivacy(oraudit.privacyPage), when those pages are written under apath.- any learning-package page relying on a default id. Its LMS identifier changes, and its learners' records with it, so give it an explicit id first.
AIKB/notes citing old ids, which the next record reports asdangling.
The report carries ids, and the next build's rename finding (moved) pairs pages by id only when the last record used the same setting, so a switch reports no false moves.
Each item's slug gets -N appended unless the controller sets an explicit slug.
Controller
The option mapper is really useful for mapping a slug from the model. This is great for dynamic slugs and a necessity when passing an array of models to the .pages() method to generate a series of pages.
import Kiss from 'kiss-ssg'
const kiss = new Kiss({ test: '123' })
kiss
.page({
title: 'Page Title',
view: 'index.hbs',
})
.pages({
view: 'course.hbs',
model: 'https://{my-cool-api}/courses',
controller: ({ model }) => {
return {
slug: model.slug,
}
},
path: 'courses',
})
.generate()
Note: controllers should stay pure — return new values rather than mutating model (or a nested option such as config.folders) in place. How much an in-place mutation costs you depends on the model kind: a .json file, a models folder, or an http(s):// URL model is re-resolved on every build and on every .watch() whole-site rebuild, so mutating one of those in place is contained to that single build. A plain object model is different — it is replayed from a shallow snapshot of the original .page()/.pages() call, so an object model your controller mutated in place is still mutated on the next rebuild: an in-place array.push(...) or property assignment on it accumulates one more change with every save, and the dev server drifts further from what a fresh build would produce. Returning new values sidesteps the distinction entirely.
Remote models
A model can be a URL, and config.fetch is how you make that usable against a real API. It applies to every http(s) model of that site (it is per instance — there is no per-page override):
const kiss = new Kiss({
fetch: {
headers: { Authorization: `Bearer ${process.env.API_TOKEN}` },
timeout: 15000,
retries: 2,
cache: './.kiss-cache/fetch',
},
})
- headers — sent with every URL-model request. This is where an API token or an
Acceptheader goes. Keep the token itself in an environment variable, not in the config you commit. Templates and controllers see the header names with every value reading'[redacted]'({{config.fetch.headers.Authorization}}renders[redacted]), except one that isundefined,nullor empty, which reads as it is; only the fetch itself sends the real values. With any header set, a redirect to the same origin is followed (at most 5 times) and a redirect to another origin fails that model, so a token never reaches a host you did not name: fetch the final URL directly, or remove the header. With no headers, redirects are followed as the fetch does. - timeout — milliseconds, per attempt, covering the response's headers and its body;
0turns the timeout off. The attempt is aborted and that page fails, naming the URL and the timeout. It defaults to 10 seconds: an API that accepts your connection and then goes quiet, before or after its headers, no longer hangs your build for ever. - retries — extra attempts, a whole number of 0 or more, after a network error or a 5xx, with a short backoff. A 4xx is never retried: the server has already told you the request itself is wrong, so asking again only risks locking a key. If every attempt fails, the error says how many were made.
- cache —
false, or a directory. A successful body is written there under a hash of the URL and the headers you sent (a different token is a different entry), and every later build reads the file instead of fetching: later in the same build, on the nextdev/.watch()rebuild, and in tomorrow's build in a new process. That is what stops a watch rebuild paying a network round trip for every remote model, and it lets you build offline. An error response is never cached. A cache folder that cannot be written is a warning, not a failure: the model is used and the next build fetches again. There is no expiry — delete the directory (or one file inside it) when you want fresh data — and it is build output, so add it to.gitignore. Ignore that folder alone, never the whole of.kiss-cache/: the image encode cache beside it (.kiss-cache/images, under Responsive images) is committed. - maxBytes — the largest body read, in bytes;
0reads any size. It defaults to 50,000,000 (50 MB). A larger body fails that page, naming the URL and the setting, and is not retried: aContent-Lengthover the limit is refused before the body is read, and an undeclared one is stopped on the byte that crosses it. The timeout alone does not bound a body: a fast server sends a great deal in 10 seconds.
Each of the five is checked when new Kiss runs. A retries, timeout or maxBytes given as a string of digits, which is what an environment variable is (timeout: process.env.TIMEOUT), is read as the number; one that is not a number of the right shape (Number(process.env.RETRIES) with the variable unset is NaN, '5,000' is not digits), headers that is not a plain object (a Headers instance or a list of pairs is refused), a cache that is neither a folder nor false (null and '' are refused), or a maxBytes that is not a whole number of 0 or more stops the build with a message naming the setting, never its value. A URL that cannot be parsed, or that holds user:pass@, is refused before any request is made. A failure kiss has no safe words for ends → an error kiss cannot describe (<name>): the error's name is kept, and nothing of its text.
.kiss-cache/fetch/
A cached model is a build input, exactly like a .json file in your models folder: what is in that directory is what your site is built from. A response with a non-2xx status fails that page: an error body is never used as the model, and never cached.
A credential belongs in headers, not in the model URL. Wherever kiss records or prints a model URL — the site map and last-build.json that kiss-ssg aikb writes into your committed AIKB/ folder, kiss-ssg check's output, a log line, an error message — it drops any user:pass@ and the fragment, and replaces every query value with [redacted], keeping the names: https://cdn.example.com/entries?access_token=SECRET&type=course is shown as https://cdn.example.com/entries?access_token=[redacted]&type=[redacted]. A URL the platform cannot parse is shown with everything between its scheme and its last @ dropped and its query cut to ?[redacted], or as only its scheme and [redacted] when that @ comes after a ? or #. The request itself, the cache entry, and the id that .generate(), .complete() and .getModelByID() give back to you all keep the URL as you wrote it. A secret in the URL's path cannot be told from a path segment and is recorded as written, so put a credential in fetch.headers. Two URL models that differ only in a query value share one row of the site map.
Markdown options
config.markdown is handed to this instance's markdown-it (opens in a new tab), kiss.markdown — the renderer behind both .md partials and the {{markdown}} helper, so the two can never disagree:
new Kiss({
markdown: { breaks: true, typographer: true },
})
The three defaults (html: true, xhtmlOut: true, breaks: false) are the keys kiss has an opinion about, not the keys it accepts — the block is passed through as-is, so any other markdown-it option (opens in a new tab) (typographer, linkify, quotes, highlight) reaches the renderer without kiss knowing its name. It is merged one level deep, like sass, fetch and assets, so setting one key keeps the rest at their defaults.
breaks: false is deliberate. With breaks: true, every newline inside a paragraph becomes a <br /> — so a paragraph you hard-wrapped in your editor renders with a line break at each of the source's wrap points, mid-sentence. Turn it on only if your .md sources genuinely treat a newline as a line break.
html: true renders HTML in Markdown as written. That is right for your own files and wrong for Markdown a CMS or a feed serves: a model string holding <script>alert(1)</script> passed to {{markdown}} is written into the page as that <script>. A site that renders Markdown it did not write sets markdown: { html: false }, and the same string is written as the text <script>alert(1)</script>. Every tag is escaped then, harmless ones such as <b> too, while the Markdown itself (**bold**, links) still renders.
Under html: false, a .md partial escapes the HTML its own helpers insert. Handlebars runs first, so the markup a {{> "nav"}} or a helper writes is part of what Markdown reads, and it is escaped with the rest. The same is true inside a {{#markdown}} block. Where a partial needs both, make it a .hbs partial and keep the call outside the block:
{{#markdown}}Some **prose**.{{/markdown}}
{{> "nav"}}
Set the options in config. A markdown-it plugin (opens in a new tab) goes on with kiss.markdown.use(plugin) after construction: .md partials and the {{markdown}} helper both render with it per page. Footnotes, for example, are a plugin rather than a default:
// npm install markdown-it-footnote
import footnote from 'markdown-it-footnote'
const kiss = new Kiss()
kiss.markdown.use(footnote)
Partial transforms
A partial's extension says what happens to it. .hbs and .html are compiled as templates; .md is compiled and then rendered as Markdown; .txt is escaped and shown, never compiled. A .md partial is a template first, so {{link "about"}}, {{> "nav"}} and hash arguments work in it:
See [the about page]({{link "about"}}).
{{> "callout" tone="warn"}}
Because Handlebars runs before Markdown, what it inserts is Markdown's input:
- a
*or_in an inserted value can become emphasis; - a partial called on a line indented four spaces becomes a code block;
- a partial call alone on its line swallows that line's newline (Handlebars' "standalone" rule), so give it a blank line before and after; if the called file has no final newline, the paragraph after the call loses its
<p>, and a second blank line puts it back; - under
markdown.html: falseinserted HTML is escaped (see Markdown options).
config.transforms adds an extension, or replaces the md or txt built-in. Each key is an extension without the dot:
let highlight
new Kiss({
transforms: {
code: {
compile: false,
setup: async () => {
const highlighter = await import('./lib/highlight.js')
highlight = highlighter.highlight
},
render: (output, { hash }) =>
`<pre><code>${highlight(output, hash.lang)}</code></pre>`,
},
},
})
With that, src/partials/install.code is a partial, and {{> "install" lang="sh"}} shows it highlighted. A partial's name drops its extension whatever the type, so install.code is "install", and it must not share that name with another partial or a layout.
render(output, ctx)is required. It is called each time a page uses the partial, with the compiled template's output, and returns the string inserted into the page as it is.ctxis{ name, file, page, context, hash }: the partial's name and file, the output path of the page rendering it, the partial's context, and the call's hash arguments ({}when there are none). It is synchronous; anything async goes insetup.compileistrueby default.falsehandsrenderthe file's own characters, so{{ }}in it is not interpolated.setup()is optional and awaited once, before the first page renders: the place to load a library that starts asynchronously. If it throws, the build reports<transform .code>and every page that calls a.codepartial fails, naming it; the rest of the site still builds. It runs once per process and a watch rebuild does not run it again, so do not read the site's content in it: under--devan edit to that file would not be seen.
examples/15-partial-transforms is a whole site built this way: a .md partial that links with {{link}} and calls a partial, a csv transform that renders a spreadsheet export as a price table (compile: false, its caption from hash), and a recipe transform for brew-recipe cards that is compiled first, so a step can use {{link}}. Its transforms live in transforms.js beside the router, because a function written inside router.js cannot be imported or tested.
html and hbs are templates, and a transform for either is refused. A file whose extension is in neither list is not a partial. Two files that give one name (note.md and note.hbs, or a partial and a layout) fail the build as <partials>, naming both files, and every page that calls the name fails too: rename all but one.
