3.7 KiB
tdir
Treat a directory as a template. File paths and 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.
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 "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 — truthy 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 (truthy 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 "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: Shema doesnt 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: Shema doesnt 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).
Unmatched directives
A <@if> without a matching <@endif> throws at render time:
// If a template file contains <@if(context.x)> with no <@endif>
render("./output", { x: true })
// Error: Unmatched <@if> without <@endif>