diff --git a/package.json b/package.json index 8cd8dd2..e1a212b 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6c28bc1..2960e41 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,9 +17,6 @@ importers: ejs: specifier: ^5.0.2 version: 5.0.2 - ent: - specifier: ^2.2.2 - version: 2.2.2 hookified: specifier: ^2.2.0 version: 2.2.0 @@ -45,9 +42,6 @@ importers: '@types/ejs': specifier: ^3.1.5 version: 3.1.5 - '@types/ent': - specifier: ^2.2.8 - version: 2.2.8 '@types/node': specifier: ^24.12.4 version: 24.12.4 @@ -901,9 +895,6 @@ packages: '@types/ejs@3.1.5': resolution: {integrity: sha512-nv+GSx77ZtXiJzwKdsASqi+YQ5Z7vwHsTP0JY2SiQgjGckkBRKZnk8nIM+7oUZ1VCtuTz0+By4qVR7fqzp/Dfg==} - '@types/ent@2.2.8': - resolution: {integrity: sha512-T7OSxLcPBbcUxTIeJTpmV8cMsFfx/OasIDHlq+PO1choUQnYIcoxQj45n9NpzrR9yOJDwte8tmvs6Y+axitRFg==} - '@types/estree-jsx@1.0.5': resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} @@ -3199,8 +3190,6 @@ snapshots: '@types/ejs@3.1.5': {} - '@types/ent@2.2.8': {} - '@types/estree-jsx@1.0.5': dependencies: '@types/estree': 1.0.8 diff --git a/src/engines/handlebars.ts b/src/engines/handlebars.ts index b319b35..87b257d 100644 --- a/src/engines/handlebars.ts +++ b/src/engines/handlebars.ts @@ -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"; @@ -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 { @@ -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 { diff --git a/test/ecto.test.ts b/test/ecto.test.ts index db39de6..1954a38 100644 --- a/test/ecto.test.ts +++ b/test/ecto.test.ts @@ -287,7 +287,7 @@ it("render via handlebars", async () => { "handlebars", ), ).toBe( - "

Hello, my name is Alan O'Connor. I'm from Somewhere, TX. I have 2 kids:

", + "

Hello, my name is Alan O'Connor. I'm from Somewhere, TX. I have 2 kids:

", ); }); @@ -386,7 +386,7 @@ it("Render from Template - Handlebars", async () => { `${testRootDirectory}/handlebars`, ); - expect(source).toContain("Alan O'Connor - Header Title "); + expect(source).toContain("Alan O'Connor - Header Title "); expect(source).toContain("Foo!"); }); @@ -397,7 +397,7 @@ it("Render from Template - Handlebars infers root path for partials", async () = handlebarsExampleData, ); - expect(source).toContain("Alan O'Connor - Header Title "); + expect(source).toContain("Alan O'Connor - Header Title "); expect(source).toContain("Foo!"); expect(source).toContain("ux layout"); }); @@ -409,7 +409,7 @@ it("Render from Template - Handlebars infers root path for partials synchronousl handlebarsExampleData, ); - expect(source).toContain("Alan O'Connor - Header Title "); + expect(source).toContain("Alan O'Connor - Header Title "); expect(source).toContain("Foo!"); expect(source).toContain("ux layout"); }); diff --git a/test/engine-customization.test.ts b/test/engine-customization.test.ts index ded40e5..63886e6 100644 --- a/test/engine-customization.test.ts +++ b/test/engine-customization.test.ts @@ -30,9 +30,11 @@ it("Engine Customization - Customize multiple engines", async () => { (text: string) => `${text}`, ); - // 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", ); diff --git a/test/engines/handlebars.test.ts b/test/engines/handlebars.test.ts index e80c595..4859ca7 100644 --- a/test/engines/handlebars.test.ts +++ b/test/engines/handlebars.test.ts @@ -7,6 +7,8 @@ const exampleSource1 = "

Hello, my name is {{name}}. I'm from {{hometown}}. I have {{kids.length}} kids:

"; const exampleSource2 = "

Hello, my name is {{name}}. I'm from {{hometown}}.

"; +// The apostrophe exercises standard `{{var}}` HTML-escaping: it +// renders as `'` (see the expectations below). const exampleData1 = { name: "Alan O'Connor", hometown: "Somewhere, TX", @@ -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'Connor", ); }); it("Handlebars - Rendering a simple string synchronous", () => { const engine = new Handlebars(); expect(engine.renderSync(exampleSource1, exampleData1)).toContain( - "Alan O'Connor", + "Alan O'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'Connor", ); expect(await engine.render(exampleSource2, exampleData1)).toContain( "Somewhere, TX", @@ -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'Connor"); expect(result).toContain("Foo!"); expect(result).toContain("ux layout"); }); @@ -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'Connor", + ); expect(engine.renderSync(source, exampleData1)).toContain("Foo!"); }); @@ -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: "", + }); + expect(result).toBe("<script>alert(1)</script>"); +}); + +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 + // `<APP_ID>` into ``, which a browser then swallows + // as an unknown tag. + const html = "
curl /v1/apps/<APP_ID>/config
"; + const result = await engine.render("{{{generatedHtml}}}", { + generatedHtml: html, + }); + expect(result).toBe(html); + expect( + engine.renderSync("{{{generatedHtml}}}", { generatedHtml: html }), + ).toBe(html); +});