Skip to content
Open
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
77 changes: 71 additions & 6 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,78 @@
* 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");

function parse(text, options, legacy) {
const find =
/(?:<(textarea|title|script).*?<\/\1|<\s*[a-zA-Z!](?:".*?"|.*?)*?[^%]>|<%([^>]*)%>)/gs;
text = text.replace(find, (match, p1, p2) => (p2 ? `<!${p2}!>` : match));
return parsers.html.parse(text, options, legacy);
// Store EJS snippets for restoration after formatting
// Key: placeholder string, Value: original EJS
const ejsMap = new Map();

// 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";

// 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;

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;
// 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 != null) {
node.value = restoreEjs(node.value);
}

if (node.attrs) {
for (const attr of node.attrs) {
if (attr.name != null) attr.name = restoreEjs(attr.name);
if (attr.value != null) 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 = {
Expand Down
15 changes: 8 additions & 7 deletions package-lock.json

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

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
102 changes: 102 additions & 0 deletions test.js
Original file line number Diff line number Diff line change
@@ -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: `<td <% if (styleData) { %> style="<%= styleData %>" <% } %>><%= data %></td>`,
},
{
name: "Issue #11: conditional attribute in button",
input: `<button class="fr-btn" <% if (locals.ariaLabel) { %>aria-label="<%= locals.ariaLabel %>"<% } %>><%= label %></button>`,
},
{
name: "Simple EJS between tags",
input: `<div><% if (x) { %><span>y</span><% } %></div>`,
},
{
name: "EJS in attribute value",
input: `<div class="<%= red %>"><%= value %></div>`,
},
{
name: "Multiple EJS in attribute value",
input: `<div class="<%= a %> <%= b %>"><%= c %></div>`,
},
{
name: "Textarea content preserved",
input: `<textarea><%= text %></textarea>`,
shouldContain: `<textarea><%= text %></textarea>`,
},
{
name: "Script content preserved",
input: `<script>const x = <% getValue() %>;</script>`,
shouldContain: `<% getValue() %>`,
},
{
name: "EJS with > (intentionally ignored)",
input: `<div class="<%= x > y %>">test</div>`,
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();