Compare commits

...

5 Commits

Author SHA1 Message Date
Gregor Lohaus
ca4a02ab4e version bump 2026-04-15 22:47:28 +02:00
Gregor Lohaus
0fd19254e9 eq directive 2026-04-15 22:37:13 +02:00
Gregor Lohaus
5edb1139b9 bump version 2026-04-08 01:31:23 +02:00
Gregor Lohaus
020ac91ab9 correct readme, add lisence 2026-04-08 01:30:52 +02:00
Gregor Lohaus
b9f675d1b9 scoped package 2026-04-08 01:16:32 +02:00
10 changed files with 105 additions and 138 deletions

View File

@@ -5,7 +5,7 @@ Treat a directory as a template. File paths and contents support conditionals (`
## Install
```sh
bun install tdir zod
bun add @gregorlohaus/tdir zod
```
## Quick start
@@ -58,8 +58,9 @@ render("./output", {
| Directive | Description |
|---|---|
| `<@if(context.x)>` | Conditional block (must end with `<@endif>`) |
| `<@elseif(context.y)>` | Else-if branch |
| `<@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`) |
@@ -69,7 +70,8 @@ render("./output", {
| Directive | Description |
|---|---|
| `<@if(context.x)>dirname` | Conditionally include directory/file |
| `<@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.
@@ -117,6 +119,10 @@ 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:

View File

@@ -3,10 +3,11 @@
"devenv": {
"locked": {
"dir": "src/modules",
"lastModified": 1775585812,
"lastModified": 1776271913,
"narHash": "sha256-j/1hNdZSci/jrYEHj3/F24EI/YE8DL0OzfMWZUgpMig=",
"owner": "cachix",
"repo": "devenv",
"rev": "35b8c42eb10c196dc84611852325c722b6f10750",
"rev": "2012662a89ff2ce92044151d7bbf3894eec5620a",
"type": "github"
},
"original": {
@@ -16,71 +17,16 @@
"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,
"lastModified": 1776097194,
"narHash": "sha256-XD4DsgNcfXC5nlCxlAcCP5hSjTYlgLXEIoTj7fKkQg4=",
"owner": "cachix",
"repo": "devenv-nixpkgs",
"rev": "fa7125ea7f1ae5430010a6e071f68375a39bd24c",
"rev": "6e8a07b02f6f8557ffab71274feac9827bcc2532",
"type": "github"
},
"original": {
@@ -93,11 +39,11 @@
"nixpkgs-src": {
"flake": false,
"locked": {
"lastModified": 1769922788,
"narHash": "sha256-H3AfG4ObMDTkTJYkd8cz1/RbY9LatN5Mk4UF48VuSXc=",
"lastModified": 1775888245,
"narHash": "sha256-nwASzrRDD1JBEu/o8ekKYEXm/oJW6EMCzCRdrwcLe90=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "207d15f1a6603226e1e223dc79ac29c7846da32e",
"rev": "13043924aaa7375ce482ebe2494338e058282925",
"type": "github"
},
"original": {
@@ -110,14 +56,10 @@
"root": {
"inputs": {
"devenv": "devenv",
"git-hooks": "git-hooks",
"nixpkgs": "nixpkgs",
"pre-commit-hooks": [
"git-hooks"
]
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}
}

View File

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

View File

@@ -289,3 +289,11 @@ test("if elseif else",() => {
expect(content).toContain("test3")
})
test("if eq in path", () => {
const createRenderer = initRenderer("./testdata/eq_in_path")
const render = createRenderer(z.object({test: z.string()}))
render(tmp,{test:"test"})
expect(existsSync(join(tmp,"test"))).toBe(true)
render(tmp,{test:"foo"})
expect(existsSync(join(tmp,"test"))).toBe(false)
})

View File

@@ -2,53 +2,6 @@ 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
}

View File

@@ -1,14 +1,26 @@
{
"name": "tdir",
"module": "index.ts",
"name": "@gregorlohaus/tdir",
"version": "0.1.2",
"license": "MIT",
"type": "module",
"devDependencies": {
"@types/bun": "^1.3.11"
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"peerDependencies": {
"files": ["dist"],
"scripts": {
"build": "bun build ./index.ts --outdir ./dist --target node --external zod && bunx tsc --project tsconfig.build.json",
"test": "bun test"
},
"devDependencies": {
"@types/bun": "^1.3.11",
"typescript": "^5"
},
"dependencies": {
"zod": "^4.3.6"
"peerDependencies": {
"zod": "^4"
}
}

View File

@@ -6,12 +6,26 @@ export type TemplateVariable = {
type: string
}
const IF_RE = /<@if\(context\.(.+?)\)>/g
const IF_RE = /<@(?:if|elseif)\((.+?)\)>/g
const VAR_RE = /<@var\(context\.(.+?)(?::(\w+))?\)>/g
const EQ_RE = /^eq\(context\.(.+?),\s*"(.*)"\)$/
const PATH_RE = /^context\.(.+)$/
function extractCondition(expr: string, vars: TemplateVariable[]) {
const eqMatch = expr.match(EQ_RE)
if (eqMatch) {
vars.push({ path: eqMatch[1]!, type: "string" })
return
}
const pathMatch = expr.match(PATH_RE)
if (pathMatch) {
vars.push({ path: pathMatch[1]!, type: "boolean" })
}
}
function extractFromString(text: string, vars: TemplateVariable[]) {
for (const match of text.matchAll(IF_RE)) {
vars.push({ path: match[1]!, type: "boolean" })
extractCondition(match[1]!, vars)
}
for (const match of text.matchAll(VAR_RE)) {
vars.push({ path: match[1]!, type: match[2] ?? "string" })

View File

@@ -1,9 +1,23 @@
import { readdirSync, statSync, readFileSync, mkdirSync, writeFileSync } from "node:fs"
import { readdirSync, statSync, readFileSync, mkdirSync, writeFileSync, rmSync } from "node:fs"
import { join } from "node:path"
const IF_PATH_RE = /^<@if\(context\.(.+?)\)>(.*)$/
const IF_PATH_RE = /^<@if\((.+?)\)>(.*)$/
const VAR_RE = /<@var\(context\.(.+?)(?::(\w+))?\)>/g
const DIRECTIVE_RE = /<@(if|elseif|else|endif)(?:\(context\.(.+?)\))?>/g
const DIRECTIVE_RE = /<@(if|elseif|else|endif)(?:\((.+?)\))?>/g
const EQ_RE = /^eq\(context\.(.+?),\s*"(.*)"\)$/
const PATH_RE = /^context\.(.+)$/
function evalCondition(expr: string, context: Record<string, unknown>): boolean {
const eqMatch = expr.match(EQ_RE)
if (eqMatch) {
return resolve(context, eqMatch[1]!) === eqMatch[2]
}
const pathMatch = expr.match(PATH_RE)
if (pathMatch) {
return !!resolve(context, pathMatch[1]!)
}
throw new Error(`Invalid condition expression: ${expr}`)
}
function resolve(context: Record<string, unknown>, path: string): unknown {
const segments = path.split(".")
@@ -36,7 +50,7 @@ function processIfBlocks(content: string, context: Record<string, unknown>): str
if (directive === "if") {
if (isEmitting()) result += content.slice(pos, match.index)
const truthy = !!resolve(context, condPath!)
const truthy = evalCondition(condPath!, context)
stack.push({ matched: truthy, active: truthy })
pos = re.lastIndex
} else if (directive === "elseif") {
@@ -48,7 +62,7 @@ function processIfBlocks(content: string, context: Record<string, unknown>): str
// A previous branch already matched — skip this one
top.active = false
} else {
const truthy = !!resolve(context, condPath!)
const truthy = evalCondition(condPath!, context)
top.matched = truthy
top.active = truthy
}
@@ -87,6 +101,15 @@ function renderDir(
srcDir: string,
destDir: string,
context: Record<string, unknown>,
) {
rmSync(destDir, { recursive: true, force: true })
renderDirInner(srcDir, destDir, context)
}
function renderDirInner(
srcDir: string,
destDir: string,
context: Record<string, unknown>,
) {
mkdirSync(destDir, { recursive: true })
const entries = readdirSync(srcDir).sort()
@@ -99,8 +122,7 @@ function renderDir(
const ifMatch = entry.match(IF_PATH_RE)
let outputName = entry
if (ifMatch) {
const conditionPath = ifMatch[1]!
if (!resolve(context, conditionPath)) continue
if (!evalCondition(ifMatch[1]!, context)) continue
outputName = ifMatch[2]!
}
@@ -111,7 +133,7 @@ function renderDir(
const destPath = join(destDir, outputName)
if (stat.isDirectory()) {
renderDir(srcPath, destPath, context)
renderDirInner(srcPath, destPath, context)
} else {
mkdirSync(destDir, { recursive: true })
const content = readFileSync(srcPath, "utf-8")

View File

10
tsconfig.build.json Normal file
View File

@@ -0,0 +1,10 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": false,
"declaration": true,
"emitDeclarationOnly": true,
"outDir": "./dist"
},
"include": ["index.ts", "parser.ts", "render.ts"]
}