File Source Blocks: Read an Event's Text From Disk

· 6 min read

A source block is how you hand a large body of text to an event. The text sits inside the program, and the event’s source parameter receives it. A block’s text is spliced in as that event’s source region, bound with a name, and discharged like any other value. It is how an HTML template lives inside a Koru program, and how std/fmt gets something large enough to care about formatting.

The problem is where the text lives. A 300-line HTML template inside main.k is unreadable — no syntax highlighting, no editing tools, and it buries the program logic under markup. So a source block should be able to come from a file. That’s the file-source block, and it exists now:

import std/fmt
import std/io

const {
    name: "World"
    count: 42
}

std/fmt:fmt.blk @"banner.txt": f |> std/io:print.ln("{{ f:s }}")

@"banner.txt" is not a string literal. It tells the compiler: read this file at compile time, relative to the source file being compiled, and treat its contents exactly as you would the text inside a { } block. The rest is unchanged — the : f binds the formatted result, and the inline |> continues it, the same grammar as the block-close }: f |> ….

The template file, held beside the source:

Hello, {{ name:s }}! The answer is {{ count:d }}.

And the program, when run:

Hello, World! The answer is 42.

That is the bare spell. It is the file twin of the inline block form, pinned side by side in the suite:

One mechanism, one spelling

The file form is not a special case — it is a second shape over the same machinery the inline block already uses. Write the text inside the program, or read it from a file; the event’s source parameter receives the same source region either way:

  • Inline blockfmt.blk { Hello, {{ name:s }}! }: f |> …. The text is inside the program.
  • File sourcefmt.blk @"banner.txt": f |> …. The text lives in its own file.

Both emit the same thing, and both appear in the suite’s 620 cluster. The AST does not know a file from a block. That is the design smell you want: a feature that disappears into the existing machinery rather than adding a new one.

One thing I’ve deliberately left out: a type tag on the source. The argument past it is real, but it isn’t settled. Source<HTML> parses but sits on the wrong side of the [state]<state> migration — HTML is an annotation, which wants [HTML], and that spelling the parser refuses (KORU033, pinned 210_120). The whole surface is parked on 210_024/210_039 as an open design question. What you see here is the untyped form — fmt.blk’s source: Source has no type parameter, so the bare spelling is exactly the honest one. If the type tag ever lands, it is a follow-on, not a dependency of this feature.

In a chain, the file form is a step

A dashboard like Ward’s runs its HTML through a chain: the configuration event heads the chain, values stream through named steps, and a block sits at the end. When the block’s text moves to a file, the chain keeps its shape — the file source is just the last step:

import std/fmt
import std/io

const {
    count: 42
}

pub tor query { q: i64 }
! ?ask i64 -> i64
| done i64

query = if(ask)
| then |> ask(q): a => done a
| else => done 0

query(q: 41)
| done r |> std/fmt:fmt.blk @"banner.txt": f |> std/io:print.ln("{{ f:s }}")

Here the file source sits in step position — | done r |> fmt.blk @"banner.txt": f |> …. The chain head splits, the terminal reads the file at compile time, binds : f, and continues inline. The HTML is no longer 300 lines inside main.k; the program logic stays readable and the markup keeps its own file, its own syntax highlighting, and its own editing tools. When the template changes, the next compile re-reads it — the file’s content is baked into the binary.

What the mechanism gives you

Because a file source is a block, everything that is true of a block is true of it:

  • Compile-time read, no runtime I/O. The file content is baked into the emitted binary. There is no deployment copy step, no “read this file at startup,” no path that can be missing in prod.
  • Same binding and discharge. The result is bound with : f and the allocator’s obligation flows the same way it does for a block — an allocated slice must be freed, and the toolchain tracks it.
  • Same chain grammar. It can be a chain step, not just a root call.

One difference is worth knowing: an inline block’s text is trimmed by the block parser, while a file’s content is taken byte-for-byte. A trailing newline in a template file will show up in the output. It is the honest behavior — the file is a file — but if you are porting a block to a file and a blank line appears, that is why.

The right way to say it: a source block used to be only the text inside the program. Now the text can live where it belongs — in its own file — and the program says where to find it.