File Source Blocks: Read an Event's Text From Disk
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 block —
fmt.blk { Hello, {{ name:s }}! }: f |> …. The text is inside the program. - File source —
fmt.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
: fand 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.