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) })