Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,6 @@
"@jaredwray/fumanchu": "^4.7.0",
"cacheable": "^2.3.5",
"ejs": "^5.0.2",
"ent": "^2.2.2",
"hookified": "^2.2.0",
"liquidjs": "^10.27.0",
"nunjucks": "^3.2.4",
Expand All @@ -65,7 +64,6 @@
"@biomejs/biome": "^2.4.15",
"@faker-js/faker": "^10.4.0",
"@types/ejs": "^3.1.5",
"@types/ent": "^2.2.8",
"@types/node": "^24.12.4",
"@types/nunjucks": "^3.2.6",
"@types/pug": "^2.0.10",
Expand Down
11 changes: 0 additions & 11 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 7 additions & 9 deletions src/engines/handlebars.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import fs from "node:fs";
import { fumanchu } from "@jaredwray/fumanchu";
import { decode } from "ent";
import { BaseEngine } from "../base-engine.js";
import type { EngineInterface } from "../engine-interface.js";

Expand Down Expand Up @@ -29,10 +28,12 @@ export class Handlebars extends BaseEngine implements EngineInterface {

const template = this.engine.compile(source, this.opts);

let result = template(data, this.opts);
result = decode(result);

return result;
// Standard Handlebars semantics: `{{var}}` HTML-escapes, `{{{var}}}`
// inserts raw. No post-render entity decoding — a blanket decode
// corrupts legitimately escaped content in the data (for example
// `<` inside pre-rendered code blocks) and re-activates
// escaped markup.
return template(data, this.opts);
}

renderSync(source: string, data?: Record<string, unknown>): string {
Expand All @@ -43,10 +44,7 @@ export class Handlebars extends BaseEngine implements EngineInterface {

const template = this.engine.compile(source, this.opts);

let result = template(data, this.opts);
result = decode(result);

return result;
return template(data, this.opts);
}

initPartials(): void {
Expand Down
8 changes: 4 additions & 4 deletions test/ecto.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -287,7 +287,7 @@ it("render via handlebars", async () => {
"handlebars",
),
).toBe(
"<p>Hello, my name is Alan O'Connor. I'm from Somewhere, TX. I have 2 kids:</p> <ul><li>Jimmy is 12</li><li>Sally is 4</li></ul>",
"<p>Hello, my name is Alan O&#x27;Connor. I'm from Somewhere, TX. I have 2 kids:</p> <ul><li>Jimmy is 12</li><li>Sally is 4</li></ul>",
);
});

Expand Down Expand Up @@ -386,7 +386,7 @@ it("Render from Template - Handlebars", async () => {
`${testRootDirectory}/handlebars`,
);

expect(source).toContain("<title>Alan O'Connor - Header Title </title>");
expect(source).toContain("<title>Alan O&#x27;Connor - Header Title </title>");
expect(source).toContain("Foo!");
});

Expand All @@ -397,7 +397,7 @@ it("Render from Template - Handlebars infers root path for partials", async () =
handlebarsExampleData,
);

expect(source).toContain("<title>Alan O'Connor - Header Title </title>");
expect(source).toContain("<title>Alan O&#x27;Connor - Header Title </title>");
expect(source).toContain("Foo!");
expect(source).toContain("ux layout");
});
Expand All @@ -409,7 +409,7 @@ it("Render from Template - Handlebars infers root path for partials synchronousl
handlebarsExampleData,
);

