diff --git a/index.js b/index.js index beab1df..2377f93 100644 --- a/index.js +++ b/index.js @@ -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 ? `` : 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 = { 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" } } 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();