eq directive
This commit is contained in:
12
README.md
12
README.md
@@ -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:
|
||||||
|
|||||||
80
devenv.lock
80
devenv.lock
@@ -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
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
|||||||
47
index.ts
47
index.ts
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
18
parser.ts
18
parser.ts
@@ -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" })
|
||||||
|
|||||||
38
render.ts
38
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"
|
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")
|
||||||
|
|||||||
0
testdata/eq_in_path/<@if(eq(context.test,"test"))>test
vendored
Normal file
0
testdata/eq_in_path/<@if(eq(context.test,"test"))>test
vendored
Normal file
Reference in New Issue
Block a user