[vue] Introduce proper support for Vue Single File Components (SFC) (#3563)

There's a lot of demand for vue sfc (#2097). This introduces partial support for them: all the html is printed as is, except for the script and style tags which are printed using prettier. I believe that this should cover a lot of the use cases while being simple to support and if we want we can extend to more in the future.

I copy pasted the html parser used by vue (it's just a single 400 lines file) so that we don't run the chancesof conflicts. I'm also very conservative: I only print the style and script at the top level and for the lang attributes we support.

I expect this to be landable as is and provide value, review welcome :)
master
Christopher Chedeau 2017-12-25 01:15:33 +01:00 committed by GitHub
parent 3ef89b5fa1
commit c40b061b80
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 545 additions and 47 deletions

View File

@ -10,6 +10,7 @@
· CSS
· SCSS
· Less
· Vue
· GraphQL
· JSON
· Markdown

View File

@ -7,6 +7,6 @@ Prettier attempts to support all JavaScript language features, including non-sta
All of JSX and Flow syntax is supported. In fact, the test suite in `tests/flow` _is_ the entire Flow test suite and they all pass.
Prettier also supports [TypeScript](https://www.typescriptlang.org/), CSS, [Less](http://lesscss.org/), [SCSS](http://sass-lang.com), [JSON](http://json.org/), [GraphQL](http://graphql.org/), and [Markdown](http://commonmark.org).
Prettier also supports [TypeScript](https://www.typescriptlang.org/), CSS, [Less](http://lesscss.org/), [SCSS](http://sass-lang.com), [Vue](https://vuejs.org/), [JSON](http://json.org/), [GraphQL](http://graphql.org/), and [Markdown](http://commonmark.org).
The minimum version of TypeScript supported is 2.1.3 as it introduces the ability to have leading `|` for type definitions which prettier outputs.

View File

@ -14,7 +14,8 @@ const parsers = [
"graphql",
"postcss",
"parse5",
"markdown"
"markdown",
"vue"
];
function pipe(string) {

View File

@ -15,7 +15,8 @@ const parsers = [
"graphql",
"postcss",
"parse5",
"markdown"
"markdown",
"vue"
];
process.env.PATH += path.delimiter + path.join(rootDir, "node_modules", ".bin");

View File

@ -232,7 +232,8 @@ const detailedOptions = normalizeDetailedOptions({
"scss",
"json",
"graphql",
"markdown"
"markdown",
"vue"
],
description: "Which parser to use.",
getter: (value, argv) => (argv["flow-parser"] ? "flow" : value)

View File

@ -20,6 +20,8 @@ function printSubtree(path, print, options) {
return fromBabylonFlowOrTypeScript(path, print, options);
case "markdown":
return fromMarkdown(path, print, options);
case "vue":
return fromVue(path, print, options);
}
}
@ -38,6 +40,51 @@ function parseAndPrint(text, partialNextOptions, parentOptions) {
return require("./printer").printAstToDoc(ast, nextOptions);
}
function fromVue(path, print, options) {
const node = path.getValue();
const parent = path.getParentNode();
if (!parent || parent.tag !== "root") {
return null;
}
let parser;
if (node.tag === "style") {
const langAttr = node.attrs.find(attr => attr.name === "lang");
if (!langAttr) {
parser = "css";
} else if (langAttr.value === "scss") {
parser = "scss";
} else if (langAttr.value === "less") {
parser = "less";
} else {
return null;
}
}
if (node.tag === "script") {
const langAttr = node.attrs.find(attr => attr.name === "lang");
if (!langAttr) {
parser = "babylon";
} else if (langAttr.value === "ts") {
parser = "typescript";
} else {
return null;
}
}
return concat([
options.originalText.slice(node.start, node.contentStart),
hardline,
parseAndPrint(
options.originalText.slice(node.contentStart, node.contentEnd),
parser,
options
),
options.originalText.slice(node.contentEnd, node.end)
]);
}
function fromMarkdown(path, print, options) {
const node = path.getValue();

408
src/parser-vue.js Normal file
View File

@ -0,0 +1,408 @@
"use strict";
/*!
* Extracted from vue codebase
* https://github.com/vuejs/vue/blob/cfd73c2386623341fdbb3ac636c4baf84ea89c2c/src/compiler/parser/html-parser.js
* HTML Parser By John Resig (ejohn.org)
* Modified by Juriy "kangax" Zaytsev
* Original code by Erik Arvidsson, Mozilla Public License
* http://erik.eae.net/simplehtmlparser/simplehtmlparser.js
*/
/**
* Make a map and return a function for checking if a key
* is in that map.
*/
function makeMap(str, expectsLowerCase) {
const map = Object.create(null);
const list = str.split(",");
for (let i = 0; i < list.length; i++) {
map[list[i]] = true;
}
return expectsLowerCase ? val => map[val.toLowerCase()] : val => map[val];
}
/**
* Always return false.
*/
const no = () => false;
// HTML5 tags https://html.spec.whatwg.org/multipage/indices.html#elements-3
// Phrasing Content https://html.spec.whatwg.org/multipage/dom.html#phrasing-content
const isNonPhrasingTag = makeMap(
"address,article,aside,base,blockquote,body,caption,col,colgroup,dd," +
"details,dialog,div,dl,dt,fieldset,figcaption,figure,footer,form," +
"h1,h2,h3,h4,h5,h6,head,header,hgroup,hr,html,legend,li,menuitem,meta," +
"optgroup,option,param,rp,rt,source,style,summary,tbody,td,tfoot,th,thead," +
"title,tr,track"
);
// Regular Expressions for parsing tags and attributes
const attribute = /^\s*([^\s"'<>/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/;
// could use https://www.w3.org/TR/1999/REC-xml-names-19990114/#NT-QName
// but for Vue templates we can enforce a simple charset
const ncname = "[a-zA-Z_][\\w\\-\\.]*";
const qnameCapture = `((?:${ncname}\\:)?${ncname})`;
const startTagOpen = new RegExp(`^<${qnameCapture}`);
const startTagClose = /^\s*(\/?)>/;
const endTag = new RegExp(`^<\\/${qnameCapture}[^>]*>`);
const doctype = /^<!DOCTYPE [^>]+>/i;
const comment = /^<!--/;
const conditionalComment = /^<!\[/;
let IS_REGEX_CAPTURING_BROKEN = false;
"x".replace(/x(.)?/g, (m, g) => {
IS_REGEX_CAPTURING_BROKEN = g === "";
});
// Special Elements (can contain anything)
const isPlainTextElement = makeMap("script,style,textarea", true);
const reCache = {};
const decodingMap = {
"&lt;": "<",
"&gt;": ">",
"&quot;": '"',
"&amp;": "&",
"&#10;": "\n",
"&#9;": "\t"
};
const encodedAttr = /&(?:lt|gt|quot|amp);/g;
const encodedAttrWithNewLines = /&(?:lt|gt|quot|amp|#10|#9);/g;
// #5992
const isIgnoreNewlineTag = makeMap("pre,textarea", true);
const shouldIgnoreFirstNewline = (tag, html) =>
tag && isIgnoreNewlineTag(tag) && html[0] === "\n";
function decodeAttr(value, shouldDecodeNewlines) {
const re = shouldDecodeNewlines ? encodedAttrWithNewLines : encodedAttr;
return value.replace(re, match => decodingMap[match]);
}
function parseHTML(html, options) {
const stack = [];
const expectHTML = options.expectHTML;
const isUnaryTag = options.isUnaryTag || no;
const canBeLeftOpenTag = options.canBeLeftOpenTag || no;
let index = 0;
let last;
let lastTag;
while (html) {
last = html;
// Make sure we're not in a plaintext content element like script/style
if (!lastTag || !isPlainTextElement(lastTag)) {
let textEnd = html.indexOf("<");
if (textEnd === 0) {
// Comment:
if (comment.test(html)) {
const commentEnd = html.indexOf("-->");
if (commentEnd >= 0) {
if (options.shouldKeepComment) {
options.comment(html.substring(4, commentEnd));
}
advance(commentEnd + 3);
continue;
}
}
// http://en.wikipedia.org/wiki/Conditional_comment#Downlevel-revealed_conditional_comment
if (conditionalComment.test(html)) {
const conditionalEnd = html.indexOf("]>");
if (conditionalEnd >= 0) {
advance(conditionalEnd + 2);
continue;
}
}
// Doctype:
const doctypeMatch = html.match(doctype);
if (doctypeMatch) {
advance(doctypeMatch[0].length);
continue;
}
// End tag:
const endTagMatch = html.match(endTag);
if (endTagMatch) {
const curIndex = index;
advance(endTagMatch[0].length);
parseEndTag(endTagMatch[1], curIndex, index);
continue;
}
// Start tag:
const startTagMatch = parseStartTag();
if (startTagMatch) {
handleStartTag(startTagMatch);
if (shouldIgnoreFirstNewline(lastTag, html)) {
advance(1);
}
continue;
}
}
let text;
let rest;
let next;
if (textEnd >= 0) {
rest = html.slice(textEnd);
while (
!endTag.test(rest) &&
!startTagOpen.test(rest) &&
!comment.test(rest) &&
!conditionalComment.test(rest)
) {
// < in plain text, be forgiving and treat it as text
next = rest.indexOf("<", 1);
if (next < 0) {
break;
}
textEnd += next;
rest = html.slice(textEnd);
}
text = html.substring(0, textEnd);
advance(textEnd);
}
if (textEnd < 0) {
text = html;
html = "";
}
if (options.chars && text) {
options.chars(text);
}
} else {
let endTagLength = 0;
const stackedTag = lastTag.toLowerCase();
const reStackedTag =
reCache[stackedTag] ||
(reCache[stackedTag] = new RegExp(
"([\\s\\S]*?)(</" + stackedTag + "[^>]*>)",
"i"
));
const rest = html.replace(reStackedTag, (all, text, endTag) => {
endTagLength = endTag.length;
if (!isPlainTextElement(stackedTag) && stackedTag !== "noscript") {
text = text
.replace(/<!--([\s\S]*?)-->/g, "$1")
.replace(/<!\[CDATA\[([\s\S]*?)]]>/g, "$1");
}
if (shouldIgnoreFirstNewline(stackedTag, text)) {
text = text.slice(1);
}
if (options.chars) {
options.chars(text);
}
return "";
});
index += html.length - rest.length;
html = rest;
parseEndTag(stackedTag, index - endTagLength, index);
}
if (html === last) {
options.chars && options.chars(html);
if (
process.env.NODE_ENV !== "production" &&
!stack.length &&
options.warn
) {
options.warn(`Mal-formatted tag at end of template: "${html}"`);
}
break;
}
}
// Clean up any remaining tags
parseEndTag();
function advance(n) {
index += n;
html = html.substring(n);
}
function parseStartTag() {
const start = html.match(startTagOpen);
if (start) {
const match = {
tagName: start[1],
attrs: [],
start: index
};
advance(start[0].length);
let end;
let attr;
while (
!(end = html.match(startTagClose)) &&
(attr = html.match(attribute))
) {
advance(attr[0].length);
match.attrs.push(attr);
}
if (end) {
match.unarySlash = end[1];
advance(end[0].length);
match.end = index;
return match;
}
}
}
function handleStartTag(match) {
const tagName = match.tagName;
const unarySlash = match.unarySlash;
if (expectHTML) {
if (lastTag === "p" && isNonPhrasingTag(tagName)) {
parseEndTag(lastTag);
}
if (canBeLeftOpenTag(tagName) && lastTag === tagName) {
parseEndTag(tagName);
}
}
const unary = isUnaryTag(tagName) || !!unarySlash;
const l = match.attrs.length;
const attrs = new Array(l);
for (let i = 0; i < l; i++) {
const args = match.attrs[i];
// hackish work around FF bug https://bugzilla.mozilla.org/show_bug.cgi?id=369778
if (IS_REGEX_CAPTURING_BROKEN && args[0].indexOf('""') === -1) {
if (args[3] === "") {
delete args[3];
}
if (args[4] === "") {
delete args[4];
}
if (args[5] === "") {
delete args[5];
}
}
const value = args[3] || args[4] || args[5] || "";
const shouldDecodeNewlines =
tagName === "a" && args[1] === "href"
? options.shouldDecodeNewlinesForHref
: options.shouldDecodeNewlines;
attrs[i] = {
name: args[1],
value: decodeAttr(value, shouldDecodeNewlines)
};
}
if (!unary) {
stack.push({
tag: tagName,
lowerCasedTag: tagName.toLowerCase(),
attrs: attrs
});
lastTag = tagName;
}
if (options.start) {
options.start(tagName, attrs, unary, match.start, match.end);
}
}
function parseEndTag(tagName, start, end) {
let pos;
let lowerCasedTagName;
if (start == null) {
start = index;
}
if (end == null) {
end = index;
}
if (tagName) {
lowerCasedTagName = tagName.toLowerCase();
}
// Find the closest opened tag of the same type
if (tagName) {
for (pos = stack.length - 1; pos >= 0; pos--) {
if (stack[pos].lowerCasedTag === lowerCasedTagName) {
break;
}
}
} else {
// If no tag name is provided, clean shop
pos = 0;
}
if (pos >= 0) {
// Close all the open elements, up the stack
for (let i = stack.length - 1; i >= pos; i--) {
if (
process.env.NODE_ENV !== "production" &&
(i > pos || !tagName) &&
options.warn
) {
options.warn(`tag <${stack[i].tag}> has no matching end tag.`);
}
if (options.end) {
options.end(stack[i].tag, start, end);
}
}
// Remove the open elements from the stack
stack.length = pos;
lastTag = pos && stack[pos - 1].tag;
} else if (lowerCasedTagName === "br") {
if (options.start) {
options.start(tagName, [], true, start, end);
}
} else if (lowerCasedTagName === "p") {
if (options.start) {
options.start(tagName, [], false, start, end);
}
if (options.end) {
options.end(tagName, start, end);
}
}
}
}
function parse(text /*, parsers, opts*/) {
const rootObj = {
tag: "root",
attrs: [],
unary: false,
start: 0,
contentStart: 0,
contentEnd: text.length,
end: text.length,
children: [],
comments: []
};
const objStack = [rootObj];
let obj = rootObj;
parseHTML(text, {
start: function(tag, attrs, unary, start, end) {
const newObj = {
tag,
attrs,
unary,
start,
contentStart: end,
children: []
};
obj.children.push(newObj);
objStack.push(newObj);
obj = newObj;
},
end: function(tag, start, end) {
objStack.pop();
obj.contentEnd = start;
obj.end = end;
obj = objStack[objStack.length - 1];
}
});
return rootObj;
}
module.exports = parse;

View File

@ -33,6 +33,9 @@ const parsers = {
},
get markdown() {
return eval("require")("./parser-markdown");
},
get vue() {
return eval("require")("./parser-vue");
}
};

22
src/printer-vue.js Normal file
View File

@ -0,0 +1,22 @@
"use strict";
const docBuilders = require("./doc-builders");
const concat = docBuilders.concat;
function genericPrint(path, options, print) {
const n = path.getValue();
const res = [];
let index = n.start;
path.each(childPath => {
const child = childPath.getValue();
res.push(options.originalText.slice(index, child.start));
res.push(childPath.call(print));
index = child.end;
}, "children");
res.push(options.originalText.slice(index, n.end));
return concat(res);
}
module.exports = genericPrint;

View File

@ -56,6 +56,8 @@ function getPrintFunction(options) {
return require("./printer-graphql");
case "parse5":
return require("./printer-htmlparser2");
case "vue":
return require("./printer-vue");
case "css":
case "less":
case "scss":

View File

@ -173,6 +173,19 @@ const supportTable = [
linguistLanguageId: 222,
vscodeLanguageIds: ["markdown"]
},
{
name: "Vue",
since: "1.10.0",
parsers: ["vue"],
group: "HTML",
tmScope: "text.html.vue",
aceMode: "html",
codemirrorMode: "htmlmixed",
codemirrorMimeType: "text/html",
extensions: [".vue"],
linguistLanguageId: 146,
vscodeLanguageIds: ["vue"]
},
{
name: "HTML",
since: undefined, // unreleased

View File

@ -9,18 +9,16 @@ exports[`template-bind.vue 1`] = `
</template>
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
<template>
<div v-bind:id="'list-' + id" />
<div v-bind:id="rawId | formatId" />
<div v-bind:id="ok ? 'YES' : 'NO'" />
<button @click="foo(arg, 'string')" />
<div v-bind:id=" 'list-' + id ">
</div>
<div v-bind:id=" rawId | formatId ">
</div>
<div v-bind:id=" ok ? 'YES' : 'NO' ">
</div>
<button @click=" foo ( arg, 'string' ) ">
</button>
</template>
`;
@ -35,24 +33,23 @@ exports[`template-class.vue 1`] = `
</h2>
</template>
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
<template>
<h2
class="title"
:class="{ 'issue-realtime-pre-pulse': preAnimation,
'issue-realtime-trigger-pulse': pulseAnimation}"
v-html="titleHtml"
>
<h2 class="title"
:class="{
'issue-realtime-pre-pulse': preAnimation,
'issue-realtime-trigger-pulse': pulseAnimation
}"
v-html="titleHtml">
</h2>
</template>
`;
exports[`vue-component.vue 1`] = `
<template >
<h1 >{{greeting}} world</h1 >
<h1 >{{greeting}} world</h1 >
<script>kikoo ( ) </script>
</template >
<script>
@ -68,28 +65,26 @@ p { font-size : 2em ; text-align : center ; }
</style >
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
<template >
<template>
<h1>{{greeting}} world</h1>
</template>
<h1 >
{{greeting}} world</h1 >
<script>
kikoo ( ) </script>
</template >
<script>
module.exports = {
data: function() {
return {
greeting: "Hello"
};
}
};
module . exports =
{data : function () {return {
greeting: "Hello"
}}
}
</script>
<style scoped>
p {
font-size: 2em;
text-align: center;
}
</style>
<style scoped >
p { font-size : 2em ; text-align : center ; }
</style >
`;

View File

@ -1 +1 @@
run_spec(__dirname, ["parse5"]);
run_spec(__dirname, ["vue"]);

View File

@ -1,5 +1,6 @@
<template >
<h1 >{{greeting}} world</h1 >
<h1 >{{greeting}} world</h1 >
<script>kikoo ( ) </script>
</template >
<script>

View File

@ -265,7 +265,7 @@ exports[`show detailed usage with --help no-semi (write) 1`] = `Array []`;
exports[`show detailed usage with --help parser (stderr) 1`] = `""`;
exports[`show detailed usage with --help parser (stdout) 1`] = `
"--parser <flow|babylon|typescript|css|less|scss|json|graphql|markdown>
"--parser <flow|babylon|typescript|css|less|scss|json|graphql|markdown|vue>
Which parser to use.
@ -280,6 +280,7 @@ Valid options:
json
graphql
markdown
vue
Default: babylon
"
@ -521,7 +522,7 @@ Format options:
--no-bracket-spacing Do not print spaces between brackets.
--jsx-bracket-same-line Put > on the last line instead of at a new line.
Defaults to false.
--parser <flow|babylon|typescript|css|less|scss|json|graphql|markdown>
--parser <flow|babylon|typescript|css|less|scss|json|graphql|markdown|vue>
Which parser to use.
Defaults to babylon.
--print-width <int> The line length where Prettier will try wrap.
@ -615,7 +616,7 @@ exports[`show warning with --help not-found (typo) (stderr) 1`] = `
`;
exports[`show warning with --help not-found (typo) (stdout) 1`] = `
"--parser <flow|babylon|typescript|css|less|scss|json|graphql|markdown>
"--parser <flow|babylon|typescript|css|less|scss|json|graphql|markdown|vue>
Which parser to use.
@ -630,6 +631,7 @@ Valid options:
json
graphql
markdown
vue
Default: babylon
"
@ -660,7 +662,7 @@ Format options:
--no-bracket-spacing Do not print spaces between brackets.
--jsx-bracket-same-line Put > on the last line instead of at a new line.
Defaults to false.
--parser <flow|babylon|typescript|css|less|scss|json|graphql|markdown>
--parser <flow|babylon|typescript|css|less|scss|json|graphql|markdown|vue>
Which parser to use.
Defaults to babylon.
--print-width <int> The line length where Prettier will try wrap.

View File

@ -5,6 +5,7 @@
- "[JSX](https://facebook.github.io/jsx/)"
- "[Flow](https://flow.org/)"
- "[TypeScript](https://www.typescriptlang.org/)"
- "[Vue](https://vuejs.org/)"
- "[JSON](http://json.org/)"
- name: CSS
image: "/images/languages/css-128px.png"