195 lines
5.5 KiB
TypeScript
195 lines
5.5 KiB
TypeScript
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<string> {
|
|
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('<div class="card">Hello</div>');
|
|
});
|
|
|
|
test("escapes the text", async () => {
|
|
const name = await template("escape", "p= value");
|
|
expect(await engine.render(name, { value: "<b>" })).toBe(
|
|
"<p><b></p>",
|
|
);
|
|
});
|
|
|
|
test("renders unescaped output", async () => {
|
|
const name = await template("raw", "p!= value");
|
|
expect(await engine.render(name, { value: "<b>x</b>" })).toBe(
|
|
"<p><b>x</b></p>",
|
|
);
|
|
});
|
|
|
|
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("<p>yes</p>");
|
|
expect(await engine.render(name, { value: false })).toBe("<p>no</p>");
|
|
});
|
|
|
|
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(
|
|
"<ul><li>a</li><li>b</li></ul>",
|
|
);
|
|
});
|
|
|
|
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('<a href="/x" class="on">Link</a>');
|
|
});
|
|
|
|
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(
|
|
'<div class="card wide">Text</div>',
|
|
);
|
|
});
|
|
|
|
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("<ul><li>a</li></ul>");
|
|
});
|
|
|
|
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("<div><footer>Footer</footer></div>");
|
|
});
|
|
|
|
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(
|
|
"<html><body><p>Page</p><footer>Footer</footer></body></html>",
|
|
);
|
|
});
|
|
|
|
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("<p>Hello</p>");
|
|
});
|
|
|
|
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('<article class="card"><h3>Card</h3></article>');
|
|
});
|
|
|
|
test("keeps the html of the optional props empty", async () => {
|
|
const name = await template("optional", "p= missing");
|
|
expect(await engine.render(name)).toBe("<p></p>");
|
|
});
|
|
});
|