diff --git a/CHANGELOG.unreleased.md b/CHANGELOG.unreleased.md index 34391e12..a5faa7d4 100644 --- a/CHANGELOG.unreleased.md +++ b/CHANGELOG.unreleased.md @@ -363,6 +363,26 @@ new (x())!.y(); new e[f().x].y(); ``` +### JavaScript: Fix nested embeds (JS in HTML in JS) ([#6038] by [@thorn0]) + +Previously, if JS code embedded in HTML (via ``; + +// Output (Prettier stable) +// SyntaxError: Expecting Unicode escape sequence \uXXXX (1:8) + +// Output (Prettier master) +const html = /* HTML */ ` + +`; +``` + ### TypeScript: Keep line breaks within mapped types.([#6146] by [@sosukesuzuki]) Previously, Prettier has removed line breaks within mapped types.This change keeps it, similar to how it treats other object types. @@ -429,6 +449,7 @@ f[a::b]; [#6133]: https://github.com/prettier/prettier/pull/6133 [#6136]: https://github.com/prettier/prettier/pull/6136 [#6140]: https://github.com/prettier/prettier/pull/6140 +[#6038]: https://github.com/prettier/prettier/pull/6038 [#6148]: https://github.com/prettier/prettier/pull/6148 [#6146]: https://github.com/prettier/prettier/pull/6146 [#6152]: https://github.com/prettier/prettier/pull/6152 diff --git a/src/common/util.js b/src/common/util.js index 10c0a81b..45df067b 100644 --- a/src/common/util.js +++ b/src/common/util.js @@ -500,10 +500,7 @@ function printString(raw, options, isDirectiveLiteral) { options.parser === "css" || options.parser === "less" || options.parser === "scss" || - options.parentParser === "html" || - options.parentParser === "vue" || - options.parentParser === "angular" || - options.parentParser === "lwc" + options.embeddedInHtml ) ); } diff --git a/src/language-html/printer-html.js b/src/language-html/printer-html.js index ce5a8fab..115f015b 100644 --- a/src/language-html/printer-html.js +++ b/src/language-html/printer-html.js @@ -113,7 +113,7 @@ function embed(path, print, textToDoc, options) { // lit-html: html`` if ( - /^PRETTIER_HTML_PLACEHOLDER_\d+_IN_JS$/.test( + /^PRETTIER_HTML_PLACEHOLDER_\d+_\d+_IN_JS$/.test( options.originalText.slice( node.valueSpan.start.offset, node.valueSpan.end.offset diff --git a/src/language-js/embed.js b/src/language-js/embed.js index 45090e71..57a56a62 100644 --- a/src/language-js/embed.js +++ b/src/language-js/embed.js @@ -16,7 +16,7 @@ const { utils: { mapDoc, stripTrailingHardline } } = require("../doc"); -function embed(path, print, textToDoc /*, options */) { +function embed(path, print, textToDoc, options) { const node = path.getValue(); const parent = path.getParentNode(); const parentParent = path.getParentNode(1); @@ -135,12 +135,20 @@ function embed(path, print, textToDoc /*, options */) { ]); } - if (isHtml(path)) { - return printHtmlTemplateLiteral(path, print, textToDoc, "html"); - } + const htmlParser = isHtml(path) + ? "html" + : isAngularComponentTemplate(path) + ? "angular" + : undefined; - if (isAngularComponentTemplate(path)) { - return printHtmlTemplateLiteral(path, print, textToDoc, "angular"); + if (htmlParser) { + return printHtmlTemplateLiteral( + path, + print, + textToDoc, + htmlParser, + options.embeddedInHtml + ); } break; @@ -195,6 +203,10 @@ function getIndentation(str) { return firstMatchedIndent === null ? "" : firstMatchedIndent[1]; } +function uncook(cookedValue) { + return cookedValue.replace(/([\\`]|\$\{)/g, "\\$1"); +} + function escapeTemplateCharacters(doc, raw) { return mapDoc(doc, currentDoc => { if (!currentDoc.parts) { @@ -205,11 +217,7 @@ function escapeTemplateCharacters(doc, raw) { currentDoc.parts.forEach(part => { if (typeof part === "string") { - parts.push( - raw - ? part.replace(/(\\*)`/g, "$1$1\\`") - : part.replace(/([\\`]|\$\{)/g, "\\$1") - ); + parts.push(raw ? part.replace(/(\\*)`/g, "$1$1\\`") : uncook(part)); } else { parts.push(part); } @@ -576,19 +584,29 @@ function isHtml(path) { ); } -function printHtmlTemplateLiteral(path, print, textToDoc, parser) { +// The counter is needed to distinguish nested embeds. +let htmlTemplateLiteralCounter = 0; + +function printHtmlTemplateLiteral( + path, + print, + textToDoc, + parser, + escapeClosingScriptTag +) { const node = path.getValue(); - const placeholderPattern = "PRETTIER_HTML_PLACEHOLDER_(\\d+)_IN_JS"; - const placeholders = node.expressions.map( - (_, i) => `PRETTIER_HTML_PLACEHOLDER_${i}_IN_JS` - ); + const counter = htmlTemplateLiteralCounter; + htmlTemplateLiteralCounter = (htmlTemplateLiteralCounter + 1) >>> 0; + + const composePlaceholder = index => + `PRETTIER_HTML_PLACEHOLDER_${index}_${counter}_IN_JS`; const text = node.quasis .map((quasi, index, quasis) => index === quasis.length - 1 - ? quasi.value.raw - : quasi.value.raw + placeholders[index] + ? quasi.value.cooked + : quasi.value.cooked + composePlaceholder(index) ) .join(""); @@ -598,14 +616,12 @@ function printHtmlTemplateLiteral(path, print, textToDoc, parser) { return "``"; } + const placeholderRegex = RegExp(composePlaceholder("(\\d+)"), "g"); + const contentDoc = mapDoc( stripTrailingHardline(textToDoc(text, { parser })), doc => { - const placeholderRegex = new RegExp(placeholderPattern, "g"); - const hasPlaceholder = - typeof doc === "string" && placeholderRegex.test(doc); - - if (!hasPlaceholder) { + if (typeof doc !== "string") { return doc; } @@ -613,10 +629,14 @@ function printHtmlTemplateLiteral(path, print, textToDoc, parser) { const components = doc.split(placeholderRegex); for (let i = 0; i < components.length; i++) { - const component = components[i]; + let component = components[i]; if (i % 2 === 0) { if (component) { + component = uncook(component); + if (escapeClosingScriptTag) { + component = component.replace(/<\/(script)\b/gi, "<\\/$1"); + } parts.push(component); } continue; diff --git a/src/language-js/needs-parens.js b/src/language-js/needs-parens.js index 1d88b910..a9cc0e58 100644 --- a/src/language-js/needs-parens.js +++ b/src/language-js/needs-parens.js @@ -129,6 +129,18 @@ function needsParens(path, options) { // Identifiers never need parentheses. if (node.type === "Identifier") { + // ...unless those identifiers are embed placeholders. They might be substituted by complex + // expressions, so the parens around them should not be dropped. Example (JS-in-HTML-in-JS): + // let tpl = html``; + // If the inner JS formatter removes the parens, the expression might change its meaning: + // f((a + b) / 2) vs f(a + b / 2) + if ( + node.extra && + node.extra.parenthesized && + /^PRETTIER_HTML_PLACEHOLDER_\d+_\d+_IN_JS$/.test(node.name) + ) { + return true; + } return false; } diff --git a/src/main/multiparser.js b/src/main/multiparser.js index c0f53a82..16e5fb60 100644 --- a/src/main/multiparser.js +++ b/src/main/multiparser.js @@ -19,6 +19,13 @@ function textToDoc(text, partialNextOptions, parentOptions, printAstToDoc) { const nextOptions = normalize( Object.assign({}, parentOptions, partialNextOptions, { parentParser: parentOptions.parser, + embeddedInHtml: !!( + parentOptions.embeddedInHtml || + parentOptions.parser === "html" || + parentOptions.parser === "vue" || + parentOptions.parser === "angular" || + parentOptions.parser === "lwc" + ), originalText: text }), { passThrough: true } diff --git a/tests/multiparser_html_js/__snapshots__/jsfmt.spec.js.snap b/tests/multiparser_html_js/__snapshots__/jsfmt.spec.js.snap index fbcd0e3c..30c347df 100644 --- a/tests/multiparser_html_js/__snapshots__/jsfmt.spec.js.snap +++ b/tests/multiparser_html_js/__snapshots__/jsfmt.spec.js.snap @@ -31,3 +31,47 @@ printWidth: 80 ================================================================================ `; + +exports[`script-tag-escaping.html 1`] = ` +====================================options===================================== +parsers: ["html"] +printWidth: 80 + | printWidth +=====================================input====================================== + + +=====================================output===================================== + + +================================================================================ +`; diff --git a/tests/multiparser_html_js/script-tag-escaping.html b/tests/multiparser_html_js/script-tag-escaping.html new file mode 100644 index 00000000..575e07b2 --- /dev/null +++ b/tests/multiparser_html_js/script-tag-escaping.html @@ -0,0 +1,13 @@ + diff --git a/tests/multiparser_js_html/__snapshots__/jsfmt.spec.js.snap b/tests/multiparser_js_html/__snapshots__/jsfmt.spec.js.snap index 3a7734fa..8736fba1 100644 --- a/tests/multiparser_js_html/__snapshots__/jsfmt.spec.js.snap +++ b/tests/multiparser_js_html/__snapshots__/jsfmt.spec.js.snap @@ -68,6 +68,17 @@ function HelloWorld() { \`; } +const trickyParens = html\`\`; +const nestedFun = /* HTML */ \`\${outerExpr( 1 )} \`; + +const closingScriptTagShouldBeEscapedProperly = /* HTML */ \` + +\`; + +const closingScriptTag2 = /* HTML */ \` +\`; +const nestedFun = /* HTML */ \` + \${outerExpr(1)} + +\`; + +const closingScriptTagShouldBeEscapedProperly = /* HTML */ \` + +\`; + +const closingScriptTag2 = /* HTML */ \` + +\`; + ================================================================================ `; diff --git a/tests/multiparser_js_html/lit-html.js b/tests/multiparser_js_html/lit-html.js index e5a511c3..d1479072 100644 --- a/tests/multiparser_js_html/lit-html.js +++ b/tests/multiparser_js_html/lit-html.js @@ -59,3 +59,14 @@ function HelloWorld() { `)} `; } + +const trickyParens = html``; +const nestedFun = /* HTML */ `${outerExpr( 1 )} `; + +const closingScriptTagShouldBeEscapedProperly = /* HTML */ ` + +`; + +const closingScriptTag2 = /* HTML */ `