tdir
Treat a directory as a template. File paths and text file contents support conditionals (@if/@elseif/@else) and variable substitution (@var). Provide a Zod schema and tdir validates it matches the template at setup time, then validates context at render time. Binary files are copied through unchanged.
Install
bun add @gregorlohaus/tdir zod
Quick start
Given a template directory:
templates/
<@if(context.web)>web/
index.html
Where index.html contains:
<html>
<@if(context.header.show)>
<head><@var(context.header.title)></head>
<@endif>
<body></body>
</html>
Render it:
import { initRenderer } from "@gregorlohaus/tdir"
import { z } from "zod"
const createRenderer = initRenderer("./templates")
const render = createRenderer(z.object({
web: z.boolean(),
header: z.object({
show: z.boolean(),
title: z.string()
})
}))
render("./output", {
web: true,
header: { show: true, title: "Hello" }
})
// Creates: output/web/index.html with <head>Hello</head>
Template directives
In file contents
| Directive | Description |
|---|---|
<@if(context.x)> |
Conditional block — boolean check (must end with <@endif>) |
<@if(eq(context.x,"value"))> |
Conditional block — string equality check |
<@elseif(context.y)> |
Else-if branch (same forms as @if) |
<@else> |
Else branch |
<@endif> |
End conditional block |
<@var(context.x)> |
Substitute with context value (default type: string) |
<@var(context.x:number)> |
Substitute with explicit type |
In directory/file names
| Directive | Description |
|---|---|
<@if(context.x)>dirname |
Conditionally include directory/file (boolean check) |
<@if(eq(context.x,"value"))>dirname |
Conditionally include by string equality |
<@var(context.x)> |
Dynamic directory/file name |
These can be combined: <@if(context.web.create)><@var(context.web.dir)> creates a directory named by context.web.dir only if context.web.create is true.
Schema validation
createRenderer validates that your Zod schema matches the template variables. Mismatches throw SchemaMismatchError:
import { initRenderer, SchemaMismatchError } from "@gregorlohaus/tdir"
import { z } from "zod"
const createRenderer = initRenderer("./templates")
// Template uses <@if(context.web)> which requires a boolean,
// but schema declares string -- throws SchemaMismatchError
createRenderer(z.object({
web: z.string(), // wrong type
header: z.object({ show: z.boolean(), title: z.string() })
}))
// SchemaMismatchError: Schema doesn't match used template variables: web: expected z.boolean() but schema has z.string()
// Schema is missing fields used in templates -- throws SchemaMismatchError
createRenderer(z.object({
web: z.boolean()
// missing header
}))
// SchemaMismatchError: Schema doesn't match used template variables: header: missing in schema
Context validation
At render time, the context is validated by Zod. Invalid context throws z.ZodError:
const render = createRenderer(z.object({
web: z.boolean(),
header: z.object({ show: z.boolean(), title: z.string() })
}))
render("./output", {})
// ZodError: required at "web", required at "header"
render("./output", { web: "not a boolean", header: { show: true, title: "Hi" } })
// ZodError: expected boolean, received string at "web"
Re-rendering
render(target, context) clears target before writing, so rendering the same template into the same directory with different contexts always produces a clean result (files/paths excluded by conditionals won't linger from a previous run).
For safety, tdir refuses to render into the filesystem root, the current working directory, the home directory, or any directory that overlaps the template source. Dynamic file and directory names are also resolved against the output directory and cannot write outside it.
Unmatched directives
A <@if> without a matching <@endif> throws when the renderer is initialized:
// If a template file contains <@if(context.x)> with no <@endif>
initRenderer("./templates")
// Error: Unmatched <@if> without <@endif>