Compare commits
5 Commits
0e552ec4f5
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca4a02ab4e | ||
|
|
0fd19254e9 | ||
|
|
5edb1139b9 | ||
|
|
020ac91ab9 | ||
|
|
b9f675d1b9 |
14
README.md
14
README.md
@@ -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:
|
||||
|
||||
80
devenv.lock
80
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
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{ pkgs, lib, config, inputs, ... }:
|
||||
|
||||
{
|
||||
packages = [ pkgs.bun ];
|
||||
packages = [ pkgs.bun pkgs.nodejs_24];
|
||||
languages.typescript.enable = true;
|
||||
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
47
index.ts
47
index.ts
@@ -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
|
||||
}
|
||||
|
||||
26
package.json
26
package.json
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
18
parser.ts
18
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" })
|
||||
|
||||
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"
|
||||
|
||||
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")
|
||||
|
||||
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
10
tsconfig.build.json
Normal file
10
tsconfig.build.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": false,
|
||||
"declaration": true,
|
||||
"emitDeclarationOnly": true,
|
||||
"outDir": "./dist"
|
||||
},
|
||||
"include": ["index.ts", "parser.ts", "render.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user