This commit is contained in:
Gregor Lohaus
2026-04-08 01:05:23 +02:00
commit 0e552ec4f5
21 changed files with 1121 additions and 0 deletions

12
.envrc Normal file
View File

@@ -0,0 +1,12 @@
#!/usr/bin/env bash
export DIRENV_WARN_TIMEOUT=20s
eval "$(devenv direnvrc)"
# `use devenv` supports the same options as the `devenv shell` command.
#
# To silence all output, use `--quiet`.
#
# Example usage: use devenv --quiet --impure --option services.postgres.enable:bool true
use devenv

45
.gitignore vendored Normal file
View File

@@ -0,0 +1,45 @@
# dependencies (bun install)
node_modules
# output
out
dist
*.tgz
# code coverage
coverage
*.lcov
# logs
logs
_.log
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# caches
.eslintcache
.cache
*.tsbuildinfo
# IntelliJ based IDEs
.idea
# Finder (MacOS) folder config
.DS_Store
# Devenv
.devenv*
devenv.local.nix
devenv.local.yaml
# direnv
.direnv
# pre-commit
.pre-commit-config.yaml

4
.helix/languages.toml Normal file
View File

@@ -0,0 +1,4 @@
[[language]]
name="typescript"
roots = ["package.json"]
language-servers = ["typescript-language-server"]

2
.ignore Normal file
View File

@@ -0,0 +1,2 @@
.devenv
node_modules

106
CLAUDE.md Normal file
View File

