245 lines
7.4 KiB
Markdown
245 lines
7.4 KiB
Markdown
# 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
|
|
|
|
```sh
|
|
bun add @gregorlohaus/tdir zod
|
|
```
|
|
|
|
## Quick start
|
|
|
|
Given a template directory:
|
|
|
|
```
|
|
templates/
|
|
<@if(context.web)>web/
|
|
index.html
|
|
```
|
|
|
|
Where `index.html` contains:
|
|
|
|
```html
|
|
<html>
|
|
<@if(context.header.show)>
|
|
<head><@var(context.header.title)></head>
|
|
<@endif>
|
|
<body></body>
|
|
</html>
|
|
```
|
|
|
|
Render it:
|
|
|
|
```ts
|
|
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`:
|
|
|
|
```ts
|
|
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`:
|
|
|
|
```ts
|
|
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.
|
|
|
|
## Reverse maps
|
|
|
|
Pass `{ reverseMap: true }` as the third render argument to write `.tdir-map.json` into the output directory:
|
|
|
|
```ts
|
|
render("./output", {
|
|
web: true,
|
|
header: { show: true, title: "Hello" }
|
|
}, { reverseMap: true })
|
|
```
|
|
|
|
Pass a string to choose a custom JSON path inside the output directory:
|
|
|
|
```ts
|
|
render("./output", context, { reverseMap: "meta/reverse-map.json" })
|
|
```
|
|
|
|
The map contains a flat lookup from rendered strings to template tokens, per-file occurrences with path/range context, inline conditional blocks, and template files skipped by path conditionals:
|
|
|
|
```json
|
|
{
|
|
"version": 1,
|
|
"files": [
|
|
{
|
|
"outputPath": "web/index.html",
|
|
"templatePath": "<@if(context.web)>web/index.html",
|
|
"tokens": [
|
|
{
|
|
"kind": "content",
|
|
"result": "Hello",
|
|
"token": "<@var(context.header.title)>",
|
|
"contextPath": "header.title",
|
|
"outputPath": "web/index.html",
|
|
"templatePath": "<@if(context.web)>web/index.html",
|
|
"range": { "start": 16, "end": 21 }
|
|
},
|
|
{
|
|
"kind": "conditional",
|
|
"result": "<head><@var(context.header.title)></head>",
|
|
"token": "<@if(context.header.show)><head><@var(context.header.title)></head><@endif>",
|
|
"outputPath": "web/index.html",
|
|
"templatePath": "<@if(context.web)>web/index.html",
|
|
"range": { "start": 9, "end": 53 },
|
|
"activeRange": { "start": 27, "end": 71 }
|
|
}
|
|
]
|
|
}
|
|
],
|
|
"skipped": [
|
|
{
|
|
"kind": "file",
|
|
"templatePath": "<@if(context.docs)>docs/readme.md",
|
|
"encoding": "utf8",
|
|
"content": "# Docs"
|
|
}
|
|
],
|
|
"tokens": {
|
|
"Hello": ["<@var(context.header.title)>"]
|
|
}
|
|
}
|
|
```
|
|
|
|
## Reverse CLI
|
|
|
|
Use the reverse map to rebuild template files from an edited rendered directory:
|
|
|
|
```sh
|
|
tdir reverse ./output ./templates
|
|
```
|
|
|
|
Without installing the package first, run the published CLI through Bun:
|
|
|
|
```sh
|
|
bunx @gregorlohaus/tdir reverse ./output ./templates
|
|
```
|
|
|
|
By default, the command reads `./output/.tdir-map.json`. Use `--map` for a custom map path relative to the rendered directory:
|
|
|
|
```sh
|
|
tdir reverse ./output ./templates --map meta/reverse-map.json
|
|
bunx @gregorlohaus/tdir reverse ./output ./templates --map meta/reverse-map.json
|
|
```
|
|
|
|
New files created in the rendered directory are ignored by default. Include them explicitly with one or more glob patterns:
|
|
|
|
```sh
|
|
tdir reverse ./output ./templates --include "components/**"
|
|
tdir reverse ./output ./templates --include "components/**/*.ts" --include "pages/*.html"
|
|
```
|
|
|
|
Programmatically, pass the same globs to `reverseDir`:
|
|
|
|
```ts
|
|
reverseDir("./output", "./templates", {
|
|
include: ["components/**/*.ts", "pages/*.html"]
|
|
})
|
|
```
|
|
|
|
The command writes files at their original template paths, restores recorded `<@var(...)>` tokens, wraps edited inline conditional output back in the original conditional block, and restores template files that were skipped by path conditionals.
|
|
|
|
The template output directory may be inside the rendered directory, for example:
|
|
|
|
```sh
|
|
tdir reverse ./ ./reversed --include "components/**"
|
|
```
|
|
|
|
When the template output directory is inside the rendered directory, reverse snapshots included files before writing and excludes the output directory from include glob matching.
|
|
|
|
## Unmatched directives
|
|
|
|
A `<@if>` without a matching `<@endif>` throws when the renderer is initialized:
|
|
|
|
```ts
|
|
// If a template file contains <@if(context.x)> with no <@endif>
|
|
initRenderer("./templates")
|
|
// Error: Unmatched <@if> without <@endif>
|
|
```
|