diff --git a/src/language-js/embed.js b/src/language-js/embed.js index 9daf9b028..7c7ee3eb9 100644 --- a/src/language-js/embed.js +++ b/src/language-js/embed.js @@ -1,31 +1,20 @@ "use strict"; -const { - builders: { - indent, - join, - line, - hardline, - softline, - literalline, - concat, - group, - dedentToRoot, - lineSuffixBoundary, - }, - utils: { mapDoc, replaceNewlinesWithLiterallines }, -} = require("../document"); const { isBlockComment } = require("./utils"); const { hasLeadingComment } = require("./comments"); +const formatMarkdown = require("./embed/markdown"); +const formatCss = require("./embed/css"); +const formatGraphql = require("./embed/graphql"); +const formatHtml = require("./embed/html"); -function getParser(path) { +function getLanguage(path) { if ( isStyledJsx(path) || isStyledComponents(path) || isCssProp(path) || isAngularComponentStyles(path) ) { - return "scss"; + return "css"; } if (isGraphQL(path)) { @@ -57,130 +46,25 @@ function embed(path, print, textToDoc, options) { return; } - const parser = getParser(path); - if (!parser) { + const language = getLanguage(path); + if (!language) { return; } - if (parser === "markdown") { - let text = node.quasis[0].value.raw.replace( - /((?:\\\\)*)\\`/g, - (_, backslashes) => "\\".repeat(backslashes.length / 2) + "`" - ); - const indentation = getIndentation(text); - const hasIndent = indentation !== ""; - if (hasIndent) { - text = text.replace(new RegExp(`^${indentation}`, "gm"), ""); - } - const doc = printMarkdown(text, textToDoc); - return concat([ - "`", - hasIndent - ? indent(concat([softline, doc])) - : concat([literalline, dedentToRoot(doc)]), - softline, - "`", - ]); + if (language === "markdown") { + return formatMarkdown(path, print, textToDoc); } - const expressionDocs = path.map( - (path) => printTemplateExpression(path, print), - "expressions" - ); - - if (parser === "scss") { - // Get full template literal with expressions replaced by placeholders - const rawQuasis = node.quasis.map((q) => q.value.raw); - let placeholderID = 0; - const text = rawQuasis.reduce((prevVal, currVal, idx) => { - return idx === 0 - ? currVal - : prevVal + - "@prettier-placeholder-" + - placeholderID++ + - "-id" + - currVal; - }, ""); - const doc = textToDoc(text, { parser }, { stripTrailingHardline: true }); - return transformCssDoc(doc, node, expressionDocs); + if (language === "css") { + return formatCss(path, print, textToDoc); } - if (parser === "graphql") { - const numQuasis = node.quasis.length; - if (numQuasis === 1 && node.quasis[0].value.raw.trim() === "") { - return "``"; - } - - const parts = []; - - for (let i = 0; i < numQuasis; i++) { - const templateElement = node.quasis[i]; - const isFirst = i === 0; - const isLast = i === numQuasis - 1; - const text = templateElement.value.cooked; - - const lines = text.split("\n"); - const numLines = lines.length; - const expressionDoc = expressionDocs[i]; - - const startsWithBlankLine = - numLines > 2 && lines[0].trim() === "" && lines[1].trim() === ""; - const endsWithBlankLine = - numLines > 2 && - lines[numLines - 1].trim() === "" && - lines[numLines - 2].trim() === ""; - - const commentsAndWhitespaceOnly = lines.every((line) => - /^\s*(?:#[^\n\r]*)?$/.test(line) - ); - - // Bail out if an interpolation occurs within a comment. - if (!isLast && /#[^\n\r]*$/.test(lines[numLines - 1])) { - return null; - } - - let doc = null; - - if (commentsAndWhitespaceOnly) { - doc = printGraphqlComments(lines); - } else { - doc = textToDoc(text, { parser }, { stripTrailingHardline: true }); - } - - if (doc) { - doc = escapeTemplateCharacters(doc, false); - if (!isFirst && startsWithBlankLine) { - parts.push(""); - } - parts.push(doc); - if (!isLast && endsWithBlankLine) { - parts.push(""); - } - } else if (!isFirst && !isLast && startsWithBlankLine) { - parts.push(""); - } - - if (expressionDoc) { - parts.push(expressionDoc); - } - } - - return concat([ - "`", - indent(concat([hardline, join(hardline, parts)])), - hardline, - "`", - ]); + if (language === "graphql") { + return formatGraphql(path, print, textToDoc); } - if (parser === "html" || parser === "angular") { - return printHtmlTemplateLiteral( - node, - expressionDocs, - textToDoc, - parser, - options - ); + if (language === "html" || language === "angular") { + return formatHtml(path, print, textToDoc, options, { parser: language }); } } @@ -200,153 +84,6 @@ function isMarkdown(path) { ); } -function printTemplateExpression(path, print) { - const node = path.getValue(); - let printed = print(path); - if (node.comments && node.comments.length) { - printed = group(concat([indent(concat([softline, printed])), softline])); - } - return concat(["${", printed, lineSuffixBoundary, "}"]); -} - -function printMarkdown(text, textToDoc) { - const doc = textToDoc( - text, - { parser: "markdown", __inJsTemplate: true }, - { stripTrailingHardline: true } - ); - return escapeTemplateCharacters(doc, true); -} - -function getIndentation(str) { - const firstMatchedIndent = str.match(/^([^\S\n]*)\S/m); - return firstMatchedIndent === null ? "" : firstMatchedIndent[1]; -} - -function uncook(cookedValue) { - return cookedValue.replace(/([\\`]|\${)/g, "\\$1"); -} - -function escapeTemplateCharacters(doc, raw) { - return mapDoc(doc, (currentDoc) => { - if (!currentDoc.parts) { - return currentDoc; - } - - const parts = currentDoc.parts.map((part) => { - if (typeof part === "string") { - return raw ? part.replace(/(\\*)`/g, "$1$1\\`") : uncook(part); - } - - return part; - }); - - return { ...currentDoc, parts }; - }); -} - -function transformCssDoc(quasisDoc, parentNode, expressionDocs) { - const isEmpty = - parentNode.quasis.length === 1 && !parentNode.quasis[0].value.raw.trim(); - if (isEmpty) { - return "``"; - } - - const newDoc = replacePlaceholders(quasisDoc, expressionDocs); - /* istanbul ignore if */ - if (!newDoc) { - throw new Error("Couldn't insert all the expressions"); - } - return concat(["`", indent(concat([hardline, newDoc])), softline, "`"]); -} - -// Search all the placeholders in the quasisDoc tree -// and replace them with the expression docs one by one -// returns a new doc with all the placeholders replaced, -// or null if it couldn't replace any expression -function replacePlaceholders(quasisDoc, expressionDocs) { - if (!expressionDocs || !expressionDocs.length) { - return quasisDoc; - } - - let replaceCounter = 0; - const newDoc = mapDoc(quasisDoc, (doc) => { - if (!doc || !doc.parts || !doc.parts.length) { - return doc; - } - - let { parts } = doc; - const atIndex = parts.indexOf("@"); - const placeholderIndex = atIndex + 1; - if ( - atIndex > -1 && - typeof parts[placeholderIndex] === "string" && - parts[placeholderIndex].startsWith("prettier-placeholder") - ) { - // If placeholder is split, join it - const at = parts[atIndex]; - const placeholder = parts[placeholderIndex]; - const rest = parts.slice(placeholderIndex + 1); - parts = parts - .slice(0, atIndex) - .concat([at + placeholder]) - .concat(rest); - } - - const replacedParts = []; - parts.forEach((part) => { - if (typeof part !== "string" || !part.includes("@prettier-placeholder")) { - replacedParts.push(part); - return; - } - - // When we have multiple placeholders in one line, like: - // ${Child}${Child2}:not(:first-child) - part.split(/@prettier-placeholder-(\d+)-id/).forEach((component, idx) => { - // The placeholder is always at odd indices - if (idx % 2 === 0) { - replacedParts.push(replaceNewlinesWithLiterallines(component)); - return; - } - - // The component will always be a number at odd index - replacedParts.push(expressionDocs[component]); - replaceCounter++; - }); - }); - return { ...doc, parts: replacedParts }; - }); - return expressionDocs.length === replaceCounter ? newDoc : null; -} - -function printGraphqlComments(lines) { - const parts = []; - let seenComment = false; - - lines - .map((textLine) => textLine.trim()) - .forEach((textLine, i, array) => { - // Lines are either whitespace only, or a comment (with potential whitespace - // around it). Drop whitespace-only lines. - if (textLine === "") { - return; - } - - if (array[i - 1] === "" && seenComment) { - // If a non-first comment is preceded by a blank (whitespace only) line, - // add in a blank line. - parts.push(concat([hardline, textLine])); - } else { - parts.push(textLine); - } - - seenComment = true; - }); - - // If `lines` was whitespace only, return `null`. - return parts.length === 0 ? null : join(hardline, parts); -} - /** * Template literal in these contexts: * @@ -555,110 +292,6 @@ function isHtml(path) { ); } -// The counter is needed to distinguish nested embeds. -let htmlTemplateLiteralCounter = 0; - -function printHtmlTemplateLiteral( - node, - expressionDocs, - textToDoc, - parser, - options -) { - 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.cooked - : quasi.value.cooked + composePlaceholder(index) - ) - .join(""); - - if (expressionDocs.length === 0 && text.trim().length === 0) { - return "``"; - } - - const placeholderRegex = new RegExp(composePlaceholder("(\\d+)"), "g"); - let topLevelCount = 0; - - const contentDoc = mapDoc( - textToDoc( - text, - { - parser, - __onHtmlRoot(root) { - topLevelCount = root.children.length; - }, - }, - { stripTrailingHardline: true } - ), - (doc) => { - if (typeof doc !== "string") { - return doc; - } - - const parts = []; - - const components = doc.split(placeholderRegex); - for (let i = 0; i < components.length; i++) { - let component = components[i]; - - if (i % 2 === 0) { - if (component) { - component = uncook(component); - if (options.embeddedInHtml) { - component = component.replace(/<\/(script)\b/gi, "<\\/$1"); - } - parts.push(component); - } - continue; - } - - const placeholderIndex = +component; - parts.push(expressionDocs[placeholderIndex]); - } - - return concat(parts); - } - ); - - const leadingWhitespace = /^\s/.test(text) ? " " : ""; - const trailingWhitespace = /\s$/.test(text) ? " " : ""; - - const linebreak = - options.htmlWhitespaceSensitivity === "ignore" - ? hardline - : leadingWhitespace && trailingWhitespace - ? line - : null; - - if (linebreak) { - return group( - concat([ - "`", - indent(concat([linebreak, group(contentDoc)])), - linebreak, - "`", - ]) - ); - } - - return group( - concat([ - "`", - leadingWhitespace, - topLevelCount > 1 ? indent(group(contentDoc)) : group(contentDoc), - trailingWhitespace, - "`", - ]) - ); -} - function hasInvalidCookedValue({ quasis }) { return quasis.some(({ value: { cooked } }) => cooked === null); } diff --git a/src/language-js/embed/css.js b/src/language-js/embed/css.js new file mode 100644 index 000000000..3ee4e1fa3 --- /dev/null +++ b/src/language-js/embed/css.js @@ -0,0 +1,103 @@ +"use strict"; + +const { + builders: { indent, hardline, softline, concat }, + utils: { mapDoc, replaceNewlinesWithLiterallines }, +} = require("../../document"); +const { printTemplateExpressions } = require("../print/template-literal"); + +function format(path, print, textToDoc) { + const node = path.getValue(); + + // Get full template literal with expressions replaced by placeholders + const rawQuasis = node.quasis.map((q) => q.value.raw); + let placeholderID = 0; + const text = rawQuasis.reduce((prevVal, currVal, idx) => { + return idx === 0 + ? currVal + : prevVal + "@prettier-placeholder-" + placeholderID++ + "-id" + currVal; + }, ""); + const doc = textToDoc( + text, + { parser: "scss" }, + { stripTrailingHardline: true } + ); + const expressionDocs = printTemplateExpressions(path, print); + return transformCssDoc(doc, node, expressionDocs); +} + +function transformCssDoc(quasisDoc, parentNode, expressionDocs) { + const isEmpty = + parentNode.quasis.length === 1 && !parentNode.quasis[0].value.raw.trim(); + if (isEmpty) { + return "``"; + } + + const newDoc = replacePlaceholders(quasisDoc, expressionDocs); + /* istanbul ignore if */ + if (!newDoc) { + throw new Error("Couldn't insert all the expressions"); + } + return concat(["`", indent(concat([hardline, newDoc])), softline, "`"]); +} + +// Search all the placeholders in the quasisDoc tree +// and replace them with the expression docs one by one +// returns a new doc with all the placeholders replaced, +// or null if it couldn't replace any expression +function replacePlaceholders(quasisDoc, expressionDocs) { + if (!expressionDocs || !expressionDocs.length) { + return quasisDoc; + } + + let replaceCounter = 0; + const newDoc = mapDoc(quasisDoc, (doc) => { + if (!doc || !doc.parts || !doc.parts.length) { + return doc; + } + + let { parts } = doc; + const atIndex = parts.indexOf("@"); + const placeholderIndex = atIndex + 1; + if ( + atIndex > -1 && + typeof parts[placeholderIndex] === "string" && + parts[placeholderIndex].startsWith("prettier-placeholder") + ) { + // If placeholder is split, join it + const at = parts[atIndex]; + const placeholder = parts[placeholderIndex]; + const rest = parts.slice(placeholderIndex + 1); + parts = parts + .slice(0, atIndex) + .concat([at + placeholder]) + .concat(rest); + } + + const replacedParts = []; + parts.forEach((part) => { + if (typeof part !== "string" || !part.includes("@prettier-placeholder")) { + replacedParts.push(part); + return; + } + + // When we have multiple placeholders in one line, like: + // ${Child}${Child2}:not(:first-child) + part.split(/@prettier-placeholder-(\d+)-id/).forEach((component, idx) => { + // The placeholder is always at odd indices + if (idx % 2 === 0) { + replacedParts.push(replaceNewlinesWithLiterallines(component)); + return; + } + + // The component will always be a number at odd index + replacedParts.push(expressionDocs[component]); + replaceCounter++; + }); + }); + return { ...doc, parts: replacedParts }; + }); + return expressionDocs.length === replaceCounter ? newDoc : null; +} + +module.exports = format; diff --git a/src/language-js/embed/graphql.js b/src/language-js/embed/graphql.js new file mode 100644 index 000000000..ba0b4efe1 --- /dev/null +++ b/src/language-js/embed/graphql.js @@ -0,0 +1,114 @@ +"use strict"; + +const { + builders: { indent, join, hardline, concat }, +} = require("../../document"); +const { + escapeTemplateCharacters, + printTemplateExpressions, +} = require("../print/template-literal"); + +function format(path, print, textToDoc) { + const node = path.getValue(); + + const numQuasis = node.quasis.length; + if (numQuasis === 1 && node.quasis[0].value.raw.trim() === "") { + return "``"; + } + + const expressionDocs = printTemplateExpressions(path, print); + const parts = []; + + for (let i = 0; i < numQuasis; i++) { + const templateElement = node.quasis[i]; + const isFirst = i === 0; + const isLast = i === numQuasis - 1; + const text = templateElement.value.cooked; + + const lines = text.split("\n"); + const numLines = lines.length; + const expressionDoc = expressionDocs[i]; + + const startsWithBlankLine = + numLines > 2 && lines[0].trim() === "" && lines[1].trim() === ""; + const endsWithBlankLine = + numLines > 2 && + lines[numLines - 1].trim() === "" && + lines[numLines - 2].trim() === ""; + + const commentsAndWhitespaceOnly = lines.every((line) => + /^\s*(?:#[^\n\r]*)?$/.test(line) + ); + + // Bail out if an interpolation occurs within a comment. + if (!isLast && /#[^\n\r]*$/.test(lines[numLines - 1])) { + return null; + } + + let doc = null; + + if (commentsAndWhitespaceOnly) { + doc = printGraphqlComments(lines); + } else { + doc = textToDoc( + text, + { parser: "graphql" }, + { stripTrailingHardline: true } + ); + } + + if (doc) { + doc = escapeTemplateCharacters(doc, false); + if (!isFirst && startsWithBlankLine) { + parts.push(""); + } + parts.push(doc); + if (!isLast && endsWithBlankLine) { + parts.push(""); + } + } else if (!isFirst && !isLast && startsWithBlankLine) { + parts.push(""); + } + + if (expressionDoc) { + parts.push(expressionDoc); + } + } + + return concat([ + "`", + indent(concat([hardline, join(hardline, parts)])), + hardline, + "`", + ]); +} + +function printGraphqlComments(lines) { + const parts = []; + let seenComment = false; + + lines + .map((textLine) => textLine.trim()) + .forEach((textLine, i, array) => { + // Lines are either whitespace only, or a comment (with potential whitespace + // around it). Drop whitespace-only lines. + if (textLine === "") { + return; + } + + if (array[i - 1] === "" && seenComment) { + // If a non-first comment is preceded by a blank (whitespace only) line, + // add in a blank line. + parts.push(concat([hardline, textLine])); + } else { + parts.push(textLine); + } + + seenComment = true; + }); + + // If `lines` was whitespace only, return `null`. + return parts.length === 0 ? null : join(hardline, parts); +} + +module.exports = format; diff --git a/src/language-js/embed/html.js b/src/language-js/embed/html.js new file mode 100644 index 000000000..f7042de40 --- /dev/null +++ b/src/language-js/embed/html.js @@ -0,0 +1,109 @@ +"use strict"; + +const { + builders: { indent, line, hardline, concat, group }, + utils: { mapDoc }, +} = require("../../document"); +const { + printTemplateExpressions, + uncookTemplateElementValue, +} = require("../print/template-literal"); + +// The counter is needed to distinguish nested embeds. +let htmlTemplateLiteralCounter = 0; +function format(path, print, textToDoc, options, { parser }) { + const node = path.getValue(); + 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.cooked + : quasi.value.cooked + composePlaceholder(index) + ) + .join(""); + + const expressionDocs = printTemplateExpressions(path, print); + if (expressionDocs.length === 0 && text.trim().length === 0) { + return "``"; + } + + const placeholderRegex = new RegExp(composePlaceholder("(\\d+)"), "g"); + let topLevelCount = 0; + const doc = textToDoc( + text, + { + parser, + __onHtmlRoot(root) { + topLevelCount = root.children.length; + }, + }, + { stripTrailingHardline: true } + ); + + const contentDoc = mapDoc(doc, (doc) => { + if (typeof doc !== "string") { + return doc; + } + + const parts = []; + + const components = doc.split(placeholderRegex); + for (let i = 0; i < components.length; i++) { + let component = components[i]; + + if (i % 2 === 0) { + if (component) { + component = uncookTemplateElementValue(component); + if (options.embeddedInHtml) { + component = component.replace(/<\/(script)\b/gi, "<\\/$1"); + } + parts.push(component); + } + continue; + } + + const placeholderIndex = +component; + parts.push(expressionDocs[placeholderIndex]); + } + + return concat(parts); + }); + + const leadingWhitespace = /^\s/.test(text) ? " " : ""; + const trailingWhitespace = /\s$/.test(text) ? " " : ""; + + const linebreak = + options.htmlWhitespaceSensitivity === "ignore" + ? hardline + : leadingWhitespace && trailingWhitespace + ? line + : null; + + if (linebreak) { + return group( + concat([ + "`", + indent(concat([linebreak, group(contentDoc)])), + linebreak, + "`", + ]) + ); + } + + return group( + concat([ + "`", + leadingWhitespace, + topLevelCount > 1 ? indent(group(contentDoc)) : group(contentDoc), + trailingWhitespace, + "`", + ]) + ); +} + +module.exports = format; diff --git a/src/language-js/embed/markdown.js b/src/language-js/embed/markdown.js new file mode 100644 index 000000000..2cef8f5d9 --- /dev/null +++ b/src/language-js/embed/markdown.js @@ -0,0 +1,42 @@ +"use strict"; + +const { + builders: { indent, softline, literalline, concat, dedentToRoot }, +} = require("../../document"); +const { escapeTemplateCharacters } = require("../print/template-literal"); + +function format(path, print, textToDoc) { + const node = path.getValue(); + let text = node.quasis[0].value.raw.replace( + /((?:\\\\)*)\\`/g, + (_, backslashes) => "\\".repeat(backslashes.length / 2) + "`" + ); + const indentation = getIndentation(text); + const hasIndent = indentation !== ""; + if (hasIndent) { + text = text.replace(new RegExp(`^${indentation}`, "gm"), ""); + } + const doc = escapeTemplateCharacters( + textToDoc( + text, + { parser: "markdown", __inJsTemplate: true }, + { stripTrailingHardline: true } + ), + true + ); + return concat([ + "`", + hasIndent + ? indent(concat([softline, doc])) + : concat([literalline, dedentToRoot(doc)]), + softline, + "`", + ]); +} + +function getIndentation(str) { + const firstMatchedIndent = str.match(/^([^\S\n]*)\S/m); + return firstMatchedIndent === null ? "" : firstMatchedIndent[1]; +} + +module.exports = format; diff --git a/src/language-js/print/template-literal.js b/src/language-js/print/template-literal.js index 5be829211..2ef4d77f9 100644 --- a/src/language-js/print/template-literal.js +++ b/src/language-js/print/template-literal.js @@ -14,6 +14,7 @@ const { addAlignmentToDoc, }, printer: { printDocToString }, + utils: { mapDoc }, } = require("../../document"); const { isBinaryish, @@ -190,4 +191,49 @@ function printJestEachTemplateLiteral(path, options, print) { } } -module.exports = printTemplateLiteral; +function printTemplateExpression(path, print) { + const node = path.getValue(); + let printed = print(path); + if (node.comments && node.comments.length) { + printed = group(concat([indent(concat([softline, printed])), softline])); + } + return concat(["${", printed, lineSuffixBoundary, "}"]); +} + +function printTemplateExpressions(path, print) { + return path.map( + (path) => printTemplateExpression(path, print), + "expressions" + ); +} + +function escapeTemplateCharacters(doc, raw) { + return mapDoc(doc, (currentDoc) => { + if (!currentDoc.parts) { + return currentDoc; + } + + const parts = currentDoc.parts.map((part) => { + if (typeof part === "string") { + return raw + ? part.replace(/(\\*)`/g, "$1$1\\`") + : uncookTemplateElementValue(part); + } + + return part; + }); + + return { ...currentDoc, parts }; + }); +} + +function uncookTemplateElementValue(cookedValue) { + return cookedValue.replace(/([\\`]|\${)/g, "\\$1"); +} + +module.exports = { + printTemplateLiteral, + printTemplateExpressions, + escapeTemplateCharacters, + uncookTemplateElementValue, +}; diff --git a/src/language-js/printer-estree.js b/src/language-js/printer-estree.js index a4f179e3b..a6b8c0bc3 100644 --- a/src/language-js/printer-estree.js +++ b/src/language-js/printer-estree.js @@ -123,7 +123,7 @@ const { printFunctionParameters, shouldHugFunctionParameters, } = require("./print/function-parameters"); -const printTemplateLiteral = require("./print/template-literal"); +const { printTemplateLiteral } = require("./print/template-literal"); const needsQuoteProps = new WeakMap();