@@ -0,0 +1,106 @@
Default to using Bun instead of Node.js.
- Use `bun <file>` instead of `node <file>` or `ts-node <file>`
- Use `bun test` instead of `jest` or `vitest`
- Use `bun build <file.html|file.ts|file.css>` instead of `webpack` or `esbuild`
- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install`
- Use `bun run <script>` instead of `npm run <script>` or `yarn run <script>` or `pnpm run <script>`
- Use `bunx <package> <command>` instead of `npx <package> <command>`
- Bun automatically loads .env, so don't use dotenv.
## APIs
- `Bun.serve()` supports WebSockets, HTTPS, and routes. Don't use `express`.
- `bun:sqlite` for SQLite. Don't use `better-sqlite3`.
- `Bun.redis` for Redis. Don't use `ioredis`.
- `Bun.sql` for Postgres. Don't use `pg` or `postgres.js`.
- `WebSocket` is built-in. Don't use `ws`.
- Prefer `Bun.file` over `node:fs`'s readFile/writeFile
- Bun.$`ls` instead of execa.
## Testing
Use `bun test` to run tests.
```ts#index.test.ts
import { test, expect } from "bun:test";
test("hello world", () => {
expect(1).toBe(1);
});
```
## Frontend
Use HTML imports with `Bun.serve()`. Don't use `vite`. HTML imports fully support React, CSS, Tailwind.
Server:
```ts#index.ts
import index from "./index.html"
Bun.serve({
routes: {
"/": index,
"/api/users/:id": {
GET: (req) => {
return new Response(JSON.stringify({ id: req.params.id }));
},
},
},
// optional websocket support
websocket: {
open: (ws) => {
ws.send("Hello, world!");
},
message: (ws, message) => {
ws.send(message);
},
close: (ws) => {
// handle close
}
},
development: {
hmr: true,
console: true,
}
})
```
HTML files can import .tsx, .jsx or .js files directly and Bun's bundler will transpile & bundle automatically. `<link>` tags can point to stylesheets and Bun's CSS bundler will bundle.
```html#index.html
<html>
<body>
<h1>Hello, world!</h1>
<script type="module" src="./frontend.tsx"></script>
</body>
</html>
```
With the following `frontend.tsx`:
```tsx#frontend.tsx
import React from "react";
import { createRoot } from "react-dom/client";
// import .css files directly and it works
import './index.css';
const root = createRoot(document.body);
export default function Frontend() {
return <h1>Hello, world!</h1>;
}
root.render(<Frontend />);
```
Then, run index.ts
```sh
bun --hot ./index.ts
```
For more information, read the Bun API docs in `node_modules/bun-types/docs/**.mdx`.

128
README.md Normal file
View File

@@ -0,0 +1,128 @@
# 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
```sh
bun install 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 "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 (must end with `<@endif>`) |
| `<@elseif(context.y)>` | Else-if branch |
| `<@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 |
| `<@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 "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`:
```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"
```
## Unmatched directives
A `<@if>` without a matching `<@endif>` throws at render time:
```ts
// If a template file contains <@if(context.x)> with no <@endif>
render("./output", { x: true })
// Error: Unmatched <@if> without <@endif>
```

31
bun.lock Normal file
View File

@@ -0,0 +1,31 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "tdir",
"dependencies": {
"zod": "^4.3.6",
},
"devDependencies": {
"@types/bun": "^1.3.11",
},
"peerDependencies": {
"typescript": "^5",
},
},
},
"packages": {
"@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="],
"@types/node": ["@types/node@25.5.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg=="],
"bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
}
}

123
devenv.lock Normal file
View File

@@ -0,0 +1,123 @@
{
"nodes": {
"devenv": {
"locked": {
"dir": "src/modules",
"lastModified": 1775585812,
"owner": "cachix",
"repo": "devenv",
"rev": "35b8c42eb10c196dc84611852325c722b6f10750",
"type": "github"
},
"original": {
"dir": "src/modules",
"owner": "cachix",
"repo": "devenv",
"type": "github"
}
},
"flake-compat": {
"flake": false,
"locked": {
"lastModified": 1767039857,
"owner": "NixOS",
"repo": "flake-compat",
"rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab",
"type": "github"
},
"original": {
"owner": "NixOS",
"repo": "flake-compat",
"type": "github"
}
},
"git-hooks": {
"inputs": {
"flake-compat": "flake-compat",
"gitignore": "gitignore",
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1775585728,
"owner": "cachix",
"repo": "git-hooks.nix",
"rev": "580633fa3fe5fc0379905986543fd7495481913d",
"type": "github"
},
"original": {
"owner": "cachix",
"repo": "git-hooks.nix",
"type": "github"
}
},
"gitignore": {
"inputs": {
"nixpkgs": [
"git-hooks",
"nixpkgs"
]
},
"locked": {
"lastModified": 1762808025,
"owner": "hercules-ci",
"repo": "gitignore.nix",
"rev": "cb5e3fdca1de58ccbc3ef53de65bd372b48f567c",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "gitignore.nix",
"type": "github"
}
},
"nixpkgs": {
"inputs": {
"nixpkgs-src": "nixpkgs-src"
},
"locked": {
"lastModified": 1774287239,
"owner": "cachix",
"repo": "devenv-nixpkgs",
"rev": "fa7125ea7f1ae5430010a6e071f68375a39bd24c",
"type": "github"
},
"original": {
"owner": "cachix",
"ref": "rolling",
"repo": "devenv-nixpkgs",
"type": "github"
}
},
"nixpkgs-src": {
"flake": false,
"locked": {
"lastModified": 1769922788,
"narHash": "sha256-H3AfG4ObMDTkTJYkd8cz1/RbY9LatN5Mk4UF48VuSXc=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "207d15f1a6603226e1e223dc79ac29c7846da32e",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"devenv": "devenv",
"git-hooks": "git-hooks",
"nixpkgs": "nixpkgs",
"pre-commit-hooks": [
"git-hooks"
]
}
}
},
"root": "root",
"version": 7
}

7
devenv.nix Normal file
View File

@@ -0,0 +1,7 @@
{ pkgs, lib, config, inputs, ... }:
{
packages = [ pkgs.bun ];
languages.typescript.enable = true;
}

15
devenv.yaml Normal file
View File

@@ -0,0 +1,15 @@
# yaml-language-server: $schema=https://devenv.sh/devenv.schema.json
inputs:
nixpkgs:
url: github:cachix/devenv-nixpkgs/rolling
# If you're using non-OSS software, you can set allowUnfree to true.
# allowUnfree: true
# If you're willing to use a package that's vulnerable
# permittedInsecurePackages:
# - "openssl-1.1.1w"
# If you have more than one devenv you can merge them
#imports:
# - ./backend

291
index.test.ts Normal file
View File

@@ -0,0 +1,291 @@
import { expect, test, beforeEach, afterEach } from 'bun:test'
import { initRenderer, SchemaMismatchError } from ".";
import { z } from "zod"
import { mkdtempSync, rmSync, existsSync, readFileSync } from "node:fs"
import { join } from "node:path"
import { tmpdir } from "node:os"
const ifExampleSchema = z.object({
web: z.boolean(),
header: z.object({
render: z.boolean(),
text: z.string()
})
})
let tmp: string
beforeEach(() => {
tmp = mkdtempSync(join(tmpdir(), "tdir-test-"))
})
afterEach(() => {
rmSync(tmp, { recursive: true, force: true })
})
test("createRenderer validates schema matches templates", () => {
const createRenderer = initRenderer("./testdata/if_example")
expect(() => createRenderer(ifExampleSchema)).not.toThrow()
expect(() => createRenderer(z.object({ web: z.boolean() }))).toThrow(SchemaMismatchError)
expect(() => createRenderer(z.object({
web: z.string(),
header: z.object({ render: z.boolean(), text: z.string() })
}))).toThrow(SchemaMismatchError)
})
test("render validates context via zod", () => {
const createRenderer = initRenderer("./testdata/if_example")
const render = createRenderer(ifExampleSchema)
expect(() => render(tmp, {} as any)).toThrow(z.ZodError)
expect(() => render(tmp, { web: "nope" } as any)).toThrow(z.ZodError)
expect(() => render(tmp, { web: true, header: { render: true } } as any)).toThrow(z.ZodError)
})
test("renders with web=true, header rendered", () => {
const createRenderer = initRenderer("./testdata/if_example")
const render = createRenderer(ifExampleSchema)
render(tmp, { web: true, header: { render: true, text: "My Title" } })
const outFile = join(tmp, "web", "if_example.html")
expect(existsSync(outFile)).toBe(true)
const content = readFileSync(outFile, "utf-8")
expect(content).toContain("My Title")
expect(content).toContain("<head>")
expect(content).toContain("<body>")
})
test("wrong schema throws error",() => {
const createRenderer = initRenderer("./testdata/if_example")
expect(() => createRenderer(z.object({
web: z.string(),
header: z.object({
render: z.string(),
text: z.boolean()
})
}))).toThrow(new SchemaMismatchError({message: 'expected z.boolean() but schema has z.string()', path: 'web'}))
})
test("wrong schema throws error",() => {
const createRenderer = initRenderer("./testdata/if_example")
expect(() => createRenderer(z.object({
web: z.boolean(),
header: z.object({
render: z.string(),
text: z.boolean()
})
}))).toThrow(new SchemaMismatchError({message: 'expected z.boolean() but schema has z.string()', path: 'header.render'}))
})
test("wrong schema throws error",() => {
const createRenderer = initRenderer("./testdata/if_example")
expect(() => createRenderer(z.object({
web: z.boolean(),
header: z.object({
render: z.boolean(),
text: z.boolean()
})
}))).toThrow(new SchemaMismatchError({message: 'expected z.string() but schema has z.boolean()', path: 'header.text'}))
})
test("renders with web=true, header not rendered", () => {
const createRenderer = initRenderer("./testdata/if_example")
const render = createRenderer(ifExampleSchema)
render(tmp, { web: true, header: { render: false, text: "Ignored" } })
const outFile = join(tmp, "web", "if_example.html")
expect(existsSync(outFile)).toBe(true)
const content = readFileSync(outFile, "utf-8")
expect(content).not.toContain("Ignored")
expect(content).not.toContain("<head>")
expect(content).toContain("<body>")
})
test("renders with web=false, skips directory", () => {
const createRenderer = initRenderer("./testdata/if_example")
const render = createRenderer(ifExampleSchema)
render(tmp, { web: false, header: { render: true, text: "Hello" } })
expect(existsSync(join(tmp, "web"))).toBe(false)
})
test("vars in path are added to schema",() => {
const createRenderer = initRenderer("./testdata/var_in_path")
expect(() => createRenderer(z.object({
web: z.boolean(),
header: z.object({
render: z.boolean(),
text: z.boolean()
})
}))).toThrow(new SchemaMismatchError({message: 'expected z.object() but schema has z.boolean()', path: 'web'}))
})
test("vars in path renders correctrly",() => {
const createRenderer = initRenderer("./testdata/var_in_path")
const render = createRenderer(z.object({
web: z.object({
create: z.boolean(),
dir: z.string()
}),
header: z.object({
render: z.boolean(),
text: z.string()
})
}))
render(tmp,{
web: {
create: true,
dir: "web"
},
header: {
render: false,
text: "test"
}
})
expect(existsSync(join(tmp, "web"))).toBe(true)
const outFile = join(tmp, "web", "var_in_path_example.html")
expect(existsSync(outFile)).toBe(true)
const content = readFileSync(outFile, "utf-8")
expect(content).not.toContain("Ignored")
expect(content).not.toContain("<head>")
expect(content).toContain("<body>")
})
test("vars in path renders correctrly",() => {
const createRenderer = initRenderer("./testdata/var_in_path")
const render = createRenderer(z.object({
web: z.object({
create: z.boolean(),
dir: z.string()
}),
header: z.object({
render: z.boolean(),
text: z.string()
})
}))
render(tmp,{
web: {
create: true,
dir: "test"
},
header: {
render: true,
text: "test"
}
})
expect(existsSync(join(tmp, "test"))).toBe(true)
const outFile = join(tmp, "test", "var_in_path_example.html")
expect(existsSync(outFile)).toBe(true)
const content = readFileSync(outFile, "utf-8")
expect(content).toContain("test")
expect(content).toContain("<head>")
expect(content).toContain("<body>")
})
test("nested if dir renders correctly",() => {
const createRenderer = initRenderer("./testdata/nested_if_dir")
const render = createRenderer(z.object({
web: z.boolean(),
webnested: z.boolean()
}))
render(tmp,{
web: true,
webnested: true
})
expect(existsSync(join(tmp, "web"))).toBe(true)
})
test("nested if dir renders correctly",() => {
const createRenderer = initRenderer("./testdata/nested_if_dir_named")
const render = createRenderer(z.object({
web: z.boolean(),
webnested: z.boolean()
}))
render(tmp,{
web: true,
webnested: true
})
expect(existsSync(join(tmp, "nested" , "web"))).toBe(true)
})
test("multiple dirs 1 var",() => {
const createRenderer = initRenderer("./testdata/multi_dir_one_var")
const render = createRenderer(z.object({
web: z.boolean(),
}))
render(tmp,{
web: true,
})
expect(existsSync(join(tmp, "dir1"))).toBe(true)
expect(existsSync(join(tmp, "dir2"))).toBe(true)
expect(existsSync(join(tmp, "dir1", "dir1n"))).toBe(true)
})
test("file if",() => {
const createRenderer = initRenderer("./testdata/file_if")
const render = createRenderer(z.object({
web: z.boolean(),
file: z.boolean(),
text: z.string()
}))
render(tmp,{
web: true,
file: true,
text: "test"
})
expect(existsSync(join(tmp, "web"))).toBe(true)
const outFile = join(tmp, "web", "example.txt")
expect(existsSync(outFile)).toBe(true)
const content = readFileSync(outFile, "utf-8")
expect(content).toContain("test")
})
test("no endif should throw",() => {
const createRenderer = initRenderer("./testdata/no_end_if")
const render = createRenderer(z.object({
test: z.boolean(),
}))
expect( () => render(tmp,{
test: true,
})).toThrow()
})
test("if elseif else",() => {
const createRenderer = initRenderer("./testdata/if_elseif_else_in_file")
const render = createRenderer(z.object({
test: z.boolean(),
test2: z.boolean()
}))
render(tmp,{
test: true,
test2: true
})
let outFile = join(tmp, "test.txt")
expect(existsSync(outFile)).toBe(true)
let content = readFileSync(outFile, "utf-8")
expect(content).toContain("test")
render(tmp,{
test: false,
test2: true
})
outFile = join(tmp, "test.txt")
expect(existsSync(outFile)).toBe(true)
content = readFileSync(outFile, "utf-8")
expect(content).toContain("test2")
render(tmp,{
test: false,
test2: false
})
outFile = join(tmp, "test.txt")
expect(existsSync(outFile)).toBe(true)
content = readFileSync(outFile, "utf-8")
expect(content).toContain("test3")
})

120
index.ts Normal file
View File

@@ -0,0 +1,120 @@
import { z } from "zod"
import { parse, type TemplateVariable } from "./parser"
import { renderDir } from "./render"
const zodTypeMap: Record<string, () => z.ZodType> = {
string: () => z.string(),
number: () => z.number(),
boolean: () => z.boolean(),
}
type ShapeNode = {
type?: string
children: Map<string, ShapeNode>
}
function buildTree(variables: TemplateVariable[]): ShapeNode {
const root: ShapeNode = { children: new Map() }
for (const v of variables) {
const segments = v.path.split(".")
let node = root
for (let i = 0; i < segments.length; i++) {
const seg = segments[i]!
if (!node.children.has(seg)) {
node.children.set(seg, { children: new Map() })
}
node = node.children.get(seg)!
if (i === segments.length - 1) {
node.type = v.type
}
}
}
return root
}
function nodeToSchema(node: ShapeNode): z.ZodType {
if (node.children.size === 0 && node.type) {
const factory = zodTypeMap[node.type]
if (!factory) throw new Error(`Unsupported type: ${node.type}`)
return factory()
}
const shape: Record<string, z.ZodType> = {}
for (const [key, child] of node.children) {
shape[key] = nodeToSchema(child)
}
return z.object(shape)
}
function inferSchema(variables: TemplateVariable[]): z.ZodType {
return nodeToSchema(buildTree(variables))
}
interface Stringable {
toString: () => string
}
interface Issue {
message: string,
path: Stringable
}
export class SchemaMismatchError extends Error {
constructor(issue: Issue) {
super(`Shema doesnt match used template variables: ${issue.path}: ${issue.message}` )
}
}
function validateSchemaMatchesTemplates(userSchema: z.ZodType, variables: TemplateVariable[]) {
if (userSchema._zod.def.type !== "object") {
throw new SchemaMismatchError({ path: "", message: "Schema must be a z.object()" })
}
// Collect all expected paths: leaf variables + intermediate segments must be objects
const expected = new Map<string, string>()
for (const v of variables) {
const segments = v.path.split(".")
for (let i = 0; i < segments.length - 1; i++) {
const intermediate = segments.slice(0, i + 1).join(".")
if (!expected.has(intermediate)) {
expected.set(intermediate, "object")
}
}
expected.set(v.path, v.type)
}
for (const [path, expectedType] of expected) {
const segments = path.split(".")
let current = userSchema
let currentPath = ""
for (const seg of segments) {
currentPath = currentPath ? `${currentPath}.${seg}` : seg
if (current._zod.def.type !== "object") {
throw new SchemaMismatchError({ path: currentPath, message: `expected z.object() but schema has z.${current._zod.def.type as string}()` })
}
const shape = (current as z.ZodObject<any>).shape
if (!(seg in shape)) {
throw new SchemaMismatchError({ path: currentPath, message: `missing in schema` })
}
current = shape[seg]
}
const actual = current._zod.def.type as string
if (actual !== expectedType) {
throw new SchemaMismatchError({ path, message: `expected z.${expectedType}() but schema has z.${actual}()` })
}
}
}
export const initRenderer = (dirPath: string) => {
const variables = parse(dirPath)
const createRenderer = <S extends z.ZodType>(userSchema: S) => {
validateSchemaMatchesTemplates(userSchema, variables)
return (targetPath: string, context: z.infer<S>) => {
userSchema.parse(context)
renderDir(dirPath, targetPath, context as Record<string, unknown>)
}
}
return createRenderer
}

14
package.json Normal file
View File

@@ -0,0 +1,14 @@
{
"name": "tdir",
"module": "index.ts",
"type": "module",
"devDependencies": {
"@types/bun": "^1.3.11"
},
"peerDependencies": {
"typescript": "^5"
},
"dependencies": {
"zod": "^4.3.6"
}
}

47
parser.ts Normal file
View File

@@ -0,0 +1,47 @@
import { readdirSync, statSync, readFileSync } from "node:fs"
import { join } from "node:path"
export type TemplateVariable = {
path: string
type: string
}
const IF_RE = /<@if\(context\.(.+?)\)>/g
const VAR_RE = /<@var\(context\.(.+?)(?::(\w+))?\)>/g
function extractFromString(text: string, vars: TemplateVariable[]) {
for (const match of text.matchAll(IF_RE)) {
vars.push({ path: match[1]!, type: "boolean" })
}
for (const match of text.matchAll(VAR_RE)) {
vars.push({ path: match[1]!, type: match[2] ?? "string" })
}
}
function walkDir(dirPath: string, vars: TemplateVariable[]) {
const entries = readdirSync(dirPath).sort()
for (const entry of entries) {
const fullPath = join(dirPath, entry)
extractFromString(entry, vars)
const stat = statSync(fullPath)
if (stat.isDirectory()) {
walkDir(fullPath, vars)
} else if (stat.isFile()) {
const content = readFileSync(fullPath, "utf-8")
extractFromString(content, vars)
}
}
}
export function parse(dirPath: string): TemplateVariable[] {
const vars: TemplateVariable[] = []
walkDir(dirPath, vars)
const seen = new Set<string>()
return vars.filter(v => {
const key = `${v.path}:${v.type}`
if (seen.has(key)) return false
seen.add(key)
return true
})
}

123
render.ts Normal file
View File

@@ -0,0 +1,123 @@
import { readdirSync, statSync, readFileSync, mkdirSync, writeFileSync } from "node:fs"
import { join } from "node:path"
const IF_PATH_RE = /^<@if\(context\.(.+?)\)>(.*)$/
const VAR_RE = /<@var\(context\.(.+?)(?::(\w+))?\)>/g
const DIRECTIVE_RE = /<@(if|elseif|else|endif)(?:\(context\.(.+?)\))?>/g
function resolve(context: Record<string, unknown>, path: string): unknown {
const segments = path.split(".")
let current: unknown = context
for (const seg of segments) {
if (current === null || current === undefined || typeof current !== "object") return undefined
current = (current as Record<string, unknown>)[seg]
}
return current
}
// Each stack frame tracks: did any branch match yet, is the current branch active
type IfFrame = { matched: boolean; active: boolean }
function processIfBlocks(content: string, context: Record<string, unknown>): string {
let result = ""
let pos = 0
const stack: IfFrame[] = []
const re = new RegExp(DIRECTIVE_RE.source, "g")
let match: RegExpExecArray | null
function isEmitting(): boolean {
return stack.every(f => f.active)
}
while ((match = re.exec(content)) !== null) {
const directive = match[1]!
const condPath = match[2]
if (directive === "if") {
if (isEmitting()) result += content.slice(pos, match.index)
const truthy = !!resolve(context, condPath!)
stack.push({ matched: truthy, active: truthy })
pos = re.lastIndex
} else if (directive === "elseif") {
if (stack.length === 0) throw new Error("Unexpected <@elseif> without <@if>")
const top = stack[stack.length - 1]!
// Emit text before this directive if current branch was active
if (isEmitting()) result += content.slice(pos, match.index)
if (top.matched) {
// A previous branch already matched — skip this one
top.active = false
} else {
const truthy = !!resolve(context, condPath!)
top.matched = truthy
top.active = truthy
}
pos = re.lastIndex
} else if (directive === "else") {
if (stack.length === 0) throw new Error("Unexpected <@else> without <@if>")
const top = stack[stack.length - 1]!
if (isEmitting()) result += content.slice(pos, match.index)
top.active = !top.matched
top.matched = true
pos = re.lastIndex
} else if (directive === "endif") {
if (stack.length === 0) throw new Error("Unexpected <@endif> without <@if>")
if (isEmitting()) result += content.slice(pos, match.index)
stack.pop()
pos = re.lastIndex
}
}
if (stack.length > 0) {
throw new Error("Unmatched <@if> without <@endif>")
}
result += content.slice(pos)
return result
}
function renderContent(content: string, context: Record<string, unknown>): string {
const processed = processIfBlocks(content, context)
return processed.replace(VAR_RE, (_match, path: string) => {
return String(resolve(context, path) ?? "")
})
}
function renderDir(
srcDir: string,
destDir: string,
context: Record<string, unknown>,
) {
mkdirSync(destDir, { recursive: true })
const entries = readdirSync(srcDir).sort()
for (const entry of entries) {
const srcPath = join(srcDir, entry)
const stat = statSync(srcPath)
// Check if path segment has an @if directive
const ifMatch = entry.match(IF_PATH_RE)
let outputName = entry
if (ifMatch) {
const conditionPath = ifMatch[1]!
if (!resolve(context, conditionPath)) continue
outputName = ifMatch[2]!
}
// Process @var in the output name
outputName = outputName.replace(VAR_RE, (_match, path: string) => {
return String(resolve(context, path) ?? "")
})
const destPath = join(destDir, outputName)
if (stat.isDirectory()) {
renderDir(srcPath, destPath, context)
} else {
mkdirSync(destDir, { recursive: true })
const content = readFileSync(srcPath, "utf-8")
writeFileSync(destPath, renderContent(content, context))
}
}
}
export { renderDir, renderContent }

View File

@@ -0,0 +1 @@
<@var(context.text)>

View File

@@ -0,0 +1,7 @@
<@if(context.test)>
test
<@elseif(context.test2)>
test2
<@else>
test3
<@endif>

View File

@@ -0,0 +1,7 @@
<document>
<@if(context.header.render)>
<head> <@var(context.header.text)> </head>
<@endif>
<body>
</body>
</document>

2
testdata/no_end_if/test.txt vendored Normal file
View File

@@ -0,0 +1,2 @@
<@if(context.test)>
test

View File

@@ -0,0 +1,7 @@
<document>
<@if(context.header.render)>
<head> <@var(context.header.text)> </head>
<@endif>
<body>
</body>
</document>

29
tsconfig.json Normal file
View File

@@ -0,0 +1,29 @@
{
"compilerOptions": {
// Environment setup & latest features
"lib": ["ESNext"],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"jsx": "react-jsx",
"allowJs": true,
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
}
}