diff --git a/README.md b/README.md index ebdd51f..d937ed2 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/devenv.lock b/devenv.lock index 53a529d..2245218 100644 --- a/devenv.lock +++ b/devenv.lock @@ -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 -} +} \ No newline at end of file diff --git a/index.test.ts b/index.test.ts index 853fbd2..4a8dbfa 100644 --- a/index.test.ts +++ b/index.test.ts @@ -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) +}) diff --git a/index.ts b/index.ts index 55808eb..75f0f83 100644 --- a/index.ts +++ b/index.ts @@ -2,53 +2,6 @@ import { z } from "zod" import { parse, type TemplateVariable } from "./parser" import { renderDir } from "./render" -const zodTypeMap: Record z.ZodType> = { - string: () => z.string(), - number: () => z.number(), - boolean: () => z.boolean(), -} - -type ShapeNode = { - type?: string - children: Map -} - -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 = {} - 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 } diff --git a/parser.ts b/parser.ts index 09bff9a..e709613 100644 --- a/parser.ts +++ b/parser.ts @@ -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" }) diff --git a/render.ts b/render.ts index c2dcc37..98cfaca 100644 --- a/render.ts +++ b/render.ts @@ -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): 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, path: string): unknown { const segments = path.split(".") @@ -36,7 +50,7 @@ function processIfBlocks(content: string, context: Record): 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): 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, +) { + rmSync(destDir, { recursive: true, force: true }) + renderDirInner(srcDir, destDir, context) +} + +function renderDirInner( + srcDir: string, + destDir: string, + context: Record, ) { 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") diff --git "a/testdata/eq_in_path/<@if(eq(context.test,\"test\"))>test" "b/testdata/eq_in_path/<@if(eq(context.test,\"test\"))>test" new file mode 100644 index 0000000..e69de29