expect(source).toContain("<title>Alan O'Connor - Header Title </title>");
expect(source).toContain("<title>Alan O&#x27;Connor - Header Title </title>");
expect(source).toContain("Foo!");
expect(source).toContain("ux layout");
});
Expand Down
6 changes: 4 additions & 2 deletions test/engine-customization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,11 @@ it("Engine Customization - Customize multiple engines", async () => {
(text: string) => `<strong>${text}</strong>`,
);

// Test Handlebars customization
// Test Handlebars customization. Helpers returning plain HTML
// strings get escaped by `{{…}}` (standard Handlebars semantics);
// triple-stash inserts the markup raw.
const hbsResult = await ecto.render(
"{{bold name}}",
"{{{bold name}}}",
{ name: "Test" },
"handlebars",
);
Expand Down
38 changes: 33 additions & 5 deletions test/engines/handlebars.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ const exampleSource1 =
"<p>Hello, my name is {{name}}. I'm from {{hometown}}. I have {{kids.length}} kids:</p> <ul>{{#kids}}<li>{{name}} is {{age}}</li>{{/kids}}</ul>";
const exampleSource2 =
"<p>Hello, my name is {{name}}. I'm from {{hometown}}. </p>";
// The apostrophe exercises standard `{{var}}` HTML-escaping: it
// renders as `&#x27;` (see the expectations below).
const exampleData1 = {
name: "Alan O'Connor",
hometown: "Somewhere, TX",
Expand Down Expand Up @@ -42,21 +44,21 @@ it("Handlebars - Extension should be a count of 1", () => {
it("Handlebars - Rendering a simple string", async () => {
const engine = new Handlebars();
expect(await engine.render(exampleSource1, exampleData1)).toContain(
"Alan O'Connor",
"Alan O&#x27;Connor",
);
});

it("Handlebars - Rendering a simple string synchronous", () => {
const engine = new Handlebars();
expect(engine.renderSync(exampleSource1, exampleData1)).toContain(
"Alan O'Connor",
"Alan O&#x27;Connor",
);
});

it("Handlebars - Rendering a simple string after inital render", async () => {
const engine = new Handlebars();
expect(await engine.render(exampleSource1, exampleData1)).toContain(
"Alan O'Connor",
"Alan O&#x27;Connor",
);
expect(await engine.render(exampleSource2, exampleData1)).toContain(
"Somewhere, TX",
Expand Down Expand Up @@ -88,7 +90,7 @@ it("Handlebars - Rendering with Partials", async () => {
engine.rootTemplatePath = testTemplateDirectory;
const result = await engine.render(source, exampleData1);

expect(result).toContain("Alan O'Connor");
expect(result).toContain("Alan O&#x27;Connor");
expect(result).toContain("Foo!");
expect(result).toContain("ux layout");
});
Expand All @@ -101,7 +103,9 @@ it("Handlebars - Render Sync with Partials", () => {
);
engine.rootTemplatePath = testTemplateDirectory;

expect(engine.renderSync(source, exampleData1)).toContain("Alan O'Connor");
expect(engine.renderSync(source, exampleData1)).toContain(
"Alan O&#x27;Connor",
);
expect(engine.renderSync(source, exampleData1)).toContain("Foo!");
});

Expand Down Expand Up @@ -440,3 +444,27 @@ it("Handlebars - forEach with navigation sidebar like docula example", async ()
expect(result).toContain('title="How to install the library"');
expect(result).toContain('title="Get up and running quickly"');
});

it("Handlebars - double-stash escapes HTML in data", async () => {
const engine = new Handlebars();
const result = await engine.render("{{content}}", {
content: "<script>alert(1)</script>",
});
expect(result).toBe("&lt;script&gt;alert(1)&lt;/script&gt;");
});

it("Handlebars - triple-stash inserts raw HTML without decoding entities", async () => {
const engine = new Handlebars();
// Pre-escaped entities (e.g. from a rendered markdown code block)
// must survive verbatim — a post-render entity decode would turn
// `&#x3C;APP_ID>` into `<APP_ID>`, which a browser then swallows
// as an unknown tag.
const html = "<pre><code>curl /v1/apps/&#x3C;APP_ID>/config</code></pre>";
const result = await engine.render("{{{generatedHtml}}}", {
generatedHtml: html,
});
expect(result).toBe(html);
expect(
engine.renderSync("{{{generatedHtml}}}", { generatedHtml: html }),
).toBe(html);
});