From b8d0ed51f8e42f404b4489db6042a7179bff375f Mon Sep 17 00:00:00 2001 From: Matthew McEachen Date: Wed, 7 Jan 2026 23:20:40 -0800 Subject: [PATCH 1/2] fix(index): support EJS inside HTML opening tags (fixes #11) Use control characters (\x01, \x02) as placeholders instead of which Prettier v3's stricter HTML parser rejects inside attributes. --- index.js | 58 ++++++++++++++++++++++++++++--- test.js | 102 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 155 insertions(+), 5 deletions(-) create mode 100644 test.js diff --git a/index.js b/index.js index beab1df..f6e1661 100644 --- a/index.js +++ b/index.js @@ -5,11 +5,59 @@ const { parsers } = require("prettier/parser-html"); -function parse(text, options, legacy) { - const find = - /(?:<(textarea|title|script).*?<\/\1|<\s*[a-zA-Z!](?:".*?"|.*?)*?[^%]>|<%([^>]*)%>)/gs; - text = text.replace(find, (match, p1, p2) => (p2 ? `` : match)); - return parsers.html.parse(text, options, legacy); +// Hide EJS from Prettier's HTML parser by replacing < and > with control chars. +// Prettier v3 rejects ]*)?>[\s\S]*?<\/\1>/gi, + (match) => { + preserved.push(match); + return "\x00".repeat(match.length); + } + ); + + // <%...%> -> \x01%...%\x02 (skip EJS containing > to avoid breaking attrs) + text = text.replace(/<(%[^>]*%)>/g, `\x01$1\x02`); + + let i = 0; + text = text.replace(/\x00+/g, () => preserved[i++]); + return text; +} + +function restoreEjs(str) { + if (typeof str !== "string") return str; + return str.replace(/\x01%/g, "<%").replace(/%\x02/g, "%>"); +} + +function restoreEjsInAst(node) { + if (!node || typeof node !== "object") return; + + if (node.value) { + node.value = restoreEjs(node.value); + } + + if (node.attrs) { + for (const attr of node.attrs) { + attr.name = restoreEjs(attr.name); + attr.value = restoreEjs(attr.value); + } + } + + if (node.children) { + for (const child of node.children) { + restoreEjsInAst(child); + } + } +} + +function parse(text, options) { + const transformed = ejsToPlaceholder(text); + const ast = parsers.html.parse(transformed, options); + restoreEjsInAst(ast); + return ast; } module.exports = { diff --git a/test.js b/test.js new file mode 100644 index 0000000..b6187de --- /dev/null +++ b/test.js @@ -0,0 +1,102 @@ +/** + * Tests for prettier-plugin-ejs + * + * Run with: node test.js + */ + +const prettier = require("prettier"); +const plugin = require("./index.js"); + +const testCases = [ + { + name: "Issue #11: EJS inside opening tag (README example)", + input: ` style="<%= styleData %>" <% } %>><%= data %>`, + }, + { + name: "Issue #11: conditional attribute in button", + input: ``, + }, + { + name: "Simple EJS between tags", + input: `
<% if (x) { %>y<% } %>
`, + }, + { + name: "EJS in attribute value", + input: `
<%= value %>
`, + }, + { + name: "Multiple EJS in attribute value", + input: `
<%= c %>
`, + }, + { + name: "Textarea content preserved", + input: ``, + shouldContain: ``, + }, + { + name: "Script content preserved", + input: ``, + shouldContain: `<% getValue() %>`, + }, + { + name: "EJS with > (intentionally ignored)", + input: `
test
`, + shouldContain: `<%= x > y %>`, + }, +]; + +async function formatEJS(input) { + return prettier.format(input, { + parser: "html", + plugins: [plugin], + }); +} + +async function runTests() { + let passed = 0; + let failed = 0; + + for (const testCase of testCases) { + process.stdout.write(`Testing: ${testCase.name}... `); + + try { + const output = await formatEJS(testCase.input); + + // Should parse without errors (no crash) + // Should contain EJS syntax (not [%...%]) + if (output.includes("[%") || output.includes("%]")) { + console.log("✗ FAILED (contains unconverted [%...%])"); + console.log(` Got: ${output.trim()}`); + failed++; + continue; + } + + // Check specific content if required + if (testCase.shouldContain && !output.includes(testCase.shouldContain)) { + console.log(`✗ FAILED (missing: ${testCase.shouldContain})`); + console.log(` Got: ${output.trim()}`); + failed++; + continue; + } + + // Should still contain EJS tags + if (!output.includes("<%") || !output.includes("%>")) { + console.log("✗ FAILED (EJS tags missing)"); + console.log(` Got: ${output.trim()}`); + failed++; + continue; + } + + console.log("✓ PASSED"); + passed++; + } catch (err) { + console.log(`✗ FAILED: ${err.message.split("\n")[0]}`); + failed++; + } + } + + console.log(`\n${passed} passed, ${failed} failed`); + process.exit(failed > 0 ? 1 : 0); +} + +runTests(); From bf7f98077c09f1a64683d056c655cd4e4077319e Mon Sep 17 00:00:00 2001 From: Matthew McEachen Date: Thu, 8 Jan 2026 00:02:35 -0800 Subject: [PATCH 2/2] fix(index.js): support Prettier 3.x and EJS inside HTML opening tags BREAKING CHANGE: Requires Prettier 3.x (import path changed from `prettier/parser-html` to `prettier/plugins/html`) Use length-preserving placeholders to completely hide EJS content from prettier, and prevent AST parser getting confused. like from ` />`. Fixes #11 --- index.js | 61 ++++++++++++++++++++++++++++++----------------- package-lock.json | 15 ++++++------ package.json | 4 ++-- 3 files changed, 49 insertions(+), 31 deletions(-) diff --git a/index.js b/index.js index f6e1661..2377f93 100644 --- a/index.js +++ b/index.js @@ -3,46 +3,63 @@ * Licensed under the MIT License */ -const { parsers } = require("prettier/parser-html"); +// Prettier 3.x moved parsers to plugins directory +const { parsers } = require("prettier/plugins/html"); -// Hide EJS from Prettier's HTML parser by replacing < and > with control chars. -// Prettier v3 rejects ]*)?>[\s\S]*?<\/\1>/gi, - (match) => { - preserved.push(match); - return "\x00".repeat(match.length); - } - ); +// Control char delimiters - invisible, won't appear in normal HTML +// "\x010\x02" = 3 chars, fits shortest EJS like "<% %>" (5 chars) +const START = "\x01"; +const END = "\x02"; - // <%...%> -> \x01%...%\x02 (skip EJS containing > to avoid breaking attrs) - text = text.replace(/<(%[^>]*%)>/g, `\x01$1\x02`); +// Create a placeholder that's the same length as the original EJS +function createPlaceholder(ejsContent, index) { + const prefix = `${START}${index}${END}`; + const targetLength = ejsContent.length; - let i = 0; - text = text.replace(/\x00+/g, () => preserved[i++]); - return text; + if (prefix.length >= targetLength) { + // Placeholder is already long enough (or longer) + return prefix; + } + + // Pad with underscores to match original length + return prefix.padEnd(targetLength, "_"); +} + +function ejsToPlaceholder(text) { + ejsMap.clear(); + let index = 0; + + return text.replace(/<%[\s\S]*?%>/g, (match) => { + const placeholder = createPlaceholder(match, index++); + ejsMap.set(placeholder, match); + return placeholder; + }); } function restoreEjs(str) { if (typeof str !== "string") return str; - return str.replace(/\x01%/g, "<%").replace(/%\x02/g, "%>"); + // Replace all placeholders with their original EJS + for (const [placeholder, original] of ejsMap) { + str = str.split(placeholder).join(original); + } + return str; } function restoreEjsInAst(node) { if (!node || typeof node !== "object") return; - if (node.value) { + if (node.value != null) { node.value = restoreEjs(node.value); } if (node.attrs) { for (const attr of node.attrs) { - attr.name = restoreEjs(attr.name); - attr.value = restoreEjs(attr.value); + if (attr.name != null) attr.name = restoreEjs(attr.name); + if (attr.value != null) attr.value = restoreEjs(attr.value); } } diff --git a/package-lock.json b/package-lock.json index fd607ff..7c38e93 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,22 +9,23 @@ "version": "1.0.3", "license": "MIT", "devDependencies": { - "prettier": "^2.8.8" + "prettier": "^3" }, "peerDependencies": { - "prettier": "2.x - 3.x" + "prettier": "3.x" } }, "node_modules/prettier": { - "version": "2.8.8", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz", - "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==", + "version": "3.7.4", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.4.tgz", + "integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==", "dev": true, + "license": "MIT", "bin": { - "prettier": "bin-prettier.js" + "prettier": "bin/prettier.cjs" }, "engines": { - "node": ">=10.13.0" + "node": ">=14" }, "funding": { "url": "https://github.com/prettier/prettier?sponsor=1" diff --git a/package.json b/package.json index fa0f67a..a4a787c 100644 --- a/package.json +++ b/package.json @@ -28,9 +28,9 @@ "format": "prettier --ignore-path=.gitignore --plugin=./index.js --write ." }, "devDependencies": { - "prettier": "^2.8.8" + "prettier": "^3" }, "peerDependencies": { - "prettier": "2.x - 3.x" + "prettier": "3.x" } }