eq directive

This commit is contained in:
Gregor Lohaus
2026-04-15 22:37:13 +02:00
parent 5edb1139b9
commit 0fd19254e9
7 changed files with 74 additions and 129 deletions

View File

@@ -58,8 +58,9 @@ render("./output", {
| Directive | Description | | Directive | Description |
|---|---| |---|---|
| `<@if(context.x)>` | Conditional block (must end with `<@endif>`) | | `<@if(context.x)>` | Conditional block — truthy check (must end with `<@endif>`) |
| `<@elseif(context.y)>` | Else-if branch | | `<@if(eq(context.x,"value"))>` | Conditional block — string equality check |
| `<@elseif(context.y)>` | Else-if branch (same forms as `@if`) |
| `<@else>` | Else branch | | `<@else>` | Else branch |
| `<@endif>` | End conditional block | | `<@endif>` | End conditional block |
| `<@var(context.x)>` | Substitute with context value (default type: `string`) | | `<@var(context.x)>` | Substitute with context value (default type: `string`) |
@@ -69,7 +70,8 @@ render("./output", {
| Directive | Description | | 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 | | `<@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. 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" // 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 ## Unmatched directives
A `<@if>` without a matching `<@endif>` throws at render time: A `<@if>` without a matching `<@endif>` throws at render time:

View File

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

View File

@@ -289,3 +289,11 @@ test("if elseif else",() => {
expect(content).toContain("test3") 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 { parse, type TemplateVariable } from "./parser"
import { renderDir } from "./render" 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 { interface Stringable {
toString: () => string toString: () => string
} }

View File

@@ -6,12 +6,26 @@ export type TemplateVariable = {
type: string type: string
} }
const IF_RE = /<@if\(context\.(.+?)\)>/g const IF_RE = /<@(?:if|elseif)\((.+?)\)>/g
const VAR_RE = /<@var\(context\.(.+?)(?::(\w+))?\)>/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[]) { function extractFromString(text: string, vars: TemplateVariable[]) {
for (const match of text.matchAll(IF_RE)) { 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)) { for (const match of text.matchAll(VAR_RE)) {
vars.push({ path: match[1]!, type: match[2] ?? "string" }) 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" import { join } from "node:path"
const IF_PATH_RE = /^<@if\(context\.(.+?)\)>(.*)$/ const IF_PATH_RE = /^<@if\((.+?)\)>(.*)$/
const VAR_RE = /<@var\(context\.(.+?)(?::(\w+))?\)>/g 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 { function resolve(context: Record<string, unknown>, path: string): unknown {
const segments = path.split(".") const segments = path.split(".")
@@ -36,7 +50,7 @@ function processIfBlocks(content: string, context: Record<string, unknown>): str
if (directive === "if") { if (directive === "if") {
if (isEmitting()) result += content.slice(pos, match.index) if (isEmitting()) result += content.slice(pos, match.index)
const truthy = !!resolve(context, condPath!) const truthy = evalCondition(condPath!, context)
stack.push({ matched: truthy, active: truthy }) stack.push({ matched: truthy, active: truthy })
pos = re.lastIndex pos = re.lastIndex
} else if (directive === "elseif") { } 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 // A previous branch already matched — skip this one
top.active = false top.active = false
} else { } else {
const truthy = !!resolve(context, condPath!) const truthy = evalCondition(condPath!, context)
top.matched = truthy top.matched = truthy
top.active = truthy top.active = truthy
} }
@@ -87,6 +101,15 @@ function renderDir(
srcDir: string, srcDir: string,
destDir: string, destDir: string,
context: Record<string, unknown>, 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 }) mkdirSync(destDir, { recursive: true })
const entries = readdirSync(srcDir).sort() const entries = readdirSync(srcDir).sort()
@@ -99,8 +122,7 @@ function renderDir(
const ifMatch = entry.match(IF_PATH_RE) const ifMatch = entry.match(IF_PATH_RE)
let outputName = entry let outputName = entry
if (ifMatch) { if (ifMatch) {
const conditionPath = ifMatch[1]! if (!evalCondition(ifMatch[1]!, context)) continue
if (!resolve(context, conditionPath)) continue
outputName = ifMatch[2]! outputName = ifMatch[2]!
} }
@@ -111,7 +133,7 @@ function renderDir(
const destPath = join(destDir, outputName) const destPath = join(destDir, outputName)
if (stat.isDirectory()) { if (stat.isDirectory()) {
renderDir(srcPath, destPath, context) renderDirInner(srcPath, destPath, context)
} else { } else {
mkdirSync(destDir, { recursive: true }) mkdirSync(destDir, { recursive: true })
const content = readFileSync(srcPath, "utf-8") const content = readFileSync(srcPath, "utf-8")

View File