prettier/src/language-js/embed.js

661 lines
18 KiB
JavaScript

"use strict";
const { isBlockComment, hasLeadingComment } = require("./comments");
const {
builders: {
indent,
join,
hardline,
softline,
literalline,
concat,
group,
dedentToRoot
},
utils: { mapDoc, stripTrailingHardline }
} = require("../doc");
function embed(path, print, textToDoc, options) {
const node = path.getValue();
const parent = path.getParentNode();
const parentParent = path.getParentNode(1);
switch (node.type) {
case "TemplateLiteral": {
const isCss = [
isStyledJsx,
isStyledComponents,
isCssProp,
isAngularComponentStyles
].some(isIt => isIt(path));
if (isCss) {
// 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: "css" });
return transformCssDoc(doc, path, print);
}
/*
* react-relay and graphql-tag
* graphql`...`
* graphql.experimental`...`
* gql`...`
*
* This intentionally excludes Relay Classic tags, as Prettier does not
* support Relay Classic formatting.
*/
if (isGraphQL(path)) {
const expressionDocs = node.expressions
? path.map(print, "expressions")
: [];
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;
// Bail out if any of the quasis have an invalid escape sequence
// (which would make the `cooked` value be `null` or `undefined`)
if (typeof text !== "string") {
return null;
}
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*(?:#[^\r\n]*)?$/.test(line)
);
// Bail out if an interpolation occurs within a comment.
if (!isLast && /#[^\r\n]*$/.test(lines[numLines - 1])) {
return null;
}
let doc = null;
if (commentsAndWhitespaceOnly) {
doc = printGraphqlComments(lines);
} else {
doc = stripTrailingHardline(textToDoc(text, { parser: "graphql" }));
}
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(concat(["${", expressionDoc, "}"]));
}
}
return concat([
"`",
indent(concat([hardline, join(hardline, parts)])),
hardline,
"`"
]);
}
const htmlParser = isHtml(path)
? "html"
: isAngularComponentTemplate(path)
? "angular"
: undefined;
if (htmlParser) {
return printHtmlTemplateLiteral(
path,
print,
textToDoc,
htmlParser,
options.embeddedInHtml
);
}
break;
}
case "TemplateElement": {
/**
* md`...`
* markdown`...`
*/
if (
parentParent &&
(parentParent.type === "TaggedTemplateExpression" &&
parent.quasis.length === 1 &&
(parentParent.tag.type === "Identifier" &&
(parentParent.tag.name === "md" ||
parentParent.tag.name === "markdown")))
) {
const text = parent.quasis[0].value.raw.replace(
/((?:\\\\)*)\\`/g,
(_, backslashes) => "\\".repeat(backslashes.length / 2) + "`"
);
const indentation = getIndentation(text);
const hasIndent = indentation !== "";
return concat([
hasIndent
? indent(
concat([
softline,
printMarkdown(
text.replace(new RegExp(`^${indentation}`, "gm"), "")
)
])
)
: concat([literalline, dedentToRoot(printMarkdown(text))]),
softline
]);
}
break;
}
}
function printMarkdown(text) {
const doc = textToDoc(text, { parser: "markdown", __inJsTemplate: true });
return stripTrailingHardline(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.forEach(part => {
if (typeof part === "string") {
parts.push(raw ? part.replace(/(\\*)`/g, "$1$1\\`") : uncook(part));
} else {
parts.push(part);
}
});
return Object.assign({}, currentDoc, { parts });
});
}
function transformCssDoc(quasisDoc, path, print) {
const parentNode = path.getValue();
const isEmpty =
parentNode.quasis.length === 1 && !parentNode.quasis[0].value.raw.trim();
if (isEmpty) {
return "``";
}
const expressionDocs = parentNode.expressions
? path.map(print, "expressions")
: [];
const newDoc = replacePlaceholders(quasisDoc, expressionDocs);
/* istanbul ignore if */
if (!newDoc) {
throw new Error("Couldn't insert all the expressions");
}
return concat([
"`",
indent(concat([hardline, stripTrailingHardline(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;
}
const expressions = expressionDocs.slice();
let replaceCounter = 0;
const newDoc = mapDoc(quasisDoc, doc => {
if (!doc || !doc.parts || !doc.parts.length) {
return doc;
}
let parts = doc.parts;
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 atPlaceholderIndex = parts.findIndex(
part =>
typeof part === "string" && part.startsWith("@prettier-placeholder")
);
if (atPlaceholderIndex > -1) {
const placeholder = parts[atPlaceholderIndex];
const rest = parts.slice(atPlaceholderIndex + 1);
const placeholderMatch = placeholder.match(
/@prettier-placeholder-(.+)-id([\s\S]*)/
);
const placeholderID = placeholderMatch[1];
// When the expression has a suffix appended, like:
// animation: linear ${time}s ease-out;
const suffix = placeholderMatch[2];
const expression = expressions[placeholderID];
replaceCounter++;
parts = parts
.slice(0, atPlaceholderIndex)
.concat(["${", expression, "}" + suffix])
.concat(rest);
}
return Object.assign({}, doc, {
parts: parts
});
});
return expressions.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:
* <style jsx>{`div{color:red}`}</style>
* css``
* css.global``
* css.resolve``
*/
function isStyledJsx(path) {
const node = path.getValue();
const parent = path.getParentNode();
const parentParent = path.getParentNode(1);
return (
(parentParent &&
node.quasis &&
parent.type === "JSXExpressionContainer" &&
parentParent.type === "JSXElement" &&
parentParent.openingElement.name.name === "style" &&
parentParent.openingElement.attributes.some(
attribute => attribute.name.name === "jsx"
)) ||
(parent &&
parent.type === "TaggedTemplateExpression" &&
parent.tag.type === "Identifier" &&
parent.tag.name === "css") ||
(parent &&
parent.type === "TaggedTemplateExpression" &&
parent.tag.type === "MemberExpression" &&
parent.tag.object.name === "css" &&
(parent.tag.property.name === "global" ||
parent.tag.property.name === "resolve"))
);
}
/**
* Angular Components can have:
* - Inline HTML template
* - Inline CSS styles
*
* ...which are both within template literals somewhere
* inside of the Component decorator factory.
*
* E.g.
* @Component({
* template: `<div>...</div>`,
* styles: [`h1 { color: blue; }`]
* })
*/
function isAngularComponentStyles(path) {
return isPathMatch(
path,
[
node => node.type === "TemplateLiteral",
(node, name) => node.type === "ArrayExpression" && name === "elements",
(node, name) =>
node.type === "Property" &&
node.key.type === "Identifier" &&
node.key.name === "styles" &&
name === "value"
].concat(getAngularComponentObjectExpressionPredicates())
);
}
function isAngularComponentTemplate(path) {
return isPathMatch(
path,
[
node => node.type === "TemplateLiteral",
(node, name) =>
node.type === "Property" &&
node.key.type === "Identifier" &&
node.key.name === "template" &&
name === "value"
].concat(getAngularComponentObjectExpressionPredicates())
);
}
function getAngularComponentObjectExpressionPredicates() {
return [
(node, name) => node.type === "ObjectExpression" && name === "properties",
(node, name) =>
node.type === "CallExpression" &&
node.callee.type === "Identifier" &&
node.callee.name === "Component" &&
name === "arguments",
(node, name) => node.type === "Decorator" && name === "expression"
];
}
/**
* styled-components template literals
*/
function isStyledComponents(path) {
const parent = path.getParentNode();
if (!parent || parent.type !== "TaggedTemplateExpression") {
return false;
}
const tag = parent.tag;
switch (tag.type) {
case "MemberExpression":
return (
// styled.foo``
isStyledIdentifier(tag.object) ||
// Component.extend``
isStyledExtend(tag)
);
case "CallExpression":
return (
// styled(Component)``
isStyledIdentifier(tag.callee) ||
(tag.callee.type === "MemberExpression" &&
((tag.callee.object.type === "MemberExpression" &&
// styled.foo.attrs({})``
(isStyledIdentifier(tag.callee.object.object) ||
// Component.extend.attrs({})``
isStyledExtend(tag.callee.object))) ||
// styled(Component).attrs({})``
(tag.callee.object.type === "CallExpression" &&
isStyledIdentifier(tag.callee.object.callee))))
);
case "Identifier":
// css``
return tag.name === "css";
default:
return false;
}
}
/**
* JSX element with CSS prop
*/
function isCssProp(path) {
const parent = path.getParentNode();
const parentParent = path.getParentNode(1);
return (
parentParent &&
parent.type === "JSXExpressionContainer" &&
parentParent.type === "JSXAttribute" &&
parentParent.name.type === "JSXIdentifier" &&
parentParent.name.name === "css"
);
}
function isStyledIdentifier(node) {
return node.type === "Identifier" && node.name === "styled";
}
function isStyledExtend(node) {
return /^[A-Z]/.test(node.object.name) && node.property.name === "extend";
}
/*
* react-relay and graphql-tag
* graphql`...`
* graphql.experimental`...`
* gql`...`
* GraphQL comment block
*
* This intentionally excludes Relay Classic tags, as Prettier does not
* support Relay Classic formatting.
*/
function isGraphQL(path) {
const node = path.getValue();
const parent = path.getParentNode();
return (
hasLanguageComment(node, "GraphQL") ||
(parent &&
((parent.type === "TaggedTemplateExpression" &&
((parent.tag.type === "MemberExpression" &&
parent.tag.object.name === "graphql" &&
parent.tag.property.name === "experimental") ||
(parent.tag.type === "Identifier" &&
(parent.tag.name === "gql" || parent.tag.name === "graphql")))) ||
(parent.type === "CallExpression" &&
parent.callee.type === "Identifier" &&
parent.callee.name === "graphql")))
);
}
function hasLanguageComment(node, languageName) {
// This checks for a leading comment that is exactly `/* GraphQL */`
// In order to be in line with other implementations of this comment tag
// we will not trim the comment value and we will expect exactly one space on
// either side of the GraphQL string
// Also see ./clean.js
return hasLeadingComment(
node,
comment => isBlockComment(comment) && comment.value === ` ${languageName} `
);
}
function isPathMatch(path, predicateStack) {
const stack = path.stack.slice();
let name = null;
let node = stack.pop();
for (const predicate of predicateStack) {
if (node === undefined) {
return false;
}
// skip index/array
if (typeof name === "number") {
name = stack.pop();
node = stack.pop();
}
if (!predicate(node, name)) {
return false;
}
name = stack.pop();
node = stack.pop();
}
return true;
}
/**
* - html`...`
* - HTML comment block
*/
function isHtml(path) {
const node = path.getValue();
return (
hasLanguageComment(node, "HTML") ||
isPathMatch(path, [
node => node.type === "TemplateLiteral",
(node, name) =>
node.type === "TaggedTemplateExpression" &&
node.tag.type === "Identifier" &&
node.tag.name === "html" &&
name === "quasi"
])
);
}
// The counter is needed to distinguish nested embeds.
let htmlTemplateLiteralCounter = 0;
function printHtmlTemplateLiteral(
path,
print,
textToDoc,
parser,
escapeClosingScriptTag
) {
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 = path.map(print, "expressions");
if (expressionDocs.length === 0 && text.trim().length === 0) {
return "``";
}
const placeholderRegex = RegExp(composePlaceholder("(\\d+)"), "g");
const contentDoc = mapDoc(
stripTrailingHardline(textToDoc(text, { parser })),
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 (escapeClosingScriptTag) {
component = component.replace(/<\/(script)\b/gi, "<\\/$1");
}
parts.push(component);
}
continue;
}
const placeholderIndex = +component;
parts.push(
concat(["${", group(expressionDocs[placeholderIndex]), "}"])
);
}
return concat(parts);
}
);
return group(
concat(["`", indent(concat([hardline, group(contentDoc)])), softline, "`"])
);
}
module.exports = embed;