Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Custom twig tags #35

Merged
merged 10 commits into from
Mar 1, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 42 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ An array containing file paths to plugin directories. This can be used to add yo

The paths are relative paths, seen from the project root. Example:

```
```json
"twigMelodyPlugins": ["src-js/some-melody-plugin", "src-js/some-other-plugin"]
```

Expand All @@ -54,16 +54,17 @@ Because Twig files might have a lot of nesting, it can be useful to define a sep

If set to `true`, objects will always be wrapped/broken, even if they would fit on one line:

```
<section class="{{ {
```html
<section
class="{{ {
base: css.prices
} | classes }}">
</section>
} | classes }}"
></section>
```

If set to `false` (default value), this would be printed as:

```
```html
<section class="{{ { base: css.prices } | classes }}"></section>
```

Expand All @@ -75,6 +76,41 @@ Follow the standards described in [https://twig.symfony.com/doc/2.x/coding_stand

Choose whether to output the block name in `{% endblock %}` tags (e.g., `{% endblock content %}`) or not. The default is not to output it.

### twigMultiTags (default: `[]`)

An array of coherent sequences of non-standard Twig tags that should be treated as belonging together. Example (inspired by [Craft CMS](https://docs.craftcms.com/v2/templating/nav.html)):

```json
twigMultiTags: [
"nav,endnav",
"switch,case,default,endswitch",
"ifchildren,endifchildren",
"cache,endcache"
]
```

Looking at the case of `nav,endnav`, this means that the Twig tags `{% nav %}` and `{% endnav %}` will be treated as a pair, and everything in between will be indented:

```twig
{% nav entry in entries %}
<li>
<a href="{{ entry.url }}">{{ entry.title }}</a>
</li>
{% endnav %}
```

If we did not list the `"nav,endnav"` entry in `twigMultiTags`, this code example would be printed without indentation, because `{% nav %}` and `{% endnav %}` would be treated as unrelated, individual Twig tags:

```twig
{% nav entry in entries %}
<li>
<a href="{{ entry.url }}">{{ entry.title }}</a>
</li>
{% endnav %}
```

Note that the order matters: It has to be `"nav,endnav"`, and it must not be `"endnav,nav"`. In general, the first and the last tag name matter. In the case of `"switch,case,default,endswitch"`, the order of `case` and `default` does not matter. However, `switch` has to come first, and `endswitch` has to come last.

## Features

### `prettier-ignore` and `prettier-ignore-start`
Expand Down
8 changes: 4 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,10 @@
},
"dependencies": {
"babel-types": "^6.26.0",
"melody-extension-core": "^1.6.0",
"melody-parser": "^1.6.0",
"melody-traverse": "^1.6.0",
"melody-types": "^1.6.0",
"melody-extension-core": "^1.7.1",
"melody-parser": "^1.7.1",
"melody-traverse": "^1.7.1",
"melody-types": "^1.7.1",
"prettier": "^1.8.2",
"resolve": "^1.12.0"
},
Expand Down
7 changes: 7 additions & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,13 @@ const options = {
description:
"Provide additional plugins for Melody. Relative file path from the project root."
},
twigMultiTags: {
type: "path",
category: "Global",
array: true,
default: [{ value: [] }],
description: "Make custom Twig tags known to the parser."
},
twigSingleQuote: {
type: "boolean",
category: "Global",
Expand Down
20 changes: 15 additions & 5 deletions src/parser.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ const createConfiguredLexer = (code, ...extensions) => {
return lexer;
};

const configureParser = (parser, ...extensions) => {
const applyParserExtensions = (parser, ...extensions) => {
for (const extension of extensions) {
if (extension.tags) {
for (const tag of extension.tags) {
Expand All @@ -45,7 +45,7 @@ const configureParser = (parser, ...extensions) => {
}
};

const createConfiguredParser = (code, ...extensions) => {
const createConfiguredParser = (code, multiTagConfig, ...extensions) => {
const parser = new Parser(
new TokenStream(createConfiguredLexer(code, ...extensions), {
ignoreWhitespace: true,
Expand All @@ -57,20 +57,30 @@ const createConfiguredParser = (code, ...extensions) => {
ignoreComments: false,
ignoreHtmlComments: false,
ignoreDeclarations: false,
decodeEntities: false
decodeEntities: false,
multiTags: multiTagConfig,
allowUnknownTags: true
}
);
configureParser(parser, ...extensions);
applyParserExtensions(parser, ...extensions);
return parser;
};

const getMultiTagConfig = (tagsCsvs = []) =>
tagsCsvs.reduce((acc, curr) => {
const tagNames = curr.split(",");
acc[tagNames[0].trim()] = tagNames.slice(1).map(s => s.trim());
return acc;
}, {});

const parse = (text, parsers, options) => {
const pluginPaths = getPluginPathsFromOptions(options);
const multiTagConfig = getMultiTagConfig(options.twigMultiTags || []);
const extensions = [
coreExtension,
...getAdditionalMelodyExtensions(pluginPaths)
];
const parser = createConfiguredParser(text, ...extensions);
const parser = createConfiguredParser(text, multiTagConfig, ...extensions);
const ast = parser.parse();
ast[ORIGINAL_SOURCE] = text;
return ast;
Expand Down
39 changes: 20 additions & 19 deletions src/print/BinaryExpression.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,26 @@ const hasLogicalOperator = node => {
return node.operator === "or" || node.operator === "and";
};

const otherNeedsParentheses = (node, otherProp) => {
const other = node[otherProp];
const isBinaryOther = Node.isBinaryExpression(other);
const ownPrecedence = operatorPrecedence[node.operator];
const otherPrecedence = isBinaryOther
? operatorPrecedence[node[otherProp].operator]
: Number.MAX_SAFE_INTEGER;
return (
otherPrecedence < ownPrecedence ||
(otherPrecedence > ownPrecedence &&
isBinaryOther &&
hasLogicalOperator(other)) ||
Node.isFilterExpression(other)
);
};

const printBinaryExpression = (node, path, print) => {
node[EXPRESSION_NEEDED] = false;
node[STRING_NEEDS_QUOTES] = true;

const isBinaryLeft = Node.isBinaryExpression(node.left);
const isBinaryRight = Node.isBinaryExpression(node.right);
const isLogicalOperator = ["and", "or"].indexOf(node.operator) > -1;
const whitespaceAroundOperator = operatorNeedsSpaces(node.operator);
Expand Down Expand Up @@ -79,29 +94,15 @@ const printBinaryExpression = (node, path, print) => {
? firstValueInAncestorChain(path, "operator")
: "";

const ownPrecedence = operatorPrecedence[node.operator];
node[OPERATOR_PRECEDENCE] = ownPrecedence;
node[OPERATOR_PRECEDENCE] = operatorPrecedence[node.operator];

const leftPrecedence = isBinaryLeft
? operatorPrecedence[node.left.operator]
: Number.MAX_SAFE_INTEGER;
const rightPrecedence = isBinaryRight
? operatorPrecedence[node.right.operator]
: Number.MAX_SAFE_INTEGER;
const printedLeft = path.call(print, "left");
const printedRight = path.call(print, "right");

const parts = [];
const leftNeedsParens =
(leftPrecedence != ownPrecedence &&
Node.isBinaryExpression(node.left) &&
hasLogicalOperator(node.left)) ||
Node.isFilterExpression(node.left);
const rightNeedsParens =
(rightPrecedence != ownPrecedence &&
Node.isBinaryExpression(node.right) &&
hasLogicalOperator(node.right)) ||
Node.isFilterExpression(node.right);
const leftNeedsParens = otherNeedsParentheses(node, "left");
const rightNeedsParens = otherNeedsParentheses(node, "right");

if (leftNeedsParens) {
parts.push("(");
}
Expand Down
8 changes: 5 additions & 3 deletions src/print/ForStatement.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
const prettier = require("prettier");
const { group, indent, line, hardline, concat } = prettier.doc.builders;
const { EXPRESSION_NEEDED, isWhitespaceNode } = require("../util");
const {
EXPRESSION_NEEDED,
isWhitespaceNode,
indentWithHardline
} = require("../util");

const printFor = (node, path, print) => {
const parts = [node.trimLeft ? "{%-" : "{%", " for "];
Expand All @@ -21,8 +25,6 @@ const printFor = (node, path, print) => {
return group(concat(parts));
};

const indentWithHardline = contents => indent(concat([hardline, contents]));

const p = (node, path, print) => {
node[EXPRESSION_NEEDED] = false;
const parts = [printFor(node, path, print)];
Expand Down
7 changes: 7 additions & 0 deletions src/print/GenericToken.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
const p = (node, path, print) => {
return node.tokenText;
};

module.exports = {
printGenericToken: p
};
31 changes: 31 additions & 0 deletions src/print/GenericTwigTag.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
const prettier = require("prettier");
const { concat, hardline } = prettier.doc.builders;
const { Node } = require("melody-types");
const {
STRING_NEEDS_QUOTES,
indentWithHardline,
printSingleTwigTag,
isEmptySequence
} = require("../util");

const p = (node, path, print) => {
node[STRING_NEEDS_QUOTES] = true;
const openingTag = printSingleTwigTag(node, path, print);
const parts = [openingTag];
const printedSections = path.map(print, "sections");
node.sections.forEach((section, i) => {
if (Node.isGenericTwigTag(section)) {
parts.push(concat([hardline, printedSections[i]]));
} else {
if (!isEmptySequence(section)) {
// Indent
parts.push(indentWithHardline(printedSections[i]));
}
}
});
return concat(parts);
};

module.exports = {
printGenericTwigTag: p
};
12 changes: 12 additions & 0 deletions src/printer.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ const { printFromStatement } = require("./print/FromStatement.js");
const { printTwigComment } = require("./print/TwigComment.js");
const { printHtmlComment } = require("./print/HtmlComment.js");
const { printDeclaration } = require("./print/Declaration.js");
const { printGenericTwigTag } = require("./print/GenericTwigTag.js");
const { printGenericToken } = require("./print/GenericToken.js");
const {
printMacroDeclarationStatement
} = require("./print/MacroDeclarationStatement.js");
Expand Down Expand Up @@ -264,6 +266,16 @@ printFunctions["MacroDeclarationStatement"] = printMacroDeclarationStatement;
printFunctions["TwigComment"] = printTwigComment;
printFunctions["HtmlComment"] = printHtmlComment;
printFunctions["Declaration"] = printDeclaration;
printFunctions["GenericTwigTag"] = (node, path, print, options) => {
const tagName = node.tagName;
if (printFunctions[tagName + "Tag"]) {
// Give the user the chance to implement a custom
// print function for certain generic Twig tags
return printFunctions[tagName + "Tag"](node, path, print, options);
}
return printGenericTwigTag(node, path, print, options);
};
printFunctions["GenericToken"] = printGenericToken;

// Fallbacks
printFunctions["String"] = s => s;
Expand Down
4 changes: 3 additions & 1 deletion src/util/index.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
const pluginUtil = require("./pluginUtil.js");
const publicSymbols = require("./publicSymbols.js");
const publicFunctions = require("./publicFunctions.js");
const printFunctions = require("./printFunctions.js");

const combinedExports = Object.assign(
{},
pluginUtil,
publicSymbols,
publicFunctions
publicFunctions,
printFunctions
);

module.exports = combinedExports;
7 changes: 6 additions & 1 deletion src/util/pluginUtil.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,15 @@ const getProjectRoot = () => {
const parts = __dirname.split(path.sep);
let index = parts.length - 1;
let dirName = parts[index];
while (dirName !== "node_modules") {
while (dirName !== "node_modules" && index > 0) {
index--;
dirName = parts[index];
}
// If we are not inside a "node_modules" folder, just
// strip away "src" and "util"
if (index === 0) {
index = parts.length - 2;
}
const subPath = parts.slice(0, index);
const joined = path.join(...subPath);

Expand Down
34 changes: 34 additions & 0 deletions src/util/printFunctions.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
const prettier = require("prettier");
const { line, indent, concat, group } = prettier.doc.builders;
const { Node } = require("melody-types");

const noSpaceBeforeToken = {
",": true
};

const printSingleTwigTag = (node, path, print) => {
const opener = node.trimLeft ? "{%-" : "{%";
const parts = [opener, " ", node.tagName];
const printedParts = path.map(print, "parts");
if (printedParts.length > 0) {
parts.push(" ", printedParts[0]);
}
const indentedParts = [];
for (let i = 1; i < node.parts.length; i++) {
const part = node.parts[i];
const isToken = Node.isGenericToken(part);
const separator =
isToken && noSpaceBeforeToken[part.tokenText] ? "" : line;
indentedParts.push(separator, printedParts[i]);
}
if (node.parts.length > 1) {
parts.push(indent(concat(indentedParts)));
}
const closing = node.trimRight ? "-%}" : "%}";
parts.push(line, closing);
return group(concat(parts));
};

module.exports = {
printSingleTwigTag
};
Loading