Falz Docs

Pipes

Our layouts now give posts a shared presentation. But posts/hello.md still has to be called hello.md to get /posts/hello/. We want the document to choose its URL independently of its filename.

The built-in md pipe converts Markdown. A custom pipe can keep that conversion and add our own interpretation of the input. The layout still decides which files match and where output goes; the pipe decides what their bytes mean.

Give a post some metadata

Replace posts/hello.md with:

---
title: First post
slug: first
---
# Hello

This is my first post.

The opening YAML block is front matter. It is a convention our pipe will recognize, not something every Falz source file needs.

Extend the conversion

Create the pipe:

falz add pipe page

This creates .falz/pipes/page/ with index.ts, pipe configuration, a package file, and TypeScript configuration. Editor declarations live under .falz/types/.

Replace .falz/pipes/page/index.ts:

import { commonOptions, toHtml } from "md"
import { parse } from "yaml"

function splitDocument(input: Buffer): { meta: Record<string, unknown>, body: string } {
  const text = input.toString("utf8")
  const match = text.match(/^---\s*\n([\s\S]*?)\n---\s*\n?/)
  if (!match) return { meta: {}, body: text }
  return { meta: parse(match[1]) ?? {}, body: text.slice(match[0].length) }
}

export function transform(this: tCtx, input: Buffer): TransformResult {
  const { meta, body } = splitDocument(input)
  return {
    content: toHtml(body, commonOptions),
    alias: String(meta.slug ?? this.file.stem),
  }
}

Falz supplies input as a Buffer, even for text. We decode it, split off the metadata, and send just the body through the built-in Markdown module. No npm packages are needed: md and yaml are provided by Falz.

transform() may return a plain string or Buffer. Here it returns a TransformResult because we also need an alias, a value the layout can use in the output path. If the document has no slug, the pipe falls back to the filename stem.

The this: tCtx annotation types Falz's per-entry context; this.file describes the source being processed. Keep this as an ordinary function, not an arrow function, so Falz can supply this.

Connect the pipe to the posts layout

Replace the pipes setting in .falz/config.yaml:

output: dist
pipes: [md, page]

In .falz/schemes/posts/layout.xml, replace the posts content rule, retaining the nested asset:

<Content name="article" src="*.md" pipe="page" to="{ALIAS}/index.html">
  <Asset name="images" src="*.{png,jpg,jpeg,webp}" to="images/{BASENAME}"/>
</Content>
falz build

Open /posts/first/. The source is still posts/hello.md, but the returned alias first takes the place of {ALIAS}. The front matter is not rendered into the page. Home and about still use md; only posts use page.

The old /posts/hello/ output is removed when the existing entry's destination changes. The custom-pipe checkpoint includes the complete site at this point.

More than the current file

A transform can also read rule variables through this.vars, project values through this.globals, and other source files through Falz's read-only fs module. For example, a document could look up an author in a sibling JSON file. The JavaScript API describes these contexts and modules.

Pipes are synchronous: a returned Promise is not awaited. Each call gets a fresh JavaScript runtime, so module-level mutable state cannot reliably carry information between posts. For supported imports, npm packages, shared helpers, and timeouts, see the pipe reference.

We can now interpret each post independently. What if every post should include a list of all posts, using their titles and slugs? That needs information from more than one transform. Index phase adds a preparation phase for that shared data.