import { expect, test, beforeEach, afterEach } from 'bun:test'
import { initRenderer, reverseDir, SchemaMismatchError } from "./index";
import { z } from "zod"
import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"
import { basename, join } from "node:path"
import { tmpdir } from "node:os"
const ifExampleSchema = z.object({
web: z.boolean(),
header: z.object({
render: z.boolean(),
text: z.string()
})
})
let tmp: string
beforeEach(() => {
tmp = mkdtempSync(join(tmpdir(), "tdir-test-"))
})
afterEach(() => {
rmSync(tmp, { recursive: true, force: true })
})
test("createRenderer validates schema matches templates", () => {
const createRenderer = initRenderer("./testdata/if_example")
expect(() => createRenderer(ifExampleSchema)).not.toThrow()
expect(() => createRenderer(z.object({ web: z.boolean() }))).toThrow(SchemaMismatchError)
expect(() => createRenderer(z.object({
web: z.string(),
header: z.object({ render: z.boolean(), text: z.string() })
}))).toThrow(SchemaMismatchError)
})
test("render validates context via zod", () => {
const createRenderer = initRenderer("./testdata/if_example")
const render = createRenderer(ifExampleSchema)
expect(() => render(tmp, {} as any)).toThrow(z.ZodError)
expect(() => render(tmp, { web: "nope" } as any)).toThrow(z.ZodError)
expect(() => render(tmp, { web: true, header: { render: true } } as any)).toThrow(z.ZodError)
})
test("renders with web=true, header rendered", () => {
const createRenderer = initRenderer("./testdata/if_example")
const render = createRenderer(ifExampleSchema)
render(tmp, { web: true, header: { render: true, text: "My Title" } })
const outFile = join(tmp, "web", "if_example.html")
expect(existsSync(outFile)).toBe(true)
const content = readFileSync(outFile, "utf-8")
expect(content).toContain("My Title")
expect(content).toContain("
")
expect(content).toContain("")
})
test("render can write a reverse map", () => {
const createRenderer = initRenderer("./testdata/if_example")
const render = createRenderer(ifExampleSchema)
render(tmp, { web: true, header: { render: true, text: "My Title" } }, { reverseMap: true })
const manifest = JSON.parse(readFileSync(join(tmp, ".tdir-map.json"), "utf-8"))
expect(manifest.version).toBe(1)
expect(manifest.tokens["My Title"]).toContain("<@var(context.header.text)>")
const file = manifest.files.find((entry: any) => entry.outputPath === join("web", "if_example.html"))
expect(file).toBeDefined()
expect(file.tokens).toContainEqual({
kind: "conditional",
result: expect.stringContaining("<@var(context.header.text)>"),
token: expect.stringContaining("<@if(context.header.render)>"),
outputPath: join("web", "if_example.html"),
templatePath: join("<@if(context.web)>web", "if_example.html"),
range: expect.any(Object),
activeRange: expect.any(Object),
before: expect.any(String),
after: expect.any(String),
})
expect(file.tokens).toContainEqual({
kind: "content",
result: "My Title",
token: "<@var(context.header.text)>",
contextPath: "header.text",
outputPath: join("web", "if_example.html"),
templatePath: join("<@if(context.web)>web", "if_example.html"),
range: expect.any(Object),
})
})
test("reverse map records path variable tokens", () => {
const createRenderer = initRenderer("./testdata/var_in_path")
const render = createRenderer(z.object({
web: z.object({
create: z.boolean(),
dir: z.string()
}),
header: z.object({
render: z.boolean(),
text: z.string()
})
}))
render(tmp,{
web: {
create: true,
dir: "web"
},
header: {
render: false,
text: "test"
}
}, { reverseMap: "meta/reverse-map.json" })
const manifest = JSON.parse(readFileSync(join(tmp, "meta", "reverse-map.json"), "utf-8"))
expect(manifest.tokens["web"]).toContain("<@var(context.web.dir)>")
const pathFile = manifest.files.find((entry: any) => entry.outputPath === "web")
expect(pathFile.tokens).toContainEqual({
kind: "path",
result: "web",
token: "<@var(context.web.dir)>",
contextPath: "web.dir",
outputPath: "web",
templatePath: "<@if(context.web.create)><@var(context.web.dir)>",
})
})
test("reverseDir rebuilds templates from rendered output and map", () => {
const createRenderer = initRenderer("./testdata/if_example")
const render = createRenderer(ifExampleSchema)
const templateOut = join(tmp, "template")
const renderedOut = join(tmp, "rendered")
render(renderedOut, { web: true, header: { render: true, text: "My Title" } }, { reverseMap: true })
writeFileSync(join(renderedOut, "web", "if_example.html"), [
"",
" My Title ",
" ",
" Edited rendered output",
" ",
"",
].join("\n"))
const result = reverseDir(renderedOut, templateOut)
expect(result.filesWritten).toBe(1)
expect(result.warnings).toEqual([])
const reversed = readFileSync(join(templateOut, "<@if(context.web)>web", "if_example.html"), "utf-8")
expect(reversed).toContain("<@if(context.header.render)>")
expect(reversed).toContain("<@endif>")
expect(reversed).toContain("<@var(context.header.text)>")
expect(reversed).toContain("Edited rendered output")
})
test("reverseDir restores files skipped by path conditionals", () => {
const createRenderer = initRenderer("./testdata/file_if")
const render = createRenderer(z.object({
web: z.boolean(),
file: z.boolean(),
text: z.string()
}))
const renderedOut = join(tmp, "rendered")
const templateOut = join(tmp, "template")
render(renderedOut,{
web: true,
file: false,
text: "test"
}, { reverseMap: true })
expect(existsSync(join(renderedOut, "web", "example.txt"))).toBe(false)
const result = reverseDir(renderedOut, templateOut)
expect(result.filesWritten).toBe(1)
const restoredPath = join(templateOut, "<@if(context.web)>web", "<@if(context.file)>example.txt")
expect(existsSync(restoredPath)).toBe(true)
expect(readFileSync(restoredPath, "utf-8")).toContain("<@var(context.text)>")
})
test("reverseDir supports custom map paths", () => {
const createRenderer = initRenderer("./testdata/var_in_path")
const render = createRenderer(z.object({
web: z.object({
create: z.boolean(),
dir: z.string()
}),
header: z.object({
render: z.boolean(),
text: z.string()
})
}))
const renderedOut = join(tmp, "rendered")
const templateOut = join(tmp, "template")
render(renderedOut,{
web: {
create: true,
dir: "web"
},
header: {
render: true,
text: "test"
}
}, { reverseMap: "meta/reverse-map.json" })
const result = reverseDir(renderedOut, templateOut, { mapPath: "meta/reverse-map.json" })
expect(result.filesWritten).toBe(1)
expect(existsSync(join(templateOut, "<@if(context.web.create)><@var(context.web.dir)>", "var_in_path_example.html"))).toBe(true)
})
test("reverseDir only includes new rendered files matching include globs", () => {
const createRenderer = initRenderer("./testdata/var_in_path")
const render = createRenderer(z.object({
web: z.object({
create: z.boolean(),
dir: z.string()
}),
header: z.object({
render: z.boolean(),
text: z.string()
})
}))
const renderedOut = join(tmp, "rendered")
const templateOut = join(tmp, "template")
const ignoredOut = join(tmp, "ignored-template")
render(renderedOut,{
web: {
create: true,
dir: "components"
},
header: {
render: true,
text: "test"
}
}, { reverseMap: true })
writeFileSync(join(renderedOut, "components", "new.ts"), "export const value = 1\n")
writeFileSync(join(renderedOut, "components", "title.ts"), "export const title = 'test'\n")
writeFileSync(join(renderedOut, "components", "debug.tmp"), "debug\n")
reverseDir(renderedOut, ignoredOut)
expect(existsSync(join(ignoredOut, "<@if(context.web.create)><@var(context.web.dir)>", "new.ts"))).toBe(false)
const result = reverseDir(renderedOut, templateOut, { include: ["components/**/*.ts"] })
expect(result.filesWritten).toBe(3)
expect(existsSync(join(templateOut, "<@if(context.web.create)><@var(context.web.dir)>", "new.ts"))).toBe(true)
expect(readFileSync(join(templateOut, "<@if(context.web.create)><@var(context.web.dir)>", "title.ts"), "utf-8")).toContain("<@var(context.header.text)>")
expect(existsSync(join(templateOut, "<@if(context.web.create)><@var(context.web.dir)>", "debug.tmp"))).toBe(false)
})
test("reverseDir can write templates inside rendered output without including its own writes", () => {
const createRenderer = initRenderer("./testdata/if_example")
const render = createRenderer(ifExampleSchema)
const renderedOut = join(tmp, "rendered")
render(renderedOut, { web: true, header: { render: true, text: "My Title" } }, { reverseMap: true })
writeFileSync(join(renderedOut, "new.html"), "new\n")
const result = reverseDir(renderedOut, join(renderedOut, "reversed"), { include: ["**/*"] })
expect(result.warnings).toEqual([])
expect(existsSync(join(renderedOut, "reversed", "<@if(context.web)>web", "if_example.html"))).toBe(true)
expect(existsSync(join(renderedOut, "reversed", "new.html"))).toBe(true)
expect(existsSync(join(renderedOut, "reversed", "reversed", "new.html"))).toBe(false)
})
test("wrong schema throws error",() => {
const createRenderer = initRenderer("./testdata/if_example")
expect(() => createRenderer(z.object({
web: z.string(),
header: z.object({
render: z.string(),
text: z.boolean()
})
}))).toThrow(new SchemaMismatchError({message: 'expected z.boolean() but schema has z.string()', path: 'web'}))
})
test("wrong schema throws error",() => {
const createRenderer = initRenderer("./testdata/if_example")
expect(() => createRenderer(z.object({
web: z.boolean(),
header: z.object({
render: z.string(),
text: z.boolean()
})
}))).toThrow(new SchemaMismatchError({message: 'expected z.boolean() but schema has z.string()', path: 'header.render'}))
})
test("wrong schema throws error",() => {
const createRenderer = initRenderer("./testdata/if_example")
expect(() => createRenderer(z.object({
web: z.boolean(),
header: z.object({
render: z.boolean(),
text: z.boolean()
})
}))).toThrow(new SchemaMismatchError({message: 'expected z.string() but schema has z.boolean()', path: 'header.text'}))
})
test("renders with web=true, header not rendered", () => {
const createRenderer = initRenderer("./testdata/if_example")
const render = createRenderer(ifExampleSchema)
render(tmp, { web: true, header: { render: false, text: "Ignored" } })
const outFile = join(tmp, "web", "if_example.html")
expect(existsSync(outFile)).toBe(true)
const content = readFileSync(outFile, "utf-8")
expect(content).not.toContain("Ignored")
expect(content).not.toContain("")
expect(content).toContain("")
})
test("renders with web=false, skips directory", () => {
const createRenderer = initRenderer("./testdata/if_example")
const render = createRenderer(ifExampleSchema)
render(tmp, { web: false, header: { render: true, text: "Hello" } })
expect(existsSync(join(tmp, "web"))).toBe(false)
})
test("vars in path are added to schema",() => {
const createRenderer = initRenderer("./testdata/var_in_path")
expect(() => createRenderer(z.object({
web: z.boolean(),
header: z.object({
render: z.boolean(),
text: z.boolean()
})
}))).toThrow(new SchemaMismatchError({message: 'expected z.object() but schema has z.boolean()', path: 'web'}))
})
test("vars in path renders correctrly",() => {
const createRenderer = initRenderer("./testdata/var_in_path")
const render = createRenderer(z.object({
web: z.object({
create: z.boolean(),
dir: z.string()
}),
header: z.object({
render: z.boolean(),
text: z.string()
})
}))
render(tmp,{
web: {
create: true,
dir: "web"
},
header: {
render: false,
text: "test"
}
})
expect(existsSync(join(tmp, "web"))).toBe(true)
const outFile = join(tmp, "web", "var_in_path_example.html")
expect(existsSync(outFile)).toBe(true)
const content = readFileSync(outFile, "utf-8")
expect(content).not.toContain("Ignored")
expect(content).not.toContain("")
expect(content).toContain("")
})
test("vars in path renders correctrly",() => {
const createRenderer = initRenderer("./testdata/var_in_path")
const render = createRenderer(z.object({
web: z.object({
create: z.boolean(),
dir: z.string()
}),
header: z.object({
render: z.boolean(),
text: z.string()
})
}))
render(tmp,{
web: {
create: true,
dir: "test"
},
header: {
render: true,
text: "test"
}
})
expect(existsSync(join(tmp, "test"))).toBe(true)
const outFile = join(tmp, "test", "var_in_path_example.html")
expect(existsSync(outFile)).toBe(true)
const content = readFileSync(outFile, "utf-8")
expect(content).toContain("test")
expect(content).toContain("")
expect(content).toContain("")
})
test("nested if dir renders correctly",() => {
const createRenderer = initRenderer("./testdata/nested_if_dir")
const render = createRenderer(z.object({
web: z.boolean(),
webnested: z.boolean()
}))
render(tmp,{
web: true,
webnested: true
})
expect(existsSync(join(tmp, "web"))).toBe(true)
})
test("nested if dir renders correctly",() => {
const createRenderer = initRenderer("./testdata/nested_if_dir_named")
const render = createRenderer(z.object({
web: z.boolean(),
webnested: z.boolean()
}))
render(tmp,{
web: true,
webnested: true
})
expect(existsSync(join(tmp, "nested" , "web"))).toBe(true)
})
test("multiple dirs 1 var",() => {
const createRenderer = initRenderer("./testdata/multi_dir_one_var")
const render = createRenderer(z.object({
web: z.boolean(),
}))
render(tmp,{
web: true,
})
expect(existsSync(join(tmp, "dir1"))).toBe(true)
expect(existsSync(join(tmp, "dir2"))).toBe(true)
expect(existsSync(join(tmp, "dir1", "dir1n"))).toBe(true)
})
test("file if",() => {
const createRenderer = initRenderer("./testdata/file_if")
const render = createRenderer(z.object({
web: z.boolean(),
file: z.boolean(),
text: z.string()
}))
render(tmp,{
web: true,
file: true,
text: "test"
})
expect(existsSync(join(tmp, "web"))).toBe(true)
const outFile = join(tmp, "web", "example.txt")
expect(existsSync(outFile)).toBe(true)
const content = readFileSync(outFile, "utf-8")
expect(content).toContain("test")
})
test("no endif should throw",() => {
expect(() => initRenderer("./testdata/no_end_if")).toThrow("test.txt")
})
test("path vars cannot write outside target",() => {
const createRenderer = initRenderer("./testdata/var_in_path")
const sentinel = join(tmp, "keep.txt")
const outsideName = `${basename(tmp)}-outside`
const outsidePath = join(tmp, "..", outsideName)
writeFileSync(sentinel, "keep")
const render = createRenderer(z.object({
web: z.object({
create: z.boolean(),
dir: z.string()
}),
header: z.object({
render: z.boolean(),
text: z.string()
})
}))
expect(() => render(tmp,{
web: {
create: true,
dir: `../${outsideName}`
},
header: {
render: false,
text: "test"
}
})).toThrow()
expect(existsSync(outsidePath)).toBe(false)
expect(existsSync(sentinel)).toBe(true)
})
test("refuses overlapping source and target directories",() => {
const source = join(tmp, "template")
const target = join(source, "out")
mkdirSync(source, { recursive: true })
writeFileSync(join(source, "test.txt"), "test")
const createRenderer = initRenderer(source)
const render = createRenderer(z.object({}))
expect(() => render(target, {})).toThrow()
})
test("binary files are copied without text rendering",() => {
const source = join(tmp, "template")
const target = join(tmp, "out")
mkdirSync(source, { recursive: true })
const binary = Buffer.from([0, 255, 1, 2, 3])
writeFileSync(join(source, "asset.bin"), binary)
const createRenderer = initRenderer(source)
const render = createRenderer(z.object({}))
render(target, {})
expect(readFileSync(join(target, "asset.bin"))).toEqual(binary)
})
test("conflicting template variable types should throw",() => {
const source = join(tmp, "template")
mkdirSync(source, { recursive: true })
writeFileSync(join(source, "test.txt"), [
"<@if(context.test)>",
"<@endif>",
"<@var(context.test)>"
].join("\n"))
const createRenderer = initRenderer(source)
expect(() => createRenderer(z.object({
test: z.boolean(),
}))).toThrow()
})
test("if elseif else",() => {
const createRenderer = initRenderer("./testdata/if_elseif_else_in_file")
const render = createRenderer(z.object({
test: z.boolean(),
test2: z.boolean()
}))
render(tmp,{
test: true,
test2: true
})
let outFile = join(tmp, "test.txt")
expect(existsSync(outFile)).toBe(true)
let content = readFileSync(outFile, "utf-8")
expect(content).toContain("test")
render(tmp,{
test: false,
test2: true
})
outFile = join(tmp, "test.txt")
expect(existsSync(outFile)).toBe(true)
content = readFileSync(outFile, "utf-8")
expect(content).toContain("test2")
render(tmp,{
test: false,
test2: false
})
outFile = join(tmp, "test.txt")
expect(existsSync(outFile)).toBe(true)
content = readFileSync(outFile, "utf-8")
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)
})
test("if neq in file", () => {
const createRenderer = initRenderer("./testdata/neq_in_file")
expect(() => createRenderer(z.object({test: z.boolean()}))).toThrow(SchemaMismatchError)
const render = createRenderer(z.object({test: z.string()}))
render(tmp,{test:"foo"})
expect(readFileSync(join(tmp,"test.txt"), "utf-8")).toContain("not-test")
render(tmp,{test:"test"})
expect(readFileSync(join(tmp,"test.txt"), "utf-8")).toContain("test")
expect(readFileSync(join(tmp,"test.txt"), "utf-8")).not.toContain("not-test")
})
test("if neq in path", () => {
const createRenderer = initRenderer("./testdata/neq_in_path")
const render = createRenderer(z.object({test: z.string()}))
render(tmp,{test:"foo"})
expect(existsSync(join(tmp,"not-test"))).toBe(true)
render(tmp,{test:"test"})
expect(existsSync(join(tmp,"not-test"))).toBe(false)
})
test("if neq multiline block", () => {
const createRenderer = initRenderer("./testdata/neq_multiline_block")
const render = createRenderer(z.object({
project: z.object({
frontend: z.string()
})
}))
render(tmp,{project:{frontend:"react"}})
expect(readFileSync(join(tmp,"test.txt"), "utf-8")).toContain("bun dev")
render(tmp,{project:{frontend:"none"}})
expect(readFileSync(join(tmp,"test.txt"), "utf-8")).not.toContain("bun dev")
})
test("and or in file", () => {
const createRenderer = initRenderer("./testdata/and_or_in_file")
expect(() => createRenderer(z.object({
enabled: z.boolean(),
fallback: z.boolean(),
kind: z.boolean()
}))).toThrow(SchemaMismatchError)
const render = createRenderer(z.object({
enabled: z.boolean(),
fallback: z.boolean(),
kind: z.string()
}))
render(tmp,{enabled:true, fallback:false, kind:"web"})
let content = readFileSync(join(tmp,"test.txt"), "utf-8")
expect(content).toContain("and")
expect(content).toContain("or")
expect(content).toContain("nested")
render(tmp,{enabled:false, fallback:true, kind:"web"})
content = readFileSync(join(tmp,"test.txt"), "utf-8")
expect(content).toContain("not-and")
expect(content).toContain("not-or")
expect(content).toContain("nested")
render(tmp,{enabled:false, fallback:true, kind:"docs"})
content = readFileSync(join(tmp,"test.txt"), "utf-8")
expect(content).toContain("not-and")
expect(content).toContain("or")
expect(content).toContain("nested")
})
test("and or in path", () => {
const createRenderer = initRenderer("./testdata/and_or_in_path")
const render = createRenderer(z.object({
enabled: z.boolean(),
kind: z.string()
}))
render(tmp,{enabled:true, kind:"web"})
expect(existsSync(join(tmp,"match"))).toBe(true)
render(tmp,{enabled:true, kind:"docs"})
expect(existsSync(join(tmp,"match"))).toBe(true)
render(tmp,{enabled:false, kind:"docs"})
expect(existsSync(join(tmp,"match"))).toBe(false)
})