import { afterAll, describe, expect, test } from "bun:test"; import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { TemplateEngine } from "../views/engine/runtime.ts"; import { lex } from "../views/engine/lexer.ts"; import { parse } from "../views/engine/parser.ts"; /** * Tests of the pug like template engine. */ const dir = await mkdtemp(join(tmpdir(), "muddle-views-")); async function template(name: string, source: string): Promise { await writeFile(join(dir, `${name}.spug`), source, "utf8"); return name; } const engine = new TemplateEngine({ viewsDir: dir, root: dir }); afterAll(async () => { await rm(dir, { recursive: true, force: true }); }); describe("lexer", () => { test("reads tags, classes and attributes", () => { const tokens = lex("a.card#top(href='/x' target='_blank') Link"); const tag = tokens[0]; expect(tag?.name).toBe("a"); expect(tag?.attributes?.map((entry) => entry.name)).toEqual([ "class", "id", "href", "target", ]); expect(tag?.text).toBe("Link"); }); test("reads a buffered expression", () => { const tokens = lex("p= title"); expect(tokens[0]?.output).toBe("title"); }); test("reads an unbuffered expression", () => { const tokens = lex("p!= title"); expect(tokens[0]?.output).toBe("title"); expect(tokens[0]?.escaped).toBe(false); }); test("reads the frontmatter", () => { const tokens = lex("---\nconst a = 1\n---\np Hello"); expect(tokens[0]?.type).toBe("frontmatter"); expect(tokens[0]?.value).toContain("const a = 1"); }); test("reads a props block", () => { const parsed = parse(lex("props\n title: string\n body?: string\np Hi")); const props = parsed.nodes.find((node) => node.kind === "props"); expect(props?.kind).toBe("props"); if (props?.kind === "props") { expect(props.props.map((prop) => prop.name)).toEqual(["title", "body"]); expect(props.props[0]?.optional).toBe(false); expect(props.props[1]?.optional).toBe(true); expect(props.props[1]?.type).toBe("string"); } }); }); describe("renderer", () => { test("renders a tag with a class", async () => { const name = await template("class", "div.card Hello"); expect(await engine.render(name)).toBe('
Hello
'); }); test("escapes the text", async () => { const name = await template("escape", "p= value"); expect(await engine.render(name, { value: "" })).toBe( "

<b>

", ); }); test("renders unescaped output", async () => { const name = await template("raw", "p!= value"); expect(await engine.render(name, { value: "x" })).toBe( "

x

", ); }); test("renders a condition", async () => { const name = await template("condition", "if value\n p yes\nelse\n p no"); expect(await engine.render(name, { value: true })).toBe("

yes

"); expect(await engine.render(name, { value: false })).toBe("

no

"); }); test("renders a loop", async () => { const name = await template( "loop", "ul\n each item in items\n li= item", ); expect(await engine.render(name, { items: ["a", "b"] })).toBe( "", ); }); test("renders an attribute expression", async () => { const name = await template( "attribute", "a(href=link.href class=link.active ? 'on' : 'off')= link.label", ); expect( await engine.render(name, { link: { href: "/x", active: true, label: "Link" }, }), ).toBe('Link'); }); test("merges the classes of the shorthand and of the attribute", async () => { const name = await template("classes", "div.card(class=extra) Text"); expect(await engine.render(name, { extra: "wide" })).toBe( '
Text
', ); }); test("renders a mixin", async () => { const name = await template( "mixin", "mixin Item({ text })\n li= text\nul\n +Item({ text: 'a' })", ); expect(await engine.render(name)).toBe(""); }); test("renders an included partial", async () => { await template("partial", "footer Footer"); const name = await template("include", "div\n include ./partial.spug"); expect(await engine.render(name)).toBe( "
Footer
", ); }); test("renders a layout with a block", async () => { await template( "layout", "html\n body\n block content\n footer Footer", ); const name = await template( "page", "extends ./layout.spug\nblock content\n p Page", ); expect(await engine.render(name)).toBe( "

Page

Footer
", ); }); test("renders the frontmatter of a layout", async () => { await template( "front-layout", "---\nconst greeting = 'Hello'\n---\np= greeting", ); expect(await engine.render("front-layout")).toBe("

Hello

"); }); test("renders a component of the components directory", async () => { const components = join(dir, "components"); await mkdir(components, { recursive: true }); await writeFile( join(components, "card.spug"), "article.card\n h3= title", "utf8", ); const scoped = new TemplateEngine({ viewsDir: dir, componentsDir: components, root: dir, }); expect(await scoped.renderComponent("card", { title: "Card" })).toBe( '

Card

', ); }); test("keeps the html of the optional props empty", async () => { const name = await template("optional", "p= missing"); expect(await engine.render(name)).toBe("

"); }); });